diff --git a/README.md b/README.md
index 8d074c3..dc84d25 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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 @@
|