diff --git a/README.md b/README.md
index b756046..d55cb7d 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.10.exe`
+产出:`release/Metona Ollama Setup v0.14.11.exe`
## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.14.10.exe`
+Output: `release/Metona Ollama Setup v0.14.11.exe`
## 🛠️ Common Commands
diff --git a/package-lock.json b/package-lock.json
index 3377778..62f9479 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.10",
+ "version": "0.14.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.14.10",
+ "version": "0.14.11",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index e64caea..f0f4f4a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.10",
+ "version": "0.14.11",
"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 5297887..cb631d5 100644
--- a/src/main/browser.ts
+++ b/src/main/browser.ts
@@ -203,7 +203,7 @@ export async function browserExtract(params: { selector?: string; max_chars?: nu
const ready = ensureBrowserReady();
if (!ready.ok) return { success: false, error: ready.error };
const win = ready.win!;
- const maxChars = params.max_chars || 15000;
+ const maxChars = params.max_chars || 0; // 0 = 不截断
const result = await win.webContents.executeJavaScript(`
(() => {
@@ -223,7 +223,7 @@ export async function browserExtract(params: { selector?: string; max_chars?: nu
const body = getText(root);
const maxC = ${maxChars};
- const text = body.length > maxC ? body.slice(0, maxC) + '\\\\n... (已截断)' : body;
+ const text = maxC > 0 && body.length > maxC ? body.slice(0, maxC) + '\\\\n... (已截断)' : body;
const links = [];
root.querySelectorAll('a[href]').forEach(a => {
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 1f46e06..061bde3 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.14.7',
+ message: 'Metona Ollama Desktop v0.14.11',
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 da1ce72..61e0b65 100644
--- a/src/main/tool-handlers-fs.ts
+++ b/src/main/tool-handlers-fs.ts
@@ -33,15 +33,11 @@ export async function handleReadFile(params: { path: string; encoding?: string;
if (params.offset_bytes !== undefined || params.limit_bytes !== undefined) {
const offset = params.offset_bytes || 0;
- const limit = params.limit_bytes || Math.min(100 * 1024, buf.length - offset); // 默认100KB
+ const limit = params.limit_bytes || buf.length - offset;
const end = Math.min(buf.length, offset + limit);
resultBuf = buf.subarray(offset, end);
byteRange = [offset, end];
truncated = end < buf.length;
- } else if (buf.length > 100 * 1024) {
- resultBuf = buf.subarray(0, 100 * 1024);
- byteRange = [0, 100 * 1024];
- truncated = true;
}
const ext = path.extname(filePath).toLowerCase();
@@ -101,7 +97,6 @@ export async function handleReadFile(params: { path: string; encoding?: string;
// 按行读取
const content = await fs.readFile(filePath, encoding);
const lines = content.split('\n');
- const defaultLimit = 2000;
let resultContent = content;
let lineRange: [number, number] = [1, lines.length];
@@ -109,14 +104,10 @@ export async function handleReadFile(params: { path: string; encoding?: string;
if (params.start_line || params.end_line) {
const start = Math.max(1, params.start_line || 1) - 1;
- const end = Math.min(lines.length, params.end_line || Math.min(start + defaultLimit, lines.length));
+ const end = Math.min(lines.length, params.end_line || lines.length);
resultContent = lines.slice(start, end).join('\n');
lineRange = [start + 1, end];
truncated = end < lines.length;
- } else if (lines.length > defaultLimit) {
- resultContent = lines.slice(0, defaultLimit).join('\n');
- lineRange = [1, defaultLimit];
- truncated = true;
}
return {
@@ -251,19 +242,16 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
const allowed = checkPathAllowed(dirPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
- const maxEntries = 2000;
const startOffset = params.offset || 0;
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 (entries.length >= maxEntries) { truncated = true; 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 (entries.length >= maxEntries) { truncated = true; return; }
if (!params.include_hidden && item.name.startsWith('.')) continue;
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
@@ -307,7 +295,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const searchType = params.search_type || 'both';
const caseSensitive = params.case_sensitive || false;
const useRegex = params.use_regex || false;
- const maxResults = params.max_results || 50;
+ const maxResults = params.max_results || 0; // 0 = 不限制
const query = params.query; // 保持原始,正则用 flags 控制大小写
// 构建搜索匹配器
@@ -355,7 +343,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const allFiles = await collectFiles(rootPath);
for (const filePath of allFiles) {
- if (totalMatches >= maxResults) break;
+ if (maxResults > 0 && totalMatches >= maxResults) break;
if (searchType === 'filename' || searchType === 'both') {
const fileName = path.basename(filePath);
@@ -383,7 +371,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const lines = content.split('\n');
const fileMatches: Array<{ line: number; text: string; column: number }> = [];
- for (let i = 0; i < lines.length && fileMatches.length < 10; i++) {
+ for (let i = 0; i < lines.length; i++) {
if (contentMatcher(lines[i])) {
const col = useRegex ? 1 : (caseSensitive ? lines[i] : lines[i].toLowerCase()).indexOf(caseSensitive ? query : query.toLowerCase());
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: Math.max(1, col + 1) });
@@ -405,7 +393,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
results,
total_files: allFiles.length,
total_matches: totalMatches,
- truncated: totalMatches >= maxResults
+ truncated: maxResults > 0 && totalMatches >= maxResults
};
} catch (err) {
return { success: false, error: (err as Error).message };
@@ -787,7 +775,7 @@ export async function handleReplaceInFiles(params: { path: string; glob: string;
export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise {
try {
- const maxChars = params.max_chars_per_file || 10000;
+ const maxChars = params.max_chars_per_file || 0; // 0 = 不截断
// 并行读取所有文件(最多50个)
const filesToRead = params.paths.slice(0, 50).map(async (p) => {
@@ -803,7 +791,7 @@ export async function handleReadMultipleFiles(params: { paths: string[]; max_cha
}
let content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').length;
- if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)';
+ if (maxChars > 0 && content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)';
return { path: p, success: true, content, lines };
} catch (err) {
return { path: p, success: false, error: (err as Error).message };
diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts
index 769d624..5e1b8bb 100644
--- a/src/main/tool-handlers-system.ts
+++ b/src/main/tool-handlers-system.ts
@@ -53,39 +53,18 @@ 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');
- 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 });
- }
- }
+ 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');
- 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 });
- }
- }
+ stderr += str;
+ mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
});
_toolProc.on('close', (code) => {
@@ -94,15 +73,13 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
sendLog(
code === 0 ? 'success' : 'warn',
`🔧 run_command 完成`,
- `exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B${stdoutTruncated ? '(已截断)' : ''} | stderr: ${stderr.length}B${stderrTruncated ? '(已截断)' : ''}`
+ `exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B`
);
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,
@@ -119,8 +96,6 @@ 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,
@@ -696,17 +671,15 @@ async function handleWebSearchSearxng(
if (isHtml) {
const html = await resp.text();
const text = htmlToText(html);
- const truncated = effectiveMax > 0 && text.length > effectiveMax * 500;
- const content = truncated ? text.slice(0, effectiveMax * 500) + '\n...(已截断)' : text;
sendLog('success', `🔍 SearXNG HTML 完成`, `${text.length} 字符`);
return {
success: true,
_mode: 'searxng',
url: apiUrl,
- content,
+ content: text,
content_type: 'text/html',
- length: content.length,
- truncated,
+ length: text.length,
+ truncated: false,
format: 'html',
_hint: '这是 SearXNG 搜索结果页面的纯文本提取。请从中提取有用的搜索结果信息来回答用户问题。'
};
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 2313100..8ca6f13 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -586,8 +586,8 @@ async function handleRetry(): Promise {
const html = buildPlanConfirmHtml(plan, steps);
return showHtmlConfirm(html, '📋 执行计划确认', true);
} catch (err) {
- logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
- return true;
+ logError('Plan Mode: 弹窗加载失败,拒绝执行以保护用户', (err as Error).message);
+ return false; // 弹窗失败时拒绝执行,避免绕过用户审批
}
},
onDone: async (finalContent, _toolRecords, loopStats) => {
@@ -1335,8 +1335,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
return approved;
} catch (err) {
- logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
- return true; // 加载失败时自动批准,不阻断执行
+ logError('Plan Mode: 弹窗加载失败,拒绝执行以保护用户', (err as Error).message);
+ return false; // 弹窗失败时拒绝执行,避免绕过用户审批
}
},
onDone: async (finalContent, _toolRecords, loopStats) => {
diff --git a/src/renderer/components/tools-modal.ts b/src/renderer/components/tools-modal.ts
index 511b818..fc227ff 100644
--- a/src/renderer/components/tools-modal.ts
+++ b/src/renderer/components/tools-modal.ts
@@ -1,16 +1,37 @@
/**
* ToolsModal - 工具面板模态框
* 展示所有可用工具的详细信息。Tool Calling 始终开启,不可关闭。
+ * 支持三档开关(auto/confirm/disabled)的工具统一管理。
*/
import { state, KEYS } from '../state/state.js';
-import { setToolEnabled, setRunCommandMode } from '../services/tool-registry.js';
+import { setToolMode, getToolMode, type ToolMode } from '../services/tool-registry.js';
import { showToast } from './toast.js';
-import { logInfo, logWarn } from '../services/log-service.js';
+import { logInfo } from '../services/log-service.js';
import type { ChatDB } from '../db/chat-db.js';
let modalEl: HTMLElement;
+const MODE_NAMES: Record = {
+ auto: '自动执行',
+ confirm: '需确认',
+ disabled: '已禁用',
+};
+
+const BADGE_MAP: Record = {
+ auto: { text: '自动', cls: 'auto' },
+ confirm: { text: '需确认', cls: 'confirm' },
+ disabled: { text: '禁用', cls: 'disabled' },
+};
+
+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}`;
+}
+
export function initToolsModal(): void {
modalEl = document.querySelector('#toolsModal')!;
@@ -21,42 +42,42 @@ export function initToolsModal(): void {
if (e.target === modalEl) closeToolsModal();
});
- // run_command 模式下拉框
- const selectMode = modalEl.querySelector('#selectRunCommandMode') as HTMLSelectElement;
- if (selectMode) {
- selectMode.addEventListener('change', async () => {
+ // 所有带 .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 db = state.get(KEYS.DB);
- const mode = selectMode.value as 'auto' | 'confirm' | 'disabled';
- state.set('runCommandMode', mode);
- if (db) await db.saveSetting('runCommandMode', mode);
- setRunCommandMode(mode);
- updateRunCommandBadge(mode);
- const modeNames: Record = { auto: '自动执行', confirm: '需确认', disabled: '已禁用' };
- showToast(`命令执行模式:${modeNames[mode]}`, 'info');
- logInfo(`命令执行模式已切换: ${mode}`);
+ // 更新运行时模式
+ setToolMode(toolName, mode);
+ updateToolBadge(toolName, mode);
+
+ // 持久化到数据库(所有工具模式合并存储为 JSON)
+ const allModes = state.get>('toolModes', {});
+ allModes[toolName] = mode;
+ state.set('toolModes', allModes);
+ if (db) await db.saveSetting('toolModes', allModes);
+
+ showToast(`${toolName}:${MODE_NAMES[mode]}`, 'info');
+ logInfo(`工具模式切换: ${toolName} → ${mode}`);
});
- }
-}
-
-function updateRunCommandBadge(mode: string): void {
- const badge = modalEl.querySelector('#runCommandBadge') as HTMLElement;
- if (!badge) return;
- const map: Record = {
- auto: { text: '自动', cls: 'auto' },
- confirm: { text: '需确认', cls: 'confirm' },
- disabled: { text: '禁用', cls: 'disabled' }
- };
- const info = map[mode] || map.confirm;
- badge.textContent = info.text;
- badge.className = `tool-card-badge ${info.cls}`;
+ });
}
export function openToolsModal(): void {
- const runCommandMode = state.get('runCommandMode', 'confirm');
- const selectMode = modalEl.querySelector('#selectRunCommandMode') as HTMLSelectElement;
- if (selectMode) selectMode.value = runCommandMode;
- updateRunCommandBadge(runCommandMode);
+ // 从 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);
+ });
modalEl.style.display = '';
}
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 6b10a5a..177b15d 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@