feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s

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:
2026-08-27 17:06:58 +08:00
parent b6e2a8bd25
commit 3940716dc2
78 changed files with 6369 additions and 1341 deletions
+128 -90
View File
@@ -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 已置 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();
@@ -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(() => {