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
+3 -3
View File
@@ -14,7 +14,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-v0.11.4-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.11.5-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">
@@ -245,7 +245,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.11.4.exe` 产出:`release/Metona Ollama Setup v0.11.5.exe`
## 🛠️ 常用命令 ## 🛠️ 常用命令
@@ -481,7 +481,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.11.4.exe` Output: `release/Metona Ollama Setup v0.11.5.exe`
## 🛠️ Common Commands ## 🛠️ Common Commands
+3 -3
View File
@@ -91,7 +91,7 @@ npm run dist # NSIS 安装包
| 文件 | 说明 | | 文件 | 说明 |
|------|------| |------|------|
| `release/Metona Ollama Setup v0.11.4.exe` | NSIS 安装包(可选目录、创建快捷方式) | | `release/Metona Ollama Setup v0.11.5.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.11.4","name":"v0.11.4","body":"Release notes","target_commitish":"master"}' -d '{"tag_name":"v0.11.5","name":"v0.11.5","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.11.4.exe" -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.5.exe"
``` ```
## 故障排查 ## 故障排查
+1 -1
View File
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范 # Metona Ollama Desktop — 开发规范
> 版本: v0.11.4 | 更新: 2026-06-05 | 维护: 项目团队 > 版本: v0.11.5 | 更新: 2026-06-05 | 维护: 项目团队
--- ---
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.11.4", "version": "0.11.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.11.4", "version": "0.11.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sql.js": "^1.11.0" "sql.js": "^1.11.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.11.4", "version": "0.11.5",
"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",
+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 '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 '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_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 '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 '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; 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 请求超时(毫秒) */ /** 设置 MCP 请求超时(毫秒) */
export function setMCPTimeout(ms: number): void { 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 通信 ─── // ─── JSON-RPC 通信 ───
+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.11.4', message: 'Metona Ollama Desktop v0.11.5',
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()
}); });
+13
View File
@@ -4,9 +4,11 @@
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import { spawn } from 'child_process'; 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';
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> { 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 { 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> { export async function handleWriteFile(params: { path: string; content: string; encoding?: string; mode?: string }): Promise<ToolResult> {
try { 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 filePath = resolvePath(params.path);
const allowed = checkPathAllowed(filePath, 'write'); const allowed = checkPathAllowed(filePath, 'write');
if (!allowed.ok) return { success: false, error: allowed.reason }; if (!allowed.ok) return { success: false, error: allowed.reason };
+88 -15
View File
@@ -128,7 +128,7 @@ let HTTP_TIMEOUT = 30_000;
/** 设置 HTTP 请求超时(毫秒) */ /** 设置 HTTP 请求超时(毫秒) */
export function setHTTPTimeout(ms: number): void { 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 搜索缓存 ────────────────────────────────── // ── LRU 搜索缓存 ──────────────────────────────────
@@ -509,13 +509,20 @@ async function handleWebSearchSearxng(
if (effectiveTimeRange) params.set('time_range', effectiveTimeRange); if (effectiveTimeRange) params.set('time_range', effectiveTimeRange);
const authKey = getSetting<string>('searxng_auth_key', ''); const authKey = getSetting<string>('searxng_auth_key', '');
const authType = getSetting<string>('searxng_auth_type', 'bearer');
const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`; const apiUrl = `${url.replace(/\/+$/, '')}/search?${params.toString()}`;
sendLog('info', `🔍 SearXNG 搜索`, `${query}${url} (${format})`); sendLog('info', `🔍 SearXNG 搜索`, `${query}${url} (${format})`);
try { try {
const headers = buildFetchHeaders(apiUrl, 0); 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); const resp = await fetchWithTimeout(apiUrl, HTTP_TIMEOUT, headers);
if (!resp || !resp.ok) { if (!resp || !resp.ok) {
return { 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 maxResults = Math.min(params.max_results || 30, 30);
const timeRange = params.time_range || ''; const timeRange = params.time_range || '';
const enhanceSnippets = params.enhance_snippets !== false; 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; let result: ToolResult;
// ── SearXNG 模式 ── // ── SearXNG 模式 ──
const searxngEnabled = getSetting<boolean>('searxng_enabled', false); const searxngEnabled = getSetting<boolean>('searxng_enabled', false);
if (searxngEnabled) { 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); result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
} else { } else {
sendLog('info', `🔍 web_search → 内置引擎`, `"${query}"`); sendLog('info', `🔍 web_search → 内置引擎`, `"${query}" | 抓取=${fetchTop}条 顺序`);
result = await (async () => { result = await (async () => {
const cacheKey = `${query}|${maxResults}|${timeRange}`; const cacheKey = `${query}|${maxResults}|${timeRange}`;
@@ -805,7 +820,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num
// ── 自动抓取前 N 条完整内容 ── // ── 自动抓取前 N 条完整内容 ──
if (fetchTop > 0 && result.success && (result as any).results) { if (fetchTop > 0 && result.success && (result as any).results) {
result = await applyAutoFetch(result, fetchTop); result = await applyAutoFetch(result, fetchTop, fetchMode);
} }
return result; return result;
@@ -814,25 +829,83 @@ export async function handleWebSearch(params: { query: string; max_results?: num
} }
} }
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容 */ /** fetchTop web_fetch
async function applyAutoFetch(result: ToolResult, fetchTop: number): Promise<ToolResult> { * @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; const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
if (!results || results.length === 0) return result; if (!results || results.length === 0) return result;
const toFetch = results.slice(0, fetchTop); const count = Math.min(fetchTop, results.length);
sendLog('info', `⬇️ 自动抓取 ${toFetch.length} 条网页`, toFetch.map(r => r.title.slice(0, 30)).join(', '));
// ── 构建抓取列表 ──
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,
}));
}
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 fetched: Array<{ url: string; title: string; content: string }> = [];
for (const r of toFetch) { 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 { try {
const fetchResult = await handleWebFetch({ url: r.url, retry: false }); const fetchResult = await handleWebFetch({ url, retry: false });
if (fetchResult.success && fetchResult.content) { if (fetchResult.success && fetchResult.content) {
fetched.push({ url: r.url, title: r.title, content: String(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);
}
}
} }
} catch { /* skip failed fetch */ }
} }
sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length}/${toFetch.length}成功`); sendLog('success', `⬇️ 自动抓取完成`, `${fetched.length} 条成功 / ${failedUrls.size}失败`);
(result as any)._fetched = fetched; (result as any)._fetched = fetched;
(result as any)._fetched_count = fetched.length; (result as any)._fetched_count = fetched.length;
return result; return result;
+17
View File
@@ -17,7 +17,10 @@ const DEFAULTS = {
time_range: '', time_range: '',
max_results: 0, max_results: 0,
auth_key: '', auth_key: '',
auth_type: 'bearer',
format: 'json', format: 'json',
fetch_count: 0,
fetch_mode: 'sequential',
}; };
let modalEl: HTMLElement; 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_time_range', searxngConfig.time_range);
await db.saveSetting('searxng_max_results', searxngConfig.max_results); await db.saveSetting('searxng_max_results', searxngConfig.max_results);
await db.saveSetting('searxng_auth_key', searxngConfig.auth_key); 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); 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 配置已保存'); 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.time_range = await db.getSetting('searxng_time_range', DEFAULTS.time_range);
searxngConfig.max_results = await db.getSetting('searxng_max_results', DEFAULTS.max_results); 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_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.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}` : '未启用'); logInfo('SearXNG 配置已加载', searxngConfig.enabled ? `已启用: ${searxngConfig.url}` : '未启用');
updateEnabledUI(searxngConfig.enabled); updateEnabledUI(searxngConfig.enabled);
} }
@@ -64,7 +75,10 @@ function syncFormFromConfig(): void {
(document.querySelector('#searxngTimeRange') as HTMLSelectElement).value = searxngConfig.time_range || ''; (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('#searxngMaxResults') as HTMLInputElement).value = searxngConfig.max_results > 0 ? String(searxngConfig.max_results) : '';
(document.querySelector('#searxngAuthKey') as HTMLInputElement).value = searxngConfig.auth_key; (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('#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; const toggle = document.querySelector('#toggleSearxngEnabled') as HTMLInputElement;
toggle.checked = searxngConfig.enabled; toggle.checked = searxngConfig.enabled;
updateEnabledUI(searxngConfig.enabled); updateEnabledUI(searxngConfig.enabled);
@@ -137,7 +151,10 @@ export function initSearxngModal(): void {
searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value; searxngConfig.time_range = (document.querySelector('#searxngTimeRange') as HTMLSelectElement).value;
searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 0; searxngConfig.max_results = parseInt((document.querySelector('#searxngMaxResults') as HTMLInputElement).value) || 0;
searxngConfig.auth_key = (document.querySelector('#searxngAuthKey') as HTMLInputElement).value.trim(); 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.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); const db = state.get<ChatDB | null>(KEYS.DB);
if (db) await saveConfig(db); if (db) await saveConfig(db);
updateEnabledUI(searxngConfig.enabled); updateEnabledUI(searxngConfig.enabled);
+25 -8
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.11.4</span> <span class="app-version">v0.11.5</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"/>
@@ -296,17 +296,17 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div> <div>
<label style="font-size:12px;color:var(--text-secondary);" for="inputHttpTimeout">🌐 HTTP 请求超时(秒)</label> <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> <p class="text-muted" style="font-size:10px;">web_search / web_fetch 等 HTTP 请求的超时</p>
</div> </div>
<div> <div>
<label style="font-size:12px;color:var(--text-secondary);" for="inputStreamTimeout">📡 流式调用超时(秒)</label> <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> <p class="text-muted" style="font-size:10px;">Ollama 单轮流式响应的最大等待时间</p>
</div> </div>
<div style="grid-column:1/-1;"> <div style="grid-column:1/-1;">
<label style="font-size:12px;color:var(--text-secondary);" for="inputMCPTimeout">🔌 MCP 请求超时(秒)</label> <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> <p class="text-muted" style="font-size:10px;">MCP Server JSON-RPC 请求的超时(需重启 MCP 服务器生效)</p>
</div> </div>
</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>💬 聊天功能</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>🔧 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 记忆系统</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>🔌 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>🔍 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> <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> <option value="html">HTML(原始网页,AI 自行分析)</option>
</select> </select>
</div> </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>
<div class="setting-group"> <div class="setting-group">
<label class="setting-label">🔑 认证 Key(非必填)</label> <label class="setting-label">🔑 认证</label>
<input class="setting-input" id="searxngAuthKey" type="password" placeholder="留空则不发送认证" style="margin-bottom:6px;"> <div style="display:flex;gap:8px;align-items:center;">
<p class="text-muted" style="font-size:11px;">SearXNG 实例的 API 认证密钥,通过 HTTP Header <code>Authorization: Bearer xxx</code> 传递。需配合 Nginx 反向代理验证</p> <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>
<div class="modal-actions"> <div class="modal-actions">
+28 -6
View File
@@ -15,7 +15,7 @@ import {
import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js'; import { searchMemories, buildMemoryContext, markMemoryUsed, isMemoryEnabled } from './memory-manager.js';
import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js'; import { extractSkillsFromToolRecords, matchSkills, buildSkillContext } from './skill-manager.js';
import { showToast } from '../components/toast.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 { getWorkspaceDirPath } from '../components/workspace-panel.js';
import { generateId } from '../utils/utils.js'; import { generateId } from '../utils/utils.js';
import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js'; import { buildContext, estimateTokens, shouldAutoCompress, compressWithLLM, AUTO_COMPRESS_THRESHOLD, recordActualTokens } from './context-manager.js';
@@ -720,6 +720,9 @@ Shell: ${osInfo.shell}
let lastContentTime = streamStartTime; // 追踪最后一次收到新内容的时间 let lastContentTime = streamStartTime; // 追踪最后一次收到新内容的时间
const PROGRESS_INTERVAL = 15000; // 每15秒输出一次流式进度 const PROGRESS_INTERVAL = 15000; // 每15秒输出一次流式进度
// ── 同步创建本轮的进度条目(appendChild,保证在后续工具日志之前)──
resetStreamProgress();
// ── 独立定时器:即使没有新 chunk 也会持续输出进度 ── // ── 独立定时器:即使没有新 chunk 也会持续输出进度 ──
progressTimer = setInterval(() => { progressTimer = setInterval(() => {
const elapsed = Date.now() - streamStartTime; const elapsed = Date.now() - streamStartTime;
@@ -736,6 +739,9 @@ Shell: ${osInfo.shell}
if (idleSec >= 10) { if (idleSec >= 10) {
logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${content.length}`); logStreamProgress(`⚠️ ${idleSec}s 无新内容,当前 ${content.length}`);
} }
} else {
// 模型尚未开始输出(可能在思考/加载模型/生成大参数)→ 显示等待状态
logStreamProgress(`⏳ 等待模型响应… ${sec}s`);
} }
}, PROGRESS_INTERVAL); }, PROGRESS_INTERVAL);
@@ -766,26 +772,42 @@ Shell: ${osInfo.shell}
if (chunk.message?.tool_calls?.length) { if (chunk.message?.tool_calls?.length) {
for (const tc of chunk.message.tool_calls) { for (const tc of chunk.message.tool_calls) {
if (tc.function?.name) { 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); const isNewTool = !toolCalls.some(existing => existing.function.name === tc.function.name);
if (isNewTool && callbacks.onToolCallPrepare) { if (isNewTool && callbacks.onToolCallPrepare) {
callbacks.onToolCallPrepare({ callbacks.onToolCallPrepare({
type: 'function', type: 'function',
function: { name: tc.function.name, arguments: tc.function.arguments || {} } function: { name: tc.function.name, arguments: parsedArgs }
}); });
} }
toolCalls.push({ toolCalls.push({
type: 'function', type: 'function',
function: { function: {
name: tc.function.name, name: tc.function.name,
arguments: tc.function.arguments || {} arguments: parsedArgs
} }
}); });
} else if (toolCalls.length > 0) { } else if (toolCalls.length > 0) {
const last = toolCalls[toolCalls.length - 1]; const last = toolCalls[toolCalls.length - 1];
if (tc.function?.arguments && typeof tc.function.arguments === 'object') { if (tc.function?.arguments) {
Object.assign(last.function.arguments, 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);
}
} }
} }
} }
+15 -17
View File
@@ -176,25 +176,23 @@ export function logThink(thinking: string): void {
export function logStream(msg: string): void { addLog('stream', msg); } 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 { export function logStreamProgress(msg: string): void {
if (logBodyEl) { // 找最后一条进度日志(当前轮)
const existing = document.getElementById('log-stream-progress'); const all = logBodyEl?.querySelectorAll?.('.log-stream-progress');
if (existing) { if (all && all.length > 0) {
// 已在 DOM 中 → 原地更新 const latest = all[all.length - 1] as HTMLElement;
const timeSpan = existing.querySelector('.log-time'); const msgSpan = latest.querySelector('.log-msg');
if (timeSpan) timeSpan.textContent = formatTime(Date.now());
const msgSpan = existing.querySelector('.log-msg');
if (msgSpan) msgSpan.textContent = 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');
} }
} }
+3 -3
View File
@@ -33,12 +33,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'function', type: 'function',
function: { function: {
name: 'write_file', 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: { parameters: {
type: 'object', type: 'object',
required: ['path', 'content'], required: ['path', 'content'],
properties: { 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".' }, 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.' }, 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.' } 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.' }, 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.' }, 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.' }, 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.' }
} }
} }
} }