feat: v0.14.0 生产级引擎+渲染全面优化

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<number> 索引替代 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 + 帮助面板全面更新
This commit is contained in:
thzxx
2026-06-28 12:54:56 +08:00
parent e62f4fbcad
commit 2a9d8606ba
16 changed files with 829 additions and 458 deletions
+31 -29
View File
@@ -14,7 +14,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.13.8-E8734A?style=flat-square" alt="version">
<img src="https://img.shields.io/badge/version-v0.14.0-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/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">
@@ -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 API70+引擎聚合)/ 四引擎 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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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()
});
+29 -7
View File
@@ -108,6 +108,9 @@ export function resetAutoScroll(): void {
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
}
/** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */
const _renderedMsgIndices = new Set<number>();
export function renderMessages(): void {
const currentSession = state.get<ChatSession | null>(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 从外部被移除时调用) */
+27 -29
View File
@@ -107,7 +107,7 @@ export async function loadModels(): Promise<void> {
}
}
/** 根据能力为下拉框选项添加图标标记 */
/** 根据能力为下拉框选项添加图标标记 + 模型自身上下文长度 */
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<void>
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<void> {
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<void> {
modelCapabilityCache.set(modelName, caps);
logDebug(`能力检测: ${modelName}`, `ctx: ${contextLength}, ` + capabilities.join(', '));
// 自动设置上下文长度为模型实际支持的值
state.set(KEYS.NUM_CTX, contextLength);
const db = state.get<any>(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);
}
+14 -1
View File
@@ -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<ChatDB | null>(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;
+97 -89
View File
@@ -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<string, string> = {
function ansiToHtml(raw: string): string {
// 先转义 HTML
// P1-2: 正则批量替换 ANSI,代替逐字遍历
let text = escapeHtml(raw);
// 处理 ANSI 转义序列
// \x1b[XXm 颜色, \x1b[0m 重置, \x1b[1m 加粗, \x1b[K 清除行尾
// 处理 \r 进度覆盖:删除 \r 前到上一换行之间的内容
text = text.replace(/\r(?!\n)/g, (match, offset) => {
const prevNewline = text.lastIndexOf('\n', offset);
return prevNewline >= 0 ? '\n' : '';
});
// 批量替换 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(';');
if (seqEnd !== -1 && seqEnd - i < 20) {
const codes = text.substring(i + 2, seqEnd).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; // 回退
// 忽略 \x1b[K (清除行)
if (text[seqEnd] === 'K') {
i = seqEnd + 1;
continue;
}
const content = text.slice(contentStart, contentEnd);
if (!content) { lastIndex = contentEnd; continue; }
if (openSpan) {
result += '</span>';
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 += `<span style="${styles.join(';')}">`;
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 {
// 清除当前行(通过移除到最后一个 <span> 之后的内容太复杂,简化处理)
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 += `<span style="${style}">${content}</span>`;
} else {
result += content;
}
result += text[i];
i++;
lastIndex = contentEnd;
}
if (openSpan) result += '</span>';
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 = '<div class="ws-term-placeholder">无终端会话</div>';
_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 = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
} else {
// 通用成功显示
const summary = JSON.stringify(r).slice(0, 300);
resultHtml = `<pre class="tool-result-content">${escapeHtml(summary)}${JSON.stringify(r).length > 300 ? '\n...' : ''}</pre>`;
// P2-1: 通用成功显示,统一截断到 500 字符
const maxResultChars = 500;
const raw = JSON.stringify(r);
const summary = raw.length > maxResultChars ? raw.slice(0, maxResultChars) : raw;
resultHtml = `<pre class="tool-result-content">${escapeHtml(summary)}${raw.length > maxResultChars ? `\n... (${raw.length - maxResultChars} 字符已截断)` : ''}</pre>`;
}
} else {
resultHtml = `<div class="tool-result-error">${escapeHtml(String(r.error || '未知错误'))}</div>`;
+12 -5
View File
@@ -28,7 +28,7 @@
<div class="header-left">
<span class="logo">🦙</span>
<span class="app-title">Metona Ollama</span>
<span class="app-version">v0.13.8</span>
<span class="app-version">v0.14.0</span>
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
<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"/>
@@ -304,6 +304,14 @@
<span class="text-muted" style="white-space:nowrap;"></span>
</div>
<p class="text-muted" style="margin-top:4px;font-size:11px;">Agent Loop 最大工具调用轮数。默认 85 轮,复杂任务可能需要更多轮次。</p>
<label class="setting-label" style="margin-top:12px;">上下文长度(Context Length</label>
<select class="setting-input" id="selectContextLength" style="margin-bottom:0;">
<option value="131072">128K</option>
<option value="262144">256K</option>
<option value="524288">512K</option>
<option value="1048576">1M</option>
</select>
<p class="text-muted" style="margin-top:4px;font-size:11px;">发送给模型的最大上下文窗口大小。默认 128K,越大可容纳更长对话但消耗更多显存/内存。</p>
</div>
<div class="setting-group">
<label class="setting-label">⏱️ 超时设置(Timeout</label>
@@ -438,13 +446,12 @@
</div>
<div class="modal-body">
<div class="help-section"><h4>🚀 快速开始</h4><ol><li>确保 Ollama 已启动(默认 <code>http://127.0.0.1:11434</code>,可在设置中修改)</li><li>顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools</li><li>输入消息,按 <kbd>Enter</kbd> 发送,<kbd>Shift+Enter</kbd> 换行</li></ol></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度自动检测</strong>切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>💬 聊天功能</h4><ul><li><strong>流式回复</strong> — 实时打字效果,随时点 ■ 停止</li><li><strong>Think 推理</strong> — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)</li><li><strong>上下文长度手动控制</strong>设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度</li><li><strong>Qwen3 兼容</strong> — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误</li><li><strong>多模态</strong> — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注</li><li><strong>文件分析</strong> — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断</li><li><strong>AI 回复顶部</strong> — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文</li></ul></div>
<div class="help-section"><h4>🔧 Tool Calling(始终开启)</h4><ul><li>所有消息均通过 <strong>Agent Loop 自主调用本地工具</strong>,像一个本地 Agent,无普通聊天模式</li><li><strong>40+1 个工具</strong>,分为 9 类:<ul><li><strong>文件系统</strong>16 个):read_file / write_file / list_directory / search_files / create_directory / delete_file / move_file / copy_file / edit_file / get_file_info / tree / download_file / diff_files / replace_in_files / read_multiple_files / compress</li><li><strong>命令执行</strong>1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)</li><li><strong>联网搜索</strong>2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)</li><li><strong>Git</strong>(1 个):git17 个子操作,push/pull/clone 内置60-120s超时保护)</li><li><strong>浏览器控制</strong>9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close</li><li><strong>记忆 & 会话</strong>(5 个):memory(统一记忆管理,5 个 action/ session_list / session_read / spawn_task / plan_track</li><li><strong>系统工具</strong>6 个):datetime / calculator / random / uuid / json_format / hash</li></ul></li><li>read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读</li><li>write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB</li><li>edit_file 支持 use_regex 正则替换</li><li>search_files 支持正则表达式搜索(use_regex=true</li><li>browser_screenshot 支持全页面截图+元素截图</li><li>browser_extract 支持CSS选择器提取特定区域</li><li>new browser_wait 等待元素出现或定时等待</li><li><code>run_command</code> 支持三模式切换:自动/需确认/禁用</li><li>危险命令(<code>rm -rf</code><code>mkfs</code>、反弹 shell 等)和系统路径(<code>/etc</code><code>~/.ssh</code> 等)被自动拦截</li><li>工具调用以<strong>可视化卡片</strong>展示状态(pending → running → success/error),在工作空间🔧工具页签中显示</li><li>默认最大 <strong>85 轮</strong>工具调用循环(设置面板可调),上下文使用率>80%时自动缩减到3轮</li><li>独立工具自动<strong>并行执行</strong>(16个只读工具加入并行白名单),有依赖关系的工具串行执行</li></ul></div>
<div class="help-section"><h4>🧠 Agent 记忆系统</h4><ul><li>记忆存储在工作空间 <strong>MEMORY.md</strong> 文件,受严格路径保护,仅 <code>memory</code> 工具可读写</li><li>新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你</li><li>对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)</li><li><strong>memory 工具</strong>5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)</li><li>点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目</li><li><strong>记忆容量上限 500 条</strong>,超限时自动清理低价值条目(规则类型受保护)</li><li>写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)</li><li>应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建</li></ul></div>
<div class="help-section"><h4>📋 Plan Mode(计划模式)</h4><ul><li>点击输入框上方 📋 按钮开启 <strong>Plan Mode</strong>(开关式)</li><li>开启后,AI <strong>先生成执行计划</strong>(Markdown 渲染弹窗),用户批准后才开始执行</li><li>计划批准后自动初始化追踪器,每个步骤完成后调用 <code>plan_track</code> 工具标记进度</li><li>系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成</li><li><strong>多步骤任务防遗忘</strong>:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒</li><li>关闭 Plan Mode 后恢复正常 Agent Loop 模式</li><li><strong>仅 Plan 模式</strong> 下 plan_track 工具可见,避免污染普通模式的工具列表</li></ul></div>
<div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 任务感知 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入关键阻断</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复</li><li><strong>16 条幻觉检测规则</strong>:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>消息数超过阈值时强制触发压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发</li><li><strong>流式调用超时保护</strong>可配置超时(默认300sOllama 死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>Final Answer 智能检测</strong>:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新</li><li><strong>自动子任务拆解</strong>:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理</li><li><strong>跨会话工具上下文</strong>:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作</li>
<li><strong>旧工具结果自动截断</strong>:超过10轮的工具结果自动截断到500字符,控制上下文膨胀</li></ul></div>
<div class="help-section"><h4>🛡️ 抗幻觉 & 稳定性</h4><ul><li><strong>5 层防御体系</strong>:系统提示词加固 → 任务感知 → 中途幻觉检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询</li><li><strong>完成闸门</strong>:对话结束前自动审查 AI 回复,检测工具幻觉和 prompt injection,阻断有问题的回复</li><li><strong>中英双语检测规则</strong>:覆盖中英文模型输出,防止 AI 声称执行了未调用的工具</li><li><strong>智能重试机制</strong>:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次</li><li><strong>看门狗超时</strong>:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止</li><li><strong>流式分级超时</strong>:空闲 30s 无新内容自动中止 + 总 300s 上限,防止模型假死</li><li><strong>中止保护</strong>:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效</li><li><strong>上下文硬上限</strong>200 条消息强制压缩 + 120 条增量压缩,防止 OOM</li></ul></div>
<div class="help-section"><h4>🤖 Agent Loop 增强</h4><ul><li><strong>🎬 视频上传</strong>:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系</li><li><strong>8 状态机</strong>INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理</li><li><strong>智能上下文压缩</strong>:滑动窗口 + LLM结构化JSON摘要,120条触发增量压缩 + 200条硬上限强制压缩</li><li><strong>流式分级超时</strong>空闲 30s 无新 token 自动中止 + 总 300s 上限Ollama 死不再永久阻塞</li><li><strong>HTTP/MCP 超时可配</strong>:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时</li><li><strong>智能工具调度</strong>:路径依赖检测自动串行化(write→read/create→write),只读工具并行执行</li><li><strong>工具缓存 TTL</strong>:搜索5分钟/网页10分钟/文件30分钟/git 30秒,默认60秒过期,不再永久缓存</li><li><strong>Plan Mode 断点续传</strong>:中止后可恢复未完成的计划,进度自动保存</li><li><strong>Token 感知迭代预算</strong>:上下文使用率>80%时自动缩减剩余轮次到3轮</li><li><strong>跨会话工具上下文</strong>:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作</li><li><strong>旧工具结果智能截断</strong>:超过10轮后自动截断到2000字符,优先在JSON边界处截断</li></ul></div>
<div class="help-section"><h4>🔌 MCPModel Context Protocol</h4><ul><li>支持连接外部 MCP Server,动态扩展工具能力</li><li>设置面板可添加/启用/禁用/删除 MCP 服务器</li><li>MCP 工具以 <code>mcp_{server}__{tool}</code> 前缀注册,与内置工具统一调度</li><li>启动时自动连接已启用的 MCP 服务器</li></ul></div>
<div class="help-section"><h4>🔍 SearXNG 元搜索引擎</h4><ul><li>点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例</li><li><strong>JSON 模式</strong>:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析</li><li><strong>HTML 模式</strong>:获取原始搜索结果页面,交由 AI 自行分析提取信息</li><li>支持认证 KeyHTTP Header Authorization),保护私有实例</li><li>启用后替代内置四引擎方案;关闭即回退,无缝切换</li><li>所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置</li></ul></div>
<div class="help-section"><h4>📋 自定义文件(SOUL.md / AGENT.md / USER.md</h4><ul><li>在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效</li><li><strong>SOUL.md</strong> — AI 身份、性格、行为准则(<strong>永远不可被压缩</strong>,注入为最高优先级系统提示词)</li><li><strong>AGENT.md</strong> — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)</li><li><strong>USER.md</strong> — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考(仅工作空间,无内置默认)</li><li>可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文</li><li>SOUL.md / AGENT.md 删除工作空间中文件可恢复为内置默认版本;USER.md 无内置版,不存在则不注入</li></ul></div>
+15 -3
View File
@@ -14,7 +14,7 @@ import { generateId } from './utils/utils.js';
import { initToast, showToast } from './components/toast.js';
import { initLightbox, closeLightbox } from './components/lightbox.js';
import { initHeader, checkConnection } from './components/header.js';
import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
import { initModelBar, loadModels, setSelectedModel, updateCtxTotalExternal } from './components/model-bar.js';
import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
import { initInputArea } from './components/input-area.js';
import { clearCtxRemain } from './components/input-area.js';
@@ -315,7 +315,7 @@ async function init(): Promise<void> {
state.set(KEYS.CURRENT_SESSION, createNewSession());
state.set(KEYS.IS_STREAMING, false);
state.set(KEYS.IS_HISTORY_VIEW, false);
state.set(KEYS.NUM_CTX, 24576);
state.set(KEYS.NUM_CTX, 131072); // 默认 128K
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
state.set('toolCallingEnabled', true);
@@ -366,12 +366,24 @@ async function init(): Promise<void> {
await db.saveSetting('selectedModel', savedModel);
}
const numCtx = await db.getSetting('numCtx', 24576);
const numCtx = await db.getSetting<number>('numCtx', 131072);
const temperature = await db.getSetting('temperature', 0.7);
// 如果首次使用(数据库中无此设置),保存默认 128K
const hasNumCtx = await db.getSetting<number | null>('numCtx', null);
if (hasNumCtx === null) {
await db.saveSetting('numCtx', 131072);
}
state.set(KEYS.NUM_CTX, numCtx);
state.set('temperature', temperature);
// 反显设置面板的下拉框
const ctxSelect = document.querySelector('#selectContextLength') as HTMLSelectElement;
if (ctxSelect) ctxSelect.value = String(numCtx);
// 更新模型栏上下文显示
updateCtxTotalExternal(numCtx);
(document.querySelector('#inputTemperature') as HTMLInputElement).value = String(temperature);
document.querySelector('#tempValue')!.textContent = parseFloat(String(temperature)).toFixed(1);
File diff suppressed because it is too large Load Diff
+52
View File
@@ -261,3 +261,55 @@ export function formatMetricsReport(metrics: AgentMetrics): string {
`高频错误: ${metrics.frequentErrors.length > 0 ? metrics.frequentErrors.map(e => `${e.pattern}(${e.count}次)`).join(', ') : '无'}`,
].join('\n');
}
/** P3-14: 导出结构化指标(JSON 格式,兼容 Prometheus/OpenTelemetry 采集器) */
export function exportMetricsJSON(): string {
const metrics = aggregateMetrics();
return JSON.stringify({
timestamp: new Date().toISOString(),
application: 'metona-ollama-desktop',
version: '0.13.8',
metrics: {
sessions_total: metrics.totalSessions,
avg_iterations_per_task: metrics.avgIterationsPerTask,
tool_success_rate: parseFloat(metrics.toolSuccessRate.toFixed(4)),
completion_gate_pass_rate: parseFloat(metrics.avgCompletionScore.toFixed(4)),
token_efficiency: parseFloat(metrics.tokenEfficiency.toFixed(4)),
top_errors: metrics.frequentErrors.slice(0, 5),
improvement_suggestions: generateImprovementSuggestions().slice(0, 3).map(s => ({
pattern: s.pattern,
severity: s.severity,
suggestion: s.suggestion,
})),
},
collected_at: metrics.collectedAt,
}, null, 2);
}
/** P3-14: 导出 Prometheus 文本格式(用于 /metrics 端点) */
export function exportMetricsPrometheus(): string {
const metrics = aggregateMetrics();
const lines: string[] = [];
lines.push('# HELP metona_sessions_total Total number of sessions');
lines.push('# TYPE metona_sessions_total gauge');
lines.push(`metona_sessions_total ${metrics.totalSessions}`);
lines.push('# HELP metona_avg_iterations Average iterations per task');
lines.push('# TYPE metona_avg_iterations gauge');
lines.push(`metona_avg_iterations ${metrics.avgIterationsPerTask}`);
lines.push('# HELP metona_tool_success_rate Tool call success rate (0-1)');
lines.push('# TYPE metona_tool_success_rate gauge');
lines.push(`metona_tool_success_rate ${metrics.toolSuccessRate.toFixed(4)}`);
lines.push('# HELP metona_completion_gate_pass_rate Completion gate pass rate (0-1)');
lines.push('# TYPE metona_completion_gate_pass_rate gauge');
lines.push(`metona_completion_gate_pass_rate ${metrics.avgCompletionScore.toFixed(4)}`);
lines.push('# HELP metona_token_efficiency Token output/input efficiency ratio');
lines.push('# TYPE metona_token_efficiency gauge');
lines.push(`metona_token_efficiency ${metrics.tokenEfficiency.toFixed(4)}`);
for (const err of metrics.frequentErrors.slice(0, 5)) {
const safeName = err.pattern.replace(/[^a-zA-Z0-9_]/g, '_');
lines.push(`# HELP metona_error_${safeName} Frequency of error pattern`);
lines.push(`# TYPE metona_error_${safeName} counter`);
lines.push(`metona_error_${safeName} ${err.count}`);
}
return lines.join('\n') + '\n';
}
+4 -69
View File
@@ -10,6 +10,7 @@
import { logWarn, logInfo } from './log-service.js';
import { estimateTokens } from './context-manager.js';
import { HALLUCINATION_RULES } from './agent-engine.js';
import type { LoopContext, CompletionCheck } from '../types.js';
// ═══════════════════════════════════════════════════════════════
@@ -138,78 +139,12 @@ const toolHallucinationCheck: CompletionCheck = {
const calledTools = new Set(ctx.allToolRecords.map(r => r.name));
const content = ctx.content;
// ── 统一的"声称 → 必需工具"映射表(覆盖全部 40 个工具)──
const CLAIM_RULES: Array<{ patterns: RegExp[]; requiredTools: string[]; label: string }> = [
// 文件写入
{ patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
/文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/],
requiredTools: ['write_file'], label: '写入/创建文件' },
// 文件读取(仅匹配明确声称调用了 read_file 工具的场景)
{ patterns: [/用read_file读取了/, /调用read_file.*读取/, /通过read_file工具/,
/使用read_file.*查看了/, /read_file.*工具.*读取了/],
requiredTools: ['read_file', 'read_multiple_files'], label: '读取文件' },
// 文件编辑
{ patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/,
/修改了.*文件/, /更新了.*文件/],
requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' },
// 文件删除
{ patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/],
requiredTools: ['delete_file'], label: '删除文件' },
// 文件移动/复制/重命名
{ patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/],
requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' },
// 目录浏览
{ patterns: [/目录包含/, /列出.*目录/, /目录.*结构/, /文件列表.*如下/, /目录.*内容/],
requiredTools: ['list_directory', 'tree'], label: '列出目录' },
// 文件搜索
{ patterns: [/搜索到.*文件/, /找到.*匹配/, /搜索.*结果.*文件/],
requiredTools: ['search_files'], label: '搜索文件' },
// 搜索/抓取
{ patterns: [/搜索结果显示/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/,
/查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/],
requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' },
// 浏览器
{ patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/,
/点击了.*按钮/, /输入了.*文本/, /页面.*截图/],
requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' },
// 命令执行
{ patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/,
/执行.*结果/, /终端.*显示/],
requiredTools: ['run_command'], label: '执行命令' },
// Git 操作
{ patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/,
/git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/],
requiredTools: ['git'], label: 'Git 操作' },
// 下载文件
{ patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/],
requiredTools: ['download_file'], label: '下载文件' },
// 压缩/解压
{ patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/],
requiredTools: ['compress'], label: '压缩/解压' },
// 文件信息
{ patterns: [/文件.*大小.*字节/, /文件.*权限.*为/, /文件.*修改日期/],
requiredTools: ['get_file_info'], label: '获取文件信息' },
// 内存/记忆操作
{ patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/],
requiredTools: ['memory'], label: '记忆操作' },
// 会话操作
{ patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/],
requiredTools: ['session_list', 'session_read'], label: '会话操作' },
// 子代理
{ patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/],
requiredTools: ['spawn_task'], label: '子代理委派' },
// 系统工具
{ patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/],
requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' },
];
for (const rule of CLAIM_RULES) {
for (const rule of HALLUCINATION_RULES) {
for (const pattern of rule.patterns) {
if (pattern.test(content)) {
const anyCalled = rule.requiredTools.some(t => calledTools.has(t));
if (!anyCalled) {
// 写后读豁免:write_file 已调用时,read_file 的声称通常是读取写入结果的自然表述,不阻断
if (rule.label === '读取文件' && calledTools.has('write_file')) {
if (rule.label.includes('读取') && calledTools.has('write_file')) {
return { passed: true, reason: '' };
}
const toolList = rule.requiredTools.join(' / ');
@@ -218,7 +153,7 @@ const toolHallucinationCheck: CompletionCheck = {
reason: `回复声称进行了"${rule.label}"操作,但 ${toolList} 工具从未被调用。请实际调用 ${toolList} 来完成任务,不要编造结果。`
};
}
break; // 该类别匹配到了且工具已调用,跳出
break;
}
}
}
+12 -4
View File
@@ -133,7 +133,7 @@ export interface ContextBuildOptions {
const DEFAULT_OPTIONS: Required<ContextBuildOptions> = {
windowSize: 20,
summaryBatchSize: 20,
maxTokens: 24576,
maxTokens: 131072,
memoryContext: '',
workspaceContext: ''
};
@@ -354,11 +354,19 @@ export async function compressWithLLM(
// 保留已压缩的中间消息 + 新摘要
const alreadyCompressed = middle.filter(m => m.compressed);
// 合并所有 system 消息为一条,避免多条 system 消息触发 Qwen3 等模型 400 错误
const mergedSystemContent = [
...systemMsgs.map(m => m.content || ''),
...alreadyCompressed.filter(m => m.role === 'system').map(m => m.content || ''),
summaryMsg.content || '',
].filter(Boolean).join('\n\n');
const nonSystemCompressed = alreadyCompressed.filter(m => m.role !== 'system');
const result: OllamaMessage[] = [
...systemMsgs,
{ role: 'system', content: mergedSystemContent },
...head,
...alreadyCompressed,
summaryMsg,
...nonSystemCompressed,
...tail
];
+1 -1
View File
@@ -61,7 +61,7 @@ export async function executeSubAgent(
// 如果子代理使用了不同于主 AI 的模型,获取该模型的实际上下文长度
const defaultModel = state.get<string>('_defaultModel', '');
let numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
let numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
if (model !== defaultModel) {
try {
const detail = await api.showModel(model);
+3 -2
View File
@@ -802,8 +802,9 @@ export async function executeTool(toolName: string, args: Record<string, unknown
return { success: true, action, id: entry.id, type: entry.type, content: entry.content };
} catch (err) {
if (err instanceof DuplicateEntryError) {
logToolResult('memory', true, `重复跳过: ${(err as DuplicateEntryError).existingPreview}`);
return { success: true, action, duplicate: true, existing_id: (err as DuplicateEntryError).existingId, message: `跳过: 相同内容的记忆已存在 (${(err as DuplicateEntryError).existingPreview})。不要再重复添加,任务已完成。` };
const dupErr = err as InstanceType<typeof DuplicateEntryError>;
logToolResult('memory', true, `重复跳过: ${dupErr.existingPreview}`);
return { success: true, action, duplicate: true, existing_id: dupErr.existingId, message: `已跳过: 相同内容的记忆已存在 (${dupErr.existingPreview})。不要再重复添加,任务已完成。` };
}
throw err;
}