diff --git a/README.md b/README.md index 9ff9894..5ed6ca7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -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 `

${icon} @@ -977,3 +1064,25 @@ export function switchToTab(tab: 'terminal' | 'tools' | 'files'): void { renderPanel(); } +/** 检查工作空间是否有 pending 或 running 的工具卡片 */ +export function hasActiveCards(): boolean { + return toolCards.some(c => c.status === 'pending' || c.status === 'running'); +} + +/** 在工具页签显示工作提示(AI 正在处理中,但还没有具体的工具调用) */ +export function showWorkingHint(text: string): void { + // 只在工具页签空闲时显示提示,避免覆盖已有的工具卡片 + if (hasActiveCards()) return; + if (activeTab !== 'tools') return; + const hint = document.querySelector('#wsToolsHint') as HTMLElement; + if (hint) { + hint.textContent = text; + hint.classList.add('running'); + } +} + +/** 清除工作提示,恢复显示 updateToolsHint */ +export function clearWorkingHint(): void { + updateToolsHint(); +} + diff --git a/src/renderer/index.html b/src/renderer/index.html index 8ec3860..8fffe25 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.11.2 + v0.11.3 - + +
-
+