feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行

流式渲染修复:
- runId 机制防止 abort 后旧流事件污染新 run
- run lock 防止并发 run 污染引擎状态
- abort race 提前退出工具执行等待
- TERMINATED 状态通过 stateChange 发射
- tool_call_delta 流式参数拼接 + pending 占位替换
- 首轮卡片创建路径统一,traceStep 按 ID 精确匹配
- compressed 事件转发为 toast 通知

安全增强:
- ConfirmationHook 支持持久化自动执行(跨会话)
- 设置面板新增自动执行工具管理 UI
- SandboxManager 双重安全校验 fail-closed
- 审计日志链式哈希防篡改
- PromptInjectionDefender 中文注入标记清理
- scanCode 28 模式 + base64/$() 检测
- validatePath realpathSync 防符号链接逃逸
- code-search 使用 execFile 防命令注入

新增工具:
- file_editor、code_search、task_manager、diff_viewer

其他:
- Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试
- MemoryManager TF-IDF 语义检索
- run_command Windows 中文编码修复(chcp 65001)
- 版本号 0.2.0 → 0.2.1
This commit is contained in:
thzxx
2026-07-12 12:54:52 +08:00
parent 469f53b623
commit 3c5aea8fb7
41 changed files with 4972 additions and 423 deletions
+167 -28
View File
@@ -11,7 +11,7 @@
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
/**
* Agent 流式事件监听 Hook
@@ -40,6 +40,26 @@ export function useAgentStream(): void {
state?: string;
};
// 会话过滤:忽略非当前会话的事件(防止切换会话后旧事件污染)
if (data.sessionId && data.sessionId !== useAgentStore.getState().currentSessionId) return;
// H-6/C-4: runId 过滤 — 忽略旧 run 的事件(abort 后重发场景)
// 修复 abort 后旧 DONE 污染新 runcurrentRunId=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);
}
}
// 每次都从 store 读取最新状态(避免闭包捕获过期快照)
const getStore = () => useAgentStore.getState();
@@ -47,6 +67,33 @@ export function useAgentStream(): void {
// 推理内容增量
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') {
@@ -57,7 +104,7 @@ export function useAgentStream(): void {
} else {
// 还没有 assistant 消息,先创建一条(仅含思考内容)
getStore().addMessage({
id: `msg_${Date.now()}_assistant`,
id: genMsgId('assistant'),
role: 'assistant',
content: '',
reasoningContent: data.delta,
@@ -80,7 +127,27 @@ export function useAgentStream(): void {
// 文本增量
case 'text_delta':
if (data.delta) {
getStore().setStreaming(true);
// 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: data.delta,
timestamp: Date.now(),
iteration: data.iteration,
});
if (data.iteration !== getStore().currentIteration) {
getStore().setCurrentIteration(data.iteration);
}
break;
}
}
// isStreaming 已在 sendMessage 时设置为 true,无需每个 delta 重复设置
getStore().updateLastAssistantMessage(data.delta);
// 无 reasoning 模式下,将文本内容也记入 Trace thought(便于追踪)
@@ -97,6 +164,30 @@ export function useAgentStream(): void {
}
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) {
@@ -107,13 +198,22 @@ export function useAgentStream(): void {
status: 'executing',
};
// 追加到当前 assistant 消息
// 追加到当前 assistant 消息(替换同 index 的 pending 占位)
const msgs = getStore().messages;
const lastAssistant = msgs[msgs.length - 1];
if (lastAssistant?.role === 'assistant') {
getStore().updateMessage(lastAssistant.id, {
toolCalls: [...(lastAssistant.toolCalls ?? []), tc],
});
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 步骤(添加工具调用信息)
@@ -165,7 +265,8 @@ export function useAgentStream(): void {
}
: tc,
);
getStore().updateTraceStep(trace.iteration, { toolCalls: updatedTraceToolCalls });
// L-2: 按 ID 精确匹配 traceStep(避免 iteration 碰撞)
getStore().updateTraceStepById(trace.id, { toolCalls: updatedTraceToolCalls });
break;
}
}
@@ -197,7 +298,11 @@ export function useAgentStream(): void {
// 流结束
case 'done':
getStore().setStreaming(false);
getStore().setAgentStatus('idle');
getStore().setCurrentRunId(null);
// 不覆盖 error 状态 — error handler 已设置 agentStatus='error'
if (getStore().agentStatus !== 'error') {
getStore().setAgentStatus('idle');
}
// 标记最后一个 Trace 步骤为已完成
getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().saveTraceData();
@@ -206,10 +311,11 @@ export function useAgentStream(): void {
// 错误
case 'error':
getStore().setStreaming(false);
getStore().setCurrentRunId(null);
getStore().setAgentStatus('error');
getStore().updateLastTraceStep({ completedAt: Date.now() });
getStore().addMessage({
id: `msg_${Date.now()}_error`,
id: genMsgId('error'),
role: 'system',
content: `错误: ${data.error?.message ?? '未知错误'}`,
timestamp: Date.now(),
@@ -236,31 +342,60 @@ export function useAgentStream(): void {
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 消息卡片
// 仅当上一轮迭代已结束(prevIteration > 0)且上一条 assistant 消息有内容时
if (data.iteration > prevIteration && prevIteration > 0) {
// 如果最后一条已经是当前迭代的 assistant 卡片(delta handler 提前创建),不重复
if (data.iteration > prevIteration) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
if (
lastMsg?.role === 'assistant' &&
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent)
) {
store.addMessage({
id: `msg_${Date.now()}_assistant`,
role: 'assistant',
content: '',
timestamp: Date.now(),
iteration: data.iteration,
});
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,
});
}
}
}
}
@@ -303,13 +438,14 @@ export function useAgentStream(): void {
}
// --- Agent 状态映射 ---
// INIT 不映射 — 避免 engine 的 transitionTo(INIT) 覆盖 sendMessage 设置的 'thinking' 状态
const stateToStatus: Record<string, AgentStatus> = {
THINKING: 'thinking',
EXECUTING: 'executing',
PARSING: 'thinking',
OBSERVING: 'thinking',
OBSERVING: 'thinking', // L-4: 观察工具结果更接近"思考"而非"执行"
REFLECTING: 'thinking',
COMPRESSING: 'thinking',
INIT: 'thinking',
TERMINATED: 'idle',
};
const newStatus = stateToStatus[data.state];
@@ -327,9 +463,12 @@ export function useAgentStream(): void {
if (!window.metona?.agent?.onProviderSwitched) return;
const unsubscribe = window.metona.agent.onProviderSwitched((data: unknown) => {
const { from, to, reason } = data as { from?: string; to?: string; reason?: string };
useAgentStore.getState().addMessage({
id: `msg_${Date.now()}_system`,
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(),