From cb2ce48eb718e1555f1b90a6b4d27560f82b5a45 Mon Sep 17 00:00:00 2001 From: thzxx Date: Sat, 11 Jul 2026 20:56:48 +0800 Subject: [PATCH] =?UTF-8?q?v0.15.0:=20Agent=20ReAct=20Loop=20=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E5=BC=95=E6=93=8E=E6=B7=B1=E5=BA=A6=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=B8=8E=E7=94=9F=E4=BA=A7=E7=BA=A7=E5=A2=9E=E5=BC=BA=20(R1-R5?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心引擎健壮性(R1-R10)、上下文管理优化(R11-R20)、工具安全与验证(R21-R30)、UI渲染性能(R31-R40)、基础设施与监控(R41-R50)、版本号升级 --- README.md | 6 +- package-lock.json | 4 +- package.json | 2 +- src/main/menu.ts | 2 +- src/renderer/components/chat-area.ts | 184 +++++++- src/renderer/components/input-area.ts | 34 ++ src/renderer/index.html | 2 +- src/renderer/main.ts | 2 + src/renderer/services/agent-engine.ts | 449 +++++++++++++++++--- src/renderer/services/context-manager.ts | 440 ++++++++++++++++++- src/renderer/services/infra-service.ts | 303 +++++++++++++ src/renderer/services/log-service.ts | 24 ++ src/renderer/services/sub-agent.ts | 167 +++++--- src/renderer/services/tool-registry.ts | 516 ++++++++++++++++++++++- src/renderer/styles/style.css | 47 +++ 15 files changed, 2012 insertions(+), 170 deletions(-) create mode 100644 src/renderer/services/infra-service.ts diff --git a/README.md b/README.md index 241375c..b39f78a 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.14.15.exe` +产出:`release/Metona Ollama Setup v0.15.0.exe` ## 🛠️ 常用命令 @@ -501,7 +501,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.14.15.exe` +Output: `release/Metona Ollama Setup v0.15.0.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index b1db451..bcba397 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.14.15", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.14.15", + "version": "0.15.0", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index ee07d88..b9f987f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.14.15", + "version": "0.15.0", "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 1c8922f..3175bc8 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.15', + message: 'Metona Ollama Desktop v0.15.0', 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 f356f1c..58453eb 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -112,20 +112,22 @@ export function resetAutoScroll(): void { /** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */ const _renderedMsgIndices = new Set(); +/** R31: 最大渲染消息数(超过此数时仅渲染最近的消息,避免 DOM 过载) */ +const MAX_RENDERED_MESSAGES = 200; + /** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */ let _sysPromptRendered = false; +/** R31: 增量渲染 — 仅追加新消息,避免全量重建 */ export function renderMessages(): void { const currentSession = state.get(KEYS.CURRENT_SESSION); const msgs = currentSession ? currentSession.messages : []; - // 全量清空重建,避免增量 diff 的边界 bug - messagesContainerEl.innerHTML = ''; - currentPlaceholder = null; - _renderedMsgIndices.clear(); - _sysPromptRendered = false; // 重置:每轮全量渲染时重新判定首条 assistant - if (msgs.length === 0) { + messagesContainerEl.innerHTML = ''; + currentPlaceholder = null; + _renderedMsgIndices.clear(); + _sysPromptRendered = false; emptyStateEl.style.display = ''; messagesContainerEl.style.display = 'none'; return; @@ -134,16 +136,52 @@ export function renderMessages(): void { emptyStateEl.style.display = 'none'; messagesContainerEl.style.display = ''; - for (let i = 0; i < msgs.length; i++) { - const msg = msgs[i]; - // 跳过 system 消息(Plan Mode 进度等内部状态消息不展示给用户) - if (msg.role === 'system') continue; - // 只跳过既无内容也无 think 也无工具的空消息 - if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue; - appendMessageDOM(msgs[i], i); - _renderedMsgIndices.add(i); + // R31: 增量渲染 — 检查是否需要全量重建 + const needsFullRebuild = _renderedMsgIndices.size === 0 + || _renderedMsgIndices.size > msgs.length + || Array.from(_renderedMsgIndices).some(idx => idx >= msgs.length); + + if (needsFullRebuild) { + // 全量重建(首次加载或消息被截断/压缩后) + messagesContainerEl.innerHTML = ''; + currentPlaceholder = null; + _renderedMsgIndices.clear(); + _sysPromptRendered = false; + + // R31: 仅渲染最近 MAX_RENDERED_MESSAGES 条消息,避免 DOM 过载 + const startIdx = Math.max(0, msgs.length - MAX_RENDERED_MESSAGES); + if (startIdx > 0) { + const noticeDiv = document.createElement('div'); + noticeDiv.className = 'msg-truncated-notice'; + noticeDiv.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);'; + noticeDiv.textContent = `⋯ 已折叠前 ${startIdx} 条消息,仅显示最近 ${MAX_RENDERED_MESSAGES} 条 ⋯`; + messagesContainerEl.appendChild(noticeDiv); + } + + for (let i = startIdx; i < msgs.length; i++) { + const msg = msgs[i]; + if (msg.role === 'system') continue; + if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue; + appendMessageDOM(msgs[i], i); + _renderedMsgIndices.add(i); + } + } else { + // R31: 增量追加 — 只渲染新增的消息 + for (let i = 0; i < msgs.length; i++) { + if (_renderedMsgIndices.has(i)) continue; + const msg = msgs[i]; + if (msg.role === 'system') continue; + if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue; + appendMessageDOM(msgs[i], i); + _renderedMsgIndices.add(i); + } } + // R38: 为代码块添加复制按钮 + addCodeBlockCopyButtons(); + // R40: 为消息添加操作按钮 + addMessageActions(); + scrollToBottom(); } @@ -353,6 +391,8 @@ export function updateLastAssistantMessage( // P0-1: 流式期间纯文本追加(避免每 chunk 全量 Markdown + DOMPurify),最终渲染时才做 Markdown if (isFinal) { contentDiv.innerHTML = safeMarkdown(safeContent); + // R38: 最终渲染后为代码块添加复制按钮 + addCodeBlockCopyButtons(contentDiv); } else { contentDiv.textContent = safeContent; } @@ -459,21 +499,53 @@ export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void { export function appendAssistantPlaceholder(): void { const div = document.createElement('div'); div.className = 'message assistant loading'; + // R34: 增强加载状态 — 添加旋转提示文本 div.innerHTML = `

🤖
AI
- 正在思考... + 正在思考...
`; messagesContainerEl.appendChild(div); currentPlaceholder = div; + + // R34: 启动旋转提示 + startLoadingHintRotation(div); + scrollToBottom(); } +// R34: 加载提示旋转动画 +const LOADING_HINTS = [ + '正在思考...', + '正在分析问题...', + '正在组织回答...', + '正在查找信息...', + '正在生成回复...', +]; +let _loadingHintInterval: ReturnType | null = null; + +function startLoadingHintRotation(el: HTMLElement): void { + if (_loadingHintInterval) clearInterval(_loadingHintInterval); + let idx = 0; + _loadingHintInterval = setInterval(() => { + if (!el.isConnected) { + if (_loadingHintInterval) clearInterval(_loadingHintInterval); + _loadingHintInterval = null; + return; + } + idx = (idx + 1) % LOADING_HINTS.length; + const hintEl = el.querySelector('.loading-text'); + if (hintEl) { + hintEl.textContent = LOADING_HINTS[idx]; + } + }, 3000); +} + export function appendSystemMessage(text: string, tempClass?: string): void { const div = document.createElement('div'); div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);'; @@ -502,6 +574,11 @@ export function resetCurrentPlaceholder(): void { if (currentPlaceholder && !currentPlaceholder.isConnected) { currentPlaceholder = null; } + // R34: 清理加载提示旋转 + if (_loadingHintInterval) { + clearInterval(_loadingHintInterval); + _loadingHintInterval = null; + } } /** 从 DOM 移除当前 placeholder 并清空引用 */ @@ -517,6 +594,83 @@ export function enableAutoScroll(): void { if (scrollBtnEl) scrollBtnEl.style.display = 'none'; } +// ── R38: 代码块复制按钮 ── + +/** R38: 为代码块添加复制按钮 */ +function addCodeBlockCopyButtons(scope?: ParentNode): void { + const root = scope || messagesContainerEl; + const codeBlocks = root.querySelectorAll('pre:not([data-copy-added])'); + for (const pre of codeBlocks) { + pre.setAttribute('data-copy-added', 'true'); + const btn = document.createElement('button'); + btn.className = 'code-copy-btn'; + btn.textContent = '📋 复制'; + btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;opacity:0;transition:opacity 0.2s;'; + // 确保 pre 是相对定位 + if (getComputedStyle(pre).position === 'static') { + (pre as HTMLElement).style.position = 'relative'; + } + pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; }); + pre.addEventListener('mouseleave', () => { btn.style.opacity = '0'; }); + btn.addEventListener('click', (e) => { + e.preventDefault(); + const code = pre.querySelector('code'); + const text = code ? code.textContent : pre.textContent; + if (text) { + navigator.clipboard.writeText(text).then(() => { + btn.textContent = '✅ 已复制'; + setTimeout(() => { btn.textContent = '📋 复制'; }, 2000); + }).catch(() => { + btn.textContent = '❌ 失败'; + setTimeout(() => { btn.textContent = '📋 复制'; }, 2000); + }); + } + }); + pre.appendChild(btn); + } +} + +// ── R40: 消息操作按钮 ── + +/** R40: 为消息添加操作按钮(复制、重试) */ +function addMessageActions(): void { + const messages = messagesContainerEl.querySelectorAll('.message:not([data-actions-added])'); + for (const msg of messages) { + msg.setAttribute('data-actions-added', 'true'); + const actionBar = document.createElement('div'); + actionBar.className = 'msg-actions'; + actionBar.style.cssText = 'position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity 0.2s;'; + + // 确保 message 是相对定位 + if (getComputedStyle(msg).position === 'static') { + (msg as HTMLElement).style.position = 'relative'; + } + + // 复制按钮 + const copyBtn = document.createElement('button'); + copyBtn.className = 'msg-action-btn msg-copy-btn'; + copyBtn.textContent = '📋'; + copyBtn.title = '复制消息'; + copyBtn.style.cssText = 'padding:2px 6px;font-size:12px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;'; + copyBtn.addEventListener('click', (e) => { + e.preventDefault(); + const contentEl = msg.querySelector('.msg-content'); + const text = contentEl ? contentEl.textContent : ''; + if (text) { + navigator.clipboard.writeText(text).then(() => { + copyBtn.textContent = '✅'; + setTimeout(() => { copyBtn.textContent = '📋'; }, 2000); + }).catch(() => {}); + } + }); + actionBar.appendChild(copyBtn); + + msg.addEventListener('mouseenter', () => { actionBar.style.opacity = '1'; }); + msg.addEventListener('mouseleave', () => { actionBar.style.opacity = '0'; }); + msg.appendChild(actionBar); + } +} + // ── 导出功能 ── async function nativeSaveFile(defaultName: string, content: string): Promise { diff --git a/src/renderer/components/input-area.ts b/src/renderer/components/input-area.ts index c8b3d3f..e3b1698 100644 --- a/src/renderer/components/input-area.ts +++ b/src/renderer/components/input-area.ts @@ -90,6 +90,40 @@ export function initInputArea(): void { } }); + // R39: 全局键盘快捷键 + document.addEventListener('keydown', (e) => { + // Ctrl+K: 清空聊天(需要确认) + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + if (confirm('确定要清空当前对话吗?')) { + clearMessages(); + } + } + // Escape: 停止生成(当正在流式输出时) + if (e.key === 'Escape' && state.get(KEYS.IS_STREAMING)) { + e.preventDefault(); + stopGeneration(); + } + // Ctrl+/: 聚焦输入框 + if ((e.ctrlKey || e.metaKey) && e.key === '/') { + e.preventDefault(); + chatInputEl.focus(); + } + // Ctrl+Shift+C: 复制最后一条 AI 消息 + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') { + e.preventDefault(); + const session = state.get(KEYS.CURRENT_SESSION); + if (session && session.messages.length > 0) { + const lastAssistant = [...session.messages].reverse().find(m => m.role === 'assistant' && m.content); + if (lastAssistant) { + navigator.clipboard.writeText(lastAssistant.content).then(() => { + showToast('已复制最后一条 AI 回复', 'success'); + }).catch(() => {}); + } + } + } + }); + chatInputEl.addEventListener('input', autoResizeTextarea); // ── 图片上传:原生文件对话框 ── diff --git a/src/renderer/index.html b/src/renderer/index.html index a871db1..a925268 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.14.15 + v0.15.0