From a336a66516383d4432eea4a580bce4884d265a1b Mon Sep 17 00:00:00 2001
From: thzxx
Date: Sat, 13 Jun 2026 21:35:22 +0800
Subject: [PATCH] =?UTF-8?q?v0.11.5:=20=E6=90=9C=E7=B4=A2=E6=8A=93=E5=8F=96?=
=?UTF-8?q?=E5=A2=9E=E5=BC=BA=20+=20=E6=B5=81=E5=BC=8F=E8=BF=9B=E5=BA=A6?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E8=B6=85=E6=97=B6/=E5=86=99=E5=85=A5?=
=?UTF-8?q?=E5=AE=B9=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
README.md | 6 +-
docs/BUILD.md | 6 +-
docs/DEVELOPMENT.md | 2 +-
package-lock.json | 4 +-
package.json | 2 +-
src/main/ipc.ts | 2 +-
src/main/mcp-manager.ts | 2 +-
src/main/menu.ts | 2 +-
src/main/tool-handlers-fs.ts | 13 +++
src/main/tool-handlers-system.ts | 111 +++++++++++++++++++----
src/renderer/components/searxng-modal.ts | 17 ++++
src/renderer/index.html | 33 +++++--
src/renderer/services/agent-engine.ts | 34 +++++--
src/renderer/services/log-service.ts | 34 ++++---
src/renderer/services/tool-registry.ts | 6 +-
15 files changed, 207 insertions(+), 67 deletions(-)
diff --git a/README.md b/README.md
index 534faa2..9ebc28b 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -245,7 +245,7 @@ npm start
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
```
-Output: `release/Metona Ollama Setup v0.11.4.exe`
+Output: `release/Metona Ollama Setup v0.11.5.exe`
## 🛠️ Common Commands
diff --git a/docs/BUILD.md b/docs/BUILD.md
index dcf64ed..73436fb 100644
--- a/docs/BUILD.md
+++ b/docs/BUILD.md
@@ -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 安装包。
@@ -103,7 +103,7 @@ npm run dist # NSIS 安装包
TOKEN="your_access_token"
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases?access_token=$TOKEN" \
-H "Content-Type: application/json" \
- -d '{"tag_name":"v0.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"
RELEASE_ID=""
curl -X POST "https://gitee.com/api/v5/repos/thzxx/metona-ollama-desktop/releases/$RELEASE_ID/attach_files?access_token=$TOKEN" \
- -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.4.exe"
+ -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.5.exe"
```
## 故障排查
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 5a44455..f9f9218 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范
-> 版本: v0.11.4 | 更新: 2026-06-05 | 维护: 项目团队
+> 版本: v0.11.5 | 更新: 2026-06-05 | 维护: 项目团队
---
diff --git a/package-lock.json b/package-lock.json
index 47673f2..ae78700 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.11.4",
+ "version": "0.11.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.11.4",
+ "version": "0.11.5",
"license": "MIT",
"dependencies": {
"sql.js": "^1.11.0"
diff --git a/package.json b/package.json
index dc9355e..e815f1d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.11.4",
+ "version": "0.11.5",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 9a48fb0..ee0ed2d 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -189,7 +189,7 @@ export async function setupIPC(): Promise {
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;
diff --git a/src/main/mcp-manager.ts b/src/main/mcp-manager.ts
index 9a5ca42..22bea03 100644
--- a/src/main/mcp-manager.ts
+++ b/src/main/mcp-manager.ts
@@ -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 通信 ───
diff --git a/src/main/menu.ts b/src/main/menu.ts
index 60f0a36..6f3250a 100644
--- a/src/main/menu.ts
+++ b/src/main/menu.ts
@@ -101,7 +101,7 @@ export function createMenu(): void {
dialog.showMessageBox(mainWindow!, {
type: 'info',
title: '关于 Metona Ollama',
- message: 'Metona Ollama Desktop v0.11.4',
+ message: 'Metona Ollama Desktop v0.11.5',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
diff --git a/src/main/tool-handlers-fs.ts b/src/main/tool-handlers-fs.ts
index 477d640..85725a6 100644
--- a/src/main/tool-handlers-fs.ts
+++ b/src/main/tool-handlers-fs.ts
@@ -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 {
try {
@@ -156,6 +158,17 @@ function extToMime(ext: string): string {
export async function handleWriteFile(params: { path: string; content: string; encoding?: string; mode?: string }): Promise {
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 };
diff --git a/src/main/tool-handlers-system.ts b/src/main/tool-handlers-system.ts
index e01b07d..23c1913 100644
--- a/src/main/tool-handlers-system.ts
+++ b/src/main/tool-handlers-system.ts
@@ -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('searxng_auth_key', '');
+ const authType = getSetting('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('searxng_enabled', false);
if (searxngEnabled) {
- sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}"`);
+ // SearXNG 模式下读取用户面板设置的抓取条数和类型
+ const userFetchCount = getSetting('fetch_count', 0);
+ fetchMode = (getSetting('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 {
+/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容
+ * @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条
+ * 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */
+async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise {
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();
+ const failedUrls = new Set();
+
+ /** 尝试抓取单条 URL,成功则加入 fetched */
+ const tryFetchOne = async (url: string, title: string): Promise => {
+ 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;
diff --git a/src/renderer/components/searxng-modal.ts b/src/renderer/components/searxng-modal.ts
index 855faa8..c2e5f10 100644
--- a/src/renderer/components/searxng-modal.ts
+++ b/src/renderer/components/searxng-modal.ts
@@ -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 {
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 {
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('fetch_count', DEFAULTS.fetch_count);
+ searxngConfig.fetch_mode = await db.getSetting('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(KEYS.DB);
if (db) await saveConfig(db);
updateEnabledUI(searxngConfig.enabled);
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 19ec43d..0e4e0a0 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@
+
+
+
+
+
+
+
+
-
-
-
SearXNG 实例的 API 认证密钥,通过 HTTP Header Authorization: Bearer xxx 传递。需配合 Nginx 反向代理验证
+
+
+
+
+
+
Bearer: API Token · Basic: 账号:密码(自动 Base64 编码)。需配合 Nginx 反向代理验证
diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts
index b5fced0..8a1a84a 100644
--- a/src/renderer/services/agent-engine.ts
+++ b/src/renderer/services/agent-engine.ts
@@ -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 = {};
+ 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;
+ }
+ }
// 首次看到工具名 → 提前在工作空间创建"准备中"卡片
- // 此时参数还没生成完,但用户可以知道 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 | 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;
+ }
+ if (chunkArgs) {
+ Object.assign(last.function.arguments, chunkArgs);
+ }
}
}
}
diff --git a/src/renderer/services/log-service.ts b/src/renderer/services/log-service.ts
index 47b6fb4..0589e91 100644
--- a/src/renderer/services/log-service.ts
+++ b/src/renderer/services/log-service.ts
@@ -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 = `${formatTime(Date.now())}📡⏳ 等待模型响应… 0s`;
+ 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;
}
}
diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts
index a28f80f..18d3fed 100644
--- a/src/renderer/services/tool-registry.ts
+++ b/src/renderer/services/tool-registry.ts
@@ -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.' }
}
}
}