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
+90 -24
View File
@@ -40,23 +40,35 @@ export class ConfirmationHook implements PreToolHook {
/** 工具定义缓存(由外部设置) */
private toolDefs = new Map<string, MetonaToolDef>();
/** 用户选择记忆(同一会话内不再重复询问) */
private rememberedDecisions = new Map<string, boolean>();
/** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */
private rememberedDecisions = new Map<string, { approved: boolean; at: number }>();
/**
* v0.4.1: 会话内拒绝记忆的 TTL10 分钟)
*
* 历史问题:用户勾选"记住拒绝"后,该工具在本会话永久被拒且无恢复入口,
* 用户只能重启会话。现给拒绝记忆加 TTL——过期后恢复询问;
* 批准记忆不受 TTL 影响(记住批准是低风险决定,保留原语义)。
*/
private static readonly DENIAL_TTL_MS = 10 * 60 * 1000;
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
private autoExecuteTools = new Set<string>();
/** 等待确认的 Promise 解析器(含完整请求信息,供 getPendingConfirmations 返回) */
private pendingConfirmations = new Map<string, {
resolve: (v: boolean) => void;
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>;
riskLevel?: string;
reason?: string;
}>();
private pendingConfirmations = new Map<
string,
{
resolve: (v: boolean) => void;
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>;
riskLevel?: string;
reason?: string;
}
>();
/** 确认超时时间(可从配置读取,默认 120 秒) */
private confirmationTimeoutMs = 120_000;
@@ -168,7 +180,12 @@ export class ConfirmationHook implements PreToolHook {
* @param remember 会话内记住决定
* @param autoExecute 永久自动执行(持久化)
*/
resolveConfirmation(toolCallId: string, approved: boolean, remember: boolean, autoExecute: boolean = false): void {
resolveConfirmation(
toolCallId: string,
approved: boolean,
remember: boolean,
autoExecute: boolean = false,
): void {
const pending = this.pendingConfirmations.get(toolCallId);
if (pending) {
clearTimeout(pending.timer);
@@ -177,9 +194,9 @@ export class ConfirmationHook implements PreToolHook {
if (autoExecute && approved) {
this.setAutoExecute(pending.toolName, true);
}
// 会话内记忆
// 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期)
if (remember) {
this.rememberedDecisions.set(pending.toolName, approved);
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
}
this.pendingConfirmations.delete(toolCallId);
}
@@ -225,7 +242,7 @@ export class ConfirmationHook implements PreToolHook {
this.setAutoExecute(pending.toolName, true);
}
if (remember) {
this.rememberedDecisions.set(pending.toolName, approved);
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
}
}
}
@@ -257,6 +274,44 @@ export class ConfirmationHook implements PreToolHook {
return result;
}
/**
* v0.4.1: 获取本会话内记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口)
*
* 拒绝记忆有 TTL(默认 10 分钟),到期自动恢复询问;
* 此方法返回未过期的拒绝记忆,前端可提供"重新询问"按钮主动重置。
*/
getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> {
const now = Date.now();
const result: Array<{ toolName: string; expiresInSeconds: number }> = [];
for (const [toolName, decision] of this.rememberedDecisions) {
if (decision.approved) continue;
const elapsed = now - decision.at;
if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
// 已过期 — 顺手清理,避免列表返回过期条目
this.rememberedDecisions.delete(toolName);
continue;
}
result.push({
toolName,
expiresInSeconds: Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000),
});
}
return result;
}
/**
* v0.4.1: 重置指定工具的会话内拒绝记忆(恢复询问)
* @returns true 表示重置成功(存在该工具的拒绝记忆);false 表示没有可重置的记忆
*/
resetRememberedDenial(toolName: string): boolean {
const decision = this.rememberedDecisions.get(toolName);
if (decision && !decision.approved) {
this.rememberedDecisions.delete(toolName);
return true;
}
return false;
}
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
const def = this.toolDefs.get(toolCall.name);
if (!def) {
@@ -265,8 +320,8 @@ export class ConfirmationHook implements PreToolHook {
}
// 检查是否需要确认
const needsConfirmation = def.requiresPermission ||
ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
const needsConfirmation =
def.requiresPermission || ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
if (!needsConfirmation) {
return { blocked: false };
@@ -277,11 +332,21 @@ export class ConfirmationHook implements PreToolHook {
return { blocked: false };
}
// 检查是否有记住的决策
// 检查是否有记住的决策v0.4.1: 拒绝记忆带 TTL,过期后恢复询问)
const remembered = this.rememberedDecisions.get(toolCall.name);
if (remembered !== undefined) {
if (remembered) return { blocked: false };
return { blocked: true, reason: `User previously denied tool "${toolCall.name}"` };
const isExpiredDenial =
!remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS;
if (isExpiredDenial) {
// 拒绝记忆已过期 — 移除并继续走正常确认流程
this.rememberedDecisions.delete(toolCall.name);
} else {
if (remembered.approved) return { blocked: false };
return {
blocked: true,
reason: `User previously denied tool "${toolCall.name}" (remembered in this session; expires in ${Math.ceil((ConfirmationHook.DENIAL_TTL_MS - (Date.now() - remembered.at)) / 60000)} min)`,
};
}
}
// 如果没有主窗口,安全起见阻止执行
@@ -347,9 +412,10 @@ export class ConfirmationHook implements PreToolHook {
this.lastTimeoutToastAt = now;
// 统计当前还有多少 pending(含本次刚超时的)
const pendingCount = this.pendingConfirmations.size + 1;
const message = pendingCount > 1
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
const message =
pendingCount > 1
? `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),${pendingCount} 个工具未执行`
: `工具确认超时(${Math.round(this.confirmationTimeoutMs / 1000)}秒),"${request.toolName}" 未执行`;
this.mainWindow.webContents.send('toast:show', {
type: 'warning',
message,