diff --git a/README.md b/README.md index edd5129..94cad12 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.14.7.exe` +产出:`release/Metona Ollama Setup v0.14.8.exe` ## 🛠️ 常用命令 @@ -501,7 +501,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.14.7.exe` +Output: `release/Metona Ollama Setup v0.14.8.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 6e41b34..fb5c0a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.14.7", + "version": "0.14.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.14.7", + "version": "0.14.8", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index 0e73324..e1a46c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.14.7", + "version": "0.14.8", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a44098a..e6b3b4a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -203,14 +203,14 @@ export async function setupIPC(): Promise { switch (toolName) { case 'read_file': result = await handleReadFile(args as { path: string; encoding?: string; start_line?: number; end_line?: number }); break; case 'write_file': result = await handleWriteFile(args as { path: string; content: string; encoding?: string }); break; - case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }); break; - case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }); break; + 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 '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 }); break; + case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }); break; case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean; fetch_top?: number }); break; case 'edit_file': result = await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean; use_regex?: boolean }); break; case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break; @@ -219,7 +219,7 @@ export async function setupIPC(): Promise { case 'diff_files': result = await handleDiffFiles(args as { file1: string; file2: string; context_lines?: number }); break; case 'replace_in_files': result = await handleReplaceInFiles(args as { path: string; glob: string; old_text: string; new_text: string }); break; case 'read_multiple_files':result = await handleReadMultipleFiles(args as { paths: string[]; max_chars_per_file?: number }); break; - case 'git': result = await handleGit(args as { action: string; path?: string; files?: string[]; message?: string; branch?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }); break; + case 'git': result = await handleGit(args as { action: string; path?: string; files?: string[]; message?: string; branch?: string; tag_name?: string; stash_sub?: string; remote?: string; remote_url?: string; count?: number; all?: boolean; staged?: boolean; new_branch?: boolean; delete_branch?: boolean; force?: boolean; url?: string }); break; case 'compress': result = await handleCompress(args as { action: string; path: string; destination?: string; format?: string }); break; case 'datetime': result = handleDateTime(args as { format?: string; timezone?: string }); break; case 'calculator': result = handleCalculator(args as { expression: string }); break; diff --git a/src/main/tool-handlers-fs.ts b/src/main/tool-handlers-fs.ts index 3d5c2bb..da1ce72 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -267,10 +267,17 @@ export async function handleListDir(params: { path: string; recursive?: boolean; if (!params.include_hidden && item.name.startsWith('.')) continue; if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue; - // 分页:跳过前 offset 个条目 + const fullPath = path.join(dir, item.name); + + // P0 修复:递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树) + if (params.recursive && item.isDirectory()) { + await scanDir(fullPath, depth + 1); + continue; + } + + // 分页:跳过前 offset 个条目(仅对非目录条目计数) if (skipped < startOffset) { skipped++; continue; } - const fullPath = path.join(dir, item.name); const stat = await fs.stat(fullPath); entries.push({ name: params.recursive ? path.relative(dirPath, fullPath) : item.name, @@ -278,10 +285,6 @@ export async function handleListDir(params: { path: string; recursive?: boolean; size: item.isFile() ? stat.size : null, modified: stat.mtime.toISOString() }); - - if (params.recursive && item.isDirectory()) { - await scanDir(fullPath, depth + 1); - } } } @@ -356,9 +359,15 @@ 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(); - const queryToCheck = caseSensitive ? query : query.toLowerCase(); - if (nameToCheck.includes(queryToCheck)) { + let nameMatched: boolean; + if (useRegex) { + nameMatched = contentMatcher(fileName); + } else { + const nameToCheck = caseSensitive ? fileName : fileName.toLowerCase(); + const queryToCheck = caseSensitive ? query : query.toLowerCase(); + nameMatched = nameToCheck.includes(queryToCheck); + } + if (nameMatched) { results.push({ path: filePath, matches: [{ line: 0, text: `[文件名匹配] ${fileName}` }] }); totalMatches++; continue; @@ -702,15 +711,29 @@ function builtinDiff(path1: string, path2: string, contextSize: number): string return diffLines.join('\n'); } +/** 将 glob 模式转换为正则表达式(支持 **, *, ?) */ +function globToRegex(glob: string): RegExp { + // 转义正则特殊字符 + let pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + // ** → 匹配任意路径(含 /) + pattern = pattern.replace(/\*\*/g, '\u0000'); // 临时占位 + // * → 匹配除 / 外的任意字符 + pattern = pattern.replace(/\*/g, '[^/]*'); + // ? → 匹配除 / 外的单个字符 + pattern = pattern.replace(/\?/g, '[^/]'); + // 恢复 ** → .* + pattern = pattern.replace(/\u0000/g, '.*'); + return new RegExp(`^${pattern}$`); +} + export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise { try { const rootPath = resolvePath(params.path); const allowed = checkPathAllowed(rootPath, 'write'); if (!allowed.ok) return { success: false, error: allowed.reason }; - // 简单 glob 匹配 - const pattern = params.glob.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.'); - const regex = new RegExp(`^${pattern}$`); + // 正确的 glob → regex 转换:支持 ** (跨目录通配), * (单层通配), ? (单字符) + const globRegex = globToRegex(params.glob); const results: Array<{ file: string; replacements: number }> = []; let totalReplacements = 0; @@ -726,10 +749,11 @@ export async function handleReplaceInFiles(params: { path: string; glob: string; if (fileCount >= maxFiles) return; if (item.name.startsWith('.')) continue; const fullPath = path.join(dir, item.name); + const relPath = path.relative(rootPath, fullPath); if (item.isDirectory()) { await scanDir(fullPath); - } else if (regex.test(item.name)) { + } else if (globRegex.test(relPath) || globRegex.test(item.name)) { fileCount++; try { const content = await fs.readFile(fullPath, 'utf-8'); diff --git a/src/main/tool-handlers-git.ts b/src/main/tool-handlers-git.ts index ee884ce..caee060 100644 --- a/src/main/tool-handlers-git.ts +++ b/src/main/tool-handlers-git.ts @@ -172,14 +172,16 @@ export async function handleGit(params: { action: string; path?: string; files?: } case 'stash': { - // stash 子命令(push/pop/apply/list/drop/show) - // message 仅作为 stash push 的提交信息 - const subAction = 'push'; // 默认 push - const args = ['stash', subAction]; - if (params.message) args.push('-m', params.message); - const r = await runGit(args); + // stash 子命令(push/pop/apply/list/drop) + // 支持 message 中的 stash 子命令或默认 push + const stashSub = (params as any).stash_sub || 'push'; + const stashArgs = ['stash', stashSub]; + if ((stashSub === 'push') && params.message) { + stashArgs.push('-m', params.message); + } + const r = await runGit(stashArgs); if (r.code !== 0) return { success: false, error: r.stderr }; - return { success: true, action: 'stash', subAction, output: r.stdout.trim() }; + return { success: true, action: 'stash', subAction: stashSub, output: r.stdout.trim() }; } case 'remote': { @@ -225,13 +227,15 @@ export async function handleGit(params: { action: string; path?: string; files?: } case 'tag': { - if (params.branch) { - // branch field reused as tag name - const args = ['tag', params.branch]; - if (params.message) args.push('-m', params.message); - const r = await runGit(args); + // 支持显式 tag_name 参数,回退兼容旧的 branch 参数 + const tagName = (params as any).tag_name || params.branch; + if (tagName) { + const tagArgs = ['tag']; + if (params.message) tagArgs.push('-a', tagName, '-m', params.message); + else tagArgs.push(tagName); + const r = await runGit(tagArgs); if (r.code !== 0) return { success: false, error: r.stderr }; - return { success: true, action: 'tag', created: params.branch }; + return { success: true, action: 'tag', created: tagName }; } const r = await runGit(['tag', '-l']); if (r.code !== 0) return { success: false, error: r.stderr }; diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index 1776f7e..769d624 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -5,6 +5,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { spawn } from 'child_process'; +import * as crypto from 'crypto'; import { checkPathAllowed, checkCommandAllowed } from './tool-security.js'; import { mainWindow } from './main.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; @@ -52,18 +53,39 @@ export async function handleRunCommand(params: { command: string; cwd?: string; let stdout = ''; let stderr = ''; + const MAX_OUTPUT = 512 * 1024; // 512KB 上限,防止 OOM + let stdoutTruncated = false; + let stderrTruncated = false; // 实时推送到工作空间终端 _toolProc.stdout?.on('data', (data: Buffer) => { const str = data.toString('utf-8'); - stdout += str; - mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str }); + if (!stdoutTruncated) { + if (stdout.length + str.length > MAX_OUTPUT) { + const remaining = MAX_OUTPUT - stdout.length; + stdout += str.slice(0, remaining); + stdoutTruncated = true; + mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str.slice(0, remaining) + '\n[stdout 超出 512KB 限制,已截断]' }); + } else { + stdout += str; + mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str }); + } + } }); _toolProc.stderr?.on('data', (data: Buffer) => { const str = data.toString('utf-8'); - stderr += str; - mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str }); + if (!stderrTruncated) { + if (stderr.length + str.length > MAX_OUTPUT) { + const remaining = MAX_OUTPUT - stderr.length; + stderr += str.slice(0, remaining); + stderrTruncated = true; + mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str.slice(0, remaining) + '\n[stderr 超出 512KB 限制,已截断]' }); + } else { + stderr += str; + mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str }); + } + } }); _toolProc.on('close', (code) => { @@ -72,13 +94,15 @@ export async function handleRunCommand(params: { command: string; cwd?: string; sendLog( code === 0 ? 'success' : 'warn', `🔧 run_command 完成`, - `exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B` + `exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B${stdoutTruncated ? '(已截断)' : ''} | stderr: ${stderr.length}B${stderrTruncated ? '(已截断)' : ''}` ); mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code }); resolve({ success: code === 0, stdout, stderr, + stdout_truncated: stdoutTruncated || undefined, + stderr_truncated: stderrTruncated || undefined, exitCode: code, duration, cwd, @@ -95,6 +119,8 @@ export async function handleRunCommand(params: { command: string; cwd?: string; success: false, stdout, stderr: stderr + (stderr ? '\n' : '') + err.message, + stdout_truncated: stdoutTruncated || undefined, + stderr_truncated: stderrTruncated || undefined, exitCode: 1, error: err.message, duration, @@ -204,8 +230,10 @@ function isBlockedPage(html: string): boolean { function jitter(ms: number): number { return ms + Math.floor(Math.random() * ms * 0.6); } /** 构建 fetch 请求头,根据尝试次数轮换 UA 和语言 */ -function buildFetchHeaders(url: string, attemptIndex: number): Record { - const ua = UA_POOL[attemptIndex % UA_POOL.length]; +function buildFetchHeaders(url: string, attemptIndex: number, useMobileUA: boolean = false): Record { + const ua = useMobileUA + ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1' + : UA_POOL[attemptIndex % UA_POOL.length]; const lang = LANG_POOL[attemptIndex % LANG_POOL.length]; let referer = 'https://www.google.com/'; try { @@ -302,6 +330,69 @@ function htmlToText(html: string): string { return text.trim(); } +/** HTML → Markdown 转换(保留标题、列表、链接、代码块等结构) */ +function htmlToMarkdown(html: string): string { + let text = html; + // 移除 script/style/nav/header/footer 等噪音标签 + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + text = text.replace(//g, ''); + + // 标题 → Markdown 标题 + text = text.replace(/]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n'); + text = text.replace(/]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n'); + text = text.replace(/]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n'); + text = text.replace(/]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n'); + text = text.replace(/]*>([\s\S]*?)<\/h5>/gi, '\n##### $1\n'); + text = text.replace(/]*>([\s\S]*?)<\/h6>/gi, '\n###### $1\n'); + + // 代码块 + text = text.replace(/]*>([\s\S]*?)<\/pre>/gi, '\n```\n$1\n```\n'); + text = text.replace(/]*>([\s\S]*?)<\/code>/gi, '`$1`'); + + // 链接和图片 + text = text.replace(/]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)'); + text = text.replace(/]*src=["']([^"']*)["'][^>]*alt=["']([^"']*)["'][^>]*\/?>/gi, '![$2]($1)'); + text = text.replace(/]*src=["']([^"']*)["'][^>]*\/?>/gi, '![]($1)'); + + // 列表 + text = text.replace(/]*>([\s\S]*?)<\/li>/gi, '- $1\n'); + text = text.replace(/<\/?(ul|ol)[^>]*>/gi, '\n'); + + // 引用块 + text = text.replace(/]*>([\s\S]*?)<\/blockquote>/gi, '\n> $1\n'); + + // 表格行 + text = text.replace(/<\/tr>/gi, '|\n'); + text = text.replace(/]*>/gi, '|'); + text = text.replace(/<\/?(td|th)[^>]*>/gi, ''); + + // 块级标签转为换行 + text = text.replace(/<\/(p|div|section|article)[^>]*>/gi, '\n'); + text = text.replace(/<(br|hr)[^>]*\/?>/gi, '\n'); + + // 加粗/斜体 + text = text.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**'); + text = text.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*'); + + // 移除剩余标签 + text = text.replace(/<[^>]+>/g, ''); + // 解码 HTML 实体 + text = decodeHTMLEntities(text); + // 清理多余空白 + text = text.replace(/[ \t]+/g, ' '); + text = text.replace(/\n\s*\n\s*\n+/g, '\n\n'); + text = text.split('\n').map(l => l.trim()).join('\n'); + return text.trim(); +} + import { browserOpen, browserExtract, browserClose } from './browser.js'; // ────────────────────────────────────────────────── @@ -375,7 +466,7 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; await new Promise(r => setTimeout(r, delay)); } - const headers = buildFetchHeaders(url, attempt); + const headers = buildFetchHeaders(url, attempt, useMobileUA); try { const controller = new AbortController(); @@ -441,7 +532,13 @@ export async function handleWebFetch(params: { url: string; max_chars?: number; } const isHTML = contentType.includes('html'); if (isHTML) { - text = htmlToText(text); + // P2 修复:支持 extract_mode 参数 — 'markdown' 模式保留结构化格式,'text' 模式纯文本 + const extractMode = params.extract_mode || 'text'; + if (extractMode === 'markdown') { + text = htmlToMarkdown(text); + } else { + text = htmlToText(text); + } // 检测是否被拦截(Cloudflare/验证码/空白页) if (isBlockedPage(text) && BROWSER_FALLBACK_ENABLED) { sendLog('warn', `🌐 web_fetch 检测到拦截页`, '自动回退浏览器渲染'); @@ -667,7 +764,8 @@ async function handleWebSearchSearxng( })), effectiveMax, false, - engineStats + engineStats, + query, ); result._mode = 'searxng'; return result; @@ -727,10 +825,10 @@ export async function handleWebSearch(params: { query: string; max_results?: num const cached = cacheGet(cacheKey); if (cached) { sendLog('success', `🔍 web_search 缓存命中`, `"${query}" → ${cached.results.length} 条`); - const cachedResult = buildSearchResponse(cached.results, maxResults, true); - cachedResult._mode = 'builtin'; - return cachedResult; - } + const cachedResult = buildSearchResponse(cached.results, maxResults, true, undefined, query); + cachedResult._mode = 'builtin'; + return cachedResult; + } sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条) [并行]`); @@ -875,7 +973,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num // ── 6. 缓存结果 ── cacheSet(cacheKey, { results: finalResults, time: Date.now() }); - const finalResult = buildSearchResponse(finalResults, maxResults, false, engineStats); + const finalResult = buildSearchResponse(finalResults, maxResults, false, engineStats, query); finalResult._mode = 'builtin'; return finalResult; })(); @@ -1045,6 +1143,7 @@ function buildSearchResponse( maxResults: number, fromCache: boolean, engineStats?: Record, + query?: string, ): ToolResult { const sliced = results.slice(0, maxResults); @@ -1083,7 +1182,7 @@ function buildSearchResponse( return { success: true, - query: '', + query: query || '', results: sliced, total: sliced.length, formatted, @@ -1250,17 +1349,42 @@ export async function handleDownloadFile(params: { url: string; destination: str sendLog('info', `⬇️ download_file`, `${params.url} → ${destPath}`); - // 带超时的下载(大文件给更长超时) - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 60_000); - let resp: Response; - try { - resp = await fetch(params.url, { signal: controller.signal }); - } finally { - clearTimeout(timeoutId); + // 带超时和 UA 的下载,支持重试 + const MAX_RETRIES = 3; + const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; + let resp: Response | null = null; + let lastError: string = ''; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 60_000); + try { + resp = await fetch(params.url, { + signal: controller.signal, + headers: { + 'User-Agent': UA, + 'Accept': '*/*', + 'Accept-Encoding': 'gzip, deflate, br', + }, + redirect: 'follow', + }); + clearTimeout(timeoutId); + if (resp.ok) break; // 成功 + lastError = `HTTP ${resp.status}: ${resp.statusText}`; + // 4xx 不重试 + if (resp.status >= 400 && resp.status < 500) break; + } catch (err) { + clearTimeout(timeoutId); + lastError = (err as Error).message; + if (lastError.includes('abort')) lastError = '下载超时 (60s)'; + } + if (attempt < MAX_RETRIES) { + await new Promise(r => setTimeout(r, 1000 * attempt)); // 指数退避 + sendLog('warn', `⬇️ download_file 重试`, `第 ${attempt + 1} 次: ${params.url}`); + } } - if (!resp.ok) return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; + if (!resp || !resp.ok) return { success: false, error: lastError || '下载失败' }; const buf = Buffer.from(await resp.arrayBuffer()); if (buf.length > 50 * 1024 * 1024) { @@ -1311,10 +1435,12 @@ export async function handleCompress(params: { action: string; path: string; des if (format === 'zip') { if (isWin) { - // Windows: PowerShell Compress-Archive + // PowerShell 转义单引号:' → '' + const safeSrc = srcName.replace(/'/g, "''"); + const safeDest = destPath.replace(/'/g, "''"); child = spawn('powershell', [ '-NoProfile', '-Command', - `Compress-Archive -Path '${srcName}' -DestinationPath '${destPath}' -Force` + `Compress-Archive -Path '${safeSrc}' -DestinationPath '${safeDest}' -Force` ], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); } else { child = spawn('zip', ['-r', destPath, srcName], { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); @@ -1355,9 +1481,11 @@ export async function handleCompress(params: { action: string; path: string; des if (isZip) { if (isWin) { + const safeArchive = archivePath.replace(/'/g, "''"); + const safeDest = destDir.replace(/'/g, "''"); child = spawn('powershell', [ '-NoProfile', '-Command', - `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force` + `Expand-Archive -Path '${safeArchive}' -DestinationPath '${safeDest}' -Force` ], { stdio: ['pipe', 'pipe', 'pipe'] }); } else { child = spawn('unzip', ['-o', archivePath, '-d', destDir], { stdio: ['pipe', 'pipe', 'pipe'] }); @@ -1587,16 +1715,11 @@ export function handleRandom(params: { type?: string; min?: number; max?: number case 'string': { const length = Math.min(params.length ?? 8, 256); const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let s = ''; - for (let i = 0; i < length; i++) s += chars.charAt(Math.floor(Math.random() * chars.length)); // 使用 crypto 增强随机性 - const buf = require('crypto').randomBytes(Math.ceil(length * 0.75)); - let bIdx = 0; + const buf = crypto.randomBytes(length); let result = ''; for (let i = 0; i < length; i++) { - const r = buf[bIdx++] || Math.floor(Math.random() * 256); - if (bIdx >= buf.length) bIdx = 0; - result += chars.charAt(r % chars.length); + result += chars.charAt(buf[i] % chars.length); } return { success: true, type: 'string', result, length, charset: 'alphanumeric' }; } diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts index c73938a..0db8429 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -7,6 +7,7 @@ import { logError, logSession } from '../services/log-service.js'; import { marked } from '../utils/marked-config.js'; import { escapeHtml, formatTime } from '../utils/utils.js'; import { estimateTokens } from '../services/context-manager.js'; +import { getToolIcon, formatToolName } from '../services/tool-registry.js'; import type { ChatSession, ChatMessage, ToolCallRecord } from '../types.js'; function getFileIcon(filename: string): string { @@ -111,6 +112,9 @@ export function resetAutoScroll(): void { /** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */ const _renderedMsgIndices = new Set(); +/** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */ +let _sysPromptRendered = false; + export function renderMessages(): void { const currentSession = state.get(KEYS.CURRENT_SESSION); const msgs = currentSession ? currentSession.messages : []; @@ -119,6 +123,7 @@ export function renderMessages(): void { messagesContainerEl.innerHTML = ''; currentPlaceholder = null; _renderedMsgIndices.clear(); + _sysPromptRendered = false; // 重置:每轮全量渲染时重新判定首条 assistant if (msgs.length === 0) { emptyStateEl.style.display = ''; @@ -155,10 +160,13 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void { let safeContent = (msg.content != null) ? String(msg.content) : ''; if (msg.role === 'assistant') { - // ── 系统提示词折叠卡片(每条助理消息顶部展示)── - const sysPrompt = state.get('_lastSystemPrompt', ''); - if (sysPrompt) { - contentHtml += renderSystemPromptCard(sysPrompt); + // ── 系统提示词折叠卡片(仅会话首条 assistant 消息展示,避免重复)── + if (!_sysPromptRendered) { + const sysPrompt = state.get('_lastSystemPrompt', ''); + if (sysPrompt) { + contentHtml += renderSystemPromptCard(sysPrompt); + } + _sysPromptRendered = true; } if (msg.think) { @@ -252,34 +260,12 @@ export function appendMessageDOM(msg: ChatMessage, index: number): void { } function renderToolCallCard(tc: ToolCallRecord): string { - const icons: Record = { - read_file: '📄', write_file: '✏️', list_directory: '📁', - search_files: '🔍', create_directory: '📂', delete_file: '🗑️', run_command: '💻', - move_file: '📦', copy_file: '📋', web_fetch: '🌐', - edit_file: '✂️', get_file_info: 'ℹ️', tree: '🌳', download_file: '⬇️', - diff_files: '🔀', replace_in_files: '🔄', read_multiple_files: '📚', - git: '🔖', compress: '🗜️', web_search: '🔍', - memory: '🧠', session_list: '📋', session_read: '📖', spawn_task: '🤖', plan_track: '📋', - browser_open: '🌐', browser_screenshot: '📸', browser_evaluate: '⚡', browser_extract: '📰', - browser_click: '👆', browser_type: '⌨️', browser_scroll: '↕️', browser_close: '❌' - }; - const names: Record = { - read_file: '读取文件', write_file: '写入文件', list_directory: '列出目录', - search_files: '搜索文件', create_directory: '创建目录', delete_file: '删除文件', run_command: '执行命令', - move_file: '移动文件', copy_file: '复制文件', web_fetch: '网页抓取', - edit_file: '编辑文件', get_file_info: '文件信息', tree: '目录树', download_file: '下载文件', - diff_files: '文件对比', replace_in_files: '批量替换', read_multiple_files: '批量读取', - git: 'Git 操作', compress: '压缩/解压', web_search: '联网搜索', - memory: '记忆管理', 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: '关闭浏览器' - }; const statusLabels: Record = { pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消' }; - const icon = icons[tc.name] || '🔧'; - const name = names[tc.name] || tc.name; + const icon = getToolIcon(tc.name); + const name = formatToolName(tc.name); const status = statusLabels[tc.status] || tc.status; const args = tc.arguments || {}; @@ -328,18 +314,22 @@ export function updateLastAssistantMessage( if (loadingDots) loadingDots.remove(); if (loadingText) loadingText.remove(); - // 流式消息首次转正:注入系统提示词折叠卡片 + // 流式消息首次转正:注入系统提示词折叠卡片(仅当会话中无前序 assistant 消息时) const sysPrompt = state.get('_lastSystemPrompt', ''); if (sysPrompt && !lastMsg.querySelector('.system-prompt-card')) { - const msgBody = lastMsg.querySelector('.msg-body'); - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = renderSystemPromptCard(sysPrompt); - const card = tempDiv.firstElementChild!; - const thinkBlock = msgBody?.querySelector('.think-block'); - if (thinkBlock) { - msgBody!.insertBefore(card, thinkBlock); - } else { - msgBody!.insertBefore(card, msgBody!.firstChild); + const session = state.get(KEYS.CURRENT_SESSION); + const hasPrevAssistant = session?.messages.some(m => m.role === 'assistant') ?? false; + if (!hasPrevAssistant) { + const msgBody = lastMsg.querySelector('.msg-body'); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = renderSystemPromptCard(sysPrompt); + const card = tempDiv.firstElementChild!; + const thinkBlock = msgBody?.querySelector('.think-block'); + if (thinkBlock) { + msgBody!.insertBefore(card, thinkBlock); + } else { + msgBody!.insertBefore(card, msgBody!.firstChild); + } } } } diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index b265b2a..2313100 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -503,6 +503,8 @@ async function handleRetry(): Promise { let retryContent = ''; let retryThinkContent = ''; let retryIterations = 0; + // P1 修复:追踪当前迭代的工具记录(与 send 路径保持一致) + let retryIterationToolRecords: ToolCallRecord[] = []; // 构建重试用的完整内容和 images(含文件内容和视频帧) let retryUserContent = userMsg.content || ''; @@ -537,7 +539,9 @@ async function handleRetry(): Promise { role: 'assistant', content: retryContent || '', model: getSelectedModel(), timestamp: now, ...(retryThinkContent && { think: retryThinkContent }), + ...(retryIterationToolRecords.length > 0 && { toolCalls: [...retryIterationToolRecords] }), }; + retryIterationToolRecords = []; state.update(KEYS.CURRENT_SESSION, (s: any) => ({ ...s, messages: [...s.messages, prevMsg], updatedAt: Date.now() })); @@ -560,14 +564,20 @@ async function handleRetry(): Promise { name, arguments: call.function.arguments, result, status: result.success ? 'success' : 'error', timestamp: Date.now() }); - updateMessageToolRecord(name, result.success ? 'success' : 'error', result); + retryIterationToolRecords.push({ + name, arguments: call.function.arguments, + result, status: result.success ? 'success' : 'error', timestamp: Date.now() + }); }, onToolCallError: (name, error, call) => { updateToolCard({ name, arguments: call.function.arguments, result: { success: false, error }, status: 'error', timestamp: Date.now() }); - updateMessageToolRecord(name, 'error', { success: false, error }); + retryIterationToolRecords.push({ + name, arguments: call.function.arguments, + result: { success: false, error }, status: 'error', timestamp: Date.now() + }); }, onConfirmTool: async (call) => showToolConfirm(call), onPlanReady: async (plan: string, steps: string[]) => { @@ -580,14 +590,16 @@ async function handleRetry(): Promise { return true; } }, - onDone: async (finalContent, toolRecords, loopStats) => { + onDone: async (finalContent, _toolRecords, loopStats) => { retryContent = finalContent; + // P1 修复:使用本地追踪的当前迭代工具记录,而非引擎的 allToolRecords(含所有迭代,会与中间消息重复) + const finalToolRecords = retryIterationToolRecords.length > 0 ? retryIterationToolRecords : undefined; if (retryIterations > 0) { if (finalContent) { const lastMsg: ChatMessage = { role: 'assistant', content: finalContent, model: getSelectedModel(), timestamp: Date.now(), ...(retryThinkContent && { think: retryThinkContent }), - ...(toolRecords?.length && { toolCalls: toolRecords }), + ...(finalToolRecords?.length && { toolCalls: finalToolRecords }), ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }), ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), @@ -600,7 +612,7 @@ async function handleRetry(): Promise { const assistantMsg: ChatMessage = { role: 'assistant', content: finalContent || '', model: getSelectedModel(), timestamp: Date.now(), ...(retryThinkContent && { think: retryThinkContent }), - ...(toolRecords?.length && { toolCalls: toolRecords }), + ...(finalToolRecords?.length && { toolCalls: finalToolRecords }), ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }), ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), @@ -1231,6 +1243,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio let assistantContent = ''; let thinkContent = ''; + // P1 修复:追踪当前迭代的工具记录,onNewIteration 时保存到消息中 + let currentIterationToolRecords: ToolCallRecord[] = []; state.set('_currentEvalCount', 0); // ── 监控定时器:流式输出期间持续显示工作提示 ── @@ -1251,14 +1265,16 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio }); }, onNewIteration: (toolCalls) => { - // 保存上一轮的卡片(不含工具记录,工具统一在 onDone 挂载) + // 保存上一轮的卡片(含工具记录) const prevMsg: ChatMessage = { role: 'assistant', content: assistantContent || '', model: getSelectedModel(), timestamp: Date.now(), ...(thinkContent && { think: thinkContent }), + ...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }), }; + currentIterationToolRecords = []; state.update(KEYS.CURRENT_SESSION, (session: any) => ({ ...session, messages: [...session.messages, prevMsg], @@ -1290,14 +1306,20 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio name, arguments: call.function.arguments, result, status: result.success ? 'success' : 'error', timestamp: Date.now() }); - updateMessageToolRecord(name, result.success ? 'success' : 'error', result); + currentIterationToolRecords.push({ + name, arguments: call.function.arguments, + result, status: result.success ? 'success' : 'error', timestamp: Date.now() + }); }, onToolCallError: (name, error, call) => { updateToolCard({ name, arguments: call.function.arguments, result: { success: false, error }, status: 'error', timestamp: Date.now() }); - updateMessageToolRecord(name, 'error', { success: false, error }); + currentIterationToolRecords.push({ + name, arguments: call.function.arguments, + result: { success: false, error }, status: 'error', timestamp: Date.now() + }); }, onConfirmTool: async (call) => { return showToolConfirm(call); @@ -1317,14 +1339,16 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio return true; // 加载失败时自动批准,不阻断执行 } }, - onDone: async (finalContent, toolRecords, loopStats) => { + onDone: async (finalContent, _toolRecords, loopStats) => { + // P1 修复:使用本地追踪的当前迭代工具记录,而非引擎的 allToolRecords(含所有迭代,会与中间消息重复) + const finalToolRecords = currentIterationToolRecords.length > 0 ? currentIterationToolRecords : undefined; const assistantMsg: ChatMessage = { role: 'assistant', content: finalContent || '', model: getSelectedModel(), timestamp: Date.now(), ...(thinkContent && { think: thinkContent }), - ...(toolRecords?.length && { toolCalls: toolRecords }), + ...(finalToolRecords?.length && { toolCalls: finalToolRecords }), ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }), ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }), ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }), @@ -1348,8 +1372,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio })); } await saveCurrentSession(); - // 更新剩余上下文 - if (loopStats?.prompt_eval_count) updateCtxRemain(loopStats.prompt_eval_count); + // 更新剩余上下文 — P0 修复:应使用 ctx_tokens(总上下文占用估算)而非 prompt_eval_count(仅最后一轮输入 token) + if (loopStats?.ctx_tokens) updateCtxRemain(loopStats.ctx_tokens); } }); } catch (err) { diff --git a/src/renderer/index.html b/src/renderer/index.html index 6f88459..5e98da6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.14.7 + v0.14.8