diff --git a/README.md b/README.md index b756046..d55cb7d 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.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 @@

Metona Ollama - v0.14.10 + v0.14.11