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 零跳过
751 lines
32 KiB
TypeScript
751 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';
|
||
// 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();
|
||
}, []);
|
||
}
|