diff --git a/README.md b/README.md index 534faa2..9ebc28b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -245,7 +245,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.11.4.exe` +产出:`release/Metona Ollama Setup v0.11.5.exe` ## 🛠️ 常用命令 @@ -481,7 +481,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.11.4.exe` +Output: `release/Metona Ollama Setup v0.11.5.exe` ## 🛠️ Common Commands diff --git a/docs/BUILD.md b/docs/BUILD.md index dcf64ed..73436fb 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -91,7 +91,7 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v0.11.4.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v0.11.5.exe` | NSIS 安装包(可选目录、创建快捷方式) | > 仅提供 NSIS 安装包。 @@ -103,7 +103,7 @@ npm run dist # NSIS 安装包 TOKEN="your_access_token" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \ -H "Content-Type: application/json" \ - -d '{"tag_name":"v0.11.4","name":"v0.11.4","body":"Release notes","target_commitish":"master"}' + -d '{"tag_name":"v0.11.5","name":"v0.11.5","body":"Release notes","target_commitish":"master"}' ``` ### 上传附件 @@ -112,7 +112,7 @@ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/release TOKEN="your_access_token" RELEASE_ID="" curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ - -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.4.exe" + -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.5.exe" ``` ## 故障排查 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5a44455..f9f9218 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v0.11.4 | 更新: 2026-06-05 | 维护: 项目团队 +> 版本: v0.11.5 | 更新: 2026-06-05 | 维护: 项目团队 --- diff --git a/package-lock.json b/package-lock.json index 47673f2..ae78700 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.11.4", + "version": "0.11.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.11.4", + "version": "0.11.5", "license": "MIT", "dependencies": { "sql.js": "^1.11.0" diff --git a/package.json b/package.json index dc9355e..e815f1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.11.4", + "version": "0.11.5", "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 9a48fb0..ee0ed2d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -189,7 +189,7 @@ export async function setupIPC(): Promise { 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_search': result = await handleWebSearch(args as { query: string; max_results?: number }); 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; case 'tree': result = await handleTree(args as { path: string; max_depth?: number; include_hidden?: boolean }); break; diff --git a/src/main/mcp-manager.ts b/src/main/mcp-manager.ts index 9a5ca42..22bea03 100644 --- a/src/main/mcp-manager.ts +++ b/src/main/mcp-manager.ts @@ -57,7 +57,7 @@ let MCP_TIMEOUT = 60_000; /** 设置 MCP 请求超时(毫秒) */ export function setMCPTimeout(ms: number): void { - MCP_TIMEOUT = Math.max(10_000, Math.min(300_000, ms)); // 10s ~ 300s + MCP_TIMEOUT = Math.max(10_000, ms); } // ─── JSON-RPC 通信 ─── diff --git a/src/main/menu.ts b/src/main/menu.ts index 60f0a36..6f3250a 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.11.4', + message: 'Metona Ollama Desktop v0.11.5', 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 477d640..85725a6 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -4,9 +4,11 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import * as os from 'os'; import { spawn } from 'child_process'; import { checkPathAllowed } from './tool-security.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; +import { getWorkspaceDir } from './workspace.js'; export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise { try { @@ -156,6 +158,17 @@ function extToMime(ext: string): string { export async function handleWriteFile(params: { path: string; content: string; encoding?: string; mode?: string }): Promise { try { + if (!params.path || typeof params.path !== 'string') { + const wsDir = getWorkspaceDir(); + const homeDir = os.homedir(); + const absExample = process.platform === 'win32' + ? `${homeDir}\\Documents\\file.txt` + : `${homeDir}/file.txt`; + return { success: false, error: `缺少 path 参数。请指定文件路径,相对路径基于工作空间 ${wsDir}。例如: "output.txt" 或 "${absExample}"` }; + } + if (params.content === undefined || params.content === null) { + return { success: false, error: '缺少 content 参数,请指定要写入的内容' }; + } const filePath = resolvePath(params.path); const allowed = checkPathAllowed(filePath, 'write'); if (!allowed.ok) return { success: false, error: allowed.reason }; diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index e01b07d..23c1913 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -128,7 +128,7 @@ let HTTP_TIMEOUT = 30_000; /** 设置 HTTP 请求超时(毫秒) */ export function setHTTPTimeout(ms: number): void { - HTTP_TIMEOUT = Math.max(5000, Math.min(300_000, ms)); // 5s ~ 300s + HTTP_TIMEOUT = Math.max(5000, ms); } // ── LRU 搜索缓存 ────────────────────────────────── @@ -509,13 +509,20 @@ async function handleWebSearchSearxng( if (effectiveTimeRange) params.set('time_range', effectiveTimeRange); const authKey = getSetting('searxng_auth_key', ''); + const authType = getSetting('searxng_auth_type', 'bearer'); const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`; sendLog('info', `🔍 SearXNG 搜索`, `${query} → ${url} (${format})`); try { const headers = buildFetchHeaders(apiUrl, 0); - if (authKey) headers['Authorization'] = `Bearer ${authKey}`; + if (authKey) { + if (authType === 'basic') { + headers['Authorization'] = 'Basic ' + Buffer.from(authKey).toString('base64'); + } else { + headers['Authorization'] = `Bearer ${authKey}`; + } + } const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers); if (!resp || !resp.ok) { return { @@ -622,17 +629,25 @@ export async function handleWebSearch(params: { query: string; max_results?: num const maxResults = Math.min(params.max_results || 30, 30); const timeRange = params.time_range || ''; const enhanceSnippets = params.enhance_snippets !== false; - const fetchTop = Math.max(3, Math.min(params.fetch_top || 3, 10)); + const aiFetchTop = Math.max(3, params.fetch_top || 5); + + // 内置引擎默认:使用 AI 指定条数 + 顺序抓取 + let fetchTop = aiFetchTop; + let fetchMode: 'sequential' | 'random' = 'sequential'; let result: ToolResult; // ── SearXNG 模式 ── const searxngEnabled = getSetting('searxng_enabled', false); if (searxngEnabled) { - sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}"`); + // SearXNG 模式下读取用户面板设置的抓取条数和类型 + const userFetchCount = getSetting('fetch_count', 0); + fetchMode = (getSetting('fetch_mode', 'sequential') || 'sequential') as 'sequential' | 'random'; + fetchTop = userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop; + sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}" | 抓取=${fetchTop}条 ${fetchMode === 'random' ? '随机' : '顺序'}`); result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets); } else { - sendLog('info', `🔍 web_search → 内置引擎`, `"${query}"`); + sendLog('info', `🔍 web_search → 内置引擎`, `"${query}" | 抓取=${fetchTop}条 顺序`); result = await (async () => { const cacheKey = `${query}|${maxResults}|${timeRange}`; @@ -805,7 +820,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num // ── 自动抓取前 N 条完整内容 ── if (fetchTop > 0 && result.success && (result as any).results) { - result = await applyAutoFetch(result, fetchTop); + result = await applyAutoFetch(result, fetchTop, fetchMode); } return result; @@ -814,25 +829,83 @@ export async function handleWebSearch(params: { query: string; max_results?: num } } -/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容 */ -async function applyAutoFetch(result: ToolResult, fetchTop: number): Promise { +/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容 + * @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条 + * 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */ +async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise { const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined; if (!results || results.length === 0) return result; - const toFetch = results.slice(0, fetchTop); - sendLog('info', `⬇️ 自动抓取 ${toFetch.length} 条网页`, toFetch.map(r => r.title.slice(0, 30)).join(', ')); + const count = Math.min(fetchTop, results.length); - const fetched: Array<{ url: string; title: string; content: string }> = []; - for (const r of toFetch) { - try { - const fetchResult = await handleWebFetch({ url: r.url, retry: false }); - if (fetchResult.success && fetchResult.content) { - fetched.push({ url: r.url, title: r.title, content: String(fetchResult.content) }); - } - } catch { /* skip failed fetch */ } + // ── 构建抓取列表 ── + let fetchList: Array<{ url: string; title: string; originalIndex: number }>; + if (fetchMode === 'random') { + // Fisher-Yates 洗牌索引,取前 count 个 + const indices = results.map((_, i) => i); + for (let i = indices.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + fetchList = indices.slice(0, count).map(idx => ({ + url: results[idx].url, + title: results[idx].title, + originalIndex: idx, + })); + } else { + fetchList = results.slice(0, count).map((r, i) => ({ + url: r.url, + title: r.title, + originalIndex: i, + })); } - sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length}/${toFetch.length} 条成功`); + const modeLabel = fetchMode === 'random' ? '随机' : '顺序'; + sendLog('info', `⬇️ 自动抓取 ${fetchList.length} 条网页 (${modeLabel})`, fetchList.map(r => r.title.slice(0, 30)).join(', ')); + + const fetched: Array<{ url: string; title: string; content: string }> = []; + const fetchedUrls = new Set(); + const failedUrls = new Set(); + + /** 尝试抓取单条 URL,成功则加入 fetched */ + const tryFetchOne = async (url: string, title: string): Promise => { + if (fetchedUrls.has(url)) return false; + try { + const fetchResult = await handleWebFetch({ url, retry: false }); + if (fetchResult.success && fetchResult.content) { + fetched.push({ url, title, content: String(fetchResult.content) }); + fetchedUrls.add(url); + sendLog('debug', ` ✅ ${title.slice(0, 40)} (${String(fetchResult.content).length} 字)`); + return true; + } else { + sendLog('warn', ` ❌ ${title.slice(0, 50)} — ${fetchResult.error || '无内容'}`); + } + } catch (e) { + sendLog('warn', ` ❌ ${title.slice(0, 50)} — ${(e as Error).message}`); + } + return false; + }; + + for (const item of fetchList) { + const ok = await tryFetchOne(item.url, item.title); + if (!ok) { + failedUrls.add(item.url); + // ── 失败重试:从剩余未抓取结果中随机选 1 条 ── + const unfetched = results.filter((r, idx) => + !fetchedUrls.has(r.url) && !failedUrls.has(r.url) + ); + if (unfetched.length > 0) { + const pick = unfetched[Math.floor(Math.random() * unfetched.length)]; + sendLog('info', `🔄 ${item.title.slice(0, 30)} 抓取失败,随机重试: ${pick.title.slice(0, 40)}`); + const retryOk = await tryFetchOne(pick.url, pick.title); + if (!retryOk) { + failedUrls.add(pick.url); + } + } + } + } + + sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length} 条成功 / ${failedUrls.size} 条失败`); (result as any)._fetched = fetched; (result as any)._fetched_count = fetched.length; return result; diff --git a/src/renderer/components/searxng-modal.ts b/src/renderer/components/searxng-modal.ts index 855faa8..c2e5f10 100644 --- a/src/renderer/components/searxng-modal.ts +++ b/src/renderer/components/searxng-modal.ts @@ -17,7 +17,10 @@ const DEFAULTS = { time_range: '', max_results: 0, auth_key: '', + auth_type: 'bearer', format: 'json', + fetch_count: 0, + fetch_mode: 'sequential', }; let modalEl: HTMLElement; @@ -36,7 +39,11 @@ async function saveConfig(db: ChatDB): Promise { await db.saveSetting('searxng_time_range', searxngConfig.time_range); await db.saveSetting('searxng_max_results', searxngConfig.max_results); await db.saveSetting('searxng_auth_key', searxngConfig.auth_key); + await db.saveSetting('searxng_auth_type', searxngConfig.auth_type); await db.saveSetting('searxng_format', searxngConfig.format); + // 通用搜索设置(非 SearXNG 专属,主进程搜索时读取) + await db.saveSetting('fetch_count', searxngConfig.fetch_count); + await db.saveSetting('fetch_mode', searxngConfig.fetch_mode); logSuccess('SearXNG 配置已保存'); } @@ -50,7 +57,11 @@ export async function loadSearxngConfig(db: ChatDB): Promise { searxngConfig.time_range = await db.getSetting('searxng_time_range', DEFAULTS.time_range); searxngConfig.max_results = await db.getSetting('searxng_max_results', DEFAULTS.max_results); searxngConfig.auth_key = await db.getSetting('searxng_auth_key', DEFAULTS.auth_key); + searxngConfig.auth_type = await db.getSetting('searxng_auth_type', DEFAULTS.auth_type); searxngConfig.format = await db.getSetting('searxng_format', DEFAULTS.format); + // 通用搜索设置 + searxngConfig.fetch_count = await db.getSetting('fetch_count', DEFAULTS.fetch_count); + searxngConfig.fetch_mode = await db.getSetting('fetch_mode', DEFAULTS.fetch_mode); logInfo('SearXNG 配置已加载', searxngConfig.enabled ? `已启用: ${searxngConfig.url}` : '未启用'); updateEnabledUI(searxngConfig.enabled); } @@ -64,7 +75,10 @@ function syncFormFromConfig(): void { (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value = searxngConfig.time_range || ''; (document.querySelector('#searxngMaxResults') as HTMLInputElement).value = searxngConfig.max_results > 0 ? String(searxngConfig.max_results) : ''; (document.querySelector('#searxngAuthKey') as HTMLInputElement).value = searxngConfig.auth_key; + (document.querySelector('#searxngAuthType') as HTMLSelectElement).value = searxngConfig.auth_type; (document.querySelector('#searxngFormat') as HTMLSelectElement).value = searxngConfig.format; + (document.querySelector('#searxngFetchCount') as HTMLInputElement).value = searxngConfig.fetch_count > 0 ? String(searxngConfig.fetch_count) : ''; + (document.querySelector('#searxngFetchMode') as HTMLSelectElement).value = searxngConfig.fetch_mode; const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement; toggle.checked = searxngConfig.enabled; updateEnabledUI(searxngConfig.enabled); @@ -137,7 +151,10 @@ export function initSearxngModal(): void { searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value; searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 0; searxngConfig.auth_key = (document.querySelector('#searxngAuthKey') as HTMLInputElement).value.trim(); + searxngConfig.auth_type = (document.querySelector('#searxngAuthType') as HTMLSelectElement).value as 'bearer' | 'basic'; searxngConfig.format = (document.querySelector('#searxngFormat') as HTMLSelectElement).value; + searxngConfig.fetch_count = parseInt((document.querySelector('#searxngFetchCount') as HTMLInputElement).value) || 0; + searxngConfig.fetch_mode = (document.querySelector('#searxngFetchMode') as HTMLSelectElement).value as 'sequential' | 'random'; const db = state.get(KEYS.DB); if (db) await saveConfig(db); updateEnabledUI(searxngConfig.enabled); diff --git a/src/renderer/index.html b/src/renderer/index.html index 19ec43d..0e4e0a0 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.11.4 + v0.11.5
+
+ + +
+
+ + +
- - -

SearXNG 实例的 API 认证密钥,通过 HTTP Header Authorization: Bearer xxx 传递。需配合 Nginx 反向代理验证

+ +
+ + +
+

Bearer: API Token · Basic: 账号:密码(自动 Base64 编码)。需配合 Nginx 反向代理验证