chore: 版本号 0.14.6 → 0.14.7

fix: onNewIteration 只在 THINKING 状态调用,消除每状态切换的冗余触发
fix: renderMessages 全量清空重建,去掉增量 diff 的边界 bug
fix: 空消息跳过增加 think 判断,纯思考卡片不再被吞掉
fix: updateMessageToolRecord 改为空函数,工具卡片统一由 onDone 挂载
This commit is contained in:
thzxx
2026-06-28 23:16:05 +08:00
parent 20701433dd
commit e67d638e06
8 changed files with 68 additions and 149 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.14.6-E8734A?style=flat-square" alt="version"> <img src="https://img.shields.io/badge/version-v0.14.7-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">
@@ -253,7 +253,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.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 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 ## 🛠️ Common Commands
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.6", "version": "0.14.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.6", "version": "0.14.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ollama-desktop", "name": "metona-ollama-desktop",
"version": "0.14.6", "version": "0.14.7",
"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
@@ -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.14.6', message: 'Metona Ollama Desktop v0.14.7',
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()
}); });
+16 -16
View File
@@ -115,37 +115,29 @@ export function renderMessages(): void {
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION); const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const msgs = currentSession ? currentSession.messages : []; const msgs = currentSession ? currentSession.messages : [];
// 全量清空重建,避免增量 diff 的边界 bug
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear();
if (msgs.length === 0) { if (msgs.length === 0) {
emptyStateEl.style.display = ''; emptyStateEl.style.display = '';
messagesContainerEl.style.display = 'none'; messagesContainerEl.style.display = 'none';
_renderedMsgIndices.clear();
return; return;
} }
emptyStateEl.style.display = 'none'; emptyStateEl.style.display = 'none';
messagesContainerEl.style.display = ''; messagesContainerEl.style.display = '';
// P1-1: 基于 Set 的稳定增量 diff
const wasExisting = _renderedMsgIndices.size > 0;
let isNewMessage = false;
for (let i = 0; i < msgs.length; i++) { for (let i = 0; i < msgs.length; i++) {
if (!_renderedMsgIndices.has(i)) { const msg = msgs[i];
// 只跳过既无内容也无 think 也无工具的空消息
if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
appendMessageDOM(msgs[i], i); appendMessageDOM(msgs[i], i);
_renderedMsgIndices.add(i); _renderedMsgIndices.add(i);
isNewMessage = true;
}
} }
// 清理超出范围的索引(消息被 undo/retry 移除时)
for (const idx of _renderedMsgIndices) {
if (idx >= msgs.length) _renderedMsgIndices.delete(idx);
}
if (isNewMessage) {
scrollToBottom(); scrollToBottom();
} else if (msgs.length > 0 && !wasExisting) {
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
}
} }
export function appendMessageDOM(msg: ChatMessage, index: number): void { 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 { export function enableAutoScroll(): void {
autoScroll = true; autoScroll = true;
if (scrollBtnEl) scrollBtnEl.style.display = 'none'; if (scrollBtnEl) scrollBtnEl.style.display = 'none';
+14 -95
View File
@@ -9,7 +9,7 @@ import { getSelectedModel, isThinkEnabled, isVisionAvailable, isToolCallingSuppo
import { import {
renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage, renderMessages, clearMessagesDOM, appendAssistantPlaceholder, appendSystemMessage,
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll, updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll, resetAutoScroll,
appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder appendToolCallCardToPlaceholder, updateToolCallCardInPlaceholder, resetCurrentPlaceholder, removeCurrentPlaceholder
} from './chat-area.js'; } from './chat-area.js';
import { showToast } from './toast.js'; import { showToast } from './toast.js';
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js'; import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
@@ -529,17 +529,14 @@ async function handleRetry(): Promise<void> {
}); });
}, },
onNewIteration: (toolCalls) => { onNewIteration: (toolCalls) => {
const hasToolCalls = toolCalls && toolCalls.length > 0; removeCurrentPlaceholder();
if (retryContent || hasToolCalls) { const hasContent = !!retryContent?.trim();
if (hasContent || retryIterations === 0) {
const now = Date.now(); 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 = { const prevMsg: ChatMessage = {
role: 'assistant', content: retryContent || '', timestamp: now, role: 'assistant', content: retryContent || '', model: getSelectedModel(),
timestamp: now,
...(retryThinkContent && { think: retryThinkContent }), ...(retryThinkContent && { think: retryThinkContent }),
...(toolCallRecords?.length && { toolCalls: toolCallRecords }),
}; };
state.update(KEYS.CURRENT_SESSION, (s: any) => ({ state.update(KEYS.CURRENT_SESSION, (s: any) => ({
...s, messages: [...s.messages, prevMsg], updatedAt: Date.now() ...s, messages: [...s.messages, prevMsg], updatedAt: Date.now()
@@ -548,7 +545,6 @@ async function handleRetry(): Promise<void> {
} }
appendAssistantPlaceholder(); appendAssistantPlaceholder();
retryContent = ''; retryContent = '';
retryThinkContent = '';
retryIterations++; retryIterations++;
}, },
onThinking: (thinking) => { retryThinkContent = thinking; updateLastAssistantMessage(retryContent, thinking, null, getSelectedModel()); }, onThinking: (thinking) => { retryThinkContent = thinking; updateLastAssistantMessage(retryContent, thinking, null, getSelectedModel()); },
@@ -1090,27 +1086,7 @@ export async function sendMessage(): Promise<void> {
/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */ /** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */
function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void { function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record<string, unknown>): void {
let updated = false; // 工具卡片统一由 onDone 挂载,不在执行中更新消息记录
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();
} }
async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> { async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise<void> {
@@ -1255,10 +1231,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
let assistantContent = ''; let assistantContent = '';
let thinkContent = ''; let thinkContent = '';
let agentModeIterations = 0; // 跟踪 onNewIteration 调用次数 state.set('_currentEvalCount', 0);
state.set('_currentEvalCount', 0); // 重置累计 token 计数,防止跨会话残留
let lastIterationEvalCount = 0; // 上一轮迭代结束时的累计 token 数
let lastIterationStartTime = Date.now(); // 当前迭代开始时间
// ── 监控定时器:流式输出期间持续显示工作提示 ── // ── 监控定时器:流式输出期间持续显示工作提示 ──
// 模型生成大参数 tool_call 可能需要几分钟,期间工作空间显示提示 // 模型生成大参数 tool_call 可能需要几分钟,期间工作空间显示提示
@@ -1278,36 +1251,13 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
}); });
}, },
onNewIteration: (toolCalls) => { onNewIteration: (toolCalls) => {
// Agent Loop 新迭代开始:保存上一轮内容为独立消息,创建新 placeholder // 保存上一轮的卡片(不含工具记录,工具统一在 onDone 挂载)
// 先移除旧 placeholder(防止被 renderMessages 当作已渲染消息计数)
const oldPlaceholder = document.querySelector('#messagesContainer .message.assistant.loading') as HTMLElement;
if (oldPlaceholder) oldPlaceholder.remove();
// 有文本内容 或 有工具调用 → 保存为独立消息
const hasToolCalls = toolCalls && toolCalls.length > 0;
if (assistantContent || hasToolCalls) {
const now = Date.now();
// 计算本轮迭代的 token 和时长
const currentEvalCount = state.get<number>('_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 = { const prevMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
content: assistantContent || '', content: assistantContent || '',
timestamp: now, model: getSelectedModel(),
timestamp: Date.now(),
...(thinkContent && { think: thinkContent }), ...(thinkContent && { think: thinkContent }),
...(toolCallRecords?.length && { toolCalls: toolCallRecords }),
...(iterationEvalCount > 0 && { eval_count: iterationEvalCount }),
...(iterationDuration > 0 && { total_duration: iterationDuration }),
}; };
state.update(KEYS.CURRENT_SESSION, (session: any) => ({ state.update(KEYS.CURRENT_SESSION, (session: any) => ({
...session, ...session,
@@ -1316,14 +1266,10 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
})); }));
renderMessages(); renderMessages();
lastIterationEvalCount = currentEvalCount; removeCurrentPlaceholder();
lastIterationStartTime = Date.now();
}
// 创建新 placeholder,重置内容
appendAssistantPlaceholder(); appendAssistantPlaceholder();
assistantContent = ''; assistantContent = '';
thinkContent = ''; thinkContent = '';
agentModeIterations++;
}, },
onThinking: (thinking) => { onThinking: (thinking) => {
thinkContent = thinking; thinkContent = thinking;
@@ -1372,34 +1318,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
} }
}, },
onDone: async (finalContent, toolRecords, loopStats) => { onDone: async (finalContent, toolRecords, loopStats) => {
assistantContent = finalContent;
// 如果有多个迭代,最后一条消息已由 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 = { const assistantMsg: ChatMessage = {
role: 'assistant', role: 'assistant',
content: assistantContent || '', content: finalContent || '',
model: getSelectedModel(), model: getSelectedModel(),
timestamp: Date.now(), timestamp: Date.now(),
...(thinkContent && { think: thinkContent }), ...(thinkContent && { think: thinkContent }),
@@ -1413,9 +1334,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
messages: [...session.messages, assistantMsg], messages: [...session.messages, assistantMsg],
updatedAt: Date.now() updatedAt: Date.now()
})); }));
}
// 清掉流式 placeholder,统一从 session 渲染(单条消息,Markdown 格式)
clearMessagesDOM();
renderMessages(); renderMessages();
// ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)── // ── 保存 Plan Mode 最终进度到会话(供下一轮 Loop 注入)──
+1 -1
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.14.6</span> <span class="app-version">v0.14.7</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"/>
+2 -2
View File
@@ -1946,8 +1946,8 @@ export async function runAgentLoop(
ctx.maxLoops = newMax; ctx.maxLoops = newMax;
} }
// 非首轮迭代:通知 UI // 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知)
if (ctx.loopCount > 0 && callbacks.onNewIteration) { if (ctx.loopCount > 0 && ctx.state === S.THINKING && callbacks.onNewIteration) {
callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined); callbacks.onNewIteration(ctx.prevToolCalls.length > 0 ? ctx.prevToolCalls : undefined);
} }