feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
+63 -14
View File
@@ -11,7 +11,12 @@
*/
import { useEffect, useRef } from 'react';
import { useAgentStore, genMsgId, type ToolCallInfo, type AgentStatus } from '@renderer/stores/agent-store';
import {
useAgentStore,
genMsgId,
type ToolCallInfo,
type AgentStatus,
} from '@renderer/stores/agent-store';
/**
* Agent 流式事件监听 Hook
@@ -102,12 +107,23 @@ export function useAgentStream(): void {
/** 工具调用增量(流式参数拼接) */
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 };
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 }>;
};
error?: { code: string; message: string };
state?: string;
};
@@ -143,7 +159,9 @@ export function useAgentStream(): void {
if (data.iteration != null) {
const msgs = getStore().messages;
const last = msgs[msgs.length - 1];
const needsNewCard = !last || last.role !== 'assistant' ||
const needsNewCard =
!last ||
last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
getStore().addMessage({
@@ -201,7 +219,9 @@ export function useAgentStream(): void {
if (data.iteration != null) {
const msgs = getStore().messages;
const last = msgs[msgs.length - 1];
const needsNewCard = !last || last.role !== 'assistant' ||
const needsNewCard =
!last ||
last.role !== 'assistant' ||
(last.iteration != null && last.iteration !== data.iteration);
if (needsNewCard) {
// F5: 新迭代前先 flush 旧缓冲区(属于上一条消息的 delta)
@@ -307,7 +327,7 @@ export function useAgentStream(): void {
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
status: data.toolResult!.success ? ('success' as const) : ('error' as const),
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
@@ -328,7 +348,9 @@ export function useAgentStream(): void {
tc.id === data.toolResult!.toolCallId
? {
...tc,
status: data.toolResult!.success ? 'success' as const : 'error' as const,
status: data.toolResult!.success
? ('success' as const)
: ('error' as const),
result: data.toolResult!.result,
error: data.toolResult!.error,
durationMs: data.toolResult!.durationMs,
@@ -375,6 +397,23 @@ export function useAgentStream(): void {
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':
// F5: 流结束前立即 flush 缓冲区,避免最后一段 delta 丢失
@@ -420,9 +459,8 @@ export function useAgentStream(): void {
getStore().addMessage({
id: genMsgId('error'),
role: 'system',
content: errorCode === 'content_filtered'
? `⚠️ ${errorMessage}`
: `错误: ${errorMessage}`,
content:
errorCode === 'content_filtered' ? `⚠️ ${errorMessage}` : `错误: ${errorMessage}`,
timestamp: Date.now(),
});
break;
@@ -494,12 +532,14 @@ export function useAgentStream(): void {
if (data.iteration > prevIteration) {
const messages = store.messages;
const lastMsg = messages[messages.length - 1];
const isAlreadyCurrentIteration = lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration;
const isAlreadyCurrentIteration =
lastMsg?.role === 'assistant' && lastMsg.iteration === data.iteration;
if (!isAlreadyCurrentIteration) {
// 首轮不要求上一轮有内容(上一轮是用户消息)
const isFirstIteration = prevIteration === 0;
const prevHasContent = lastMsg?.role === 'assistant' &&
const prevHasContent =
lastMsg?.role === 'assistant' &&
(lastMsg.content || lastMsg.toolCalls?.length || lastMsg.reasoningContent);
if (isFirstIteration || prevHasContent) {
@@ -527,7 +567,8 @@ export function useAgentStream(): void {
// 判断是否需要创建新步骤:同一 runId + 同一迭代的首次状态创建新步骤
// 后续状态转换(EXECUTING/OBSERVING等)追加到 states 数组
// runId 判定:防止跨 run 的事件被误判为同一迭代(如 traceSteps 未清空时残留的旧 step
const isSameIteration = lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId;
const isSameIteration =
lastStep && lastStep.iteration === data.iteration && lastStep.runId === data.runId;
if (isSameIteration) {
// 同一迭代内的状态转换 → 追加状态到 states 数组,更新当前 state
@@ -555,7 +596,10 @@ export function useAgentStream(): void {
completedAt: Date.now(),
});
}
} else if (data.state === 'TERMINATED' && (!lastStep || lastStep.runId !== data.runId)) {
} else if (
data.state === 'TERMINATED' &&
(!lastStep || lastStep.runId !== data.runId)
) {
// 跨 run 的孤立 TERMINATED 事件:上一条消息已结束/已 abort,没有当前 run 的 step 可更新
// 不创建孤立的只含 TERMINATED 的 step(无意义),仅更新 Agent 状态
} else {
@@ -601,7 +645,12 @@ export function useAgentStream(): void {
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 };
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;