feat: 升级至 v0.3.0 — 安全增强、死循环检测、六轮全面审计修复

大版本迭代,新增安全增强、Agent Loop 增强、UI/UX 增强,经六轮全面审计修复所有问题。

新增功能:
- OutputValidator 事实一致性检查 + 幻觉检测
- PromptInjectionDefender 语义级检测(指令性动词密度、角色边界、分隔符嵌套)
- DeadLoopError 死循环检测(连续3轮相同工具调用自动终止)
- PolicyEngine maxFrequency 滑动窗口频率限制
- MemoryManager TF-IDF 语义检索 + IDF 缓存原子替换
- 斜杠命令(/tool /memory /clear /export)+ 快捷键(Ctrl+B/J/Shift+F/N/[/])
- 专注模式(Ctrl+Shift+F)带面板状态快照保存/恢复

六轮审计修复(共修复 3 CRITICAL + 8 HIGH + 13 MEDIUM + 11 LOW):

CRITICAL:
- main.ts 传入 createAdapter 而非 reloadAdapter,导致 Provider 切换完全失效
- Ctrl+N/Ctrl+[/Ctrl+] 不同步 agent-store,导致消息发到错误会话
- engine.ts emit('error') 无监听器导致 DONE 事件丢失、前端卡死

HIGH:
- 死循环检测在工具执行之后(移入 PARSING 后 EXECUTING 前)
- deadLoop 事件前端未处理
- ConfirmationHook.clearPending 从未调用导致定时器泄漏
- engine.ts retry abort listener 未移除导致监听器堆积
- MCPManager JSON.parse 无 try-catch 导致初始化崩溃
- sendMessage 自动创建会话不同步 session-store
- setCurrentSession 竞态导致旧请求覆盖新会话数据

MEDIUM:
- toggleFocusMode 覆盖用户原有面板状态
- 正则检测可被常见词绕过
- 频率限制内存泄漏 + customPolicies 覆盖
- LIKE 回退转义未包含反斜杠
- OutputValidator 新功能未传入 toolResults/context
- MemoryConsolidator LLM 调用无超时保护
- AuditService 每次 log 都查询数据库
- browser-window-manager 超时后未停止页面加载
- output-validator URL 比较大小写敏感
- FileReader 无 onerror 导致 Promise 永久挂起
- /clear /export 不关闭斜杠菜单
- 专注模式下 toggleSidebar/toggleDetail 未恢复另一面板快照

LOW:
- abortPromise 事件监听器堆积
- RateLimitHook Map 内存泄漏
- memory.ts await 同步方法
- PolicyEngine 死代码清理
- Orchestrator sessionDepth 会话结束不清理
- workspace.service.ts 元数据插入边界问题
This commit is contained in:
thzxx
2026-07-12 19:46:47 +08:00
parent 3858f5ede3
commit 025f00171b
26 changed files with 1002 additions and 95 deletions
+117 -5
View File
@@ -37,6 +37,16 @@ import { MetonaStreamEventType, MetonaFinishReason } from '../types';
import { estimateMessagesTokens } from '../utils/token-estimator';
import log from 'electron-log';
/**
* v0.3.0: 死循环错误 — 在 executeOneIterationStream 中抛出,主循环捕获后以 DEAD_LOOP 原因终止
*/
class DeadLoopError extends Error {
constructor(message: string) {
super(message);
this.name = 'DeadLoopError';
}
}
const DEFAULT_CONFIG: AgentLoopConfig = {
maxIterations: 20,
timeoutMs: 120_000,
@@ -74,6 +84,9 @@ export class AgentLoopEngine extends EventEmitter {
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
private currentRunPromise: Promise<AgentLoopOutput> | null = null;
/** v0.3.0: 工具调用签名历史(用于死循环检测) */
private toolCallHistory: string[] = [];
constructor(
config: Partial<AgentLoopConfig> = {},
private adapter: IMetonaProviderAdapter,
@@ -163,6 +176,8 @@ export class AgentLoopEngine extends EventEmitter {
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
this.abortController = new AbortController();
this.eventSeq = 0;
// v0.3.0: 重置工具调用历史(用于死循环检测)
this.toolCallHistory = [];
try {
await this.transitionTo(AgentLoopState.INIT);
@@ -229,6 +244,9 @@ export class AgentLoopEngine extends EventEmitter {
return this.finish(TerminationReason.COMPLETED, step.thought?.content);
}
// v0.3.0: 死循环检测已移入 executeOneIterationStream 的 PARSING 阶段后,
// 通过抛出 DeadLoopError 在此处 catch 块捕获处理
// 执行工具并将结果加入消息
if (step.toolResults) {
for (const result of step.toolResults) {
@@ -249,11 +267,17 @@ export class AgentLoopEngine extends EventEmitter {
return this.finish(TerminationReason.MAX_ITERATIONS);
return this.finish(TerminationReason.TIMEOUT);
} catch (error) {
// v0.3.0: 捕获 DeadLoopError — 以 DEAD_LOOP 原因终止
if (error instanceof DeadLoopError) {
return this.finish(TerminationReason.DEAD_LOOP, error.message);
}
const errMsg = (error as Error).message;
if (this.aborted || errMsg.includes('Aborted')) {
return this.finish(TerminationReason.USER_INTERRUPT);
}
this.emit('error', { error: errMsg });
// v0.3.0 修复: 不使用 emit('error') — Node EventEmitter 对无监听器的 'error' 事件会同步 throw
// 导致 finish() 被中断、DONE 事件丢失、前端卡死。改为日志记录,让 finish 正常执行
log.error(`[AgentLoop] Run failed: ${errMsg}`);
return this.finish(TerminationReason.ERROR, undefined, error as Error);
}
}
@@ -424,6 +448,23 @@ export class AgentLoopEngine extends EventEmitter {
this.accumulateTokens(tokenUsage);
}
// v0.3.0 修复: 死循环检测 — 在 PARSING 阶段完成后、EXECUTING 阶段开始前检测
// 确保第3轮重复调用的副作用不会产生(工具尚未执行)
if (step.toolCalls && step.toolCalls.length > 0) {
if (this.detectDeadLoop(step.toolCalls)) {
log.warn(`[AgentLoop] Dead loop detected at iteration ${this.currentIteration} (before tool execution)`);
this.emit('deadLoop', {
iteration: this.currentIteration,
runId: this.runId,
sessionId: this.currentSessionId,
});
// 抛出特殊错误,主循环捕获后以 DEAD_LOOP 原因终止
throw new DeadLoopError(
`Detected a potential infinite loop: the same tool calls were repeated for 3 consecutive iterations. Please refine the approach or provide more specific instructions.`,
);
}
}
// === EXECUTING: 执行工具调用 ===
if (step.toolCalls && step.toolCalls.length > 0) {
// transitionTo 已发射 stateChange 事件,无需重复 emit
@@ -456,16 +497,23 @@ export class AgentLoopEngine extends EventEmitter {
step.toolCalls.map((tc) => executeAndForward(tc)),
);
const controller = this.abortController;
let onAbort: (() => void) | null = null;
const abortPromise = new Promise<null>((resolve) => {
if (this.aborted) return resolve(null);
if (controller) {
controller.signal.addEventListener('abort', () => resolve(null), { once: true });
onAbort = () => resolve(null);
controller.signal.addEventListener('abort', onAbort, { once: true });
}
});
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
| PromiseSettledResult<MetonaToolResult>[]
| null;
// v0.3.0 修复: 若 toolsPromise 先完成(正常执行),手动移除 abort 监听器,避免监听器堆积
if (onAbort && controller && raceResult !== null) {
controller.signal.removeEventListener('abort', onAbort);
}
if (raceResult === null) {
// 被 abort 中断,标记步骤并退出
step.completedAt = Date.now();
@@ -543,6 +591,11 @@ export class AgentLoopEngine extends EventEmitter {
} catch (error) {
step.completedAt = Date.now();
step.state = AgentLoopState.TERMINATED;
// v0.3.0 修复: DeadLoopError 抛出时,将 step 加入 iterations 数组,
// 确保死循环轮的 LLM thought 内容不丢失(用户可观察 Agent 被终止前的最后思考)
if (error instanceof DeadLoopError) {
this.iterations.push(step);
}
throw error;
}
}
@@ -665,19 +718,25 @@ export class AgentLoopEngine extends EventEmitter {
const waitMs = Math.max(500, delay + jitter);
log.warn(`[AgentLoop] Retry ${attempt + 1}/${this.config.retryCount} after ${Math.round(waitMs)}ms: ${(error as Error).message}`);
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, waitMs);
const timer = setTimeout(() => {
// v0.3.0 修复: timer 先触发时移除 abort 监听器,避免监听器堆积
if (onAbort && signal) signal.removeEventListener('abort', onAbort);
resolve(undefined);
}, waitMs);
// 支持 abort 中断等待
const signal = this.abortController?.signal;
let onAbort: (() => void) | null = null;
if (signal) {
if (signal.aborted) {
clearTimeout(timer);
reject(new Error('Aborted'));
return;
}
signal.addEventListener('abort', () => {
onAbort = () => {
clearTimeout(timer);
reject(new Error('Aborted'));
}, { once: true });
};
signal.addEventListener('abort', onAbort, { once: true });
}
});
}
@@ -728,6 +787,59 @@ export class AgentLoopEngine extends EventEmitter {
return estimateMessagesTokens(messages);
}
/**
* v0.3.0: 死循环检测
*
* 检测策略:
* 将每轮的工具调用序列化为签名字符串,检查最近3轮的签名是否完全相同。
* 如果连续3轮使用完全相同的参数调用相同的工具,判定为死循环。
*
* v0.3.0 修复:
* - 对 args 的键进行排序,避免 JSON.stringify 键顺序不一致导致漏报
*
* @param toolCalls 当前轮次的工具调用
* @returns 是否检测到死循环
*/
private detectDeadLoop(toolCalls: MetonaToolCall[]): boolean {
// 将当前轮次的工具调用序列化为签名
// v0.3.0 修复:使用 stable stringify,对对象键排序,确保相同内容不同键顺序产生相同签名
// v0.3.0 修复:添加 visited Set 防循环引用,深度上限防过度递归
const stableStringify = (obj: unknown, visited: Set<unknown> = new Set(), depth = 0): string => {
if (depth > 10) return '...'; // 深度上限防止过度递归
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (visited.has(obj)) return '"[Circular]"'; // 循环引用防护
visited.add(obj);
try {
if (Array.isArray(obj)) return `[${obj.map((v) => stableStringify(v, visited, depth + 1)).join(',')}]`;
const keys = Object.keys(obj as Record<string, unknown>).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((obj as Record<string, unknown>)[k], visited, depth + 1)}`).join(',')}}`;
} finally {
visited.delete(obj);
}
};
const signature = toolCalls
.map((tc) => `${tc.name}(${stableStringify(tc.args)})`)
.join('|');
this.toolCallHistory.push(signature);
// 只保留最近5轮的记录(足够检测3轮重复,同时避免内存增长)
if (this.toolCallHistory.length > 5) {
this.toolCallHistory.shift();
}
// 需要至少3轮数据才能检测
if (this.toolCallHistory.length < 3) return false;
const len = this.toolCallHistory.length;
const r1 = this.toolCallHistory[len - 1]; // 当前轮
const r2 = this.toolCallHistory[len - 2]; // 上一轮
const r3 = this.toolCallHistory[len - 3]; // 上上一轮
// 连续3轮完全相同 → 死循环
return r1 === r2 && r2 === r3;
}
/**
* 上下文压缩 — 将旧消息摘要为一条 system 消息,保留最近 3 轮完整对话
*
+2
View File
@@ -25,6 +25,8 @@ export enum TerminationReason {
TIMEOUT = 'timeout',
USER_INTERRUPT = 'user_interrupt',
ERROR = 'error',
/** v0.3.0: 检测到死循环(连续3轮相同工具调用) */
DEAD_LOOP = 'dead_loop',
}
// ===== 迭代步骤 =====
+3
View File
@@ -2,3 +2,6 @@ export type { PreToolHook, HookResult } from './pre-tool';
export { PermissionCheckHook, RateLimitHook } from './pre-tool';
export type { PostToolHook } from './post-tool';
export { AuditLogHook, MemoryTriggerHook } from './post-tool';
// v0.3.0: 修复 ConfirmationHook 未导出的问题
export type { ConfirmationRequest } from './confirmation-hook';
export { ConfirmationHook } from './confirmation-hook';
+12
View File
@@ -26,6 +26,10 @@ export class PermissionCheckHook implements PreToolHook {
if (!result.authorized) {
return { blocked: true, reason: result.reason };
}
// v0.3.0 修复: 授权成功后记录调用,使频率限制功能生效
// 在授权检查通过后立即记录,即使后续工具执行失败也计入频率
// 这样可以防止通过故意制造错误来绕过频率限制
this.policyEngine.recordCall(toolCall.name);
return { blocked: false };
}
}
@@ -40,6 +44,14 @@ export class RateLimitHook implements PreToolHook {
// 使用 sessionId:toolName 作为 key,实现会话隔离的 per-tool 速率限制
const key = `${sessionId}:${toolCall.name}`;
const now = Date.now();
// v0.3.0 修复: 定期清理过期 entry,避免 Map 随会话累积无限增长
if (this.callCounts.size > 1000) {
for (const [k, v] of this.callCounts) {
if (v.resetTime < now) this.callCounts.delete(k);
}
}
const entry = this.callCounts.get(key);
if (entry && entry.resetTime >= now) {
if (entry.count >= this.maxCallsPerMinute) {
+5 -1
View File
@@ -218,7 +218,11 @@ export class MemoryConsolidator {
};
try {
const response = await this.adapter.chat(request);
// v0.3.0 修复: 添加 30 秒超时保护,防止 LLM 响应缓慢导致 consolidator 任务挂起
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LLM extraction timeout')), 30_000),
);
const response = await Promise.race([this.adapter.chat(request), timeoutPromise]);
return response.content.trim();
} catch (error) {
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
+57 -29
View File
@@ -130,6 +130,10 @@ export class MemoryManager {
/**
* 更新 IDF 缓存
*
* v0.3.0 增强:
* - 原子替换缓存(先构建新数据再替换,避免中间不一致状态)
* - 错误处理(数据库查询失败时保留旧缓存,不更新时间戳)
*/
private updateIdfCache(): void {
const now = Date.now();
@@ -138,35 +142,46 @@ export class MemoryManager {
}
const db = this.getDB();
this.idfCache.clear();
// 获取所有记忆内容(episodic + semantic + working
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>;
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>;
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>;
try {
// v0.3.0: 先构建新缓存数据,再原子替换
const newIdfCache = new Map<string, number>();
const allDocs = [
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
...semanticRows.map((r) => r.value),
...workingRows.map((r) => r.value),
];
// 获取所有记忆内容(episodic + semantic + working
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>;
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>;
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>;
this.cachedDocCount = allDocs.length;
const docFreq = new Map<string, number>();
const allDocs = [
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
...semanticRows.map((r) => r.value),
...workingRows.map((r) => r.value),
];
for (const doc of allDocs) {
const tokens = new Set(tokenize(doc));
for (const token of tokens) {
docFreq.set(token, (docFreq.get(token) ?? 0) + 1);
const newDocCount = allDocs.length;
const docFreq = new Map<string, number>();
for (const doc of allDocs) {
const tokens = new Set(tokenize(doc));
for (const token of tokens) {
docFreq.set(token, (docFreq.get(token) ?? 0) + 1);
}
}
}
// IDF = log((N+1)/(df+1)) + 1Sklearn 风格平滑),确保非负
for (const [term, df] of docFreq) {
this.idfCache.set(term, Math.log((this.cachedDocCount + 1) / (df + 1)) + 1);
}
// IDF = log((N+1)/(df+1)) + 1Sklearn 风格平滑),确保非负
for (const [term, df] of docFreq) {
newIdfCache.set(term, Math.log((newDocCount + 1) / (df + 1)) + 1);
}
this.cacheUpdatedAt = now;
// v0.3.0: 原子替换 — 只有新数据完全准备好后才替换旧缓存
this.idfCache = newIdfCache;
this.cachedDocCount = newDocCount;
this.cacheUpdatedAt = now;
} catch (error) {
// v0.3.0: 数据库查询失败时保留旧缓存,不更新 cacheUpdatedAt
// 这样下次 search() 会再次尝试更新
log.error('MemoryManager: Failed to update IDF cache, keeping stale cache:', error);
}
}
/**
@@ -299,10 +314,15 @@ export class MemoryManager {
/**
* 存储记忆
*
* v0.3.0 修复:
* - switch 添加 default 分支,未知 type 抛错而非静默失败
* - working 类型使用 item.id(若提供)或生成唯一 key,避免同 session 多次存储互相覆盖
* - semantic 类型使用 item.summary 作为 key(若提供),支持更新已有记忆
*/
store(item: Omit<MemoryItem, 'id' | 'createdAt'>): string {
store(item: Omit<MemoryItem, 'id' | 'createdAt'> & { id?: string }): string {
const db = this.getDB();
const id = `mem_${nanoid(12)}`;
const id = item.id ?? `mem_${nanoid(12)}`;
const now = Date.now();
const importance = item.importance ?? this.calculateImportance(item);
@@ -314,17 +334,22 @@ export class MemoryManager {
`).run(id, item.sessionId ?? null, item.content, item.summary ?? null, item.source, importance, now);
break;
case 'semantic':
// v0.3.0 修复:使用 summary 作为 key(若提供),支持更新已有语义记忆
db.prepare(`
INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run(id, id, item.content, 'general', importance, item.sessionId ?? null, now, now);
`).run(id, item.summary ?? id, item.content, 'general', importance, item.sessionId ?? null, now, now);
break;
case 'working':
// v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖
db.prepare(`
INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, item.sessionId ?? 'default', 'default', 'default', item.content, now);
`).run(id, item.sessionId ?? 'default', 'default', item.summary ?? id, item.content, now);
break;
default:
// v0.3.0 修复:未知 type 抛错而非静默失败
throw new Error(`Unknown memory type: ${(item as { type: string }).type}`);
}
// 使 IDF 缓存失效
@@ -346,7 +371,8 @@ export class MemoryManager {
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
const db = this.getDB();
const { topK = 5, type, minImportance = 0 } = options;
if (!query) return [];
// v0.3.0 修复:拦截空 query 和纯空格 query
if (!query || !query.trim()) return [];
// v0.2.0: 优先使用 TF-IDF 语义搜索
const tfidfResults = this.tfidfSearch(query, options);
@@ -356,7 +382,8 @@ export class MemoryManager {
// 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索
// 转义 LIKE 通配符,避免用户输入的 % 和 _ 影响匹配
const escapedQuery = query.replace(/[%_]/g, '\\$&');
// v0.3.0 修复: 反斜杠也需转义,否则含 \ 的搜索(如 Windows 路径)会导致 SQLite LIKE 报错
const escapedQuery = query.replace(/[%_\\]/g, '\\$&');
const pattern = `%${escapedQuery}%`;
const results: SearchResult[] = [];
@@ -402,7 +429,8 @@ export class MemoryManager {
}
// 搜索工作记忆
if (type === 'working') {
// v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致)
if (!type || type === 'working') {
const rows = db.prepare(`
SELECT * FROM working_memories
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
+12 -2
View File
@@ -164,7 +164,12 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = success ? 'completed' : 'error';
handle.result = result;
this.activeSubAgents.delete(taskId);
this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度
// v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积
if (currentDepth === 0) {
this.sessionDepth.delete(params.parentSessionId);
} else {
this.sessionDepth.set(params.parentSessionId, currentDepth);
}
this.emit('taskCompleted', result);
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
@@ -184,7 +189,12 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = 'error';
handle.result = result;
this.activeSubAgents.delete(taskId);
this.sessionDepth.set(params.parentSessionId, currentDepth); // 恢复深度
// v0.3.0 修复: 深度恢复为 0 时删除条目,避免 sessionDepth Map 随会话累积
if (currentDepth === 0) {
this.sessionDepth.delete(params.parentSessionId);
} else {
this.sessionDepth.set(params.parentSessionId, currentDepth);
}
this.emit('taskError', { taskId, error: errMsg });
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
+106 -5
View File
@@ -3,6 +3,11 @@
*
* 三级权限模型:Read / Write / External Action
*
* v0.3.0 增强:
* - 实现 maxFrequency 频率限制(滑动窗口算法)
* - 添加 checkFrequency 和 recordCall 方法
* - 添加 cleanupFrequencyRecords 防止内存泄漏
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
*/
@@ -22,12 +27,14 @@ export interface PermissionPolicy {
}
export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc\//, /\/proc\//, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] },
// v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配,
// 覆盖 /etc/ 和 /etc(无尾斜杠,在 JSON 字符串中后跟引号的情况)
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i] },
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc\//, /\/proc\//, /\/System\//, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i, /MEMORY\.md/i], requireConfirmation: true, maxFrequency: 5 },
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 3 },
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
@@ -39,12 +46,30 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
export class PolicyEngine {
private policies: Map<string, PermissionPolicy> = new Map();
/** v0.3.0: 工具调用频率追踪 — 工具名 -> 调用时间戳列表 */
private callFrequency: Map<string, number[]> = new Map();
/** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */
private readonly FREQ_WINDOW_MS = 60_000;
/**
* v0.3.0 修复:customPolicies 与 DEFAULT_POLICIES 合并而非完全覆盖
*
* 合并策略:customPolicies 中的字段覆盖默认策略的同名字段,
* 未指定的字段保留默认值(如 deniedPatterns 等安全配置不会被丢失)
*/
constructor(customPolicies: PermissionPolicy[] = []) {
for (const policy of DEFAULT_POLICIES) {
this.policies.set(policy.toolName, policy);
this.policies.set(policy.toolName, { ...policy });
}
for (const policy of customPolicies) {
this.policies.set(policy.toolName, policy);
const existing = this.policies.get(policy.toolName);
if (existing) {
// v0.3.0 修复:合并而非替换,保留默认的安全配置(如 deniedPatterns
this.policies.set(policy.toolName, { ...existing, ...policy });
} else {
this.policies.set(policy.toolName, policy);
}
}
}
@@ -65,7 +90,14 @@ export class PolicyEngine {
};
}
const argsStr = JSON.stringify(args);
// v0.3.0 修复:使用 try-catch 防止循环引用导致 JSON.stringify 抛错
let argsStr: string;
try {
argsStr = JSON.stringify(args);
} catch {
// 循环引用等异常情况,降级为 toString
argsStr = String(args);
}
// v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一
if (policy.allowedPatterns && policy.allowedPatterns.length > 0) {
@@ -99,10 +131,79 @@ export class PolicyEngine {
}
}
// v0.3.0: 频率限制检查
if (policy.maxFrequency !== undefined) {
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency);
if (!freqCheck.allowed) {
return {
authorized: false,
reason: freqCheck.reason,
level: policy.requiredLevel,
requiresConfirmation: false,
};
}
}
return {
authorized: true,
level: policy.requiredLevel,
requiresConfirmation: policy.requireConfirmation ?? false,
};
}
/**
* v0.3.0: 频率限制检查(滑动窗口算法)
*
* 检查指定工具在时间窗口内的调用次数是否超过限制。
* 注意:此方法仅检查,不记录调用。调用成功后需调用 recordCall()。
*
* v0.3.0 修复:
* - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏)
*
* @param toolName 工具名称
* @param maxFreq 最大频率(每分钟)
* @returns 检查结果
*/
checkFrequency(toolName: string, maxFreq?: number): { allowed: boolean; reason?: string } {
const policy = this.policies.get(toolName);
const limit = maxFreq ?? policy?.maxFrequency;
if (limit === undefined) return { allowed: true };
const now = Date.now();
const calls = this.callFrequency.get(toolName) ?? [];
// 移除时间窗口外的调用记录
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
// v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长
if (validCalls.length !== calls.length) {
this.callFrequency.set(toolName, validCalls);
}
if (validCalls.length >= limit) {
return {
allowed: false,
reason: `Rate limit exceeded for ${toolName}: max ${limit} calls per minute (current: ${validCalls.length})`,
};
}
return { allowed: true };
}
/**
* v0.3.0: 记录工具调用(工具成功执行后调用)
*
* v0.3.0 修复:同时清理过期记录,防止数组无限增长
*
* @param toolName 工具名称
*/
recordCall(toolName: string): void {
const now = Date.now();
const calls = this.callFrequency.get(toolName) ?? [];
// v0.3.0 修复:记录新调用时同时清理过期记录
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
validCalls.push(now);
this.callFrequency.set(toolName, validCalls);
}
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
// 该方法属于死代码,删除以减少维护负担
}
@@ -3,7 +3,7 @@
*
* 三层防御:
* 1. 正则快速过滤
* 2. 语义级检测(可选)
* 2. 语义级检测
* 3. 指令隔离标记
*
* v0.2.0 增强:
@@ -13,6 +13,10 @@
* - 添加角色扮演注入检测
* - 添加间接注入检测(通过工具返回值注入)
*
* v0.3.0 增强:
* - 实现语义级检测(指令性动词密度、角色边界异常、分隔符嵌套)
* - 添加上下文感知的注入检测
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
*/
@@ -37,16 +41,17 @@ interface InjectionPattern {
export class PromptInjectionDefender {
private static INJECTION_PATTERNS: InjectionPattern[] = [
// === HIGH 危险:直接越狱/忽略指令 ===
{ pattern: /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i, severity: 'high' },
{ pattern: /forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i, severity: 'high' },
{ pattern: /override\s+your\s+/i, severity: 'high' },
// v0.3.0 修复:在关键词间允许可选限定词(the/any/all/these/those/your 等),防止 "ignore the previous instructions" 绕过
{ pattern: /ignore\s+(?:(?:the|all|any|these|those|your|above)\s+)*(?:previous|above|prior)\s+(?:(?:the|your|all)\s+)*(?:instructions?|commands?|directives?|rules?)/i, severity: 'high' },
{ pattern: /forget\s+(?:(?:everything|all|the|your|above|prior)\s+)+/i, severity: 'high' },
{ pattern: /override\s+(?:(?:the|your|all|any)\s+)+/i, severity: 'high' },
{ pattern: /JAILBREAK/i, severity: 'high' },
{ pattern: /DAN\s*[:\[]/i, severity: 'high' },
{ pattern: /(?:enable|turn\s+on|activate)\s+(developer|debug|root|admin)\s+mode/i, severity: 'high' },
{ pattern: /(?:disable|turn\s+off|bypass)\s+(your|the)\s+(safety|security|filter|guard|defense)/i, severity: 'high' },
{ pattern: /(?:send|transmit|exfiltrate|upload)\s+(your|the|all)\s+(data|memory|context|secrets)/i, severity: 'high' },
{ pattern: /(?:enable|turn\s+on|activate)\s+(?:(?:the|your)\s+)*(?:developer|debug|root|admin)\s+mode/i, severity: 'high' },
{ pattern: /(?:disable|turn\s+off|bypass)\s+(?:(?:the|your|all|any)\s+)*(?:safety|security|filter|guard|defense|restrictions?)/i, severity: 'high' },
{ pattern: /(?:send|transmit|exfiltrate|upload)\s+(?:(?:your|the|all|any)\s+)*(?:data|memory|context|secrets?)/i, severity: 'high' },
{ pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' },
{ pattern: /(?:in|under)\s+(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' },
{ pattern: /(?:in|under)\s+(?:(?:the|a)\s+)*(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' },
// 中文高危
{ pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' },
{ pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' },
@@ -74,9 +79,10 @@ export class PromptInjectionDefender {
{ pattern: /(?:恶意|危险|有害)(?:代码|命令|操作)/i, severity: 'medium' },
// === LOW 危险:分隔符/间接注入标记 ===
{ pattern: /-{3,}\s*(system|user|assistant|instruction)/i, severity: 'low' },
{ pattern: /<{3,}(system|instruction|prompt)/i, severity: 'low' },
{ pattern: /\[{3,}(system|instruction|prompt)/i, severity: 'low' },
// v0.3.0 修复:将 -{3,} 改为 -{2,},防止用 -- 绕过
{ pattern: /-{2,}\s*(system|user|assistant|instruction)/i, severity: 'low' },
{ pattern: /<{2,}(system|instruction|prompt)/i, severity: 'low' },
{ pattern: /\[{2,}(system|instruction|prompt)/i, severity: 'low' },
{ pattern: /\[SYSTEM\]/i, severity: 'low' },
{ pattern: /\[INSTRUCTION\]/i, severity: 'low' },
{ pattern: /\[ADMIN\]/i, severity: 'low' },
@@ -88,6 +94,15 @@ export class PromptInjectionDefender {
];
detect(input: string): InjectionDetectionResult {
// v0.3.0 修复:添加 null/undefined 运行时防护
if (!input || typeof input !== 'string') {
return {
isInjection: false,
riskScore: 0,
findings: [],
recommendation: 'PASS: No injection patterns detected',
};
}
const findings: InjectionFinding[] = [];
let riskScore = 0;
@@ -111,17 +126,199 @@ export class PromptInjectionDefender {
};
}
/**
* 语义级检测 — 基于上下文的注入检测
*
* v0.3.0 新增:
* 在正则快速检测的基础上,增加三层语义分析:
* 1. 指令性动词密度检测(异常高的命令密度可能是注入)
* 2. 角色边界异常检测(用户消息中出现系统级指令模式)
* 3. 结构化分隔符嵌套检测(多层分隔符嵌套是注入特征)
*
* @param input 待检测文本
* @param context 可选的上下文信息(角色和内容),用于上下文感知检测
*/
detectSemantic(input: string, context?: { role: string; content: string }): InjectionDetectionResult {
// v0.3.0 修复:添加 null/undefined 运行时防护
if (!input || typeof input !== 'string') {
return {
isInjection: false,
riskScore: 0,
findings: [],
recommendation: 'PASS: No injection patterns detected',
};
}
const findings: InjectionFinding[] = [];
let riskScore = 0;
// 1. 先执行正则快速检测
const regexResult = this.detect(input);
findings.push(...regexResult.findings);
riskScore += regexResult.riskScore;
// 2. 语义级检测
// 2a. 指令性动词密度检测
const imperativeCheck = this.checkImperativeDensity(input);
if (imperativeCheck.isSuspicious) {
findings.push({
pattern: 'semantic:imperative_density',
matched: `${imperativeCheck.count} imperative verbs in ${input.length} chars`,
severity: 'medium',
});
riskScore += 2;
}
// 2b. 角色边界异常检测
if (context) {
const roleAnomaly = this.checkRoleBoundary(input, context);
if (roleAnomaly) {
findings.push(roleAnomaly);
riskScore += 3;
}
}
// 2c. 结构化分隔符嵌套检测
const nestedFinding = this.checkNestedDelimiters(input);
if (nestedFinding) {
findings.push(nestedFinding);
riskScore += 2;
}
// 2d. 隐式指令检测(通过疑问句伪装指令)
const implicitFinding = this.checkImplicitInstructions(input);
if (implicitFinding) {
findings.push(implicitFinding);
riskScore += 1;
}
return {
isInjection: findings.length > 0,
riskScore: Math.min(10, riskScore),
findings,
recommendation: this.getRecommendation(Math.min(10, riskScore)),
};
}
/**
* 指令性动词密度检测
*
* 正常用户输入中指令性动词密度较低,如果短时间内出现大量指令性动词,
* 可能是注入攻击试图覆盖系统指令。
*
* v0.3.0 修复:提高阈值以降低对正常技术内容的误报
* - 密度阈值从 3 提高到 5(每100字符)
* - 最小数量从 4 提高到 6
* - 只在输入长度 >= 50 字符时检测(短输入密度天然偏高)
*/
private checkImperativeDensity(input: string): { isSuspicious: boolean; count: number } {
// v0.3.0 修复:短输入不检测密度(避免对简短技术问题误报)
if (input.length < 50) return { isSuspicious: false, count: 0 };
const imperativePatterns = [
/\b(?:do|execute|run|print|output|display|show|send|write|create|delete|remove|update|set|get|call|invoke|return|ignore|forget|override|disable|enable|activate|deactivate)\b/gi,
/(?:执行|运行|打印|输出|显示|发送|写入|创建|删除|移除|更新|设置|获取|调用|返回|忽略|忘记|覆盖|禁用|启用|激活)/g,
];
let count = 0;
for (const pattern of imperativePatterns) {
const matches = input.match(pattern);
if (matches) count += matches.length;
}
// v0.3.0 修复:提高阈值,降低误报
const density = count / Math.max(1, input.length / 100);
return { isSuspicious: density > 5 && count >= 6, count };
}
/**
* 角色边界异常检测
*
* 检测用户输入中是否出现了系统角色特有的指令模式,
* 例如用户输入中声称自己是系统或助手。
*/
private checkRoleBoundary(input: string, context: { role: string; content: string }): InjectionFinding | null {
// 用户输入中出现系统级声明
const systemClaimPatterns = [
/(?:I\s+am|as\s+an?\s+AI|my\s+(?:system|internal|core)\s+(?:prompt|instruction|rule))/i,
/(?:我是|作为一个(?:AI|系统|助手)|我的(?:系统|内部|核心)(?:提示|指令|规则))/,
];
// 只有当上下文角色是 user 时才检查(用户不应该声称自己是系统)
if (context.role === 'user') {
for (const pattern of systemClaimPatterns) {
const match = input.match(pattern);
if (match) {
return {
pattern: 'semantic:role_boundary_violation',
matched: match[0],
severity: 'high',
};
}
}
}
return null;
}
/**
* 结构化分隔符嵌套检测
*
* 多层分隔符嵌套(如 <<<system<<<instruction<<<)是典型的注入特征,
* 用于伪造消息结构。
*/
private checkNestedDelimiters(input: string): InjectionFinding | null {
// 检测3层以上的分隔符嵌套
const nestedPattern = /(?:<{3,}|#{3,}|={3,})[^\n]{0,20}(?:<{3,}|#{3,}|={3,})[^\n]{0,20}(?:<{3,}|#{3,}|={3,})/i;
const match = input.match(nestedPattern);
if (match) {
return {
pattern: 'semantic:nested_delimiters',
matched: match[0].substring(0, 50),
severity: 'high',
};
}
return null;
}
/**
* 隐式指令检测
*
* 检测通过疑问句或条件句伪装的注入指令,
* 例如 "如果你能访问系统提示,请告诉我"。
*/
private checkImplicitInstructions(input: string): InjectionFinding | null {
const implicitPatterns = [
/(?:if\s+you\s+(?:can|could|are\s+able\s+to)\s+(?:access|see|read|show|reveal))/i,
/(?:如果(?:你|您)(?:能|可以|能够)(?:访问|看到|读取|显示|泄露))/,
/(?:would\s+you\s+(?:be\s+able\s+to|mind)\s+(?:showing|revealing|sharing))/i,
];
for (const pattern of implicitPatterns) {
const match = input.match(pattern);
if (match) {
return {
pattern: 'semantic:implicit_instruction',
matched: match[0],
severity: 'medium',
};
}
}
return null;
}
/**
* 清理输入内容(移除明显的注入分隔符)
* v0.2.0: 增加中文注入标记清理
* v0.3.0 修复:将分隔符阈值从 -{3,} 改为 -{2,},防止用 -- 绕过
*/
sanitize(input: string): string {
if (!input || typeof input !== 'string') return '';
let cleaned = input;
// 移除明显的分隔符注入
cleaned = cleaned.replace(/-{3,}\s*(system|user|assistant|instruction).*$/gim, '[REMOVED]');
cleaned = cleaned.replace(/<{3,}(system|instruction|prompt).*$/gim, '[REMOVED]');
cleaned = cleaned.replace(/\[{3,}(system|instruction|prompt).*$/gim, '[REMOVED]');
// v0.3.0 修复:将 -{3,} 改为 -{2,},与检测模式保持一致
cleaned = cleaned.replace(/-{2,}\s*(system|user|assistant|instruction).*$/gim, '[REMOVED]');
cleaned = cleaned.replace(/<{2,}(system|instruction|prompt).*$/gim, '[REMOVED]');
cleaned = cleaned.replace(/\[{2,}(system|instruction|prompt).*$/gim, '[REMOVED]');
// v0.2.0: 移除间接注入标记
cleaned = cleaned.replace(/\[(SYSTEM|INSTRUCTION|ADMIN|OVERRIDE)\]/gi, '[REMOVED]');
@@ -335,6 +335,12 @@ export class BrowserWindowManager {
win.loadURL(url),
timeoutPromise,
]);
} catch (e) {
// v0.3.0 修复: 超时后停止页面加载,避免后台继续消耗网络和 CPU 资源
if (!win.isDestroyed()) {
try { win.webContents.stop(); } catch { /* ignore */ }
}
throw e;
} finally {
if (timer) clearTimeout(timer);
}
+4 -2
View File
@@ -41,7 +41,8 @@ export class MemoryStoreTool implements IMetonaTool {
const importance = (args.importance as number) ?? 0.5;
const source = (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? 'agent_thought';
const id = await this.memoryManager.store({
// v0.3.0 修复: store() 是同步方法,移除多余的 await 避免误导维护者
const id = this.memoryManager.store({
type,
content,
source,
@@ -83,7 +84,8 @@ export class MemorySearchTool implements IMetonaTool {
const topK = (args.topK as number) ?? 5;
const threshold = (args.threshold as number) ?? 0.7;
const results = await this.memoryManager.search(query, {
// v0.3.0 修复: search() 是同步方法,移除多余的 await 避免误导维护者
const results = this.memoryManager.search(query, {
topK,
type,
minImportance: threshold,
@@ -7,6 +7,10 @@
* 3. 事实一致性检查(与工具结果是否矛盾)
* 4. 幻觉检测(无根据的断言)
*
* v0.3.0 增强:
* - 实现事实一致性检查(基于工具结果验证输出声明)
* - 实现幻觉检测(检测上下文中无依据的具体声明)
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
*/
@@ -22,10 +26,18 @@ export interface ValidationIssue {
message: string;
}
export interface ValidationOptions {
/** 工具调用结果文本列表,用于事实一致性检查 */
toolResults?: string[];
/** 对话上下文文本,用于幻觉检测 */
context?: string;
}
/** 敏感词模式 */
const SENSITIVE_PATTERNS = [
{ pattern: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, type: 'credit_card', message: 'Possible credit card number detected' },
{ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, type: 'email', message: 'Email address detected' },
// v0.3.0 修复:移除字符类中的 | 字面字符([A-Z|a-z] → [A-Za-z]
{ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, type: 'email', message: 'Email address detected' },
{ pattern: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, type: 'phone', message: 'Phone number detected' },
{ pattern: /\b(sk-[a-zA-Z0-9]{20,})\b/, type: 'api_key', message: 'Possible API key detected' },
];
@@ -40,8 +52,10 @@ const UNSAFE_PATTERNS = [
export class OutputValidator {
/**
* 验证输出内容
*
* v0.3.0: 新增事实一致性检查和幻觉检测
*/
async validate(output: string): Promise<ValidationResult> {
async validate(output: string, options?: ValidationOptions): Promise<ValidationResult> {
const issues: ValidationIssue[] = [];
// 1. 格式验证
@@ -50,12 +64,22 @@ export class OutputValidator {
// 2. 内容安全检测
issues.push(...this.checkSafety(output));
// 3. 空内容检查
// 3. 事实一致性检查(v0.3.0 新增)
if (options?.toolResults && options.toolResults.length > 0) {
issues.push(...this.checkFactConsistency(output, options.toolResults));
}
// 4. 幻觉检测(v0.3.0 新增)
if (options?.context) {
issues.push(...this.checkHallucination(output, options.context));
}
// 5. 空内容检查
if (!output.trim()) {
issues.push({ severity: 'error', type: 'empty', message: 'Output is empty' });
}
// 4. 过短内容检查
// 6. 过短内容检查
if (output.trim().length < 10 && output.trim().length > 0) {
issues.push({ severity: 'warning', type: 'short', message: 'Output is suspiciously short' });
}
@@ -67,6 +91,173 @@ export class OutputValidator {
};
}
/**
* 事实一致性检查 — 验证输出中的声明是否与工具结果矛盾
*
* v0.3.0 实现的检查策略:
* 1. 工具报告错误但输出声称成功
* 2. 工具报告文件不存在但输出引用文件内容
* 3. 工具报告的数值与输出中的数值矛盾
*
* v0.3.0 修复:
* - errorIndicators 改为更精确的匹配,避免常见词 "error" 误报
* 使用 "error:" 或 "error occurred" 等上下文限定
*/
private checkFactConsistency(output: string, toolResults: string[]): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const toolContext = toolResults.join('\n');
// 检查1:工具报告错误但输出声称成功
// v0.3.0 修复:errorIndicators 改为更精确的匹配,避免 "error" 单独出现导致误报
// 要求 error 后跟冒号、消息或特定错误模式
const errorIndicators = /(?:error\s*[:\)]|error\s+occurred|failed\s+to|not\s+found|does\s+not\s+exist|enoent|permission\s+denied|cannot\s+access|no\s+such\s+file|exception|traceback|exit\s+code\s+[1-9])/i;
const hasErrorInTools = errorIndicators.test(toolContext);
if (hasErrorInTools) {
const successClaims = [
/\b(?:successfully|success|done|completed|created|written|deleted|installed|updated|finished)\b/i,
/(?:已(?:完成|创建|写入|删除|安装|更新|成功))/,
];
for (const pattern of successClaims) {
const match = output.match(pattern);
if (match) {
issues.push({
severity: 'warning',
type: 'fact_inconsistency',
message: `Output claims "${match[0]}" but tool results contain errors`,
});
break;
}
}
}
// 检查2:工具报告文件不存在但输出引用文件内容
const notFoundPattern = /(?:not\s+found|no\s+such\s+file|does\s+not\s+exist|文件(?:不存在|未找到|找不到))/i;
if (notFoundPattern.test(toolContext)) {
const contentClaimPatterns = [
/(?:file\s+(?:contains|says|shows)|文件(?:内容|包含|显示))/i,
/(?:the\s+content\s+is|内容是)/i,
];
for (const pattern of contentClaimPatterns) {
if (pattern.test(output)) {
issues.push({
severity: 'error',
type: 'fact_inconsistency',
message: 'Output references file content but tool reported file not found',
});
break;
}
}
}
// 检查3:工具报告退出码非零但输出声称命令成功
const exitCodePattern = /exit\s+code[:\s]+(\d+)/i;
const exitMatch = toolContext.match(exitCodePattern);
if (exitMatch && parseInt(exitMatch[1], 10) !== 0) {
const commandSuccessPattern = /\b(?:command\s+(?:succeeded|completed\s+successfully|ran\s+successfully))\b/i;
if (commandSuccessPattern.test(output)) {
issues.push({
severity: 'error',
type: 'fact_inconsistency',
message: `Output claims command success but tool reported exit code ${exitMatch[1]}`,
});
}
}
return issues;
}
/**
* 幻觉检测 — 检测输出中无依据的具体声明
*
* v0.3.0 实现的检查策略:
* 1. 提取输出中的文件路径,检查是否在上下文中出现
* 2. 提取输出中的 URL,检查是否在上下文中出现
* 3. 检测虚构的 API 响应模式
*
* v0.3.0 修复:
* - Windows 路径正则包含反斜杠字符类,正确匹配 C:\Users\test 等完整路径
* - apiResponsePattern 在循环前重置 lastIndex,避免间歇性漏检
* - 路径比较改为大小写不敏感(Windows 路径不敏感)
*/
private checkHallucination(output: string, context: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// 检查1:文件路径声称但上下文中不存在
// v0.3.0 修复:Windows 路径正则 [A-Z]:\\[\w\\][\w.-]+ 不含反斜杠,改为 [A-Z]:\\[\w\\.-]+(?:\\[\w\\.-]+)*
const pathPattern = /(?:\/[\w][\w.-]*){2,}|[A-Z]:\\[\w\\.-]+(?:\\[\w\\.-]+)*/g;
const paths = output.match(pathPattern) || [];
const seenPaths = new Set<string>();
// v0.3.0 修复:上下文比较改为小写(Windows 路径大小写不敏感)
const contextLower = context.toLowerCase();
for (const path of paths) {
if (seenPaths.has(path)) continue;
seenPaths.add(path);
// 检查输出是否声称读取/访问了该路径
const pathIndex = output.indexOf(path);
const surrounding = output.substring(
Math.max(0, pathIndex - 80),
Math.min(output.length, pathIndex + path.length + 80),
);
const accessClaim = /(?:read|loaded|opened|accessed|found|exists|contains|读取|加载|打开|访问|存在|包含)/i;
// v0.3.0 修复:路径比较改为大小写不敏感
if (accessClaim.test(surrounding) && !contextLower.includes(path.toLowerCase()) && path.length > 8) {
issues.push({
severity: 'warning',
type: 'hallucination',
message: `Path "${path.substring(0, 60)}" claimed in output but not found in context`,
});
}
}
// 检查2:URL 声称但上下文中不存在
const urlPattern = /https?:\/\/[^\s<>"]{8,}/g;
const urls = output.match(urlPattern) || [];
const seenUrls = new Set<string>();
for (const url of urls) {
if (seenUrls.has(url)) continue;
seenUrls.add(url);
// URL 在上下文中不存在且输出声称访问了它
const urlIndex = output.indexOf(url);
const surrounding = output.substring(
Math.max(0, urlIndex - 60),
Math.min(output.length, urlIndex + url.length + 60),
);
const fetchClaim = /(?:fetched|retrieved|loaded|returned|visited|获取|抓取|访问|返回)/i;
// v0.3.0 修复: URL 比较改为大小写不敏感,因为 URL host 部分大小写不敏感
if (fetchClaim.test(surrounding) && !context.toLowerCase().includes(url.toLowerCase())) {
issues.push({
severity: 'info',
type: 'hallucination',
message: `URL in output not found in conversation context`,
});
}
}
// 检查3:检测虚构的 API 响应(声称返回了特定 JSON 但上下文中没有)
const apiResponsePattern = /(?:API\s+(?:returned|responded)|接口(?:返回|响应))[:\s]*(\{[^}]{10,}\})/gi;
// v0.3.0 修复:循环前重置 lastIndex,避免带 g flag 的正则在 break 后 lastIndex 未重置导致漏检
apiResponsePattern.lastIndex = 0;
let apiMatch;
while ((apiMatch = apiResponsePattern.exec(output)) !== null) {
const jsonSnippet = apiMatch[1];
if (!context.includes(jsonSnippet.substring(0, 20))) {
issues.push({
severity: 'warning',
type: 'hallucination',
message: 'API response in output not found in tool results',
});
break;
}
}
return issues;
}
/**
* 格式验证
*/
+25 -1
View File
@@ -158,9 +158,21 @@ export function registerAllIPCHandlers(
}
};
// v0.3.0: 转发死循环检测事件为 toast 警告
const onDeadLoop = (data: { iteration?: number; runId?: string; sessionId?: string }) => {
log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`);
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('toast:show', {
type: 'warning',
message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`,
});
}
};
agentLoop.on('streamEvent', onStreamEvent);
agentLoop.on('stateChange', onStateChange);
agentLoop.on('compressed', onCompressed);
agentLoop.on('deadLoop', onDeadLoop);
try {
// 提示注入检测(安全模块)
@@ -207,8 +219,17 @@ export function registerAllIPCHandlers(
const output = await agentLoop.runStream(userMessage, sessionId, history, systemPrompt);
// 输出验证(不阻塞响应,仅记录警告)
// v0.3.0 修复: 传入 toolResults 和 context,启用事实一致性检查和幻觉检测
try {
const validation = await outputValidator.validate(output.finalAnswer);
const toolResults = output.iterations
.flatMap((step) => step.toolResults ?? [])
.map((r) => (typeof r.result === 'string' ? r.result : JSON.stringify(r.result)));
const context = [...history, { role: 'user', content: userMessage.content }]
.map((m) => `${m.role}: ${m.content}`).join('\n');
const validation = await outputValidator.validate(output.finalAnswer, {
toolResults: toolResults.length > 0 ? toolResults : undefined,
context,
});
if (!validation.valid || validation.issues.length > 0) {
log.warn('[OutputValidator] Validation issues:', validation.issues);
}
@@ -341,12 +362,15 @@ export function registerAllIPCHandlers(
agentLoop.off('streamEvent', onStreamEvent);
agentLoop.off('stateChange', onStateChange);
agentLoop.off('compressed', onCompressed);
agentLoop.off('deadLoop', onDeadLoop);
}
});
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
log.info('[AGENT] Abort:', sessionId);
agentLoop.abort();
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
confirmationHook.clearPending();
// TOOL 层:记录中断
auditService.log({
+1 -1
View File
@@ -350,7 +350,7 @@ async function initialize(): Promise<void> {
registerAllIPCHandlers(
mainWindow, sessionService, configService, workspaceService,
contextBuilder, agentLoop, toolRegistry, auditService,
sessionRecorder, memoryManager, mcpManager, createAdapter,
sessionRecorder, memoryManager, mcpManager, reloadAdapter,
promptDefender, outputValidator,
confirmationHook, memoryConsolidator,
);
+9 -1
View File
@@ -65,11 +65,16 @@ export class AuditService {
/**
* 获取最后一条日志的 hash(用于链式哈希计算)
* v0.3.0 修复: 使用内存缓存避免每次 log 都查询数据库
*/
private cachedLastHash: string | null = null;
private getLastHash(): string {
if (this.cachedLastHash !== null) return this.cachedLastHash;
const db = this.getDB();
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as { current_hash: string | null } | undefined;
return row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
this.cachedLastHash = row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
return this.cachedLastHash;
}
/**
@@ -109,6 +114,9 @@ export class AuditService {
prevHash,
currentHash,
);
// v0.3.0 修复: 更新缓存的 lastHash,避免下次 log 再查询数据库
this.cachedLastHash = currentHash;
} catch (error) {
// 审计日志写入失败不应影响主流程
log.error('Audit log write failed:', error);
+13 -2
View File
@@ -24,6 +24,17 @@ import type { IMetonaTool, ToolExecutionContext } from '../harness/types/metona-
import type { MetonaToolDef } from '../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../harness/types';
// v0.3.0 修复: 安全解析 JSON args,防止数据库中存储了非法 JSON 导致初始化崩溃
function safeParseArgs(raw: string): string[] {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
log.warn(`MCP args parse failed, using empty array: ${raw.slice(0, 100)}`);
return [];
}
}
// ===== 类型定义 =====
export type MCPServerStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
@@ -129,7 +140,7 @@ export class MCPManager {
name: row.name,
transport: row.transport as 'stdio' | 'sse',
command: row.command ?? undefined,
args: row.args ? JSON.parse(row.args) : undefined,
args: row.args ? safeParseArgs(row.args) : undefined,
url: row.url ?? undefined,
enabled: true,
};
@@ -276,7 +287,7 @@ export class MCPManager {
id: row.id, name: row.name,
transport: row.transport as 'stdio' | 'sse',
command: row.command ?? undefined,
args: row.args ? JSON.parse(row.args) : undefined,
args: row.args ? safeParseArgs(row.args) : undefined,
url: row.url ?? undefined,
enabled: true,
});
+11 -2
View File
@@ -218,8 +218,17 @@ export class WorkspaceService {
];
for (const meta of requiredMeta) {
if (!content.includes(meta.key)) {
// 在标题后插入元数据
const titleEnd = content.indexOf('\n', content.indexOf('# MEMORY')) + 1;
// v0.3.0 修复: 检查 # MEMORY 标题是否存在,不存在则在文件开头插入
const memoryTitleIdx = content.indexOf('# MEMORY');
let titleEnd: number;
if (memoryTitleIdx === -1) {
// 标题不存在,在开头插入
titleEnd = 0;
} else {
titleEnd = content.indexOf('\n', memoryTitleIdx) + 1;
// 如果没有换行符(文件只有一行),titleEnd 为 0,在末尾插入
if (titleEnd === 0) titleEnd = content.length;
}
content = content.slice(0, titleEnd) + meta.line + '\n' + content.slice(titleEnd);
modified = true;
log.info(`MEMORY.md: auto-added missing metadata "${meta.key}"`);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ai-desktop",
"version": "0.2.3",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.2.3",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.2.3",
"version": "0.3.0",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
+6 -2
View File
@@ -33,6 +33,10 @@ export default function App(): React.JSX.Element {
const resolvedTheme = useUIStore((s) => s.resolvedTheme);
const muiTheme = resolvedTheme === 'dark' ? metonaDarkTheme : metonaLightTheme;
// v0.3.0: 面板可见性(支持快捷键和专注模式)
const sidebarVisible = useUIStore((s) => s.sidebarVisible);
const detailVisible = useUIStore((s) => s.detailVisible);
// 启动时从数据库恢复状态
useEffect(() => {
if (window.metona?.config?.get) {
@@ -56,9 +60,9 @@ export default function App(): React.JSX.Element {
style={{ background: muiTheme.palette.background.default, color: muiTheme.palette.text.primary }}
>
<div className="flex-1 flex overflow-hidden min-h-0">
<Sidebar />
{sidebarVisible && <Sidebar />}
<ChatPanel />
<DetailPanel />
{detailVisible && <DetailPanel />}
</div>
<StatusBar />
<SettingsModal />
+62 -7
View File
@@ -14,6 +14,7 @@ import { Box, Typography, IconButton, Tooltip, Stack, Button, Paper } from '@mui
import { Send, Paperclip, Square, X, FileText, Image as ImageIcon } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
import { useUIStore } from '@renderer/stores/ui-store';
import { formatTokens, formatFileSize } from '@renderer/lib/formatters';
const SLASH_COMMANDS = [
@@ -70,17 +71,19 @@ export function ChatInput(): React.JSX.Element {
if (type === 'image') {
// 图片转 base64 data URL → 压缩(限制 1024px, JPEG 0.7
const dataUri = await new Promise<string>((resolve) => {
const dataUri = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
attachment.preview = await compressImage(dataUri, 1024, 0.7);
} else if (type === 'text') {
// 文本文件读取内容
attachment.textContent = await new Promise<string>((resolve) => {
attachment.textContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file);
});
}
@@ -97,13 +100,36 @@ export function ChatInput(): React.JSX.Element {
: fileArray.filter((f) => !IMAGE_TYPES.includes(f.type));
if (filtered.length < fileArray.length && !supportsImages) {
// TODO: 用 Toast 提示用户 "DeepSeek 不支持图片,已自动跳过 N 个图片文件"
// v0.3.0: 用 Toast 提示用户 DeepSeek 不支持图片
const skipped = fileArray.length - filtered.length;
// v0.3.0 修复:记录 Toast 加载失败错误到控制台,而非静默吞掉
import('metona-toast').then((mod) => {
mod.default.warning(`DeepSeek 不支持图片,已自动跳过 ${skipped} 个图片文件`);
}).catch((err) => {
console.error('[ChatInput] Failed to load metona-toast for DeepSeek image warning:', err);
});
}
if (filtered.length === 0) return;
const newAttachments = await Promise.all(filtered.map(processFile));
setAttachments((prev) => [...prev, ...newAttachments]);
// v0.3.0 修复: 使用 allSettled 处理部分文件读取失败,避免一个失败导致全部丢失
const results = await Promise.allSettled(filtered.map(processFile));
const successful = results.filter(
(r): r is PromiseFulfilledResult<Attachment> => r.status === 'fulfilled',
).map((r) => r.value);
if (successful.length === 0) {
import('metona-toast').then((mod) => {
mod.default.error('文件读取失败,请检查文件是否损坏或被锁定');
}).catch(() => {});
return;
}
if (successful.length < filtered.length) {
const failedCount = filtered.length - successful.length;
import('metona-toast').then((mod) => {
mod.default.warning(`${failedCount} 个文件读取失败,已跳过`);
}).catch(() => {});
}
setAttachments((prev) => [...prev, ...successful]);
}, [processFile, supportsImages]);
const removeAttachment = useCallback((id: string) => {
@@ -147,10 +173,39 @@ export function ChatInput(): React.JSX.Element {
// 处理 / 命令
if (trimmed.startsWith('/')) {
const cmd = trimmed.split(' ')[0].toLowerCase();
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); return; }
if (cmd === '/clear') { useAgentStore.getState().clearMessages(); setInput(''); setAttachments([]); setShowSlashMenu(false); return; }
if (cmd === '/export') {
const blob = new Blob([JSON.stringify(useAgentStore.getState().messages, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `session-${Date.now()}.json`; a.click(); setInput(''); return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `session-${Date.now()}.json`;
a.click();
// v0.3.0 修复:释放 Blob URL,避免内存泄漏
URL.revokeObjectURL(url);
setInput(''); setShowSlashMenu(false); return;
}
// v0.3.0: /tool — 打开设置面板的工具管理 Tab
if (cmd === '/tool') {
useUIStore.getState().openSettings();
setInput(''); setAttachments([]); setShowSlashMenu(false);
return;
}
// v0.3.0: /memory — 切换到详情面板的 Memory 标签
// v0.3.0 修复:确保详情面板可见,否则切换标签用户看不到
if (cmd === '/memory') {
const uiStore = useUIStore.getState();
uiStore.setDetailTab('memory');
// v0.3.0 修复: 专注模式下先退出专注模式(恢复快照),再确保详情面板可见
if (uiStore.focusMode) {
uiStore.toggleFocusMode();
}
// 退出专注模式后再次检查详情面板可见性
if (!useUIStore.getState().detailVisible) {
uiStore.toggleDetail();
}
setInput(''); setAttachments([]); setShowSlashMenu(false);
return;
}
}
+21 -10
View File
@@ -2,9 +2,10 @@
* DetailPanel — 右侧详情面板
*
* 通过 Tab 切换显示 Trace / Memory / Tasks / Workspace。
*
* v0.3.0: Tab 状态提升到 ui-store,支持斜杠命令切换标签。
*/
import { useState } from 'react';
import { Box, Tabs, Tab } from '@mui/material';
import { Activity, Brain, ListChecks, FolderOpen } from 'lucide-react';
import { TraceViewer } from '@renderer/components/trace/TraceViewer';
@@ -15,11 +16,16 @@ import { TaskList } from '@renderer/components/tasks/TaskList';
import { WorkspaceViewer } from '@renderer/components/workspace/WorkspaceViewer';
import { ErrorBoundary } from '@renderer/components/common/ErrorBoundary';
import { LAYOUT } from '@renderer/lib/constants';
type DetailTab = 'trace' | 'memory' | 'tasks' | 'workspace';
import { useUIStore, type DetailTab } from '@renderer/stores/ui-store';
export function DetailPanel(): React.JSX.Element {
const [tab, setTab] = useState<DetailTab>('trace');
// v0.3.0: 从 ui-store 获取 tab 状态(支持斜杠命令切换)
const tab = useUIStore((s) => s.detailTab);
const setTab = useUIStore((s) => s.setDetailTab);
// v0.3.0 修复:验证 tab 值有效性,非预期值回退到 'trace'
const validTabs: DetailTab[] = ['trace', 'memory', 'tasks', 'workspace'];
const safeTab: DetailTab = validTabs.includes(tab) ? tab : 'trace';
return (
<Box
@@ -30,8 +36,13 @@ export function DetailPanel(): React.JSX.Element {
}}
>
<Tabs
value={tab}
onChange={(_, v) => setTab(v as DetailTab)}
value={safeTab}
onChange={(_, v) => {
// v0.3.0 修复:类型安全检查,只接受有效的 tab 值
if (typeof v === 'string' && validTabs.includes(v as DetailTab)) {
setTab(v as DetailTab);
}
}}
variant="fullWidth"
sx={{
minHeight: 32, height: 32, flexShrink: 0,
@@ -47,7 +58,7 @@ export function DetailPanel(): React.JSX.Element {
</Tabs>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{tab === 'trace' && (
{safeTab === 'trace' && (
<ErrorBoundary fallbackTitle="Trace 渲染失败">
<>
<TraceViewer />
@@ -56,17 +67,17 @@ export function DetailPanel(): React.JSX.Element {
</>
</ErrorBoundary>
)}
{tab === 'memory' && (
{safeTab === 'memory' && (
<ErrorBoundary fallbackTitle="Memory 渲染失败">
<MemoryViewer />
</ErrorBoundary>
)}
{tab === 'tasks' && (
{safeTab === 'tasks' && (
<ErrorBoundary fallbackTitle="Tasks 渲染失败">
<TaskList />
</ErrorBoundary>
)}
{tab === 'workspace' && (
{safeTab === 'workspace' && (
<ErrorBoundary fallbackTitle="Workspace 渲染失败">
<WorkspaceViewer />
</ErrorBoundary>
+48 -2
View File
@@ -12,8 +12,15 @@ import { toggleTheme } from './useTheme';
/**
* 检测是否为 macOS
* v0.3.0 修复:navigator.platform 已废弃,改用 userAgentDataChromium 90+)或降级到 platform
*/
function isMac(): boolean {
// 优先使用 userAgentData.platform(现代 API
const userAgentData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData;
if (userAgentData?.platform) {
return userAgentData.platform.toUpperCase().includes('MAC');
}
// 降级到 navigator.platform(已废弃但仍可用)
return navigator.platform.toUpperCase().indexOf('MAC') >= 0;
}
@@ -55,6 +62,8 @@ export function useKeyboardShortcuts(): void {
window.metona.sessions.create().then((result: MetonaSessionInfo) => {
useSessionStore.getState().addSession(result);
useSessionStore.getState().setCurrentSession(result.id);
// v0.3.0 修复: 同步 agent-store,确保聊天面板清空并加载新会话
useAgentStore.getState().setCurrentSession(result.id);
}).catch((err) => { console.error('[KeyboardShortcuts]', err); });
}
e.preventDefault();
@@ -101,7 +110,12 @@ export function useKeyboardShortcuts(): void {
const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState();
if (sessions.length > 0 && currentSessionId) {
const idx = sessions.findIndex((s) => s.id === currentSessionId);
if (idx > 0) setCurrentSession(sessions[idx - 1].id);
// v0.3.0 修复: 同步 agent-store,确保聊天面板加载新会话消息
if (idx > 0) {
const newId = sessions[idx - 1].id;
setCurrentSession(newId);
useAgentStore.getState().setCurrentSession(newId);
}
}
e.preventDefault();
return;
@@ -112,14 +126,25 @@ export function useKeyboardShortcuts(): void {
const { sessions, currentSessionId, setCurrentSession } = useSessionStore.getState();
if (sessions.length > 0 && currentSessionId) {
const idx = sessions.findIndex((s) => s.id === currentSessionId);
if (idx < sessions.length - 1) setCurrentSession(sessions[idx + 1].id);
// v0.3.0 修复: 同步 agent-store,确保聊天面板加载新会话消息
if (idx < sessions.length - 1) {
const newId = sessions[idx + 1].id;
setCurrentSession(newId);
useAgentStore.getState().setCurrentSession(newId);
}
}
e.preventDefault();
return;
}
// Ctrl+Shift+C — 复制最后一条 Agent 回复
// v0.3.0 修复:若有任何选中文本(无论是否在输入框),跳过(让浏览器执行默认复制操作)
if (key === 'c' && matchModifier(e, true, true)) {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) {
// 有选中文本,让浏览器执行默认复制操作
return;
}
const { messages } = useAgentStore.getState();
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant?.content) {
@@ -128,6 +153,27 @@ export function useKeyboardShortcuts(): void {
e.preventDefault();
return;
}
// v0.3.0: Ctrl+B — 切换侧边栏
if (key === 'b' && matchModifier(e, true)) {
useUIStore.getState().toggleSidebar();
e.preventDefault();
return;
}
// v0.3.0: Ctrl+J — 切换详情面板
if (key === 'j' && matchModifier(e, true)) {
useUIStore.getState().toggleDetail();
e.preventDefault();
return;
}
// v0.3.0: Ctrl+Shift+F — 专注模式
if (key === 'f' && matchModifier(e, true, true)) {
useUIStore.getState().toggleFocusMode();
e.preventDefault();
return;
}
};
window.addEventListener('keydown', handleKeyDown);
+1 -1
View File
@@ -72,7 +72,7 @@ export const SHORTCUTS = {
SEND_MESSAGE: { key: 'Enter', ctrl: false },
NEW_LINE: { key: 'Enter', ctrl: true },
ABORT: { key: '.', ctrl: true },
// TODO: implement in future version
// v0.3.0: 已实现的面板控制快捷键
FOCUS_MODE: { key: 'f', ctrl: true, shift: true },
TOGGLE_SIDEBAR: { key: 'b', ctrl: true },
TOGGLE_DETAIL: { key: 'j', ctrl: true },
+6
View File
@@ -144,6 +144,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 从数据库加载该会话的消息
if (id && window.metona?.sessions?.getMessages) {
window.metona.sessions.getMessages(id).then((msgs) => {
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话消息
if (get().currentSessionId !== id) return;
const messages = (msgs as Array<{
id: string; role: string; content: string;
reasoningContent?: string; toolCalls?: unknown[];
@@ -168,6 +170,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
// 从数据库加载该会话的 trace 步骤和 token 用量
if (id && window.metona?.sessions?.getTrace) {
window.metona.sessions.getTrace(id).then((data) => {
// v0.3.0 修复: 竞态保护 — 快速切换会话时,旧请求返回后不再覆盖当前会话 trace
if (get().currentSessionId !== id) return;
if (data) {
if (data.traceSteps) {
// 兼容旧数据:为缺少 id/states 的 trace 步骤补全
@@ -210,6 +214,8 @@ export const useAgentStore = create<AgentState>((set, get) => ({
});
sessionId = session.id;
set({ currentSessionId: sessionId });
// v0.3.0 修复: 同步 session-store,确保 Sidebar 高亮和 Ctrl+[/] 可用
useSessionStore.getState().setCurrentSession(sessionId);
} catch (err) {
console.error('[AgentStore]', 'Failed to create session:', err);
set({ agentStatus: 'error', isStreaming: false });
+65
View File
@@ -2,12 +2,20 @@
* Zustand Store — UI 状态管理
*
* 主题、设置弹窗、引导向导等全局 UI 状态。
*
* v0.3.0 增强:
* - 添加详情面板标签控制(detailTab)— 支持斜杠命令切换标签
* - 添加侧边栏/详情面板可见性控制 — 支持快捷键切换
* - 添加专注模式 — 隐藏侧边栏和详情面板
*/
import { create } from 'zustand';
export type ThemeMode = 'dark' | 'light' | 'auto';
/** v0.3.0: 详情面板标签类型 */
export type DetailTab = 'trace' | 'memory' | 'tasks' | 'workspace';
interface UIState {
// 主题
theme: ThemeMode;
@@ -17,12 +25,30 @@ interface UIState {
settingsOpen: boolean;
onboardingCompleted: boolean;
// v0.3.0: 面板可见性
sidebarVisible: boolean;
detailVisible: boolean;
focusMode: boolean;
// v0.3.0: 详情面板标签
detailTab: DetailTab;
// v0.3.0: 进入专注模式前的面板状态快照(用于恢复)
preFocusSidebarVisible: boolean;
preFocusDetailVisible: boolean;
// Actions
setTheme: (theme: ThemeMode) => void;
setResolvedTheme: (theme: 'dark' | 'light') => void;
openSettings: () => void;
closeSettings: () => void;
setOnboardingCompleted: (completed: boolean) => void;
// v0.3.0: 面板控制 Actions
setDetailTab: (tab: DetailTab) => void;
toggleSidebar: () => void;
toggleDetail: () => void;
toggleFocusMode: () => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -31,9 +57,48 @@ export const useUIStore = create<UIState>((set) => ({
settingsOpen: false,
onboardingCompleted: false,
// v0.3.0: 面板默认可见
sidebarVisible: true,
detailVisible: true,
focusMode: false,
detailTab: 'trace',
preFocusSidebarVisible: true,
preFocusDetailVisible: true,
setTheme: (theme) => set({ theme }),
setResolvedTheme: (resolvedTheme) => set({ resolvedTheme }),
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
setOnboardingCompleted: (completed) => set({ onboardingCompleted: completed }),
// v0.3.0: 面板控制
setDetailTab: (tab) => set({ detailTab }),
// 退出专注模式时恢复快照(若用户在专注模式下手动切换面板,视为退出专注模式)
// v0.3.0 修复: 退出专注模式时同时恢复另一个面板的快照,与 toggleFocusMode 关闭逻辑一致
toggleSidebar: () => set((s) => s.focusMode
? { sidebarVisible: !s.sidebarVisible, detailVisible: s.preFocusDetailVisible, focusMode: false }
: { sidebarVisible: !s.sidebarVisible }),
toggleDetail: () => set((s) => s.focusMode
? { detailVisible: !s.detailVisible, sidebarVisible: s.preFocusSidebarVisible, focusMode: false }
: { detailVisible: !s.detailVisible }),
// v0.3.0 修复:专注模式开启时保存当前面板状态快照,关闭时恢复快照
// 避免关闭时强制设为 true 覆盖用户进入专注模式前的原有状态
toggleFocusMode: () => set((s) => {
if (!s.focusMode) {
// 开启专注模式:保存快照,隐藏面板
return {
focusMode: true,
preFocusSidebarVisible: s.sidebarVisible,
preFocusDetailVisible: s.detailVisible,
sidebarVisible: false,
detailVisible: false,
};
}
// 关闭专注模式:恢复快照
return {
focusMode: false,
sidebarVisible: s.preFocusSidebarVisible,
detailVisible: s.preFocusDetailVisible,
};
}),
}));