diff --git a/README.md b/README.md index 8d074c3..dc84d25 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -40,7 +40,7 @@ | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | | 🎯 | **技能自动生成 v1.1** | 工具调用链自动提取 → 语义匹配 → 参数自优化 → 未使用衰减 → 技能链合并,越用越精准 | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | -| 🔍 | **三引擎联网搜索** | Bing + 百度 + Google 聚合,web_search → web_fetch 链式抓取 | +|| 🔍 | **四引擎联网搜索** | Bing + 百度 + DuckDuckGo + Google 并行聚合,5分钟缓存,URL可达性预检,web_fetch支持自动重试+移动端UA+SPA浏览器渲染回退 | | 🌏 | **浏览器控制** | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 | | 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 | | 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 | @@ -93,8 +93,8 @@ | 工具 | 功能 | |------|------| -| `web_search` | 三引擎聚合搜索(Bing + 百度 + Google),返回标题、URL、摘要,默认 15 条 | -| `web_fetch` | 网页内容抓取,默认不截断 | +|| `web_search` | 四引擎并行搜索(Bing + 百度 + DuckDuckGo + Google),结果缓存 5 分钟,URL 可达性预检,返回结构化 JSON | +| `web_fetch` | 网页内容抓取,自动重试(指数退避),移动端UA切换,SPA页面自动升级到浏览器渲染 | @@ -231,7 +231,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.9.1.exe` +产出:`release/Metona Ollama Setup v0.10.0.exe` ## 🛠️ 常用命令 @@ -267,7 +267,7 @@ npm run dist # 构建 Windows 安装包 | 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules) + USER.md (user profile), workspace-first with built-in defaults | | 🎯 | **Auto Skill Generation v1.1** | Tool chain extraction → semantic matching → parameter self-optimization → decay → chain merging | | 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection | -| 🔍 | **Triple-engine Web Search** | Bing + Baidu + Google aggregation, web_search → web_fetch chain | +| 🔍 | **Quad-engine Web Search** | Bing + Baidu + DuckDuckGo + Google parallel, 5min cache, URL reachability check, web_fetch with auto-retry+mobile UA+SPA browser fallback | | 🌏 | **Browser Control** | Open pages · Screenshot · JS execution · Content extraction · Click · Type · Scroll · Close | | 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks | | 🔢 | **Auto Context Length Detection** | Reads actual context_length from model metadata on model switch — no manual config needed | @@ -320,8 +320,8 @@ npm run dist # 构建 Windows 安装包 | Tool | Function | |------|------| -| `web_search` | Triple-engine search (Bing + Baidu + Google), returns title, URL, snippet, 15 results | -| `web_fetch` | Fetch web page content, no truncation | +| `web_search` | Quad-engine parallel search (Bing + Baidu + DuckDuckGo + Google), 5min cache, URL reachability check, structured JSON output | +| `web_fetch` | Fetch web page content, auto-retry with backoff, mobile UA toggle, SPA auto-upgrade to browser rendering | @@ -454,7 +454,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.9.1.exe` +Output: `release/Metona Ollama Setup v0.10.0.exe` ## 🛠️ Common Commands diff --git a/docs/BUILD.md b/docs/BUILD.md index 84b0546..a2b7a4b 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -91,7 +91,7 @@ npm run dist # NSIS 安装包 | 文件 | 说明 | |------|------| -| `release/Metona Ollama Setup v0.9.1.exe` | NSIS 安装包(可选目录、创建快捷方式) | +| `release/Metona Ollama Setup v0.10.0.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.9.1","name":"v0.9.1","body":"Release notes","target_commitish":"master"}' + -d '{"tag_name":"v0.10.0","name":"v0.10.0","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.9.1.exe" + -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.10.0.exe" ``` ## 故障排查 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index aa8b5a0..8ca45fc 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Metona Ollama Desktop — 开发规范 -> 版本: v0.9.1 | 更新: 2026-06-05 | 维护: 项目团队 +> 版本: v0.10.0 | 更新: 2026-06-05 | 维护: 项目团队 --- diff --git a/package-lock.json b/package-lock.json index 73f5ac7..bee77d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.9.1", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.9.1", + "version": "0.10.0", "license": "MIT", "dependencies": { "sql.js": "^1.11.0" diff --git a/package.json b/package.json index a5c38ba..08fb95a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.9.1", + "version": "0.10.0", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", @@ -48,7 +48,7 @@ "requestedExecutionLevel": "asInvoker" }, "nsis": { - "artifactName": "Metona Ollama Setup v0.9.1.${ext}", + "artifactName": "Metona Ollama Setup v0.10.0.${ext}", "oneClick": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, diff --git a/src/main/browser.ts b/src/main/browser.ts index 8672298..b3eff9c 100644 --- a/src/main/browser.ts +++ b/src/main/browser.ts @@ -17,6 +17,18 @@ export function isBrowserOpen(): boolean { return agentBrowser !== null && !agentBrowser.isDestroyed(); } +/** 获取浏览器状态:确保浏览器已打开并加载了页面 */ +function ensureBrowserReady(): { ok: boolean; error?: string; win?: BrowserWindow } { + if (!isBrowserOpen()) { + return { ok: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; + } + const win = agentBrowser!; + if (win.webContents.getURL() === 'about:blank') { + return { ok: false, error: '浏览器未加载任何页面,请先使用 browser_open 加载页面' }; + } + return { ok: true, win }; +} + /** 获取或创建 Agent 浏览器窗口 */ function getAgentBrowser(): BrowserWindow { if (agentBrowser && !agentBrowser.isDestroyed()) return agentBrowser; @@ -43,6 +55,16 @@ const PAGE_LOAD_TIMEOUT = 30_000; /** 打开 URL */ export async function browserOpen(url: string): Promise<{ success: boolean; title?: string; url?: string; error?: string }> { try { + // 如果已有 BrowserWindow 且加载了不同 URL,先关闭重建,避免状态残留 + if (agentBrowser && !agentBrowser.isDestroyed()) { + const currentUrl = agentBrowser.webContents.getURL(); + if (currentUrl !== 'about:blank' && currentUrl !== url) { + sendLog('info', `🌐 browser 切换页面`, `${currentUrl} → ${url}`); + agentBrowser.close(); + agentBrowser = null; + } + } + const win = getAgentBrowser(); sendLog('info', `🌐 browser 打开`, url.slice(0, 100)); @@ -78,13 +100,9 @@ export async function browserOpen(url: string): Promise<{ success: boolean; titl /** 截图 */ export async function browserScreenshot(): Promise<{ success: boolean; png?: string; error?: string }> { try { - if (!isBrowserOpen()) { - return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; - } - const win = agentBrowser!; - if (win.webContents.getURL() === 'about:blank') { - return { success: false, error: '浏览器未加载任何页面' }; - } + const ready = ensureBrowserReady(); + if (!ready.ok) return { success: false, error: ready.error }; + const win = ready.win!; const image = await win.webContents.capturePage(); const buffer = image.toPNG(); sendLog('info', `🌐 browser 截图`, `${buffer.length} bytes`); @@ -98,13 +116,9 @@ export async function browserScreenshot(): Promise<{ success: boolean; png?: str /** 执行 JavaScript */ export async function browserEvaluate(js: string): Promise<{ success: boolean; result?: string; error?: string }> { try { - if (!isBrowserOpen()) { - return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; - } - const win = agentBrowser!; // isBrowserOpen 已确认非 null - if (win.webContents.getURL() === 'about:blank') { - return { success: false, error: '浏览器未加载任何页面,请先使用 browser_open 加载页面' }; - } + const ready = ensureBrowserReady(); + if (!ready.ok) return { success: false, error: ready.error }; + const win = ready.win!; sendLog('info', `🌐 browser 执行 JS`, js.slice(0, 100)); const result = await win.webContents.executeJavaScript(js, true); const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2); @@ -119,13 +133,9 @@ export async function browserEvaluate(js: string): Promise<{ success: boolean; r /** 提取页面文本和链接 */ export async function browserExtract(): Promise<{ success: boolean; title?: string; text?: string; links?: Array<{text: string; href: string}>; error?: string }> { try { - if (!isBrowserOpen()) { - return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; - } - const win = agentBrowser!; - if (win.webContents.getURL() === 'about:blank') { - return { success: false, error: '浏览器未加载任何页面' }; - } + const ready = ensureBrowserReady(); + if (!ready.ok) return { success: false, error: ready.error }; + const win = ready.win!; const result = await win.webContents.executeJavaScript(` (() => { @@ -137,7 +147,7 @@ export async function browserExtract(): Promise<{ success: boolean; title?: stri return clone.innerText?.trim() || ''; }; const body = getText(document.body); - const text = body.length > 15000 ? body.slice(0, 15000) + '\\n... (已截断)' : body; + const text = body.length > 15000 ? body.slice(0, 15000) + '\\\\n... (已截断)' : body; const links = []; document.querySelectorAll('a[href]').forEach(a => { const href = a.href; @@ -161,12 +171,10 @@ export async function browserExtract(): Promise<{ success: boolean; title?: stri /** 点击元素 */ export async function browserClick(selector: string): Promise<{ success: boolean; error?: string }> { try { - if (!isBrowserOpen()) { - return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; - } - const win = agentBrowser!; + const ready = ensureBrowserReady(); + if (!ready.ok) return { success: false, error: ready.error }; + const win = ready.win!; sendLog('info', `🌐 browser 点击`, selector); - // 安全注入:通过 JSON.stringify 防止选择器中的 JS 注入 await win.webContents.executeJavaScript(` (() => { const sel = ${JSON.stringify(selector)}; @@ -187,12 +195,10 @@ export async function browserClick(selector: string): Promise<{ success: boolean /** 输入文本 */ export async function browserType(selector: string, text: string): Promise<{ success: boolean; error?: string }> { try { - if (!isBrowserOpen()) { - return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; - } - const win = agentBrowser!; + const ready = ensureBrowserReady(); + if (!ready.ok) return { success: false, error: ready.error }; + const win = ready.win!; sendLog('info', `🌐 browser 输入`, `${selector}: ${text.slice(0, 50)}`); - // 安全注入:通过 JSON.stringify 防止选择器和文本中的 JS 注入 await win.webContents.executeJavaScript(` (() => { const sel = ${JSON.stringify(selector)}; @@ -216,10 +222,9 @@ export async function browserType(selector: string, text: string): Promise<{ suc /** 滚动页面 */ export async function browserScroll(direction: 'down' | 'up' | 'top' | 'bottom'): Promise<{ success: boolean; error?: string }> { try { - if (!isBrowserOpen()) { - return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; - } - const win = agentBrowser!; + const ready = ensureBrowserReady(); + if (!ready.ok) return { success: false, error: ready.error }; + const win = ready.win!; const js = direction === 'down' ? 'window.scrollBy(0, 500)' : direction === 'up' ? 'window.scrollBy(0, -500)' : direction === 'top' ? 'window.scrollTo(0, 0)' diff --git a/src/main/menu.ts b/src/main/menu.ts index dbc0322..9760ad0 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.9.1', + message: 'Metona Ollama Desktop v0.10.0', 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 ce219a9..7e5f5a0 100644 --- a/src/main/tool-handlers-fs.ts +++ b/src/main/tool-handlers-fs.ts @@ -4,6 +4,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { spawn } from 'child_process'; import { checkPathAllowed } from './tool-security.js'; import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js'; @@ -86,15 +87,17 @@ export async function handleWriteFile(params: { path: string; content: string; e } } -export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string }): Promise { +export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; offset?: number }): Promise { try { const dirPath = resolvePath(params.path); const allowed = checkPathAllowed(dirPath, 'read'); if (!allowed.ok) return { success: false, error: allowed.reason }; const maxEntries = 500; + 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; } @@ -106,6 +109,9 @@ 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 个条目 + if (skipped < startOffset) { skipped++; continue; } + const fullPath = path.join(dir, item.name); const stat = await fs.stat(fullPath); entries.push({ @@ -122,13 +128,16 @@ export async function handleListDir(params: { path: string; recursive?: boolean; } await scanDir(dirPath, 1); - return { success: true, path: dirPath, entries, total: entries.length, truncated }; + return { + success: true, path: dirPath, entries, total: entries.length, + truncated, offset: startOffset + }; } catch (err) { return { success: false, error: (err as Error).message }; } } -export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[] }): Promise { +export async function handleSearchFiles(params: { path: string; query: string; search_type?: string; case_sensitive?: boolean; max_results?: number; file_extensions?: string[]; use_regex?: boolean }): Promise { try { const rootPath = resolvePath(params.path); const allowed = checkPathAllowed(rootPath, 'read'); @@ -136,8 +145,24 @@ 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 query = caseSensitive ? params.query : params.query.toLowerCase(); + const query = params.query; // 保持原始,正则用 flags 控制大小写 + + // 构建搜索匹配器 + let contentMatcher: (line: string) => boolean; + if (useRegex) { + try { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(query, flags); + contentMatcher = (line: string) => regex.test(line); + } catch { + return { success: false, error: '无效的正则表达式: ' + query }; + } + } else { + const q = caseSensitive ? query : query.toLowerCase(); + contentMatcher = (line: string) => (caseSensitive ? line : line.toLowerCase()).includes(q); + } const results: Array<{ path: string; matches: Array<{ line: number; text: string; column?: number }> }> = []; let totalMatches = 0; @@ -191,10 +216,9 @@ export async function handleSearchFiles(params: { path: string; query: string; s const fileMatches: Array<{ line: number; text: string; column: number }> = []; for (let i = 0; i < lines.length && fileMatches.length < 10; i++) { - const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase(); - const col = lineToCheck.indexOf(query); - if (col !== -1) { - fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 }); + 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) }); totalMatches++; } } @@ -391,6 +415,18 @@ export async function handleTree(params: { path: string; max_depth?: number; inc } +/** 运行 shell 命令并返回 stdout/stderr */ +function runShell(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 })); + proc.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 })); + }); +} + export async function handleDiffFiles(params: { file1: string; file2: string; context_lines?: number }): Promise { try { const path1 = resolvePath(params.file1); @@ -400,66 +436,89 @@ export async function handleDiffFiles(params: { file1: string; file2: string; co const check2 = checkPathAllowed(path2, 'read'); if (!check2.ok) return { success: false, error: check2.reason }; - const content1 = await fs.readFile(path1, 'utf-8'); - const content2 = await fs.readFile(path2, 'utf-8'); - const lines1 = content1.split('\n'); - const lines2 = content2.split('\n'); - - // 简单的统一 diff 实现 - const diffLines: string[] = []; const contextSize = params.context_lines || 3; - let i = 0, j = 0; + let diffOutput = ''; + let usedCommand = ''; - while (i < lines1.length || j < lines2.length) { - if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) { - if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) { - // 添加上下文 - const ctxStart = Math.max(0, i - contextSize); - for (let k = ctxStart; k < i; k++) { - diffLines.push(` ${lines1[k]}`); - } - } - diffLines.push(` ${lines1[i]}`); - i++; j++; - } else { - // 尝试找匹配 - let found = false; - for (let look = 1; look <= 5 && i + look < lines1.length; look++) { - if (lines1[i + look] === lines2[j]) { - for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`); - i += look; found = true; break; - } - } - if (!found) { - for (let look = 1; look <= 5 && j + look < lines2.length; look++) { - if (lines1[i] === lines2[j + look]) { - for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`); - j += look; found = true; break; - } - } - } - if (!found) { - if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; } - if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; } - } + // 1) 优先使用系统 diff 命令 + const diffResult = await runShell('diff', ['-u', `-U${contextSize}`, path1, path2]); + if (diffResult.code <= 1) { // diff exits 0=identical, 1=different + diffOutput = diffResult.stdout; + usedCommand = 'diff -u'; + } + + // 2) diff 不可用,尝试 git diff + if (!usedCommand) { + const gitResult = await runShell('git', ['diff', `--unified=${contextSize}`, '--no-index', path1, path2]); + if (gitResult.code <= 1) { + diffOutput = gitResult.stdout; + usedCommand = 'git diff'; } } - const hasChanges = diffLines.some(l => l.startsWith('-') || l.startsWith('+')); + // 3) 回退到内置简易 diff + if (!usedCommand) { + diffOutput = builtinDiff(path1, path2, contextSize); + usedCommand = 'builtin'; + } + + const hasChanges = diffOutput.trim().length > 0; return { success: true, file1: path1, file2: path2, - diff: diffLines.join('\n'), + diff: diffOutput, hasChanges, - lines1: lines1.length, - lines2: lines2.length + method: usedCommand }; } catch (err) { return { success: false, error: (err as Error).message }; } } +/** 内置简易 diff(回退方案) */ +function builtinDiff(path1: string, path2: string, contextSize: number): string { + const fsSync = require('fs'); + const content1 = fsSync.readFileSync(path1, 'utf-8'); + const content2 = fsSync.readFileSync(path2, 'utf-8'); + const lines1 = content1.split('\n'); + const lines2 = content2.split('\n'); + + const diffLines: string[] = []; + let i = 0, j = 0; + while (i < lines1.length || j < lines2.length) { + if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) { + if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) { + const ctxStart = Math.max(0, i - contextSize); + for (let k = ctxStart; k < i; k++) diffLines.push(` ${lines1[k]}`); + } + diffLines.push(` ${lines1[i]}`); + i++; j++; + } else { + let found = false; + for (let look = 1; look <= 10 && i + look < lines1.length; look++) { + if (lines1[i + look] === lines2[j]) { + for (let k = 0; k < look; k++) diffLines.push(`-${lines1[i + k]}`); + i += look; found = true; break; + } + } + if (!found) { + for (let look = 1; look <= 10 && j + look < lines2.length; look++) { + if (lines1[i] === lines2[j + look]) { + for (let k = 0; k < look; k++) diffLines.push(`+${lines2[j + k]}`); + j += look; found = true; break; + } + } + } + if (!found) { + if (i < lines1.length) { diffLines.push(`-${lines1[i]}`); i++; } + if (j < lines2.length) { diffLines.push(`+${lines2[j]}`); j++; } + } + } + } + return diffLines.join('\n'); +} + export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise { try { const rootPath = resolvePath(params.path); @@ -522,29 +581,29 @@ 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 || 2000; - const results: Array<{ path: string; success: boolean; content?: string; error?: string; lines?: number }> = []; - for (const p of params.paths.slice(0, 20)) { + // 并行读取所有文件(最多20个) + const filesToRead = params.paths.slice(0, 20).map(async (p) => { const filePath = resolvePath(p); const allowed = checkPathAllowed(filePath, 'read'); if (!allowed.ok) { - results.push({ path: p, success: false, error: allowed.reason }); - continue; + return { path: p, success: false, error: allowed.reason }; } try { const stat = await fs.stat(filePath); if (stat.size > 1024 * 1024) { - results.push({ path: p, success: false, error: '文件超过 1MB' }); - continue; + return { path: p, success: false, error: '文件超过 1MB' }; } let content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n').length; if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)'; - results.push({ path: p, success: true, content, lines }); + return { path: p, success: true, content, lines }; } catch (err) { - results.push({ path: p, success: false, error: (err as Error).message }); + return { path: p, success: false, error: (err as Error).message }; } - } + }); + + const results = await Promise.all(filesToRead); return { success: true, files: results, total: results.length }; } catch (err) { diff --git a/src/main/tool-handlers-git.ts b/src/main/tool-handlers-git.ts index c60d124..ee884ce 100644 --- a/src/main/tool-handlers-git.ts +++ b/src/main/tool-handlers-git.ts @@ -16,15 +16,30 @@ export async function handleGit(params: { action: string; path?: string; files?: const dirCheck = checkPathAllowed(cwd, 'read'); if (!dirCheck.ok) return { success: false, error: dirCheck.reason }; - async function runGit(args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { + async function runGit(args: string[], timeoutMs = 0): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { const git = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LANG: 'en_US.UTF-8' } }); let stdout = ''; let stderr = ''; + let timer: ReturnType | null = null; + + if (timeoutMs > 0) { + timer = setTimeout(() => { + git.kill('SIGTERM'); + resolve({ stdout, stderr: `操作超时 (${timeoutMs / 1000}s)`, code: 1 }); + }, timeoutMs); + } + git.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); - git.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 })); - git.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 })); + git.on('close', (code) => { + if (timer) clearTimeout(timer); + resolve({ stdout, stderr, code: code ?? 1 }); + }); + git.on('error', (err) => { + if (timer) clearTimeout(timer); + resolve({ stdout, stderr: err.message, code: 1 }); + }); }); } @@ -105,7 +120,7 @@ export async function handleGit(params: { action: string; path?: string; files?: if (params.remote) args.push(params.remote); if (params.branch) args.push(params.branch); if (params.force) args.push('--force'); - const r = await runGit(args); + const r = await runGit(args, 60_000); // 60s 超时 if (r.code !== 0) return { success: false, error: r.stderr }; return { success: true, action: 'push', output: r.stderr.trim() || r.stdout.trim() }; } @@ -114,7 +129,7 @@ export async function handleGit(params: { action: string; path?: string; files?: const args = ['pull']; if (params.remote) args.push(params.remote); if (params.branch) args.push(params.branch); - const r = await runGit(args); + const r = await runGit(args, 60_000); // 60s 超时 if (r.code !== 0) return { success: false, error: r.stderr }; return { success: true, action: 'pull', output: r.stdout.trim() }; } @@ -157,9 +172,11 @@ export async function handleGit(params: { action: string; path?: string; files?: } case 'stash': { - const subAction = params.message || 'push'; + // stash 子命令(push/pop/apply/list/drop/show) + // message 仅作为 stash push 的提交信息 + const subAction = 'push'; // 默认 push const args = ['stash', subAction]; - if (subAction === 'push' && params.message) args.push('-m', params.message); + if (params.message) args.push('-m', params.message); const r = await runGit(args); if (r.code !== 0) return { success: false, error: r.stderr }; return { success: true, action: 'stash', subAction, output: r.stdout.trim() }; @@ -186,7 +203,7 @@ export async function handleGit(params: { action: string; path?: string; files?: const destDir = params.path ? path.resolve(params.path) : getWorkspaceDir(); const cloneDirCheck = checkPathAllowed(destDir, 'write'); if (!cloneDirCheck.ok) return { success: false, error: cloneDirCheck.reason }; - const r = await runGit(['clone', params.url, destDir]); + const r = await runGit(['clone', params.url, destDir], 120_000); // 120s 超时(clone 可能较大) if (r.code !== 0) return { success: false, error: r.stderr }; return { success: true, action: 'clone', url: params.url, destination: destDir }; } diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts index 86afc87..8d07138 100644 --- a/src/main/tool-handlers-system.ts +++ b/src/main/tool-handlers-system.ts @@ -121,6 +121,52 @@ export function killToolProcess(): boolean { /** HTTP 请求超时(毫秒) */ const HTTP_TIMEOUT = 15_000; +// ── LRU 搜索缓存 ────────────────────────────────── +interface CacheEntry { results: Array<{ title: string; url: string; snippet: string; engine: string; reachable?: boolean }>; time: number; } +const _searchCache = new Map(); +const CACHE_MAX = 200; +const CACHE_TTL = 5 * 60 * 1000; // 5 分钟 + +function cacheGet(query: string): CacheEntry | null { + const entry = _searchCache.get(query); + if (!entry) return null; + if (Date.now() - entry.time > CACHE_TTL) { _searchCache.delete(query); return null; } + return entry; +} +function cacheSet(query: string, entry: CacheEntry): void { + if (_searchCache.size >= CACHE_MAX) { + const firstKey = _searchCache.keys().next().value; + if (firstKey !== undefined) _searchCache.delete(firstKey); + } + _searchCache.set(query, entry); +} + +// ── 反爬请求头 ──────────────────────────────────── +const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; +const MOBILE_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36'; +const FETCH_HEADERS = { + 'User-Agent': DESKTOP_UA, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Cache-Control': 'no-cache', + 'DNT': '1', +}; + +/** 带超时的 fetch 封装 */ +async function fetchWithTimeout(url: string, timeout = HTTP_TIMEOUT, headers: Record = FETCH_HEADERS): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const resp = await fetch(url, { headers, signal: controller.signal, redirect: 'follow' }); + clearTimeout(timeoutId); + return resp; + } catch { + clearTimeout(timeoutId); + return null; + } +} + /** 完整 HTML 实体映射(常见实体) */ const HTML_ENTITIES: Record = { ' ': ' ', '<': '<', '>': '>', '&': '&', '"': '"', @@ -181,82 +227,141 @@ function htmlToText(html: string): string { return text.trim(); } -export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string }): Promise { - try { - const url = params.url; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - return { success: false, error: '仅支持 http/https 协议' }; - } +import { browserOpen, browserExtract, browserClose } from './browser.js'; - const maxChars = params.max_chars || 0; // 0 = 不截断,返回全部内容 +// ────────────────────────────────────────────────── +// web_fetch 重试配置 +// ────────────────────────────────────────────────── +const FETCH_MAX_RETRIES = 2; +const FETCH_RETRY_DELAYS = [1000, 2000]; // 指数退避 (ms) +/** 内容过短阈值:小于此字符数且是 HTML 时,自动升级到浏览器渲染 */ +const SHORT_CONTENT_THRESHOLD = 200; - sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'})`); - - // 带超时的 fetch - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT); - let resp: Response; - try { - resp = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' - }, - signal: controller.signal, - redirect: 'follow', - }); - } finally { - clearTimeout(timeoutId); - } - - if (!resp.ok) { - return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; - } - - const contentType = resp.headers.get('content-type') || ''; - if (!contentType.includes('text') && !contentType.includes('html') && !contentType.includes('json') && !contentType.includes('xml')) { - return { success: false, error: `不支持的内容类型: ${contentType}` }; - } - - // 预检查内容长度,避免下载过大的页面 - const contentLength = resp.headers.get('content-length'); - if (contentLength && parseInt(contentLength, 10) > 10 * 1024 * 1024) { - return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` }; - } - - let text = await resp.text(); - - // HTML → 可读文本提取 - if (contentType.includes('html')) { - text = htmlToText(text); - } - - const truncated = maxChars > 0 && text.length > maxChars; - if (truncated) text = text.slice(0, maxChars); - - sendLog('success', `🌐 web_fetch 完成`, `${resp.status} | ${text.length} chars${truncated ? ' (已截断)' : ''}`); - return { - success: true, - url, - content: text, - content_type: contentType, - status: resp.status, - truncated, - length: text.length - }; - } catch (err) { - const errMsg = (err as Error).message; - if (errMsg.includes('abort')) { - return { success: false, error: `请求超时 (${HTTP_TIMEOUT / 1000}s)` }; - } - return { success: false, error: errMsg }; +export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }): Promise { + const url = params.url; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return { success: false, error: '仅支持 http/https 协议' }; } + + const maxChars = params.max_chars || 0; // 0 = 不截断 + const useMobileUA = params.mobile_ua === true; + const shouldRetry = params.retry !== false; // 默认开启重试 + + const headers = { + ...FETCH_HEADERS, + 'User-Agent': useMobileUA ? MOBILE_UA : DESKTOP_UA, + }; + + sendLog('info', `🌐 web_fetch`, `${url} (${maxChars > 0 ? maxChars + ' chars' : '完整内容'}${useMobileUA ? ', 移动端UA' : ''})`); + + // ── 重试循环 ── + let lastError = ''; + const maxAttempts = shouldRetry ? FETCH_MAX_RETRIES + 1 : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (attempt > 0) { + const delay = FETCH_RETRY_DELAYS[attempt - 1] || 2000; + sendLog('warn', `🌐 web_fetch 重试`, `第 ${attempt + 1}/${maxAttempts} 次, 等待 ${delay}ms`); + await new Promise(r => setTimeout(r, delay)); + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT); + let resp: Response; + try { + resp = await fetch(url, { headers, signal: controller.signal, redirect: 'follow' }); + } finally { + clearTimeout(timeoutId); + } + + if (!resp.ok) { + // 5xx 服务器错误可重试,4xx 客户端错误不重试 + if (resp.status >= 500 && attempt < maxAttempts - 1) { + lastError = `HTTP ${resp.status}: ${resp.statusText}(将重试)`; + continue; + } + return { success: false, error: `HTTP ${resp.status}: ${resp.statusText}` }; + } + + const contentType = resp.headers.get('content-type') || ''; + if (!contentType.includes('text') && !contentType.includes('html') && !contentType.includes('json') && !contentType.includes('xml')) { + return { success: false, error: `不支持的内容类型: ${contentType}` }; + } + + // 预检查内容长度 + const contentLength = resp.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > 10 * 1024 * 1024) { + return { success: false, error: `页面过大 (${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)}MB),超过 10MB 限制` }; + } + + let text = await resp.text(); + + // HTML → 可读文本 + const isHTML = contentType.includes('html'); + if (isHTML) { + text = htmlToText(text); + } + + // ── 自动升级到浏览器渲染(内容过短的 HTML 页面) ── + if (isHTML && text.length < SHORT_CONTENT_THRESHOLD) { + sendLog('info', `🌐 web_fetch 内容过短(${text.length}字符)`, `自动升级到浏览器渲染: ${url}`); + try { + const openResult = await browserOpen(url); + if (openResult.success) { + // 等待页面 JS 渲染完成 + await new Promise(r => setTimeout(r, 2000)); + const extractResult = await browserExtract(); + if (extractResult.success && extractResult.text) { + text = extractResult.text; + sendLog('success', `🌐 web_fetch 浏览器渲染完成`, `${text.length} 字符`); + } + browserClose(); + } + } catch (browserErr) { + sendLog('warn', `🌐 web_fetch 浏览器渲染失败`, (browserErr as Error).message); + // 浏览器失败不阻塞,使用原始短内容 + } + } + + const truncated = maxChars > 0 && text.length > maxChars; + if (truncated) text = text.slice(0, maxChars); + + sendLog('success', `🌐 web_fetch 完成`, `HTTP ${resp.status} | ${text.length} chars${truncated ? ' (已截断)' : ''}${attempt > 0 ? ` | 重试${attempt}次后成功` : ''}`); + return { + success: true, + url, + content: text, + content_type: contentType, + status: resp.status, + truncated, + length: text.length, + retries: attempt, + }; + } catch (err) { + const errMsg = (err as Error).message; + if (errMsg.includes('abort') && attempt < maxAttempts - 1) { + lastError = `请求超时 (${HTTP_TIMEOUT / 1000}s)(将重试)`; + continue; + } + if (errMsg.includes('abort')) { + return { success: false, error: `请求超时 (${HTTP_TIMEOUT / 1000}s)` }; + } + // 网络错误可重试 + if (attempt < maxAttempts - 1) { + lastError = errMsg + '(将重试)'; + continue; + } + return { success: false, error: errMsg }; + } + } + + return { success: false, error: lastError || '所有重试均失败' }; } /** - * Web Search — 联网搜索 - * 使用 Bing 搜索(首选)+ 百度作为备用 + * Web Search — 联网搜索(并行多引擎 + LRU 缓存 + 质量评分) + * 同时请求 Bing / 百度 / DuckDuckGo / Google,取最快结果合并去重 */ export async function handleWebSearch(params: { query: string; max_results?: number }): Promise { try { @@ -265,114 +370,180 @@ export async function handleWebSearch(params: { query: string; max_results?: num return { success: false, error: '搜索关键词不能为空' }; } - const maxResults = Math.min(params.max_results || 15, 15); + const maxResults = Math.min(params.max_results || 15, 20); + const cacheKey = `${query}|${maxResults}`; - sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条)`); - - const searchHeaders = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', - 'Accept': 'text/html', - 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8' - }; - - /** 带超时的搜索请求 */ - async function fetchWithTimeout(url: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT); - try { - const resp = await fetch(url, { headers: searchHeaders, signal: controller.signal }); - clearTimeout(timeoutId); - return resp; - } catch { - clearTimeout(timeoutId); - return null; - } + // ── 1. 检查缓存 ── + const cached = cacheGet(cacheKey); + if (cached) { + sendLog('success', `🔍 web_search 缓存命中`, `"${query}" → ${cached.results.length} 条`); + return buildSearchResponse(cached.results, maxResults, true); } - // 1. Bing 搜索(首选) - let results: Array<{ title: string; url: string; snippet: string }> = []; + sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条) [并行]`); - try { - const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`; - const resp = await fetchWithTimeout(bingUrl); - if (resp?.ok) { - const html = await resp.text(); - results = parseBingResults(html, maxResults); - } - } catch (e) { - sendLog('warn', `🔍 Bing 搜索失败`, (e as Error).message); - } - - // 2. Bing 无结果,尝试百度 - if (results.length === 0) { - try { - const baiduUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`; - const resp = await fetchWithTimeout(baiduUrl); - if (resp?.ok) { - const html = await resp.text(); - results = parseBaiduResults(html, maxResults); + // ── 2. 并行请求所有搜索引擎 ── + const engines: Array<{ name: string; fetcher: () => Promise> }> = [ + { + name: 'bing', + fetcher: async () => { + const resp = await fetchWithTimeout( + `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}` + ); + if (!resp?.ok) throw new Error(`Bing ${resp?.status}`); + return parseBingResults(await resp.text(), maxResults); } - } catch (e) { - sendLog('warn', `🔍 百度搜索失败`, (e as Error).message); - } - } - - // 3. 百度也失败,尝试 Google - if (results.length === 0) { - try { - const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}`; - const resp = await fetchWithTimeout(googleUrl); - if (resp?.ok) { - const html = await resp.text(); - results = parseGoogleResults(html, maxResults); + }, + { + name: 'baidu', + fetcher: async () => { + const resp = await fetchWithTimeout( + `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}` + ); + if (!resp?.ok) throw new Error(`百度 ${resp?.status}`); + return parseBaiduResults(await resp.text(), maxResults); } - } catch (e) { - sendLog('warn', `🔍 Google 搜索失败`, (e as Error).message); - } - } + }, + { + name: 'ddg', + fetcher: async () => { + const resp = await fetchWithTimeout( + `https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}` + ); + if (!resp?.ok) throw new Error(`DDG ${resp?.status}`); + return parseDDGResults(await resp.text(), maxResults); + } + }, + { + name: 'google', + fetcher: async () => { + const resp = await fetchWithTimeout( + `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}` + ); + if (!resp?.ok) throw new Error(`Google ${resp?.status}`); + return parseGoogleResults(await resp.text(), maxResults); + } + }, + ]; - if (results.length === 0) { - return { success: false, error: '未找到搜索结果,请尝试其他关键词' }; - } + // 所有引擎并行执行,收集结果 + const settled = await Promise.allSettled(engines.map(e => + e.fetcher().then(results => ({ engine: e.name, results })) + )); - // URL 去重:同一 URL 只保留第一个结果 + // 汇总各引擎结果,按引擎优先级合并去重 + const engineOrder = ['bing', 'baidu', 'ddg', 'google']; + const allResults: Array<{ title: string; url: string; snippet: string; engine: string; reachable?: boolean }> = []; const seenUrls = new Set(); - const deduped: typeof results = []; - for (const r of results) { - const normalizedUrl = r.url.replace(/\/+$/, '').toLowerCase(); - if (!seenUrls.has(normalizedUrl)) { - seenUrls.add(normalizedUrl); - deduped.push(r); + + // 记录各引擎状态 + const engineStats: Record = {}; + + for (let i = 0; i < settled.length; i++) { + const s = settled[i]; + const engineName = engineOrder[i]; + if (s.status === 'fulfilled') { + const r = s.value; + engineStats[engineName] = `${r.results.length} 条`; + for (const item of r.results) { + const normalized = item.url.replace(/\/+$/, '').toLowerCase(); + if (!seenUrls.has(normalized)) { + seenUrls.add(normalized); + allResults.push({ ...item, engine: engineName }); + } + } + } else { + engineStats[engineName] = `失败: ${(s.reason as Error)?.message || 'timeout'}`; + sendLog('warn', `🔍 ${engineName} 搜索失败`, (s.reason as Error)?.message || '未知'); } } - // 清理摘要中的残留 HTML 标签和多余空白 - for (const r of deduped) { + if (allResults.length === 0) { + return { success: false, error: '所有搜索引擎均未返回结果,请尝试其他关键词' }; + } + + // ── 3. 清理摘要 ── + for (const r of allResults) { r.title = r.title.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); r.snippet = r.snippet.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); } - // 构建格式化的搜索结果(附带当前日期) - const now = new Date(); - const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`; - const formatted = `[当前日期: ${dateStr}(搜索时的实时日期)]\n\n` + deduped.map((r, i) => - `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}` - ).join('\n\n'); + // ── 4. URL 可达性预检(并行但限制并发 5 个,3 秒超时) ── + const topResults = allResults.slice(0, maxResults); + const CONCURRENCY = 5; + for (let i = 0; i < topResults.length; i += CONCURRENCY) { + const batch = topResults.slice(i, i + CONCURRENCY); + const checks = batch.map(async r => { + try { + const resp = await fetchWithTimeout(r.url, 3000, { + 'User-Agent': DESKTOP_UA, + 'Accept': 'text/html,*/*', + }); + r.reachable = resp?.ok === true; + } catch { + r.reachable = false; + } + }); + await Promise.allSettled(checks); + } - sendLog('success', `🔍 web_search 完成`, `"${query}" → ${deduped.length} 条结果`); + // ── 5. 缓存结果 ── + cacheSet(cacheKey, { results: topResults, time: Date.now() }); - return { - success: true, - query, - results: deduped, - total: deduped.length, - formatted - }; + return buildSearchResponse(topResults, maxResults, false, engineStats); } catch (err) { return { success: false, error: (err as Error).message }; } } +/** 构建搜索响应(格式化文本 + 结构化 JSON) */ +function buildSearchResponse( + results: Array<{ title: string; url: string; snippet: string; engine: string; reachable?: boolean }>, + maxResults: number, + fromCache: boolean, + engineStats?: Record, +): ToolResult { + const sliced = results.slice(0, maxResults); + + // 格式化文本(保持向后兼容) + const now = new Date(); + const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`; + let formatted = `[当前日期: ${dateStr}(搜索时的实时日期)]`; + if (fromCache) formatted += ` [缓存]`; + formatted += `\n\n`; + + if (engineStats) { + const statsStr = Object.entries(engineStats).map(([k, v]) => `${k}: ${v}`).join(' | '); + formatted += `[引擎状态: ${statsStr}]\n\n`; + } + + formatted += sliced.map((r, i) => { + const reachableMark = r.reachable === false ? ' ⚠️可能不可达' : ''; + return `[${i + 1}] ${r.title}${reachableMark}\n URL: ${r.url}\n ${r.snippet}`; + }).join('\n\n'); + + // 结构化 JSON(新增) + const structured = sliced.map(r => ({ + index: sliced.indexOf(r) + 1, + title: r.title, + url: r.url, + snippet: r.snippet, + engine: r.engine, + reachable: r.reachable, + })); + + return { + success: true, + query: '', + results: sliced, + total: sliced.length, + formatted, + structured, + from_cache: fromCache, + engine_stats: engineStats || {}, + }; +} + /** 解析百度 HTML 搜索结果 */ function parseBaiduResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { const results: Array<{ title: string; url: string; snippet: string }> = []; @@ -412,6 +583,55 @@ function parseBaiduResults(html: string, maxResults: number): Array<{ title: str return results; } +/** 解析 DuckDuckGo Lite HTML 搜索结果 */ +function parseDDGResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { + const results: Array<{ title: string; url: string; snippet: string }> = []; + + // DDG Lite 结果: 内的 + + // 尝试匹配:title 后跟 显示URL + // 以及 snippet + const blockRegex = /]*class="[^"]*result-snippet[^"]*"[^>]*>([\s\S]*?)<\/tr>/gi; + let blockMatch; + + while ((blockMatch = blockRegex.exec(html)) !== null && results.length < maxResults) { + const block = blockMatch[1]; + + // 提取 URL + const linkMatch = block.match(/]*href="(https?:\/\/[^"]*)"[^>]*>([\s\S]*?)<\/a>/); + if (!linkMatch) continue; + + const url = decodeHTML(linkMatch[1].trim()); + const title = decodeHTML(linkMatch[2].replace(/<[^>]+>/g, '').trim()); + + // 跳过 DuckDuckGo 自身链接 + if (url.includes('duckduckgo.com')) continue; + + // 提取摘要 + const snippetMatch = block.match(/]*class="[^"]*result-snippet[^"]*"[^>]*>([\s\S]*?)<\/td>/); + const snippet = snippetMatch ? decodeHTML(snippetMatch[1].replace(/<[^>]+>/g, '').trim()) : ''; + + if (title && url.startsWith('http')) { + results.push({ title, url, snippet }); + } + } + + // 回退:尝试匹配更通用的 DDG 结果格式 + if (results.length === 0) { + const altRegex = /]*href="(https?:\/\/[^"]*)"[^>]*class="[^"]*result-link[^"]*"[^>]*>([\s\S]*?)<\/a>/gi; + let altMatch; + while ((altMatch = altRegex.exec(html)) !== null && results.length < maxResults) { + const url = decodeHTML(altMatch[1].trim()); + const title = decodeHTML(altMatch[2].replace(/<[^>]+>/g, '').trim()); + if (url.includes('duckduckgo.com')) continue; + if (title && url.startsWith('http')) { + results.push({ title, url, snippet: '' }); + } + } + } + + return results; +} + /** 解析 Bing HTML 搜索结果 */ function parseBingResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { const results: Array<{ title: string; url: string; snippet: string }> = []; diff --git a/src/renderer/index.html b/src/renderer/index.html index 2a0344e..2a9acc6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.9.1 + v0.10.0