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 桥契约
750 lines
32 KiB
TypeScript
750 lines
32 KiB
TypeScript
/**
|
||
* 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 污染新 run:currentRunId=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 已置 idle(isStreaming=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();
|
||
}, []);
|
||
}
|