perf: 会话切换去卡顿——历史渐进渲染 + 消息树 memo + 主进程减负

This commit is contained in:
Rick Ye
2026-09-09 02:05:44 +08:00
parent 24df36a9b4
commit 3f9d79ca30
6 changed files with 308 additions and 118 deletions
+29 -32
View File
@@ -308,40 +308,17 @@ export class AgentService {
private emitSwitched(session: AgentSession): void {
const state = this.buildState(session)
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
this.emit({
type: 'session_switched',
state,
messages: session.messages.map((m) => toCloneable(m)),
activeTools: this.collectToolStates(session)
messages: session.messages.map((m) => toCloneable(m))
})
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
void this.emitStats()
}
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
private collectToolStates(session: AgentSession): Record<string, unknown> {
const tools: Record<string, unknown> = {}
for (const message of session.messages) {
const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
if (block.type === 'toolCall' && block.id) {
tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' }
}
}
}
for (const message of session.messages) {
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown }
if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue
tools[m.toolCallId] = {
...((tools[m.toolCallId] as Record<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
result: toCloneable(m),
status: m.isError ? 'error' : 'done'
}
}
return tools
}
async ensureReady(): Promise<SessionState> {
try {
const runtime = await this.getRuntime()
@@ -498,12 +475,12 @@ export class AgentService {
switch (m.role) {
case 'user':
case 'assistant':
byKey.get('messages')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
byKey.get('messages')!.tokens += estimateMessageTokens(message)
break
case 'toolResult': {
const key =
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
byKey.get(key)!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
byKey.get(key)!.tokens += estimateMessageTokens(message)
break
}
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
@@ -511,7 +488,7 @@ export class AgentService {
case 'custom':
case 'compactionSummary':
case 'branchSummary':
byKey.get('other')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
byKey.get('other')!.tokens += estimateMessageTokens(message)
break
default:
break
@@ -1154,15 +1131,35 @@ function samePath(a: string, b: string): boolean {
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
}
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
function toCloneable(value: unknown): unknown {
if (value === null || typeof value !== 'object') return value ?? null
try {
return JSON.parse(JSON.stringify(value ?? null))
return structuredClone(value)
} catch {
return null
try {
return JSON.parse(JSON.stringify(value))
} catch {
return null
}
}
}
/** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。
* emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */
const messageTokenCache = new WeakMap<object, number>()
function estimateMessageTokens(message: object): number {
let tokens = messageTokenCache.get(message)
if (tokens === undefined) {
tokens = estimateTokens(message as Parameters<typeof estimateTokens>[0])
messageTokenCache.set(message, tokens)
}
return tokens
}
function readRole(message: unknown): string {
if (message && typeof message === 'object' && 'role' in message) {
return String((message as { role: unknown }).role)