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 桥契约
This commit is contained in:
+128
-90
@@ -10,13 +10,16 @@
|
||||
* tool_result 反向搜索包含对应 toolCallId 的 TraceStep(修复工具结果丢失)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
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
|
||||
@@ -26,6 +29,65 @@ import {
|
||||
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;
|
||||
@@ -36,60 +98,11 @@ export function useAgentStream(): void {
|
||||
// 方案:累积 delta 到缓冲区,用 rAF 每帧 commit 一次,合并多次 store 写入。
|
||||
// done/error 时立即 flush,避免最后一段 delta 丢失。
|
||||
// 新迭代卡片创建逻辑(needsNewCard)立即处理,不缓冲。
|
||||
let textDeltaBuffer = '';
|
||||
let textBufferSessionId: string | undefined;
|
||||
let textRafId: number | null = null;
|
||||
|
||||
const flushTextDelta = () => {
|
||||
textRafId = null;
|
||||
if (!textDeltaBuffer) return;
|
||||
const delta = textDeltaBuffer;
|
||||
const bufferedSessionId = textBufferSessionId;
|
||||
textDeltaBuffer = '';
|
||||
textBufferSessionId = 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 scheduleTextFlush = (): void => {
|
||||
if (textRafIdRef.current != null) return;
|
||||
textRafIdRef.current = requestAnimationFrame(flushTextDelta);
|
||||
};
|
||||
|
||||
// F9: reasoning_delta 的 traceSteps thought 更新走 rAF 批处理
|
||||
// 问题:reasoning_delta 每个 delta 调用 updateLastTraceStep,频率 10-30 次/秒,
|
||||
// 触发 TraceViewer 等订阅者 re-render(即使不可见也有 selector 调用开销)。
|
||||
// 方案:累积 reasoning delta 到缓冲区,rAF 每帧 commit 一次。
|
||||
// message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示思考内容)。
|
||||
// 新迭代卡片创建时的 thought 更新保持即时(新卡片需立即显示)。
|
||||
let traceThoughtBuffer = '';
|
||||
let traceRafId: number | null = null;
|
||||
|
||||
const flushTraceThought = () => {
|
||||
traceRafId = null;
|
||||
if (!traceThoughtBuffer) return;
|
||||
const delta = traceThoughtBuffer;
|
||||
traceThoughtBuffer = '';
|
||||
const store = useAgentStore.getState();
|
||||
const steps = store.traceSteps;
|
||||
const step = steps[steps.length - 1];
|
||||
if (step) {
|
||||
store.updateLastTraceStep({
|
||||
thought: (step.thought ?? '') + delta,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = window.metona.agent.onStreamEvent((event: unknown) => {
|
||||
const data = event as {
|
||||
type?: string;
|
||||
@@ -150,6 +163,16 @@ export function useAgentStream(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -207,9 +230,9 @@ export function useAgentStream(): void {
|
||||
|
||||
// F9: traceSteps thought 更新走 rAF 批处理(减少 TraceViewer re-render 频率)
|
||||
// message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示)
|
||||
traceThoughtBuffer += data.delta;
|
||||
if (traceRafId === null) {
|
||||
traceRafId = requestAnimationFrame(flushTraceThought);
|
||||
traceThoughtBufferRef.current += data.delta;
|
||||
if (traceRafIdRef.current === null) {
|
||||
traceRafIdRef.current = requestAnimationFrame(flushTraceThought);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -226,11 +249,15 @@ export function useAgentStream(): void {
|
||||
last.role !== 'assistant' ||
|
||||
(last.iteration != null && last.iteration !== data.iteration);
|
||||
if (needsNewCard) {
|
||||
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
|
||||
if (textRafId !== null) {
|
||||
cancelAnimationFrame(textRafId);
|
||||
flushTextDelta();
|
||||
// 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',
|
||||
@@ -246,13 +273,11 @@ export function useAgentStream(): void {
|
||||
}
|
||||
// F5: 累积 delta 到缓冲区,用 rAF 每帧 commit 一次
|
||||
// 首次缓冲时记录 sessionId(用于会话切换保护)
|
||||
if (textDeltaBuffer === '') {
|
||||
textBufferSessionId = data.sessionId;
|
||||
}
|
||||
textDeltaBuffer += data.delta;
|
||||
if (textRafId === null) {
|
||||
textRafId = requestAnimationFrame(flushTextDelta);
|
||||
if (textDeltaBufferRef.current === '') {
|
||||
textBufferSessionIdRef.current = data.sessionId;
|
||||
}
|
||||
textDeltaBufferRef.current += data.delta;
|
||||
scheduleTextFlush();
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -428,28 +453,32 @@ export function useAgentStream(): void {
|
||||
reason !== 'user_interrupt' &&
|
||||
reason !== 'dead_loop'
|
||||
) {
|
||||
// v0.6.4 P3-5: 系统消息文案出层 —— 数据层 hook 不再拼硬编码字符串,
|
||||
// 统一走集中字典(稳定 key),为多语言与文案审计建立单一来源。
|
||||
const reasonLabels: Record<string, string> = {
|
||||
max_iterations: '已达到最大迭代次数',
|
||||
timeout: '总执行超时',
|
||||
max_iterations: t('agent.terminated.max_iterations'),
|
||||
timeout: t('agent.terminated.timeout'),
|
||||
error: '执行出错',
|
||||
};
|
||||
getStore().addMessage({
|
||||
id: genMsgId('system'),
|
||||
role: 'system',
|
||||
content: `⏹ 会话已停止:${reasonLabels[reason] ?? reason}`,
|
||||
content: t('agent.system.stopped', { reason: reasonLabels[reason] ?? reason }),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
|
||||
if (textRafId !== null) {
|
||||
cancelAnimationFrame(textRafId);
|
||||
flushTextDelta();
|
||||
if (textRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(textRafIdRef.current);
|
||||
textRafIdRef.current = null;
|
||||
}
|
||||
flushTextDelta();
|
||||
// F9: flush traceThought 缓冲区,避免最后一段 reasoning delta 丢失
|
||||
if (traceRafId !== null) {
|
||||
cancelAnimationFrame(traceRafId);
|
||||
flushTraceThought();
|
||||
if (traceRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(traceRafIdRef.current);
|
||||
traceRafIdRef.current = null;
|
||||
}
|
||||
flushTraceThought();
|
||||
getStore().setStreaming(false);
|
||||
getStore().setCurrentRunId(null);
|
||||
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
|
||||
@@ -465,15 +494,17 @@ export function useAgentStream(): void {
|
||||
// 错误
|
||||
case 'error':
|
||||
// F5: 错误前立即 flush 缓冲区,保留已接收的内容
|
||||
if (textRafId !== null) {
|
||||
cancelAnimationFrame(textRafId);
|
||||
flushTextDelta();
|
||||
if (textRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(textRafIdRef.current);
|
||||
textRafIdRef.current = null;
|
||||
}
|
||||
flushTextDelta();
|
||||
// F9: flush traceThought 缓冲区,保留已接收的 reasoning 内容
|
||||
if (traceRafId !== null) {
|
||||
cancelAnimationFrame(traceRafId);
|
||||
flushTraceThought();
|
||||
if (traceRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(traceRafIdRef.current);
|
||||
traceRafIdRef.current = null;
|
||||
}
|
||||
flushTraceThought();
|
||||
getStore().setStreaming(false);
|
||||
getStore().setCurrentRunId(null);
|
||||
getStore().setAgentStatus('error');
|
||||
@@ -485,7 +516,9 @@ export function useAgentStream(): void {
|
||||
id: genMsgId('error'),
|
||||
role: 'system',
|
||||
content:
|
||||
errorCode === 'content_filtered' ? `⚠️ ${errorMessage}` : `错误: ${errorMessage}`,
|
||||
errorCode === 'content_filtered'
|
||||
? t('agent.error.content_filtered', { message: errorMessage })
|
||||
: t('agent.error.generic', { message: errorMessage }),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
@@ -496,18 +529,20 @@ export function useAgentStream(): void {
|
||||
|
||||
return () => {
|
||||
// F5: 组件卸载时清理挂起的 rAF,并 flush 残留 delta(保留已接收内容)
|
||||
if (textRafId !== null) {
|
||||
cancelAnimationFrame(textRafId);
|
||||
flushTextDelta();
|
||||
if (textRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(textRafIdRef.current);
|
||||
textRafIdRef.current = null;
|
||||
}
|
||||
flushTextDelta();
|
||||
// F9: 清理 traceThought 的 rAF
|
||||
if (traceRafId !== null) {
|
||||
cancelAnimationFrame(traceRafId);
|
||||
flushTraceThought();
|
||||
if (traceRafIdRef.current !== null) {
|
||||
cancelAnimationFrame(traceRafIdRef.current);
|
||||
traceRafIdRef.current = null;
|
||||
}
|
||||
flushTraceThought();
|
||||
cleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
}, [flushTextDelta, flushTraceThought]);
|
||||
|
||||
// ===== 状态变化事件(迭代追踪 + Trace 步骤 + 消息卡片) =====
|
||||
useEffect(() => {
|
||||
@@ -632,6 +667,9 @@ export function useAgentStream(): void {
|
||||
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,
|
||||
@@ -663,7 +701,7 @@ export function useAgentStream(): void {
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
}, [flushPendingBuffersBeforeNewIteration]);
|
||||
|
||||
// 监听 Provider 切换通知
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user