feat: v0.10.0 — 全面增强网络搜索、Agent Loop、工具稳定性与性能

网络搜索增强:
- web_search: 4引擎并行(Bing+百度+DuckDuckGo+Google), LRU缓存5min, URL可达性预检, 结构化JSON输出
- web_fetch: 自动重试(指数退避2次), 移动端UA切换, SPA页面自动升级浏览器渲染

Agent Loop增强:
- 流式调用超时保护(默认180s可配置)
- Final Answer多模式检测 + 内容长度验证
- 预算警告改用ephemeral标记(context优先丢弃)
- Token感知动态迭代预算(>80%自动缩减到3轮)
- 工具缓存TTL(搜索5min/网页10min/文件30min)
- 自动子任务拆解(并行关键词检测+spawn子代理)
- 增量记忆提取(每20轮触发)
- 智能批次划分: 16个只读工具加入ALWAYS_PARALLEL白名单
- 旧工具结果超过10轮自动截断到500字符

工具增强:
- read_multiple_files: 串行→并行Promise.all
- diff_files: 三级回退(diff -u → git diff → builtin)
- search_files: 新增use_regex正则搜索
- list_directory: 新增offset分页
- Git: stash参数修复, clone/push/pull超时保护
- browser: 提取ensureBrowserReady()消除重复, browser_open切换URL自动重建

文档更新:
- 帮助面板新增Agent Loop v0.10.0增强章节
- README中英文同步更新(四引擎搜索/工具描述)
- SOUL.md/AGENT.md更新(v0.10.0能力描述/SPA规则翻转)
This commit is contained in:
thzxx
2026-06-05 21:20:03 +08:00
parent 2217113014
commit c9adc764ae
18 changed files with 751 additions and 320 deletions
+9 -9
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.9.1-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.10.0-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron"> <img src="https://img.shields.io/badge/electron-33+-47848F?style=flat-square&logo=electron" alt="electron">
<img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript"> <img src="https://img.shields.io/badge/typescript-5.7+-3178C6?style=flat-square&logo=typescript" alt="typescript">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license"> <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
@@ -40,7 +40,7 @@
| 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则)+ USER.md(用户画像),工作空间优先,内置默认 fallback |
| 🎯 | **技能自动生成 v1.1** | 工具调用链自动提取 → 语义匹配 → 参数自优化 → 未使用衰减 → 技能链合并,越用越精准 | | 🎯 | **技能自动生成 v1.1** | 工具调用链自动提取 → 语义匹配 → 参数自优化 → 未使用衰减 → 技能链合并,越用越精准 |
| 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 |
| 🔍 | **引擎联网搜索** | Bing + 百度 + Google 聚合,web_search → web_fetch 链式抓取 | || 🔍 | **引擎联网搜索** | Bing + 百度 + DuckDuckGo + Google 并行聚合,5分钟缓存,URL可达性预检,web_fetch支持自动重试+移动端UA+SPA浏览器渲染回退 |
| 🌏 | **浏览器控制** | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 | | 🌏 | **浏览器控制** | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 |
| 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 | | 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 |
| 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 | | 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 |
@@ -93,8 +93,8 @@
| 工具 | 功能 | | 工具 | 功能 |
|------|------| |------|------|
| `web_search` | 引擎聚合搜索(Bing + 百度 + Google),返回标题、URL、摘要,默认 15 条 | || `web_search` | 引擎并行搜索(Bing + 百度 + DuckDuckGo + Google),结果缓存 5 分钟,URL 可达性预检,返回结构化 JSON |
| `web_fetch` | 网页内容抓取,默认不截断 | | `web_fetch` | 网页内容抓取,自动重试(指数退避),移动端UA切换,SPA页面自动升级到浏览器渲染 |
</details> </details>
@@ -231,7 +231,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist 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 | | 📋 | **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 | | 🎯 | **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 | | 🌐 | **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 | | 🌏 | **Browser Control** | Open pages · Screenshot · JS execution · Content extraction · Click · Type · Scroll · Close |
| 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks | | 🖥️ | **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 | | 🔢 | **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 | | Tool | Function |
|------|------| |------|------|
| `web_search` | Triple-engine search (Bing + Baidu + Google), returns title, URL, snippet, 15 results | | `web_search` | Quad-engine parallel search (Bing + Baidu + DuckDuckGo + Google), 5min cache, URL reachability check, structured JSON output |
| `web_fetch` | Fetch web page content, no truncation | | `web_fetch` | Fetch web page content, auto-retry with backoff, mobile UA toggle, SPA auto-upgrade to browser rendering |
</details> </details>
@@ -454,7 +454,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist 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 ## 🛠️ Common Commands
+3 -3
View File
@@ -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 安装包。 > 仅提供 NSIS 安装包。
@@ -103,7 +103,7 @@ npm run dist # NSIS 安装包
TOKEN="your_access_token" TOKEN="your_access_token"
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \ curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
-H "Content-Type: application/json" \ -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" TOKEN="your_access_token"
RELEASE_ID="<release_id>" RELEASE_ID="<release_id>"
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \ 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"
``` ```
## 故障排查 ## 故障排查
+1 -1
View File
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范 # Metona Ollama Desktop — 开发规范
> 版本: v0.9.1 | 更新: 2026-06-05 | 维护: 项目团队 > 版本: v0.10.0 | 更新: 2026-06-05 | 维护: 项目团队
--- ---
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.9.1", "version": "0.10.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.9.1", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sql.js": "^1.11.0" "sql.js": "^1.11.0"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.9.1", "version": "0.10.0",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "thzxx", "author": "thzxx",
@@ -48,7 +48,7 @@
"requestedExecutionLevel": "asInvoker" "requestedExecutionLevel": "asInvoker"
}, },
"nsis": { "nsis": {
"artifactName": "Metona Ollama Setup v0.9.1.${ext}", "artifactName": "Metona Ollama Setup v0.10.0.${ext}",
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true, "createDesktopShortcut": true,
+41 -36
View File
@@ -17,6 +17,18 @@ export function isBrowserOpen(): boolean {
return agentBrowser !== null && !agentBrowser.isDestroyed(); 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 浏览器窗口 */ /** 获取或创建 Agent 浏览器窗口 */
function getAgentBrowser(): BrowserWindow { function getAgentBrowser(): BrowserWindow {
if (agentBrowser && !agentBrowser.isDestroyed()) return agentBrowser; if (agentBrowser && !agentBrowser.isDestroyed()) return agentBrowser;
@@ -43,6 +55,16 @@ const PAGE_LOAD_TIMEOUT = 30_000;
/** 打开 URL */ /** 打开 URL */
export async function browserOpen(url: string): Promise<{ success: boolean; title?: string; url?: string; error?: string }> { export async function browserOpen(url: string): Promise<{ success: boolean; title?: string; url?: string; error?: string }> {
try { 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(); const win = getAgentBrowser();
sendLog('info', `🌐 browser 打开`, url.slice(0, 100)); 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 }> { export async function browserScreenshot(): Promise<{ success: boolean; png?: string; error?: string }> {
try { try {
if (!isBrowserOpen()) { const ready = ensureBrowserReady();
return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; if (!ready.ok) return { success: false, error: ready.error };
} const win = ready.win!;
const win = agentBrowser!;
if (win.webContents.getURL() === 'about:blank') {
return { success: false, error: '浏览器未加载任何页面' };
}
const image = await win.webContents.capturePage(); const image = await win.webContents.capturePage();
const buffer = image.toPNG(); const buffer = image.toPNG();
sendLog('info', `🌐 browser 截图`, `${buffer.length} bytes`); sendLog('info', `🌐 browser 截图`, `${buffer.length} bytes`);
@@ -98,13 +116,9 @@ export async function browserScreenshot(): Promise<{ success: boolean; png?: str
/** 执行 JavaScript */ /** 执行 JavaScript */
export async function browserEvaluate(js: string): Promise<{ success: boolean; result?: string; error?: string }> { export async function browserEvaluate(js: string): Promise<{ success: boolean; result?: string; error?: string }> {
try { try {
if (!isBrowserOpen()) { const ready = ensureBrowserReady();
return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; if (!ready.ok) return { success: false, error: ready.error };
} const win = ready.win!;
const win = agentBrowser!; // isBrowserOpen 已确认非 null
if (win.webContents.getURL() === 'about:blank') {
return { success: false, error: '浏览器未加载任何页面,请先使用 browser_open 加载页面' };
}
sendLog('info', `🌐 browser 执行 JS`, js.slice(0, 100)); sendLog('info', `🌐 browser 执行 JS`, js.slice(0, 100));
const result = await win.webContents.executeJavaScript(js, true); const result = await win.webContents.executeJavaScript(js, true);
const resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2); 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 }> { export async function browserExtract(): Promise<{ success: boolean; title?: string; text?: string; links?: Array<{text: string; href: string}>; error?: string }> {
try { try {
if (!isBrowserOpen()) { const ready = ensureBrowserReady();
return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; if (!ready.ok) return { success: false, error: ready.error };
} const win = ready.win!;
const win = agentBrowser!;
if (win.webContents.getURL() === 'about:blank') {
return { success: false, error: '浏览器未加载任何页面' };
}
const result = await win.webContents.executeJavaScript(` const result = await win.webContents.executeJavaScript(`
(() => { (() => {
@@ -137,7 +147,7 @@ export async function browserExtract(): Promise<{ success: boolean; title?: stri
return clone.innerText?.trim() || ''; return clone.innerText?.trim() || '';
}; };
const body = getText(document.body); 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 = []; const links = [];
document.querySelectorAll('a[href]').forEach(a => { document.querySelectorAll('a[href]').forEach(a => {
const href = a.href; 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 }> { export async function browserClick(selector: string): Promise<{ success: boolean; error?: string }> {
try { try {
if (!isBrowserOpen()) { const ready = ensureBrowserReady();
return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; if (!ready.ok) return { success: false, error: ready.error };
} const win = ready.win!;
const win = agentBrowser!;
sendLog('info', `🌐 browser 点击`, selector); sendLog('info', `🌐 browser 点击`, selector);
// 安全注入:通过 JSON.stringify 防止选择器中的 JS 注入
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(() => { (() => {
const sel = ${JSON.stringify(selector)}; 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 }> { export async function browserType(selector: string, text: string): Promise<{ success: boolean; error?: string }> {
try { try {
if (!isBrowserOpen()) { const ready = ensureBrowserReady();
return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; if (!ready.ok) return { success: false, error: ready.error };
} const win = ready.win!;
const win = agentBrowser!;
sendLog('info', `🌐 browser 输入`, `${selector}: ${text.slice(0, 50)}`); sendLog('info', `🌐 browser 输入`, `${selector}: ${text.slice(0, 50)}`);
// 安全注入:通过 JSON.stringify 防止选择器和文本中的 JS 注入
await win.webContents.executeJavaScript(` await win.webContents.executeJavaScript(`
(() => { (() => {
const sel = ${JSON.stringify(selector)}; 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 }> { export async function browserScroll(direction: 'down' | 'up' | 'top' | 'bottom'): Promise<{ success: boolean; error?: string }> {
try { try {
if (!isBrowserOpen()) { const ready = ensureBrowserReady();
return { success: false, error: '浏览器未打开,请先使用 browser_open 加载页面' }; if (!ready.ok) return { success: false, error: ready.error };
} const win = ready.win!;
const win = agentBrowser!;
const js = direction === 'down' ? 'window.scrollBy(0, 500)' const js = direction === 'down' ? 'window.scrollBy(0, 500)'
: direction === 'up' ? 'window.scrollBy(0, -500)' : direction === 'up' ? 'window.scrollBy(0, -500)'
: direction === 'top' ? 'window.scrollTo(0, 0)' : direction === 'top' ? 'window.scrollTo(0, 0)'
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, { dialog.showMessageBox(mainWindow!, {
type: 'info', type: 'info',
title: '关于 Metona Ollama', 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', detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath() icon: getIconPath()
}); });
+120 -61
View File
@@ -4,6 +4,7 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { spawn } from 'child_process';
import { checkPathAllowed } from './tool-security.js'; import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.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<ToolResult> { export async function handleListDir(params: { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; offset?: number }): Promise<ToolResult> {
try { try {
const dirPath = resolvePath(params.path); const dirPath = resolvePath(params.path);
const allowed = checkPathAllowed(dirPath, 'read'); const allowed = checkPathAllowed(dirPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason }; if (!allowed.ok) return { success: false, error: allowed.reason };
const maxEntries = 500; const maxEntries = 500;
const startOffset = params.offset || 0;
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = []; const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
let truncated = false; let truncated = false;
let skipped = 0;
async function scanDir(dir: string, depth: number): Promise<void> { async function scanDir(dir: string, depth: number): Promise<void> {
if (entries.length >= maxEntries) { truncated = true; return; } 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.include_hidden && item.name.startsWith('.')) continue;
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) 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 fullPath = path.join(dir, item.name);
const stat = await fs.stat(fullPath); const stat = await fs.stat(fullPath);
entries.push({ entries.push({
@@ -122,13 +128,16 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
} }
await scanDir(dirPath, 1); 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) { } catch (err) {
return { success: false, error: (err as Error).message }; 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<ToolResult> { 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<ToolResult> {
try { try {
const rootPath = resolvePath(params.path); const rootPath = resolvePath(params.path);
const allowed = checkPathAllowed(rootPath, 'read'); 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 searchType = params.search_type || 'both';
const caseSensitive = params.case_sensitive || false; const caseSensitive = params.case_sensitive || false;
const useRegex = params.use_regex || false;
const maxResults = params.max_results || 50; 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 }> }> = []; const results: Array<{ path: string; matches: Array<{ line: number; text: string; column?: number }> }> = [];
let totalMatches = 0; 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 }> = []; const fileMatches: Array<{ line: number; text: string; column: number }> = [];
for (let i = 0; i < lines.length && fileMatches.length < 10; i++) { for (let i = 0; i < lines.length && fileMatches.length < 10; i++) {
const lineToCheck = caseSensitive ? lines[i] : lines[i].toLowerCase(); if (contentMatcher(lines[i])) {
const col = lineToCheck.indexOf(query); const col = useRegex ? 1 : (caseSensitive ? lines[i] : lines[i].toLowerCase()).indexOf(caseSensitive ? query : query.toLowerCase());
if (col !== -1) { fileMatches.push({ line: i + 1, text: lines[i].trim(), column: Math.max(1, col + 1) });
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: col + 1 });
totalMatches++; 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<ToolResult> { export async function handleDiffFiles(params: { file1: string; file2: string; context_lines?: number }): Promise<ToolResult> {
try { try {
const path1 = resolvePath(params.file1); const path1 = resolvePath(params.file1);
@@ -400,66 +436,89 @@ export async function handleDiffFiles(params: { file1: string; file2: string; co
const check2 = checkPathAllowed(path2, 'read'); const check2 = checkPathAllowed(path2, 'read');
if (!check2.ok) return { success: false, error: check2.reason }; 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; const contextSize = params.context_lines || 3;
let i = 0, j = 0; let diffOutput = '';
let usedCommand = '';
while (i < lines1.length || j < lines2.length) { // 1) 优先使用系统 diff 命令
if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) { const diffResult = await runShell('diff', ['-u', `-U${contextSize}`, path1, path2]);
if (diffLines.length === 0 || diffLines[diffLines.length - 1].startsWith('-') || diffLines[diffLines.length - 1].startsWith('+')) { if (diffResult.code <= 1) { // diff exits 0=identical, 1=different
// 添加上下文 diffOutput = diffResult.stdout;
const ctxStart = Math.max(0, i - contextSize); usedCommand = 'diff -u';
for (let k = ctxStart; k < i; k++) { }
diffLines.push(` ${lines1[k]}`);
} // 2) diff 不可用,尝试 git diff
} if (!usedCommand) {
diffLines.push(` ${lines1[i]}`); const gitResult = await runShell('git', ['diff', `--unified=${contextSize}`, '--no-index', path1, path2]);
i++; j++; if (gitResult.code <= 1) {
} else { diffOutput = gitResult.stdout;
// 尝试找匹配 usedCommand = 'git diff';
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++; }
}
} }
} }
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 { return {
success: true, success: true,
file1: path1, file1: path1,
file2: path2, file2: path2,
diff: diffLines.join('\n'), diff: diffOutput,
hasChanges, hasChanges,
lines1: lines1.length, method: usedCommand
lines2: lines2.length
}; };
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message }; 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<ToolResult> { export async function handleReplaceInFiles(params: { path: string; glob: string; old_text: string; new_text: string }): Promise<ToolResult> {
try { try {
const rootPath = resolvePath(params.path); 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<ToolResult> { export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise<ToolResult> {
try { try {
const maxChars = params.max_chars_per_file || 2000; 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 filePath = resolvePath(p);
const allowed = checkPathAllowed(filePath, 'read'); const allowed = checkPathAllowed(filePath, 'read');
if (!allowed.ok) { if (!allowed.ok) {
results.push({ path: p, success: false, error: allowed.reason }); return { path: p, success: false, error: allowed.reason };
continue;
} }
try { try {
const stat = await fs.stat(filePath); const stat = await fs.stat(filePath);
if (stat.size > 1024 * 1024) { if (stat.size > 1024 * 1024) {
results.push({ path: p, success: false, error: '文件超过 1MB' }); return { path: p, success: false, error: '文件超过 1MB' };
continue;
} }
let content = await fs.readFile(filePath, 'utf-8'); let content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').length; const lines = content.split('\n').length;
if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)'; 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) { } 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 }; return { success: true, files: results, total: results.length };
} catch (err) { } catch (err) {
+25 -8
View File
@@ -16,15 +16,30 @@ export async function handleGit(params: { action: string; path?: string; files?:
const dirCheck = checkPathAllowed(cwd, 'read'); const dirCheck = checkPathAllowed(cwd, 'read');
if (!dirCheck.ok) return { success: false, error: dirCheck.reason }; 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) => { return new Promise((resolve) => {
const git = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LANG: 'en_US.UTF-8' } }); const git = spawn('git', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LANG: 'en_US.UTF-8' } });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
let timer: ReturnType<typeof setTimeout> | 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.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); git.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
git.on('close', (code) => resolve({ stdout, stderr, code: code ?? 1 })); git.on('close', (code) => {
git.on('error', (err) => resolve({ stdout, stderr: err.message, code: 1 })); 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.remote) args.push(params.remote);
if (params.branch) args.push(params.branch); if (params.branch) args.push(params.branch);
if (params.force) args.push('--force'); 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 }; if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'push', output: r.stderr.trim() || r.stdout.trim() }; 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']; const args = ['pull'];
if (params.remote) args.push(params.remote); if (params.remote) args.push(params.remote);
if (params.branch) args.push(params.branch); 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 }; if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'pull', output: r.stdout.trim() }; 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': { 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]; 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); const r = await runGit(args);
if (r.code !== 0) return { success: false, error: r.stderr }; if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'stash', subAction, output: r.stdout.trim() }; 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 destDir = params.path ? path.resolve(params.path) : getWorkspaceDir();
const cloneDirCheck = checkPathAllowed(destDir, 'write'); const cloneDirCheck = checkPathAllowed(destDir, 'write');
if (!cloneDirCheck.ok) return { success: false, error: cloneDirCheck.reason }; 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 }; if (r.code !== 0) return { success: false, error: r.stderr };
return { success: true, action: 'clone', url: params.url, destination: destDir }; return { success: true, action: 'clone', url: params.url, destination: destDir };
} }
+375 -155
View File
@@ -121,6 +121,52 @@ export function killToolProcess(): boolean {
/** HTTP 请求超时(毫秒) */ /** HTTP 请求超时(毫秒) */
const HTTP_TIMEOUT = 15_000; 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<string, CacheEntry>();
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<string,string> = FETCH_HEADERS): Promise<Response | null> {
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 实体映射(常见实体) */ /** 完整 HTML 实体映射(常见实体) */
const HTML_ENTITIES: Record<string, string> = { const HTML_ENTITIES: Record<string, string> = {
'&nbsp;': ' ', '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"', '&nbsp;': ' ', '&lt;': '<', '&gt;': '>', '&amp;': '&', '&quot;': '"',
@@ -181,82 +227,141 @@ function htmlToText(html: string): string {
return text.trim(); return text.trim();
} }
export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string }): Promise<ToolResult> { import { browserOpen, browserExtract, browserClose } from './browser.js';
try {
const url = params.url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { success: false, error: '仅支持 http/https 协议' };
}
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' : '完整内容'})`); export async function handleWebFetch(params: { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }): Promise<ToolResult> {
const url = params.url;
// 带超时的 fetch if (!url.startsWith('http://') && !url.startsWith('https://')) {
const controller = new AbortController(); return { success: false, error: '仅支持 http/https 协议' };
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 };
} }
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 — 联网搜索 * Web Search — 联网搜索(并行多引擎 + LRU 缓存 + 质量评分)
* 使用 Bing 搜索(首选)+ 百度作为备用 * 同时请求 Bing / 百度 / DuckDuckGo / Google,取最快结果合并去重
*/ */
export async function handleWebSearch(params: { query: string; max_results?: number }): Promise<ToolResult> { export async function handleWebSearch(params: { query: string; max_results?: number }): Promise<ToolResult> {
try { try {
@@ -265,114 +370,180 @@ export async function handleWebSearch(params: { query: string; max_results?: num
return { success: false, error: '搜索关键词不能为空' }; 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} 条)`); // ── 1. 检查缓存 ──
const cached = cacheGet(cacheKey);
const searchHeaders = { if (cached) {
'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', sendLog('success', `🔍 web_search 缓存命中`, `"${query}" → ${cached.results.length}`);
'Accept': 'text/html', return buildSearchResponse(cached.results, maxResults, true);
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
};
/** 带超时的搜索请求 */
async function fetchWithTimeout(url: string): Promise<Response | null> {
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. Bing 搜索(首选) sendLog('info', `🔍 web_search`, `"${query}" (${maxResults} 条) [并行]`);
let results: Array<{ title: string; url: string; snippet: string }> = [];
try { // ── 2. 并行请求所有搜索引擎 ──
const bingUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`; const engines: Array<{ name: string; fetcher: () => Promise<Array<{title:string;url:string;snippet:string}>> }> = [
const resp = await fetchWithTimeout(bingUrl); {
if (resp?.ok) { name: 'bing',
const html = await resp.text(); fetcher: async () => {
results = parseBingResults(html, maxResults); const resp = await fetchWithTimeout(
} `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`
} catch (e) { );
sendLog('warn', `🔍 Bing 搜索失败`, (e as Error).message); if (!resp?.ok) throw new Error(`Bing ${resp?.status}`);
} return parseBingResults(await resp.text(), maxResults);
// 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);
} }
} catch (e) { },
sendLog('warn', `🔍 百度搜索失败`, (e as Error).message); {
} name: 'baidu',
} fetcher: async () => {
const resp = await fetchWithTimeout(
// 3. 百度也失败,尝试 Google `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`
if (results.length === 0) { );
try { if (!resp?.ok) throw new Error(`百度 ${resp?.status}`);
const googleUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=${maxResults}`; return parseBaiduResults(await resp.text(), maxResults);
const resp = await fetchWithTimeout(googleUrl);
if (resp?.ok) {
const html = await resp.text();
results = parseGoogleResults(html, 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<string>(); const seenUrls = new Set<string>();
const deduped: typeof results = [];
for (const r of results) { // 记录各引擎状态
const normalizedUrl = r.url.replace(/\/+$/, '').toLowerCase(); const engineStats: Record<string, string> = {};
if (!seenUrls.has(normalizedUrl)) {
seenUrls.add(normalizedUrl); for (let i = 0; i < settled.length; i++) {
deduped.push(r); 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 标签和多余空白 if (allResults.length === 0) {
for (const r of deduped) { return { success: false, error: '所有搜索引擎均未返回结果,请尝试其他关键词' };
}
// ── 3. 清理摘要 ──
for (const r of allResults) {
r.title = r.title.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); r.title = r.title.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
r.snippet = r.snippet.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); r.snippet = r.snippet.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
} }
// 构建格式化的搜索结果(附带当前日期) // ── 4. URL 可达性预检(并行但限制并发 5 个,3 秒超时) ──
const now = new Date(); const topResults = allResults.slice(0, maxResults);
const dateStr = `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`; const CONCURRENCY = 5;
const formatted = `[当前日期: ${dateStr}(搜索时的实时日期)]\n\n` + deduped.map((r, i) => for (let i = 0; i < topResults.length; i += CONCURRENCY) {
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}` const batch = topResults.slice(i, i + CONCURRENCY);
).join('\n\n'); 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 { return buildSearchResponse(topResults, maxResults, false, engineStats);
success: true,
query,
results: deduped,
total: deduped.length,
formatted
};
} catch (err) { } catch (err) {
return { success: false, error: (err as Error).message }; 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<string, string>,
): 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 搜索结果 */ /** 解析百度 HTML 搜索结果 */
function parseBaiduResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { function parseBaiduResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: 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; 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 结果:<tr class="result-snippet"> 内的 <a> + <td class="result-snippet">
// 尝试匹配:<a rel="nofollow" href="...">title</a> 后跟 <span class="link-text"> 显示URL
// 以及 <td class="result-snippet">snippet</td>
const blockRegex = /<tr[^>]*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(/<a[^>]*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(/<td[^>]*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 = /<a[^>]*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 搜索结果 */ /** 解析 Bing HTML 搜索结果 */
function parseBingResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> { function parseBingResults(html: string, maxResults: number): Array<{ title: string; url: string; snippet: string }> {
const results: Array<{ title: string; url: string; snippet: string }> = []; const results: Array<{ title: string; url: string; snippet: string }> = [];
+4 -3
View File
@@ -28,7 +28,7 @@
<div class="header-left"> <div class="header-left">
<span class="logo">🦙</span> <span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span> <span class="app-title">Metona Ollama</span>
<span class="app-version">v0.9.1</span> <span class="app-version">v0.10.0</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助"> <button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/> <circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
@@ -394,8 +394,9 @@
<div class="modal-body"> <div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div> <div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div> <div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong> — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片,模型需支持 Vision 能力。图片自动压缩至合适分辨率,节省上下文</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool CallingAI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>38 个工具</strong>,分为 8 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / append_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式)</li><li><strong>联网搜索</strong>2 个):web_search联网搜索,Bing + 百度 + Google 三引擎)/ web_fetch(网页抓取</li><li><strong>Git</strong>1 个):gitstatus / log / diff / add / commit / push / pull / branch / checkout / stash / reset / merge / clone / init / remote / tag,共 16 个子操作</li><li><strong>浏览器控制</strong>8 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_close</li><li><strong>压缩</strong>1 个):compress(创建/解压 zip/tar.gz</li><li><strong>记忆 & 会话 & Skill</strong>8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>1 个):spawn_task</li></ul></li><li><code>run_command</code>(命令执行)支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,可在设置中调整,也可随时中断</li><li>无超时限制,宁可等待也不中断,由用户控制生命周期</li><li>独立工具自动并行执行,有依赖关系的工具串行执行</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral</li></ul></div> <div class="help-section"><h4>🔧 Tool CallingAI 自主操作)</h4><ul><li>设置中开启后,AI 可以在对话中<strong>自主调用本地工具</strong>,像一个本地 Agent</li><li><strong>38 个工具</strong>,分为 8 类:<ul><li><strong>文件系统</strong>17 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / append_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时</li><li><strong>联网搜索</strong>2 个):web_search4引擎并行:Bing+百度+DuckDuckGo+Google,结果缓存5分钟,URL可达性预检)/ web_fetch(自动重试+移动端UA切换+SPA页面自动升级到浏览器渲染</li><li><strong>Git</strong>1 个):git16 个子操作,push/pull/clone 内置60-120s超时保护</li><li><strong>浏览器控制</strong>8 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_close</li><li><strong>记忆 & 会话 & Skill</strong>8 个):memory_search / memory_add / memory_replace / memory_remove / session_list / session_read / skill_list / skill_view</li><li><strong>子代理</strong>1 个):spawn_task</li></ul></li><li>read_multiple_files 支持<strong>并行读取</strong>diff_files 优先调用系统 diff/git diff 命令</li><li>search_files 支持<strong>正则表达式搜索</strong>use_regex=true</li><li>list_directory 支持 <strong>offset 分页</strong>,大目录可分页读取</li><li><code>run_command</code>(命令执行)支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环,上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>16个只读工具加入并行白名单),有依赖关系的工具串行执行</li><li>⚠️ 需要模型支持 Tool Calling(推荐 Qwen3、Llama 3.1+、Mistral</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li>对话满 <strong>6 条消息</strong>自动触发记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 <code>memory_search</code> / <code>memory_add</code> / <code>memory_replace</code> / <code>memory_remove</code> 工具主动管理记忆</li><li>AI 可通过 <code>session_list</code> / <code>session_read</code> 查阅历史会话</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div> <div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>设置中开启后,AI 从对话中<strong>自动提取关键信息</strong>(事实/偏好/规则),跨会话持续积累</li><li><strong>增量提取</strong>:长对话中每 20 轮自动触发轻量级记忆提取,不等到对话结束</li><li>对话结束时自动触发完整记忆提取</li><li>新对话时自动检索相关记忆注入上下文,让 AI "记住"你</li><li>点击顶部 🧠 按钮打开记忆面板:搜索、筛选、编辑、删除</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li><strong>向量语义搜索</strong>:在设置中选择嵌入模型后,支持语义级别记忆检索(更精准)</li><li>未选择嵌入模型时,使用关键词匹配检索记忆</li><li>AI 可通过 memory 工具主动管理记忆</li><li>记忆写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>记忆存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop v0.10.0 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认180s),Ollama 卡死不再永久阻塞</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div> <div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}_{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div> <div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>删除工作空间中的文件即可恢复为内置默认版本</li></ul></div>
<div class="help-section"><h4>🎯 技能自动生成</h4><ul><li>AI 完成包含 <strong>2+ 工具调用</strong>的复杂任务后,自动从执行轨迹中提取可复用技能</li><li><strong>语义匹配</strong>:配置嵌入模型后,根据用户消息的语义自动匹配最相关技能</li><li><strong>参数自优化</strong>:同一技能链多次执行后,自动合并高频参数提示,越用越精准</li><li><strong>技能衰减</strong>:30 天未使用的技能自动降权,90 天几乎忽略</li><li><strong>链合并</strong>:相同前缀(前 2 步相同)的多个技能自动合并为抽象技能</li><li>AI 可通过 <code>skill_list</code>(渐进式摘要)和 <code>skill_view</code>(完整工具链)查看技能</li><li>技能存储在本地 SQLite,不会上传到任何服务器</li></ul></div> <div class="help-section"><h4>🎯 技能自动生成</h4><ul><li>AI 完成包含 <strong>2+ 工具调用</strong>的复杂任务后,自动从执行轨迹中提取可复用技能</li><li><strong>语义匹配</strong>:配置嵌入模型后,根据用户消息的语义自动匹配最相关技能</li><li><strong>参数自优化</strong>:同一技能链多次执行后,自动合并高频参数提示,越用越精准</li><li><strong>技能衰减</strong>:30 天未使用的技能自动降权,90 天几乎忽略</li><li><strong>链合并</strong>:相同前缀(前 2 步相同)的多个技能自动合并为抽象技能</li><li>AI 可通过 <code>skill_list</code>(渐进式摘要)和 <code>skill_view</code>(完整工具链)查看技能</li><li>技能存储在本地 SQLite,不会上传到任何服务器</li></ul></div>
+2 -2
View File
@@ -1,6 +1,6 @@
# AGENT.md — Agent 行为准则 # AGENT.md — Agent 行为准则
你是具备工具调用能力的 AI 助手。你有文件操作、命令执行、联网搜索、浏览器控制、Git、记忆管理等工具可用。 你是具备工具调用能力的 AI 助手。你有文件操作、命令执行、四引擎联网搜索Bing+百度+DuckDuckGo+Google 并行)、网页抓取(支持自动重试+移动端UA+SPA浏览器渲染回退)、浏览器控制、Git、记忆管理等工具可用。
## 核心规则 ## 核心规则
@@ -21,5 +21,5 @@
- **严禁猜 URL**:所有 URL 必须来自搜索结果。 - **严禁猜 URL**:所有 URL 必须来自搜索结果。
- **上传文件已在对话中**:用户上传的文件内容已在消息中,无需再用 read_file 读取。 - **上传文件已在对话中**:用户上传的文件内容已在消息中,无需再用 read_file 读取。
- **不要抓取 SPA 页面**GitHub releases、npm 等直接搜结果即可 - **SPA 页面自动处理**:web_fetch 遇到内容过短的 SPA 页面会自动升级到浏览器渲染,无需手动用 browser_open。GitHub releases、npm 等页面可直接通过 web_fetch 获取
- 永远不要只输出"我将执行xxx"然后结束。 - 永远不要只输出"我将执行xxx"然后结束。
+1 -1
View File
@@ -6,7 +6,7 @@
- **名称**: Metona - **名称**: Metona
- **角色**: Metona Ollama 桌面 AI 助手 - **角色**: Metona Ollama 桌面 AI 助手
- **核心能力**: ReAct Agent Loop + 38 个内置工具 + 记忆系统 + 技能自动生成 - **核心能力**: ReAct Agent Loop v0.10.0 + 38 个增强工具 + 记忆系统 + 技能自动生成 + 智能上下文压缩
## 性格与语气 ## 性格与语气
+146 -26
View File
@@ -49,6 +49,14 @@ const TOOL_MAX_RESULT_SIZE: Record<string, number> = {
/** v4.1: 工具并行执行 — 依赖检测用 */ /** v4.1: 工具并行执行 — 依赖检测用 */
const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']); const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch', 'edit_file', 'append_file', 'move_file', 'delete_file']);
/** 始终可并行的只读/独立工具(无数据依赖,永远可以同批执行) */
const ALWAYS_PARALLEL = new Set([
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate',
'memory_search', 'session_list', 'session_read', 'skill_list', 'skill_view',
'diff_files', 'git',
]);
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */ /** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
const VALID_TOOL_NAMES = new Set([ const VALID_TOOL_NAMES = new Set([
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory', 'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
@@ -125,7 +133,27 @@ function parseToolCallsFromText(content: string): ToolCall[] {
return calls; return calls;
} }
const toolResultCache = new Map<string, ToolResult>(); const toolResultCache = new Map<string, { result: ToolResult; timestamp: number }>();
/** 工具缓存 TTL(毫秒),按工具类型设定 */
const CACHE_TTL_MAP: Record<string, number> = {
web_search: 5 * 60_000, // 搜索: 5分钟
web_fetch: 10 * 60_000, // 网页: 10分钟
read_file: 30 * 60_000, // 文件: 30分钟
list_directory: 60_000, // 目录: 1分钟
search_files: 60_000, // 文件搜索: 1分钟
browser_screenshot: 60_000, // 截图: 1分钟
browser_extract: 5 * 60_000,// 浏览器内容: 5分钟
// 其他工具默认无限期
default: Infinity,
};
/** 检查缓存是否过期 */
function isCacheValid(toolName: string, timestamp: number): boolean {
const ttl = CACHE_TTL_MAP[toolName] ?? CACHE_TTL_MAP.default;
if (!isFinite(ttl)) return true;
return Date.now() - timestamp < ttl;
}
/** 生成工具调用缓存 key */ /** 生成工具调用缓存 key */
function getToolCacheKey(name: string, args: Record<string, unknown>): string { function getToolCacheKey(name: string, args: Record<string, unknown>): string {
@@ -482,6 +510,47 @@ export async function runAgentLoop(
}; };
messages.push(userMsg); messages.push(userMsg);
// P2-1: 自动子任务拆解 — 检测用户消息中的并行任务关键词
const PARALLEL_PATTERNS = /同时|分别|以及|另外|此外|并且|也|also|and\s+also|separately|in\s+addition|meanwhile/i;
const SUBTASK_SEPARATORS = /(?:^|\n)\s*(?:[1-9][.、)]|[-*•]\s+)/;
const userText = userContent || '';
const hasParallel = PARALLEL_PATTERNS.test(userText) && userText.length > 100;
if (hasParallel && useTools) {
// 尝试按数字列表拆分子任务
const parts = userText.split(SUBTASK_SEPARATORS).filter(p => p.trim().length > 20);
if (parts.length >= 3) {
logInfo(`自动子任务拆解: 检测到 ${parts.length} 个并行子任务`);
const subtaskResults: string[] = [];
const { executeSubAgent } = await import('./sub-agent.js');
const subTasks = parts.slice(0, Math.min(parts.length, 3)); // 最多3个
// 并行 spawn
const subResults = await Promise.allSettled(
subTasks.map((subTask, idx) =>
executeSubAgent(subTask.trim(), `这是父任务的第 ${idx + 1}/${subTasks.length} 个子任务`, {
maxLoops: 8,
timeout: 120_000,
})
)
);
for (let i = 0; i < subResults.length; i++) {
const r = subResults[i];
if (r.status === 'fulfilled' && r.value.success) {
const content = (r.value as any).content || JSON.stringify(r.value);
subtaskResults.push(`[子任务 ${i + 1}] ${subTasks[i].trim().slice(0, 80)}...\n结果: ${content.slice(0, 1000)}`);
}
}
if (subtaskResults.length > 0) {
messages.push({
role: 'system',
content: `以下是通过并行子代理预先完成的子任务结果,你可以直接引用这些结果来加速回答:\n\n${subtaskResults.join('\n\n')}`,
ephemeral: true,
});
logInfo(`子任务拆解完成: ${subtaskResults.length}/${subTasks.length} 个成功`);
}
}
}
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪 // 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576); const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
const contextResult = buildContext(messages, { maxTokens: numCtx, windowSize: 20 }); const contextResult = buildContext(messages, { maxTokens: numCtx, windowSize: 20 });
@@ -507,8 +576,8 @@ export async function runAgentLoop(
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`); logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, tokens≈${estimateTokens(messages.map(m => m.content || '').join(''))}`);
// 迭代预算:从 state 读取,默认 85 // 迭代预算:从 state 读取,默认 85(后续可能被 Token 感知策略动态缩减)
const maxLoops = state.get<number>('maxTurns', 85); let maxLoops = state.get<number>('maxTurns', 85);
let loopCount = 0; let loopCount = 0;
const allToolRecords: ToolCallRecord[] = []; const allToolRecords: ToolCallRecord[] = [];
@@ -545,6 +614,14 @@ export async function runAgentLoop(
throw new DOMException('Aborted', 'AbortError'); throw new DOMException('Aborted', 'AbortError');
} }
// P1-4: Token 感知的动态迭代预算 — context window 使用率 > 80% 时强制缩减剩余轮次
const usageRatio = estimateTokens(messages.map(m => m.content || '').join('')) / numCtx;
if (usageRatio > 0.8 && (maxLoops - loopCount) > 3) {
const newMax = loopCount + 3;
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 限制剩余迭代为 ${newMax - loopCount} 轮(原 ${maxLoops - loopCount} 轮)`);
maxLoops = newMax;
}
// 非首轮迭代:通知 UI 创建新的消息气泡(防止每轮内容互相覆盖) // 非首轮迭代:通知 UI 创建新的消息气泡(防止每轮内容互相覆盖)
if (loopCount > 1 && callbacks.onNewIteration) { if (loopCount > 1 && callbacks.onNewIteration) {
callbacks.onNewIteration(prevToolCalls.length > 0 ? prevToolCalls : undefined); callbacks.onNewIteration(prevToolCalls.length > 0 ? prevToolCalls : undefined);
@@ -556,22 +633,30 @@ export async function runAgentLoop(
content = ''; content = '';
const toolCalls: ToolCall[] = []; const toolCalls: ToolCall[] = [];
// v5.1.2 预算警告:接近迭代上限时注入临时提示 // v5.1.2 预算警告:接近迭代上限时注入临时提示(ephemeral 标记,上下文裁剪时优先丢弃)
let budgetWarningIdx = -1;
const remaining = maxLoops - loopCount + 1; const remaining = maxLoops - loopCount + 1;
if (remaining <= 5 && remaining > 0) { if (remaining <= 5 && remaining > 0) {
const warning = remaining <= 2 const warning = remaining <= 2
? `\n⚠️ CRITICAL: You have only ${remaining} iteration(s) left. Stop using tools and provide your final answer NOW.` ? `\n⚠️ CRITICAL: You have only ${remaining} iteration(s) left. Stop using tools and provide your final answer NOW.`
: `\n⚠️ WARNING: You have approximately ${remaining} iterations remaining. Start wrapping up and prepare your final answer.`; : `\n⚠️ WARNING: You have approximately ${remaining} iterations remaining. Start wrapping up and prepare your final answer.`;
messages.push({ role: 'system', content: warning }); messages.push({ role: 'system', content: warning, ephemeral: true });
budgetWarningIdx = messages.length - 1;
} }
const abortController = new AbortController(); const abortController = new AbortController();
state.set(KEYS.ABORT_CONTROLLER, abortController); state.set(KEYS.ABORT_CONTROLLER, abortController);
// 流式调用超时保护(可配置,0 = 禁用超时)
const STREAM_TIMEOUT_MS = state.get<number>('streamTimeout', 180_000); // 默认 180s
let streamTimer: ReturnType<typeof setTimeout> | null = null;
if (STREAM_TIMEOUT_MS > 0) {
streamTimer = setTimeout(() => {
logWarn(`流式调用超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`);
abortController.abort();
}, STREAM_TIMEOUT_MS);
}
try { try {
// 流式调用(无超时限制) // 流式调用
await api.chatStream( await api.chatStream(
{ {
model, model,
@@ -642,7 +727,12 @@ export async function runAgentLoop(
} }
} catch { /* 校准失败不阻塞主流程 */ } } catch { /* 校准失败不阻塞主流程 */ }
// 流式调用成功完成,清除超时定时器
if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
} catch (err) { } catch (err) {
// 清除超时定时器
if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
logInfo('流式调用已中止'); logInfo('流式调用已中止');
// 保存工具记录到 state,供消费方的 catch 处理中止消息 // 保存工具记录到 state,供消费方的 catch 处理中止消息
@@ -652,10 +742,6 @@ export async function runAgentLoop(
throw err; // 抛出 AbortError,由消费方统一处理 throw err; // 抛出 AbortError,由消费方统一处理
} }
logError('流式调用异常', (err as Error).message); logError('流式调用异常', (err as Error).message);
// 清理预算警告临时消息
if (budgetWarningIdx >= 0) {
messages.splice(budgetWarningIdx, 1);
}
if (content || thinking) { if (content || thinking) {
messages.push({ role: 'assistant', content: content || '(模型响应异常)', ...(thinking && { thinking }) }); messages.push({ role: 'assistant', content: content || '(模型响应异常)', ...(thinking && { thinking }) });
} }
@@ -663,11 +749,6 @@ export async function runAgentLoop(
return; return;
} }
// 清理预算警告临时消息
if (budgetWarningIdx >= 0) {
messages.splice(budgetWarningIdx, 1);
}
// 提取 ReAct 思考过程 // 提取 ReAct 思考过程
const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i); const thoughtMatch = content.match(/\*\*Thought:\*\*\s*([\s\S]*?)(?=\*\*Action:\*\*|\*\*Final Answer:\*\*|$)/i);
const thought = thoughtMatch ? thoughtMatch[1].trim() : ''; const thought = thoughtMatch ? thoughtMatch[1].trim() : '';
@@ -693,8 +774,17 @@ export async function runAgentLoop(
} }
} }
// 检查是否是 Final Answer没有工具调用且包含 Final Answer // 检查是否是 Final Answer多模式匹配 + 内容长度验证
const isFinalAnswer = content.includes('Final Answer:') || content.includes('最终答案:'); // 只有当无工具调用且有实际内容时才可能是最终回答
const FINAL_PATTERNS: RegExp[] = [
/Final\s*Answer\s*:/i,
/最终答案[:]/,
/最终回答[:]/,
/总结[:]/,
];
const isFinalAnswer = toolCalls.length === 0
&& content.length > 50
&& FINAL_PATTERNS.some(p => p.test(content));
// 处理空响应:如果之前有工具调用但模型返回空内容,不立即结束 // 处理空响应:如果之前有工具调用但模型返回空内容,不立即结束
if (toolCalls.length === 0 && !content.trim() && loopCount > 1 && allToolRecords.length > 0) { if (toolCalls.length === 0 && !content.trim() && loopCount > 1 && allToolRecords.length > 0) {
@@ -774,13 +864,15 @@ export async function runAgentLoop(
for (const call of toolCalls) { for (const call of toolCalls) {
if (currentBatch.length === 0) { if (currentBatch.length === 0) {
currentBatch.push(call); currentBatch.push(call);
} else if (ALWAYS_PARALLEL.has(call.function.name)) {
// 只读/独立工具 → 永远可以并行
currentBatch.push(call);
} else { } else {
// 检查当前工具是否依赖当前批次中任何工具的结果 // 检查当前工具是否依赖当前批次中任何工具的结果
const needsPrevResult = currentBatch.some(prev => const needsPrevResult = currentBatch.some(prev =>
TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name
); );
if (needsPrevResult) { if (needsPrevResult) {
// 有依赖,当前批次结束,开启新批次
batches.push(currentBatch); batches.push(currentBatch);
batchDeps.set(call, currentBatch[currentBatch.length - 1].function.name); batchDeps.set(call, currentBatch[currentBatch.length - 1].function.name);
currentBatch = [call]; currentBatch = [call];
@@ -806,20 +898,24 @@ export async function runAgentLoop(
if (cached) { if (cached) {
return [{ return [{
name: call.function.name, arguments: call.function.arguments, name: call.function.name, arguments: call.function.arguments,
result: cached, status: 'success' as const, timestamp: Date.now() result: cached.result, status: 'success' as const, timestamp: Date.now()
}, null]; }, null];
} }
} }
// 跨轮次缓存命中 // 跨轮次缓存命中(含 TTL 过期检查)
const cachedResult = toolResultCache.get(cacheKey); const cachedEntry = toolResultCache.get(cacheKey);
if (cachedResult) { if (cachedEntry && isCacheValid(call.function.name, cachedEntry.timestamp)) {
logInfo(`工具缓存命中: ${call.function.name}`); logInfo(`工具缓存命中: ${call.function.name}`);
return [{ return [{
name: call.function.name, arguments: call.function.arguments, name: call.function.name, arguments: call.function.arguments,
result: cachedResult, status: 'success' as const, timestamp: Date.now() result: cachedEntry.result, status: 'success' as const, timestamp: Date.now()
}, null]; }, null];
} }
if (cachedEntry) {
// 过期缓存,删除
toolResultCache.delete(cacheKey);
}
callbacks.onToolCallStart(call); callbacks.onToolCallStart(call);
logToolStart(call.function.name, JSON.stringify(call.function.arguments)); logToolStart(call.function.name, JSON.stringify(call.function.arguments));
@@ -885,7 +981,7 @@ export async function runAgentLoop(
role: 'tool', tool_name: record.name, role: 'tool', tool_name: record.name,
content: formatToolResultForModel(record.name, record.result!) content: formatToolResultForModel(record.name, record.result!)
}); });
if (cacheKey) toolResultCache.set(cacheKey, record.result!); if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
if (record.status === 'success') { if (record.status === 'success') {
callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!); callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!);
} else if (record.status === 'cancelled') { } else if (record.status === 'cancelled') {
@@ -907,8 +1003,32 @@ export async function runAgentLoop(
).join('; '); ).join('; ');
saveTrace(traceStep); saveTrace(traceStep);
// P2-2: 增量记忆提取 — 每 20 轮自动触发轻量级记忆提取
if (isMemoryEnabled() && loopCount > 1 && loopCount % 20 === 0 && messages.length >= 10) {
try {
const { extractMemoriesFromConversation } = await import('./memory-manager.js');
const recentMsgs = messages.slice(-30).filter(m => m.role === 'user' || m.role === 'assistant');
await extractMemoriesFromConversation(
recentMsgs.map(m => ({ role: m.role, content: m.content })),
currentSession?.title
);
logInfo('增量记忆提取完成', `${loopCount}`);
} catch { /* 不阻塞 */ }
}
// 保存本轮工具调用,供下一轮 onNewIteration 使用 // 保存本轮工具调用,供下一轮 onNewIteration 使用
prevToolCalls = toolCalls; prevToolCalls = toolCalls;
// P1-3: 增量工具结果截断 — 超过 10 轮的旧工具结果自动截断到 500 字符
if (loopCount > 10 && loopCount % 5 === 0) {
const truncateBefore = messages.length - 15;
for (let i = 0; i < messages.length && i < truncateBefore; i++) {
const m = messages[i];
if (m.role === 'tool' && m.content && m.content.length > 500) {
messages[i] = { ...m, content: m.content.slice(0, 500) + '\n... (已截断旧工具结果)' };
}
}
}
} }
logWarn('ReAct Agent Loop 达到最大工具调用次数限制'); logWarn('ReAct Agent Loop 达到最大工具调用次数限制');
+3
View File
@@ -74,6 +74,9 @@ export function scoreMessageImportance(msg: OllamaMessage): number {
if (msg.role === 'user') score += 2; // 用户消息最重要 if (msg.role === 'user') score += 2; // 用户消息最重要
if (msg.role === 'system') score -= 2; // 系统消息通常可压缩 if (msg.role === 'system') score -= 2; // 系统消息通常可压缩
// ephemeral 临时消息 → 最低权重,优先丢弃
if (msg.ephemeral) return 0;
// 已压缩标记 → 低权重(避免压缩产物堆积) // 已压缩标记 → 低权重(避免压缩产物堆积)
if (msg.compressed) score = 1; if (msg.compressed) score = 1;
+1 -1
View File
@@ -1,5 +1,5 @@
/** /**
* SubAgent - 子代理委派系统 (v0.9.1 增强版) * SubAgent - 子代理委派系统 (v0.10.0 增强版)
* 子代理拥有受限工具集(只读),可独立完成调研/搜索/分析类任务 * 子代理拥有受限工具集(只读),可独立完成调研/搜索/分析类任务
* 主 Agent 通过 spawn_task 工具并行委派多个子代理 * 主 Agent 通过 spawn_task 工具并行委派多个子代理
*/ */
+13 -9
View File
@@ -45,7 +45,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function', type: 'function',
function: { function: {
name: 'list_directory', name: 'list_directory',
description: 'List directory contents. Returns file names, types, sizes, and modification times.', description: 'List directory contents. Returns file names, types, sizes, and modification times. Supports offset-based pagination for directories with many entries.',
parameters: { parameters: {
type: 'object', type: 'object',
required: ['path'], required: ['path'],
@@ -53,7 +53,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
path: { type: 'string', description: 'Directory path.' }, path: { type: 'string', description: 'Directory path.' },
recursive: { type: 'boolean', description: 'List recursively. Default: false.' }, recursive: { type: 'boolean', description: 'List recursively. Default: false.' },
max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' }, max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' },
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' } include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' },
offset: { type: 'integer', description: 'Skip first N entries for pagination. Default: 0.' }
} }
} }
} }
@@ -62,17 +63,18 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function', type: 'function',
function: { function: {
name: 'search_files', name: 'search_files',
description: 'Search files by name pattern or text content within files.', description: 'Search files by name pattern or text content within files. Supports regular expressions with use_regex=true.',
parameters: { parameters: {
type: 'object', type: 'object',
required: ['path', 'query'], required: ['path', 'query'],
properties: { properties: {
path: { type: 'string', description: 'Root directory to search.' }, path: { type: 'string', description: 'Root directory to search.' },
query: { type: 'string', description: 'Search query (glob or text).' }, query: { type: 'string', description: 'Search query (glob, text, or regex).' },
search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' }, search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' },
case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' }, case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' },
max_results: { type: 'integer', description: 'Max results. Default: 50.' }, max_results: { type: 'integer', description: 'Max results. Default: 50.' },
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' } file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' },
use_regex: { type: 'boolean', description: 'Treat query as regular expression. Default: false.' }
} }
} }
} }
@@ -156,14 +158,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function', type: 'function',
function: { function: {
name: 'web_fetch', name: 'web_fetch',
description: 'Fetch content from a URL. Returns the page text content (HTML stripped). Supports HTTP/HTTPS.', description: 'Fetch content from a URL. Returns the page text content (HTML stripped). Supports HTTP/HTTPS. Automatically retries on failure (up to 2 times with exponential backoff). Will auto-upgrade to browser rendering for JavaScript-heavy pages that return empty shells.',
parameters: { parameters: {
type: 'object', type: 'object',
required: ['url'], required: ['url'],
properties: { properties: {
url: { type: 'string', description: 'URL to fetch (http/https).' }, url: { type: 'string', description: 'URL to fetch (http/https).' },
max_chars: { type: 'integer', description: 'Max characters to return. Default: 0 (no limit, return full content).' }, max_chars: { type: 'integer', description: 'Max characters to return. Default: 0 (no limit, return full content).' },
extract_mode: { type: 'string', enum: ['text', 'markdown'], description: 'Extraction mode. Default: text.' } extract_mode: { type: 'string', enum: ['text', 'markdown'], description: 'Extraction mode. Default: text.' },
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent. Some sites return simpler content for mobile. Default: false.' },
retry: { type: 'boolean', description: 'Enable auto-retry on failure (5xx/network errors, up to 2 times). Default: true.' }
} }
} }
} }
@@ -342,13 +346,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function', type: 'function',
function: { function: {
name: 'web_search', name: 'web_search',
description: 'Search the web and return relevant results. Use this when you need to find current information, news, facts, or answer questions that require up-to-date knowledge beyond your training data.', description: 'Search the web using multiple engines in parallel (Bing + Baidu + DuckDuckGo + Google) and return merged, deduplicated results. Results are cached for 5 minutes. Includes reachability checks for top results. Returns both formatted text and structured JSON. Use this when you need current information, news, facts, or answers requiring up-to-date knowledge beyond your training data.',
parameters: { parameters: {
type: 'object', type: 'object',
required: ['query'], required: ['query'],
properties: { properties: {
query: { type: 'string', description: 'The search query. Be specific and concise for best results.' }, query: { type: 'string', description: 'The search query. Be specific and concise for best results.' },
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 15, max: 15.' } max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 15, max: 20.' }
} }
} }
} }
+2
View File
@@ -10,6 +10,8 @@ export interface OllamaMessage {
tool_name?: string; tool_name?: string;
/** 标记此消息为 LLM 压缩摘要生成 */ /** 标记此消息为 LLM 压缩摘要生成 */
compressed?: boolean; compressed?: boolean;
/** 标记此消息为临时性消息(预算警告等),上下文裁剪时优先丢弃 */
ephemeral?: boolean;
} }
export interface OllamaChatParams { export interface OllamaChatParams {