feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 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:
+63
-14
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user