Files
metona-ai-desktop/src/hooks/useAgentStream.ts
T
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

751 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';
// 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<string, AgentStatus> = {
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<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;
};
// ===== 事件准入判定(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<string, string> = {
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<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();
}, []);
}