v0.11.5: 搜索抓取增强 + 流式进度修复 + 超时/写入容错

feat: 搜索面板新增认证类型 Bearer/Basic,自动抓取条数/类型(顺序/随机)
feat: 随机抓取失败自动从未抓取池补1条,上限移除
fix: write_file 缺 path 时返回引导性报错(含工作空间路径+平台示例)
fix: Ollama tool_calls arguments JSON字符串解析
fix: 流式进度日志不再删除,改为完整时间线记录
fix: 进度日志同步appendChild确保排在工具日志之前
fix: 超时设置面板/主进程 remove max上限
chore: 版本号 0.11.4 → 0.11.5
This commit is contained in:
thzxx
2026-06-13 21:35:22 +08:00
parent 5fdcab444c
commit a336a66516
15 changed files with 207 additions and 67 deletions
+1 -1
View File
@@ -189,7 +189,7 @@ export async function setupIPC(): Promise<void> {
case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break;
case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break;
case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string }); break;
case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number }); break;
case 'web_search': result = await handleWebSearch(args as { query: string; max_results?: number; time_range?: string; enhance_snippets?: boolean; fetch_top?: number }); break;
case 'edit_file': result = await handleEditFile(args as { path: string; old_text: string; new_text: string; all?: boolean; use_regex?: boolean }); break;
case 'get_file_info': result = await handleGetFileInfo(args as { path: string }); break;
case 'tree': result = await handleTree(args as { path: string; max_depth?: number; include_hidden?: boolean }); break;
+1 -1
View File
@@ -57,7 +57,7 @@ let MCP_TIMEOUT = 60_000;
/** 设置 MCP 请求超时(毫秒) */
export function setMCPTimeout(ms: number): void {
MCP_TIMEOUT = Math.max(10_000, Math.min(300_000, ms)); // 10s ~ 300s
MCP_TIMEOUT = Math.max(10_000, ms);
}
// ─── JSON-RPC 通信 ───
+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.11.4',
message: 'Metona Ollama Desktop v0.11.5',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
+13
View File
@@ -4,9 +4,11 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process';
import { checkPathAllowed } from './tool-security.js';
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
import { getWorkspaceDir } from './workspace.js';
export async function handleReadFile(params: { path: string; encoding?: string; start_line?: number; end_line?: number; mode?: string; offset_bytes?: number; limit_bytes?: number }): Promise<ToolResult> {
try {
@@ -156,6 +158,17 @@ function extToMime(ext: string): string {
export async function handleWriteFile(params: { path: string; content: string; encoding?: string; mode?: string }): Promise<ToolResult> {
try {
if (!params.path || typeof params.path !== 'string') {
const wsDir = getWorkspaceDir();
const homeDir = os.homedir();
const absExample = process.platform === 'win32'
? `${homeDir}\\Documents\\file.txt`
: `${homeDir}/file.txt`;
return { success: false, error: `缺少 path 参数。请指定文件路径,相对路径基于工作空间 ${wsDir}。例如: "output.txt" 或 "${absExample}"` };
}
if (params.content === undefined || params.content === null) {
return { success: false, error: '缺少 content 参数,请指定要写入的内容' };
}
const filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason };
+92 -19
View File
@@ -128,7 +128,7 @@ let HTTP_TIMEOUT = 30_000;
/** 设置 HTTP 请求超时(毫秒) */
export function setHTTPTimeout(ms: number): void {
HTTP_TIMEOUT = Math.max(5000, Math.min(300_000, ms)); // 5s ~ 300s
HTTP_TIMEOUT = Math.max(5000, ms);
}
// ── LRU 搜索缓存 ──────────────────────────────────
@@ -509,13 +509,20 @@ async function handleWebSearchSearxng(
if (effectiveTimeRange) params.set('time_range', effectiveTimeRange);
const authKey = getSetting<string>('searxng_auth_key', '');
const authType = getSetting<string>('searxng_auth_type', 'bearer');
const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`;
sendLog('info', `🔍 SearXNG 搜索`, `${query}${url} (${format})`);
try {
const headers = buildFetchHeaders(apiUrl, 0);
if (authKey) headers['Authorization'] = `Bearer ${authKey}`;
if (authKey) {
if (authType === 'basic') {
headers['Authorization'] = 'Basic ' + Buffer.from(authKey).toString('base64');
} else {
headers['Authorization'] = `Bearer ${authKey}`;
}
}
const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers);
if (!resp || !resp.ok) {
return {
@@ -622,17 +629,25 @@ export async function handleWebSearch(params: { query: string; max_results?: num
const maxResults = Math.min(params.max_results || 30, 30);
const timeRange = params.time_range || '';
const enhanceSnippets = params.enhance_snippets !== false;
const fetchTop = Math.max(3, Math.min(params.fetch_top || 3, 10));
const aiFetchTop = Math.max(3, params.fetch_top || 5);
// 内置引擎默认:使用 AI 指定条数 + 顺序抓取
let fetchTop = aiFetchTop;
let fetchMode: 'sequential' | 'random' = 'sequential';
let result: ToolResult;
// ── SearXNG 模式 ──
const searxngEnabled = getSetting<boolean>('searxng_enabled', false);
if (searxngEnabled) {
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}"`);
// SearXNG 模式下读取用户面板设置的抓取条数和类型
const userFetchCount = getSetting<number>('fetch_count', 0);
fetchMode = (getSetting<string>('fetch_mode', 'sequential') || 'sequential') as 'sequential' | 'random';
fetchTop = userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop;
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}" | 抓取=${fetchTop}${fetchMode === 'random' ? '随机' : '顺序'}`);
result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
} else {
sendLog('info', `🔍 web_search → 内置引擎`, `"${query}"`);
sendLog('info', `🔍 web_search → 内置引擎`, `"${query}" | 抓取=${fetchTop}条 顺序`);
result = await (async () => {
const cacheKey = `${query}|${maxResults}|${timeRange}`;
@@ -805,7 +820,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num
// ── 自动抓取前 N 条完整内容 ──
if (fetchTop > 0 && result.success && (result as any).results) {
result = await applyAutoFetch(result, fetchTop);
result = await applyAutoFetch(result, fetchTop, fetchMode);
}
return result;
@@ -814,25 +829,83 @@ export async function handleWebSearch(params: { query: string; max_results?: num
}
}
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容 */
async function applyAutoFetch(result: ToolResult, fetchTop: number): Promise<ToolResult> {
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容
* @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条
* 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */
async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise<ToolResult> {
const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
if (!results || results.length === 0) return result;
const toFetch = results.slice(0, fetchTop);
sendLog('info', `⬇️ 自动抓取 ${toFetch.length} 条网页`, toFetch.map(r => r.title.slice(0, 30)).join(', '));
const count = Math.min(fetchTop, results.length);
const fetched: Array<{ url: string; title: string; content: string }> = [];
for (const r of toFetch) {
try {
const fetchResult = await handleWebFetch({ url: r.url, retry: false });
if (fetchResult.success && fetchResult.content) {
fetched.push({ url: r.url, title: r.title, content: String(fetchResult.content) });
}
} catch { /* skip failed fetch */ }
// ── 构建抓取列表 ──
let fetchList: Array<{ url: string; title: string; originalIndex: number }>;
if (fetchMode === 'random') {
// Fisher-Yates 洗牌索引,取前 count 个
const indices = results.map((_, i) => i);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
fetchList = indices.slice(0, count).map(idx => ({
url: results[idx].url,
title: results[idx].title,
originalIndex: idx,
}));
} else {
fetchList = results.slice(0, count).map((r, i) => ({
url: r.url,
title: r.title,
originalIndex: i,
}));
}
sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length}/${toFetch.length} 条成功`);
const modeLabel = fetchMode === 'random' ? '随机' : '顺序';
sendLog('info', `⬇️ 自动抓取 ${fetchList.length} 条网页 (${modeLabel})`, fetchList.map(r => r.title.slice(0, 30)).join(', '));
const fetched: Array<{ url: string; title: string; content: string }> = [];
const fetchedUrls = new Set<string>();
const failedUrls = new Set<string>();
/** 尝试抓取单条 URL,成功则加入 fetched */
const tryFetchOne = async (url: string, title: string): Promise<boolean> => {
if (fetchedUrls.has(url)) return false;
try {
const fetchResult = await handleWebFetch({ url, retry: false });
if (fetchResult.success && fetchResult.content) {
fetched.push({ url, title, content: String(fetchResult.content) });
fetchedUrls.add(url);
sendLog('debug', `${title.slice(0, 40)} (${String(fetchResult.content).length} 字)`);
return true;
} else {
sendLog('warn', `${title.slice(0, 50)}${fetchResult.error || '无内容'}`);
}
} catch (e) {
sendLog('warn', `${title.slice(0, 50)}${(e as Error).message}`);
}
return false;
};
for (const item of fetchList) {
const ok = await tryFetchOne(item.url, item.title);
if (!ok) {
failedUrls.add(item.url);
// ── 失败重试:从剩余未抓取结果中随机选 1 条 ──
const unfetched = results.filter((r, idx) =>
!fetchedUrls.has(r.url) && !failedUrls.has(r.url)
);
if (unfetched.length > 0) {
const pick = unfetched[Math.floor(Math.random() * unfetched.length)];
sendLog('info', `🔄 ${item.title.slice(0, 30)} 抓取失败,随机重试: ${pick.title.slice(0, 40)}`);
const retryOk = await tryFetchOne(pick.url, pick.title);
if (!retryOk) {
failedUrls.add(pick.url);
}
}
}
}
sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length} 条成功 / ${failedUrls.size} 条失败`);
(result as any)._fetched = fetched;
(result as any)._fetched_count = fetched.length;
return result;
+17
View File
@@ -17,7 +17,10 @@ const DEFAULTS = {
time_range: '',
max_results: 0,
auth_key: '',
auth_type: 'bearer',
format: 'json',
fetch_count: 0,
fetch_mode: 'sequential',
};
let modalEl: HTMLElement;
@@ -36,7 +39,11 @@ async function saveConfig(db: ChatDB): Promise<void> {
await db.saveSetting('searxng_time_range', searxngConfig.time_range);
await db.saveSetting('searxng_max_results', searxngConfig.max_results);
await db.saveSetting('searxng_auth_key', searxngConfig.auth_key);
await db.saveSetting('searxng_auth_type', searxngConfig.auth_type);
await db.saveSetting('searxng_format', searxngConfig.format);
// 通用搜索设置(非 SearXNG 专属,主进程搜索时读取)
await db.saveSetting('fetch_count', searxngConfig.fetch_count);
await db.saveSetting('fetch_mode', searxngConfig.fetch_mode);
logSuccess('SearXNG 配置已保存');
}
@@ -50,7 +57,11 @@ export async function loadSearxngConfig(db: ChatDB): Promise<void> {
searxngConfig.time_range = await db.getSetting('searxng_time_range', DEFAULTS.time_range);
searxngConfig.max_results = await db.getSetting('searxng_max_results', DEFAULTS.max_results);
searxngConfig.auth_key = await db.getSetting('searxng_auth_key', DEFAULTS.auth_key);
searxngConfig.auth_type = await db.getSetting('searxng_auth_type', DEFAULTS.auth_type);
searxngConfig.format = await db.getSetting('searxng_format', DEFAULTS.format);
// 通用搜索设置
searxngConfig.fetch_count = await db.getSetting<number>('fetch_count', DEFAULTS.fetch_count);
searxngConfig.fetch_mode = await db.getSetting<string>('fetch_mode', DEFAULTS.fetch_mode);
logInfo('SearXNG 配置已加载', searxngConfig.enabled ? `已启用: ${searxngConfig.url}` : '未启用');
updateEnabledUI(searxngConfig.enabled);
}
@@ -64,7 +75,10 @@ function syncFormFromConfig(): void {
(document.querySelector('#searxngTimeRange') as HTMLSelectElement).value = searxngConfig.time_range || '';
(document.querySelector('#searxngMaxResults') as HTMLInputElement).value = searxngConfig.max_results > 0 ? String(searxngConfig.max_results) : '';
(document.querySelector('#searxngAuthKey') as HTMLInputElement).value = searxngConfig.auth_key;
(document.querySelector('#searxngAuthType') as HTMLSelectElement).value = searxngConfig.auth_type;
(document.querySelector('#searxngFormat') as HTMLSelectElement).value = searxngConfig.format;
(document.querySelector('#searxngFetchCount') as HTMLInputElement).value = searxngConfig.fetch_count > 0 ? String(searxngConfig.fetch_count) : '';
(document.querySelector('#searxngFetchMode') as HTMLSelectElement).value = searxngConfig.fetch_mode;
const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
toggle.checked = searxngConfig.enabled;
updateEnabledUI(searxngConfig.enabled);
@@ -137,7 +151,10 @@ export function initSearxngModal(): void {
searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value;
searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 0;
searxngConfig.auth_key = (document.querySelector('#searxngAuthKey') as HTMLInputElement).value.trim();
searxngConfig.auth_type = (document.querySelector('#searxngAuthType') as HTMLSelectElement).value as 'bearer' | 'basic';
searxngConfig.format = (document.querySelector('#searxngFormat') as HTMLSelectElement).value;
searxngConfig.fetch_count = parseInt((document.querySelector('#searxngFetchCount') as HTMLInputElement).value) || 0;
searxngConfig.fetch_mode = (document.querySelector('#searxngFetchMode') as HTMLSelectElement).value as 'sequential' | 'random';
const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await saveConfig(db);
updateEnabledUI(searxngConfig.enabled);
+25 -8
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.11.4</span>
<span class="app-version">v0.11.5</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"/>
@@ -296,17 +296,17 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label style="font-size:12px;color:var(--text-secondary);" for="inputHttpTimeout">🌐 HTTP 请求超时(秒)</label>
<input class="setting-input" id="inputHttpTimeout" type="number" min="0" max="300" step="5" placeholder="30" style="margin-bottom:0;">
<input class="setting-input" id="inputHttpTimeout" type="number" min="0" step="5" placeholder="30" style="margin-bottom:0;">
<p class="text-muted" style="font-size:10px;">web_search / web_fetch 等 HTTP 请求的超时</p>
</div>
<div>
<label style="font-size:12px;color:var(--text-secondary);" for="inputStreamTimeout">📡 流式调用超时(秒)</label>
<input class="setting-input" id="inputStreamTimeout" type="number" min="0" max="600" step="10" placeholder="300" style="margin-bottom:0;">
<input class="setting-input" id="inputStreamTimeout" type="number" min="0" step="10" placeholder="300" style="margin-bottom:0;">
<p class="text-muted" style="font-size:10px;">Ollama 单轮流式响应的最大等待时间</p>
</div>
<div style="grid-column:1/-1;">
<label style="font-size:12px;color:var(--text-secondary);" for="inputMCPTimeout">🔌 MCP 请求超时(秒)</label>
<input class="setting-input" id="inputMCPTimeout" type="number" min="0" max="300" step="10" placeholder="60" style="margin-bottom:0;">
<input class="setting-input" id="inputMCPTimeout" type="number" min="0" step="10" placeholder="60" style="margin-bottom:0;">
<p class="text-muted" style="font-size:10px;">MCP Server JSON-RPC 请求的超时(需重启 MCP 服务器生效)</p>
</div>
</div>
@@ -420,7 +420,7 @@
<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 Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>44 个工具</strong>,分为 10 类:<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>记忆 & 会话 & 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><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>🧠 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.11.4 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</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>🤖 Agent Loop v0.11.5 增强</h4><ul><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</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>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 KeyHTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</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>
@@ -784,12 +784,29 @@
<option value="html">HTML(原始网页,AI 自行分析)</option>
</select>
</div>
<div class="setting-group">
<label class="setting-label">自动抓取条数</label>
<input class="setting-input" id="searxngFetchCount" type="number" min="0" step="1" placeholder="0=使用AI指定值" style="margin-bottom:0;">
</div>
<div class="setting-group">
<label class="setting-label">抓取类型</label>
<select class="setting-input" id="searxngFetchMode" style="margin-bottom:0;">
<option value="sequential">顺序抓取</option>
<option value="random">随机抓取</option>
</select>
</div>
</div>
<div class="setting-group">
<label class="setting-label">🔑 认证 Key(非必填)</label>
<input class="setting-input" id="searxngAuthKey" type="password" placeholder="留空则不发送认证" style="margin-bottom:6px;">
<p class="text-muted" style="font-size:11px;">SearXNG 实例的 API 认证密钥,通过 HTTP Header <code>Authorization: Bearer xxx</code> 传递。需配合 Nginx 反向代理验证</p>
<label class="setting-label">🔑 认证</label>
<div style="display:flex;gap:8px;align-items:center;">
<select class="setting-input" id="searxngAuthType" style="width:110px;flex-shrink:0;margin-bottom:0;">
<option value="bearer">Bearer</option>
<option value="basic">Basic</option>
</select>
<input class="setting-input" id="searxngAuthKey" type="password" placeholder="留空则不发送认证" style="flex:1;margin-bottom:0;">
</div>
<p class="text-muted" style="font-size:11px;margin-top:4px;">Bearer: API Token · Basic: 账号:密码(自动 Base64 编码)。需配合 Nginx 反向代理验证</p>
</div>
<div class="modal-actions">
+28 -6
View File
@@ -15,7 +15,7 @@ import {
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
import { showToast } from '../components/toast.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress } from './log-service.js';
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
import { getWorkspaceDirPath } from '../components/workspace-panel.js';
import { generateId } from '../utils/utils.js';
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
@@ -720,6 +720,9 @@ Shell: ${osInfo.shell}
let lastContentTime = streamStartTime; // 追踪最后一次收到新内容的时间
const PROGRESS_INTERVAL = 15000; // 每15秒输出一次流式进度
// ── 同步创建本轮的进度条目(appendChild,保证在后续工具日志之前)──
resetStreamProgress();
// ── 独立定时器:即使没有新 chunk 也会持续输出进度 ──
progressTimer = setInterval(() => {
const elapsed = Date.now() - streamStartTime;
@@ -736,6 +739,9 @@ Shell: ${osInfo.shell}
if (idleSec >= 10) {
logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${content.length}`);
}
} else {
// 模型尚未开始输出(可能在思考/加载模型/生成大参数)→ 显示等待状态
logStreamProgress(`⏳ 等待模型响应… ${sec}s`);
}
}, PROGRESS_INTERVAL);
@@ -766,26 +772,42 @@ Shell: ${osInfo.shell}
if (chunk.message?.tool_calls?.length) {
for (const tc of chunk.message.tool_calls) {
if (tc.function?.name) {
// Ollama 可能返回 arguments 为 JSON 字符串,统一转为对象
let parsedArgs: Record<string, unknown> = {};
if (tc.function.arguments) {
if (typeof tc.function.arguments === 'string') {
try { parsedArgs = JSON.parse(tc.function.arguments); } catch { parsedArgs = {}; }
} else if (typeof tc.function.arguments === 'object') {
parsedArgs = tc.function.arguments as Record<string, unknown>;
}
}
// 首次看到工具名 → 提前在工作空间创建"准备中"卡片
// 此时参数还没生成完,但用户可以知道 AI 打算调哪个工具
const isNewTool = !toolCalls.some(existing => existing.function.name === tc.function.name);
if (isNewTool && callbacks.onToolCallPrepare) {
callbacks.onToolCallPrepare({
type: 'function',
function: { name: tc.function.name, arguments: tc.function.arguments || {} }
function: { name: tc.function.name, arguments: parsedArgs }
});
}
toolCalls.push({
type: 'function',
function: {
name: tc.function.name,
arguments: tc.function.arguments || {}
arguments: parsedArgs
}
});
} else if (toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && typeof tc.function.arguments === 'object') {
Object.assign(last.function.arguments, tc.function.arguments);
if (tc.function?.arguments) {
let chunkArgs: Record<string, unknown> | null = null;
if (typeof tc.function.arguments === 'string') {
try { chunkArgs = JSON.parse(tc.function.arguments); } catch { /* ignore */ }
} else if (typeof tc.function.arguments === 'object') {
chunkArgs = tc.function.arguments as Record<string, unknown>;
}
if (chunkArgs) {
Object.assign(last.function.arguments, chunkArgs);
}
}
}
}
+16 -18
View File
@@ -176,25 +176,23 @@ export function logThink(thinking: string): void {
export function logStream(msg: string): void { addLog('stream', msg); }
/** 更新流式进度日志(原地更新,不追加新条目 */
let _progressCreated = false;
/** 本轮流式开始:同步创建新进度条目到日志底部(不删旧,日志就是完整记录 */
export function resetStreamProgress(): void {
if (!logBodyEl) return;
const entry = document.createElement('div');
entry.className = 'log-entry log-stream log-stream-progress';
entry.innerHTML = `<span class="log-time">${formatTime(Date.now())}</span><span class="log-icon">📡</span><span class="log-msg">⏳ 等待模型响应… 0s</span>`;
logBodyEl.appendChild(entry);
}
/** 更新**最新**一条流式进度日志(原地更新消息,不改变 DOM 位置和时间戳) */
export function logStreamProgress(msg: string): void {
if (logBodyEl) {
const existing = document.getElementById('log-stream-progress');
if (existing) {
// 已在 DOM 中 → 原地更新
const timeSpan = existing.querySelector('.log-time');
if (timeSpan) timeSpan.textContent = formatTime(Date.now());
const msgSpan = existing.querySelector('.log-msg');
if (msgSpan) msgSpan.textContent = msg;
return;
}
}
// DOM 中不存在,且还没创建过 → addLog(customId 确保渲染后 ID 正确)
// _progressCreated 防止 addLog 在未渲染前被重复调用
if (!_progressCreated) {
_progressCreated = true;
addLog('stream', msg, undefined, undefined, 'log-stream-progress');
// 找最后一条进度日志(当前轮)
const all = logBodyEl?.querySelectorAll?.('.log-stream-progress');
if (all && all.length > 0) {
const latest = all[all.length - 1] as HTMLElement;
const msgSpan = latest.querySelector('.log-msg');
if (msgSpan) msgSpan.textContent = msg;
}
}
+3 -3
View File
@@ -33,12 +33,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function',
function: {
name: 'write_file',
description: 'Write content to a local file. Creates parent directories automatically. Supports text (utf-8/latin1) and binary (base64) modes. Use mode="append" to add to existing file instead of overwriting. Max 10MB (overwrite) or 5MB (append). Returns file stats including overwrite info.',
description: 'Write content to a local file. Creates parent directories automatically. The "path" parameter is REQUIRED — always specify a file path (relative to workspace or absolute). For example: path="output.txt" writes to workspace. Supports text (utf-8/latin1) and binary (base64). Use mode="append" to add to existing file. Max 10MB overwrite / 5MB append.',
parameters: {
type: 'object',
required: ['path', 'content'],
properties: {
path: { type: 'string', description: 'The file path to write to. Absolute or relative to workspace.' },
path: { type: 'string', description: 'REQUIRED — File path. Relative paths use workspace as base. E.g. "output.txt", "docs/report.md", or absolute "/home/user/file.txt".' },
content: { type: 'string', description: 'The content to write. For binary files, pass base64-encoded string with encoding="base64".' },
encoding: { type: 'string', enum: ['utf-8', 'latin1', 'base64'], description: 'File encoding. Use "base64" to write binary content (images, PDFs, etc). Default: utf-8.' },
mode: { type: 'string', enum: ['overwrite', 'append'], description: 'Write mode. overwrite = replace file, append = add to end. Default: overwrite.' }
@@ -345,7 +345,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
max_results: { type: 'integer', description: 'Maximum number of results to return. Default: 30, max: 30.' },
time_range: { type: 'string', enum: ['day', 'week', 'month', 'year'], description: 'Filter results by time. Supported by Bing and Google. Use for recent news, latest version, etc.' },
enhance_snippets: { type: 'boolean', description: 'Auto-fetch detailed snippet for results with too-short descriptions. Default: true.' },
fetch_top: { type: 'integer', description: 'Number of top results to auto-fetch full content for. Min: 3, max: 10. Results shorter than this are useless — snippets lack detail.' }
fetch_top: { type: 'integer', description: 'Number of top results to auto-fetch full content for. Min: 3. Results shorter than this are useless — snippets lack detail.' }
}
}
}