feat: 移除所有工具结果截断,确保内容完整传给AI

删除TOOL_MAX_RESULT_SIZE配置表和formatDefaultToolResult截断; read_file移除2000行/100KB截断; read_multiple_files移除10000字符截断; list_directory移除2000条目上限; search_files移除10条匹配和50结果限制; run_command移除512KB输出截断; browser_extract移除15000字符截断; session_read移除50条消息限制; session_list移除20条限制; memory search移除8条限制; web_search移除10条结果限制; SearXNG HTML移除内容截断; 版本号升级至0.14.11; 实现工具三档执行模式(auto/confirm/disabled)
This commit is contained in:
thzxx
2026-07-09 22:31:04 +08:00
parent ea82c64f4b
commit e3b44a0357
15 changed files with 311 additions and 193 deletions
+3 -3
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.14.10-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.14.11-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/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">
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
产出:`release/Metona Ollama Setup v0.14.10.exe`
产出:`release/Metona Ollama Setup v0.14.11.exe`
## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
Output: `release/Metona Ollama Setup v0.14.10.exe`
Output: `release/Metona Ollama Setup v0.14.11.exe`
## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
"version": "0.14.10",
"version": "0.14.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
"version": "0.14.10",
"version": "0.14.11",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
"version": "0.14.10",
"version": "0.14.11",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
+2 -2
View File
@@ -203,7 +203,7 @@ export async function browserExtract(params: { selector?: string; max_chars?: nu
const ready = ensureBrowserReady();
if (!ready.ok) return { success: false, error: ready.error };
const win = ready.win!;
const maxChars = params.max_chars || 15000;
const maxChars = params.max_chars || 0; // 0 = 不截断
const result = await win.webContents.executeJavaScript(`
(() => {
@@ -223,7 +223,7 @@ export async function browserExtract(params: { selector?: string; max_chars?: nu
const body = getText(root);
const maxC = ${maxChars};
const text = body.length > maxC ? body.slice(0, maxC) + '\\\\n... (已截断)' : body;
const text = maxC > 0 && body.length > maxC ? body.slice(0, maxC) + '\\\\n... (已截断)' : body;
const links = [];
root.querySelectorAll('a[href]').forEach(a => {
+1 -1
View File
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
message: 'Metona Ollama Desktop v0.14.7',
message: 'Metona Ollama Desktop v0.14.11',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+8 -20
View File
@@ -33,15 +33,11 @@ export async function handleReadFile(params: { path: string; encoding?: string;
if (params.offset_bytes !== undefined || params.limit_bytes !== undefined) {
const offset = params.offset_bytes || 0;
const limit = params.limit_bytes || Math.min(100 * 1024, buf.length - offset); // 默认100KB
const limit = params.limit_bytes || buf.length - offset;
const end = Math.min(buf.length, offset + limit);
resultBuf = buf.subarray(offset, end);
byteRange = [offset, end];
truncated = end < buf.length;
} else if (buf.length > 100 * 1024) {
resultBuf = buf.subarray(0, 100 * 1024);
byteRange = [0, 100 * 1024];
truncated = true;
}
const ext = path.extname(filePath).toLowerCase();
@@ -101,7 +97,6 @@ export async function handleReadFile(params: { path: string; encoding?: string;
// 按行读取
const content = await fs.readFile(filePath, encoding);
const lines = content.split('\n');
const defaultLimit = 2000;
let resultContent = content;
let lineRange: [number, number] = [1, lines.length];
@@ -109,14 +104,10 @@ export async function handleReadFile(params: { path: string; encoding?: string;
if (params.start_line || params.end_line) {
const start = Math.max(1, params.start_line || 1) - 1;
const end = Math.min(lines.length, params.end_line || Math.min(start + defaultLimit, lines.length));
const end = Math.min(lines.length, params.end_line || lines.length);
resultContent = lines.slice(start, end).join('\n');
lineRange = [start + 1, end];
truncated = end < lines.length;
} else if (lines.length > defaultLimit) {
resultContent = lines.slice(0, defaultLimit).join('\n');
lineRange = [1, defaultLimit];
truncated = true;
}
return {
@@ -251,19 +242,16 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
const allowed = checkPathAllowed(dirPath, 'read');
if (!allowed.ok) return { success: false, error: allowed.reason };
const maxEntries = 2000;
const startOffset = params.offset || 0;
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
let truncated = false;
let skipped = 0;
async function scanDir(dir: string, depth: number): Promise<void> {
if (entries.length >= maxEntries) { truncated = true; return; }
if (params.recursive && params.max_depth && depth > params.max_depth) return;
const items = await fs.readdir(dir, { withFileTypes: true });
for (const item of items) {
if (entries.length >= maxEntries) { truncated = true; return; }
if (!params.include_hidden && item.name.startsWith('.')) continue;
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
@@ -307,7 +295,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const searchType = params.search_type || 'both';
const caseSensitive = params.case_sensitive || false;
const useRegex = params.use_regex || false;
const maxResults = params.max_results || 50;
const maxResults = params.max_results || 0; // 0 = 不限制
const query = params.query; // 保持原始,正则用 flags 控制大小写
// 构建搜索匹配器
@@ -355,7 +343,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const allFiles = await collectFiles(rootPath);
for (const filePath of allFiles) {
if (totalMatches >= maxResults) break;
if (maxResults > 0 && totalMatches >= maxResults) break;
if (searchType === 'filename' || searchType === 'both') {
const fileName = path.basename(filePath);
@@ -383,7 +371,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
const lines = content.split('\n');
const fileMatches: Array<{ line: number; text: string; column: number }> = [];
for (let i = 0; i < lines.length && fileMatches.length < 10; i++) {
for (let i = 0; i < lines.length; i++) {
if (contentMatcher(lines[i])) {
const col = useRegex ? 1 : (caseSensitive ? lines[i] : lines[i].toLowerCase()).indexOf(caseSensitive ? query : query.toLowerCase());
fileMatches.push({ line: i + 1, text: lines[i].trim(), column: Math.max(1, col + 1) });
@@ -405,7 +393,7 @@ export async function handleSearchFiles(params: { path: string; query: string; s
results,
total_files: allFiles.length,
total_matches: totalMatches,
truncated: totalMatches >= maxResults
truncated: maxResults > 0 && totalMatches >= maxResults
};
} catch (err) {
return { success: false, error: (err as Error).message };
@@ -787,7 +775,7 @@ export async function handleReplaceInFiles(params: { path: string; glob: string;
export async function handleReadMultipleFiles(params: { paths: string[]; max_chars_per_file?: number }): Promise<ToolResult> {
try {
const maxChars = params.max_chars_per_file || 10000;
const maxChars = params.max_chars_per_file || 0; // 0 = 不截断
// 并行读取所有文件(最多50个)
const filesToRead = params.paths.slice(0, 50).map(async (p) => {
@@ -803,7 +791,7 @@ export async function handleReadMultipleFiles(params: { paths: string[]; max_cha
}
let content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').length;
if (content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)';
if (maxChars > 0 && content.length > maxChars) content = content.slice(0, maxChars) + '\n... (已截断)';
return { path: p, success: true, content, lines };
} catch (err) {
return { path: p, success: false, error: (err as Error).message };
+8 -35
View File
@@ -53,39 +53,18 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
let stdout = '';
let stderr = '';
const MAX_OUTPUT = 512 * 1024; // 512KB 上限,防止 OOM
let stdoutTruncated = false;
let stderrTruncated = false;
// 实时推送到工作空间终端
_toolProc.stdout?.on('data', (data: Buffer) => {
const str = data.toString('utf-8');
if (!stdoutTruncated) {
if (stdout.length + str.length > MAX_OUTPUT) {
const remaining = MAX_OUTPUT - stdout.length;
stdout += str.slice(0, remaining);
stdoutTruncated = true;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str.slice(0, remaining) + '\n[stdout 超出 512KB 限制,已截断]' });
} else {
stdout += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str });
}
}
stdout += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stdout', data: str });
});
_toolProc.stderr?.on('data', (data: Buffer) => {
const str = data.toString('utf-8');
if (!stderrTruncated) {
if (stderr.length + str.length > MAX_OUTPUT) {
const remaining = MAX_OUTPUT - stderr.length;
stderr += str.slice(0, remaining);
stderrTruncated = true;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str.slice(0, remaining) + '\n[stderr 超出 512KB 限制,已截断]' });
} else {
stderr += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
}
}
stderr += str;
mainWindow?.webContents.send('cmd:output', { command: params.command, type: 'stderr', data: str });
});
_toolProc.on('close', (code) => {
@@ -94,15 +73,13 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
sendLog(
code === 0 ? 'success' : 'warn',
`🔧 run_command 完成`,
`exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B${stdoutTruncated ? '(已截断)' : ''} | stderr: ${stderr.length}B${stderrTruncated ? '(已截断)' : ''}`
`exitCode: ${code} | ${duration}ms | stdout: ${stdout.length}B | stderr: ${stderr.length}B`
);
mainWindow?.webContents.send('cmd:done', { command: params.command, exitCode: code });
resolve({
success: code === 0,
stdout,
stderr,
stdout_truncated: stdoutTruncated || undefined,
stderr_truncated: stderrTruncated || undefined,
exitCode: code,
duration,
cwd,
@@ -119,8 +96,6 @@ export async function handleRunCommand(params: { command: string; cwd?: string;
success: false,
stdout,
stderr: stderr + (stderr ? '\n' : '') + err.message,
stdout_truncated: stdoutTruncated || undefined,
stderr_truncated: stderrTruncated || undefined,
exitCode: 1,
error: err.message,
duration,
@@ -696,17 +671,15 @@ async function handleWebSearchSearxng(
if (isHtml) {
const html = await resp.text();
const text = htmlToText(html);
const truncated = effectiveMax > 0 && text.length > effectiveMax * 500;
const content = truncated ? text.slice(0, effectiveMax * 500) + '\n...(已截断)' : text;
sendLog('success', `🔍 SearXNG HTML 完成`, `${text.length} 字符`);
return {
success: true,
_mode: 'searxng',
url: apiUrl,
content,
content: text,
content_type: 'text/html',
length: content.length,
truncated,
length: text.length,
truncated: false,
format: 'html',
_hint: '这是 SearXNG 搜索结果页面的纯文本提取。请从中提取有用的搜索结果信息来回答用户问题。'
};
+4 -4
View File
@@ -586,8 +586,8 @@ async function handleRetry(): Promise<void> {
const html = buildPlanConfirmHtml(plan, steps);
return showHtmlConfirm(html, '📋 执行计划确认', true);
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true;
logError('Plan Mode: 弹窗加载失败,拒绝执行以保护用户', (err as Error).message);
return false; // 弹窗失败时拒绝执行,避免绕过用户审批
}
},
onDone: async (finalContent, _toolRecords, loopStats) => {
@@ -1335,8 +1335,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
return approved;
} catch (err) {
logWarn('Plan Mode: 弹窗加载失败,自动批准', (err as Error).message);
return true; // 加载失败时自动批准,不阻断执行
logError('Plan Mode: 弹窗加载失败,拒绝执行以保护用户', (err as Error).message);
return false; // 弹窗失败时拒绝执行,避免绕过用户审批
}
},
onDone: async (finalContent, _toolRecords, loopStats) => {
+53 -32
View File
@@ -1,16 +1,37 @@
/**
* ToolsModal -
* Tool Calling
* auto/confirm/disabled
*/
import { state, KEYS } from '../state/state.js';
import { setToolEnabled, setRunCommandMode } from '../services/tool-registry.js';
import { setToolMode, getToolMode, type ToolMode } from '../services/tool-registry.js';
import { showToast } from './toast.js';
import { logInfo, logWarn } from '../services/log-service.js';
import { logInfo } from '../services/log-service.js';
import type { ChatDB } from '../db/chat-db.js';
let modalEl: HTMLElement;
const MODE_NAMES: Record<ToolMode, string> = {
auto: '自动执行',
confirm: '需确认',
disabled: '已禁用',
};
const BADGE_MAP: Record<ToolMode, { text: string; cls: string }> = {
auto: { text: '自动', cls: 'auto' },
confirm: { text: '需确认', cls: 'confirm' },
disabled: { text: '禁用', cls: 'disabled' },
};
function updateToolBadge(toolName: string, mode: ToolMode): void {
const badge = modalEl.querySelector(`#badge_${toolName}`) as HTMLElement;
if (!badge) return;
const info = BADGE_MAP[mode];
badge.textContent = info.text;
badge.className = `tool-card-badge ${info.cls}`;
}
export function initToolsModal(): void {
modalEl = document.querySelector('#toolsModal')!;
@@ -21,42 +42,42 @@ export function initToolsModal(): void {
if (e.target === modalEl) closeToolsModal();
});
// run_command 模式下拉框
const selectMode = modalEl.querySelector('#selectRunCommandMode') as HTMLSelectElement;
if (selectMode) {
selectMode.addEventListener('change', async () => {
// 所有带 .tool-mode-select 的下拉框统一绑定 change 事件
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
selects.forEach(selectEl => {
selectEl.addEventListener('change', async () => {
const toolName = selectEl.dataset.tool;
if (!toolName) return;
const mode = selectEl.value as ToolMode;
const db = state.get<ChatDB | null>(KEYS.DB);
const mode = selectMode.value as 'auto' | 'confirm' | 'disabled';
state.set('runCommandMode', mode);
if (db) await db.saveSetting('runCommandMode', mode);
setRunCommandMode(mode);
updateRunCommandBadge(mode);
const modeNames: Record<string, string> = { auto: '自动执行', confirm: '需确认', disabled: '已禁用' };
showToast(`命令执行模式:${modeNames[mode]}`, 'info');
logInfo(`命令执行模式已切换: ${mode}`);
// 更新运行时模式
setToolMode(toolName, mode);
updateToolBadge(toolName, mode);
// 持久化到数据库(所有工具模式合并存储为 JSON)
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
allModes[toolName] = mode;
state.set('toolModes', allModes);
if (db) await db.saveSetting('toolModes', allModes);
showToast(`${toolName}${MODE_NAMES[mode]}`, 'info');
logInfo(`工具模式切换: ${toolName}${mode}`);
});
}
}
function updateRunCommandBadge(mode: string): void {
const badge = modalEl.querySelector('#runCommandBadge') as HTMLElement;
if (!badge) return;
const map: Record<string, { text: string; cls: string }> = {
auto: { text: '自动', cls: 'auto' },
confirm: { text: '需确认', cls: 'confirm' },
disabled: { text: '禁用', cls: 'disabled' }
};
const info = map[mode] || map.confirm;
badge.textContent = info.text;
badge.className = `tool-card-badge ${info.cls}`;
});
}
export function openToolsModal(): void {
const runCommandMode = state.get<string>('runCommandMode', 'confirm');
const selectMode = modalEl.querySelector('#selectRunCommandMode') as HTMLSelectElement;
if (selectMode) selectMode.value = runCommandMode;
updateRunCommandBadge(runCommandMode);
// 从 state 读取所有工具模式,同步 UI
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
selects.forEach(selectEl => {
const toolName = selectEl.dataset.tool;
if (!toolName) return;
const mode = allModes[toolName] || getToolMode(toolName);
selectEl.value = mode;
updateToolBadge(toolName, mode);
});
modalEl.style.display = '';
}
+85 -13
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.14.10</span>
<span class="app-version">v0.14.11</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<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"/>
@@ -440,7 +440,7 @@
<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><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度手动控制</strong> — 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度</li><li><strong>Qwen3 兼容</strong> — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>40+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_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_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</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></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>40+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_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_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li>所有写操作工具均支持三档开关:自动/需确认/禁用(浏览器工具始终自动执行)</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></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</li></ul></div>
<div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
<div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 任务感知 → 中途幻觉检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询)</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测工具幻觉和 prompt injection,阻断有问题的回复</li><li><strong>中英双语检测规则</strong>:覆盖中英文模型输出,防止 AI 声称执行了未调用的工具</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式总超时</strong>:可配置超时(默认 300s),Ollama 卡死不再永久阻塞</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>:200 条消息强制压缩 + 120 条增量压缩,防止 OOM</li></ul></div>
@@ -475,8 +475,16 @@
<div class="tool-card-desc">读取文件(文本/binary/base64)2000行默认,截断自动提示续读</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">write_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">write_file</span><span class="tool-card-badge confirm" id="badge_write_file">需确认</span></div>
<div class="tool-card-desc">写入/追加文件,支持base64二进制,最大10MB</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="write_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📁</span><span class="tool-card-name">list_directory</span><span class="tool-card-badge auto">自动</span></div>
@@ -487,35 +495,75 @@
<div class="tool-card-desc">按文件名或内容搜索,支持正则表达式</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📂</span><span class="tool-card-name">create_directory</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">📂</span><span class="tool-card-name">create_directory</span><span class="tool-card-badge confirm" id="badge_create_directory">需确认</span></div>
<div class="tool-card-desc">创建目录,自动创建父级</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="create_directory" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">delete_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">delete_file</span><span class="tool-card-badge confirm" id="badge_delete_file">需确认</span></div>
<div class="tool-card-desc">删除文件或目录,返回被删文件数和总大小</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="delete_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📦</span><span class="tool-card-name">move_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">📦</span><span class="tool-card-name">move_file</span><span class="tool-card-badge confirm" id="badge_move_file">需确认</span></div>
<div class="tool-card-desc">移动或重命名文件/目录</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="move_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">copy_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">copy_file</span><span class="tool-card-badge confirm" id="badge_copy_file">需确认</span></div>
<div class="tool-card-desc">复制文件或目录到新位置</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="copy_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🌐</span><span class="tool-card-name">web_fetch</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">抓取网页内容,反爬headers+UA切换+自动回退浏览器渲染</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✂️</span><span class="tool-card-name">edit_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">✂️</span><span class="tool-card-name">edit_file</span><span class="tool-card-badge confirm" id="badge_edit_file">需确认</span></div>
<div class="tool-card-desc">查找替换(支持正则use_regex),全部或单次替换</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="edit_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">💻</span><span class="tool-card-name">run_command</span><span class="tool-card-badge confirm" id="runCommandBadge">需确认</span></div>
<div class="tool-card-header"><span class="tool-card-icon">💻</span><span class="tool-card-name">run_command</span><span class="tool-card-badge confirm" id="badge_run_command">需确认</span></div>
<div class="tool-card-desc">执行 shell 命令,无超时限制</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select id="selectRunCommandMode" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<select class="tool-mode-select" data-tool="run_command" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
@@ -531,16 +579,32 @@
<div class="tool-card-desc">以树形结构展示目录内容</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">⬇️</span><span class="tool-card-name">download_file</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">⬇️</span><span class="tool-card-name">download_file</span><span class="tool-card-badge confirm" id="badge_download_file">需确认</span></div>
<div class="tool-card-desc">从 URL 下载文件到本地</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="download_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔀</span><span class="tool-card-name">diff_files</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-desc">对比两个文件,显示差异</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔄</span><span class="tool-card-name">replace_in_files</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">🔄</span><span class="tool-card-name">replace_in_files</span><span class="tool-card-badge confirm" id="badge_replace_in_files">需确认</span></div>
<div class="tool-card-desc">按 glob 模式批量查找替换多个文件</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="replace_in_files" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">📚</span><span class="tool-card-name">read_multiple_files</span><span class="tool-card-badge auto">自动</span></div>
@@ -551,8 +615,16 @@
<div class="tool-card-desc">Git 全操作(17个子命令:status/log/diff/add/commit/push等)</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge auto">自动</span></div>
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge confirm" id="badge_compress">需确认</span></div>
<div class="tool-card-desc">创建或解压 zip/tar.gz 归档</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
<select class="tool-mode-select" data-tool="compress" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
<option value="confirm">需确认</option>
<option value="auto">自动执行</option>
<option value="disabled">禁用</option>
</select>
</div>
</div>
<div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">🔍</span><span class="tool-card-name">web_search</span><span class="tool-card-badge auto">自动</span></div>
+19 -5
View File
@@ -23,7 +23,7 @@ import { initHistoryModal, closeHistoryModal } from './components/history-modal.
import { initMemoryModal } from './components/memory-modal.js';
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js';
import { setToolEnabled, setRunCommandMode, refreshMCPTools } from './services/tool-registry.js';
import { setToolEnabled, setToolMode, refreshMCPTools } from './services/tool-registry.js';
import { startAllMCPServers } from './services/mcp-client.js';
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
@@ -319,7 +319,7 @@ async function init(): Promise<void> {
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
state.set('toolCallingEnabled', true);
state.set('runCommandMode', 'confirm'); // 默认需确认
state.set('runCommandMode', 'confirm'); // 兼容旧代码读取
state.set('thinkEnabled', false);
initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示
@@ -448,9 +448,23 @@ async function init(): Promise<void> {
// ── Tool Calling 设置(永久开启,不可关闭)──
state.set('toolCallingEnabled', true);
const runCommandMode = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
state.set('runCommandMode', runCommandMode);
setRunCommandMode(runCommandMode);
// 统一加载所有工具的执行模式(auto/confirm/disabled
{
// 优先读取合并存储的 toolModes JSON
const savedModes = await db.getSetting<Record<string, 'auto' | 'confirm' | 'disabled'>>('toolModes', {});
// 兼容旧版:如果 toolModes 中没有 run_command 但有独立的 runCommandMode,则迁移
if (!savedModes['run_command']) {
const legacy = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
savedModes['run_command'] = legacy;
await db.saveSetting('toolModes', savedModes);
await db.saveSetting('runCommandMode', null); // 清除旧键
}
state.set('toolModes', savedModes);
// 为每个工具应用模式
for (const [toolName, mode] of Object.entries(savedModes)) {
setToolMode(toolName, mode);
}
}
// ── MCP 工具注册 ──
await startAllMCPServers();
+57 -44
View File
@@ -109,21 +109,6 @@ function getOSEnvironment() {
};
}
/** 每个工具返回给模型的最大字符数 */
const TOOL_MAX_RESULT_SIZE: Record<string, number> = {
web_fetch: 20000,
web_search: 3000,
read_file: 15000,
read_multiple_files: 10000,
list_directory: 5000,
search_files: 5000,
run_command: 10000,
git: 5000,
session_read: 15000,
browser_extract: 10000,
browser_evaluate: 8000,
};
/** 始终可并行的只读/独立工具 */
const ALWAYS_PARALLEL = new Set([
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
@@ -466,10 +451,7 @@ function formatDefaultToolResult(toolName: string, result: ToolResult): string {
k === 'status' || k === 'length' || k === 'isDirectory') continue;
clean[k] = v;
}
let json = JSON.stringify(clean);
const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000;
if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)';
return json;
return JSON.stringify(clean);
}
/**
@@ -484,13 +466,12 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
case 'web_search': {
const raw = result.results as Array<{ title: string; url: string; snippet: string }> | undefined;
if (!raw?.length) return JSON.stringify({ success: true, message: '未找到结果' });
const visible = Math.min(10, raw.length);
const top = raw.slice(0, visible).map((r, i) =>
const top = raw.map((r, i) =>
`[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`
).join('\n\n');
const fetched = (result as any)._fetched as Array<{ url: string; title: string; content: string }> | undefined;
const body = JSON.stringify({
success: true, query: result.query, total: result.total, shown: visible, results: top,
success: true, query: result.query, total: result.total, shown: raw.length, results: top,
});
if (fetched && fetched.length > 0) {
return body + '\n\n' + fetched.map((f, i) =>
@@ -881,18 +862,14 @@ async function handleInit(
if (ctx.mode === 'plan') {
const resumeData = state.get<{ steps: Array<{index:number;label:string;done:boolean}>; total: number; done: number; loopCount: number } | null>('_planResumeData', null);
if (resumeData && resumeData.steps.length > 0 && resumeData.done < resumeData.total) {
// 恢复追踪器状态
const { initPlanTracker } = await import('./tool-registry.js');
const steps = resumeData.steps.map(s => s.label);
const tracker = initPlanTracker(steps);
// 标记已完成的步骤
for (const s of resumeData.steps) {
if (s.done) {
tracker.steps[s.index - 1].done = true;
tracker.done++;
}
}
// 直接从恢复数据构建追踪器,一次保存(避免 initPlanTracker 的冗余中间保存)
const { savePlanTracker } = await import('./tool-registry.js');
const tracker = {
steps: resumeData.steps.map(s => ({ index: s.index, label: s.label, done: s.done })),
total: resumeData.total,
done: resumeData.done,
active: true as const,
};
savePlanTracker(tracker);
// 清空恢复数据,避免重复恢复
state.set('_planResumeData', null);
@@ -990,6 +967,7 @@ async function handleInit(
role: 'user' as const,
content: `[Plan Mode] 请按照 Plan Mode 执行规则中指定的格式输出执行计划。不要直接执行工具调用,先输出计划等待用户批准。`,
ephemeral: true,
planConstraint: true,
});
}
@@ -1018,7 +996,7 @@ function extractPlanSteps(content: string): string[] {
// 匹配: 1. **步骤名** — 工具: xxx — 描述
const match = line.match(/^\d+[\.\)、]\s*(?:\*\*)?(.+?)(?:\*\*)?(?:\s*[—\-]\s*)/);
if (match) {
const step = match[0].trim();
const step = match[1].trim();
if (step.length >= 5 && step.length <= 200) {
steps.push(step);
continue;
@@ -1048,7 +1026,7 @@ function extractPlanSteps(content: string): string[] {
}
}
return steps.length > 0 ? steps.slice(0, 8) : ['执行任务计划(详见上方描述)'];
return steps.slice(0, 8);
}
/**
@@ -1283,7 +1261,8 @@ async function handleThinking(
}
// 多步骤任务待办追踪 — 用户一句话里有多个动作时,提醒未完成的
if (ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
// Plan Mode 下由 Plan Tracker 统一追踪进度,跳过避免信息冗余
if (ctx.mode !== 'plan' && ctx.loopCount >= 2 && ctx.allToolRecords.length > 0) {
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
const userText = firstUserMsg?.content || '';
const pending = detectPendingActions(userText, ctx.allToolRecords);
@@ -1888,8 +1867,12 @@ async function handleReflecting(
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
// P1 修复:移除 ctx.toolCalls.length === 0 条件 — 模型可能忽略 Plan Mode 指令直接调用工具,
// 此时应取消工具执行,转为等待用户确认计划
if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.content.length > 50) {
const isPlanLike = /计划|步骤|step|plan|思路|方案|流程/.test(ctx.content.slice(0, 200));
if (ctx.mode === 'plan' && ctx.loopCount === 1 && ctx.content.length > 20) {
// 收紧检测:需要同时包含计划关键词和结构化标记(编号列表或 Markdown 标题)
const head = ctx.content.slice(0, 300);
const hasPlanKeyword = /执行计划|步骤[一二三四五六七八九十\d]|Step\s*\d|Plan:|行动计划|实施方案/.test(head);
const hasStructure = /^\s*(\d+[\.\)、]|[-*]\s)/m.test(head) || /^#{1,3}\s/m.test(head);
const isPlanLike = hasPlanKeyword && hasStructure;
if (isPlanLike && callbacks.onPlanReady) {
// 如果模型在第一轮就调用了工具,取消这些工具调用(Plan Mode 要求先规划后执行)
if (ctx.toolCalls.length > 0) {
@@ -1928,10 +1911,9 @@ async function handleReflecting(
initPlanTracker(steps);
logInfo('Plan Tracker 已启动', `${steps.length} 个步骤`);
}
// 移除 Plan Mode 的"不要执行工具"约束消息,替换为执行指令
// 从后往前找到并移除 Plan Mode 提示(ephemeral 标记)
// 移除 Plan Mode 的约束消息(通过 planConstraint 标记精确匹配,不依赖字符串内容)
for (let i = ctx.messages.length - 1; i >= 0; i--) {
if (ctx.messages[i].ephemeral && ctx.messages[i].content?.includes('不要直接执行工具调用')) {
if (ctx.messages[i].planConstraint) {
ctx.messages.splice(i, 1);
break;
}
@@ -1944,9 +1926,40 @@ async function handleReflecting(
transition(ctx, S.THINKING);
return;
}
// 非计划回复(如简单问答)→ 跳过 Plan Mode,直接完成
// 非计划回复 → 检查是否需要保护
if (!isPlanLike) {
logInfo('Plan Mode: 回复非计划内容,直接完成');
// 模型未输出计划但调用了工具 → 取消工具,要求重新规划
if (ctx.toolCalls.length > 0) {
ctx.planRetries++;
if (ctx.planRetries >= 3) {
logWarn('Plan Mode: 非计划回复+工具调用达到最大重试次数,强制执行');
for (const call of ctx.toolCalls) {
callbacks.onToolCallError(call.function.name, 'Plan Mode: 超过最大重试次数', call);
}
ctx.toolCalls.length = 0;
ctx.messages.push({
role: 'user',
content: '已达到最大计划重试次数。请基于当前回复直接开始执行任务。',
ephemeral: true,
});
transition(ctx, S.THINKING);
return;
}
logWarn(`Plan Mode: 回复非计划内容但调用了 ${ctx.toolCalls.length} 个工具,取消并要求重新规划 (重试 ${ctx.planRetries}/3)`);
for (const call of ctx.toolCalls) {
callbacks.onToolCallError(call.function.name, 'Plan Mode: 首轮应输出计划,请先规划再执行', call);
}
ctx.toolCalls.length = 0;
ctx.messages.push({
role: 'user',
content: '请按照 Plan Mode 执行规则输出结构化执行计划(包含编号步骤),不要直接回答或调用工具。',
ephemeral: true,
});
transition(ctx, S.THINKING);
return;
}
// 无工具调用的简单回复 → 跳过 Plan Mode,直接完成
logInfo('Plan Mode: 回复非计划内容(简单问答),直接完成');
}
}
@@ -1977,13 +1990,13 @@ async function handleReflecting(
logInfo('模型停止工具调用 → ReAct Agent Loop 结束');
// ── 保存 Plan Mode 最终进度到状态,供下一轮 Loop 注入历史 ──
// 注意:不在此处 clearPlanTracker,由 finally 块统一处理(保存 resumeData 后清理)
if (ctx.mode === 'plan') {
const tracker = getPlanTracker();
if (tracker.active && tracker.steps.length > 0) {
state.set('_lastPlanStatus', formatPlanStatus());
}
}
clearPlanTracker();
// ── 自动记忆提取(异步,不阻塞用户看到回复)──
const abortController = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER);
+3 -3
View File
@@ -286,7 +286,7 @@ export function serializeMemoryMd(entries: MemoryEntry[]): string {
/**
*
*/
export function searchMemory(entries: MemoryEntry[], query: string, limit = 8): MemorySearchResult[] {
export function searchMemory(entries: MemoryEntry[], query: string, limit = 0): MemorySearchResult[] {
if (!query || entries.length === 0) return [];
const queryLower = query.toLowerCase();
@@ -331,7 +331,7 @@ export function searchMemory(entries: MemoryEntry[], query: string, limit = 8):
}
}
return merged.slice(0, limit);
return limit > 0 ? merged.slice(0, limit) : merged;
}
// ═══════════════════════════════════════════════════════════════
@@ -437,7 +437,7 @@ export async function loadAllEntries(): Promise<MemoryEntry[]> {
}
/** 搜索记忆(读取 MEMORY.md → 解析 → 搜索) */
export async function search(query: string, limit = 8): Promise<MemorySearchResult[]> {
export async function search(query: string, limit = 0): Promise<MemorySearchResult[]> {
try {
const entries = await loadAllEntries();
return searchMemory(entries, query, limit);
+58 -27
View File
@@ -446,7 +446,7 @@ CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) an
type: 'object',
required: ['action'],
properties: {
action: { type: 'string', enum: ['status', 'mark_done', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_all_done=全部完成' },
action: { type: 'string', enum: ['status', 'mark_done', 'mark_undone', 'mark_all_done'], description: 'status=查看当前进度, mark_done=标记步骤完成, mark_undone=撤销步骤完成标记, mark_all_done=全部完成' },
step_index: { type: 'integer', description: 'Step number (1-indexed) to mark as done. Required for mark_done.' },
step_label: { type: 'string', description: 'Optional: description of what was completed, for logging.' }
}
@@ -675,34 +675,45 @@ CRITICAL: For add action, you MUST include both "type" (fact/preference/rule) an
}
];
// 需要用户确认的工具:写操作、删除、命令执行、压缩、浏览器操作
const CONFIRM_TOOLS = [
// 支持三档开关的工具列表(auto/confirm/disabled
// 浏览器工具不需要确认,永远自动执行
const MODE_TOOLS = [
'run_command',
'write_file', 'create_directory', 'delete_file',
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
'download_file', 'compress',
'browser_open', 'browser_click', 'browser_type', 'browser_evaluate',
];
export type ToolMode = 'auto' | 'confirm' | 'disabled';
// 工具模式缓存:key=工具名, value=模式
const _toolModes = new Map<string, ToolMode>();
export function needsConfirmation(toolName: string): boolean {
if (toolName === 'run_command') {
// run_command 确认模式由 runCommandMode 状态控制
const runCommandMode = (window as any).__runCommandMode || 'confirm';
return runCommandMode === 'confirm';
}
return CONFIRM_TOOLS.includes(toolName);
if (!MODE_TOOLS.includes(toolName)) return false;
return getToolMode(toolName) === 'confirm';
}
/** 设置 run_command 执行模式 */
export function setRunCommandMode(mode: 'auto' | 'confirm' | 'disabled'): void {
(window as any).__runCommandMode = mode;
/** 获取工具模式,默认 confirm */
export function getToolMode(toolName: string): ToolMode {
return _toolModes.get(toolName) ?? 'confirm';
}
/** 设置工具执行模式 */
export function setToolMode(toolName: string, mode: ToolMode): void {
_toolModes.set(toolName, mode);
if (mode === 'disabled') {
setToolEnabled('run_command', false);
setToolEnabled(toolName, false);
} else {
setToolEnabled('run_command', true);
setToolEnabled(toolName, true);
}
}
/** 兼容旧接口:设置 run_command 执行模式 */
export function setRunCommandMode(mode: ToolMode): void {
setToolMode('run_command', mode);
}
let enabledTools: Set<string> = new Set([
'read_file', 'list_directory', 'search_files',
'write_file', 'create_directory', 'delete_file',
@@ -729,8 +740,8 @@ export function setPlanModeActive(active: boolean): void {
enabledTools.add('plan_track');
} else {
enabledTools.delete('plan_track');
// 清除追踪器状态
state.set('_planTracker', null);
// 清除追踪器状态(同时清模块变量和 state
clearPlanTracker();
}
}
@@ -793,9 +804,9 @@ export async function executeTool(toolName: string, args: Record<string, unknown
switch (action) {
case 'search': {
const query = args.query as string;
const limit = (args.limit as number) || 8;
if (!query) return { success: false, error: '缺少 query 参数' };
const results = await search(query, limit);
const limit = (args.limit as number) || 0; // 0 = 不限制
if (!query) return { success: false, error: '缺少 query 参数' };
const results = await search(query, limit);
logToolResult('memory', true, `${results.length} 条结果`);
return { success: true, action, results, total: results.length };
}
@@ -843,7 +854,7 @@ export async function executeTool(toolName: string, args: Record<string, unknown
if (toolName === 'session_list') {
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const limit = (args.limit as number) || 20;
const limit = (args.limit as number) || 0; // 0 = 不限制
const search = (args.search as string) || '';
const sessions = await bridge.db.getAllSessions();
let filtered = sessions.map((s: any) => ({
@@ -858,7 +869,7 @@ export async function executeTool(toolName: string, args: Record<string, unknown
filtered = filtered.filter((s: any) => s.title.toLowerCase().includes(search.toLowerCase()));
}
filtered.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
filtered = filtered.slice(0, limit);
if (limit > 0) filtered = filtered.slice(0, limit);
logToolResult('session_list', true, `${filtered.length} 个会话`);
return { success: true, sessions: filtered, total: filtered.length };
}
@@ -866,12 +877,12 @@ export async function executeTool(toolName: string, args: Record<string, unknown
const bridge = window.metonaDesktop;
if (!bridge?.db) return { success: false, error: '桌面 API 不可用' };
const sessionId = args.session_id as string;
const maxMessages = (args.max_messages as number) || 50;
const maxMessages = (args.max_messages as number) || 0; // 0 = 不限制
if (!sessionId) return { success: false, error: '缺少 session_id 参数' };
const session = await bridge.db.getSession(sessionId);
if (!session) return { success: false, error: `会话 ${sessionId} 不存在` };
const messages = await bridge.db.getMessages(sessionId);
const msgs = messages.slice(0, maxMessages).map((m: any) => ({
const msgs = (maxMessages > 0 ? messages.slice(0, maxMessages) : messages).map((m: any) => ({
role: m.role,
content: m.content || '',
timestamp: m.created_at
@@ -909,6 +920,9 @@ export async function executeTool(toolName: string, args: Record<string, unknown
return { success: true, action: 'status', steps: tracker.steps, total: tracker.total, done: tracker.done };
}
if (action === 'mark_done' && typeof stepIndex === 'number' && stepIndex > 0) {
if (!Number.isInteger(stepIndex)) {
return { success: false, error: `step_index 必须是整数,收到 ${stepIndex}` };
}
const idx = stepIndex - 1;
if (idx >= 0 && idx < tracker.steps.length) {
tracker.steps[idx].done = true;
@@ -921,6 +935,21 @@ export async function executeTool(toolName: string, args: Record<string, unknown
}
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
}
if (action === 'mark_undone' && typeof stepIndex === 'number' && stepIndex > 0) {
if (!Number.isInteger(stepIndex)) {
return { success: false, error: `step_index 必须是整数,收到 ${stepIndex}` };
}
const idx = stepIndex - 1;
if (idx >= 0 && idx < tracker.steps.length) {
tracker.steps[idx].done = false;
tracker.done = tracker.steps.filter(s => s.done).length;
savePlanTracker(tracker);
const remaining = tracker.total - tracker.done;
logToolResult('plan_track', true, `步骤 ${stepIndex} 已撤销完成标记 (${tracker.done}/${tracker.total}, 剩余 ${remaining})`);
return { success: true, action: 'mark_undone', step: stepIndex, done: tracker.done, total: tracker.total, remaining };
}
return { success: false, error: `步骤 ${stepIndex} 不存在(共 ${tracker.total} 步)` };
}
if (action === 'mark_all_done') {
for (const s of tracker.steps) s.done = true;
tracker.done = tracker.total;
@@ -996,10 +1025,12 @@ export interface PlanTracker {
let _planTracker: PlanTracker = { steps: [], total: 0, done: 0, active: false };
const EMPTY_TRACKER: PlanTracker = { steps: [], total: 0, done: 0, active: false };
export function getPlanTracker(): PlanTracker {
// 每次读取时同步 state 中的最新状态
// 始终从 state 同步最新状态state 为 null 时返回空追踪器
const saved = state.get<PlanTracker | null>('_planTracker', null);
if (saved) _planTracker = saved;
_planTracker = saved ?? EMPTY_TRACKER;
return _planTracker;
}
@@ -1021,7 +1052,7 @@ export function initPlanTracker(steps: string[]): PlanTracker {
}
export function clearPlanTracker(): void {
_planTracker = { steps: [], total: 0, done: 0, active: false };
_planTracker = EMPTY_TRACKER;
state.set('_planTracker', null);
}
+7 -1
View File
@@ -12,6 +12,8 @@ export interface OllamaMessage {
compressed?: boolean;
/** 标记此消息为临时性消息(预算警告等),上下文裁剪时优先丢弃 */
ephemeral?: boolean;
/** 标记此消息为 Plan Mode 约束消息(首轮禁止工具调用),批准后据此精确清理 */
planConstraint?: boolean;
}
export interface OllamaChatParams {
@@ -283,7 +285,11 @@ export type StateKey =
| 'subAgentModel'
| 'agentMode'
| 'loopWatchdogMs'
| '_planTracker';
| '_planTracker'
| '_planResumeData'
| '_lastPlanStatus'
| '_planLastInjectedDone'
| 'toolModes';
// ═══════════════════════════════════════════════════════════
// Tool Calling 类型