From 2a9d8606bab9260d5a1b4e63e76746da0ddab39b Mon Sep 17 00:00:00 2001 From: thzxx Date: Sun, 28 Jun 2026 12:54:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.14.0=20=E7=94=9F=E4=BA=A7=E7=BA=A7?= =?UTF-8?q?=E5=BC=95=E6=93=8E+=E6=B8=B2=E6=9F=93=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine 核心优化 (P0-P3): - 智能工具重试: 永久错误(ENOENT/EACCES)立即返回, 瞬态错误指数退避 - 路径依赖检测: write→read/create→write 自动串行化, 避免并行竞态 - AbortController 集中生命周期管理, 防泄漏+竞态 - 上下文压缩: 200条硬上限 + 120条增量压缩, 防止 Ollama OOM - 流式分级超时: 空闲30s + 总300s, 区分思考/卡死 - 工具缓存: default TTL 60s, 各工具独立TTL, 不再永久缓存 - 结果截断: 2000字智能截断, 优先JSON/换行边界保护 - Plan Mode 断点续传: 中止后自动恢复未完成计划 - 幻觉检测: 中英双语规则, agent-engine+completion-gate 统一共享 - Trace 缓冲批量写入 + 3次重试 - Agent Metrics JSON/Prometheus 双格式导出 渲染优化 (P0-P2): - 流式纯文本追加, 流结束才 Markdown, 消除 O(n²) 性能瓶颈 - 终端增量 DOM appendChild, 替代全量 innerHTML 重建 - 消息 diff: Set 索引替代 DOM querySelectorAll 计数 - ANSI 解析: 正则批量替换替代逐字遍历 - 终端截断保留系统消息行 Qwen3 兼容: - system 消息始终唯一且位于首位 - SOUL.md 合并到统一 system prompt - 中途注入 system→user, 避免触发热模板 400 上下文长度控制: - 设置面板下拉(128K/256K/512K/1M), 默认128K - 模型栏显示配置值, 下拉框显示模型自身ctx - 全局硬编码 24576→131072 版本号 v0.13.8 → v0.14.0 README.md + 帮助面板全面更新 --- README.md | 60 +- package-lock.json | 4 +- package.json | 2 +- src/main/menu.ts | 2 +- src/renderer/components/chat-area.ts | 36 +- src/renderer/components/model-bar.ts | 56 +- src/renderer/components/settings-modal.ts | 15 +- src/renderer/components/workspace-panel.ts | 188 +++--- src/renderer/index.html | 17 +- src/renderer/main.ts | 18 +- src/renderer/services/agent-engine.ts | 741 +++++++++++++++------ src/renderer/services/agent-metrics.ts | 52 ++ src/renderer/services/completion-gate.ts | 73 +- src/renderer/services/context-manager.ts | 16 +- src/renderer/services/sub-agent.ts | 2 +- src/renderer/services/tool-registry.ts | 5 +- 16 files changed, 829 insertions(+), 458 deletions(-) diff --git a/README.md b/README.md index a23371c..347ce78 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- version + version electron typescript license @@ -34,26 +34,27 @@ | | 功能 | 说明 | |:---:|:---|:---| -| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),自动重试 2 次,工具去重,并行/链式执行,看门狗超时保护 | -| 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入/文件/工具 4 类关键阻断) | -| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态 | +| 🤖 | **ReAct Agent Loop** | 始终开启的唯一对话模式。8 状态机(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置),智能重试(永久错误立即返回、瞬态错误指数退避),工具去重,智能路径依赖串行化,看门狗超时保护 | +| 🛡️ | **5 层抗幻觉系统** | 提示词加固 → 任务感知 → 中途检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询) | +| 📋 | **Plan Mode** | 开关切换,AI 首先生成执行计划(Markdown 渲染确认弹窗),批准后按步骤追踪执行,plan_track 工具标记完成状态,**支持断点续传**(中止后可恢复未完成计划) | | 🔧 | **40 个内置工具** | 文件系统(16个) · 命令执行 · 联网搜索 · 浏览器控制(9个) · Git · 记忆 · 会话 · 子代理 · 系统工具 · Plan Mode 追踪 | | 🧠 | **智能记忆系统** | 三类记忆(fact / preference / rule),存储于工作空间 MEMORY.md 文件,受路径保护仅 memory 工具可访问,写入前安全扫描,容量 500 条,对话结束自动提取,严格格式校验 | | 📋 | **自定义文件** | SOUL.md(人格,不可压缩)+ AGENT.md(行为准则,内置 fallback)+ USER.md(用户画像,仅工作空间读取,不存在则不注入) | | 🌐 | **MCP 协议扩展** | JSON-RPC 2.0 over stdio,动态工具发现,Shadowing 防护 | | 🔍 | **联网搜索(双模式)** | SearXNG 元搜索引擎 JSON API(70+引擎聚合)/ 四引擎 HTML 解析(Bing+百度+搜狗+360),双模式可切换;web_fetch 支持反爬+UA切换+浏览器回退 | | 🌏 | **浏览器控制** | 打开网页 · 截图 · 执行 JS · 提取内容 · 点击 · 输入 · 滚动 · 关闭 | -| 🖥️ | **工作空间面板** | 终端(实时流式输出)+ 文件浏览器,命令安全检查 | -| 🔢 | **上下文长度自动检测** | 切换模型时自动从 model_info 获取实际支持的上下文长度,无需手动配置 | -| 🗜️ | **智能上下文管理** | 滑动窗口 + Token 自动校准 + 消息重要性评分 + LLM 结构化 JSON 压缩 | -| ⏱️ | **看门狗超时** | Agent Loop 全局超时保护(可配,默认 30min),防止模型卡死无限循环 | -| 🪝 | **Hook 系统** | 4 阶段生命周期钩子(pre_tool / post_tool / post_iteration / pre_completion),内置文件变更审计、工具结果校验 | +| 🖥️ | **工作空间面板** | 终端(增量流式输出)+ 文件浏览器,命令安全检查 | +| 🔢 | **上下文长度手动控制** | 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K,模型栏显示当前配置值,下拉框中显示每个模型自身的上下文长度 | +| 🗜️ | **智能上下文管理** | 滑动窗口 + Token 自动校准 + 消息重要性评分 + LLM 结构化 JSON 压缩,智能触发(120 条增量压缩 + 200 条硬上限) | +| ⏱️ | **智能超时保护** | Agent Loop 看门狗(可配,默认 30min)+ 流式分级超时(空闲 30s + 总 300s)+ 工具 HTTP/MCP 超时可配 | +| 🪝 | **Hook 系统** | 4 阶段生命周期钩子(pre_tool / post_tool / post_iteration / pre_completion),内置安全检查、文件去重、自动计划追踪、变更审计、结果校验 | | 👥 | **子代理委派** | spawn_task 工具,独立上下文 + 超时保护 | | 📈 | **Token 仪表盘** | 全局 + 会话统计,消耗趋势柱状图,2 秒刷新,输入/输出分色 | | 📋 | **系统提示词卡片** | 每条 AI 回复顶部折叠卡片,点击查看实际发送给模型的完整上下文 | -| 🎨 | **暖色调 UI** | 奶白 `#FAF7F2` + 珊瑚橙 `#E8734A`,长时间使用不疲劳 | +| 🎨 | **暖色调 UI** | 奶白 `#FAF7F2` + 珊瑚橙 `#E8734A`,长时间使用不疲劳,流式渲染优化 | | 🔔 | **系统托盘** | 原生托盘集成 | | 🔐 | **AES-256-GCM** | 数据加密支持 | +| 🧪 | **可观测性** | 执行轨迹缓冲批量写入 SQLite + Agent Metrics JSON/Prometheus 双格式导出 | ## 🔧 工具清单 @@ -178,13 +179,13 @@ ↓ 记忆检索 (MEMORY.md 关键词搜索) → 上下文注入 ↓ - Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 看门狗 30min) + Agent Engine (8 状态机 ReAct Loop, ≤85 轮, 智能重试, 路径依赖串行化, 看门狗+分级超时) ↓ - 5 层抗幻觉系统 (提示词 → 中途检测 → 进度锚 → 完成闸门 → 校验) + 5 层抗幻觉系统(中英双语规则)(提示词 → 中途检测 → 进度锚 → 完成闸门 → 校验) ↓ - Ollama API (流式响应,num_ctx 自动检测) + Ollama API (流式响应,num_ctx 用户可配 128K/256K/512K/1M) ↓ - Tool Registry (42 内置 + MCP 动态 + Plan Mode plan_track) + Tool Registry (40 内置 + MCP 动态 + Plan Mode plan_track) ↓ Hook 系统 (pre/post tool/iteration/completion) → 观察结果 → 反思 → 循环 / 最终回答 ``` @@ -252,7 +253,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -产出:`release/Metona Ollama Setup v0.13.8.exe` +产出:`release/Metona Ollama Setup v0.14.0.exe` ## 🛠️ 常用命令 @@ -282,26 +283,27 @@ npm run dist # 构建 Windows 安装包 | | Feature | Description | |:---:|:---|:---| -| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine (INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED), up to 85 iterations (configurable), auto-retry 2 times, tool dedup, parallel/chain execution, watchdog timeout protection | -| 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (16 rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination, injection, file, tool — 4 critical blocks) | -| 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, with plan_track tool marking completion | +| 🤖 | **ReAct Agent Loop** | Always-on, only chat mode. 8-state machine, up to 85 iterations (configurable), smart retry (permanent errors return immediately, transient errors use exponential backoff), tool dedup, path-aware dependency serialization, watchdog + tiered timeout protection | +| 🛡️ | **5-Layer Anti-Hallucination** | Prompt hardening → task awareness → mid-task detection (bilingual CN/EN rules covering all tool categories) → progress anchors → completion gate (6 checks: hallucination/injection → block, quality/efficiency → advisory) | +| 📋 | **Plan Mode** | Toggle switch. AI first generates an execution plan (Markdown-rendered confirmation dialog), then tracks step-by-step execution, **supports resume** (interrupted plans restore on next session) | | 🔧 | **40 Built-in Tools** | File system · Command · Web search · Browser · Git · Memory · Sessions · Sub-agent · System · Plan Mode tracking | | 🧠 | **Smart Memory System** | Three types (fact / preference / rule), stored in workspace MEMORY.md file, path-protected — only the memory tool can access it, pre-write security scan, 500 capacity, auto-extract on conversation end, strict format validation | | 📋 | **Custom Files** | SOUL.md (persona, never compressed) + AGENT.md (behavior rules, built-in fallback) + USER.md (user profile, workspace only, skipped if absent) | | 🌐 | **MCP Protocol Extension** | JSON-RPC 2.0 over stdio, dynamic tool discovery, Shadowing protection | | 🔍 | **Web Search (dual-mode)** | SearXNG meta-search JSON API (70+ engines) / quad-engine HTML parsing (Bing+Baidu+Sogou+360), switchable; web_fetch with auto-retry+mobile UA+SPA browser fallback | | 🌏 | **Browser Control** | Open pages · Screenshot · JS execution · Content extraction · Click · Type · Scroll · Close | -| 🖥️ | **Workspace Panel** | Terminal (real-time streaming) + file browser, command security checks | -| 🔢 | **Auto Context Length Detection** | Reads actual context_length from model metadata on model switch — no manual config needed | -| 🗜️ | **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression | -| ⏱️ | **Watchdog Timeout** | Global Agent Loop timeout protection (configurable, default 30min), prevents infinite loops from model stalls | -| 🪝 | **Hook System** | 4-phase lifecycle hooks (pre_tool / post_tool / post_iteration / pre_completion), built-in file change audit and tool result verification | +| 🖥️ | **Workspace Panel** | Terminal (incremental streaming) + file browser, command security checks | +| 🔢 | **Manual Context Length** | Settings dropdown (128K / 256K / 512K / 1M), default 128K. Model bar shows configured value, dropdown shows per-model context length | +| 🗜️ | **Smart Context Manager** | Sliding window + Token auto-calibration + message importance scoring + LLM structured JSON compression, smart triggers (120 msg incremental + 200 msg hard limit) | +| ⏱️ | **Smart Timeout Protection** | Agent Loop watchdog (configurable, default 30min) + tiered stream timeout (idle 30s + total 300s) + configurable HTTP/MCP timeouts | +| 🪝 | **Hook System** | 4-phase lifecycle hooks (pre_tool / post_tool / post_iteration / pre_completion), built-in security check, file dedup, auto plan tracking, change audit, result validation | | 👥 | **Sub-agent Delegation** | spawn_task tool, isolated context + timeout protection | | 📈 | **Token Dashboard** | Global + session stats, consumption trend bar chart, 2s refresh, input/output color-coded | | 📋 | **System Prompt Card** | Collapsible card atop each AI reply — click to inspect the full context sent to the model | -| 🎨 | **Warm-tone UI** | Cream `#FAF7F2` + coral `#E8734A`, fatigue-free for extended use | +| 🎨 | **Warm-tone UI** | Cream `#FAF7F2` + coral `#E8734A`, fatigue-free for extended use, streaming render optimized | | 🔔 | **System Tray** | Native tray integration | | 🔐 | **AES-256-GCM** | Data encryption support | +| 🧪 | **Observability** | Buffered trace persistence to SQLite + Agent Metrics JSON/Prometheus dual-format export | ## 🔧 Tool List @@ -425,13 +427,13 @@ User message → workspace SOUL.md (never compressed) → AGENT.md → USER.md ( ↓ Memory Retrieval (MEMORY.md keyword search) → Context Injection ↓ - Agent Engine (8-state ReAct Loop, ≤85 iter, watchdog 30min) + Agent Engine (8-state ReAct Loop, ≤85 iter, smart retry, path-aware serialization, watchdog+tiered timeout) ↓ - 5-Layer Anti-Hallucination (prompt → mid-task detection → anchors → gate → verification) + 5-Layer Anti-Hallucination (bilingual CN/EN rules) (prompt → mid-task detection → anchors → gate → verification) ↓ - Ollama API (Streaming Response) + Ollama API (Streaming Response, num_ctx user-configurable 128K/256K/512K/1M) ↓ - Tool Registry (42 Built-in + MCP Dynamic + Plan Mode plan_track) + Tool Registry (40 Built-in + MCP Dynamic + Plan Mode plan_track) ↓ Hook System (pre/post tool/iteration/completion) → Observation → Reflection → Loop / Final Answer ``` @@ -499,7 +501,7 @@ npm start ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist ``` -Output: `release/Metona Ollama Setup v0.13.8.exe` +Output: `release/Metona Ollama Setup v0.14.0.exe` ## 🛠️ Common Commands diff --git a/package-lock.json b/package-lock.json index 4be484b..be538be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ollama-desktop", - "version": "0.13.8", + "version": "0.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ollama-desktop", - "version": "0.13.8", + "version": "0.14.0", "license": "MIT", "dependencies": { "ffmpeg-static": "^5.2.0", diff --git a/package.json b/package.json index bcbddf4..6f5431b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ollama-desktop", - "version": "0.13.8", + "version": "0.14.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 96d7125..86b5a37 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.13.8', + message: 'Metona Ollama Desktop v0.14.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 2499236..424a252 100644 --- a/src/renderer/components/chat-area.ts +++ b/src/renderer/components/chat-area.ts @@ -108,6 +108,9 @@ export function resetAutoScroll(): void { if (scrollBtnEl) scrollBtnEl.style.display = 'none'; } +/** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */ +const _renderedMsgIndices = new Set(); + export function renderMessages(): void { const currentSession = state.get(KEYS.CURRENT_SESSION); const msgs = currentSession ? currentSession.messages : []; @@ -115,22 +118,32 @@ export function renderMessages(): void { if (msgs.length === 0) { emptyStateEl.style.display = ''; messagesContainerEl.style.display = 'none'; + _renderedMsgIndices.clear(); return; } emptyStateEl.style.display = 'none'; messagesContainerEl.style.display = ''; - // 只计算已渲染的 .message 元素(排除 loading placeholder 和其他非消息元素) - const existingCount = messagesContainerEl.querySelectorAll('.message:not(.loading)').length; - const isNewMessage = existingCount > 0 && existingCount < msgs.length; - for (let i = existingCount; i < msgs.length; i++) { - appendMessageDOM(msgs[i], i); + // 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; + } + } + + // 清理超出范围的索引(消息被 undo/retry 移除时) + for (const idx of _renderedMsgIndices) { + if (idx >= msgs.length) _renderedMsgIndices.delete(idx); } if (isNewMessage) { scrollToBottom(); - } else if (msgs.length > 0) { + } else if (msgs.length > 0 && !wasExisting) { chatAreaEl.scrollTop = chatAreaEl.scrollHeight; } } @@ -314,6 +327,8 @@ export function updateLastAssistantMessage( const lastMsg = currentPlaceholder; if (!lastMsg) return; + const isFinal = toolCalls !== undefined || timestamp !== undefined; + if (lastMsg.classList.contains('loading')) { lastMsg.classList.remove('loading'); const loadingDots = lastMsg.querySelector('.loading-dots'); @@ -351,7 +366,12 @@ export function updateLastAssistantMessage( const contentDiv = lastMsg.querySelector('.msg-content'); const safeContent = (content != null) ? String(content) : ''; if (contentDiv && safeContent) { - contentDiv.innerHTML = safeMarkdown(safeContent); + // P0-1: 流式期间纯文本追加(避免每 chunk 全量 Markdown + DOMPurify),最终渲染时才做 Markdown + if (isFinal) { + contentDiv.innerHTML = safeMarkdown(safeContent); + } else { + contentDiv.textContent = safeContent; + } } let thinkBlock = lastMsg.querySelector('.think-block'); @@ -484,11 +504,13 @@ export function appendSystemMessage(text: string, tempClass?: string): void { /** v4.1: 清除所有消息 DOM 元素(用于 undo/compress 后强制重建) */ export function clearMessagesDOM(): void { messagesContainerEl.innerHTML = ''; + _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 } export function clearMessages(): void { messagesContainerEl.innerHTML = ''; currentPlaceholder = null; + _renderedMsgIndices.clear(); // P1-1: 重置 diff 状态 } /** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */ diff --git a/src/renderer/components/model-bar.ts b/src/renderer/components/model-bar.ts index 88a7a5d..4ad8ce6 100644 --- a/src/renderer/components/model-bar.ts +++ b/src/renderer/components/model-bar.ts @@ -107,7 +107,7 @@ export async function loadModels(): Promise { } } -/** 根据能力为下拉框选项添加图标标记 */ +/** 根据能力为下拉框选项添加图标标记 + 模型自身上下文长度 */ function updateOptionIcons(): void { for (const opt of Array.from(modelSelectEl.options)) { const caps = modelCapabilityCache.get(opt.value); @@ -118,7 +118,9 @@ function updateOptionIcons(): void { if (caps.think) icons.push('🧠'); if (caps.vision) icons.push('👁️'); if (caps.tools) icons.push('🔧'); - opt.textContent = icons.length > 0 ? `${base} ${icons.join('')}` : base; + // 显示模型自身的上下文长度 + const modelCtx = caps.contextLength > 0 ? ` [${formatCtxLen(caps.contextLength)}]` : ''; + opt.textContent = `${base}${modelCtx}${icons.length > 0 ? ' ' + icons.join('') : ''}`; } } @@ -132,22 +134,22 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise const caps = info.capabilities || []; const isCompletion = caps.includes('completion'); - // 从 model_info 提取上下文长度 - let contextLength = 2048; - for (const key of Object.keys(info.model_info || {})) { - if (key.endsWith('.context_length')) { - contextLength = Number(info.model_info![key]) || 2048; - break; - } - } + // 从 model_info 提取上下文长度(仅用于显示,不再覆盖用户设置) + let contextLength = 2048; + for (const key of Object.keys(info.model_info || {})) { + if (key.endsWith('.context_length')) { + contextLength = Number(info.model_info![key]) || 2048; + break; + } + } - modelCapabilityCache.set(m.name, { - think: caps.includes('thinking'), - vision: caps.includes('vision'), - tools: caps.includes('tools'), - completion: isCompletion, - contextLength, - }); + modelCapabilityCache.set(m.name, { + think: caps.includes('thinking'), + vision: caps.includes('vision'), + tools: caps.includes('tools'), + completion: isCompletion, + contextLength, + }); if (!isCompletion) { const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`); if (opt) opt.remove(); @@ -173,8 +175,6 @@ async function checkModelCapability(modelName: string): Promise { if (modelCapabilityCache.has(modelName)) { const cached = modelCapabilityCache.get(modelName)!; - state.set(KEYS.NUM_CTX, cached.contextLength); - updateCtxTotal(cached.contextLength); applyCapability(modelName, cached); return; } @@ -206,14 +206,6 @@ async function checkModelCapability(modelName: string): Promise { modelCapabilityCache.set(modelName, caps); logDebug(`能力检测: ${modelName}`, `ctx: ${contextLength}, ` + capabilities.join(', ')); - // 自动设置上下文长度为模型实际支持的值 - state.set(KEYS.NUM_CTX, contextLength); - const db = state.get(KEYS.DB); - if (db) await db.saveSetting('numCtx', contextLength); - - // 更新模型栏上下文长度显示 - updateCtxTotal(contextLength); - applyCapability(modelName, caps); } catch (err) { logWarn(`能力检测失败: ${modelName}`, (err as Error).message); @@ -296,15 +288,21 @@ export function isToolCallingSupported(): boolean { return caps?.tools === true; } -function formatCtx(n: number): string { +export function formatCtxLen(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1024) return `${(n / 1024).toFixed(0)}K`; return String(n); } +/** 更新模型栏上下文长度显示(显示用户配置的值,而非模型自身值) */ function updateCtxTotal(contextLength: number): void { const el = document.querySelector('#ctxTotal') as HTMLElement; if (!el) return; el.style.display = 'inline-flex'; - el.textContent = formatCtx(contextLength); + el.textContent = formatCtxLen(contextLength); +} + +/** 外部调用:更新上下文长度显示 */ +export function updateCtxTotalExternal(contextLength: number): void { + updateCtxTotal(contextLength); } diff --git a/src/renderer/components/settings-modal.ts b/src/renderer/components/settings-modal.ts index ad1351a..6762c33 100644 --- a/src/renderer/components/settings-modal.ts +++ b/src/renderer/components/settings-modal.ts @@ -7,7 +7,7 @@ import { debounce } from '../utils/utils.js'; import { encryptData, decryptData } from '../services/crypto.js'; import { logSetting, logInfo, logWarn, logError, logSuccess } from '../services/log-service.js'; import { checkConnection, updateConnectionInfo, updateRunningModels } from './header.js'; -import { loadModels } from './model-bar.js'; +import { loadModels, updateCtxTotalExternal, formatCtxLen } from './model-bar.js'; import { showToast } from './toast.js'; import { showConfirm } from './prompt-modal.js'; import { OllamaAPI } from '../api/ollama.js'; @@ -63,6 +63,19 @@ export function initSettingsModal(): void { }, 500); document.querySelector('#inputMaxTurns')!.addEventListener('input', saveMaxTurns); + // ── 上下文长度选择 ── + const selectCtxLen = document.querySelector('#selectContextLength') as HTMLSelectElement; + selectCtxLen.addEventListener('change', async () => { + const db = state.get(KEYS.DB); + const val = parseInt(selectCtxLen.value); + if (!val) return; + state.set(KEYS.NUM_CTX, val); + if (db) await db.saveSetting('numCtx', val); + // 更新模型栏显示 + updateCtxTotalExternal(val); + logSetting('上下文长度', formatCtxLen(val)); + }); + // ── v5.2 超时设置 ── const inputHttpTimeout = document.querySelector('#inputHttpTimeout') as HTMLInputElement; const inputStreamTimeout = document.querySelector('#inputStreamTimeout') as HTMLInputElement; diff --git a/src/renderer/components/workspace-panel.ts b/src/renderer/components/workspace-panel.ts index 9738c42..08c08d8 100644 --- a/src/renderer/components/workspace-panel.ts +++ b/src/renderer/components/workspace-panel.ts @@ -47,6 +47,10 @@ let currentFileDir = ''; let fileTree: FileNode[] = []; let previewFile: { name: string; content: string; path: string } | null = null; let _counter = 0; +/** P0-2: 终端增量渲染 — 记录已渲染行数,避免全量重建 */ +let _termRenderedCount = 0; +/** 终端容器 DOM 缓存,避免重复 querySelector */ +let _termContainer: HTMLElement | null = null; // ── 空闲状态轮转贴士 ── const IDLE_TIPS = [ @@ -78,6 +82,8 @@ export function clearTerminalExternal(): void { terminal.running = false; } currentAiCommand = null; + _termRenderedCount = 0; // P0-2: 重置增量计数器 + if (_termContainer) _termContainer.innerHTML = ''; if (activeTab === 'terminal') renderTerminal(); updateStopBtnState(); updateHint(); @@ -107,85 +113,55 @@ const ANSI_COLORS: Record = { function ansiToHtml(raw: string): string { - // 先转义 HTML + // P1-2: 正则批量替换 ANSI,代替逐字遍历 let text = escapeHtml(raw); + + // 处理 \r 进度覆盖:删除 \r 前到上一换行之间的内容 + text = text.replace(/\r(?!\n)/g, (match, offset) => { + const prevNewline = text.lastIndexOf('\n', offset); + return prevNewline >= 0 ? '\n' : ''; + }); - // 处理 ANSI 转义序列 - // \x1b[XXm 颜色, \x1b[0m 重置, \x1b[1m 加粗, \x1b[K 清除行尾 + // 批量替换 ANSI 序列为 span(连续同色合并) let result = ''; - let openSpan = false; let i = 0; + const seqRe = /\x1b\[([\d;]*)m/g; + let lastIndex = 0; + let match: RegExpExecArray | null; - while (i < text.length) { - // 匹配 \x1b[...m 或 \x1b[...K - if (text[i] === '\x1b' && text[i + 1] === '[') { - const end = text.indexOf('m', i + 2); - const endK = text.indexOf('K', i + 2); - const seqEnd = end !== -1 && (endK === -1 || end < endK) ? end : endK; + while ((match = seqRe.exec(text)) !== null) { + // 输出序列之前的文本 + result += text.slice(lastIndex, match.index); + const codes = match[1].split(';'); + + // 关闭之前的 span(如果有) + // 简化:每个颜色序列独立包裹,不复用 span + // 查找对应的文本片段:从当前位置到下一个 ANSI 序列或文本末尾 + const nextSeq = seqRe.exec(text); + const contentStart = match.index + match[0].length; + const contentEnd = nextSeq ? nextSeq.index : text.length; + seqRe.lastIndex = nextSeq ? nextSeq.index : contentEnd; // 回退 - if (seqEnd !== -1 && seqEnd - i < 20) { - const codes = text.substring(i + 2, seqEnd).split(';'); + const content = text.slice(contentStart, contentEnd); + if (!content) { lastIndex = contentEnd; continue; } - // 忽略 \x1b[K (清除行) - if (text[seqEnd] === 'K') { - i = seqEnd + 1; - continue; - } - - if (openSpan) { - result += ''; - openSpan = false; - } - - // 检查是否有颜色代码 - let color = ''; - let bold = false; - for (const code of codes) { - if (code === '0' || code === '') { - // reset - } else if (code === '1') { - bold = true; - } else if (ANSI_COLORS[code]) { - color = ANSI_COLORS[code]; - } - } - - if (color || bold) { - const styles: string[] = []; - if (color) styles.push(`color:${color}`); - if (bold) styles.push('font-weight:bold'); - result += ``; - openSpan = true; - } - - i = seqEnd + 1; - continue; - } + let color = ''; + let bold = false; + for (const code of codes) { + if (code === '1') bold = true; + else if (code === '0' || code === '') { /* reset */ } + else if (ANSI_COLORS[code]) color = ANSI_COLORS[code]; } - // 处理回车符(ollama pull 等用 \r 更新进度) - if (text[i] === '\r' && text[i + 1] !== '\n') { - // \r 后面不是 \n → 进度更新,忽略之前的内容 - // 找到上一个 \n 或开头 - const lastNewline = result.lastIndexOf('\n'); - if (lastNewline !== -1) { - result = result.substring(0, lastNewline + 1); - } else { - // 清除当前行(通过移除到最后一个 之后的内容太复杂,简化处理) - const spanStart = result.lastIndexOf('><'); - if (spanStart !== -1) { - // 不处理,保留 - } - } - i++; - continue; + if (color || bold) { + const style = [color ? `color:${color}` : '', bold ? 'font-weight:bold' : ''].filter(Boolean).join(';'); + result += `${content}`; + } else { + result += content; } - - result += text[i]; - i++; + lastIndex = contentEnd; } - - if (openSpan) result += ''; + result += text.slice(lastIndex); return result; } @@ -346,9 +322,11 @@ function appendToolStreamOutput(command: string, type: 'stdout' | 'stderr', data } } - // 限制最大行数 + // P1-3: 限制最大行数,但保留系统消息(命令头/退出状态等) if (session.lines.length > 3000) { - session.lines = session.lines.slice(-2000); + const systemLines = session.lines.filter(l => l.type === 'system'); + const otherLines = session.lines.filter(l => l.type !== 'system'); + session.lines = [...systemLines.slice(-10), ...otherLines.slice(-1990)]; } renderTerminal(); @@ -405,9 +383,11 @@ function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data } } - // 限制最大行数 + // P1-3: 限制最大行数,保留系统消息 if (session.lines.length > 3000) { - session.lines = session.lines.slice(-2000); + const systemLines = session.lines.filter(l => l.type === 'system'); + const otherLines = session.lines.filter(l => l.type !== 'system'); + session.lines = [...systemLines.slice(-10), ...otherLines.slice(-1990)]; } renderTerminal(); @@ -465,6 +445,8 @@ function clearTerminal(): void { const session = getActiveSession(); if (!session) return; session.lines = []; + _termRenderedCount = 0; // P0-2: 重置增量计数器 + if (_termContainer) _termContainer.innerHTML = ''; renderTerminal(); } @@ -544,17 +526,20 @@ export function renderPanel(): void { } function renderTerminal(): void { - const container = document.querySelector('#wsTermOutput') as HTMLElement; + if (!_termContainer) _termContainer = document.querySelector('#wsTermOutput') as HTMLElement; + const container = _termContainer; if (!container) return; const session = getActiveSession(); if (!session) { container.innerHTML = '

无终端会话
'; + _termRenderedCount = 0; return; } // 空闲状态:显示 Logo + 信息 + 贴士 if (session.lines.length === 0 && !session.running) { + _termRenderedCount = 0; renderIdleState(container); startTipRotation(container); return; @@ -563,28 +548,49 @@ function renderTerminal(): void { // 有内容时停止贴士轮转 stopTipRotation(); - // 使用 DocumentFragment 优化渲染 - const wrapper = document.createElement('div'); - - for (const line of session.lines) { - const div = document.createElement('div'); - div.className = `ws-term-line ws-term-${line.type}`; - div.innerHTML = ansiToHtml(line.text); - wrapper.appendChild(div); + // ── P0-2: 终端增量渲染 — 只追加新行,不重建已有 DOM ── + // 检测重置(行数骤减说明被清空) + if (_termRenderedCount > session.lines.length) { + container.innerHTML = ''; + _termRenderedCount = 0; } - container.innerHTML = wrapper.innerHTML; + // 增量追加新行 + const fragment = document.createDocumentFragment(); + for (let i = _termRenderedCount; i < session.lines.length; i++) { + const line = session.lines[i]; + const div = document.createElement('div'); + div.className = `ws-term-line ws-term-${line.type}`; + div.innerHTML = ansiToHtml(line.text); // P1-2: ANSI 颜色,正则批量替换 + fragment.appendChild(div); + } + if (fragment.childNodes.length > 0) { + container.appendChild(fragment); + _termRenderedCount = session.lines.length; + } + + // 行数超限时清理旧 DOM 节点并同步计数 + if (container.children.length > 3000) { + const excess = container.children.length - 2000; + for (let i = 0; i < excess && container.firstChild; i++) { + container.removeChild(container.firstChild); + } + _termRenderedCount = 2000; + } // 自动滚动到底部 if (session.autoScroll) { container.scrollTop = container.scrollHeight; } - // 监听滚动事件 - container.onscroll = () => { - const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; - session.autoScroll = atBottom; - }; + // 监听滚动事件(只绑定一次) + if (!(container as any)._scrollBound) { + (container as any)._scrollBound = true; + container.addEventListener('scroll', () => { + const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50; + session.autoScroll = atBottom; + }); + } } /** 渲染空闲状态 */ @@ -1066,9 +1072,11 @@ function renderToolCard(tc: ToolCallRecord): string { } else if (tc.name === 'create_directory') { resultHtml = `
✅ 已创建 ${escapeHtml(String(r.path || ''))}
`; } else { - // 通用成功显示 - const summary = JSON.stringify(r).slice(0, 300); - resultHtml = `
${escapeHtml(summary)}${JSON.stringify(r).length > 300 ? '\n...' : ''}
`; + // P2-1: 通用成功显示,统一截断到 500 字符 + const maxResultChars = 500; + const raw = JSON.stringify(r); + const summary = raw.length > maxResultChars ? raw.slice(0, maxResultChars) : raw; + resultHtml = `
${escapeHtml(summary)}${raw.length > maxResultChars ? `\n... (${raw.length - maxResultChars} 字符已截断)` : ''}
`; } } else { resultHtml = `
${escapeHtml(String(r.error || '未知错误'))}
`; diff --git a/src/renderer/index.html b/src/renderer/index.html index 763edd6..25693e6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -28,7 +28,7 @@
Metona Ollama - v0.13.8 + v0.14.0

Agent Loop 最大工具调用轮数。默认 85 轮,复杂任务可能需要更多轮次。

+ + +

发送给模型的最大上下文窗口大小。默认 128K,越大可容纳更长对话但消耗更多显存/内存。

@@ -438,13 +446,12 @@