From 7c78a65a9c92b9fec78a9bd2eff253084e8d4d69 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?=
Date: Tue, 7 Jul 2026 11:44:48 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E9=9D=A2=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E5=86=85=E7=BD=AE=E5=B7=A5=E5=85=B7=E9=97=AE=E9=A2=98=20+=20?=
=?UTF-8?q?=E6=9E=B6=E6=9E=84=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D=20(v0.14?=
=?UTF-8?q?.8)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P0: replace_in_files glob重写, search_files正则修复, list_directory递归分页修复, git stash/tag参数修复, buildSearchResponse query字段修复; P1: compress命令注入修复, run_command输出限制, download_file UA+重试; P2: web_fetch extract_mode/mobile_ua生效, read_multiple_files默认值对齐, CONFIRM_TOOLS扩展, 工具图标/名称映射补全; P3: random死代码清理, IPC类型补全; 架构: 系统提示词重复渲染修复, 版本号动态注入, 上下文余量字段修复, 工具记录丢失修复
---
README.md | 6 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/ipc.ts | 8 +-
src/main/tool-handlers-fs.ts | 50 +++++--
src/main/tool-handlers-git.ts | 30 ++--
src/main/tool-handlers-system.ts | 193 ++++++++++++++++++++-----
src/renderer/components/chat-area.ts | 66 ++++-----
src/renderer/components/input-area.ts | 48 ++++--
src/renderer/index.html | 2 +-
src/renderer/main.ts | 9 ++
src/renderer/services/agent-engine.ts | 43 ++++--
src/renderer/services/agent-metrics.ts | 13 +-
src/renderer/services/tool-registry.ts | 21 ++-
14 files changed, 355 insertions(+), 140 deletions(-)
diff --git a/README.md b/README.md
index edd5129..94cad12 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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(/