diff --git a/README.md b/README.md
index 1909a05..edd5129 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -253,7 +253,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-产出:`release/Metona Ollama Setup v0.14.6.exe`
+产出:`release/Metona Ollama Setup v0.14.7.exe`
## 🛠️ 常用命令
@@ -501,7 +501,7 @@ npm start
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
```
-Output: `release/Metona Ollama Setup v0.14.6.exe`
+Output: `release/Metona Ollama Setup v0.14.7.exe`
## 🛠️ Common Commands
diff --git a/package-lock.json b/package-lock.json
index 81ffdb9..6e41b34 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.6",
+ "version": "0.14.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ollama-desktop",
- "version": "0.14.6",
+ "version": "0.14.7",
"license": "MIT",
"dependencies": {
"ffmpeg-static": "^5.2.0",
diff --git a/package.json b/package.json
index 2a1ec92..0e73324 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ollama-desktop",
- "version": "0.14.6",
+ "version": "0.14.7",
"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 f282380..1f46e06 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.14.6',
+ message: 'Metona Ollama Desktop v0.14.7',
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
icon: getIconPath()
});
diff --git a/src/renderer/components/chat-area.ts b/src/renderer/components/chat-area.ts
index 424a252..c73938a 100644
--- a/src/renderer/components/chat-area.ts
+++ b/src/renderer/components/chat-area.ts
@@ -115,37 +115,29 @@ export function renderMessages(): void {
const currentSession = state.get(KEYS.CURRENT_SESSION);
const msgs = currentSession ? currentSession.messages : [];
+ // 全量清空重建,避免增量 diff 的边界 bug
+ messagesContainerEl.innerHTML = '';
+ currentPlaceholder = null;
+ _renderedMsgIndices.clear();
+
if (msgs.length === 0) {
emptyStateEl.style.display = '';
messagesContainerEl.style.display = 'none';
- _renderedMsgIndices.clear();
return;
}
emptyStateEl.style.display = 'none';
messagesContainerEl.style.display = '';
- // P1-1: 基于 Set 的稳定增量 diff
- const wasExisting = _renderedMsgIndices.size > 0;
- let isNewMessage = false;
for (let i = 0; i < msgs.length; i++) {
- if (!_renderedMsgIndices.has(i)) {
- appendMessageDOM(msgs[i], i);
- _renderedMsgIndices.add(i);
- isNewMessage = true;
- }
+ const msg = msgs[i];
+ // 只跳过既无内容也无 think 也无工具的空消息
+ if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
+ appendMessageDOM(msgs[i], i);
+ _renderedMsgIndices.add(i);
}
- // 清理超出范围的索引(消息被 undo/retry 移除时)
- for (const idx of _renderedMsgIndices) {
- if (idx >= msgs.length) _renderedMsgIndices.delete(idx);
- }
-
- if (isNewMessage) {
- scrollToBottom();
- } else if (msgs.length > 0 && !wasExisting) {
- chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
- }
+ scrollToBottom();
}
export function appendMessageDOM(msg: ChatMessage, index: number): void {
@@ -520,6 +512,14 @@ export function resetCurrentPlaceholder(): void {
}
}
+/** 从 DOM 移除当前 placeholder 并清空引用 */
+export function removeCurrentPlaceholder(): void {
+ if (currentPlaceholder) {
+ currentPlaceholder.remove();
+ currentPlaceholder = null;
+ }
+}
+
export function enableAutoScroll(): void {
autoScroll = true;
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts
index 5ed941a..b265b2a 100644
--- a/src/renderer/components/input-area.ts
+++ b/src/renderer/components/input-area.ts
@@ -9,7 +9,7 @@ import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSuppo
import {
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll,
- appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder
+ appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder, removeCurrentPlaceholder
} from './chat-area.js';
import { showToast } from './toast.js';
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
@@ -529,17 +529,14 @@ async function handleRetry(): Promise {
});
},
onNewIteration: (toolCalls) => {
- const hasToolCalls = toolCalls && toolCalls.length > 0;
- if (retryContent || hasToolCalls) {
+ removeCurrentPlaceholder();
+ const hasContent = !!retryContent?.trim();
+ if (hasContent || retryIterations === 0) {
const now = Date.now();
- const toolCallRecords: ToolCallRecord[] | undefined = toolCalls?.map(tc => ({
- name: tc.function.name, arguments: tc.function.arguments,
- result: null, status: 'running' as const, timestamp: now
- }));
const prevMsg: ChatMessage = {
- role: 'assistant', content: retryContent || '', timestamp: now,
+ role: 'assistant', content: retryContent || '', model: getSelectedModel(),
+ timestamp: now,
...(retryThinkContent && { think: retryThinkContent }),
- ...(toolCallRecords?.length && { toolCalls: toolCallRecords }),
};
state.update(KEYS.CURRENT_SESSION, (s: any) => ({
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
@@ -548,7 +545,6 @@ async function handleRetry(): Promise {
}
appendAssistantPlaceholder();
retryContent = '';
- retryThinkContent = '';
retryIterations++;
},
onThinking: (thinking) => { retryThinkContent = thinking; updateLastAssistantMessage(retryContent, thinking, null, getSelectedModel()); },
@@ -1090,27 +1086,7 @@ export async function sendMessage(): Promise {
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record): void {
- let updated = false;
- state.update(KEYS.CURRENT_SESSION, (session: any) => {
- if (!session) return session;
- const messages = [...session.messages];
- // 从后往前找最近一条包含该工具 running 状态记录的消息
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (!msg.toolCalls) continue;
- const idx = msg.toolCalls.findIndex((tc: any) => tc.name === toolName && tc.status === 'running');
- if (idx !== -1) {
- const updatedRecords = [...msg.toolCalls];
- updatedRecords[idx] = { ...updatedRecords[idx], status, result };
- messages[i] = { ...msg, toolCalls: updatedRecords };
- updated = true;
- return { ...session, messages, updatedAt: Date.now() };
- }
- }
- return session;
- });
- // 状态更新后重新渲染消息,使工具卡片状态即时生效
- if (updated) renderMessages();
+ // 工具卡片统一由 onDone 挂载,不在执行中更新消息记录
}
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise {
@@ -1255,10 +1231,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
let assistantContent = '';
let thinkContent = '';
- let agentModeIterations = 0; // 跟踪 onNewIteration 调用次数
- state.set('_currentEvalCount', 0); // 重置累计 token 计数,防止跨会话残留
- let lastIterationEvalCount = 0; // 上一轮迭代结束时的累计 token 数
- let lastIterationStartTime = Date.now(); // 当前迭代开始时间
+ state.set('_currentEvalCount', 0);
// ── 监控定时器:流式输出期间持续显示工作提示 ──
// 模型生成大参数 tool_call 可能需要几分钟,期间工作空间显示提示
@@ -1278,52 +1251,25 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
});
},
onNewIteration: (toolCalls) => {
- // Agent Loop 新迭代开始:保存上一轮内容为独立消息,创建新 placeholder
- // 先移除旧 placeholder(防止被 renderMessages 当作已渲染消息计数)
- const oldPlaceholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
- if (oldPlaceholder) oldPlaceholder.remove();
+ // 保存上一轮的卡片(不含工具记录,工具统一在 onDone 挂载)
+ const prevMsg: ChatMessage = {
+ role: 'assistant',
+ content: assistantContent || '',
+ model: getSelectedModel(),
+ timestamp: Date.now(),
+ ...(thinkContent && { think: thinkContent }),
+ };
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, prevMsg],
+ updatedAt: Date.now()
+ }));
+ renderMessages();
- // 有文本内容 或 有工具调用 → 保存为独立消息
- const hasToolCalls = toolCalls && toolCalls.length > 0;
- if (assistantContent || hasToolCalls) {
- const now = Date.now();
- // 计算本轮迭代的 token 和时长
- const currentEvalCount = state.get('_currentEvalCount', 0);
- const iterationEvalCount = Math.max(0, currentEvalCount - lastIterationEvalCount);
- const iterationDuration = (now - lastIterationStartTime) * 1e6;
-
- const toolCallRecords: ToolCallRecord[] | undefined = toolCalls?.map(tc => ({
- name: tc.function.name,
- arguments: tc.function.arguments,
- result: null,
- status: 'running' as const,
- timestamp: now
- }));
-
- const prevMsg: ChatMessage = {
- role: 'assistant',
- content: assistantContent || '',
- timestamp: now,
- ...(thinkContent && { think: thinkContent }),
- ...(toolCallRecords?.length && { toolCalls: toolCallRecords }),
- ...(iterationEvalCount > 0 && { eval_count: iterationEvalCount }),
- ...(iterationDuration > 0 && { total_duration: iterationDuration }),
- };
- state.update(KEYS.CURRENT_SESSION, (session: any) => ({
- ...session,
- messages: [...session.messages, prevMsg],
- updatedAt: Date.now()
- }));
- renderMessages();
-
- lastIterationEvalCount = currentEvalCount;
- lastIterationStartTime = Date.now();
- }
- // 创建新 placeholder,重置内容
+ removeCurrentPlaceholder();
appendAssistantPlaceholder();
assistantContent = '';
thinkContent = '';
- agentModeIterations++;
},
onThinking: (thinking) => {
thinkContent = thinking;
@@ -1372,50 +1318,23 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}
},
onDone: async (finalContent, toolRecords, loopStats) => {
- assistantContent = finalContent;
+ const assistantMsg: ChatMessage = {
+ role: 'assistant',
+ content: finalContent || '',
+ model: getSelectedModel(),
+ timestamp: Date.now(),
+ ...(thinkContent && { think: thinkContent }),
+ ...(toolRecords?.length && { toolCalls: toolRecords }),
+ ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
+ ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
+ ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
+ };
+ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
+ ...session,
+ messages: [...session.messages, assistantMsg],
+ updatedAt: Date.now()
+ }));
- // 如果有多个迭代,最后一条消息已由 onNewIteration 保存,这里创建新消息保存最终回复
- if (agentModeIterations > 0) {
- if (assistantContent) {
- const lastIterDuration = (Date.now() - lastIterationStartTime) * 1e6;
- const lastMsg: ChatMessage = {
- role: 'assistant',
- content: assistantContent,
- model: getSelectedModel(),
- timestamp: Date.now(),
- ...(thinkContent && { think: thinkContent }),
- ...(toolRecords?.length && { toolCalls: toolRecords }),
- ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
- ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
- ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
- };
- state.update(KEYS.CURRENT_SESSION, (session: any) => ({
- ...session,
- messages: [...session.messages, lastMsg],
- updatedAt: Date.now()
- }));
- }
- } else {
- // 单迭代模式:正常保存
- const assistantMsg: ChatMessage = {
- role: 'assistant',
- content: assistantContent || '',
- model: getSelectedModel(),
- timestamp: Date.now(),
- ...(thinkContent && { think: thinkContent }),
- ...(toolRecords?.length && { toolCalls: toolRecords }),
- ...(loopStats?.eval_count && { eval_count: loopStats.eval_count }),
- ...(loopStats?.prompt_eval_count && { prompt_eval_count: loopStats.prompt_eval_count }),
- ...(loopStats?.total_duration && { total_duration: loopStats.total_duration }),
- };
- state.update(KEYS.CURRENT_SESSION, (session: any) => ({
- ...session,
- messages: [...session.messages, assistantMsg],
- updatedAt: Date.now()
- }));
- }
- // 清掉流式 placeholder,统一从 session 渲染(单条消息,Markdown 格式)
- clearMessagesDOM();
renderMessages();
// ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)──
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 6db271e..6f88459 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -28,7 +28,7 @@