Files
metona-ai-desktop/src/hooks/useAgentStream.ts
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
2026-08-27 17:06:58 +08:00

750 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
/**
* 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<string | undefined>(undefined);
const textRafIdRef = useRef<number | null>(null);
const traceThoughtBufferRef = useRef('');
const traceRafIdRef = useRef<number | null>(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<string, unknown> };
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;
};
// 会话过滤:忽略非当前会话的事件(防止切换会话后旧事件污染)
if (data.sessionId && data.sessionId !== useAgentStore.getState().currentSessionId) return;
// H-6/C-4: runId 过滤 — 忽略旧 run 的事件(abort 后重发场景)
// 修复 abort 后旧 DONE 污染新 runcurrentRunId=null 时忽略终止事件
const currentState = useAgentStore.getState();
if (data.runId) {
if (currentState.currentRunId) {
// currentRunId 已设置:只接受匹配的事件
if (data.runId !== currentState.currentRunId) return;
} else {
// currentRunId 未设置:
// done/error 是终止事件 — currentRunId 为 null 说明已被 abort 或尚未开始,
// 忽略旧 run 的延迟终止事件,避免错误地 setStreaming(false)
if (data.type === 'done' || data.type === 'error') return;
// 首个活跃事件,设置 currentRunId
currentState.setCurrentRunId(data.runId);
}
}
// v0.6.4: abort 尾巴过滤 —— 用户中断后 agent-store 已置 idleisStreaming=false
// 并刻意保留 currentRunId 用于吞掉延迟终止事件;但引擎在 abort 生效的间隙里
// 仍会放出同 runId 的尾部增量(reasoning/text delta、TOOL_RESULT 等)。
// 此前这些事件照常写入 messages —— 停止按钮按下后聊天区还会"自己长出内容"。
// 规则:非流式状态下除终止事件(done/error,负责收尾落盘与解锁等待方)外,
// 其余内容类事件一律丢弃。
if (!currentState.isStreaming && data.type !== 'done' && data.type !== 'error') {
return;
}
// 每次都从 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];
const needsNewCard =
!last ||
last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
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];
const needsNewCard =
!last ||
last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
// 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(修复工具结果丢失)
// tool_call_complete 在 THINKING 阶段写入,tool_result 在 EXECUTING 阶段到达
// 两者可能在同一轮迭代但不同状态转换,需反向查找
const steps = getStore().traceSteps;
for (let i = steps.length - 1; i >= 0; i--) {
const trace = steps[i];
if (trace.toolCalls?.some((tc) => tc.id === data.toolResult!.toolCallId)) {
const updatedTraceToolCalls = trace.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,
);
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
getStore().updateTraceStepById(trace.id, { toolCalls: updatedTraceToolCalls });
break;
}
}
}
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<string, string> = {
max_iterations: t('agent.terminated.max_iterations'),
timeout: t('agent.terminated.timeout'),
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();
// 会话过滤:忽略非当前会话的状态变化
if (data.sessionId && data.sessionId !== store.currentSessionId) return;
// H-6/C-4: runId 过滤 — 忽略旧 run 的状态变化
// 修复 abort 后旧 TERMINATED 污染:currentRunId=null 时只有 INIT 设置 currentRunId,其他忽略
if (data.runId) {
if (store.currentRunId) {
// currentRunId 已设置:只接受匹配的状态变化
if (data.runId !== store.currentRunId) return;
} else {
// currentRunId 未设置:
// 只有 INIT 是新 run 的第一个状态,设置 currentRunId
// 其他状态(如旧 run 的 TERMINATED)忽略,避免污染
if (data.state === 'INIT') {
store.setCurrentRunId(data.runId);
} else {
return;
}
}
}
// --- 迭代号更新 + 新消息卡片创建 ---
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<string, AgentStatus> = {
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();
}, []);
}