v0.12.3: 全面审计修复 + 稳定性加固 + 文档更新

P0 安全/稳定性修复 (6项):
- sqlite persist 改用临时文件+rename,I/O错误日志
- IPC fs 处理器加入 checkPathAllowed 路径验证
- handleCompress 命令注入修复 + 跨平台 (powershell/tar/zip/unzip)
- browser 全页截图简化,使用 capturePage full rect
- workspace 竞态条件修复
- editFile 5MB 大小限制

P1 安全/稳定性修复 (7项):
- 大小写不敏感搜索: query 同步转换小写
- web_fetch 无 content-length 时流式读取+10MB限制防OOM
- Windows SIGTERM 改用 taskkill /PID /T /F 强制终止
- startsWith 路径检查 Windows 大小写不敏感
- 安全黑名单扩展: 33个目录路径 + 30条危险命令 (Linux+Windows)
- plan_track stepIndex 改用 typeof === 'number' 精确检查
- MCP 前缀改用双下划线分隔 mcp_{server}__{tool} 防歧义

P2 代码质量修复 (8项):
- 修复 EXECUTING→THINKING 死循环Bug (转换表缺失)
- COMPRESSING 硬上限绕过时同步 _loopState 全局状态
- Abort 路径补齐 onDone 回调, 防止UI状态残留
- 幻觉检测从4条扩展到16条规则, 覆盖全部工具类别
- 移除 getTokenEfficiency 死代码
- 跨平台路径清理 (replace(/[\/]+$/, ''))
- 动态 import 加 try/catch, 失败自动批准不阻断
- #modelSelect 加 null 守卫, 防止运行时崩溃

文档 & UI 更新:
- README.md: 版本号/特性表/架构图/安全机制/工具清单全面刷新
- 帮助面板: 新增 Plan Mode、抗幻觉&稳定性章节, Agent Loop补8状态机
- 工具面板: plan_track卡片 + plan-mode徽章样式, 标题更新为42+1
- 工作空间面板宽度: 480px → 400px
- 源码注释移除所有版本标签, 仅保留4处必要位置
- 版本号 0.12.2 → 0.12.3
This commit is contained in:
紫影233
2026-06-23 17:06:43 +08:00
parent 20cbfbd169
commit 042e00e4a6
35 changed files with 3692 additions and 784 deletions
+11 -25
View File
@@ -149,37 +149,23 @@ export async function browserScreenshot(params: { full_page?: boolean; selector?
}
if (params.full_page) {
// 全页面截图:先滚动获取完整高度,截完恢复
// 全页面截图:一次性捕获整个页面矩形区域
const pageSize = await win.webContents.executeJavaScript(`
(() => {
const w = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, window.innerWidth);
const h = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, window.innerHeight);
const origScroll = { x: window.scrollX, y: window.scrollY };
window.scrollTo(0, 0);
return { width: w, height: h, origScroll };
return { width: w, height: h };
})()
`, true) as { width: number; height: number; origScroll: { x: number; y: number } };
`, true) as { width: number; height: number };
// 分块截图拼接(超过窗口高度的页面)
const viewHeight = 800;
const chunks: Buffer[] = [];
for (let y = 0; y < pageSize.height; y += viewHeight) {
await win.webContents.executeJavaScript(`window.scrollTo(0, ${y})`, true);
await new Promise(r => setTimeout(r, 200));
const image = await win.webContents.capturePage({
x: 0, y: 0,
width: pageSize.width,
height: Math.min(viewHeight, pageSize.height - y)
});
chunks.push(image.toPNG());
}
// 恢复滚动位置
await win.webContents.executeJavaScript(`window.scrollTo(${pageSize.origScroll.x}, ${pageSize.origScroll.y})`, true);
// 拼接所有截图(简单合并 base64)
const fullPng = Buffer.concat(chunks).toString('base64');
sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${fullPng.length} chars`);
return { success: true, png: fullPng, width: pageSize.width, height: pageSize.height };
const image = await win.webContents.capturePage({
x: 0, y: 0,
width: pageSize.width,
height: pageSize.height
});
const buffer = image.toPNG();
sendLog('success', `🌐 browser 截图(全页)`, `${pageSize.width}x${pageSize.height}, ${buffer.length} bytes`);
return { success: true, png: buffer.toString('base64'), width: pageSize.width, height: pageSize.height };
}
// 视口截图(默认)
+15 -6
View File
@@ -87,8 +87,14 @@ function persist(): void {
try {
const data = db.export();
const buf = Buffer.from(data);
fs.writeFileSync(dbPath, buf);
} catch { /* 静默失败,不阻断主流程 */ }
// 先写临时文件再 rename,避免写一半崩溃导致数据库损坏
const tmpPath = dbPath + '.tmp';
fs.writeFileSync(tmpPath, buf);
fs.renameSync(tmpPath, dbPath);
} catch (err) {
// 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获)
console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`);
}
}
/** 初始化数据库(异步,需加载 WASM) */
@@ -200,6 +206,7 @@ export async function initDatabase(): Promise<SQL.Database> {
action_input TEXT,
observation TEXT,
loop_count INTEGER,
error_pattern TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
@@ -210,7 +217,8 @@ export async function initDatabase(): Promise<SQL.Database> {
// 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON)
try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ }
// 兼容迁移:为已有 messages 表补充 prompt_eval_count
// 兼容迁移:v0.12.0 — 为已有 traces 表补充 error_pattern
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
// 尝试创建 FTS5 全文搜索(可选,sql.js 默认 WASM 可能不包含 FTS5
@@ -305,6 +313,7 @@ export interface TraceRow {
action_input: string | null;
observation: string | null;
loop_count: number | null;
error_pattern: string | null;
created_at: number;
}
@@ -481,9 +490,9 @@ export function getToolCallsBySession(sessionId: string): ToolCallRow[] {
// ─── Traces CRUD ───
export function saveTrace(trace: TraceRow): string {
runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.created_at]
runExec(getDb(), `INSERT OR REPLACE INTO traces (id, session_id, step_index, thought, action, action_input, observation, loop_count, error_pattern, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[trace.id, trace.session_id, trace.step_index, trace.thought, trace.action, trace.action_input, trace.observation, trace.loop_count, trace.error_pattern, trace.created_at]
);
persist();
return trace.id;
+7 -1
View File
@@ -55,7 +55,7 @@ import {
} from './tool-handlers.js';
import { browserOpen, browserScreenshot, browserEvaluate, browserExtract, browserClick, browserType, browserScroll, browserClose, browserWait } from './browser.js';
import { startServer, stopServer, stopAllServers, callTool, getAllTools, getServerStatuses, refreshTools, setMCPTimeout } from './mcp-manager.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs } from './tool-security.js';
import { getAllowedDirs, getBlockedDirs, setAllowedDirs, checkPathAllowed } from './tool-security.js';
import { startProcess, killProcess, getWorkspaceDir, setWorkspaceDir, listWorkspaceDir } from './workspace.js';
import { setHTTPTimeout } from './tool-handlers-system.js';
import { execFile } from 'child_process';
@@ -132,6 +132,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
try {
const check = checkPathAllowed(path.resolve(filePath), 'read');
if (!check.ok) return { success: false, error: check.reason };
const content = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, content, name: path.basename(filePath) };
} catch (err) {
@@ -141,6 +143,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:readFileBase64', async (_, filePath: string) => {
try {
const check = checkPathAllowed(path.resolve(filePath), 'read');
if (!check.ok) return { success: false, error: check.reason };
const buf = await fs.promises.readFile(filePath);
return { success: true, content: buf.toString('base64'), size: buf.length, name: path.basename(filePath) };
} catch (err) {
@@ -150,6 +154,8 @@ export async function setupIPC(): Promise<void> {
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string, encoding?: string) => {
try {
const check = checkPathAllowed(path.resolve(filePath), 'write');
if (!check.ok) return { success: false, error: check.reason };
await fs.promises.writeFile(filePath, content, (encoding as BufferEncoding) || 'utf-8');
return { success: true };
} catch (err) {
+1 -1
View File
@@ -277,7 +277,7 @@ export function getAllTools(): MCPToolInfo[] {
tools.push({
serverName,
name: tool.name,
fullName: `mcp_${serverName}_${tool.name}`,
fullName: `mcp_${serverName}__${tool.name}`,
description: `[MCP:${serverName}] ${tool.description || ''}`,
inputSchema: tool.inputSchema
});
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.11.11',
message: 'Metona Ollama Desktop v0.12.3',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+5 -1
View File
@@ -357,7 +357,8 @@ export async function handleSearchFiles(params: { path: string; query: string; s
if (searchType === 'filename' || searchType === 'both') {
const fileName = path.basename(filePath);
const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase();
if (nameToCheck.includes(query)) {
const queryToCheck = caseSensitive ? query : query.toLowerCase();
if (nameToCheck.includes(queryToCheck)) {
results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] });
totalMatches++;
continue;
@@ -471,6 +472,9 @@ export async function handleEditFile(params: { path: string; old_text: string; n
if (!allowed.ok) return { success: false, error: allowed.reason };
const content = await fs.readFile(filePath, 'utf-8');
if (content.length > 5 * 1024 * 1024) {
return { success: false, error: `文件过大 (${(content.length / 1024 / 1024).toFixed(1)}MB),最大支持 5MB` };
}
let replaceCount: number;
let newContent: string;
+69 -15
View File
@@ -375,7 +375,34 @@ export async function handleWebFetch(params: { url: string; max_chars?: number;
return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` };
}
let text = await resp.text();
// 无 content-length 头时,流式读取并限制最大体积(防 OOM)
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
let text: string;
if (!contentLength && resp.body) {
const reader = resp.body.getReader();
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.length;
if (totalSize > MAX_BODY_SIZE) {
reader.cancel();
return { success: false, error: `页面过大 (超过 10MB),已中止下载` };
}
chunks.push(value);
}
const decoder = new TextDecoder();
text = decoder.decode(Buffer.concat(chunks));
} else if (!contentLength) {
// 无 body stream 回退,但仍需限制
text = await resp.text();
if (text.length > MAX_BODY_SIZE) {
return { success: false, error: `页面过大 (${(text.length / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` };
}
} else {
text = await resp.text();
}
const isHTML = contentType.includes('html');
if (isHTML) {
text = htmlToText(text);
@@ -1163,6 +1190,7 @@ export async function handleDownloadFile(params: { url: string; destination: str
export async function handleCompress(params: { action: string; path: string; destination?: string; format?: string }): Promise<ToolResult> {
try {
const format = params.format || 'tar.gz';
const isWin = process.platform === 'win32';
if (params.action === 'create') {
const srcPath = resolvePath(params.path);
@@ -1176,13 +1204,28 @@ export async function handleCompress(params: { action: string; path: string; des
if (!destCheck.ok) return { success: false, error: destCheck.reason };
return new Promise((resolve) => {
const cmd = format === 'zip'
? `zip -r "${destPath}" "${path.basename(srcPath)}"`
: `tar czf "${destPath}" "${path.basename(srcPath)}"`;
const proc = spawn('bash', ['-c', cmd], { cwd: path.dirname(srcPath), stdio: ['pipe', 'pipe', 'pipe'] });
let child;
const srcName = path.basename(srcPath);
const cwd = path.dirname(srcPath);
if (format === 'zip') {
if (isWin) {
// Windows: PowerShell Compress-Archive
child = spawn('powershell', [
'-NoProfile', '-Command',
`Compress-Archive -Path '${srcName}' -DestinationPath '${destPath}' -Force`
], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
} else {
child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
}
} else {
// tar.gz: Windows 10+ has tar, Unix has tar
child = spawn('tar', ['czf', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
}
let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
child.on('close', (code) => {
if (code === 0) {
fs.stat(destPath).then(s => {
resolve({ success: true, action: 'create', archive: destPath, size: s.size });
@@ -1191,7 +1234,7 @@ export async function handleCompress(params: { action: string; path: string; des
resolve({ success: false, error: stderr || `压缩失败 (exit ${code})` });
}
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
child.on('error', (err) => resolve({ success: false, error: err.message }));
});
} else {
// extract
@@ -1206,20 +1249,31 @@ export async function handleCompress(params: { action: string; path: string; des
await fs.mkdir(destDir, { recursive: true });
return new Promise((resolve) => {
let child;
const isZip = archivePath.endsWith('.zip');
const cmd = isZip
? `unzip -o "${archivePath}" -d "${destDir}"`
: `tar xzf "${archivePath}" -C "${destDir}"`;
const proc = spawn('bash', ['-c', cmd], { stdio: ['pipe', 'pipe', 'pipe'] });
if (isZip) {
if (isWin) {
child = spawn('powershell', [
'-NoProfile', '-Command',
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`
], { stdio: ['pipe', 'pipe', 'pipe'] });
} else {
child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
}
} else {
child = spawn('tar', ['xzf', archivePath, '-C', destDir], { stdio: ['pipe', 'pipe', 'pipe'] });
}
let stderr = '';
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
proc.on('close', (code) => {
child.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
child.on('close', (code) => {
resolve(code === 0
? { success: true, action: 'extract', destination: destDir }
: { success: false, error: stderr || `解压失败 (exit ${code})` }
);
});
proc.on('error', (err) => resolve({ success: false, error: err.message }));
child.on('error', (err) => resolve({ success: false, error: err.message }));
});
}
} catch (err) {
+23 -1
View File
@@ -21,15 +21,28 @@ let allowedDirs: string[] = [
/** 永久禁止的目录 */
const BLOCKED_DIRS: string[] = [
// Linux/macOS 系统目录
'/etc', '/sys', '/proc', '/dev', '/boot', '/root',
'C:\\Windows', 'C:\\Program Files', 'C:\\ProgramData',
'/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/lib',
'/var/log', '/var/run', '/var/spool', '/var/mail',
'/tmp/.X11-unix', '/tmp/.font-unix',
// Windows 系统目录
'C:\\Windows', 'C:\\Windows\\System32', 'C:\\Windows\\SysWOW64',
'C:\\Windows\\System', 'C:\\Windows\\WinSxS',
'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData',
// 用户敏感目录
path.join(HOME, '.ssh'),
path.join(HOME, '.gnupg'),
path.join(HOME, '.aws'),
path.join(HOME, '.azure'),
path.join(HOME, '.config'),
path.join(HOME, '.kube'),
path.join(HOME, 'AppData'),
];
/** 命令黑名单 */
const BLOCKED_COMMANDS: string[] = [
// POSIX 危险命令
'rm -rf /', 'rm -rf /*', ':(){ :|:& };:',
'mkfs', 'dd if=', 'wipefs', 'shred',
'shutdown', 'reboot', 'poweroff', 'halt',
@@ -39,6 +52,15 @@ const BLOCKED_COMMANDS: string[] = [
'systemctl enable', 'systemctl disable',
'curl | sh', 'wget | sh', 'curl | bash', 'wget | bash',
'eval', 'exec >',
// Windows 危险命令
'rmdir /s', 'del /f', 'del /s', 'del /q',
'format', 'diskpart',
'reg add', 'reg delete', 'reg import', 'reg export',
'sc stop', 'sc delete', 'sc config',
'net user', 'net localgroup', 'net share',
'icacls', 'takeown', 'cacls',
'bcdedit', 'wmic', 'rundll32',
'schtasks /create', 'schtasks /delete',
];
export interface CheckResult {
+19 -4
View File
@@ -142,7 +142,12 @@ export function killProcess(id: string): boolean {
return false;
}
try {
proc.kill('SIGTERM');
// Windows 不支持 POSIX 信号,需用 taskkill 强制终止进程树
if (process.platform === 'win32' && proc.pid) {
spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']);
} else {
proc.kill('SIGTERM');
}
activeProcesses.delete(id);
sendLog('info', `🖥️ 进程已终止`, `ID: ${id}`);
return true;
@@ -156,7 +161,13 @@ export function killProcess(id: string): boolean {
/** 停止所有进程(窗口关闭时调用) */
export function killAllProcesses(): void {
for (const [id, proc] of activeProcesses) {
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
try {
if (process.platform === 'win32' && proc.pid) {
spawn('taskkill', ['/PID', String(proc.pid), '/T', '/F']);
} else {
proc.kill('SIGTERM');
}
} catch { /* ignore */ }
}
activeProcesses.clear();
}
@@ -166,8 +177,12 @@ export function listWorkspaceDir(dirPath?: string): { success: boolean; entries?
try {
const targetDir = dirPath ? path.resolve(dirPath) : _workspaceDir;
// 安全检查:必须在 workspace 范围内
if (!targetDir.startsWith(_workspaceDir) && !targetDir.startsWith(DEFAULT_WORKSPACE_DIR)) {
// 安全检查:必须在 workspace 范围内Windows 路径大小写不敏感)
const isWin = process.platform === 'win32';
const targetCheck = isWin ? targetDir.toLowerCase() : targetDir;
const wsCheck = isWin ? _workspaceDir.toLowerCase() : _workspaceDir;
const defaultCheck = isWin ? DEFAULT_WORKSPACE_DIR.toLowerCase() : DEFAULT_WORKSPACE_DIR;
if (!targetCheck.startsWith(wsCheck) && !targetCheck.startsWith(defaultCheck)) {
return { success: false, error: '只能浏览工作空间目录内的文件' };
}
+2 -2
View File
@@ -254,7 +254,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '✂️', get_file_info: '️', tree: '🌳', download_file: '⬇️',
diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚',
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖',
memory_search: '🧠', memory_add: '💾', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
@@ -265,7 +265,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件',
diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取',
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
memory_search: '搜索记忆', memory_add: '添加记忆', session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
+6 -5
View File
@@ -55,11 +55,12 @@ export function initHistoryModal(): void {
const target = (e.target as HTMLElement).closest('[data-id]') as HTMLElement;
if (!target) return;
const sessionId = target.dataset.id!;
const db = state.get<ChatDB>(KEYS.DB);
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
if (target.classList.contains('btn-delete-session')) {
if (await showConfirm('确定删除此会话?', '删除会话')) {
await db!.deleteSession(sessionId);
await db.deleteSession(sessionId);
logSession('删除', sessionId);
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
if (currentSession && currentSession.id === sessionId) {
@@ -68,13 +69,13 @@ export function initHistoryModal(): void {
loadHistory();
}
} else if (target.classList.contains('btn-export-md')) {
const session = await db!.getSession(sessionId);
const session = await db.getSession(sessionId);
if (session) exportAsMarkdown(session);
} else if (target.classList.contains('btn-export-html')) {
const session = await db!.getSession(sessionId);
const session = await db.getSession(sessionId);
if (session) exportAsHtml(session);
} else if (target.classList.contains('btn-export-txt')) {
const session = await db!.getSession(sessionId);
const session = await db.getSession(sessionId);
if (session) exportAsTxt(session);
} else if (target.classList.contains('history-info') || target.classList.contains('history-item')) {
loadHistorySession(sessionId);
+52 -3
View File
@@ -20,13 +20,14 @@ import { estimateTokens } from '../services/context-manager.js';
import { searchMemories, buildMemoryContext, markMemoryUsed, extractMemoriesFromConversation, isMemoryEnabled } from '../services/memory-manager.js';
import { showToolConfirm } from './tool-confirm-modal.js';
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord } from '../types.js';
import type { ChatSession, ChatMessage, OllamaStreamChunk, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
let chatInputEl: HTMLTextAreaElement;
let btnSendEl: HTMLButtonElement;
let imagePreviewEl: HTMLElement;
let filePreviewEl: HTMLElement;
let videoPreviewEl: HTMLElement;
let planToggleCheckbox: HTMLInputElement;
let pendingImages: Array<{ name: string; base64: string }> = [];
let pendingFiles: Array<{ name: string; content: string; language: string; size: number }> = [];
let pendingVideoFrames: Array<{ name: string; base64: string; timestampSeconds: number }> = [];
@@ -150,6 +151,21 @@ export function initInputArea(): void {
renderFilePreviews();
}
});
// ── v0.12.0 Plan Mode 切换 ──
const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement;
if (planToggleEl) {
planToggleCheckbox = planToggleEl;
planToggleEl.addEventListener('change', () => {
const mode: AgentMode = planToggleEl.checked ? 'plan' : 'auto';
state.set('agentMode', mode);
// Plan Mode 切换时注册/移除 plan_track 工具
import('../services/tool-registry.js').then(({ setPlanModeActive }) => {
setPlanModeActive(mode === 'plan');
});
logInfo(`Agent 模式切换: ${mode === 'plan' ? 'Plan(先规划后执行)' : 'Auto(直接执行)'}`);
});
}
}
function autoResizeTextarea(): void {
@@ -211,13 +227,14 @@ function compressImage(base64: string): Promise<{ compressed: string; origBytes:
async function handleImagePaths(paths: string[]): Promise<void> {
const bridge = getBridge();
if (!bridge) return;
const maxSize = 20 * 1024 * 1024;
// 并行读取所有图片
const results = await Promise.all(paths.map(async (filePath) => {
const name = filePath.split(/[/\\]/).pop() || filePath;
try {
const result = await bridge!.fs.readFileBase64(filePath);
const result = await bridge.fs.readFileBase64(filePath);
if (!result.success) return { name, error: result.error };
const size = result.size ?? 0;
if (size > maxSize) return { name, error: `超过 20MB 限制` };
@@ -372,13 +389,14 @@ function renderImagePreviews(): void {
async function handleTextFilePaths(paths: string[]): Promise<void> {
const bridge = getBridge();
if (!bridge) return;
// 并行读取所有文件
const results = await Promise.all(paths.map(async (filePath) => {
const name = filePath.split(/[/\\]/).pop() || filePath;
if (!isTextFile(name)) return { name, error: `不是支持的文本/代码文件` };
try {
const result = await bridge!.fs.readFile(filePath);
const result = await bridge.fs.readFile(filePath);
if (!result.success) return { name, error: result.error };
const content = result.content as string;
const size = new TextEncoder().encode(content).length;
@@ -561,6 +579,19 @@ async function handleRetry(): Promise<void> {
updateMessageToolRecord(name, 'error', { success: false, error });
},
onConfirmTool: async (call) => showToolConfirm(call),
onPlanReady: async (plan: string, steps: string[]) => {
try {
const { showHtmlConfirm } = await import('./prompt-modal.js');
const stepsHtml = steps.length > 0
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s: string, i: number) => `&nbsp;&nbsp;${i + 1}. ${s}`).join('<br>')
: '';
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
return showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true; // 加载失败时自动批准,不阻断执行
}
},
onDone: async (finalContent, toolRecords, loopStats) => {
retryContent = finalContent;
if (retryIterations > 0) {
@@ -1237,6 +1268,24 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
onConfirmTool: async (call) => {
return showToolConfirm(call);
},
/** v0.12.1: Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
onPlanReady: async (plan: string, steps: string[]) => {
try {
const { showHtmlConfirm } = await import('./prompt-modal.js');
const stepsHtml = steps.length > 0
? '<br><br><strong>📋 执行步骤:</strong><br>' + steps.map((s, i) => `&nbsp;&nbsp;${i + 1}. ${s}`).join('<br>')
: '';
const html = `<div>${safeMarkdown(plan.slice(0, 1500))}${plan.length > 1500 ? '<br>…' : ''}${stepsHtml}</div>`;
const approved = await showHtmlConfirm(html, '📋 Plan Mode — 确认执行计划', true);
if (approved) {
logInfo('Plan Mode: 用户批准计划');
}
return approved;
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true; // 加载失败时自动批准,不阻断执行
}
},
onDone: async (finalContent, toolRecords, loopStats) => {
assistantContent = finalContent;
+17
View File
@@ -124,6 +124,23 @@ export function showPrompt(options: {
});
}
/** 显示自定义 confirm(支持 HTML),返回 true/false */
export function showHtmlConfirm(html: string, title = '确认', wide = false): Promise<boolean> {
ensureModal();
return new Promise((resolve) => {
resolveFn = (val) => resolve(val !== null);
titleEl!.textContent = title;
bodyEl!.innerHTML = `<div style="color:var(--text-secondary);font-size:13px;line-height:1.6;">${html}</div>`;
inputEl = null;
overlayEl!.style.display = '';
// 宽屏模式:Plan Mode 确认框需要更大空间
const modalEl = overlayEl!.querySelector('.modal') as HTMLElement;
if (modalEl) modalEl.style.maxWidth = wide ? '700px' : '';
okBtn!.focus();
});
}
/** 显示自定义 confirm,返回 true/false */
export function showConfirm(message: string, title = '确认'): Promise<boolean> {
ensureModal();
+34 -5
View File
@@ -182,11 +182,13 @@ export function initSettingsModal(): void {
});
document.querySelector('#btnReleaseVRAM')!.addEventListener('click', async () => {
const api = state.get<OllamaAPI>(KEYS.API);
const model = (document.querySelector('#modelSelect') as HTMLSelectElement).value;
const api = state.get<OllamaAPI | null>(KEYS.API, null);
if (!api) { showToast('Ollama API 未连接', 'warning'); return; }
const sel = document.querySelector('#modelSelect') as HTMLSelectElement | null;
const model = sel?.value || '';
logInfo('释放显存', model || '所有模型');
try {
await api!.chat({ model: model || 'any', messages: [], keep_alive: 0 });
await api.chat({ model: model || 'any', messages: [], keep_alive: 0 });
showToast('显存已释放', 'success');
logSuccess('显存已释放');
updateRunningModels();
@@ -198,9 +200,10 @@ export function initSettingsModal(): void {
document.querySelector('#btnClearAllHistory')!.addEventListener('click', async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) { showToast('数据库未就绪', 'warning'); return; }
if (await showConfirm('确定清空所有历史记录?此操作不可恢复!', '清空历史')) {
logWarn('清空所有历史记录');
await db!.clearAll();
await db.clearAll();
(document.querySelector('#btnNewChat') as HTMLElement).click();
showToast('已清空所有历史记录', 'success');
logSuccess('历史记录已清空');
@@ -305,6 +308,19 @@ export function openSettingsModal(): void {
populateSubAgentModels();
updateMemoryVectorStatus();
loadTimeoutSettings();
loadWatchdogSetting();
}
/** 加载看门狗设置到输入框 */
async function loadWatchdogSetting(): Promise<void> {
const db = state.get<ChatDB | null>(KEYS.DB);
if (!db) return;
const ms = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
const el = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
if (!el) return;
if (ms === 0) el.value = '0';
else if (ms > 0) el.value = String(Math.round(ms / 60_000));
else el.value = '';
}
export function closeSettingsModal(): void {
@@ -494,6 +510,18 @@ const saveSubAgentTimeout = debounce(async () => {
}, 500);
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
// ── v0.12.1 看门狗超时(分钟,0=禁用)──
const saveLoopWatchdog = debounce(async () => {
const db = state.get<ChatDB | null>(KEYS.DB);
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
const minutes = val ? parseInt(val) : -1;
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
state.set('loopWatchdogMs', ms);
if (db) await db.saveSetting('loopWatchdogMs', ms);
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
}, 500);
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
function updateMemoryVectorStatus(): void {
const statusEl = document.querySelector('#memoryVectorStatus');
if (!statusEl) return;
@@ -520,7 +548,8 @@ async function importSessions(filePath: string): Promise<void> {
try {
const bridge = window.metonaDesktop;
const result = await bridge!.fs.readFileBase64(filePath);
if (!bridge) { showToast('仅桌面端支持导入', 'warning'); return; }
const result = await bridge.fs.readFileBase64(filePath);
if (!result.success) {
showToast(`读取文件失败: ${result.error}`, 'error');
return;
+2 -2
View File
@@ -965,7 +965,7 @@ function getToolDisplayName(name: string): string {
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派', plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
@@ -982,7 +982,7 @@ function getToolIcon(name: string): string {
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '✏️', memory_remove: '🗑️',
session_list: '📋', session_read: '📖', spawn_task: '🤖',
session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
+5 -1
View File
@@ -12,7 +12,11 @@ function isDesktop(): boolean {
/** 桌面端 DB 桥接(isDesktop 已检查,安全断言非空) */
function dbBridge() {
return window.metonaDesktop!.db!;
const bridge = window.metonaDesktop;
if (!bridge?.db) {
throw new Error('DB bridge 不可用:非桌面环境或 SQLite 未初始化');
}
return bridge.db;
}
export class ChatDB {
+24 -5
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.11.11</span>
<span class="app-version">v0.12.3</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -174,6 +174,14 @@
</svg>
</span>
</label>
<label class="plan-toggle" id="planToggleWrap" title="Plan 模式:先规划后执行">
<input type="checkbox" id="togglePlan">
<span class="plan-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="12" y2="17"/>
</svg>
</span>
</label>
<button class="send-btn" id="btnSend">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
@@ -371,6 +379,11 @@
<span style="font-size:13px;">超时(秒)</span>
<input type="number" id="inputSubAgentTimeout" class="setting-input" placeholder="300" min="0" max="3600" step="30" style="width:80px;">
</div>
<div style="display:flex;gap:16px;align-items:center;margin-top:8px;">
<span style="font-size:13px;">看门狗(分)</span>
<input type="number" id="inputLoopWatchdog" class="setting-input" placeholder="30" min="0" max="120" step="5" style="width:80px;">
<span class="text-muted" style="font-size:11px;">0=禁用,默认30分钟</span>
</div>
</div>
<div class="setting-group">
<label class="setting-label">显存管理</label>
@@ -435,10 +448,12 @@
<div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>42 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>6 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read</li><li><strong>子代理</strong>1 个):spawn_task</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>42+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>6 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read</li><li><strong>子代理</strong>1 个):spawn_task</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop v0.11.11 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
<div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查)→ 钩子校验</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复</li><li><strong>16 条幻觉检测规则</strong>:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:消息数超过阈值时强制触发压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}__{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 KeyHTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
<div class="help-section"><h4>📊 Token 实时监控</h4><ul><li>点击顶部 📊 按钮打开 Token 监控仪表盘</li><li><strong>全局统计</strong> — 跨会话累计 Token 消耗,柱状图按会话展示趋势</li><li><strong>当前会话</strong> — 实时显示本轮对话的 Token 消耗,按轮次展示明细</li><li>每 2 秒自动刷新数据,支持输入/输出分色显示</li></ul></div>
@@ -453,7 +468,7 @@
<div class="modal-overlay" id="toolsModal" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3>🔧 工具面板(42 个)</h3>
<h3>🔧 工具面板(42 个 + 1 Plan 专属</h3>
<button class="icon-btn" id="btnCloseTools">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
@@ -639,6 +654,10 @@
<div class="tool-card-header"><span class="tool-card-icon">🔐</span><span class="tool-card-name">hash</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">哈希计算(MD5/SHA1/SHA256/SHA384/SHA512</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">plan_track</span><span class="tool-card-badge plan-mode">Plan</span></div>
<div class="tool-card-desc">Plan 模式专属:追踪执行进度,标记/查看步骤完成状态</div>
</div>
</div>
</div>
</div>
+19 -1
View File
@@ -31,6 +31,8 @@ import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, swit
import { initLogPanel, addLog } from './services/log-service.js';
import { logInfo, logSuccess, logError, logDebug, logInit, logWarn } from './services/log-service.js';
import { initSearxngModal, closeSearxngModal, loadSearxngConfig } from './components/searxng-modal.js';
import { initHarnessHooks } from './services/hooks.js';
import { initVerificationSystem } from './services/verification.js';
import type { ChatSession } from './types.js';
// ─── v4.0 数据迁移:IndexedDB → SQLite ───
@@ -346,7 +348,14 @@ async function init(): Promise<void> {
await loadModels();
await initMemoryManager();
await loadSearxngConfig(db);
logInit('所有组件已就绪');
// ── v0.12.0: Harness Engineering 系统初始化 ──
initHarnessHooks();
initVerificationSystem();
// Agent Metrics 无需显式初始化(按需启动)
// Context Indexer 在 Agent INIT 状态按需构建
logInit('所有组件已就绪(含 Harness v0.12.0');
const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) {
@@ -409,6 +418,13 @@ async function init(): Promise<void> {
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
// ── v0.12.1 看门狗超时 ──
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
state.set('loopWatchdogMs', loopWatchdogMs);
const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs;
const elWatchdog = document.querySelector('#inputLoopWatchdog') as HTMLInputElement;
if (elWatchdog) elWatchdog.value = loopWatchdogMs > 0 ? String(watchdogMin) : (loopWatchdogMs === 0 ? '0' : '');
// ── Tool Calling 设置(永久开启,不可关闭)──
state.set('toolCallingEnabled', true);
const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
@@ -453,6 +469,8 @@ async function init(): Promise<void> {
initTokenDashboard();
initWorkspacePanel();
setupDesktopIntegration();
initHarnessHooks();
initVerificationSystem();
checkConnection().then(loadModels);
}
}
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
/**
* Agent Metrics — 治理与度量模块 (v0.12.0)
* Harness Engineering Phase 8: 度量驱动的优化循环
*
* 功能:
* - 会话级度量采集(迭代效率、工具成功率、完成质量)
* - 全局趋势聚合
* - 错误模式识别(转向循环)
* - 度量仪表盘数据源
*/
import { logInfo, logDebug } from './log-service.js';
import type { AgentMetrics, LoopContext } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 度量采集
// ═══════════════════════════════════════════════════════════════
/** 当前会话的度量快照 */
interface SessionMetrics {
sessionId: string;
model: string;
startTime: number;
endTime: number;
totalIterations: number;
toolCalls: Array<{ name: string; status: string; duration?: number }>;
totalInputTokens: number;
totalOutputTokens: number;
completionGatePassed: boolean;
errorPatterns: string[];
}
let currentMetrics: SessionMetrics | null = null;
const sessionMetricsHistory: SessionMetrics[] = [];
/** 开始采集会话度量 */
export function startSessionMetrics(sessionId: string, model: string): void {
currentMetrics = {
sessionId,
model,
startTime: Date.now(),
endTime: 0,
totalIterations: 0,
toolCalls: [],
totalInputTokens: 0,
totalOutputTokens: 0,
completionGatePassed: false,
errorPatterns: [],
};
logDebug(`Agent Metrics: 开始采集会话 ${sessionId}`);
}
/** 记录一轮迭代 */
export function recordIteration(ctx: LoopContext): void {
if (!currentMetrics) return;
currentMetrics.totalIterations = ctx.loopCount;
currentMetrics.totalInputTokens = ctx.totalPromptEvalCount;
currentMetrics.totalOutputTokens = ctx.totalEvalCount;
}
/** 记录工具调用 */
export function recordToolCall(name: string, status: string, durationMs?: number): void {
if (!currentMetrics) return;
currentMetrics.toolCalls.push({ name, status, duration: durationMs });
if (status === 'error') {
// 错误模式:记录工具名和失败状态
const pattern = `${name}:error`;
if (!currentMetrics.errorPatterns.includes(pattern)) {
currentMetrics.errorPatterns.push(pattern);
}
}
}
/** 完成门控结果 */
export function recordCompletionGate(passed: boolean): void {
if (!currentMetrics) return;
currentMetrics.completionGatePassed = passed;
}
/** 结束会话度量 */
export function endSessionMetrics(): SessionMetrics | null {
if (!currentMetrics) return null;
currentMetrics.endTime = Date.now();
sessionMetricsHistory.push({ ...currentMetrics });
const metrics = { ...currentMetrics };
logInfo(
`Agent Metrics: 会话完成 — ` +
`${metrics.totalIterations} 轮, ` +
`${metrics.toolCalls.length} 工具调用, ` +
`${formatPercent(getToolSuccessRate(metrics))} 成功率`
);
currentMetrics = null;
return metrics;
}
// ═══════════════════════════════════════════════════════════════
// 度量计算
// ═══════════════════════════════════════════════════════════════
/** 计算工具成功率 */
function getToolSuccessRate(m: SessionMetrics): number {
if (m.toolCalls.length === 0) return 1;
const successCount = m.toolCalls.filter(t => t.status === 'success').length;
return successCount / m.toolCalls.length;
}
/** 格式化百分比 */
function formatPercent(ratio: number): string {
return (ratio * 100).toFixed(1) + '%';
}
/** 聚合全局度量 */
export function aggregateMetrics(): AgentMetrics {
const history = sessionMetricsHistory;
if (history.length === 0) {
return {
totalSessions: 0,
avgIterationsPerTask: 0,
toolSuccessRate: 0,
avgCompletionScore: 0,
frequentErrors: [],
tokenEfficiency: 0,
collectedAt: Date.now(),
};
}
const totalIterations = history.reduce((s, m) => s + m.totalIterations, 0);
const allToolCalls = history.flatMap(m => m.toolCalls);
const successCalls = allToolCalls.filter(t => t.status === 'success').length;
const totalInputTokens = history.reduce((s, m) => s + m.totalInputTokens, 0);
const totalOutputTokens = history.reduce((s, m) => s + m.totalOutputTokens, 0);
// 聚合错误模式
const errorCounts = new Map<string, number>();
for (const m of history) {
for (const pattern of m.errorPatterns) {
errorCounts.set(pattern, (errorCounts.get(pattern) || 0) + 1);
}
}
const frequentErrors = Array.from(errorCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pattern, count]) => ({ pattern, count }));
return {
totalSessions: history.length,
avgIterationsPerTask: Math.round(totalIterations / history.length),
toolSuccessRate: allToolCalls.length > 0 ? successCalls / allToolCalls.length : 0,
avgCompletionScore: history.filter(m => m.completionGatePassed).length / history.length,
frequentErrors,
tokenEfficiency: totalInputTokens > 0 ? totalOutputTokens / totalInputTokens : 0,
collectedAt: Date.now(),
};
}
// ═══════════════════════════════════════════════════════════════
// 转向循环:错误模式 → 改进建议
// ═══════════════════════════════════════════════════════════════
export interface ImprovementSuggestion {
pattern: string;
frequency: number;
suggestion: string;
severity: 'high' | 'medium' | 'low';
}
/**
* 分析错误模式,生成改进建议
* 实现"让错误只犯一次"的核心理念
*/
export function generateImprovementSuggestions(): ImprovementSuggestion[] {
const metrics = aggregateMetrics();
const suggestions: ImprovementSuggestion[] = [];
for (const err of metrics.frequentErrors) {
const [tool, status] = err.pattern.split(':');
// 特定工具的重复失败
if (status === 'error') {
if (tool === 'web_fetch' && err.count >= 3) {
suggestions.push({
pattern: err.pattern,
frequency: err.count,
suggestion: `web_fetch 频繁失败 (${err.count}次)。考虑在 AGENT.md 中添加规则:优先使用 browser_open + browser_extract 作为替代方案。`,
severity: 'high',
});
} else if (tool === 'web_search' && err.count >= 3) {
suggestions.push({
pattern: err.pattern,
frequency: err.count,
suggestion: `web_search 频繁失败 (${err.count}次)。检查 SearXNG 配置或网络连接。可在 AGENT.md 中添加备用搜索策略。`,
severity: 'high',
});
} else if (err.count >= 5) {
suggestions.push({
pattern: err.pattern,
frequency: err.count,
suggestion: `工具 ${tool} 反复失败 ${err.count} 次。建议审查该工具的参数构造逻辑,或在 AGENT.md 中添加错误处理指南。`,
severity: 'medium',
});
}
}
}
// Token 效率过低
if (metrics.tokenEfficiency < 0.1 && metrics.totalSessions > 3) {
suggestions.push({
pattern: 'low_token_efficiency',
frequency: metrics.totalSessions,
suggestion: `Token 效率过低 (${formatPercent(metrics.tokenEfficiency)})。Agent 可能过度调用工具。建议在 AGENT.md 中添加:简单常识问题直接回答,不要调用工具。`,
severity: 'medium',
});
}
// 迭代次数过高
if (metrics.avgIterationsPerTask > 50) {
suggestions.push({
pattern: 'high_iterations',
frequency: metrics.totalSessions,
suggestion: `平均迭代次数过高 (${metrics.avgIterationsPerTask} 轮)。建议降低 maxTurns 设置或在 AGENT.md 中添加提前终止条件。`,
severity: 'medium',
});
}
return suggestions;
}
/**
* 将改进建议格式化为 AGENT.md 补充规则
*/
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
if (suggestions.length === 0) return '';
let rules = '\n\n## 自动生成的改进规则 (v0.12.0)\n';
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
for (const s of suggestions) {
rules += `### ${s.pattern}\n`;
rules += `- **严重程度**: ${s.severity}\n`;
rules += `- **出现频率**: ${s.frequency}\n`;
rules += `- **建议**: ${s.suggestion}\n\n`;
}
return rules;
}
// ═══════════════════════════════════════════════════════════════
// 格式化输出(供仪表盘使用)
// ═══════════════════════════════════════════════════════════════
export function formatMetricsReport(metrics: AgentMetrics): string {
return [
`Agent Metrics 报告 (${new Date(metrics.collectedAt).toLocaleString()})`,
`${'─'.repeat(50)}`,
`总会话数: ${metrics.totalSessions}`,
`平均迭代/任务: ${metrics.avgIterationsPerTask}`,
`工具成功率: ${formatPercent(metrics.toolSuccessRate)}`,
`完成门控通过率: ${formatPercent(metrics.avgCompletionScore)}`,
`Token 效率: ${formatPercent(metrics.tokenEfficiency)}`,
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
].join('\n');
}
+368
View File
@@ -0,0 +1,368 @@
/**
* Completion Gate — 完成门控模块 (v0.12.0)
* Harness Engineering Phase 3: 在 Agent 输出最终答案前进行结构化检查
*
* 设计理念:
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
* - 推理性反馈补充(AI 判断、秒级、非确定)
* - 所有检查项可独立开关
*/
import { logWarn, logInfo } from './log-service.js';
import { estimateTokens } from './context-manager.js';
import type { LoopContext, CompletionCheck } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 内置检查项
// ═══════════════════════════════════════════════════════════════
/** 内容质量检查:回复不应过短或不完整 */
const contentQualityCheck: CompletionCheck = {
name: 'contentQuality',
description: '检查回复长度和质量是否达到最低标准',
check: async (ctx: LoopContext) => {
if (ctx.content.length < 50) {
return { passed: false, reason: '回复内容过短(<50字),可能不完整。请给出更详细的最终回答。' };
}
return { passed: true, reason: '' };
},
};
/** 工具结果审查:是否存在未处理的工具错误 */
const toolResultReviewCheck: CompletionCheck = {
name: 'toolResultReview',
description: '检查是否存在未处理的工具执行错误',
check: async (ctx: LoopContext) => {
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
if (errorRecords.length === 0) return { passed: true, reason: '' };
// 检查这些错误是否在最近的上下文中被讨论过
const recentContent = ctx.messages.slice(-5).map(m => m.content || '').join(' ');
const unresolvedErrors = errorRecords.filter(r => {
const errorName = r.name;
return !recentContent.includes(errorName + ' 失败')
&& !recentContent.includes(errorName + ' error')
&& !recentContent.includes(errorName + ' 错误');
});
if (unresolvedErrors.length > 0) {
return {
passed: false,
reason: `存在 ${unresolvedErrors.length} 个未处理的工具错误: ${unresolvedErrors.map(r => r.name).join(', ')}。请说明这些错误的影响或提供替代方案。`
};
}
return { passed: true, reason: '' };
},
};
/** 思考阶段检测:回复不应仍在"思考中"状态 */
const notThinkingCheck: CompletionCheck = {
name: 'notThinking',
description: '检查回复是否已从思考阶段过渡到结论阶段',
check: async (ctx: LoopContext) => {
// 仅检查短回复(<200字)——长回复几乎肯定是完整回答
if (ctx.content.length > 200) return { passed: true, reason: '' };
const contentLower = ctx.content.slice(0, 100).toLowerCase();
const thinkingMarkers = ['让我看看', '观察一下', '先检查一下', '我需要先', '正在分析', '让我再'];
const continuationSignals = ctx.content.endsWith('...') || ctx.content.endsWith('等等') || ctx.content.endsWith('…');
const conclusionMarkers = [
/Final\s*Answer/i, /最终答案/, /最终回答/, /总结/, /任务完成/, /以上就是/,
/以上[是为]/, /我的能力/, /可以帮你/, /能够/, /我是/, /以下[是为]/,
];
const hasConclusion = conclusionMarkers.some(p => p.test(ctx.content));
if (hasConclusion) return { passed: true, reason: '' };
const thinkingCount = thinkingMarkers.filter(m => contentLower.includes(m)).length;
const isClearlyThinking = thinkingCount >= 2 || (thinkingCount >= 1 && continuationSignals);
if (isClearlyThinking) {
return {
passed: false,
reason: '回复看起来仍在思考阶段(短回复 + 思考动词 + 无结论标志),请基于已有结果给出明确的结论。'
};
}
return { passed: true, reason: '' };
},
};
/** 上下文效率检查:是否存在过度调用工具 */
const contextEfficiencyCheck: CompletionCheck = {
name: 'contextEfficiency',
description: '检查工具调用效率是否合理(避免过度调用)',
check: async (ctx: LoopContext) => {
const toolMessages = ctx.messages.filter(m => m.role === 'tool');
const nonToolMessages = ctx.messages.filter(m => m.role !== 'tool' && m.role !== 'system');
if (toolMessages.length > nonToolMessages.length * 3 && ctx.loopCount > 5) {
return {
passed: false,
reason: `工具调用过多(${toolMessages.length} 次 vs ${nonToolMessages.length} 次非工具消息),可能存在重复或低效的工具调用。请检查是否可以直接基于已有结果给出回答。`
};
}
return { passed: true, reason: '' };
},
};
/** 系统提示词注入检测:防止模型在回复中尝试注入 prompt */
const promptInjectionCheck: CompletionCheck = {
name: 'promptInjection',
description: '检测回复中是否包含疑似 prompt injection 内容',
check: async (ctx: LoopContext) => {
const injectionPatterns = [
/ignore\s+(all\s+)?(previous|above)\s+instructions/i,
/you\s+are\s+now\s+(a|an)\s+/i,
/new\s+system\s*prompt/i,
/forget\s+(all\s+)?(previous|above)/i,
/\[SOUL\.md\]/,
/\[AGENT\.md\]/,
];
for (const pattern of injectionPatterns) {
if (pattern.test(ctx.content)) {
return {
passed: false,
reason: '回复中包含疑似 prompt injection 内容(试图覆盖系统提示词)。请移除相关内容后重新回答。'
};
}
}
return { passed: true, reason: '' };
},
};
/** 工具幻觉检测:模型声称完成了某操作但该工具从未被调用 */
const toolHallucinationCheck: CompletionCheck = {
name: 'toolHallucination',
description: '检测模型是否编造了工具调用结果',
check: async (ctx: LoopContext) => {
const calledTools = new Set(ctx.allToolRecords.map(r => r.name));
const content = ctx.content;
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 42 个工具)──
const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [
// 文件写入
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
/文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/],
requiredTools: ['write_file'], label: '写入/创建文件' },
// 文件读取
{ patterns: [/已读取文件/, /文件内容显示/, /读取了.*文件/, /文件.*内容.*如下/,
/查看.*文件.*内容/, /打开文件.*看到/],
requiredTools: ['read_file', 'read_multiple_files'], label: '读取文件' },
// 文件编辑
{ patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/,
/修改了.*文件/, /更新了.*文件/],
requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' },
// 文件删除
{ patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/],
requiredTools: ['delete_file'], label: '删除文件' },
// 文件移动/复制/重命名
{ patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/],
requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' },
// 目录浏览
{ patterns: [/目录包含/, /列出.*目录/, /目录.*结构/, /文件列表.*如下/, /目录.*内容/],
requiredTools: ['list_directory', 'tree'], label: '列出目录' },
// 文件搜索
{ patterns: [/搜索到.*文件/, /找到.*匹配/, /搜索.*结果.*文件/],
requiredTools: ['search_files'], label: '搜索文件' },
// 搜索/抓取
{ patterns: [/搜索结果显示/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/,
/查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/],
requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' },
// 浏览器
{ patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/,
/点击了.*按钮/, /输入了.*文本/, /页面.*截图/],
requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' },
// 命令执行
{ patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/,
/执行.*结果/, /终端.*显示/],
requiredTools: ['run_command'], label: '执行命令' },
// Git 操作
{ patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/,
/git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/],
requiredTools: ['git'], label: 'Git 操作' },
// 下载文件
{ patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/],
requiredTools: ['download_file'], label: '下载文件' },
// 压缩/解压
{ patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/],
requiredTools: ['compress'], label: '压缩/解压' },
// 文件信息
{ patterns: [/文件.*大小.*字节/, /文件.*权限.*为/, /文件.*修改日期/],
requiredTools: ['get_file_info'], label: '获取文件信息' },
// 内存/记忆操作
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/],
requiredTools: ['memory_add', 'memory_replace', 'memory_remove'], label: '记忆操作' },
// 会话操作
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/],
requiredTools: ['session_list', 'session_read'], label: '会话操作' },
// 子代理
{ patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/],
requiredTools: ['spawn_task'], label: '子代理委派' },
// 系统工具
{ patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/],
requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' },
];
for (const rule of CLAIM_RULES) {
for (const pattern of rule.patterns) {
if (pattern.test(content)) {
const anyCalled = rule.requiredTools.some(t => calledTools.has(t));
if (!anyCalled) {
const toolList = rule.requiredTools.join(' / ');
return {
passed: false,
reason: `回复声称进行了"${rule.label}"操作,但 ${toolList} 工具从未被调用。请实际调用 ${toolList} 来完成任务,不要编造结果。`
};
}
break; // 该类别匹配到了且工具已调用,跳出
}
}
}
return { passed: true, reason: '' };
},
};
// ═══════════════════════════════════════════════════════════════
// 检查项注册与管理
// ═══════════════════════════════════════════════════════════════
/** 默认启用的检查项 */
const DEFAULT_ENABLED_CHECKS = new Set([
'contentQuality',
'toolResultReview',
'notThinking',
'promptInjection',
'toolHallucination',
]);
/** 阻断级检查:不通过时强制模型重新回答 */
const CRITICAL_CHECKS = new Set([
'toolHallucination',
'promptInjection',
]);
/** 注册表 */
const checkRegistry = new Map<string, CompletionCheck>([
['contentQuality', contentQualityCheck],
['toolResultReview', toolResultReviewCheck],
['notThinking', notThinkingCheck],
['contextEfficiency', contextEfficiencyCheck],
['promptInjection', promptInjectionCheck],
['toolHallucination', toolHallucinationCheck],
]);
/** 已启用的检查项 */
const enabledChecks = new Set(DEFAULT_ENABLED_CHECKS);
/** 注册自定义检查项 */
export function registerCompletionCheck(check: CompletionCheck): void {
checkRegistry.set(check.name, check);
logInfo(`Completion Gate: 注册检查项 "${check.name}"`);
}
/** 移除检查项 */
export function unregisterCompletionCheck(name: string): void {
checkRegistry.delete(name);
enabledChecks.delete(name);
}
/** 启用/禁用检查项 */
export function setCheckEnabled(name: string, enabled: boolean): void {
if (enabled) {
if (checkRegistry.has(name)) enabledChecks.add(name);
} else {
enabledChecks.delete(name);
}
}
/** 获取所有已注册检查项 */
export function getRegisteredChecks(): CompletionCheck[] {
return Array.from(checkRegistry.values());
}
/** 获取已启用的检查项 */
export function getEnabledChecks(): CompletionCheck[] {
return getRegisteredChecks().filter(c => enabledChecks.has(c.name));
}
// ═══════════════════════════════════════════════════════════════
// 执行门控
// ═══════════════════════════════════════════════════════════════
export interface GateResult {
passed: boolean;
reason: string;
/** 是否为阻断级失败(必须重新回答) */
critical: boolean;
/** 各项检查的详细结果 */
details: Array<{ name: string; passed: boolean; reason: string; durationMs: number }>;
}
/**
* 运行完成门控检查
*/
export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
const checks = getEnabledChecks();
if (checks.length === 0) {
return { passed: true, reason: '', critical: false, details: [] };
}
const details: GateResult['details'] = [];
// 关键检查优先(幻觉/注入),咨询检查在后
const sorted = [...checks].sort((a, b) => {
const aCrit = CRITICAL_CHECKS.has(a.name) ? 0 : 1;
const bCrit = CRITICAL_CHECKS.has(b.name) ? 0 : 1;
return aCrit - bCrit;
});
for (const check of sorted) {
const start = Date.now();
try {
const result = await check.check(ctx);
details.push({
name: check.name,
passed: result.passed,
reason: result.reason,
durationMs: Date.now() - start,
});
if (!result.passed) {
const isCritical = CRITICAL_CHECKS.has(check.name);
logWarn(`Completion Gate: "${check.name}" ${isCritical ? '🔴阻断' : '🟡咨询'}${result.reason}`);
return {
passed: false,
reason: result.reason,
critical: isCritical,
details,
};
}
} catch (err) {
details.push({
name: check.name,
passed: true,
reason: `检查异常: ${(err as Error).message}`,
durationMs: Date.now() - start,
});
}
}
return { passed: true, reason: '', critical: false, details };
}
/**
* 生成门控报告(供日志/调试使用)
*/
export function formatGateReport(result: GateResult): string {
const status = result.passed ? '✅ 通过' : '❌ 未通过';
let report = `Completion Gate ${status}`;
if (!result.passed) {
report += `${result.reason}`;
}
report += `\n${'─'.repeat(40)}`;
for (const d of result.details) {
const icon = d.passed ? '✅' : '❌';
report += `\n${icon} ${d.name}: ${d.reason || '通过'} (${d.durationMs}ms)`;
}
return report;
}
+230
View File
@@ -0,0 +1,230 @@
/**
* Context Indexer — 渐进式披露模块 (v0.12.0)
* Harness Engineering Phase 6: 三级上下文管理
*
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
* 实现层 (Implementation) — 修改时加载:具体源代码
*
* 设计理念:
* - 用目录式索引告诉智能体"去哪找",而非"全记住"
* - 上下文可从数万 Token 压至几千
* - 通过 load_context 工具按需触发接口层和实现层的加载
*/
import { logInfo, logDebug, logWarn } from './log-service.js';
import { estimateTokens } from './context-manager.js';
import type { ProjectIndex, ContextTier } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 项目索引缓存
// ═══════════════════════════════════════════════════════════════
/** 项目索引缓存(5 分钟 TTL) */
let cachedIndex: ProjectIndex | null = null;
let cacheTimestamp = 0;
const INDEX_CACHE_TTL = 5 * 60 * 1000; // 5 分钟
/** 最大索引 Token 预算 */
const MAX_INDEX_TOKENS = 2000;
/**
* 构建项目索引
* 扫描工作空间目录结构,生成精简的结构摘要
*/
export async function buildProjectIndex(workspaceDir: string): Promise<ProjectIndex> {
// 检查缓存
if (cachedIndex && Date.now() - cacheTimestamp < INDEX_CACHE_TTL) {
return cachedIndex;
}
try {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) {
return createEmptyIndex();
}
// 利用现有 tree 工具扫描目录结构(限制深度 3 层)
const treeResult = await bridge.tool.execute('tree', {
path: workspaceDir,
max_depth: 3,
include_hidden: false,
});
let structure = '';
if (treeResult.success && treeResult.tree) {
structure = String(treeResult.tree);
// Token 预算截断
if (estimateTokens(structure) > MAX_INDEX_TOKENS) {
const lines = structure.split('\n');
structure = lines.slice(0, Math.min(lines.length, 60)).join('\n')
+ '\n... (目录结构已截断,使用 list_directory 查看完整内容)';
}
} else {
structure = '(无法读取工作空间目录结构)';
}
// 识别入口文件
const entryFiles: string[] = [];
const commonEntries = [
'package.json', 'tsconfig.json', 'vite.config.ts',
'main.ts', 'index.ts', 'index.html', 'app.ts',
'Cargo.toml', 'pyproject.toml', 'go.mod', 'CMakeLists.txt',
'README.md', 'Makefile', 'docker-compose.yml',
];
for (const entry of commonEntries) {
try {
// 跨平台路径拼接(清理尾部斜杠,统一用 posix 风格,Node.js 可容错处理)
const cleanDir = workspaceDir.replace(/[\\/]+$/, '');
const filePath = cleanDir + '/' + entry;
const checkResult = await bridge.workspace.readFile(filePath);
if (checkResult?.success) {
entryFiles.push(entry);
}
} catch { /* ignore */ }
}
// 检测技术栈
const techStack = detectTechStack(entryFiles);
const index: ProjectIndex = {
structure,
entryFiles,
techStack,
tokenCount: estimateTokens(structure),
generatedAt: Date.now(),
};
cachedIndex = index;
cacheTimestamp = Date.now();
logInfo('项目索引已构建', `${techStack.join(', ')}, ${entryFiles.length} 入口文件, ${index.tokenCount} tokens`);
return index;
} catch (err) {
logWarn('项目索引构建失败', (err as Error).message);
return createEmptyIndex();
}
}
/** 创建空索引 */
function createEmptyIndex(): ProjectIndex {
return {
structure: '(未检测到工作空间)',
entryFiles: [],
techStack: [],
tokenCount: 0,
generatedAt: Date.now(),
};
}
/** 根据入口文件检测技术栈 */
function detectTechStack(entryFiles: string[]): string[] {
const stack: string[] = [];
const fileSet = new Set(entryFiles.map(f => f.toLowerCase()));
if (fileSet.has('package.json')) stack.push('Node.js');
if (fileSet.has('tsconfig.json')) stack.push('TypeScript');
if (fileSet.has('vite.config.ts')) stack.push('Vite');
if (fileSet.has('cargo.toml')) stack.push('Rust');
if (fileSet.has('pyproject.toml')) stack.push('Python');
if (fileSet.has('go.mod')) stack.push('Go');
if (fileSet.has('cmakelists.txt')) stack.push('C/C++');
if (fileSet.has('docker-compose.yml')) stack.push('Docker');
if (fileSet.has('makefile')) stack.push('Make');
return stack.length > 0 ? stack : ['未知'];
}
/**
* 生成索引层系统提示词
* 始终保留在上下文中,告诉 AI "去哪找"
*/
export function buildIndexContext(index: ProjectIndex): string {
if (!index.structure || index.tokenCount === 0) return '';
let context = `【项目索引 — 始终可见】
项目结构:
${index.structure}
技术栈: ${index.techStack.join(', ') || '未检测'}
入口文件: ${index.entryFiles.length > 0 ? index.entryFiles.join(', ') : '未检测'}
💡 使用 list_directory 查看目录详情,使用 read_file 读取具体文件。
💡 使用 search_files 按内容搜索代码。
`;
// Token 预算控制
if (estimateTokens(context) > MAX_INDEX_TOKENS) {
context = context.slice(0, Math.floor(context.length * 0.8)) + '\n... (索引已截断)';
}
return context;
}
/**
* 构建接口层上下文(按需加载)
* @param modulePattern 模块匹配模式,如 "src/services/"
*/
export async function buildInterfaceContext(modulePattern: string, workspaceDir: string): Promise<string> {
try {
const bridge = window.metonaDesktop;
if (!bridge?.isDesktop) return '';
// 搜索模块相关的类型定义和配置文件
const searchResult = await bridge.tool.execute('search_files', {
path: workspaceDir,
query: modulePattern,
search_type: 'filename',
max_results: 10,
});
if (!searchResult.success || !(searchResult as any).results?.length) {
return `(未找到与 "${modulePattern}" 相关的接口文件)`;
}
const results = (searchResult as any).results as Array<{ path: string }>;
const paths = results.map(r => r.path).slice(0, 8);
// 批量读取接口文件(限制每文件 2000 字符)
const readResult = await bridge.tool.execute('read_multiple_files', {
paths,
max_chars_per_file: 2000,
});
if (readResult.success) {
const filesInfo = paths.map(p => ` 📄 ${p}`).join('\n');
return `【接口层 — ${modulePattern}
相关文件:
${filesInfo}
内容预览:
${JSON.stringify((readResult as any).files)}`;
}
return `【接口层 — ${modulePattern}
相关文件:${paths.join(', ')}`;
} catch (err) {
logWarn('接口层上下文加载失败', (err as Error).message);
return '';
}
}
/**
* 加载指定层级的上下文
*/
export async function loadContextByTier(
tier: ContextTier,
modulePattern: string,
workspaceDir: string,
): Promise<string> {
switch (tier) {
case 'index': {
const index = await buildProjectIndex(workspaceDir);
return buildIndexContext(index);
}
case 'interface':
return buildInterfaceContext(modulePattern, workspaceDir);
case 'implementation':
// 实现层由 Agent 自行通过 read_file 加载
return '';
}
}
+234
View File
@@ -0,0 +1,234 @@
/**
* Harness Hook 系统 (v0.12.0)
* Harness Engineering Phase 4: 可扩展的 Agent 执行钩子架构
*
* 钩子阶段:
* - pre_tool: 工具执行前(安全检查、缓存检查、权限验证)
* - post_tool: 工具执行后(结果验证、自动 lint、自动测试)
* - post_iteration: 每轮迭代后(记忆提取、上下文裁剪)
* - pre_completion: 最终输出前(完成门控、质量检查)
*
* 设计原则:
* - 异步并行执行,失败不阻塞主流程
* - 优先级排序,高优先级先执行
* - 可动态注册/移除
* - MCP 工具可注册自定义 Hook
*/
import { logInfo, logWarn, logDebug } from './log-service.js';
import type { HarnessHook, HookPhase, HookResult, HookData, LoopContext } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// Hook 注册表
// ═══════════════════════════════════════════════════════════════
const hookRegistry = new Map<string, HarnessHook>();
const phaseIndex = new Map<HookPhase, HarnessHook[]>();
function ensurePhaseIndex(phase: HookPhase): void {
if (!phaseIndex.has(phase)) {
phaseIndex.set(phase, []);
}
}
/** 注册 Hook */
export function registerHook(hook: HarnessHook): void {
hookRegistry.set(hook.name, hook);
ensurePhaseIndex(hook.phase);
phaseIndex.get(hook.phase)!.push(hook);
// 按优先级降序排列
phaseIndex.get(hook.phase)!.sort((a, b) => b.priority - a.priority);
logDebug(`Hook 已注册: "${hook.name}" phase=${hook.phase} priority=${hook.priority}`);
}
/** 移除 Hook */
export function unregisterHook(name: string): void {
const hook = hookRegistry.get(name);
if (!hook) return;
hookRegistry.delete(name);
const list = phaseIndex.get(hook.phase);
if (list) {
const idx = list.findIndex(h => h.name === name);
if (idx !== -1) list.splice(idx, 1);
}
}
/** 启用/禁用 Hook */
export function setHookEnabled(name: string, enabled: boolean): void {
const hook = hookRegistry.get(name);
if (hook) {
hook.enabled = enabled;
logDebug(`Hook "${name}" ${enabled ? '已启用' : '已禁用'}`);
}
}
/** 获取所有已注册 Hook */
export function getRegisteredHooks(): HarnessHook[] {
return Array.from(hookRegistry.values());
}
/** 获取指定阶段已启用的 Hook */
export function getPhaseHooks(phase: HookPhase): HarnessHook[] {
return (phaseIndex.get(phase) || []).filter(h => h.enabled);
}
// ═══════════════════════════════════════════════════════════════
// Hook 执行引擎
// ═══════════════════════════════════════════════════════════════
/**
* 并行执行指定阶段的所有 Hook
* @returns 所有 Hook 的执行结果汇总
*/
export async function executeHooks(
phase: HookPhase,
ctx: LoopContext,
data: HookData = {}
): Promise<{ results: HookResult[]; allPassed: boolean }> {
const hooks = getPhaseHooks(phase);
if (hooks.length === 0) {
return { results: [], allPassed: true };
}
const results = await Promise.all(
hooks.map(async (hook) => {
try {
const result = await hook.handler(ctx, data);
if (!result.passed) {
logWarn(`Hook "${hook.name}" 未通过: ${result.message}`);
}
return result;
} catch (err) {
logWarn(`Hook "${hook.name}" 执行异常: ${(err as Error).message}`);
return { passed: true, message: `Hook 异常(已忽略): ${(err as Error).message}` };
}
})
);
const allPassed = results.every(r => r.passed);
return { results, allPassed };
}
// ═══════════════════════════════════════════════════════════════
// 内置 Hook
// ═══════════════════════════════════════════════════════════════
/**
* SecurityCheckHook — 工具执行前安全检查
* 检查命令和路径是否在黑名单内
*/
export const securityCheckHook: HarnessHook = {
name: 'SecurityCheck',
phase: 'pre_tool',
priority: 100, // 最高优先级
enabled: true,
handler: async (_ctx: LoopContext, data: HookData) => {
const { toolName, toolArgs } = data;
if (toolName === 'run_command' && typeof toolArgs?.command === 'string') {
const cmd = toolArgs.command as string;
// 快速黑名单检查
const dangerous = ['rm -rf /', 'mkfs', 'dd if=', 'shutdown', 'reboot', ':(){', 'chmod 777 /'];
for (const d of dangerous) {
if (cmd.toLowerCase().includes(d.toLowerCase())) {
return { passed: false, message: `危险命令被拦截: ${cmd.slice(0, 100)}` };
}
}
// 管道执行 shell 检测
if (/(\||>|<)\s*(sh|bash|zsh|powershell|cmd)/i.test(cmd)) {
return { passed: false, message: `禁止通过管道执行 shell: ${cmd.slice(0, 100)}` };
}
}
if (toolName === 'delete_file' && typeof toolArgs?.path === 'string') {
const p = (toolArgs.path as string).toLowerCase();
const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg'];
for (const pp of protectedPaths) {
if (p.includes(pp)) {
return { passed: false, message: `受保护路径不可删除: ${p}` };
}
}
}
return { passed: true, message: '' };
},
};
/**
* ResultValidationHook — 工具执行后结果验证
* 检查工具返回是否有效。注意:此 Hook 为咨询性(post_tool 阶段不阻断执行),
* 实际的截断由 agent-engine 的 formatToolResultForModel 和 40 条消息硬限制负责。
*/
export const resultValidationHook: HarnessHook = {
name: 'ResultValidation',
phase: 'post_tool',
priority: 90,
enabled: true,
handler: async (_ctx: LoopContext, data: HookData) => {
const { toolName, toolResult } = data;
if (!toolResult) return { passed: true, message: '' };
// 检查空结果
if (!toolResult.success && !toolResult.error) {
return { passed: false, message: `工具 ${toolName} 执行失败但未提供错误信息` };
}
// 检查超大结果。不同工具有不同阈值:
// web_search 包含自动抓取内容,允许较大结果
const SIZE_LIMITS: Record<string, number> = {
web_search: 1_000_000, // 1MB(含自动抓取的多页内容)
web_fetch: 500_000, // 500KB
read_file: 200_000, // 200KB
default: 500_000, // 默认 500KB
};
const limit = SIZE_LIMITS[toolName || ''] || SIZE_LIMITS.default;
if (toolResult.success && toolResult) {
const resultStr = JSON.stringify(toolResult);
if (resultStr.length > limit) {
// 仅警告,不阻断(实际截断由 agent-engine 负责)
return {
passed: true,
message: `工具 ${toolName} 返回结果较大 (${(resultStr.length / 1024).toFixed(0)}KB),将由上下文管理器自动截断`,
data: { oversized: true, size: resultStr.length },
};
}
}
return { passed: true, message: '' };
},
};
/**
* IterationMetricsHook — 迭代度量收集
* 每轮迭代后记录关键指标
*/
export const iterationMetricsHook: HarnessHook = {
name: 'IterationMetrics',
phase: 'post_iteration',
priority: 50,
enabled: true,
handler: async (ctx: LoopContext, _data: HookData) => {
// 收集本轮迭代统计
const totalMessages = ctx.messages.length;
const toolMessages = ctx.messages.filter(m => m.role === 'tool').length;
logDebug(
`迭代度量 [第 ${ctx.loopCount} 轮]: ` +
`${totalMessages} 消息, ${toolMessages} 工具消息, ` +
`${ctx.totalEvalCount} eval tokens, ${ctx.allToolRecords.length} 总工具调用`
);
return { passed: true, message: '' };
},
};
// ═══════════════════════════════════════════════════════════════
// 初始化:注册所有内置 Hook
// ═══════════════════════════════════════════════════════════════
let hooksInitialized = false;
export function initHarnessHooks(): void {
if (hooksInitialized) return;
registerHook(securityCheckHook);
registerHook(resultValidationHook);
registerHook(iterationMetricsHook);
hooksInitialized = true;
logInfo('Harness Hook 系统已初始化', `${hookRegistry.size} 个 Hook 已注册`);
}
+2 -2
View File
@@ -151,8 +151,8 @@ export async function executeMCPTool(toolName: string, args: Record<string, unkn
return { success: false, error: 'MCP API 不可用' };
}
// 解析工具全名: mcp_{serverName}_{toolName}
const match = toolName.match(/^mcp_(.+?)_(.+)$/);
// 解析工具全名: mcp_{serverName}__{toolName}(双下划线分隔,避免 serverName/toolName 含下划线时歧义)
const match = toolName.match(/^mcp_(.+?)__(.+)$/);
if (!match) {
return { success: false, error: `无效的 MCP 工具名: ${toolName}` };
}
+122 -1
View File
@@ -465,6 +465,25 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
}
},
// ══════════════════════════════════════════════
// 新增工具:Plan Mode 执行追踪
// ══════════════════════════════════════════════
{
type: 'function',
function: {
name: 'plan_track',
description: 'Manage Plan Mode execution progress. Use this to mark plan steps as completed so you always know what remains. REQUIRED in Plan Mode — call plan_track after completing each step.',
parameters: {
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['status', 'mark_done', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_all_done=全部完成' },
step_index: { type: 'integer', description: 'Step number (1-indexed) to mark as done. Required for mark_done.' },
step_label: { type: 'string', description: 'Optional: description of what was completed, for logging.' }
}
}
}
},
// ══════════════════════════════════════════════
// v5.0 新增工具:浏览器控制
// ══════════════════════════════════════════════
// v5.1 Browser 控制工具(增强版)
@@ -717,7 +736,7 @@ let enabledTools: Set<string> = new Set([
'memory_search', 'memory_add', 'memory_replace', 'memory_remove',
'session_list', 'session_read', 'spawn_task',
'browser_open', 'browser_screenshot', 'browser_evaluate', 'browser_extract',
'browser_click', 'browser_type', 'browser_scroll', 'browser_close',
'browser_click', 'browser_type', 'browser_scroll', 'browser_wait', 'browser_close',
'datetime', 'calculator',
'random', 'uuid', 'json_format', 'hash'
]);
@@ -727,6 +746,17 @@ export function setToolEnabled(toolName: string, enabled: boolean): void {
else enabledTools.delete(toolName);
}
/** Plan Mode 激活时注册 plan_track,关闭时移除 */
export function setPlanModeActive(active: boolean): void {
if (active) {
enabledTools.add('plan_track');
} else {
enabledTools.delete('plan_track');
// 清除追踪器状态
state.set('_planTracker', null);
}
}
export function getEnabledToolDefinitions(): ToolDefinition[] {
const builtIn = TOOL_DEFINITIONS.filter(def => enabledTools.has(def.function.name));
@@ -879,6 +909,38 @@ export async function executeTool(toolName: string, args: Record<string, unknown
return result;
}
// plan_track — Plan Mode 执行追踪
if (toolName === 'plan_track') {
const action = args.action as string;
const stepIndex = args.step_index as number | undefined;
const stepLabel = (args.step_label as string) || '';
const tracker = getPlanTracker();
if (action === 'status') {
return { success: true, action: 'status', steps: tracker.steps, total: tracker.total, done: tracker.done };
}
if (action === 'mark_done' && typeof stepIndex === 'number' && stepIndex > 0) {
const idx = stepIndex - 1;
if (idx >= 0 && idx < tracker.steps.length) {
tracker.steps[idx].done = true;
tracker.done = tracker.steps.filter(s => s.done).length;
if (stepLabel) tracker.steps[idx].label = stepLabel;
savePlanTracker(tracker);
const remaining = tracker.total - tracker.done;
logToolResult('plan_track', true, `步骤 ${stepIndex} 已完成 (${tracker.done}/${tracker.total}${remaining > 0 ? `, 剩余 ${remaining}` : ', 全部完成!'})`);
return { success: true, action: 'mark_done', step: stepIndex, done: tracker.done, total: tracker.total, remaining };
}
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
}
if (action === 'mark_all_done') {
for (const s of tracker.steps) s.done = true;
tracker.done = tracker.total;
savePlanTracker(tracker);
logToolResult('plan_track', true, `全部 ${tracker.total} 步已完成`);
return { success: true, action: 'mark_all_done', done: tracker.total, total: tracker.total, remaining: 0 };
}
return { success: false, error: `未知操作: ${action}` };
}
// run_command 走 workspace IPC
if (toolName === 'run_command') {
const command = args.command as string;
@@ -918,12 +980,70 @@ export function getToolIcon(name: string): string {
git: '🔖', compress: '🗜️', web_search: '🔍',
memory_search: '🧠', memory_add: '💾', memory_replace: '🔄', memory_remove: '🗑️',
session_list: '📋', session_read: '📖', spawn_task: '🤖',
plan_track: '📋',
browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰',
browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌'
};
return icons[name] || '🔧';
}
/** ── Plan Mode 执行追踪器 ── */
export interface PlanStep {
index: number;
label: string;
done: boolean;
}
export interface PlanTracker {
steps: PlanStep[];
total: number;
done: number;
active: boolean;
}
let _planTracker: PlanTracker = { steps: [], total: 0, done: 0, active: false };
export function getPlanTracker(): PlanTracker {
// 每次读取时同步 state 中的最新状态
const saved = state.get<PlanTracker | null>('_planTracker', null);
if (saved) _planTracker = saved;
return _planTracker;
}
export function savePlanTracker(tracker: PlanTracker): void {
_planTracker = tracker;
state.set('_planTracker', tracker);
}
export function initPlanTracker(steps: string[]): PlanTracker {
const tracker: PlanTracker = {
steps: steps.map((label, i) => ({ index: i + 1, label, done: false })),
total: steps.length,
done: 0,
active: true,
};
savePlanTracker(tracker);
logInfo('Plan Tracker 已初始化', `${tracker.total} 个步骤`);
return tracker;
}
export function clearPlanTracker(): void {
_planTracker = { steps: [], total: 0, done: 0, active: false };
state.set('_planTracker', null);
}
export function formatPlanStatus(): string {
const t = getPlanTracker();
if (!t.active || t.steps.length === 0) return '';
const lines = t.steps.map(s => {
const icon = s.done ? '✅' : '⬜';
return `${icon} 步骤${s.index}: ${s.label}`;
});
const pct = t.total > 0 ? Math.round(t.done / t.total * 100) : 0;
return `[Plan Mode 执行进度: ${t.done}/${t.total} (${pct}%)]\n${lines.join('\n')}\n\n完成每一步后请调用 plan_track(action='mark_done', step_index=N) 标记已完成。`;
}
export function formatToolName(name: string): string {
const names: Record<string, string> = {
read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录',
@@ -935,6 +1055,7 @@ export function formatToolName(name: string): string {
git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索',
memory_search: '搜索记忆', memory_add: '添加记忆', memory_replace: '替换记忆', memory_remove: '删除记忆',
session_list: '会话列表', session_read: '读取会话', spawn_task: '子代理委派',
plan_track: '计划追踪',
browser_open: '打开网页', browser_screenshot: '浏览器截图', browser_evaluate: '执行JS', browser_extract: '提取内容',
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Verification System (v0.12.0)
* Harness Engineering Phase 5: 计算性反馈管道
*
* Agent
* - Linter
* - Type Checker
* - Test Runner
* - Diff Analyzer
*
*
* - run_command
* - Hook hooks.ts post_tool
* - Observation Agent
* -
*/
import { registerHook } from './hooks.js';
import type { HarnessHook } from '../types.js';
import { logInfo, logDebug, logWarn } from './log-service.js';
import type { LoopContext, HookData, HookResult } from '../types.js';
// ═══════════════════════════════════════════════════════════════
// 验证配置
// ═══════════════════════════════════════════════════════════════
export interface VerificationConfig {
/** 是否启用自动 Lint */
autoLint: boolean;
/** 是否启用自动类型检查 */
autoTypeCheck: boolean;
/** 是否启用自动测试 */
autoTest: boolean;
/** Lint 命令(留空自动检测) */
lintCommand: string;
/** 类型检查命令 */
typeCheckCommand: string;
/** 测试命令 */
testCommand: string;
}
/** 默认配置 */
const defaultConfig: VerificationConfig = {
autoLint: false,
autoTypeCheck: false,
autoTest: false,
lintCommand: '',
typeCheckCommand: '',
testCommand: '',
};
/** 运行时配置 */
let config: VerificationConfig = { ...defaultConfig };
// ═══════════════════════════════════════════════════════════════
// 文件类型检测
// ═══════════════════════════════════════════════════════════════
/** 检测文件扩展名对应的语言 */
function detectFileType(filePath: string): string | null {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
py: 'python', rs: 'rust', go: 'go', java: 'java',
cpp: 'cpp', c: 'c', rb: 'ruby', css: 'css',
html: 'html', vue: 'vue', svelte: 'svelte',
};
return map[ext] || null;
}
/** 检测项目根目录(查找 package.json / pyproject.toml / Cargo.toml */
function detectProjectRoot(filePath: string): string | null {
// 简化实现:向上查找 package.json
const parts = filePath.replace(/\\/g, '/').split('/');
for (let i = parts.length - 1; i >= 0; i--) {
const dir = parts.slice(0, i + 1).join('/');
// 这里无法实际访问文件系统,返回文件所在目录的父级作为近似
}
// 返回文件所在目录
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
return lastSep > 0 ? filePath.slice(0, lastSep) : null;
}
// ═══════════════════════════════════════════════════════════════
// 验证 Hook 实现
// ═══════════════════════════════════════════════════════════════
/**
* DiffAnalyzerHook
* Agent
*/
export const diffAnalyzerHook: HarnessHook = {
name: 'DiffAnalyzer',
phase: 'post_tool',
priority: 75,
enabled: true,
handler: async (_ctx: LoopContext, data: HookData): Promise<HookResult> => {
const { toolName, toolArgs } = data;
if (toolName !== 'write_file' && toolName !== 'edit_file') {
return { passed: true, message: '' };
}
const filePath = (toolArgs?.path as string) || '';
if (!filePath) return { passed: true, message: '' };
// 分析修改的文件类型
const fileType = detectFileType(filePath);
if (!fileType) return { passed: true, message: '' };
// 分析跨层调用风险
const warnings: string[] = [];
// 检测是否修改了核心基础模块
const criticalPaths = ['/core/', '/base/', '/common/', '/shared/', '/utils/', '/lib/'];
const filePathLower = filePath.toLowerCase();
for (const cp of criticalPaths) {
if (filePathLower.includes(cp)) {
warnings.push(`⚠️ 修改了核心基础模块 "${filePath}",可能影响多个依赖方`);
break;
}
}
if (warnings.length > 0) {
return {
passed: true, // 警告不阻止,但注入提醒
message: warnings.join('; '),
data: { warnings },
};
}
return { passed: true, message: '' };
},
};
/**
* FileChangeAuditHook
* Agent
*/
export const fileChangeAuditHook: HarnessHook = {
name: 'FileChangeAudit',
phase: 'post_tool',
priority: 30,
enabled: true,
handler: async (ctx: LoopContext, data: HookData): Promise<HookResult> => {
const { toolName, toolArgs, toolResult } = data;
if (toolName !== 'write_file' && toolName !== 'edit_file' && toolName !== 'delete_file') {
return { passed: true, message: '' };
}
const filePath = (toolArgs?.path as string) || '';
const success = toolResult?.success || false;
logInfo(
`文件变更审计 [第 ${ctx.loopCount} 轮]: ` +
`${toolName} ${filePath}${success ? '✅' : '❌'}`
);
return { passed: true, message: '' };
},
};
// ═══════════════════════════════════════════════════════════════
// 初始化
// ═══════════════════════════════════════════════════════════════
let verificationInitialized = false;
export function initVerificationSystem(): void {
if (verificationInitialized) return;
registerHook(diffAnalyzerHook);
registerHook(fileChangeAuditHook);
verificationInitialized = true;
logInfo('验证系统已初始化', 'DiffAnalyzer + FileChangeAudit Hook 已注册');
}
/** 获取验证配置 */
export function getVerificationConfig(): VerificationConfig {
return { ...config };
}
/** 更新验证配置 */
export function updateVerificationConfig(updates: Partial<VerificationConfig>): void {
Object.assign(config, updates);
logInfo('验证配置已更新', JSON.stringify(config));
}
+54 -2
View File
@@ -631,6 +631,52 @@ html, body {
background: var(--critical-bg);
}
/* ── Plan 按钮 (v0.12.0) ── */
.plan-toggle {
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
user-select: none;
}
.plan-toggle input { display: none; }
.plan-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-control);
background: var(--bg-card);
border: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
color: var(--text-tertiary);
}
.plan-btn svg {
width: 18px;
height: 18px;
}
.plan-toggle:hover .plan-btn {
border-color: var(--border-strong);
color: var(--text-secondary);
}
.plan-toggle input:checked + .plan-btn {
background: rgba(71, 132, 143, 0.1);
border-color: #47848F;
color: #47848F;
box-shadow: 0 0 16px rgba(71, 132, 143, 0.2);
}
.plan-toggle input:checked + .plan-btn:hover {
background: rgba(71, 132, 143, 0.15);
box-shadow: 0 0 20px rgba(71, 132, 143, 0.3);
}
/* ── 开关控件 ── */
.toggle-label {
display: flex;
@@ -2449,8 +2495,8 @@ html, body {
*/
.workspace-panel {
width: 480px;
min-width: 480px;
width: 400px;
min-width: 400px;
height: 100vh;
display: flex;
flex-direction: column;
@@ -3539,6 +3585,12 @@ html, body {
border: 1px solid rgba(217, 79, 92, 0.15);
}
.tool-card-badge.plan-mode {
color: var(--accent);
background: rgba(232, 115, 74, 0.08);
border: 1px solid rgba(232, 115, 74, 0.2);
}
.tool-card-desc {
font-size: 12px;
color: var(--text-secondary);
+150 -1
View File
@@ -324,7 +324,22 @@ export type StateKey =
| 'runCommandMode'
| 'memoryEnabled'
| 'memoryEntries'
| 'embeddingModel';
| 'embeddingModel'
| '_defaultModel'
| '_lastSystemPrompt'
| '_currentEvalCount'
| '_abortToolRecords'
| '_loopState'
| '_loopContext'
| 'modelSupportsTools'
| 'maxTurns'
| 'streamTimeout'
| 'subAgentMaxLoops'
| 'subAgentTimeout'
| 'subAgentModel'
| 'agentMode'
| 'loopWatchdogMs'
| '_planTracker';
// ═══════════════════════════════════════════════════════════
// Tool Calling 类型
@@ -379,6 +394,139 @@ export interface ToolCallRecord {
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
// ═══════════════════════════════════════════════════════════
// Harness Engineering: Agent Loop 状态机 (v0.12.0)
// ═══════════════════════════════════════════════════════════
/** Agent Loop 状态枚举 */
export type LoopState =
| 'INIT' // 构建系统提示词、准备上下文
| 'THINKING' // 调用 LLM 流式
| 'PARSING' // 解析输出(tool_calls 提取 + 文本兜底)
| 'EXECUTING' // 执行工具(按批次并行)
| 'OBSERVING' // 收集工具结果、格式化
| 'REFLECTING' // 反思:去重检测、过早停止检测、Final Answer 检测
| 'COMPRESSING' // 触发上下文压缩
| 'TERMINATED'; // 终止
/** Agent Loop 上下文(可序列化,支持暂停/恢复) */
export interface LoopContext {
/** 状态机当前状态 */
state: LoopState;
/** 会话 ID */
sessionId: string;
/** 当前循环计数 */
loopCount: number;
/** 最大循环次数 */
maxLoops: number;
/** 当前轮模型输出内容 */
content: string;
/** 当前轮思考内容 */
thinking: string;
/** 本轮工具调用列表 */
toolCalls: ToolCall[];
/** 所有工具调用记录 */
allToolRecords: ToolCallRecord[];
/** 消息列表(Ollama 格式) */
messages: OllamaMessage[];
/** 累计 eval tokens */
totalEvalCount: number;
/** 累计 prompt_eval tokens */
totalPromptEvalCount: number;
/** 累计推理时间(纳秒) */
totalInferenceNs: number;
/** 本轮 eval tokens */
loopEvalCount: number;
/** 本轮 prompt_eval tokens */
loopPromptEvalCount: number;
/** 本轮推理时间 */
loopInferenceNs: number;
/** 上一轮成功工具调用的缓存键(用于去重) */
prevLoopSuccessKeys: string[];
/** 上一轮工具调用(用于 onNewIteration */
prevToolCalls: ToolCall[];
/** Agent 模式:auto(直接执行)| plan(先规划后执行) */
mode: AgentMode;
/** Plan 模式下等待用户确认的 Promise resolve */
planApproved?: boolean;
/** Plan Mode 重试计数(防止无限循环) */
planRetries: number;
/** 循环开始时间 */
startTime: number;
}
/** Agent 运行模式 */
export type AgentMode = 'auto' | 'plan';
/** System Prompt 分区(支持 LLM Prefix Caching */
export interface SystemPromptZones {
/** 静态区:SOUL.md + 环境基础 → 利用 LLM Prefix Caching */
staticZone: string;
/** 动态区:记忆检索 + 工作空间 + 实时日期 */
dynamicZone: string;
}
/** Hook 阶段 */
export type HookPhase = 'pre_tool' | 'post_tool' | 'post_iteration' | 'pre_completion';
/** Hook 执行结果 */
export interface HookResult {
/** 是否通过 */
passed: boolean;
/** 消息(通过/失败原因) */
message: string;
/** 附加数据 */
data?: Record<string, unknown>;
}
/** Harness Hook 接口 */
export interface HarnessHook {
name: string;
phase: HookPhase;
priority: number;
handler: (ctx: LoopContext, data: HookData) => Promise<HookResult>;
enabled: boolean;
}
/** Hook 上下文数据 */
export interface HookData {
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: ToolResult;
iterationIndex?: number;
[key: string]: unknown;
}
/** 完成门控检查项 */
export interface CompletionCheck {
name: string;
description: string;
check: (ctx: LoopContext) => Promise<{ passed: boolean; reason: string }>;
}
/** Agent 度量指标 */
export interface AgentMetrics {
totalSessions: number;
avgIterationsPerTask: number;
toolSuccessRate: number;
avgCompletionScore: number;
frequentErrors: Array<{ pattern: string; count: number }>;
tokenEfficiency: number;
collectedAt: number;
}
/** 渐进式披露:上下文层级 */
export type ContextTier = 'index' | 'interface' | 'implementation';
/** 项目索引摘要 */
export interface ProjectIndex {
structure: string;
entryFiles: string[];
techStack: string[];
tokenCount: number;
generatedAt: number;
}
// ═══════════════════════════════════════════════════════════
// ReAct Trace 类型 (v4.0)
// ═══════════════════════════════════════════════════════════
@@ -392,6 +540,7 @@ export interface TraceEntry {
actionInput: string;
observation: string;
loopCount: number;
errorPattern?: string;
createdAt: number;
}