diff --git a/README.md b/README.md index 2623f0a..ef61a5a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -253,7 +253,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.16.8.exe` +产出:`release/Metona Ollama Setup v0.16.9.exe` ## 🛠️ 常用命令 @@ -501,7 +501,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.16.8.exe` +Output: `release/Metona Ollama Setup v0.16.9.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 3f6c37b..55aaeae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.16.8", + "version": "0.16.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.16.8", + "version": "0.16.9", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index bcb1e4f..3cf76c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.16.8", + "version": "0.16.9", "description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端", "main": "dist/main/main.js", "author": "thzxx", diff --git a/src/main/db/sqlite.ts b/src/main/db/sqlite.ts index 17ba5a4..83d54e0 100644 --- a/src/main/db/sqlite.ts +++ b/src/main/db/sqlite.ts @@ -327,6 +327,17 @@ export function saveSetting(key: string, value: unknown): void { persist(); } +/** P1-P2 修复:批量保存设置,只触发一次 persist,避免每个 setting 都全库写盘 */ +export function saveSettingsBatch(entries: Array<{ key: string; value: unknown }>): void { + const d = getDb(); + for (const { key, value } of entries) { + runExec(d, 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', + [key, JSON.stringify(value), Date.now()] + ); + } + persist(); // 只写盘一次 +} + export function getSetting(key: string, defaultValue: T | null = null): T { const row = queryOne(getDb(), 'SELECT value FROM settings WHERE key = ?', [key]) as { value: string } | null; if (!row) return defaultValue as T; @@ -338,19 +349,10 @@ export function getSetting(key: string, defaultValue: T | null = nu } // ─── Tool Calls CRUD ─── - -export function saveToolCall(tc: ToolCallRow): string { - runExec(getDb(), `INSERT OR REPLACE INTO tool_calls (id, message_id, session_id, tool_name, arguments, result, status, duration_ms, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [tc.id, tc.message_id, tc.session_id, tc.tool_name, tc.arguments, tc.result, tc.status, tc.duration_ms, tc.created_at] - ); - persist(); - return tc.id; -} - -export function getToolCallsBySession(sessionId: string): ToolCallRow[] { - return queryAll(getDb(), 'SELECT * FROM tool_calls WHERE session_id = ? ORDER BY created_at ASC', [sessionId]) as unknown as ToolCallRow[]; -} +// P1-P3 修复:saveToolCall 和 getToolCallsBySession 是死代码(渲染进程从未调用)。 +// tool_calls 表保留(已有数据库可能包含此表,删除表定义会导致升级时报错), +// 但删除未使用的函数和 IPC handler。 +// 工具调用记录实际通过 messages.tool_calls JSON 列存储。 // ─── Traces CRUD ─── diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b9988a3..bcb1c4d 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,8 +11,7 @@ import { showNotification } from './utils.js'; import { initDatabase, saveSession, getSession, getAllSessions, deleteSession, clearAllSessions, saveMessage, getMessagesBySession, - saveSetting, getSetting, - saveToolCall, getToolCallsBySession, + saveSetting, getSetting, saveSettingsBatch, saveTrace, getTracesBySession, exportAllSessions, importSessions, getAllSessionsTokenStats @@ -391,20 +390,17 @@ export async function setupIPC(): Promise { try { saveSetting(key, value); return { success: true }; } catch (err) { return { success: false, error: (err as Error).message }; } }); + // P1-P2 修复:批量保存设置,只触发一次 persist + ipcMain.handle('db:saveSettingsBatch', (_, entries: Array<{ key: string; value: unknown }>) => { + try { saveSettingsBatch(entries); return { success: true }; } + catch (err) { return { success: false, error: (err as Error).message }; } + }); ipcMain.handle('db:getSetting', (_, key: string, defaultValue?: unknown) => { try { return getSetting(key, defaultValue ?? null); } catch { return defaultValue ?? null; } }); - // Tool Calls - ipcMain.handle('db:saveToolCall', (_, tc) => { - try { return { success: true, id: saveToolCall(tc) }; } - catch (err) { return { success: false, error: (err as Error).message }; } - }); - ipcMain.handle('db:getToolCalls', (_, sessionId) => { - try { return getToolCallsBySession(sessionId); } - catch { return []; } - }); + // Tool Calls — P1-P3 修复:删除死代码(渲染进程从未调用,工具记录通过 messages.tool_calls 存储) // Traces ipcMain.handle('db:saveTrace', (_, trace) => { diff --git a/src/main/menu.ts b/src/main/menu.ts index e8c2706..0fb03c6 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.16.8', + message: 'Metona Ollama Desktop v0.16.9', 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 525208c..7eb463e 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -376,8 +376,6 @@ function patchIncompleteMarkdown(md: string): string { // ── 流式渲染节流状态 ── let _streamRenderPending = false; let _streamRenderContent = ''; -let _streamRenderThink: string | null = null; -let _streamRenderModel: string | undefined; // R58: 流式渲染优化 — 增量阈值 + 内容指纹,避免高频小片段冗余重渲染 let _streamLastRenderedLen = 0; // 上次渲染时的内容长度 let _streamLastRenderedHash = 0; // 上次渲染时的内容哈希(简单 DJB2) @@ -400,6 +398,9 @@ function _doStreamRender(): void { _streamRenderPending = false; const lastMsg = currentPlaceholder; if (!lastMsg) return; + // P1-R1 修复:rAF 回调执行时 currentPlaceholder 可能已被 appendAssistantPlaceholder 替换为新节点, + // 且 _streamRenderContent 已被重置为 '',此时不会误渲染。appendAssistantPlaceholder 中的 + // _streamRenderContent = '' 重置确保了旧 rAF 回调拿到的是空内容,不会渲染到新 placeholder。 // R58: 增量阈值检查 — 内容变化不足时跳过本次渲染 const contentLen = _streamRenderContent.length; @@ -551,44 +552,6 @@ export function updateLastAssistantMessage( scrollToBottom(); } -/** 实时追加工具调用卡片到当前 placeholder(直播聊天时使用) */ -export function appendToolCallCardToPlaceholder(tc: ToolCallRecord): void { - if (!currentPlaceholder) return; - let container = currentPlaceholder.querySelector('.tool-calls-container'); - if (!container) { - container = document.createElement('div'); - container.className = 'tool-calls-container'; - const msgBody = currentPlaceholder.querySelector('.msg-body'); - if (msgBody) msgBody.appendChild(container); - } - const cardDiv = document.createElement('div'); - cardDiv.innerHTML = renderToolCallCard(tc); - const card = cardDiv.firstElementChild as HTMLElement; - card.dataset.toolName = tc.name; - container.appendChild(card); - scrollToBottom(); -} - -/** 更新 placeholder 中已有的工具卡片状态(running → success/error) */ -export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void { - if (!currentPlaceholder) return; - const container = currentPlaceholder.querySelector('.tool-calls-container'); - if (!container) return; - // 找到第一个同名且状态为 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); - const newCard = cardDiv.firstElementChild as HTMLElement; - newCard.dataset.toolName = tc.name; - existing.replaceWith(newCard); - } else { - // 没找到 running 的卡片,直接追加 - appendToolCallCardToPlaceholder(tc); - } -} - export function appendAssistantPlaceholder(): void { const div = document.createElement('div'); div.className = 'message assistant loading'; @@ -606,6 +569,12 @@ export function appendAssistantPlaceholder(): void { messagesContainerEl.appendChild(div); currentPlaceholder = div; + // P0-R2 修复:新 placeholder 必须重置流式渲染状态,否则上一条的 _streamLastRenderedLen 会压制新消息渲染 + _streamLastRenderedLen = 0; + _streamLastRenderedHash = 0; + _streamRenderContent = ''; + _streamRenderPending = false; + // R34: 启动旋转提示 startLoadingHintRotation(div); @@ -654,12 +623,16 @@ export function appendSystemMessage(text: string, tempClass?: string): void { export function clearMessagesDOM(): void { messagesContainerEl.innerHTML = ''; _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 + // P1-R2 修复:重置系统提示词标记,否则清空后首条消息不再显示系统卡片 + _sysPromptRendered = false; } export function clearMessages(): void { messagesContainerEl.innerHTML = ''; currentPlaceholder = null; _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 + // P1-R2 修复:与 clearMessagesDOM 保持一致 + _sysPromptRendered = false; } /** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */ diff --git a/src/renderer/components/history-modal.ts b/src/renderer/components/history-modal.ts index bc811a8..e7e0de6 100644 --- a/src/renderer/components/history-modal.ts +++ b/src/renderer/components/history-modal.ts @@ -177,6 +177,8 @@ async function loadHistorySession(sessionId: string): Promise { logSession('加载', `${session.title} (${session.messages.length}条消息)`); state.set(KEYS.CURRENT_SESSION, session); state.set(KEYS.IS_HISTORY_VIEW, true); + // P1-P1 修复:切换会话时重置已保存消息追踪 + db.resetSavedMsgTracking(); (document.querySelector('#historyBar') as HTMLElement).style.display = ''; (document.querySelector('#inputArea') as HTMLElement).style.display = 'none'; diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index 3373c54..122197b 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, removeCurrentPlaceholder + 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'; @@ -1098,10 +1098,6 @@ export async function sendMessage(): Promise { return; } -/** 更新当前会话消息中最近一个 'running' 状态的同名工具记录 */ -function updateMessageToolRecord(toolName: string, status: 'success' | 'error', result: Record): void { - // 工具卡片统一由 onDone 挂载,不在执行中更新消息记录 -} async function sendMessageWithAgentLoop(text: string, currentSession: ChatSession, model: string): Promise { const now = Date.now(); @@ -1286,6 +1282,9 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio messages: [...session.messages, prevMsg], updatedAt: Date.now() })); + // P0-P1 修复:中间迭代消息必须持久化,否则崩溃时前几轮的 assistant 消息全丢失 + saveCurrentSession().catch(e => logError('onNewIteration 保存失败', String(e))); + // P1-R3 修复:先移除旧 placeholder → 渲染历史 → 创建新 placeholder,避免渲染空隙 removeCurrentPlaceholder(); renderMessages(); appendAssistantPlaceholder(); @@ -1433,6 +1432,25 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio if ((err as Error).message.includes('Failed to fetch') || (err as Error).message.includes('NetworkError')) { errMsg = '❌ 连接失败。请检查 Ollama 是否正在运行'; } + // P0-P2 修复:非 AbortError 异常时也保存已生成的 partial 消息,避免网络断开/Ollama 崩溃时内容丢失 + if (assistantContent) { + const partialMsg: ChatMessage = { + role: 'assistant', + content: assistantContent, + model: getSelectedModel(), + timestamp: Date.now(), + ...(thinkContent && { think: thinkContent }), + ...(currentIterationToolRecords.length > 0 && { toolCalls: [...currentIterationToolRecords] }), + interrupted: true, + }; + currentIterationToolRecords = []; + state.update(KEYS.CURRENT_SESSION, (session: any) => ({ + ...session, + messages: [...session.messages, partialMsg], + updatedAt: Date.now() + })); + await saveCurrentSession(); + } appendSystemMessage(errMsg); } } finally { diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index a338d64..d4d6e98 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -878,11 +878,15 @@ 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; + // P1-R4 修复:同名工具并发执行时 querySelector 只返回第一个匹配,可能更新到错误的卡片。 + // 改为 querySelectorAll 查找所有同名 pending/running 卡片,取最后一个(最新的那个)更新。 + // 这与 updateToolCard 函数(行 839-853)在 JS 数组层面反向遍历找最后一个 running/pending 的逻辑一致。 + const escapedName = escapeAttr(tc.name); + const allRunningCards = Array.from(container.querySelectorAll( + `.tool-call-card[data-tool-name="${escapedName}"].tool-call-running,` + + `.tool-call-card[data-tool-name="${escapedName}"].tool-call-pending` + )) as HTMLElement[]; + const runningCard = allRunningCards.length > 0 ? allRunningCards[allRunningCards.length - 1] : null; if (runningCard) { const wrapper = document.createElement('div'); diff --git a/src/renderer/db/chat-db.ts b/src/renderer/db/chat-db.ts index 29a8ffe..321ab9e 100644 --- a/src/renderer/db/chat-db.ts +++ b/src/renderer/db/chat-db.ts @@ -69,6 +69,9 @@ export class ChatDB { // ── Sessions ── + // P1-P1 修复:追踪已保存的消息 ID,避免每次 saveSession 全量重写所有消息 + private _savedMsgIds = new Set(); + async saveSession(session: ChatSession): Promise { if (isDesktop()) { const row = { @@ -82,11 +85,14 @@ export class ChatDB { updated_at: session.updatedAt }; await dbBridge().saveSession(row); - // 同步保存消息 + // P1-P1 修复:只保存新增消息(不在 _savedMsgIds 中的),避免全量重写 for (let mi = 0; mi < session.messages.length; mi++) { const msg = session.messages[mi]; + const msgId = `${session.id}_${msg.timestamp}_${msg.role}_${mi}`; + if (this._savedMsgIds.has(msgId)) continue; // 已保存,跳过 + this._savedMsgIds.add(msgId); const msgRow = { - id: `${session.id}_${msg.timestamp}_${msg.role}_${mi}`, + id: msgId, session_id: session.id, role: msg.role, content: msg.content || null, @@ -107,6 +113,11 @@ export class ChatDB { return this._idbSaveSession(session); } + /** P1-P1 修复:切换会话时重置已保存消息追踪,释放内存 */ + resetSavedMsgTracking(): void { + this._savedMsgIds.clear(); + } + async getSession(id: string): Promise { if (isDesktop()) { const row = await dbBridge().getSession(id); diff --git a/src/renderer/index.html b/src/renderer/index.html index 70915b2..b9100fa 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@

Metona Ollama - v0.16.8 + v0.16.9