perf(frontend): optimize ACP tool call output handling to reduce CPU and memory pressure

Replace raw_output single-string accumulation with a chunks array to
eliminate O(n^2) string concatenation on every 200ms terminal poll event.
Batch tool_call_update dispatches via requestAnimationFrame so multiple
agents no longer trigger 25+ React re-renders per second.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-13 22:05:26 +08:00
parent 8fefad14d4
commit 96acd6039d
2 changed files with 150 additions and 27 deletions

View File

@@ -62,7 +62,8 @@ export interface ToolCallInfo {
status: string status: string
content: string | null content: string | null
raw_input: string | null raw_input: string | null
raw_output: string | null raw_output_chunks: string[]
raw_output_total_bytes: number
} }
export interface PendingPermission { export interface PendingPermission {
@@ -152,6 +153,21 @@ type Action =
raw_output: string | null raw_output: string | null
raw_output_append?: boolean raw_output_append?: boolean
} }
| {
type: "BATCH_TOOL_CALL_UPDATES"
actions: Array<{
contextKey: string
tool_call_id: string
title: string | null
fallback_title: string
fallback_kind: string
status: string | null
content: string | null
raw_input: string | null
raw_output: string | null
raw_output_append?: boolean
}>
}
| { | {
type: "PERMISSION_REQUEST" type: "PERMISSION_REQUEST"
contextKey: string contextKey: string
@@ -241,12 +257,6 @@ export function getCachedSelectors(agentType: string) {
return selectorsCache.get(agentType) ?? null return selectorsCache.get(agentType) ?? null
} }
function clampLiveRawOutput(output: string | null): string | null {
if (typeof output !== "string") return output
if (output.length <= MAX_LIVE_TOOL_RAW_OUTPUT_CHARS) return output
return output.slice(-MAX_LIVE_TOOL_RAW_OUTPUT_CHARS)
}
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return null return null
@@ -649,9 +659,14 @@ function connectionsReducer(
status: action.status ?? block.info.status, status: action.status ?? block.info.status,
content: action.content ?? block.info.content, content: action.content ?? block.info.content,
raw_input: action.raw_input ?? block.info.raw_input, raw_input: action.raw_input ?? block.info.raw_input,
raw_output: clampLiveRawOutput( raw_output_chunks:
action.raw_output ?? block.info.raw_output action.raw_output !== null
), ? [action.raw_output]
: block.info.raw_output_chunks,
raw_output_total_bytes:
action.raw_output !== null
? action.raw_output.length
: block.info.raw_output_total_bytes,
}, },
}, },
...prev.content.slice(existingIndex + 1), ...prev.content.slice(existingIndex + 1),
@@ -671,7 +686,9 @@ function connectionsReducer(
status: action.status, status: action.status,
content: action.content, content: action.content,
raw_input: action.raw_input, raw_input: action.raw_input,
raw_output: clampLiveRawOutput(action.raw_output), raw_output_chunks:
action.raw_output !== null ? [action.raw_output] : [],
raw_output_total_bytes: action.raw_output?.length ?? 0,
}, },
}, },
] ]
@@ -695,7 +712,9 @@ function connectionsReducer(
let newContent: LiveContentBlock[] let newContent: LiveContentBlock[]
if (existingIndex === -1) { if (existingIndex === -1) {
const normalizedRawOutput = clampLiveRawOutput(action.raw_output) const initialChunks =
action.raw_output !== null ? [action.raw_output] : []
const initialBytes = action.raw_output?.length ?? 0
newContent = [ newContent = [
...prev.content, ...prev.content,
{ {
@@ -706,23 +725,54 @@ function connectionsReducer(
kind: action.fallback_kind, kind: action.fallback_kind,
status: status:
action.status ?? action.status ??
(normalizedRawOutput ? "in_progress" : "pending"), (initialChunks.length > 0 ? "in_progress" : "pending"),
content: action.content, content: action.content,
raw_input: action.raw_input, raw_input: action.raw_input,
raw_output: normalizedRawOutput, raw_output_chunks: initialChunks,
raw_output_total_bytes: initialBytes,
}, },
}, },
] ]
} else { } else {
const block = prev.content[existingIndex] const block = prev.content[existingIndex]
if (block.type !== "tool_call") return state if (block.type !== "tool_call") return state
const mergedRawOutput =
action.raw_output === null let newChunks: string[]
? block.info.raw_output let newTotalBytes: number
: action.raw_output_append
? (block.info.raw_output ?? "") + action.raw_output if (action.raw_output === null) {
: action.raw_output newChunks = block.info.raw_output_chunks
const normalizedRawOutput = clampLiveRawOutput(mergedRawOutput) newTotalBytes = block.info.raw_output_total_bytes
} else if (action.raw_output_append) {
newChunks = [...block.info.raw_output_chunks, action.raw_output]
newTotalBytes =
block.info.raw_output_total_bytes + action.raw_output.length
// 超限时从头部批量移除 chunks单次 slice 替代循环 shift
if (
newTotalBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS &&
newChunks.length > 1
) {
let evictCount = 0
let evictedBytes = 0
while (
evictCount < newChunks.length - 1 &&
newTotalBytes - evictedBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS
) {
evictedBytes += newChunks[evictCount].length
evictCount++
}
if (evictCount > 0) {
newChunks = newChunks.slice(evictCount)
newTotalBytes -= evictedBytes
}
}
} else {
// 非 append 模式(替换)
newChunks = [action.raw_output]
newTotalBytes = action.raw_output.length
}
newContent = [ newContent = [
...prev.content.slice(0, existingIndex), ...prev.content.slice(0, existingIndex),
{ {
@@ -733,7 +783,8 @@ function connectionsReducer(
status: action.status ?? block.info.status, status: action.status ?? block.info.status,
content: action.content ?? block.info.content, content: action.content ?? block.info.content,
raw_input: action.raw_input ?? block.info.raw_input, raw_input: action.raw_input ?? block.info.raw_input,
raw_output: normalizedRawOutput, raw_output_chunks: newChunks,
raw_output_total_bytes: newTotalBytes,
}, },
}, },
...prev.content.slice(existingIndex + 1), ...prev.content.slice(existingIndex + 1),
@@ -748,6 +799,17 @@ function connectionsReducer(
return next return next
} }
case "BATCH_TOOL_CALL_UPDATES": {
let current = state
for (const sub of action.actions) {
current = connectionsReducer(current, {
type: "TOOL_CALL_UPDATE",
...sub,
})
}
return current
}
case "PERMISSION_REQUEST": { case "PERMISSION_REQUEST": {
const conn = state.get(action.contextKey) const conn = state.get(action.contextKey)
if (!conn) return state if (!conn) return state
@@ -804,7 +866,8 @@ function connectionsReducer(
status: "pending", status: "pending",
content: null, content: null,
raw_input: permissionToolInput, raw_input: permissionToolInput,
raw_output: null, raw_output_chunks: [],
raw_output_total_bytes: 0,
}, },
}, },
], ],
@@ -1306,6 +1369,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
for (const key of keys) { for (const key of keys) {
notifyKeyListeners(key) notifyKeyListeners(key)
} }
} else if (action.type === "BATCH_TOOL_CALL_UPDATES") {
const keys = new Set(action.actions.map((item) => item.contextKey))
for (const key of keys) {
notifyKeyListeners(key)
}
} else { } else {
const key = getAffectedKey(action) const key = getAffectedKey(action)
if (key) notifyKeyListeners(key) if (key) notifyKeyListeners(key)
@@ -1453,6 +1521,50 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
[] []
) )
// ── RAF batching for tool_call_update events ──
const pendingToolCallUpdates = useRef<
Array<{
contextKey: string
tool_call_id: string
title: string | null
fallback_title: string
fallback_kind: string
status: string | null
content: string | null
raw_input: string | null
raw_output: string | null
raw_output_append?: boolean
}>
>([])
const toolCallUpdateRafId = useRef<number | null>(null)
const flushPendingToolCallUpdates = useCallback(() => {
if (pendingToolCallUpdates.current.length === 0) return
if (toolCallUpdateRafId.current !== null) {
cancelAnimationFrame(toolCallUpdateRafId.current)
toolCallUpdateRafId.current = null
}
const batch = pendingToolCallUpdates.current
pendingToolCallUpdates.current = []
dispatch({ type: "BATCH_TOOL_CALL_UPDATES", actions: batch })
}, [dispatch])
const scheduleToolCallUpdateFlush = useCallback(() => {
if (toolCallUpdateRafId.current !== null) return
toolCallUpdateRafId.current = requestAnimationFrame(() => {
toolCallUpdateRafId.current = null
flushPendingToolCallUpdates()
})
}, [flushPendingToolCallUpdates])
useEffect(() => {
return () => {
if (toolCallUpdateRafId.current !== null) {
cancelAnimationFrame(toolCallUpdateRafId.current)
}
}
}, [])
const handleMappedEvent = useCallback( const handleMappedEvent = useCallback(
(contextKey: string, e: AcpEvent) => { (contextKey: string, e: AcpEvent) => {
switch (e.type) { switch (e.type) {
@@ -1486,8 +1598,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
break break
case "tool_call_update": case "tool_call_update":
flushStreamingQueue() flushStreamingQueue()
dispatch({ pendingToolCallUpdates.current.push({
type: "TOOL_CALL_UPDATE",
contextKey, contextKey,
tool_call_id: e.tool_call_id, tool_call_id: e.tool_call_id,
title: e.title, title: e.title,
@@ -1499,6 +1610,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
raw_output: e.raw_output, raw_output: e.raw_output,
raw_output_append: e.raw_output_append, raw_output_append: e.raw_output_append,
}) })
scheduleToolCallUpdateFlush()
break break
case "permission_request": case "permission_request":
flushStreamingQueue() flushStreamingQueue()
@@ -1657,6 +1769,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
break break
case "turn_complete": { case "turn_complete": {
flushStreamingQueue() flushStreamingQueue()
flushPendingToolCallUpdates()
dispatch({ dispatch({
type: "STATUS_CHANGED", type: "STATUS_CHANGED",
contextKey, contextKey,
@@ -1779,7 +1892,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
break break
} }
}, },
[dispatch, enqueueStreamingAction, flushStreamingQueue, t] [
dispatch,
enqueueStreamingAction,
flushPendingToolCallUpdates,
flushStreamingQueue,
scheduleToolCallUpdateFlush,
t,
]
) )
// Single global event listener // Single global event listener

View File

@@ -221,7 +221,10 @@ function buildStreamingTurnsFromLiveMessage(
currentBlocks.push({ currentBlocks.push({
type: "tool_result", type: "tool_result",
tool_use_id: block.info.tool_call_id, tool_use_id: block.info.tool_call_id,
output_preview: block.info.raw_output ?? block.info.content, output_preview:
block.info.raw_output_chunks.length > 0
? block.info.raw_output_chunks.join("")
: block.info.content,
is_error: block.info.status === "failed", is_error: block.info.status === "failed",
}) })
currentGroupHasCompletedTool = true currentGroupHasCompletedTool = true