diff --git a/README.md b/README.md
index 9ff9894..5ed6ca7 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.2.exe`
+产出:`release/Metona Ollama Setup v0.11.3.exe`
## 🛠️ 常用命令
@@ -481,7 +481,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.11.2.exe`
+Output: `release/Metona Ollama Setup v0.11.3.exe`
## 🛠️ Common Commands
diff --git a/docs/BUILD.md b/docs/BUILD.md
index 89c583d..eec23dc 100644
--- a/docs/BUILD.md
+++ b/docs/BUILD.md
@@ -91,7 +91,7 @@ npm run dist # NSIS 安装包
| 文件 | 说明 |
|------|------|
-| `release/Metona Ollama Setup v0.11.2.exe` | NSIS 安装包(可选目录、创建快捷方式) |
+| `release/Metona Ollama Setup v0.11.3.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.2","name":"v0.11.2","body":"Release notes","target_commitish":"master"}'
+ -d '{"tag_name":"v0.11.3","name":"v0.11.3","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.2.exe"
+ -H "Content-Type: multipart/form-data" -F "file=@release/Metona Ollama Setup v0.11.3.exe"
```
## 故障排查
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 49ad2d0..2b71406 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -1,6 +1,6 @@
# Metona Ollama Desktop — 开发规范
-> 版本: v0.11.2 | 更新: 2026-06-05 | 维护: 项目团队
+> 版本: v0.11.3 | 更新: 2026-06-05 | 维护: 项目团队
---
diff --git a/package-lock.json b/package-lock.json
index fc0a071..6633769 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.11.2",
+ "version": "0.11.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.11.2",
+ "version": "0.11.3",
"license": "MIT",
"dependencies": {
"sql.js": "^1.11.0"
diff --git a/package.json b/package.json
index e885cac..64e729e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.11.2",
+ "version": "0.11.3",
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
"main": "dist/main/main.js",
"author": "thzxx",
diff --git a/src/main/menu.ts b/src/main/menu.ts
index e89cb3c..37a624e 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.2',
+ message: 'Metona Ollama Desktop v0.11.3',
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 09c3a53..477d640 100644
--- a/src/main/tool-handlers-fs.ts
+++ b/src/main/tool-handlers-fs.ts
@@ -196,6 +196,7 @@ export async function handleWriteFile(params: { path: string; content: string; e
}
const dir = path.dirname(filePath);
+ sendLog('info', `✏️ write_file 开始`, `${filePath} (${contentLength}B, ${writeMode})`);
await fs.mkdir(dir, { recursive: true });
if (writeMode === 'append') {
diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts
index c4b71c4..0d4033a 100644
--- a/src/renderer/components/chat-area.ts
+++ b/src/renderer/components/chat-area.ts
@@ -261,7 +261,7 @@ function renderToolCallCard(tc: ToolCallRecord): string {
browser_click: '点击元素', browser_type: '输入文本', browser_scroll: '滚动页面', browser_close: '关闭浏览器'
};
const statusLabels: Record = {
- pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
+ pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
};
const icon = icons[tc.name] || '🔧';
@@ -428,8 +428,9 @@ export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void {
if (!currentPlaceholder) return;
const container = currentPlaceholder.querySelector('.tool-calls-container');
if (!container) return;
- // 找到第一个同名且状态为 running 的卡片
- const existing = container.querySelector(`.tool-call-card[data-tool-name="${tc.name}"].tool-call-running`) as HTMLElement;
+ // 找到第一个同名且状态为 running 的卡片(CSS.escape 防特殊字符)
+ const escapedName = CSS.escape(tc.name);
+ const existing = container.querySelector(`.tool-call-card[data-tool-name="${escapedName}"].tool-call-running`) as HTMLElement;
if (existing) {
const cardDiv = document.createElement('div');
cardDiv.innerHTML = renderToolCallCard(tc);
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 276e93f..4575413 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -12,7 +12,7 @@ import {
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder
} from './chat-area.js';
import { showToast } from './toast.js';
-import { addToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath } from './workspace-panel.js';
+import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
import { ChatDB } from '../db/chat-db.js';
import { OllamaAPI } from '../api/ollama.js';
import { runAgentLoop } from '../services/agent-engine.js';
@@ -352,11 +352,26 @@ async function handleRetry(): Promise {
return { role: m.role, content, ...(m.images?.length && { images: m.images }) };
});
+ let retryMonitor: ReturnType | null = null;
+
try {
let retryContent = '';
let retryThinkContent = '';
let retryIterations = 0;
+
+ // ── 监控定时器 ──
+ retryMonitor = setInterval(() => {
+ if (!hasActiveCards()) {
+ showWorkingHint('⏳ AI 正在处理中...');
+ }
+ }, 2000);
await runAgentLoop(userMsg.content || '', userMsg.images || [], historyMessages, {
+ onToolCallPrepare: (call) => {
+ addToolCard({
+ name: call.function.name, arguments: call.function.arguments,
+ result: null, status: 'pending', timestamp: Date.now()
+ });
+ },
onNewIteration: (toolCalls) => {
if (retryContent || toolCalls) {
const now = Date.now();
@@ -382,7 +397,7 @@ async function handleRetry(): Promise {
onThinking: (thinking) => { retryThinkContent = thinking; updateLastAssistantMessage(retryContent, thinking, null, getSelectedModel()); },
onContent: (content) => { retryContent = content; updateLastAssistantMessage(content, retryThinkContent || null, null, getSelectedModel()); },
onToolCallStart: (call) => {
- addToolCard({
+ startToolCard({
name: call.function.name, arguments: call.function.arguments,
result: null, status: 'running', timestamp: Date.now()
});
@@ -441,6 +456,9 @@ async function handleRetry(): Promise {
} catch (err) {
logError('重试失败', (err as Error).message);
appendSystemMessage(`❌ 重试失败: ${(err as Error).message}`);
+ } finally {
+ if (retryMonitor) clearInterval(retryMonitor as unknown as number);
+ clearWorkingHint();
}
state.set(KEYS.IS_STREAMING, false);
@@ -909,8 +927,23 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
let lastIterationEvalCount = 0; // 上一轮迭代结束时的累计 token 数
let lastIterationStartTime = Date.now(); // 当前迭代开始时间
+ // ── 监控定时器:流式输出期间持续显示工作提示 ──
+ // 模型生成大参数 tool_call 可能需要几分钟,期间工作空间显示提示
+ const monitorInterval = setInterval(() => {
+ if (!hasActiveCards()) {
+ showWorkingHint('⏳ AI 正在处理中...');
+ }
+ }, 2000);
+
try {
await runAgentLoop(text || (pendingFiles.length > 0 ? `请分析 ${pendingFiles.map(f => f.name).join(', ')}` : ''), images, historyMessages, {
+ /** 模型正在生成 tool_call 参数中 — 提前在工作空间显示"准备中"卡片 */
+ onToolCallPrepare: (call) => {
+ addToolCard({
+ name: call.function.name, arguments: call.function.arguments,
+ result: null, status: 'pending', timestamp: Date.now()
+ });
+ },
onNewIteration: (toolCalls) => {
// Agent Loop 新迭代开始:保存上一轮内容为独立消息,创建新 placeholder
// 先移除旧 placeholder(防止被 renderMessages 当作已渲染消息计数)
@@ -967,7 +1000,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
updateLastAssistantMessage(assistantContent, thinkContent || null, null, getSelectedModel());
},
onToolCallStart: (call) => {
- addToolCard({
+ startToolCard({
name: call.function.name, arguments: call.function.arguments,
result: null, status: 'running', timestamp: Date.now()
});
@@ -1097,6 +1130,8 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
appendSystemMessage(errMsg);
}
} finally {
+ clearInterval(monitorInterval);
+ clearWorkingHint();
state.set(KEYS.IS_STREAMING, false);
updateSendButton(false);
state.set(KEYS.ABORT_CONTROLLER, null);
diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts
index 85c67df..10a3149 100644
--- a/src/renderer/components/workspace-panel.ts
+++ b/src/renderer/components/workspace-panel.ts
@@ -40,7 +40,7 @@ interface FileNode {
}
let panelVisible = true; // 常驻显示,不可关闭
-let activeTab: 'terminal' | 'tools' | 'files' = 'terminal';
+let activeTab: 'terminal' | 'tools' | 'files' = 'tools';
let terminal: TerminalSession | null = null; // 唯一终端
let workspaceDir = '';
let currentFileDir = '';
@@ -85,7 +85,6 @@ export function clearTerminalExternal(): void {
/** 工具调用卡片列表 */
let toolCards: ToolCallRecord[] = [];
-let toolAutoScroll = true;
/** 当前正在运行的 AI 命令(用于用户手动终止时通知 AI) */
let currentAiCommand: string | null = null;
@@ -764,33 +763,110 @@ function isTerminalBusy(): boolean {
export function addToolCard(tc: ToolCallRecord): void {
toolCards.push(tc);
+ // ── 立即追加 DOM 节点,不等待全量渲染 ──
+ if (activeTab === 'tools') {
+ appendToolCardDOM(tc);
+ }
+
+ const isPending = tc.status === 'pending';
if (tc.name === 'run_command') {
// run_command:始终切到终端
switchToTab('terminal');
- } else if (!isTerminalBusy()) {
- // 终端空闲时才切到工具页签
+ } else if (!isTerminalBusy() && !isPending) {
+ // 终端空闲且非 pending 时才切到工具页签
+ // pending 卡片不切换 tab(准备中不应打断当前视图)
switchToTab('tools');
}
- // 终端忙碌时不切换,避免打断用户看命令输出
}
-/** 更新工具调用卡片状态(由外部调用) */
+/** 工具开始执行:将 pending 卡片升级为 running,或新增 running 卡片 */
+export function startToolCard(tc: ToolCallRecord): void {
+ // 查找是否有同名 pending 卡片(由 onToolCallPrepare 提前创建)
+ const pendingIdx = toolCards.findIndex(
+ c => c.name === tc.name && c.status === 'pending'
+ );
+ if (pendingIdx !== -1) {
+ // 升级 pending → running,增量更新 DOM
+ toolCards[pendingIdx] = tc;
+ if (activeTab === 'tools') {
+ updateToolCardDOM(tc);
+ }
+ } else {
+ // 没有提前创建的 pending 卡片,走正常追加流程
+ addToolCard(tc);
+ }
+ updateToolsHint();
+}
+
+/** 更新工具调用卡片状态(由外部调用)—— 增量更新 DOM,不触发全量重绘 */
export function updateToolCard(tc: ToolCallRecord): void {
- // 找到最后一个同名且 running 的卡片
+ // 找到最后一个同名且 running/pending 的卡片(pending 被 onToolCallStart 更新为 running)
for (let i = toolCards.length - 1; i >= 0; i--) {
- if (toolCards[i].name === tc.name && toolCards[i].status === 'running') {
+ if (toolCards[i].name === tc.name && (toolCards[i].status === 'running' || toolCards[i].status === 'pending')) {
toolCards[i] = tc;
break;
}
}
- // 如果当前在工具页签,刷新显示
+ // 增量更新 DOM:只替换这一个卡片的 DOM 节点,保留其他卡片不动
if (activeTab === 'tools') {
- renderToolCalls();
+ updateToolCardDOM(tc);
}
updateToolsHint();
}
+/** 将单个工具卡片追加到 #wsToolsOutput DOM 容器 */
+function appendToolCardDOM(tc: ToolCallRecord): void {
+ const container = document.querySelector('#wsToolsOutput') as HTMLElement;
+ if (!container) return;
+
+ // 首次添加卡片时清除空闲状态占位
+ if (container.querySelector('.ws-idle-state')) {
+ container.innerHTML = '';
+ }
+
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = renderToolCard(tc);
+ const card = wrapper.firstElementChild as HTMLElement;
+ if (card) {
+ card.dataset.toolName = tc.name;
+ container.appendChild(card);
+ // 新卡片出现时始终滚到底部(用户需要看到最新的工具状态)
+ container.scrollTop = container.scrollHeight;
+ }
+}
+
+/** 增量更新工具卡片 DOM 节点(找到 pending/running 状态的卡片并替换) */
+function updateToolCardDOM(tc: ToolCallRecord): void {
+ const container = document.querySelector('#wsToolsOutput') as HTMLElement;
+ if (!container) return;
+
+ // 查找同名且状态为 pending 或 running 的卡片 DOM 节点
+ const runningCard = (
+ container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-running`) ||
+ container.querySelector(`.tool-call-card[data-tool-name="${escapeAttr(tc.name)}"].tool-call-pending`)
+ ) as HTMLElement | null;
+
+ if (runningCard) {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = renderToolCard(tc);
+ const newCard = wrapper.firstElementChild as HTMLElement;
+ if (newCard) {
+ newCard.dataset.toolName = tc.name;
+ runningCard.replaceWith(newCard);
+ }
+ } else {
+ // running 卡片不在 DOM 中(可能是 tab 切换后再切回或其他边缘情况)
+ // 回退:追加新卡片
+ appendToolCardDOM(tc);
+ }
+}
+
+/** CSS.escape 回退(兼容旧 Electron) */
+function escapeAttr(value: string): string {
+ try { return CSS.escape(value); } catch { return value.replace(/["\\]/g, '\\$&'); }
+}
+
function clearToolCards(): void {
toolCards = [];
if (activeTab === 'tools') {
@@ -802,17 +878,32 @@ function clearToolCards(): void {
function updateToolsHint(): void {
const hint = document.querySelector('#wsToolsHint') as HTMLElement;
if (!hint) return;
- const running = toolCards.filter(c => c.status === 'running');
- if (running.length > 0) {
- hint.textContent = `⏳ 执行中: ${running.map(c => getToolDisplayName(c.name)).join(', ')}`;
- hint.classList.add('running');
- } else {
- const total = toolCards.length;
- const success = toolCards.filter(c => c.status === 'success').length;
- const error = toolCards.filter(c => c.status === 'error').length;
- hint.textContent = total > 0 ? `共 ${total} 个工具调用(✅${success} ❌${error})` : '等待工具调用...';
+ if (toolCards.length === 0) {
+ hint.textContent = '等待工具调用...';
hint.classList.remove('running');
+ return;
}
+
+ const byStatus: Record = {};
+ for (const tc of toolCards) {
+ const name = getToolDisplayName(tc.name);
+ if (!byStatus[tc.status]) byStatus[tc.status] = [];
+ byStatus[tc.status].push(name);
+ }
+
+ const parts: string[] = [];
+ const order = ['pending', 'running', 'success', 'error', 'cancelled'];
+ const icons: Record = {
+ pending: '📝', running: '🔄', success: '✅', error: '❌', cancelled: '🚫'
+ };
+ for (const s of order) {
+ if (byStatus[s]?.length) {
+ parts.push(`${icons[s] || ''} ${byStatus[s].join(', ')}`);
+ }
+ }
+
+ hint.textContent = parts.join(' · ');
+ hint.classList.toggle('running', !!(byStatus.pending || byStatus.running));
}
function renderToolCalls(): void {
@@ -845,14 +936,8 @@ function renderToolCalls(): void {
html += renderToolCard(tc);
}
container.innerHTML = html;
-
- if (toolAutoScroll) {
- container.scrollTop = container.scrollHeight;
- }
-
- container.onscroll = () => {
- toolAutoScroll = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
- };
+ // 全量渲染后滚到底部
+ container.scrollTop = container.scrollHeight;
}
function getToolDisplayName(name: string): string {
@@ -892,8 +977,12 @@ function getToolIcon(name: string): string {
function renderToolCard(tc: ToolCallRecord): string {
const icon = getToolIcon(tc.name);
const name = getToolDisplayName(tc.name);
+ const statusClass = tc.status === 'running' ? 'tool-call-running'
+ : tc.status === 'pending' ? 'tool-call-pending'
+ : tc.status === 'error' ? 'tool-call-error'
+ : '';
const statusLabels: Record = {
- pending: '⏳ 等待确认', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
+ pending: '📝 准备中…', running: '🔄 执行中', success: '✅ 完成', error: '❌ 失败', cancelled: '🚫 已取消'
};
const status = statusLabels[tc.status] || tc.status;
const args = tc.arguments || {};
@@ -951,8 +1040,6 @@ function renderToolCard(tc: ToolCallRecord): string {
}
}
- const statusClass = tc.status === 'running' ? 'tool-call-running' : tc.status === 'error' ? 'tool-call-error' : '';
-
return `