diff --git a/README.md b/README.md index 92591da..64c22fa 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -253,7 +253,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.16.2.exe` +产出:`release/Metona Ollama Setup v0.16.7.exe` ## 🛠️ 常用命令 @@ -501,7 +501,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.16.2.exe` +Output: `release/Metona Ollama Setup v0.16.7.exe` ## 🛠️ Common Commands diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index af55505..ed2ac00 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -10,11 +10,11 @@ Metona Ollama Desktop 是基于 TypeScript + Electron 的 Windows 本地 AI 桌 核心架构: - **ReAct Agent Loop** — 8 状态机驱动的智能体循环(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置) -- **42 个内置工具** — 文件系统(16)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(4)、会话/子代理(3)、系统(6) -- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate(6 项检查)+ Agent Metrics + 渐进式披露 +- **40 个内置工具** — 文件系统(16)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(1)、会话/子代理(3)、系统(6)、Plan Mode(1) +- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate(5 项检查)+ Agent Metrics + 渐进式披露 - **MCP 协议扩展** — JSON-RPC 2.0 over stdio,动态工具发现 - **Plan Mode** — 开关切换,先规划后执行,步骤级进度追踪 -- **SQLite 存储** — sql.js WASM,6 张表,WAL 模式 + FTS5 全文搜索 +- **SQLite 存储** — sql.js WASM,5 张表,WAL 模式 --- @@ -52,7 +52,7 @@ src/ │ ├── tool-handlers-git.ts # Git 全操作 │ ├── tool-handlers-shared.ts # 共享类型和辅助函数 │ └── db/ -│ ├── sqlite.ts # SQLite 数据库层(6 张表 + FTS5) +│ ├── sqlite.ts # SQLite 数据库层(5 张表) │ └── sql.js.d.ts # sql.js 类型声明 │ ├── renderer/ # 渲染进程 @@ -78,24 +78,23 @@ src/ │ │ ├── toast.ts # Toast 通知 │ │ ├── token-dashboard.ts # Token 消耗仪表盘(全局 + 会话统计) │ │ ├── tool-confirm-modal.ts # 工具执行确认对话框 -│ │ ├── tools-modal.ts # 工具列表面板(42 个工具卡片) +│ │ ├── tools-modal.ts # 工具列表面板(40 个工具卡片) │ │ └── workspace-panel.ts # 工作空间面板(终端 + 工具卡片 + 文件浏览) -│ ├── services/ # 15 个服务模块 +│ ├── services/ # 14 个服务模块 │ │ ├── agent-engine.ts # ★ ReAct Agent Loop 核心引擎(8 状态机) -│ │ ├── tool-registry.ts # 工具注册与调度中心(42 内置 + MCP 动态 + Plan Mode) -│ │ ├── memory-manager.ts # 记忆管理核心(FTS5 + 向量语义搜索) -│ │ ├── vector-memory.ts # 向量记忆索引(IVF) -│ │ ├── vector-store.ts # 向量存储 + IVF 索引引擎 +│ │ ├── tool-registry.ts # 工具注册与调度中心(40 内置 + MCP 动态 + Plan Mode) +│ │ ├── memory-service.ts # 记忆管理(MEMORY.md 读写 + 格式校验 + 自动提取) │ │ ├── context-manager.ts # 上下文窗口管理(滑动窗口 + Token 校准 + LLM 压缩) │ │ ├── sub-agent.ts # 子代理委派(独立上下文 + 超时保护) │ │ ├── mcp-client.ts # MCP 渲染端客户端 │ │ ├── log-service.ts # 结构化日志(9 级分类) │ │ ├── crypto.ts # AES-256-GCM 加密 │ │ ├── hooks.ts # 4 阶段 Hook 系统(pre_tool/post_tool/post_iteration/pre_completion) -│ │ ├── completion-gate.ts # 完成门控(6 项检查,阻断/咨询两级) +│ │ ├── completion-gate.ts # 完成门控(5 项检查,阻断/咨询两级) │ │ ├── agent-metrics.ts # Agent 度量采集 + 错误模式识别 + 改进建议 +│ │ ├── agent-safety.ts # Agent 安全防护(工具阴影检测 + 路径校验) │ │ ├── context-indexer.ts # 渐进式披露(索引层→接口层→实现层) -│ │ └── verification.ts # 验证系统(DiffAnalyzer + FileChangeAudit) +│ │ └── infra-service.ts # 基础设施(全局错误处理 + 配置校验) │ ├── db/ │ │ └── chat-db.ts # 渲染端数据库接口 + IndexedDB→SQLite 迁移 │ ├── state/ @@ -179,11 +178,11 @@ logDebug('调试信息', '可选详情'); ### 5.1 五大子系统 ``` -① Agent 系统 → agent-engine.ts + tool-registry.ts(42 内置工具 + MCP 动态) -② 记忆系统 → memory-manager.ts + vector-memory.ts + vector-store.ts +① Agent 系统 → agent-engine.ts + tool-registry.ts(40 内置工具 + MCP 动态) +② 记忆系统 → memory-service.ts(MEMORY.md 文件存储 + 格式校验 + 自动提取) ③ 上下文系统 → context-manager.ts + context-indexer.ts(渐进式披露) ④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程) -⑤ 数据层 → db/sqlite.ts(SQLite, 6 张表, FTS5)+ chat-db.ts(渲染端接口) +⑤ 数据层 → db/sqlite.ts(SQLite, 5 张表)+ chat-db.ts(渲染端接口) ``` ### 5.2 ReAct Agent Loop(8 状态机) @@ -214,14 +213,13 @@ INIT → THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (CO ### 5.3 SQLite 数据库 -6 张表,WAL 模式 + NORMAL 同步: +5 张表,WAL 模式 + NORMAL 同步: | 表 | 用途 | 关键特性 | |---|---|---| | `sessions` | 会话 | parent_id 父子关系 | | `messages` | 消息 | 外键级联删除,thinking/tool_calls/attachments/eval_count | | `tool_calls` | 工具调用记录 | 按会话+工具名索引 | -| `memories` | Agent 记忆 | FTS5 全文搜索,向量嵌入,容量 500 上限 | | `settings` | 设置 | JSON 序列化 | | `traces` | ReAct 执行轨迹 | Agent 可观测性 | diff --git a/package-lock.json b/package-lock.json index ab52dfd..be3b98c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.16.2", + "version": "0.16.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.16.2", + "version": "0.16.7", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index b54cd9a..671b2fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.16.2", + "version": "0.16.7", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/browser.ts b/src/main/browser.ts index cb631d5..35752c5 100644 --- a/src/main/browser.ts +++ b/src/main/browser.ts @@ -3,7 +3,7 @@ * 通过隐藏 BrowserWindow 实现网页加载、截图、JS 执行 */ -import { BrowserWindow } from 'electron'; +import { BrowserWindow, session } from 'electron'; import { mainWindow } from './main.js'; function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string, detail?: string): void { @@ -12,6 +12,12 @@ function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string, let agentBrowser: BrowserWindow | null = null; +/** Agent 浏览器使用的独立 session partition(内存隔离,重启清空) */ +const AGENT_PARTITION = 'agent'; + +/** 伪装的 User-Agent — 移除 Electron 字样,使用常见 Chrome UA */ +const AGENT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'; + /** 浏览器是否已打开且可用 */ export function isBrowserOpen(): boolean { return agentBrowser !== null && !agentBrowser.isDestroyed(); @@ -41,10 +47,14 @@ function getAgentBrowser(): BrowserWindow { nodeIntegration: false, contextIsolation: true, sandbox: true, - webSecurity: false, + webSecurity: true, // 开启同源策略(防跨域攻击) + partition: AGENT_PARTITION, // 独立 session:与主窗口隔离 cookie/storage/缓存 } }); + // 设置伪装 User-Agent + agentBrowser.webContents.setUserAgent(AGENT_UA); + agentBrowser.on('closed', () => { agentBrowser = null; }); return agentBrowser; } @@ -385,11 +395,21 @@ export async function browserWait(params: { selector?: string; time_ms?: number } } -/** 关闭浏览器 */ -export function browserClose(): void { +/** 关闭浏览器并清理 session 资源 */ +export async function browserClose(): Promise { if (agentBrowser && !agentBrowser.isDestroyed()) { agentBrowser.close(); - sendLog('info', '🌐 browser 已关闭'); + agentBrowser = null; + // 清理 Agent session 的缓存和存储数据(内存 partition 关闭后自动清空,这里做显式清理确保彻底) + try { + const agentSession = session.fromPartition(AGENT_PARTITION); + await Promise.all([ + agentSession.clearCache(), + agentSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'] }), + ]); + } catch { /* 清理失败不阻塞 */ } + sendLog('info', '🌐 browser 已关闭,session 已清理'); + } else { + agentBrowser = null; } - agentBrowser = null; } diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index e5ca1d6..17ba5a4 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -91,7 +91,7 @@ function persist(): void { fs.writeFileSync(tmpPath, buf); fs.renameSync(tmpPath, dbPath); } catch (err) { - // 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获) + // 主进程无 log-service,使用 console.error 输出到 stderr(会被 Electron 日志捕获) console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`); } } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2ca4386..b9988a3 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -75,11 +75,11 @@ function summarizeResult(toolName: string, result: Record): str case 'list_directory': return `${result.path} (${result.total} 项${result.truncated ? ', 已截断' : ''})`; case 'search_files': return `"${result.query}" → ${result.total_matches} 匹配 / ${result.total_files} 文件`; case 'create_directory': return String(result.path); - case 'delete_file': return String(result.path); + case 'delete_file': return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths} 个路径` : String(result.path); case 'move_file': return `${result.source} → ${result.destination}`; case 'copy_file': return `${result.source} → ${result.destination}`; case 'web_fetch': return `${result.status} | ${result.length} chars`; - case 'web_search': return `[${(result as any)._mode === 'searxng' ? 'SearXNG' : '内置'}] "${result.query}" → ${result.total} 条结果`; + case 'web_search': return `[${String(result._mode) === 'searxng' ? 'SearXNG' : '内置'}] "${result.query}" → ${result.total} 条结果`; case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`; case 'get_file_info': return `${result.name} (${result.type}, ${result.size}B)`; case 'tree': return `${result.path} (${result.fileCount} 文件 / ${result.dirCount} 目录)`; @@ -89,12 +89,12 @@ function summarizeResult(toolName: string, result: Record): str case 'read_multiple_files': return `${result.total} 个文件`; case 'git': return `${result.action} ✓`; case 'compress': return `${result.action} → ${result.archive || result.destination}`; - case 'datetime': return `${(result as any).date || (result as any).iso}`; - case 'calculator': return `${(result as any).expression} = ${(result as any).result}`; - case 'random': return `${(result as any).type === 'pick' ? '🎲 ' + String((result as any).result) : String((result as any).result)}`; - case 'uuid': return `${(result as any).result}`; - case 'json_format': return `${(result as any).keys || 0} keys, ${(result as any).formatted_size}B`; - case 'hash': return `${(result as any).algorithm}: ${(result as any).hash?.slice(0, 16)}...`; + case 'datetime': return `${result.date || result.iso}`; + case 'calculator': return `${result.expression} = ${result.result}`; + case 'random': return `${String(result.type) === 'pick' ? '🎲 ' : ''}${result.result}`; + case 'uuid': return `${result.result}`; + case 'json_format': return `${Number(result.keys) || 0} keys, ${result.formatted_size}B`; + case 'hash': return `${result.algorithm}: ${String(result.hash ?? '').slice(0, 16)}...`; default: return '完成'; } } @@ -206,8 +206,8 @@ export async function setupIPC(): Promise { case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; limit?: number; offset?: number }); break; case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; use_regex?: boolean; max_results?: number; file_extensions?: string[] }); break; case 'create_directory': result = await handleCreateDir(args as { path: string }); break; - case 'delete_file': result = await handleDeleteFile(args as { path: string; recursive?: boolean }); break; - case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); // run_command 自身管理日志 + case 'delete_file': result = await handleDeleteFile(args as { path?: string; paths?: string[]; recursive?: boolean }); break; + case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string }); // run_command 自身管理日志 case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break; case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break; case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }); break; @@ -236,7 +236,7 @@ export async function setupIPC(): Promise { case 'browser_type': result = await browserType(args.selector as string, args.text as string, args.clear !== false, args.submit as boolean || false); break; case 'browser_scroll': result = await browserScroll({ direction: args.direction as string, selector: args.selector as string }); break; case 'browser_wait': result = await browserWait({ selector: args.selector as string, time_ms: args.time_ms as number }); break; - case 'browser_close': browserClose(); result = { success: true }; break; + case 'browser_close': await browserClose(); result = { success: true }; break; default: return { success: false, error: `未知工具: ${toolName}` }; } // 结果日志 @@ -588,14 +588,12 @@ function validateMemoryContent(content: string): boolean { const firstLine = lines[0]?.trim(); if (firstLine !== '# METONA MEMORY') return false; - // 检查是否有 ## 条目头,且格式正确 - let entryCount = 0; + // 检查 ## 条目头格式是否正确 for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('>')) continue; const match = trimmed.match(/^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i); if (match) { - entryCount++; const importance = parseInt(match[3], 10); if (importance < 1 || importance > 10) return false; const tagsStr = match[4]?.trim(); @@ -635,89 +633,86 @@ function parseVideoInfo(stderr: string): { duration: number; width: number; heig return { duration, width, height }; } -function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> { - return new Promise(async (resolve) => { - const tmpDir = path.join(os.tmpdir(), `metona-video-${crypto.randomBytes(6).toString('hex')}`); - try { - await fs.promises.mkdir(tmpDir, { recursive: true }); +async function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> { + const tmpDir = path.join(os.tmpdir(), `metona-video-${crypto.randomBytes(6).toString('hex')}`); + try { + await fs.promises.mkdir(tmpDir, { recursive: true }); - // ffmpeg 提取帧:1fps,同时捕获 stderr 解析进度和视频信息 - const outPattern = path.join(tmpDir, 'frame_%04d.jpg'); - let ffmpegStderr = ''; - let lastProgressFrame = 0; - let lastProgressTime = Date.now(); + // ffmpeg 提取帧:1fps,同时捕获 stderr 解析进度和视频信息 + const outPattern = path.join(tmpDir, 'frame_%04d.jpg'); + let ffmpegStderr = ''; + let lastProgressFrame = 0; + let lastProgressTime = Date.now(); - await new Promise((ffResolve, ffReject) => { - const child = execFile(getFFmpegPath(), [ - '-y', - '-i', filePath, - '-vf', `scale=${maxWidth}:-1,fps=1`, - '-q:v', '3', - '-frames:v', String(maxFrames), - '-threads', '2', - '-progress', 'pipe:1', // 结构化进度输出到 stdout - outPattern - ], { timeout: 120_000 }, (err) => { - if (err) { ffReject(new Error(`ffmpeg 执行失败: ${err.message}`)); return; } - ffResolve(); - }); - child.stderr?.on('data', (data: Buffer) => { - ffmpegStderr += data.toString(); - // 实时推送进度到渲染进程(每秒一次,覆盖更新) - const match = ffmpegStderr.match(/frame=\s*(\d+)/g); - if (match) { - const last = match[match.length - 1]; - const currentFrame = parseInt(last.replace(/\D/g, '')); - if (currentFrame > lastProgressFrame && Date.now() - lastProgressTime > 1000) { - lastProgressFrame = currentFrame; - lastProgressTime = Date.now(); - mainWindow?.webContents.send('video:progress', { current: currentFrame }); - } - } - }); + await new Promise((ffResolve, ffReject) => { + const child = execFile(getFFmpegPath(), [ + '-y', + '-i', filePath, + '-vf', `scale=${maxWidth}:-1,fps=1`, + '-q:v', '3', + '-frames:v', String(maxFrames), + '-threads', '2', + '-progress', 'pipe:1', // 结构化进度输出到 stdout + outPattern + ], { timeout: 120_000 }, (err) => { + if (err) { ffReject(new Error(`ffmpeg 执行失败: ${err.message}`)); return; } + ffResolve(); }); + child.stderr?.on('data', (data: Buffer) => { + ffmpegStderr += data.toString(); + // 实时推送进度到渲染进程(每秒一次,覆盖更新) + const match = ffmpegStderr.match(/frame=\s*(\d+)/g); + if (match) { + const last = match[match.length - 1]; + const currentFrame = parseInt(last.replace(/\D/g, '')); + if (currentFrame > lastProgressFrame && Date.now() - lastProgressTime > 1000) { + lastProgressFrame = currentFrame; + lastProgressTime = Date.now(); + mainWindow?.webContents.send('video:progress', { current: currentFrame }); + } + } + }); + }); - // 从 stderr 解析视频信息,失败则从帧数估算 - let videoInfo = parseVideoInfo(ffmpegStderr); - if (!videoInfo) { - videoInfo = { duration: 0, width: 0, height: 0 }; - } - - // 读取提取的帧 - const files = await fs.promises.readdir(tmpDir); - const jpegs = files.filter(f => f.endsWith('.jpg')).sort(); - if (jpegs.length === 0) { - await fs.promises.rm(tmpDir, { recursive: true, force: true }); - resolve({ success: false, error: '没有提取到视频帧' }); - return; - } - - // 如果没解析到时长,从帧数估算(fps=1) - if (videoInfo.duration <= 0) { - videoInfo.duration = jpegs.length; - } - - const frames: ExtractedFrame[] = []; - for (let i = 0; i < jpegs.length; i++) { - const f = jpegs[i]; - const buf = await fs.promises.readFile(path.join(tmpDir, f)); - if (buf.length < 200) continue; - const timestampSec = i; // fps=1,帧索引 = 秒数 - frames.push({ - name: `frame_${String(i + 1).padStart(2, '0')}_${timestampSec.toFixed(1)}s.jpg`, - base64: buf.toString('base64'), - timestampSeconds: timestampSec - }); - } - - await fs.promises.rm(tmpDir, { recursive: true, force: true }); - - resolve({ success: true, frames, videoInfo }); - - } catch (err) { - try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } - sendLog('error', '视频帧提取失败', (err as Error).message); - resolve({ success: false, error: (err as Error).message }); + // 从 stderr 解析视频信息,失败则从帧数估算 + let videoInfo = parseVideoInfo(ffmpegStderr); + if (!videoInfo) { + videoInfo = { duration: 0, width: 0, height: 0 }; } - }); + + // 读取提取的帧 + const files = await fs.promises.readdir(tmpDir); + const jpegs = files.filter(f => f.endsWith('.jpg')).sort(); + if (jpegs.length === 0) { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + return { success: false, error: '没有提取到视频帧' }; + } + + // 如果没解析到时长,从帧数估算(fps=1) + if (videoInfo.duration <= 0) { + videoInfo.duration = jpegs.length; + } + + const frames: ExtractedFrame[] = []; + for (let i = 0; i < jpegs.length; i++) { + const f = jpegs[i]; + const buf = await fs.promises.readFile(path.join(tmpDir, f)); + if (buf.length < 200) continue; + const timestampSec = i; // fps=1,帧索引 = 秒数 + frames.push({ + name: `frame_${String(i + 1).padStart(2, '0')}_${timestampSec.toFixed(1)}s.jpg`, + base64: buf.toString('base64'), + timestampSeconds: timestampSec + }); + } + + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + + return { success: true, frames, videoInfo }; + + } catch (err) { + try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + sendLog('error', '视频帧提取失败', (err as Error).message); + return { success: false, error: (err as Error).message }; + } } diff --git a/src/main/main.ts b/src/main/main.ts index d138ab1..1b7341b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -197,7 +197,7 @@ app.on('window-all-closed', () => { app.on('before-quit', async () => { isQuitting = true; // 清理浏览器 - browserClose(); + browserClose().catch(() => {}); // 清理 MCP 服务器 stopAllServers(); // 清理所有工作空间进程 diff --git a/src/main/menu.ts b/src/main/menu.ts index 4dc1630..10525d6 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -101,7 +101,7 @@ export function createMenu(): void { dialog.showMessageBox(mainWindow!, { type: 'info', title: '关于 Metona Ollama', - message: 'Metona Ollama Desktop v0.16.2', + message: 'Metona Ollama Desktop v0.16.7', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama', icon: getIconPath() }); diff --git a/src/main/tool-handlers-fs.ts b/src/main/tool-handlers-fs.ts index 61e0b65..940fdda 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -243,21 +243,24 @@ export async function handleListDir(params: { path: string; recursive?: boolean; if (!allowed.ok) return { success: false, error: allowed.reason }; const startOffset = params.offset || 0; + const MAX_ENTRIES = 2000; const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = []; let truncated = false; let skipped = 0; async function scanDir(dir: string, depth: number): Promise { + if (truncated) return; if (params.recursive && params.max_depth && depth > params.max_depth) return; const items = await fs.readdir(dir, { withFileTypes: true }); for (const item of items) { + if (truncated) return; if (!params.include_hidden && item.name.startsWith('.')) continue; if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue; const fullPath = path.join(dir, item.name); - // P0 修复:递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树) + // 递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树) if (params.recursive && item.isDirectory()) { await scanDir(fullPath, depth + 1); continue; @@ -266,6 +269,9 @@ export async function handleListDir(params: { path: string; recursive?: boolean; // 分页:跳过前 offset 个条目(仅对非目录条目计数) if (skipped < startOffset) { skipped++; continue; } + // 截断保护:超过 2000 条标记 truncated + if (entries.length >= MAX_ENTRIES) { truncated = true; return; } + const stat = await fs.stat(fullPath); entries.push({ name: params.recursive ? path.relative(dirPath, fullPath) : item.name, @@ -413,9 +419,67 @@ export async function handleCreateDir(params: { path: string }): Promise { +export async function handleDeleteFile(params: { path?: string; paths?: string[]; recursive?: boolean }): Promise { + // 收集所有要删除的路径 + const allPaths: string[] = []; + if (params.paths && Array.isArray(params.paths) && params.paths.length > 0) { + allPaths.push(...params.paths); + } + if (params.path) { + allPaths.push(params.path); + } + if (allPaths.length === 0) { + return { success: false, error: '未提供 path 或 paths 参数' }; + } + + // 单个路径 — 保持原逻辑(返回单个结果) + if (allPaths.length === 1) { + return handleDeleteSingle(allPaths[0], params.recursive); + } + + // 批量删除 + const results: Array<{ path: string; success: boolean; error?: string; type?: string; deletedSize?: number; filesDeleted?: number }> = []; + let totalDeletedSize = 0; + let totalFilesDeleted = 0; + let successCount = 0; + let failCount = 0; + + for (const p of allPaths) { + const result = await handleDeleteSingle(p, params.recursive); + if (result.success) { + successCount++; + totalDeletedSize += (result as Record).deletedSize as number || 0; + totalFilesDeleted += (result as Record).filesDeleted as number || 1; + results.push({ + path: (result as Record).path as string, + success: true, + type: (result as Record).type as string, + deletedSize: (result as Record).deletedSize as number, + ...((result as Record).filesDeleted !== undefined && { filesDeleted: (result as Record).filesDeleted as number }), + }); + } else { + failCount++; + results.push({ path: p, success: false, error: (result as Record).error as string }); + } + } + + return { + success: failCount === 0, + batch: true, + totalPaths: allPaths.length, + successCount, + failCount, + results, + deletedSize: totalDeletedSize, + ...(totalFilesDeleted > 0 && { filesDeleted: totalFilesDeleted }), + ...(failCount > 0 && { error: `${failCount} 个路径删除失败` }), + }; +} + +/** 删除单个文件或目录 */ +async function handleDeleteSingle(rawPath: string, recursive?: boolean): Promise { try { - const filePath = resolvePath(params.path); + const filePath = resolvePath(rawPath); const allowed = checkPathAllowed(filePath, 'write'); if (!allowed.ok) return { success: false, error: allowed.reason }; @@ -442,7 +506,7 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole await countDir(filePath); deletedCount = fileCount; - if (params.recursive) { + if (recursive) { await fs.rm(filePath, { recursive: true, force: true }); } else { await fs.rmdir(filePath); @@ -453,6 +517,7 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole return { success: true, path: filePath, deleted: true, + message: `已删除${isDir ? '目录' : '文件'}:${filePath}`, type: isDir ? 'directory' : 'file', ...(isDir && { filesDeleted: deletedCount }), deletedSize, diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index caed0f2..a15ba02 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -11,11 +11,12 @@ import { mainWindow } from './main.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; import { getWorkspaceDir } from './workspace.js'; import { getSetting } from './db/sqlite.js'; +import { browserOpen, browserExtract, browserClose } from './browser.js'; /** 当前工具命令进程(用于用户手动终止) */ let _toolProc: ReturnType | null = null; -export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise { +export async function handleRunCommand(params: { command: string; cwd?: string }): Promise { try { const cmdCheck = checkCommandAllowed(params.command); if (!cmdCheck.ok) { @@ -375,8 +376,6 @@ function htmlToMarkdown(html: string): string { return text.trim(); } -import { browserOpen, browserExtract, browserClose } from './browser.js'; - // ────────────────────────────────────────────────── // web_fetch 重试配置 // ────────────────────────────────────────────────── @@ -407,7 +406,7 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s if (!openResult.success) return null; await new Promise(r => setTimeout(r, 2500)); // 等待 JS 渲染 const extractResult = await browserExtract(); - browserClose(); + await browserClose(); if (extractResult.success && extractResult.text && extractResult.text.length > 0) { let text = extractResult.text; if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars); @@ -419,7 +418,7 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s } return null; } catch { - try { browserClose(); } catch { /* */ } + try { await browserClose(); } catch { /* */ } return null; } } @@ -1649,7 +1648,7 @@ export function handleCalculator(params: { expression: string }): ToolResult { function safeCalc(expr: string): number { expr = expr.replace(/\s+/g, ''); - if (!/^[\d+\-*/().%^]+$/.test(expr)) { + if (!/^[\d+\-*/().%]+$/.test(expr)) { throw new Error('表达式包含非法字符'); } let pos = 0; diff --git a/src/renderer/components/header.ts b/src/renderer/components/header.ts index 14b480d..edd54cf 100644 --- a/src/renderer/components/header.ts +++ b/src/renderer/components/header.ts @@ -5,7 +5,7 @@ import { state, KEYS } from '../state/state.js'; import { formatSize } from '../utils/utils.js'; import { OllamaAPI } from '../api/ollama.js'; -import { logConnection, logError, logDebug } from '../services/log-service.js'; +import { logConnection, logError, logDebug, logInfo } from '../services/log-service.js'; let connStatusEl: HTMLElement; @@ -54,7 +54,7 @@ export async function updateConnectionInfo(): Promise { badge.className = 'status-badge success'; verEl.textContent = `v${version.version}`; corsHint.style.display = 'none'; - logConnection('info', `Ollama v${version.version}`); + logInfo(`Ollama 服务器版本: v${version.version}`); } catch (err) { badge.textContent = '✗ 未连接'; badge.className = 'status-badge error'; diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index aa4ee88..3373c54 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -15,9 +15,10 @@ import { showToast } from './toast.js'; import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js'; import { ChatDB } from '../db/chat-db.js'; import { OllamaAPI } from '../api/ollama.js'; -import { runAgentLoop } from '../services/agent-engine.js'; +import { runAgentLoop, formatToolResultForModel } from '../services/agent-engine.js'; import { estimateTokens } from '../services/context-manager.js'; import { showToolConfirm } from './tool-confirm-modal.js'; +import { showConfirm } from './prompt-modal.js'; import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js'; import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js'; @@ -95,9 +96,9 @@ export function initInputArea(): void { // Ctrl+K: 清空聊天(需要确认) if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); - if (confirm('确定要清空当前对话吗?')) { - clearMessages(); - } + showConfirm('确定要清空当前对话吗?', '清空对话').then(ok => { + if (ok) clearMessages(); + }); } // Escape: 停止生成(当正在流式输出时) if (e.key === 'Escape' && state.get(KEYS.IS_STREAMING)) { @@ -1023,17 +1024,21 @@ function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage result.push(assistantMsg); // ── 注入 tool 结果消息(role: 'tool')── + // 复用 formatToolResultForModel 保持与当前 Loop 格式一致 if (msg.toolCalls?.length) { for (const tc of msg.toolCalls) { if (!tc.result) continue; if (result.length >= maxCount) break; - const resultContent = tc.status === 'success' - ? JSON.stringify(tc.result) - : JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' }); + const formattedResult = formatToolResultForModel(tc.name, tc.result); + // R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记 + const resultDuration = Date.now() - tc.timestamp; + const resultSize = formattedResult.length; + const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small'; + const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`; result.push({ role: 'tool', tool_name: tc.name, - content: resultContent, + content: `<<>>\n${r92Header}\n${formattedResult}\n<<>>`, }); } } diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index 2e778a3..2a23ced 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -285,6 +285,51 @@ export function initSettingsModal(): void { } }); } + + // ── 子代理相关设置(原顶层事件监听器移入此处,确保 DOM 已就绪)── + // 子代理模型设置保存 + document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => { + const db = state.get(KEYS.DB); + if (!db) return; + const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value; + await db.saveSetting('subAgentModel', val); + logSetting('子代理默认模型', val || '跟随当前模型'); + }); + + // 子代理最大轮次 + const saveSubAgentMaxLoops = debounce(async () => { + const db = state.get(KEYS.DB); + const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim(); + const maxLoops = val ? parseInt(val) : 10; + state.set('subAgentMaxLoops', maxLoops); + if (db) await db.saveSetting('subAgentMaxLoops', maxLoops); + logSetting('子代理最大轮次', `${maxLoops} 轮`); + }, 500); + document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops); + + // 子代理超时(秒) + const saveSubAgentTimeout = debounce(async () => { + const db = state.get(KEYS.DB); + const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim(); + const sec = val ? parseInt(val) : -1; // -1=默认 + const ms = sec >= 0 ? sec * 1000 : 300_000; + state.set('subAgentTimeout', ms); + if (db) await db.saveSetting('subAgentTimeout', sec); + logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)'); + }, 500); + document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout); + + // ── 看门狗超时(分钟,0=禁用)── + const saveLoopWatchdog = debounce(async () => { + const db = state.get(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); } export function openSettingsModal(): void { @@ -429,50 +474,6 @@ async function populateSubAgentModels(): Promise { } } -// 子代理模型设置保存 -document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => { - const db = state.get(KEYS.DB); - if (!db) return; - const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value; - await db.saveSetting('subAgentModel', val); - logSetting('子代理默认模型', val || '跟随当前模型'); -}); - -// 子代理最大轮次 -const saveSubAgentMaxLoops = debounce(async () => { - const db = state.get(KEYS.DB); - const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim(); - const maxLoops = val ? parseInt(val) : 10; - state.set('subAgentMaxLoops', maxLoops); - if (db) await db.saveSetting('subAgentMaxLoops', maxLoops); - logSetting('子代理最大轮次', `${maxLoops} 轮`); -}, 500); -document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops); - -// 子代理超时(秒) -const saveSubAgentTimeout = debounce(async () => { - const db = state.get(KEYS.DB); - const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim(); - const sec = val ? parseInt(val) : -1; // -1=默认 - const ms = sec >= 0 ? sec * 1000 : 300_000; - state.set('subAgentTimeout', ms); - if (db) await db.saveSetting('subAgentTimeout', sec); - logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)'); -}, 500); -document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout); - -// ── 看门狗超时(分钟,0=禁用)── -const saveLoopWatchdog = debounce(async () => { - const db = state.get(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); - async function importSessions(filePath: string): Promise { const db = state.get(KEYS.DB); if (!db) return; diff --git a/src/renderer/components/token-dashboard.ts b/src/renderer/components/token-dashboard.ts index 4684bc0..2bbbc15 100644 --- a/src/renderer/components/token-dashboard.ts +++ b/src/renderer/components/token-dashboard.ts @@ -4,7 +4,7 @@ */ import { state, KEYS } from '../state/state.js'; -import { formatTime } from '../utils/utils.js'; +import { formatTime, escapeHtml } from '../utils/utils.js'; import { logError } from '../services/log-service.js'; import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js'; import { estimateTokens } from '../services/context-manager.js'; @@ -39,17 +39,6 @@ interface AllSessionsTokenStats { totals: GlobalTokenTotals; } -/** P1 #5: HTML 转义,防止 XSS */ -function escapeHtml(s: unknown): string { - if (s == null) return ''; - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - /** 打开仪表盘 */ export function openTokenDashboard(): void { if (!dashboardModalEl) return; @@ -198,7 +187,7 @@ async function fetchGlobalStats(): Promise { return result; } } catch (err) { - logError('Token Dashboard: 获取全局统计失败', String(err)); + logError('Token Dashboard: 获取全局统计失败', (err as Error).message); } return null; } diff --git a/src/renderer/components/tool-confirm-modal.ts b/src/renderer/components/tool-confirm-modal.ts index f37c946..3bfe16c 100644 --- a/src/renderer/components/tool-confirm-modal.ts +++ b/src/renderer/components/tool-confirm-modal.ts @@ -1,5 +1,8 @@ /** * ToolConfirmModal - 工具调用确认对话框 + * + * 队列模式:当多个工具并行执行需要确认时,按 FIFO 队列逐个确认, + * 避免单例竞态导致前一个确认被自动取消。 */ import type { ToolCall } from '../types.js'; @@ -8,9 +11,18 @@ import { logDebug, logInfo } from '../services/log-service.js'; import { escapeHtml } from '../utils/utils.js'; let modalEl: HTMLElement | null = null; -let resolveConfirm: ((confirmed: boolean) => void) | null = null; +let confirmBtn: HTMLButtonElement | null = null; +let cancelBtn: HTMLButtonElement | null = null; let initialized = false; +/** 确认请求队列 */ +interface ConfirmRequest { + call: ToolCall; + resolve: (confirmed: boolean) => void; +} +const confirmQueue: ConfirmRequest[] = []; +let isShowing = false; + export function initToolConfirmModal(): void { modalEl = document.querySelector('#toolConfirmModal')!; if (!modalEl || initialized) return; @@ -18,7 +30,7 @@ export function initToolConfirmModal(): void { modalEl.addEventListener('click', (e) => { if (e.target === modalEl) { - cancelConfirm(); + cancelCurrent(); } }); } @@ -31,91 +43,105 @@ export function showToolConfirm(call: ToolCall): Promise { if (!initialized) { initialized = true; modalEl.addEventListener('click', (e) => { - if (e.target === modalEl) cancelConfirm(); + if (e.target === modalEl) cancelCurrent(); }); } } - // 如果有未完成的确认,先取消 - if (resolveConfirm) { - const oldResolve = resolveConfirm; - resolveConfirm = null; - oldResolve(false); + // 加入队列,如果当前没有显示的确认框,则立即显示 + confirmQueue.push({ call, resolve }); + if (!isShowing) { + showNext(); } - - resolveConfirm = resolve; - - const icon = getToolIcon(call.function.name); - const name = formatToolName(call.function.name); - logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments)); - - const titleEl = modalEl.querySelector('#toolConfirmTitle')!; - const bodyEl = modalEl.querySelector('#toolConfirmBody')!; - const confirmBtn = modalEl.querySelector('#toolConfirmBtn')! as HTMLButtonElement; - const cancelBtn = modalEl.querySelector('#toolCancelBtn')! as HTMLButtonElement; - - titleEl.textContent = `${icon} 确认操作:${name}`; - - let bodyHtml = `

`; - bodyHtml += `
操作:${name}
`; - - const args = call.function.arguments || {}; - if (call.function.name === 'read_file' || call.function.name === 'write_file' || - call.function.name === 'list_directory' || call.function.name === 'search_files' || - call.function.name === 'create_directory' || call.function.name === 'delete_file') { - if (args.path) { - bodyHtml += `
路径:${escapeHtml(String(args.path))}
`; - } - } - if (call.function.name === 'run_command') { - if (args.command) { - bodyHtml += `
命令:${escapeHtml(String(args.command))}
`; - } - } - if (call.function.name === 'write_file' && args.content) { - const preview = String(args.content).slice(0, 500); - bodyHtml += `
内容预览:
`; - bodyHtml += `
${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}
`; - } - - bodyHtml += `
`; - bodyEl.innerHTML = bodyHtml; - - modalEl.style.display = ''; - - const onConfirm = (e: Event) => { - e.stopPropagation(); - const r = resolveConfirm; - cleanup(); - logInfo(`已确认: ${call.function.name}`); - r?.(true); - }; - const onCancel = (e: Event) => { - e.stopPropagation(); - const r = resolveConfirm; - cleanup(); - logInfo(`已取消: ${call.function.name}`); - r?.(false); - }; - const cleanup = () => { - confirmBtn.removeEventListener('click', onConfirm); - cancelBtn.removeEventListener('click', onCancel); - if (modalEl) modalEl.style.display = 'none'; - resolveConfirm = null; - }; - - confirmBtn.addEventListener('click', onConfirm); - cancelBtn.addEventListener('click', onCancel); }); } -function cancelConfirm(): void { - if (resolveConfirm) { - const r = resolveConfirm; - resolveConfirm = null; - if (modalEl) modalEl.style.display = 'none'; - r(false); +/** 显示队列中的下一个确认 */ +function showNext(): void { + if (confirmQueue.length === 0) { + isShowing = false; + return; } + isShowing = true; + + const { call, resolve } = confirmQueue.shift()!; + const icon = getToolIcon(call.function.name); + const name = formatToolName(call.function.name); + logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments)); + + const titleEl = modalEl!.querySelector('#toolConfirmTitle')!; + const bodyEl = modalEl!.querySelector('#toolConfirmBody')!; + confirmBtn = modalEl!.querySelector('#toolConfirmBtn') as HTMLButtonElement; + cancelBtn = modalEl!.querySelector('#toolCancelBtn') as HTMLButtonElement; + + titleEl.textContent = `${icon} 确认操作:${name}`; + + let bodyHtml = `
`; + bodyHtml += `
操作:${name}
`; + + const args = call.function.arguments || {}; + if (call.function.name === 'read_file' || call.function.name === 'write_file' || + call.function.name === 'list_directory' || call.function.name === 'search_files' || + call.function.name === 'create_directory' || call.function.name === 'delete_file') { + // 支持 paths 数组(批量删除)和单个 path + if (args.paths && Array.isArray(args.paths) && args.paths.length > 0) { + bodyHtml += `
路径(${args.paths.length} 项):
`; + bodyHtml += `
${escapeHtml(args.paths.map((p: string) => String(p)).join('\n'))}
`; + } else if (args.path) { + bodyHtml += `
路径:${escapeHtml(String(args.path))}
`; + } + } + if (call.function.name === 'run_command') { + if (args.command) { + bodyHtml += `
命令:${escapeHtml(String(args.command))}
`; + } + } + if (call.function.name === 'write_file' && args.content) { + const preview = String(args.content).slice(0, 500); + bodyHtml += `
内容预览:
`; + bodyHtml += `
${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}
`; + } + + bodyHtml += `
`; + bodyEl.innerHTML = bodyHtml; + + // 队列计数提示 + if (confirmQueue.length > 0) { + bodyEl.querySelector('.tool-confirm-info')!.insertAdjacentHTML('beforeend', + `
队列中还有 ${confirmQueue.length} 个待确认
`); + } + + modalEl!.style.display = ''; + + const onConfirm = (e: Event) => { + e.stopPropagation(); + cleanup(); + logInfo(`已确认: ${call.function.name}`); + resolve(true); + showNext(); + }; + const onCancel = (e: Event) => { + e.stopPropagation(); + cleanup(); + logInfo(`已取消: ${call.function.name}`); + resolve(false); + showNext(); + }; + const cleanup = () => { + confirmBtn?.removeEventListener('click', onConfirm); + cancelBtn?.removeEventListener('click', onCancel); + if (modalEl) modalEl.style.display = 'none'; + }; + + confirmBtn.addEventListener('click', onConfirm); + cancelBtn.addEventListener('click', onCancel); } - +/** 取消当前显示的确认(不影响队列中的后续确认) */ +function cancelCurrent(): void { + if (!isShowing) return; + // 当前显示的确认已从队列 shift 出来,直接触发取消按钮 + if (cancelBtn) { + cancelBtn.click(); + } +} diff --git a/src/renderer/components/tools-modal.ts b/src/renderer/components/tools-modal.ts index b5f4066..f7aaa40 100644 --- a/src/renderer/components/tools-modal.ts +++ b/src/renderer/components/tools-modal.ts @@ -1,35 +1,43 @@ /** * ToolsModal - 工具面板模态框 * 展示所有可用工具的详细信息。Tool Calling 始终开启,不可关闭。 - * 支持三档开关(auto/confirm/disabled)的工具统一管理。 + * 全局执行模式统一管理所有工具的确认行为。 */ import { state, KEYS } from '../state/state.js'; -import { setToolMode, getToolMode, type ToolMode } from '../services/tool-registry.js'; +import { setToolMode, type ToolMode } from '../services/tool-registry.js'; import { showToast } from './toast.js'; import { logInfo } from '../services/log-service.js'; import type { ChatDB } from '../db/chat-db.js'; let modalEl: HTMLElement; -const MODE_NAMES: Record = { +const MODE_NAMES: Record = { auto: '自动执行', confirm: '需确认', - disabled: '已禁用', }; -const BADGE_MAP: Record = { - auto: { text: '自动', cls: 'auto' }, - confirm: { text: '需确认', cls: 'confirm' }, - disabled: { text: '禁用', cls: 'disabled' }, -}; +// 需要全局模式管理的工具列表(与 tool-registry.ts MODE_TOOLS 一致) +const MANAGED_TOOLS = [ + 'run_command', + 'write_file', 'create_directory', 'delete_file', + 'edit_file', 'replace_in_files', 'move_file', 'copy_file', + 'download_file', 'compress', +]; -function updateToolBadge(toolName: string, mode: ToolMode): void { - const badge = modalEl.querySelector(`#badge_${toolName}`) as HTMLElement; - if (!badge) return; - const info = BADGE_MAP[mode]; - badge.textContent = info.text; - badge.className = `tool-card-badge ${info.cls}`; +function updateAllBadges(mode: string): void { + const badgeText = mode === 'auto' ? '自动' : '需确认'; + const badgeCls = mode === 'auto' ? 'auto' : 'confirm'; + // 更新所有被管理工具的 badge + const badges = modalEl.querySelectorAll('.tool-card-badge'); + badges.forEach(badge => { + // 只更新被管理工具的 badge(通过 tool-card-name 文本匹配) + const nameEl = badge.closest('.tool-card')?.querySelector('.tool-card-name'); + if (nameEl && MANAGED_TOOLS.includes(nameEl.textContent || '')) { + badge.textContent = badgeText; + badge.className = `tool-card-badge ${badgeCls}`; + } + }); } function updatePlanBadge(autoConfirm: boolean): void { @@ -47,36 +55,33 @@ function updatePlanBadge(autoConfirm: boolean): void { export function initToolsModal(): void { modalEl = document.querySelector('#toolsModal')!; - // Header 按钮 document.querySelector('#btnTools')!.addEventListener('click', openToolsModal); document.querySelector('#btnCloseTools')!.addEventListener('click', closeToolsModal); modalEl.addEventListener('click', (e) => { if (e.target === modalEl) closeToolsModal(); }); - // 所有带 .tool-mode-select 的下拉框统一绑定 change 事件 - const selects = modalEl.querySelectorAll('.tool-mode-select'); - selects.forEach(selectEl => { - selectEl.addEventListener('change', async () => { - const toolName = selectEl.dataset.tool; - if (!toolName) return; - const mode = selectEl.value as ToolMode; + // 全局工具执行模式 + const globalSelect = modalEl.querySelector('#select_global_tool_mode'); + if (globalSelect) { + globalSelect.addEventListener('change', async () => { + const mode = globalSelect.value as ToolMode; const db = state.get(KEYS.DB); - // 更新运行时模式 - setToolMode(toolName, mode); - updateToolBadge(toolName, mode); + // 更新所有被管理工具的运行时模式 + for (const toolName of MANAGED_TOOLS) { + setToolMode(toolName, mode); + } - // 持久化到数据库(所有工具模式合并存储为 JSON) - const allModes = state.get>('toolModes', {}); - allModes[toolName] = mode; - state.set('toolModes', allModes); - if (db) await db.saveSetting('toolModes', allModes); + // 持久化全局模式 + state.set('globalToolMode', mode); + if (db) await db.saveSetting('globalToolMode', mode); - showToast(`${toolName}:${MODE_NAMES[mode]}`, 'info'); - logInfo(`工具模式切换: ${toolName} → ${mode}`); + updateAllBadges(mode); + showToast(`工具执行模式:${MODE_NAMES[mode]}`, 'info'); + logInfo(`全局工具模式切换: → ${mode}`); }); - }); + } // Plan 确认模式下拉框 const planSelect = modalEl.querySelector('#select_plan_confirm'); @@ -96,18 +101,15 @@ export function initToolsModal(): void { } export function openToolsModal(): void { - // 从 state 读取所有工具模式,同步 UI - const allModes = state.get>('toolModes', {}); - const selects = modalEl.querySelectorAll('.tool-mode-select'); - selects.forEach(selectEl => { - const toolName = selectEl.dataset.tool; - if (!toolName) return; - const mode = allModes[toolName] || getToolMode(toolName); - selectEl.value = mode; - updateToolBadge(toolName, mode); - }); + // 同步全局工具执行模式 + const globalMode = state.get('globalToolMode', 'confirm'); + const globalSelect = modalEl.querySelector('#select_global_tool_mode'); + if (globalSelect) { + globalSelect.value = globalMode; + updateAllBadges(globalMode); + } - // 同步 Plan 确认模式下拉框 + // 同步 Plan 确认模式 const planSelect = modalEl.querySelector('#select_plan_confirm'); if (planSelect) { const autoConfirm = state.get('planAutoConfirm', false); diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index 08c08d8..a338d64 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -555,6 +555,12 @@ function renderTerminal(): void { _termRenderedCount = 0; } + // 从 idle 状态切换到有内容时,清空 idle DOM + // idle 状态用 position:absolute 覆盖在内容上方,不清空会同时显示 + if (_termRenderedCount === 0 && container.querySelector('.ws-idle-state')) { + container.innerHTML = ''; + } + // 增量追加新行 const fragment = document.createDocumentFragment(); for (let i = _termRenderedCount; i < session.lines.length; i++) { @@ -1068,7 +1074,16 @@ function renderToolCard(tc: ToolCallRecord): string { } else if (tc.name === 'write_file') { resultHtml = `
✅ 已写入 ${escapeHtml(String(r.path || ''))}
`; } else if (tc.name === 'delete_file') { - resultHtml = `
✅ 已删除 ${escapeHtml(String(r.path || ''))}
`; + if (r.batch) { + resultHtml = `
✅ 批量删除 ${r.successCount}/${r.totalPaths} 个路径
`; + if (r.results) { + for (const res of r.results) { + resultHtml += `
${res.success ? '✅' : '❌'} ${escapeHtml(String(res.path || ''))}${res.success ? '' : ' — ' + escapeHtml(String(res.error || ''))}
`; + } + } + } else { + resultHtml = `
✅ 已删除 ${escapeHtml(String(r.path || ''))}
`; + } } else if (tc.name === 'create_directory') { resultHtml = `
✅ 已创建 ${escapeHtml(String(r.path || ''))}
`; } else { diff --git a/src/renderer/index.html b/src/renderer/index.html index 9b16d3d..e423dcc 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.16.2 + v0.16.7