/** * useAgentStream — Agent 流式事件监听 Hook * * 监听 window.metona.agent.onStreamEvent / onStateChange,将事件分发到 Zustand Store。 * * 架构设计: * - onStateChange:迭代号追踪 + 消息卡片创建 + Trace 步骤管理 + Agent 状态映射 * 每轮迭代只创建一个 TraceStep,状态转换时更新当前步骤的 state 字段(避免碎片化) * - onStreamEvent:纯内容更新(reasoning、text、tool_call、tool_result、usage、done、error) * tool_result 反向搜索包含对应 toolCallId 的 TraceStep(修复工具结果丢失) */ import { useCallback, useEffect, useRef } from 'react'; import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus, } from '@renderer/stores/agent-store'; // v0.6.4 P3-5: 文案集中字典(含注册副作用,须在 t() 使用前 import) import { t } from '@renderer/lib/i18n'; import '@renderer/lib/i18n-strings'; // v0.7.2 P4-13: 事件处理判定抽取为纯函数(表测锁定关键回归路径) import { decideStreamEvent, decideStateEvent, needsNewAssistantCard, findTraceStepIndexForToolCall, } from '@renderer/lib/stream-event-pipeline'; // v0.7.2 A4: traceSteps 入 store 时即剥离工具结果中的 base64/超长字段 import { toDisplayResult } from '@renderer/lib/tool-result-display'; /** * v0.7.2 P3-11: 后台会话运行状态映射(stateChange → sessionRunStates 指示图)。 * 与下方 stateToStatus 的差异:INIT 不映射(无实际内容); * TERMINATED 不在此映射(调用方直接移除条目)。 */ const BACKGROUND_RUN_STATUS: Record = { THINKING: 'thinking', PARSING: 'thinking', OBSERVING: 'thinking', REFLECTING: 'thinking', COMPRESSING: 'thinking', EXECUTING: 'executing', }; /** * Agent 流式事件监听 Hook * * 在 App 根组件调用一次,自动监听当前会话的流式事件。 */ export function useAgentStream(): void { const cleanupRef = useRef<(() => void) | null>(null); // ===== F5/F9 缓冲区(v0.6.4 提升到 hook 顶层)===== // 原实现 text/reasoning 缓冲与 flush 闭包被锁在"流式事件 effect"内部, // "状态机 effect"(onStateChange)新建迭代 Trace step 时无法先 flush, // 导致上一迭代末尾的 reasoning 尾巴晚于新 step 首个 stateChange 到达时, // 被追加进错误的(新)step —— 跨迭代 thought 污染。提升为共享引用后, // 两处创建点(新卡片 / 新 trace step)都能先 flush 再创建。 const textDeltaBufferRef = useRef(''); const textBufferSessionIdRef = useRef(undefined); const textRafIdRef = useRef(null); const traceThoughtBufferRef = useRef(''); const traceRafIdRef = useRef(null); const flushTextDelta = useCallback((): void => { textRafIdRef.current = null; if (!textDeltaBufferRef.current) return; const delta = textDeltaBufferRef.current; const bufferedSessionId = textBufferSessionIdRef.current; textDeltaBufferRef.current = ''; textBufferSessionIdRef.current = undefined; const store = useAgentStore.getState(); // 会话切换保护:如果缓冲时的会话与当前会话不一致,丢弃(避免跨会话污染) if (bufferedSessionId && store.currentSessionId !== bufferedSessionId) return; store.updateLastAssistantMessage(delta); // 无 reasoning 模式下,将文本内容也记入 Trace thought const messages = store.messages; const lastMsg = messages[messages.length - 1]; const steps = store.traceSteps; const step = steps[steps.length - 1]; if (step && lastMsg?.role === 'assistant' && !lastMsg.reasoningContent) { store.updateLastTraceStep({ thought: (step.thought ?? '') + delta, }); } }, []); const flushTraceThought = useCallback((): void => { traceRafIdRef.current = null; if (!traceThoughtBufferRef.current) return; const delta = traceThoughtBufferRef.current; traceThoughtBufferRef.current = ''; const store = useAgentStore.getState(); const steps = store.traceSteps; const step = steps[steps.length - 1]; if (step) { store.updateLastTraceStep({ thought: (step.thought ?? '') + delta, }); } }, []); /** v0.6.4: 新卡片/新 Trace step 创建前的统一 flush 入口 */ const flushPendingBuffersBeforeNewIteration = useCallback((): void => { flushTextDelta(); flushTraceThought(); }, [flushTextDelta, flushTraceThought]); // ===== 流式内容事件 ===== useEffect(() => { if (!window.metona?.agent?.onStreamEvent) return; // F5: text_delta rAF 批处理 // 问题:每个 text_delta 直接调用 updateLastAssistantMessage + updateLastTraceStep, // 频率 30-50 次/秒,每次触发 store 更新 + React re-render。 // 方案:累积 delta 到缓冲区,用 rAF 每帧 commit 一次,合并多次 store 写入。 // done/error 时立即 flush,避免最后一段 delta 丢失。 // 新迭代卡片创建逻辑(needsNewCard)立即处理,不缓冲。 const scheduleTextFlush = (): void => { if (textRafIdRef.current != null) return; textRafIdRef.current = requestAnimationFrame(flushTextDelta); }; const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => { const data = event as { type?: string; requestId?: string; sessionId?: string; iteration?: number; /** 事件序列号(与 MetonaStreamEvent.seq 对齐,规范必填字段) */ seq?: number; /** 事件时间戳(与 MetonaStreamEvent.timestamp 对齐,规范必填字段) */ timestamp?: number; /** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */ runId?: string; delta?: string; content?: string; /** 工具调用增量(流式参数拼接) */ toolCallDelta?: { index: number; name?: string; argsDelta?: string }; toolCall?: { id: string; name: string; args: Record }; toolResult?: { toolCallId: string; success: boolean; result?: unknown; error?: string; durationMs?: number; }; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }; /** v0.3.18 修复: 上下文压缩事件数据 */ savedTokens?: number; originalTokens?: number; compressedTokens?: number; /** v0.4.1: 输出验证结果(OutputValidator 检出的疑似问题,不阻断输出) */ validation?: { score: number; issues: Array<{ severity: string; type: string; message: string }>; }; /** DONE 事件的终止原因(completed / max_iterations / timeout / user_interrupt / dead_loop / error) */ terminationReason?: string; error?: { code: string; message: string }; state?: string; }; // ===== 事件准入判定(v0.7.2 P4-13: 纯函数抽取)===== // 覆盖:跨会话拒绝 / runId 守卫(含旧 run 终止事件吞掉)/ abort 尾巴过滤 const storeBefore = useAgentStore.getState(); const decision = decideStreamEvent(data, { currentSessionId: storeBefore.currentSessionId, currentRunId: storeBefore.currentRunId, isStreaming: storeBefore.isStreaming, }); if (decision.action === 'reject') return; if (decision.adoptRunId) storeBefore.setCurrentRunId(decision.adoptRunId); // 每次都从 store 读取最新状态(避免闭包捕获过期快照) const getStore = () => useAgentStore.getState(); switch (data.type) { // 推理内容增量 case 'reasoning_delta': if (data.delta) { // C-1: 如果 delta 属于新迭代,先创建新卡片(不依赖 stateChange 到达顺序) if (data.iteration != null) { const msgs = getStore().messages; const last = msgs[msgs.length - 1]; // v0.7.2 P4-13: 卡片边界判定抽取为纯函数 if (needsNewAssistantCard(last, data.iteration)) { getStore().addMessage({ id: genMsgId('assistant'), role: 'assistant', content: '', reasoningContent: data.delta, timestamp: Date.now(), iteration: data.iteration, }); if (data.iteration !== getStore().currentIteration) { getStore().setCurrentIteration(data.iteration); } // 同步更新当前 Trace 步骤的 thought 字段 const ts = getStore().traceSteps; const step = ts[ts.length - 1]; if (step) { getStore().updateLastTraceStep({ thought: (step.thought ?? '') + data.delta }); } break; } } const messages = getStore().messages; const lastMsg = messages[messages.length - 1]; if (lastMsg?.role === 'assistant') { // 追加到最后一条 assistant 消息(不可变更新) getStore().updateMessage(lastMsg.id, { reasoningContent: (lastMsg.reasoningContent ?? '') + data.delta, }); } else { // 还没有 assistant 消息,先创建一条(仅含思考内容) getStore().addMessage({ id: genMsgId('assistant'), role: 'assistant', content: '', reasoningContent: data.delta, timestamp: Date.now(), iteration: getStore().currentIteration || undefined, }); } // F9: traceSteps thought 更新走 rAF 批处理(减少 TraceViewer re-render 频率) // message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示) traceThoughtBufferRef.current += data.delta; if (traceRafIdRef.current === null) { traceRafIdRef.current = requestAnimationFrame(flushTraceThought); } } break; // 文本增量 case 'text_delta': if (data.delta) { // C-1: 如果 delta 属于新迭代,先创建新卡片(不依赖 stateChange 到达顺序) if (data.iteration != null) { const msgs = getStore().messages; const last = msgs[msgs.length - 1]; // v0.7.2 P4-13: 卡片边界判定抽取为纯函数 if (needsNewAssistantCard(last, data.iteration)) { // F5 + v0.6.4: 新迭代前先 flush 全部旧缓冲区 // (text delta 属于上一条消息;reasoning 尾巴属于上一迭代的 step) if (textRafIdRef.current !== null) { cancelAnimationFrame(textRafIdRef.current); } if (traceRafIdRef.current !== null) { cancelAnimationFrame(traceRafIdRef.current); } flushPendingBuffersBeforeNewIteration(); getStore().addMessage({ id: genMsgId('assistant'), role: 'assistant', content: data.delta, timestamp: Date.now(), iteration: data.iteration, }); if (data.iteration !== getStore().currentIteration) { getStore().setCurrentIteration(data.iteration); } break; } } // F5: 累积 delta 到缓冲区,用 rAF 每帧 commit 一次 // 首次缓冲时记录 sessionId(用于会话切换保护) if (textDeltaBufferRef.current === '') { textBufferSessionIdRef.current = data.sessionId; } textDeltaBufferRef.current += data.delta; scheduleTextFlush(); } break; // M-1: 工具调用增量(流式参数拼接)— 仅更新 UI 占位,完整调用由 tool_call_complete 处理 case 'tool_call_delta': if (data.toolCallDelta) { const msgs = getStore().messages; const lastAssistant = msgs[msgs.length - 1]; const { index, name } = data.toolCallDelta; // 如果最后一条 assistant 消息还没有该 index 的占位工具调用,添加一个 pending 占位 if (lastAssistant?.role === 'assistant' && name) { const existing = (lastAssistant.toolCalls ?? []).find((_, i) => i === index); if (!existing) { const placeholder: ToolCallInfo = { id: `tc_pending_${index}`, name, args: {}, status: 'pending', }; getStore().updateMessage(lastAssistant.id, { toolCalls: [...(lastAssistant.toolCalls ?? []), placeholder], }); } } } break; // 工具调用完成 case 'tool_call_complete': if (data.toolCall) { const tc: ToolCallInfo = { id: data.toolCall.id, name: data.toolCall.name, args: data.toolCall.args, status: 'executing', }; // 追加到当前 assistant 消息(替换同 index 的 pending 占位) const msgs = getStore().messages; const lastAssistant = msgs[msgs.length - 1]; if (lastAssistant?.role === 'assistant') { const existingTcs = lastAssistant.toolCalls ?? []; // 检查是否有同 index 的 pending 占位需要替换 const pendingIdx = existingTcs.findIndex((t) => t.id.startsWith('tc_pending_')); if (pendingIdx >= 0) { const updated = [...existingTcs]; updated[pendingIdx] = tc; getStore().updateMessage(lastAssistant.id, { toolCalls: updated }); } else { getStore().updateMessage(lastAssistant.id, { toolCalls: [...existingTcs, tc], }); } } // 更新当前 Trace 步骤(添加工具调用信息) const curSteps = getStore().traceSteps; const lastStep = curSteps[curSteps.length - 1]; if (lastStep) { getStore().updateLastTraceStep({ toolCalls: [...(lastStep.toolCalls ?? []), tc], }); } } break; // 工具执行结果 case 'tool_result': { if (data.toolResult) { const msgs = getStore().messages; const lastMsg = msgs[msgs.length - 1]; if (lastMsg?.role === 'assistant' && lastMsg?.toolCalls) { const updatedToolCalls = lastMsg.toolCalls.map((tc) => tc.id === data.toolResult!.toolCallId ? { ...tc, status: data.toolResult!.success ? ('success' as const) : ('error' as const), result: data.toolResult!.result, error: data.toolResult!.error, durationMs: data.toolResult!.durationMs, } : tc, ); getStore().updateMessage(lastMsg.id, { toolCalls: updatedToolCalls }); } // 反向搜索包含该 toolCallId 的 TraceStep(修复工具结果丢失) // v0.7.2 P4-13: 反向归属查找抽取为纯函数;v0.7.2 A4: 写入 traceSteps 的 // result 先行瘦身(剥离 base64/超长字段)—— messages 保留原始结果 //(ToolResultBlock 渲染时自行裁剪),trace 侧无任何消费方需要原始大字段 const steps = getStore().traceSteps; const traceIdx = findTraceStepIndexForToolCall(steps, data.toolResult.toolCallId); if (traceIdx >= 0) { const trace = steps[traceIdx]; // findTraceStepIndexForToolCall 命中即保证 toolCalls 非空(?? [] 为类型收窄兜底) const updatedTraceToolCalls = (trace.toolCalls ?? []).map((tc) => tc.id === data.toolResult!.toolCallId ? { ...tc, status: data.toolResult!.success ? ('success' as const) : ('error' as const), result: toDisplayResult(data.toolResult!.result), error: data.toolResult!.error, durationMs: data.toolResult!.durationMs, } : tc, ); // L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞) getStore().updateTraceStepById(trace.id, { toolCalls: updatedTraceToolCalls }); } } break; } // Token 使用统计 case 'usage': if (data.usage) { const cur = getStore().tokenUsage; getStore().updateTokenUsage({ // 累计值:所有轮次累加,只增不减 inputTokens: cur.inputTokens + (data.usage.inputTokens ?? 0), outputTokens: cur.outputTokens + (data.usage.outputTokens ?? 0), totalTokens: cur.totalTokens + (data.usage.totalTokens ?? 0), // v0.3.18 修复: 单次上下文占用 — 替换不累加 // 反映当前对话上下文的真实大小,压缩后会大幅下降 lastInputTokens: data.usage.inputTokens ?? 0, }); // 同步更新当前 Trace 步骤的 token 用量 getStore().updateLastTraceStep({ tokenUsage: { promptTokens: data.usage.inputTokens ?? 0, completionTokens: data.usage.outputTokens ?? 0, totalTokens: data.usage.totalTokens ?? 0, }, }); } break; // v0.3.18 修复: 上下文压缩事件 — 记录节省的 token,供 UI 显示压缩效果 case 'compressed': { getStore().applyCompression(data.savedTokens ?? 0); break; } // v0.4.1: 输出验证结果 — OutputValidator 检出的疑似问题以轻量 system 消息展示(不阻断) case 'validation': { const issues = data.validation?.issues ?? []; if (issues.length > 0) { const lines = issues.map( (i) => `${i.severity === 'error' ? '❌' : '⚠️'} [${i.type}] ${i.message}`, ); getStore().addMessage({ id: genMsgId('system'), role: 'system', content: `🔍 输出验证:发现 ${issues.length} 个疑似问题(含幻觉/事实一致性检测,仅供参考)\n${lines.join('\n')}`, timestamp: Date.now(), }); } break; } // 流结束 case 'done': { // F-可见性修复: 非 completed 的终止原因此前静默结束(MAX_ITERATIONS/ // TIMEOUT 无任何提示 — 用户感知为"会话直接停止")。此处显示 system 消息。 // USER_INTERRUPT 不提示(用户主动触发已有感知);DEAD_LOOP 已有专属 toast。 const reason = data.terminationReason; if ( reason && reason !== 'completed' && reason !== 'user_interrupt' && reason !== 'dead_loop' ) { // v0.6.4 P3-5: 系统消息文案出层 —— 数据层 hook 不再拼硬编码字符串, // 统一走集中字典(稳定 key),为多语言与文案审计建立单一来源。 const reasonLabels: Record = { max_iterations: t('agent.terminated.max_iterations'), timeout: t('agent.terminated.timeout'), // v0.7.2 P4-15: 此前硬编码的中文文案出层 error: t('agent.terminated.error'), }; getStore().addMessage({ id: genMsgId('system'), role: 'system', content: t('agent.system.stopped', { reason: reasonLabels[reason] ?? reason }), timestamp: Date.now(), }); } // F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失 if (textRafIdRef.current !== null) { cancelAnimationFrame(textRafIdRef.current); textRafIdRef.current = null; } flushTextDelta(); // F9: flush traceThought 缓冲区,避免最后一段 reasoning delta 丢失 if (traceRafIdRef.current !== null) { cancelAnimationFrame(traceRafIdRef.current); traceRafIdRef.current = null; } flushTraceThought(); getStore().setStreaming(false); getStore().setCurrentRunId(null); // 不覆盖 error 状态 — error handler 已设置 agentStatus='error' if (getStore().agentStatus !== 'error') { getStore().setAgentStatus('idle'); } // 标记最后一个 Trace 步骤为已完成 getStore().updateLastTraceStep({ completedAt: Date.now() }); getStore().saveTraceData(); break; } // 错误 case 'error': // F5: 错误前立即 flush 缓冲区,保留已接收的内容 if (textRafIdRef.current !== null) { cancelAnimationFrame(textRafIdRef.current); textRafIdRef.current = null; } flushTextDelta(); // F9: flush traceThought 缓冲区,保留已接收的 reasoning 内容 if (traceRafIdRef.current !== null) { cancelAnimationFrame(traceRafIdRef.current); traceRafIdRef.current = null; } flushTraceThought(); getStore().setStreaming(false); getStore().setCurrentRunId(null); getStore().setAgentStatus('error'); getStore().updateLastTraceStep({ completedAt: Date.now() }); // v0.3.17: 对 content_filtered 错误码显示更友好的提示 const errorCode = data.error?.code; const errorMessage = data.error?.message ?? '未知错误'; getStore().addMessage({ id: genMsgId('error'), role: 'system', content: errorCode === 'content_filtered' ? t('agent.error.content_filtered', { message: errorMessage }) : t('agent.error.generic', { message: errorMessage }), timestamp: Date.now(), }); break; } }); cleanupRef.current = unsubscribe; return () => { // F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容) if (textRafIdRef.current !== null) { cancelAnimationFrame(textRafIdRef.current); textRafIdRef.current = null; } flushTextDelta(); // F9: 清理 traceThought 的 rAF if (traceRafIdRef.current !== null) { cancelAnimationFrame(traceRafIdRef.current); traceRafIdRef.current = null; } flushTraceThought(); cleanupRef.current?.(); }; }, [flushTextDelta, flushTraceThought]); // ===== 状态变化事件(迭代追踪 + Trace 步骤 + 消息卡片) ===== useEffect(() => { if (!window.metona?.agent?.onStateChange) return; const unsubscribe = window.metona.agent.onStateChange((state: unknown) => { const data = state as { sessionId?: string; iteration?: number; state?: string; previous?: string; current?: string; runId?: string; }; const store = useAgentStore.getState(); // ===== v0.7.2 P3-11: 后台会话运行指示 ===== // 非当前会话的 stateChange 不进入消息流/Trace(多会话隔离不变), // 但必须维护 sessionRunStates 指示图 —— 用户切走会话后该会话的 Agent // 仍在运行,Sidebar 需要点亮其状态点。TERMINATED 移除条目,运行结束。 if (data.sessionId && data.sessionId !== store.currentSessionId) { const st = data.state ?? data.current ?? ''; if (st === 'TERMINATED') { store.updateSessionRunState(data.sessionId, null); } else { const mapped = BACKGROUND_RUN_STATUS[st]; if (mapped) store.updateSessionRunState(data.sessionId, mapped); } return; } // ===== 事件准入判定(v0.7.2 P4-13: 纯函数抽取)===== // 覆盖:跨会话拒绝 / runId 守卫(currentRunId 未设置时仅 INIT 采纳, // 其余为旧 run 的延迟状态变化 —— abort 后重发场景的污染源) const decision = decideStateEvent(data, { currentSessionId: store.currentSessionId, currentRunId: store.currentRunId, }); if (decision.action === 'reject') return; if (decision.adoptRunId) store.setCurrentRunId(decision.adoptRunId); // --- 迭代号更新 + 新消息卡片创建 --- if (data.iteration != null && data.iteration !== store.currentIteration) { const prevIteration = store.currentIteration; store.setCurrentIteration(data.iteration); // M-7: 统一所有迭代的卡片创建路径(包括首轮) // 迭代号增大 → 新一轮 ReAct 迭代开始,创建新的 assistant 消息卡片 // 如果最后一条已经是当前迭代的 assistant 卡片(delta handler 提前创建),不重复 if (data.iteration > prevIteration) { const messages = store.messages; const lastMsg = messages[messages.length - 1]; const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration; if (!isAlreadyCurrentIteration) { // 首轮不要求上一轮有内容(上一轮是用户消息) const isFirstIteration = prevIteration === 0; const prevHasContent = lastMsg?.role === 'assistant' && (lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent); if (isFirstIteration || prevHasContent) { store.addMessage({ id: genMsgId('assistant'), role: 'assistant', content: '', timestamp: Date.now(), iteration: data.iteration, }); } } } } // --- Trace 步骤管理(每轮迭代一个步骤,状态转换时追加到 states 数组)--- if (data.state && data.iteration != null) { // 跳过 INIT (iteration=0):引擎初始化阶段无实际内容,不创建 trace step if (data.state === 'INIT' && data.iteration === 0) { // 仍需更新 Agent 状态映射 } else { const traceSteps = store.traceSteps; const lastStep = traceSteps[traceSteps.length - 1]; // 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤 // 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组 // runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step) const isSameIteration = lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId; if (isSameIteration) { // 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state const currentStates = lastStep.states ?? [lastStep.state]; if (currentStates[currentStates.length - 1] !== data.state) { store.updateLastTraceStep({ state: data.state, states: [...currentStates, data.state], // MT-2 修复: TERMINATED 状态到达时,标记步骤为已完成 // 防止 abort 后最后一个步骤永远显示转圈 ...(data.state === 'TERMINATED' ? { completedAt: Date.now() } : {}), }); } } else { // MT-2 修复: 当 state === 'TERMINATED' 且 iteration 不匹配时, // 说明 abort 发生在循环条件检查时,currentIteration 已自增但没有对应 step。 // 此时不应创建孤立的只含 TERMINATED 的步骤,而是更新最后一个步骤的 states 数组 // runId 一致性检查:仅当 lastStep 属于当前 run 时才 append,避免污染上一条消息的 step if (data.state === 'TERMINATED' && lastStep && lastStep.runId === data.runId) { const currentStates = lastStep.states ?? [lastStep.state]; if (currentStates[currentStates.length - 1] !== 'TERMINATED') { store.updateLastTraceStep({ state: 'TERMINATED', states: [...currentStates, 'TERMINATED'], completedAt: Date.now(), }); } } else if ( data.state === 'TERMINATED' && (!lastStep || lastStep.runId !== data.runId) ) { // 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新 // 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态 } else { // 新迭代 → 标记上一步完成(仅当 lastStep 属于当前 run),创建新步骤 if (lastStep && !lastStep.completedAt && lastStep.runId === data.runId) { store.updateLastTraceStep({ completedAt: Date.now() }); } // v0.6.4: 创建新 step 前先 flush reason/text 缓冲 —— 迟到的上一迭代 // 尾巴必须先落进上一 step,否则会被追加进这条新建的(错误归属)step flushPendingBuffersBeforeNewIteration(); store.addTraceStep({ id: `trace_${data.iteration}_${data.state}_${Date.now()}`, iteration: data.iteration, state: data.state, states: [data.state], startedAt: Date.now(), runId: data.runId, }); } } } // --- Agent 状态映射 --- // INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态 const stateToStatus: Record = { THINKING: 'thinking', EXECUTING: 'executing', PARSING: 'thinking', OBSERVING: 'thinking', // L-4: 观察工具结果更接近"思考"而非"执行" REFLECTING: 'thinking', COMPRESSING: 'thinking', TERMINATED: 'idle', }; const newStatus = stateToStatus[data.state]; if (newStatus) { store.setAgentStatus(newStatus); } } }); return () => unsubscribe(); }, [flushPendingBuffersBeforeNewIteration]); // 监听 Provider 切换通知 useEffect(() => { if (!window.metona?.agent?.onProviderSwitched) return; const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => { const { from, to, reason, sessionId } = data as { from?: string; to?: string; reason?: string; sessionId?: string; }; // L-3: 仅在当前会话中显示 Provider 切换消息 const store = useAgentStore.getState(); if (sessionId && store.currentSessionId && sessionId !== store.currentSessionId) return; store.addMessage({ id: genMsgId('system'), role: 'system', content: `Provider 已切换: ${from ?? '未知'} → ${to ?? '未知'}${reason ? ` (${reason})` : ''}`, timestamp: Date.now(), }); }); return () => unsubscribe(); }, []); // v0.3.17: 监听配置变更广播,实时更新 store 中的配置字段 // 解决场景:用户在 SettingsModal 修改 agent.maxIterations 后,详情栏分母不刷新 useEffect(() => { if (!window.metona?.config?.onChanged) return; const unsubscribe = window.metona.config.onChanged((data: { key: string; value: unknown }) => { const store = useAgentStore.getState(); const { key, value } = data; // 按需更新 store 中缓存的配置字段 if (key === 'agent.maxIterations' && typeof value === 'number') { store.setMaxIterations(value); } // 其他 agent.* 配置项若 store 有对应字段,可在此扩展 }); return () => unsubscribe(); }, []); }