diff --git a/README.md b/README.md
index a23371c..347ce78 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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 @@
Agent Loop 最大工具调用轮数。默认 85 轮,复杂任务可能需要更多轮次。
+
+
+ 发送给模型的最大上下文窗口大小。默认 128K,越大可容纳更长对话但消耗更多显存/内存。
@@ -438,13 +446,12 @@
🚀 快速开始
- 确保 Ollama 已启动(默认
http://127.0.0.1:11434,可在设置中修改) - 顶部模型栏选择一个模型,右侧会显示模型能力徽章(🧠 Think / 👁️ Vision / 🔧 Tools)
- 输入消息,按 Enter 发送,Shift+Enter 换行
-
💬 聊天功能
- 流式回复 — 实时打字效果,随时点 ■ 停止
- Think 推理 — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)
- 上下文长度自动检测 — 切换模型时自动从模型元数据获取实际支持的上下文长度(如 Qwen3.6 27B = 262,144 tokens),无需手动配置
- 多模态 — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注
- 文件分析 — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断
- AI 回复顶部 — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文
+
💬 聊天功能
- 流式回复 — 实时打字效果,随时点 ■ 停止
- Think 推理 — 下方 Think 按钮切换,让模型展示深度思考过程(需模型支持,如 Qwen3)
- 上下文长度手动控制 — 设置面板下拉选择(128K / 256K / 512K / 1M),默认 128K。模型栏显示当前配置值,下拉框中每个模型旁显示其自身的上下文长度
- Qwen3 兼容 — 自动保证 system 消息唯一且位于首位,避免 Qwen3 等模型 400 错误
- 多模态 — 上传图片或视频(≤10MB),模型需支持 Vision。图片自动压缩,视频 1fps 提取帧序列带时序标注
- 文件分析 — 支持 50+ 种文本/代码格式,单文件 ≤500KB,自动剥离注释并按上下文预算智能截断
- AI 回复顶部 — 每条 AI 回复上方展示 📋 系统提示词折叠卡片,可点击查看实际发送给模型的完整上下文
🔧 Tool Calling(始终开启)
- 所有消息均通过 Agent Loop 自主调用本地工具,像一个本地 Agent,无普通聊天模式
- 40+1 个工具,分为 9 类:
- 文件系统(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
- 命令执行(1 个):run_command(实时流式输出,支持自动/需确认/禁用三种模式,可配置超时)
- 联网搜索(2 个):web_search(支持 SearXNG 元搜索引擎 JSON API / 四引擎 HTML 解析,双模式可切换)/ web_fetch(反爬headers+UA自动切换+自动回退浏览器渲染)
- Git(1 个):git(17 个子操作,push/pull/clone 内置60-120s超时保护)
- 浏览器控制(9 个):browser_open / browser_screenshot / browser_evaluate / browser_extract / browser_click / browser_type / browser_scroll / browser_wait / browser_close
- 记忆 & 会话(5 个):memory(统一记忆管理,5 个 action)/ session_list / session_read / spawn_task / plan_track
- 系统工具(6 个):datetime / calculator / random / uuid / json_format / hash
- read_file 支持 binary模式读取/base64解码/字节分页/2000行默认+截断hint自动续读
- write_file 支持 base64写二进制文件/mode(overwrite+append)合并append_file功能/10MB
- edit_file 支持 use_regex 正则替换
- search_files 支持正则表达式搜索(use_regex=true)
- browser_screenshot 支持全页面截图+元素截图
- browser_extract 支持CSS选择器提取特定区域
- new browser_wait 等待元素出现或定时等待
- 仅
run_command 支持三模式切换:自动/需确认/禁用 - 危险命令(
rm -rf、mkfs、反弹 shell 等)和系统路径(/etc、~/.ssh 等)被自动拦截 - 工具调用以可视化卡片展示状态(pending → running → success/error),在工作空间🔧工具页签中显示
- 默认最大 85 轮工具调用循环(设置面板可调),上下文使用率>80%时自动缩减到3轮
- 独立工具自动并行执行(16个只读工具加入并行白名单),有依赖关系的工具串行执行
🧠 Agent 记忆系统
- 记忆存储在工作空间 MEMORY.md 文件,受严格路径保护,仅
memory 工具可读写 - 新对话时自动检索相关记忆注入 AI 上下文,让 AI "记住"你
- 对话结束时 AI 自动提取有价值的用户信息保存(多层质量过滤,宁缺毋滥)
- memory 工具(5 个 action):search(关键词搜索)/ add(添加)/ replace(替换)/ remove(删除)/ read_all(读取全部)
- 点击顶部 🧠 按钮打开记忆面板:查看、添加、删除记忆条目
- 记忆容量上限 500 条,超限时自动清理低价值条目(规则类型受保护)
- 写入前自动安全扫描(prompt injection / 敏感信息 / 不可见字符检测)
- 应用启动时自动校验 MEMORY.md 格式,格式错误自动备份重建
📋 Plan Mode(计划模式)
- 点击输入框上方 📋 按钮开启 Plan Mode(开关式)
- 开启后,AI 先生成执行计划(Markdown 渲染弹窗),用户批准后才开始执行
- 计划批准后自动初始化追踪器,每个步骤完成后调用
plan_track 工具标记进度 - 系统提示词自动注入当前进度状态,AI 始终知道还剩多少步未完成
- 多步骤任务防遗忘:自动检测用户请求中的动作动词,对比已完成步骤,注入提醒
- 关闭 Plan Mode 后恢复正常 Agent Loop 模式
- 仅 Plan 模式 下 plan_track 工具可见,避免污染普通模式的工具列表
-
🛡️ 抗幻觉 & 稳定性
- 5 层防御体系:系统提示词加固 → 任务感知 → 中途幻觉检测(16 条规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入关键阻断)
- 完成闸门:对话结束前自动审查 AI 回复,检测 4 类关键问题(工具幻觉/注入/文件操作/遗漏),阻断有问题的回复
- 16 条幻觉检测规则:覆盖文件编辑、浏览器操作、Git、记忆、压缩等所有工具类别,防止 AI 声称执行了未调用的工具
- 看门狗超时:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止
- 中止保护:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效
- 上下文硬上限:消息数超过阈值时强制触发压缩,防止 OOM
-
🤖 Agent Loop 增强
- 🎬 视频上传:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系
- 8 状态机:INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理
- 智能上下文压缩:滑动窗口 + LLM结构化JSON摘要,超过50%上下文窗口自动触发
- 流式调用超时保护:可配置超时(默认300s),Ollama 卡死不再永久阻塞
- HTTP/MCP 超时可配:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时
- Final Answer 智能检测:多模式匹配(Final Answer/最终答案/最终回答/总结),避免漏检或误触
- Token 感知迭代预算:上下文使用率>80%时自动缩减剩余轮次到3轮
- 工具缓存 TTL:搜索5分钟/网页10分钟/文件30分钟,过期自动刷新
- 自动子任务拆解:检测"同时/分别/以及"等并行关键词,自动 spawn 子代理并行处理
- 跨会话工具上下文:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作
-- 旧工具结果自动截断:超过10轮的工具结果自动截断到500字符,控制上下文膨胀
+
🛡️ 抗幻觉 & 稳定性
- 5 层防御体系:系统提示词加固 → 任务感知 → 中途幻觉检测(中英双语规则覆盖全部工具类别)→ 进度锚点 → 完成闸门(6 项检查,幻觉/注入 → 阻断,质量/效率 → 咨询)
- 完成闸门:对话结束前自动审查 AI 回复,检测工具幻觉和 prompt injection,阻断有问题的回复
- 中英双语检测规则:覆盖中英文模型输出,防止 AI 声称执行了未调用的工具
- 智能重试机制:永久错误(文件不存在/权限拒绝)立即返回,瞬态错误(网络/超时)指数退避最多重试 2 次
- 看门狗超时:设置面板可配置全局超时(默认 30 分钟),AI 卡死或无限循环时自动中止
- 流式分级超时:空闲 30s 无新内容自动中止 + 总 300s 上限,防止模型假死
- 中止保护:所有状态处理器 + 重试循环均检查中止信号,点击 ■ 按钮立即生效
- 上下文硬上限:200 条消息强制压缩 + 120 条增量压缩,防止 OOM
+
🤖 Agent Loop 增强
- 🎬 视频上传:支持上传 .mp4/.avi/.mov/.mkv/.webm 等视频(≤10MB),自动 1fps 提取帧序列(带时间戳),多模态模型原生理解视频时序关系
- 8 状态机:INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED,每个状态独立的中止检查和处理
- 智能上下文压缩:滑动窗口 + LLM结构化JSON摘要,120条触发增量压缩 + 200条硬上限强制压缩
- 流式分级超时:空闲 30s 无新 token 自动中止 + 总 300s 上限,Ollama 假死不再永久阻塞
- HTTP/MCP 超时可配:设置面板可分别调整 HTTP(默认30s)和 MCP(默认60s)超时
- 智能工具调度:路径依赖检测自动串行化(write→read/create→write),只读工具并行执行
- 工具缓存 TTL:搜索5分钟/网页10分钟/文件30分钟/git 30秒,默认60秒过期,不再永久缓存
- Plan Mode 断点续传:中止后可恢复未完成的计划,进度自动保存
- Token 感知迭代预算:上下文使用率>80%时自动缩减剩余轮次到3轮
- 跨会话工具上下文:新对话自动注入上一轮已执行的工具调用及结果(role:tool 消息),AI 不会重复执行已完成的操作
- 旧工具结果智能截断:超过10轮后自动截断到2000字符,优先在JSON边界处截断
🔌 MCP(Model Context Protocol)
- 支持连接外部 MCP Server,动态扩展工具能力
- 设置面板可添加/启用/禁用/删除 MCP 服务器
- MCP 工具以
mcp_{server}__{tool} 前缀注册,与内置工具统一调度 - 启动时自动连接已启用的 MCP 服务器
🔍 SearXNG 元搜索引擎
- 点击顶部 🔍 按钮打开配置面板,可接入自部署的 SearXNG 实例
- JSON 模式:调用 SearXNG JSON API,聚合 70+ 引擎结果,结构化解析
- HTML 模式:获取原始搜索结果页面,交由 AI 自行分析提取信息
- 支持认证 Key(HTTP Header Authorization),保护私有实例
- 启用后替代内置四引擎方案;关闭即回退,无缝切换
- 所有参数(引擎、语言、安全搜索、时间范围等)均可独立配置
📋 自定义文件(SOUL.md / AGENT.md / USER.md)
- 在工作空间目录创建以下文件即可自定义 AI 行为,修改后下一轮对话立即生效
- SOUL.md — AI 身份、性格、行为准则(永远不可被压缩,注入为最高优先级系统提示词)
- AGENT.md — 工具调用规则、链式调用模式、核心约束(内置精简默认版,可通过工作空间覆盖)
- USER.md — 用户画像:技术栈、偏好、习惯等个人信息,AI 在对话中自动参考(仅工作空间,无内置默认)
- 可在 AI 回复顶部的 📋 系统提示词卡片中查看实际注入的完整上下文
- SOUL.md / AGENT.md 删除工作空间中文件可恢复为内置默认版本;USER.md 无内置版,不存在则不注入
diff --git a/src/renderer/main.ts b/src/renderer/main.ts
index 1dbcaa5..b119a14 100644
--- a/src/renderer/main.ts
+++ b/src/renderer/main.ts
@@ -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
{
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 {
await db.saveSetting('selectedModel', savedModel);
}
- const numCtx = await db.getSetting('numCtx', 24576);
+ const numCtx = await db.getSetting('numCtx', 131072);
const temperature = await db.getSetting('temperature', 0.7);
+ // 如果首次使用(数据库中无此设置),保存默认 128K
+ const hasNumCtx = await db.getSetting('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);
diff --git a/src/renderer/services/agent-engine.ts b/src/renderer/services/agent-engine.ts
index 46781e2..edc1210 100644
--- a/src/renderer/services/agent-engine.ts
+++ b/src/renderer/services/agent-engine.ts
@@ -39,7 +39,8 @@ import type {
} from '../types.js';
const MAX_RETRIES = 2; // 工具错误自动重试次数
-const MAX_MESSAGES = 500; // 上下文硬上限,超过则强制压缩
+const MAX_MESSAGES = 200; // 上下文硬上限,超过则强制压缩(P0-4: 从500降到200,防止Ollama context超限)
+const INCREMENTAL_COMPRESS_AT = 120;// 增量压缩触发点:到达此数量后在 handleObserving 中触发 COMPRESSING
/** ── LoopState 常量 ── */
const S = {
@@ -108,9 +109,6 @@ const TOOL_MAX_RESULT_SIZE: Record = {
browser_evaluate: 8000,
};
-/** v4.1: 工具并行执行 — 依赖检测。只有确实依赖前序工具输出结果的才串行 */
-const TOOLS_WITH_DATA_DEPS = new Set(['web_fetch']);
-
/** 始终可并行的只读/独立工具 */
const ALWAYS_PARALLEL = new Set([
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
@@ -121,6 +119,105 @@ const ALWAYS_PARALLEL = new Set([
'random', 'uuid', 'json_format', 'hash',
]);
+/** P0-2: 检测工具调用之间是否存在路径依赖(写入 → 读取/写入同一路径) */
+function hasPathDependency(call: ToolCall, prevBatch: ToolCall[]): boolean {
+ const callPath = extractWritePath(call);
+ if (!callPath) return false; // 非写操作,无依赖
+ for (const prev of prevBatch) {
+ const prevPath = extractAffectedPath(prev);
+ if (prevPath && pathsConflict(callPath, prevPath)) {
+ logInfo(`串行化: ${prev.function.name}(${prevPath}) → ${call.function.name}(${callPath})`);
+ return true;
+ }
+ }
+ return false;
+}
+
+/** 提取工具写入的目标路径(会修改文件系统的操作) */
+function extractWritePath(call: ToolCall): string | null {
+ const args = call.function.arguments;
+ switch (call.function.name) {
+ case 'write_file': return String(args.path || '');
+ case 'edit_file': return String(args.path || '');
+ case 'delete_file': return String(args.path || '');
+ case 'create_directory': return String(args.path || '');
+ case 'move_file': return String(args.destination || args.source || '');
+ case 'copy_file': return String(args.destination || '');
+ case 'download_file': return String(args.destination || '');
+ case 'compress': return String(args.destination || args.path || '');
+ default: return null;
+ }
+}
+
+/** 提取工具可能影响或读取的路径 */
+function extractAffectedPath(call: ToolCall): string | null {
+ const args = call.function.arguments;
+ switch (call.function.name) {
+ case 'read_file': return String(args.path || '');
+ case 'write_file': return String(args.path || '');
+ case 'edit_file': return String(args.path || '');
+ case 'delete_file': return String(args.path || '');
+ case 'list_directory': return String(args.path || '');
+ case 'search_files': return String(args.path || '');
+ case 'create_directory': return String(args.path || '');
+ case 'move_file': return String(args.source || '');
+ case 'copy_file': return String(args.source || '');
+ case 'get_file_info': return String(args.path || '');
+ case 'tree': return String(args.path || '');
+ case 'diff_files': return String(args.file1 || args.file2 || '');
+ case 'replace_in_files': return String(args.path || '');
+ case 'read_multiple_files': return null; // 多文件,难以精确判断
+ case 'download_file': return null; // 下载不依赖本地文件
+ case 'compress': return String(args.path || '');
+ default: return null;
+ }
+}
+
+/** 两个路径是否冲突(相同、父子、重叠) */
+function pathsConflict(a: string, b: string): boolean {
+ if (!a || !b) return false;
+ const na = a.replace(/\\/g, '/').replace(/\/+$/, '');
+ const nb = b.replace(/\\/g, '/').replace(/\/+$/, '');
+ // 完全相同或互为前缀(父子目录/文件)
+ return na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/');
+}
+
+/** P0-1: 判断错误是否为永久性(不应重试) */
+function isPermanentError(errorMsg: string): boolean {
+ const lower = errorMsg.toLowerCase();
+ const permanentPatterns = [
+ /ENOENT/i, /no such file/i, /not found/i, /文件不存在/i, /找不到/i,
+ /EACCES/i, /permission denied/i, /权限/i,
+ /EINVAL/i, /invalid/i, /参数.*错误/i,
+ /EISDIR/i, /ENOTDIR/i,
+ /ENOSPC/i, /disk.*full/i, /空间不足/i,
+ /超出.*限制/i, /超过.*限制/i, /too large/i, /过大/i,
+ /unsupported/i, /不支持/i, /not supported/i,
+ /syntax error/i, /parse error/i, /格式错误/i,
+ /禁止/i, /blocked/i, /拦截/i, /黑名单/i,
+ ];
+ return permanentPatterns.some(p => p.test(lower));
+}
+
+/** P1-7: 智能截断文本,优先在 JSON 边界处截断,避免破坏数据结构 */
+function smartTruncate(text: string, maxLen: number): string {
+ if (text.length <= maxLen) return text;
+ // 尝试在最后一个完整 JSON 块处截断
+ const lastBrace = text.lastIndexOf('}', maxLen);
+ const lastBracket = text.lastIndexOf(']', maxLen);
+ const boundary = Math.max(lastBrace, lastBracket);
+ if (boundary > maxLen * 0.5) {
+ return text.slice(0, boundary + 1) + `\n... (${text.length - boundary - 1}字符 已截断)`;
+ }
+ // 回退到换行边界
+ const lastNewline = text.lastIndexOf('\n', maxLen);
+ if (lastNewline > maxLen * 0.5) {
+ return text.slice(0, lastNewline) + `\n... (${text.length - lastNewline}字符 已截断)`;
+ }
+ // 最后手段:硬截断
+ return text.slice(0, maxLen) + `... (${text.length - maxLen}字符 已截断)`;
+}
+
/** 工具名白名单:用于文本解析兜底时过滤非法工具名 */
const VALID_TOOL_NAMES = new Set([
'read_file', 'write_file', 'list_directory', 'search_files', 'create_directory',
@@ -192,16 +289,23 @@ function parseToolCallsFromText(content: string): ToolCall[] {
const toolResultCache = new Map();
-/** 工具缓存 TTL(毫秒),按工具类型设定 */
+/** 工具缓存 TTL(毫秒),按工具类型设定。P1-6: default 从 Infinity 改为 60s */
const CACHE_TTL_MAP: Record = {
web_search: 5 * 60_000,
web_fetch: 10 * 60_000,
read_file: 30 * 60_000,
- list_directory: 60_000,
- search_files: 60_000,
+ list_directory: 2 * 60_000,
+ search_files: 2 * 60_000,
browser_screenshot: 60_000,
browser_extract: 5 * 60_000,
- default: Infinity,
+ get_file_info: 2 * 60_000,
+ tree: 2 * 60_000,
+ git: 30_000, // git status 30s 过期
+ datetime: 5_000, // 时间 5s 过期
+ session_list: 60_000,
+ session_read: 60_000,
+ diff_files: 2 * 60_000,
+ default: 60_000, // 默认 60s,不再永久缓存
};
/** 检查缓存是否过期 */
@@ -229,6 +333,20 @@ function isDuplicateCall(call: ToolCall, allCalls: ToolCall[]): boolean {
});
}
+/** 格式化工具结果的通用默认路径 */
+function formatDefaultToolResult(toolName: string, result: ToolResult): string {
+ const clean: Record = {};
+ for (const [k, v] of Object.entries(result)) {
+ if (k === 'success' || k === 'formatted' || k === 'content_type' ||
+ k === 'status' || k === 'length' || k === 'isDirectory') continue;
+ clean[k] = v;
+ }
+ let json = JSON.stringify(clean);
+ const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000;
+ if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)';
+ return json;
+}
+
/**
* 格式化工具结果,生成模型友好的简洁表示
*/
@@ -364,20 +482,12 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
}
return lines.join('\n');
}
- // 其他 action(add/replace/remove)保留完整 JSON
+ // 其他 action(add/replace/remove)→ 保留完整 JSON,走 default 逻辑
+ return formatDefaultToolResult(toolName, result);
}
default: {
- const clean: Record = {};
- for (const [k, v] of Object.entries(result)) {
- if (k === 'success' || k === 'formatted' || k === 'content_type' ||
- k === 'status' || k === 'length' || k === 'isDirectory') continue;
- clean[k] = v;
- }
- let json = JSON.stringify(clean);
- const maxLen = TOOL_MAX_RESULT_SIZE[toolName] || 15000;
- if (json.length > maxLen) json = json.slice(0, maxLen) + '\n... (已截断)';
- return json;
+ return formatDefaultToolResult(toolName, result);
}
}
}
@@ -396,13 +506,21 @@ export interface AgentCallbacks {
onPlanReady?: (plan: string, steps: string[]) => Promise;
}
-/** 保存执行轨迹到 SQLite */
-async function saveTrace(trace: Record): Promise {
- try {
- const bridge = window.metonaDesktop;
- if (!bridge?.db) return;
+/** P3-13: 带缓冲的轨迹保存 — 批量写入 SQLite,重试失败条目 */
+const _traceBuffer: Array> = [];
+const TRACE_FLUSH_INTERVAL = 5000; // 5 秒刷新一次
+const TRACE_MAX_RETRIES = 3;
+let _traceFlushTimer: ReturnType | null = null;
+
+async function flushTraces(): Promise {
+ if (_traceBuffer.length === 0) return;
+ const batch = _traceBuffer.splice(0, _traceBuffer.length);
+ const bridge = window.metonaDesktop;
+ if (!bridge?.db) return;
+
+ for (const trace of batch) {
const entry = {
- id: `trace_${generateId()}`,
+ id: String(trace.id || `trace_${generateId()}`),
session_id: trace.sessionId,
step_index: trace.stepIndex,
thought: trace.thought,
@@ -413,8 +531,69 @@ async function saveTrace(trace: Record): Promise {
error_pattern: trace.errorPattern || null,
created_at: trace.createdAt
};
- await bridge.db.saveTrace(entry);
- } catch { /* 不阻塞主流程 */ }
+ // 重试最多 TRACE_MAX_RETRIES 次
+ for (let attempt = 0; attempt < TRACE_MAX_RETRIES; attempt++) {
+ try {
+ await bridge.db.saveTrace(entry);
+ break;
+ } catch {
+ if (attempt < TRACE_MAX_RETRIES - 1) {
+ await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
+ }
+ }
+ }
+ }
+}
+
+/** 保存执行轨迹到 SQLite(缓冲写入) */
+async function saveTrace(trace: Record): Promise {
+ _traceBuffer.push(trace);
+ if (!_traceFlushTimer) {
+ _traceFlushTimer = setTimeout(() => {
+ _traceFlushTimer = null;
+ flushTraces();
+ }, TRACE_FLUSH_INTERVAL);
+ }
+ // 缓冲区超过 20 条立即刷新
+ if (_traceBuffer.length >= 20) {
+ if (_traceFlushTimer) { clearTimeout(_traceFlushTimer); _traceFlushTimer = null; }
+ flushTraces();
+ }
+}
+
+/** 强制刷新所有待写入的轨迹(在 Agent Loop 结束时调用) */
+export async function flushAllTraces(): Promise {
+ if (_traceFlushTimer) { clearTimeout(_traceFlushTimer); _traceFlushTimer = null; }
+ await flushTraces();
+}
+
+// ═══════════════════════════════════════════════════════════════
+// P0-3: AbortController 集中管理 — 防泄漏、防竞态
+// ═══════════════════════════════════════════════════════════════
+
+/** 注册新的 AbortController,自动清理旧实例 */
+function registerAbortController(): AbortController {
+ const old = state.get(KEYS.ABORT_CONTROLLER);
+ if (old) {
+ try { old.abort(); } catch { /* ignore */ }
+ }
+ const ac = new AbortController();
+ state.set(KEYS.ABORT_CONTROLLER, ac);
+ return ac;
+}
+
+/** 检查当前是否已被中止 */
+function isAborted(): boolean {
+ return state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted === true;
+}
+
+/** 清理 AbortController(在 finally 块中调用) */
+function cleanupAbortController(): void {
+ const ac = state.get(KEYS.ABORT_CONTROLLER);
+ if (ac) {
+ try { ac.abort(); } catch { /* ignore */ }
+ state.set(KEYS.ABORT_CONTROLLER, null);
+ }
}
// ═══════════════════════════════════════════════════════════════
@@ -459,6 +638,95 @@ function restoreLoopContext(_sessionId: string): Partial | null {
// Harness: 状态处理器
// ═══════════════════════════════════════════════════════════════
+/** P2-11: 加载自定义文件(SOUL.md / AGENT.md / USER.md),返回 system prompt 片段 */
+async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]): Promise {
+ // SOUL.md — 工作空间优先,内置 fallback,不可压缩
+ let soulMdContent = '';
+ if (workspaceDir) {
+ try {
+ const r = await window.metonaDesktop?.workspace.readFile(workspaceDir.replace(/[\\/]+$/, '') + '/SOUL.md');
+ if (r?.success && r.content) { soulMdContent = r.content; logInfo('SOUL.md 已从工作空间加载', `${r.lines || 0} 行`); }
+ } catch { /* ignore */ }
+ }
+ if (!soulMdContent) {
+ try {
+ const resp = await fetch('./SOUL.md');
+ if (resp.ok) { soulMdContent = await resp.text(); logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); }
+ } catch { /* ignore */ }
+ }
+ if (soulMdContent) systemPromptParts.unshift(`[SOUL.md] ${soulMdContent}`);
+
+ // AGENT.md — 工作空间优先,内置 fallback,Token 预算截断
+ let agentMdContent = '';
+ if (workspaceDir) {
+ try {
+ const r = await window.metonaDesktop?.workspace.readFile(workspaceDir.replace(/[\\/]+$/, '') + '/AGENT.md');
+ if (r?.success && r.content) { agentMdContent = r.content; logInfo('AGENT.md 已从工作空间加载', `${r.lines || 0} 行`); }
+ } catch { /* ignore */ }
+ }
+ if (!agentMdContent) {
+ try {
+ const resp = await fetch('./AGENT.md');
+ if (resp.ok) { agentMdContent = await resp.text(); logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`); }
+ } catch { /* ignore */ }
+ }
+ if (agentMdContent) systemPromptParts.push(`[AGENT.md] ${truncateByTokenBudget(agentMdContent, 2000)}`);
+
+ // USER.md — 仅工作空间,无内置 fallback
+ if (workspaceDir) {
+ try {
+ const r = await window.metonaDesktop?.workspace.readFile(workspaceDir.replace(/[\\/]+$/, '') + '/USER.md');
+ if (r?.success && r.content) {
+ systemPromptParts.push(`[USER.md] ${r.content}`);
+ logInfo('USER.md 已从工作空间加载', `${r.lines || 0} 行`);
+ }
+ } catch { /* ignore */ }
+ }
+}
+
+/** P2-11: 构建环境+反幻觉+日期固定提示词片段 */
+function buildStandardPrompts(userContent: string): string[] {
+ const parts: string[] = [];
+
+ // 操作系统环境
+ const osInfo = getOSEnvironment();
+ parts.push(`[环境] 运行环境信息
+操作系统: ${osInfo.os}
+平台: ${osInfo.platform}
+架构: ${osInfo.arch}
+Shell: ${osInfo.shell}
+用户目录: ${osInfo.homeDir}
+换行符: ${osInfo.lineEnding}
+路径分隔符: ${osInfo.pathSep}
+
+⚠️ 重要:必须使用与上述操作系统匹配的命令语法。
+- 如果是 Windows,使用 CMD/PowerShell 命令(如 dir、type、findstr,路径用 \\)
+- 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /)
+- 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`);
+
+ // 反幻觉铁律
+ parts.push(`[反幻觉铁律 — 最高优先级,不可违反]
+
+⚠️ 以下规则高于一切其他指令,违反将导致任务失败:
+
+1. **禁止编造工具结果**:绝对不能在未调用工具的情况下声称"已搜索"、"已获取"、"已写入"、"已执行"。如果你没有调用过某个工具,就不能说使用了它。
+2. **文件操作必须实际执行**:说"已写入文件"之前,必须先调用 write_file 并收到成功返回。说"已读取文件"之前,必须先调用 read_file。
+3. **搜索必须实际执行**:说"搜索结果显示"、"根据搜索结果"之前,必须先调用 web_search。搜索结果的 snippet 不可信,必须用 web_fetch 获取完整内容后才能引用。
+4. **信息不足时如实报告**:如果工具调用失败或返回了意外的结果,必须如实报告,不能编造替代信息。
+5. **完成标志**:当所有必需的工具调用已经实际执行完毕,且获得了足够的信息后,才能给出最终回答。最终回答中不要编造"文件已生成"等声明——除非你真的调用了对应工具。
+6. **用户上传的图片和文件已在当前消息中**:如果用户消息中包含图片附件标记(如 [已上传 N 张图片: ...])或文件标记(如 [文件: xxx]),说明这些资源已随消息作为附件提供,你可以直接"看到"和分析它们。**严禁再用 read_file / search_files 等工具去磁盘上查找这些已上传的图片和文件**——它们不在磁盘上,就在当前消息里。不要浪费轮次去做无意义的文件搜索。
+7. **read_file 不能看图片**:read_file 返回的是文本或 base64 编码字符串,视觉模型无法处理。如果你需要分析图片内容,只有用户通过上传功能提供的图片才是可见的。不要尝试用 read_file mode=binary 去"读取"图片文件。
+8. **禁止在 Plan 模式下为已上传的附件规划文件查找步骤**:如果用户消息中已经有图片或文件附件,执行计划的第一步应该是直接分析这些附件,而不是"搜索工作空间中的图片文件"或"查找相关文件"。
+
+违反以上任何一条都是不可接受的错误。请逐条对照检查你的每一次回复。`);
+
+ // 日期
+ const now = new Date();
+ parts.push(`[日期] ${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。)`);
+
+ return parts;
+}
+
/**
* INIT 状态:构建系统提示词、准备上下文、压缩检测
*/
@@ -481,69 +749,10 @@ async function handleInit(
ctx.messages.length = 0;
const systemPromptParts: string[] = [];
-
- // ── 扫描工作空间 SOUL.md(静态区,不可压缩)──
- let soulMdContent = '';
const workspaceDir = getWorkspaceDirPath();
- if (workspaceDir) {
- try {
- const soulResult = await window.metonaDesktop?.workspace.readFile(
- workspaceDir.replace(/[\\/]+$/, '') + '/SOUL.md'
- );
- if (soulResult?.success && soulResult.content) {
- soulMdContent = soulResult.content;
- logInfo('SOUL.md 已从工作空间加载', `${soulResult.lines || 0} 行`);
- }
- } catch { /* ignore */ }
- }
- if (!soulMdContent) {
- try {
- const resp = await fetch('./SOUL.md');
- if (resp.ok) { soulMdContent = await resp.text(); logInfo('SOUL.md 已从内置加载', `${soulMdContent.length} 字符`); }
- } catch { /* ignore */ }
- }
- if (soulMdContent) {
- ctx.messages.push({ role: 'system', content: `[SOUL.md] ${soulMdContent}` });
- }
- // ── 扫描工作空间 AGENT.md ──
- let agentMdContent = '';
- if (workspaceDir) {
- try {
- const agentResult = await window.metonaDesktop?.workspace.readFile(
- workspaceDir.replace(/[\\/]+$/, '') + '/AGENT.md'
- );
- if (agentResult?.success && agentResult.content) {
- agentMdContent = agentResult.content;
- logInfo('AGENT.md 已从工作空间加载', `${agentResult.lines || 0} 行`);
- }
- } catch { /* ignore */ }
- }
- if (!agentMdContent) {
- try {
- const resp = await fetch('./AGENT.md');
- if (resp.ok) { agentMdContent = await resp.text(); logInfo('AGENT.md 已从内置加载', `${agentMdContent.length} 字符`); }
- } catch { /* ignore */ }
- }
- if (agentMdContent) {
- // Token 预算截断(限制 2000 tokens)
- const truncated = truncateByTokenBudget(agentMdContent, 2000);
- systemPromptParts.push(`[AGENT.md] ${truncated}`);
- }
-
- // ── 扫描工作空间 USER.md(仅工作空间,无内置 fallback)──
- if (workspaceDir) {
- try {
- const userResult = await window.metonaDesktop?.workspace.readFile(
- workspaceDir.replace(/[\\/]+$/, '') + '/USER.md'
- );
- if (userResult?.success && userResult.content) {
- const userMdContent = userResult.content;
- systemPromptParts.push(`[USER.md] ${userMdContent}`);
- logInfo('USER.md 已从工作空间加载', `${userResult.lines || 0} 行`);
- }
- } catch { /* ignore */ }
- }
+ // P2-11: 加载自定义文件
+ await loadCustomFiles(workspaceDir || '', systemPromptParts);
// 注入记忆上下文
if (userContent) {
@@ -562,6 +771,36 @@ async function handleInit(
你可以使用 run_command 工具在此目录下执行命令,所有命令通过工作空间进程管理执行,无超时限制。
文件操作工具(read_file、write_file 等)的相对路径基于此目录解析。`);
+ // ── P1-8: Plan Mode 断点续传 — 检测上一轮未完成的计划 ──
+ if (ctx.mode === 'plan') {
+ const resumeData = state.get<{ steps: Array<{index:number;label:string;done:boolean}>; total: number; done: number; loopCount: number } | null>('_planResumeData', null);
+ if (resumeData && resumeData.steps.length > 0 && resumeData.done < resumeData.total) {
+ // 恢复追踪器状态
+ const { initPlanTracker } = await import('./tool-registry.js');
+ const steps = resumeData.steps.map(s => s.label);
+ const tracker = initPlanTracker(steps);
+ // 标记已完成的步骤
+ for (const s of resumeData.steps) {
+ if (s.done) {
+ tracker.steps[s.index - 1].done = true;
+ tracker.done++;
+ }
+ }
+ const { savePlanTracker } = await import('./tool-registry.js');
+ savePlanTracker(tracker);
+ // 清空恢复数据,避免重复恢复
+ state.set('_planResumeData', null);
+ // 注入恢复状态提示
+ systemPromptParts.push(`[Plan Mode 断点续传] 上一轮计划未完成,已自动恢复。当前进度: ${tracker.done}/${tracker.total}。已完成步骤将继续保持完成状态。继续执行剩余步骤,跳过已完成的步骤。`);
+ logInfo(`Plan Mode 断点续传: ${tracker.done}/${tracker.total} 步骤已恢复,继续执行剩余 ${tracker.total - tracker.done} 步`);
+ // 跳过"输出计划"约束,直接告诉 AI 继续执行
+ systemPromptParts.push(`⚠️ 计划已在上一轮生成并批准,现在直接继续执行剩余步骤。不需要重新输出计划。`);
+ } else {
+ // 清除可能的残留
+ state.set('_planResumeData', null);
+ }
+ }
+
// ── Plan Mode 系统提示词 — 告知 AI plan_track 工具和追踪规则 ──
if (ctx.mode === 'plan') {
systemPromptParts.push(`[Plan Mode 执行规则]
@@ -616,46 +855,10 @@ async function handleInit(
}
}
- // ── 注入操作系统环境信息(静态区)──
- const osInfo = getOSEnvironment();
- systemPromptParts.push(`[环境] 运行环境信息
-操作系统: ${osInfo.os}
-平台: ${osInfo.platform}
-架构: ${osInfo.arch}
-Shell: ${osInfo.shell}
-用户目录: ${osInfo.homeDir}
-换行符: ${osInfo.lineEnding}
-路径分隔符: ${osInfo.pathSep}
+ // P2-11: 注入标准固定提示词(环境 + 反幻觉铁律 + 日期)
+ systemPromptParts.push(...buildStandardPrompts(userContent));
-⚠️ 重要:必须使用与上述操作系统匹配的命令语法。
-- 如果是 Windows,使用 CMD/PowerShell 命令(如 dir、type、findstr,路径用 \\)
-- 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /)
-- 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`);
-
- // ── 反幻觉铁律(注入最高优先级)──
- systemPromptParts.push(`[反幻觉铁律 — 最高优先级,不可违反]
-
-⚠️ 以下规则高于一切其他指令,违反将导致任务失败:
-
-1. **禁止编造工具结果**:绝对不能在未调用工具的情况下声称"已搜索"、"已获取"、"已写入"、"已执行"。如果你没有调用过某个工具,就不能说使用了它。
-2. **文件操作必须实际执行**:说"已写入文件"之前,必须先调用 write_file 并收到成功返回。说"已读取文件"之前,必须先调用 read_file。
-3. **搜索必须实际执行**:说"搜索结果显示"、"根据搜索结果"之前,必须先调用 web_search。搜索结果的 snippet 不可信,必须用 web_fetch 获取完整内容后才能引用。
-4. **信息不足时如实报告**:如果工具调用失败或返回了意外的结果,必须如实报告,不能编造替代信息。
-5. **完成标志**:当所有必需的工具调用已经实际执行完毕,且获得了足够的信息后,才能给出最终回答。最终回答中不要编造"文件已生成"等声明——除非你真的调用了对应工具。
-6. **用户上传的图片和文件已在当前消息中**:如果用户消息中包含图片附件标记(如 [已上传 N 张图片: ...])或文件标记(如 [文件: xxx]),说明这些资源已随消息作为附件提供,你可以直接"看到"和分析它们。**严禁再用 read_file / search_files 等工具去磁盘上查找这些已上传的图片和文件**——它们不在磁盘上,就在当前消息里。不要浪费轮次去做无意义的文件搜索。
-7. **read_file 不能看图片**:read_file 返回的是文本或 base64 编码字符串,视觉模型无法处理。如果你需要分析图片内容,只有用户通过上传功能提供的图片才是可见的。不要尝试用 read_file mode=binary 去"读取"图片文件。
-8. **禁止在 Plan 模式下为已上传的附件规划文件查找步骤**:如果用户消息中已经有图片或文件附件,执行计划的第一步应该是直接分析这些附件,而不是"搜索工作空间中的图片文件"或"查找相关文件"。
-
-违反以上任何一条都是不可接受的错误。请逐条对照检查你的每一次回复。`);
-
- // ── 实时日期(动态区)──
- const _now = new Date();
- const realDate = `${_now.getFullYear()}年${_now.getMonth() + 1}月${_now.getDate()}日`;
-
- const fullSystemPrompt = [
- ...systemPromptParts,
- `[日期] ${realDate}(此日期来自系统时钟,绝对可信。你的训练数据可能已过时,请以此日期为准构造所有搜索查询和时效性回答。绝对不要基于训练数据推断日期。)`
- ].join('\n\n');
+ const fullSystemPrompt = systemPromptParts.join('\n\n');
ctx.messages.push({ role: 'system', content: fullSystemPrompt });
@@ -689,7 +892,7 @@ Shell: ${osInfo.shell}
ctx.messages.push(userMsg);
// 上下文窗口管理:滑动窗口 + 摘要压缩 + token 裁剪
- const numCtx = state.get(KEYS.NUM_CTX, 24576);
+ const numCtx = state.get(KEYS.NUM_CTX, 131072);
const contextResult = buildContext(ctx.messages, { maxTokens: numCtx, windowSize: 20 });
ctx.messages.length = 0;
ctx.messages.push(...contextResult);
@@ -781,49 +984,113 @@ function extractPlanSteps(content: string): string[] {
}
/**
- * 中途幻觉检测 — 轻量级检查
- * 仅检查最关键的几类幻觉(文件写入、搜索、命令执行)
- * 完整检查在 Completion Gate 中
+ * P2-9 + P2-12: 中途幻觉检测 — 中英文双语规则,与 completion-gate 共用统一规则表
+ * 通过提取共享规则 + 添加英文 pattern,覆盖中英文模型输出
*/
function detectMidTaskHallucination(
content: string,
allToolRecords: Array<{ name: string }>,
): string | null {
const called = new Set(allToolRecords.map(r => r.name));
+ const lower = content.toLowerCase();
- // 多维度幻觉检测(按置信度排序)
- const rules: Array<{ re: RegExp; tools: string[]; msg: string }> = [
- // 文件操作类
- { re: /已(写入|创建|生成).*文件/, tools: ['write_file'], msg: '声称已写入/创建文件' },
- { re: /已成功(写入|创建|保存)/, tools: ['write_file'], msg: '声称文件操作成功' },
- { re: /已(编辑|修改|更新)了?(文件|代码)/, tools: ['edit_file', 'replace_in_files'], msg: '声称编辑了文件' },
- { re: /已(删除|移除)了?.*(文件|目录)/, tools: ['delete_file'], msg: '声称删除了文件' },
- { re: /已(移动|重命名|复制)了?.*(文件|目录)/, tools: ['move_file', 'copy_file'], msg: '声称移动/复制了文件' },
- { re: /已创建了?(目录|文件夹)/, tools: ['create_directory'], msg: '声称创建了目录' },
- { re: /已(下载|获取)了?.*文件/, tools: ['download_file', 'web_fetch'], msg: '声称下载了文件' },
- // 搜索类
- { re: /搜索(结果|到|显示).*(条|个)/, tools: ['web_search', 'web_fetch'], msg: '声称获得了搜索结果' },
- { re: /记忆.*(搜索到|找到|显示)/, tools: ['memory_search'], msg: '声称搜索了记忆' },
- // 命令执行类
- { re: /运行(了|的)?(命令|脚本).*(成功|输出|结果)/, tools: ['run_command'], msg: '声称执行了命令' },
- { re: /(git|提交|推送|合并).*(成功|完成)/, tools: ['git'], msg: '声称执行了 Git 操作' },
- // 浏览器类
- { re: /(打开|浏览)了?.*(网页|页面|浏览器)/, tools: ['browser_open', 'browser_screenshot', 'browser_extract'], msg: '声称使用了浏览器' },
- { re: /截图了?(页面|屏幕|网页)/, tools: ['browser_screenshot'], msg: '声称截取了页面' },
- // 压缩类
- { re: /(压缩|解压|打包).*(成功|完成)/, tools: ['compress'], msg: '声称执行了压缩/解压操作' },
- // 记忆类
- { re: /(已记住|保存了|存储了|添加到记忆)/, tools: ['memory_add', 'memory_replace'], msg: '声称保存了记忆' },
- ];
-
- for (const rule of rules) {
- if (rule.re.test(content) && !rule.tools.some(t => called.has(t))) {
- return `${rule.msg}但对应工具从未被实际调用。`;
+ // 统一规则表:单一定义,mid-task 和 completion-gate 共用
+ for (const rule of HALLUCINATION_RULES) {
+ for (const pattern of rule.patterns) {
+ if (pattern.test(content)) {
+ const anyCalled = rule.requiredTools.some(t => called.has(t));
+ if (anyCalled) break; // 工具已调用,跳过此类检测
+ // 写后读豁免
+ if (rule.label.includes('读取') && called.has('write_file')) break;
+ return `${rule.label}操作但对应工具从未被实际调用(应调用 ${rule.requiredTools.join(' / ')})。`;
+ }
}
}
return null;
}
+/** P2-12: 统一幻觉检测规则表(中英文双语),completion-gate.ts 通过 import 共享此表 */
+interface HallucinationRule {
+ patterns: RegExp[];
+ requiredTools: string[];
+ label: string;
+}
+
+export const HALLUCINATION_RULES: HallucinationRule[] = [
+ // 文件写入 — 中英
+ { patterns: [/已写入文件/, /已创建文件/, /文件已生成/, /已保存到/, /已成功创建/, /成功写入/,
+ /文件.*已.*创建/, /文件.*已.*写入/, /生成.*文件/, /写入.*工作空间/,
+ /(?:have|has)\s+(?:written|created|saved|generated)\s+(?:the\s+)?file/i,
+ /file\s+(?:has been|was)\s+(?:written|created|saved|generated)/i,
+ /I(?:'ve|\s+have)\s+(?:written|created|saved)/i],
+ requiredTools: ['write_file'], label: '写入/创建文件' },
+ // 文件编辑
+ { patterns: [/已修改文件/, /已编辑文件/, /文件已更新/, /已替换.*内容/, /修改了.*文件/, /更新了.*文件/,
+ /(?:have|has)\s+(?:modified|edited|updated)\s+(?:the\s+)?file/i,
+ /I(?:'ve|\s+have)\s+(?:modified|edited|updated)\s+the\s+file/i],
+ requiredTools: ['edit_file', 'write_file'], label: '编辑/修改文件' },
+ // 文件删除
+ { patterns: [/已删除文件/, /已移除文件/, /文件已清除/, /删除了.*文件/,
+ /(?:have|has)\s+(?:deleted|removed)\s+(?:the\s+)?file/i],
+ requiredTools: ['delete_file'], label: '删除文件' },
+ // 文件移动/复制
+ { patterns: [/已移动文件/, /已复制文件/, /已重命名/, /文件.*已.*移动/, /文件.*已.*复制/,
+ /(?:have|has)\s+(?:moved|copied|renamed)\s+(?:the\s+)?file/i],
+ requiredTools: ['move_file', 'copy_file'], label: '移动/复制文件' },
+ // 目录创建
+ { patterns: [/已创建了?(目录|文件夹)/,
+ /(?:have|has)\s+created\s+(?:a\s+)?(?:directory|folder)/i],
+ requiredTools: ['create_directory'], label: '创建目录' },
+ // 搜索/抓取网页
+ { patterns: [/搜索(结果|到|显示).*(条|个)/, /根据搜索/, /抓取了.*网页/, /从.*搜索.*得到/,
+ /查询到/, /检索到/, /搜索到/, /网络.*显示/, /实时.*数据.*显示/,
+ /search\s+results\s+(?:show|indicate|suggest)/i, /according\s+to\s+(?:the\s+)?search/i,
+ /I\s+(?:found|discovered|searched)\s+(?:on|the\s+web|online)/i,
+ /based\s+on\s+(?:the\s+)?search\s+results/i],
+ requiredTools: ['web_search', 'web_fetch'], label: '搜索/抓取网页' },
+ // 浏览器
+ { patterns: [/浏览器截图显示/, /打开网页.*看到/, /页面显示/, /浏览器.*打开/,
+ /点击了.*按钮/, /输入了.*文本/, /页面.*截图/,
+ /browser\s+(?:screenshot|shows|displays)/i, /opened\s+(?:the\s+)?(?:page|url|website)/i,
+ /I\s+(?:opened|navigated|browsed)\s+(?:to\s+)?(?:the\s+)?/i],
+ requiredTools: ['browser_open', 'browser_screenshot', 'browser_extract', 'browser_click', 'browser_type'], label: '浏览器操作' },
+ // 命令执行
+ { patterns: [/运行.*命令/, /执行.*脚本/, /命令.*输出/, /运行结果/, /命令.*返回/, /执行.*结果/, /终端.*显示/,
+ /(?:ran|executed|run)\s+(?:the\s+)?command/i, /command\s+(?:output|result)/i,
+ /I(?:'ve|\s+have)\s+(?:run|executed)\s+(?:the\s+)?command/i],
+ requiredTools: ['run_command'], label: '执行命令' },
+ // Git 操作
+ { patterns: [/已提交/, /已推送/, /已暂存/, /已创建分支/, /已合并/, /已克隆/,
+ /git.*commit/, /git.*push/, /git.*add/, /提交了.*代码/,
+ /(?:have|has)\s+(?:committed|pushed|staged|merged|cloned)/i,
+ /I(?:'ve|\s+have)\s+(?:committed|pushed|staged)/i],
+ requiredTools: ['git'], label: 'Git 操作' },
+ // 下载
+ { patterns: [/已下载文件/, /下载完成/, /文件.*已.*下载/, /从.*下载/,
+ /(?:have|has)\s+downloaded\s+(?:the\s+)?file/i],
+ requiredTools: ['download_file'], label: '下载文件' },
+ // 压缩/解压
+ { patterns: [/已压缩/, /已解压/, /压缩完成/, /解压完成/, /归档.*已.*创建/,
+ /(?:have|has)\s+(?:compressed|extracted|archived)/i],
+ requiredTools: ['compress'], label: '压缩/解压' },
+ // 记忆操作
+ { patterns: [/已记住/, /已添加记忆/, /记忆已保存/, /已更新记忆/, /已删除记忆/,
+ /(?:have|has)\s+(?:remembered|memorized|saved\s+to\s+memory)/i],
+ requiredTools: ['memory'], label: '记忆操作' },
+ // 会话操作
+ { patterns: [/历史会话.*显示/, /已读取.*会话/, /会话.*内容.*如下/,
+ /(?:have|has)\s+(?:read|listed)\s+(?:the\s+)?session/i],
+ requiredTools: ['session_list', 'session_read'], label: '会话操作' },
+ // 子代理
+ { patterns: [/子代理.*完成/, /子任务.*已.*执行/, /spawn.*task.*完成/,
+ /sub[\s-]?agent\s+(?:has\s+)?(?:completed|finished)/i],
+ requiredTools: ['spawn_task'], label: '子代理委派' },
+ // 系统工具
+ { patterns: [/计算.*结果.*为/, /哈希.*值.*为/, /UUID.*生成/, /随机.*生成.*为/,
+ /(?:calculated|computed|hashed)\s+(?:the\s+)?(?:result|value)/i],
+ requiredTools: ['calculator', 'hash', 'uuid', 'random'], label: '系统工具' },
+];
+
/**
* 多步骤任务待办检测 — 解析用户请求中的动作动词,对比已完成的工具调用
* 返回尚未完成的任务描述列表
@@ -1069,20 +1336,36 @@ async function handleThinking(
const warning = remaining <= 2
? `\n⚠️ CRITICAL: You have only ${remaining} iteration(s) left. Stop using tools and provide your final answer NOW.`
: `\n⚠️ WARNING: You have approximately ${remaining} iterations remaining. Start wrapping up and prepare your final answer.`;
- ctx.messages.push({ role: 'system', content: warning, ephemeral: true });
- }
+ ctx.messages.push({ role: 'user', content: warning, ephemeral: true });
+ }
- const abortController = new AbortController();
- state.set(KEYS.ABORT_CONTROLLER, abortController);
+ const abortController = registerAbortController();
+
+ // ── P1-5: 流式超时分级 ──
+ const STREAM_TIMEOUT_MS = state.get('streamTimeout', 300_000); // 总超时 300s
+ const IDLE_TIMEOUT_MS = 30_000; // 空闲超时 30s(无新 token)
+
+ let totalTimer: ReturnType | null = null;
+ let idleTimer: ReturnType | null = null;
+ let lastContentTime = Date.now();
+
+ const resetIdleTimer = (): void => {
+ lastContentTime = Date.now();
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ const idleSec = Math.round((Date.now() - lastContentTime) / 1000);
+ logWarn(`流式空闲超时 (${idleSec}s 无新 token),中止本轮`);
+ abortController.abort();
+ }, IDLE_TIMEOUT_MS);
+ };
- const STREAM_TIMEOUT_MS = state.get('streamTimeout', 300_000);
- let streamTimer: ReturnType | null = null;
if (STREAM_TIMEOUT_MS > 0) {
- streamTimer = setTimeout(() => {
- logWarn(`流式调用超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`);
+ totalTimer = setTimeout(() => {
+ logWarn(`流式总超时 (${STREAM_TIMEOUT_MS / 1000}s),中止本轮`);
abortController.abort();
}, STREAM_TIMEOUT_MS);
}
+ resetIdleTimer(); // 启动空闲超时
let progressTimer: ReturnType | null = null;
@@ -1118,20 +1401,21 @@ async function handleThinking(
stream: true,
think: state.get('thinkEnabled', false),
options: {
- num_ctx: state.get(KEYS.NUM_CTX, 24576),
+ num_ctx: state.get(KEYS.NUM_CTX, 131072),
temperature: state.get('temperature', 0.7)
},
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() })
},
- (chunk: OllamaStreamChunk) => {
- if (chunk.message?.thinking) {
- ctx.thinking += chunk.message.thinking;
- callbacks.onThinking(ctx.thinking);
- }
- if (chunk.message?.content) {
- ctx.content += chunk.message.content;
- callbacks.onContent(ctx.content);
- }
+ (chunk: OllamaStreamChunk) => {
+ if (chunk.message?.thinking) {
+ ctx.thinking += chunk.message.thinking;
+ callbacks.onThinking(ctx.thinking);
+ }
+ if (chunk.message?.content) {
+ ctx.content += chunk.message.content;
+ callbacks.onContent(ctx.content);
+ resetIdleTimer(); // P1-5: 收到内容 token → 重置空闲超时
+ }
if (chunk.eval_count) { ctx.loopEvalCount = chunk.eval_count; }
if (chunk.prompt_eval_count) { ctx.loopPromptEvalCount = chunk.prompt_eval_count; }
if (chunk.total_duration) { ctx.loopInferenceNs = chunk.total_duration; }
@@ -1196,13 +1480,15 @@ async function handleThinking(
ctx.loopPromptEvalCount = 0;
ctx.loopInferenceNs = 0;
- if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
+ if (totalTimer) { clearTimeout(totalTimer); totalTimer = null; }
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (progressTimer) clearInterval(progressTimer as unknown as number);
transition(ctx, S.PARSING);
} catch (err) {
- if (streamTimer) { clearTimeout(streamTimer); streamTimer = null; }
+ if (totalTimer) { clearTimeout(totalTimer); totalTimer = null; }
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
if (progressTimer) clearInterval(progressTimer as unknown as number);
if (abortController.signal.aborted) {
logInfo('流式调用已中止');
@@ -1281,7 +1567,7 @@ async function handleExecuting(
logInfo('检测到重复调用但上一轮有失败,允许重试');
}
- // ── 工具并行执行:批次划分 ──
+ // ── 工具并行执行:智能批次划分(P0修复:检测实际数据依赖)──
const batches: ToolCall[][] = [];
let currentBatch: ToolCall[] = [];
@@ -1290,16 +1576,12 @@ async function handleExecuting(
currentBatch.push(call);
} else if (ALWAYS_PARALLEL.has(call.function.name)) {
currentBatch.push(call);
+ } else if (hasPathDependency(call, currentBatch)) {
+ // 存在路径依赖 → 新开批次(串行化)
+ batches.push(currentBatch);
+ currentBatch = [call];
} else {
- const needsPrevResult = currentBatch.some(prev =>
- TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name
- );
- if (needsPrevResult) {
- batches.push(currentBatch);
- currentBatch = [call];
- } else {
- currentBatch.push(call);
- }
+ currentBatch.push(call);
}
}
if (currentBatch.length > 0) batches.push(currentBatch);
@@ -1308,7 +1590,7 @@ async function handleExecuting(
logInfo(`工具并行执行: ${ctx.toolCalls.length} 个工具 → ${batches.length} 批次(首批 ${batches[0].length} 个并行)`);
}
- /** 执行单个工具(含重试) */
+ /** 执行单个工具(含智能重试:永久错误立即返回,瞬态错误最多重试 MAX_RETRIES 次) */
const executeSingleTool = async (call: ToolCall): Promise<[ToolCallRecord, string | null]> => {
const cacheKey = getToolCacheKey(call.function.name, call.function.arguments);
@@ -1335,7 +1617,8 @@ async function handleExecuting(
toolResultCache.delete(cacheKey);
}
- await new Promise(r => requestAnimationFrame(r));
+ // P2-10 修复:queueMicrotask 替代 rAF,在隐藏窗口下不会降频
+ await new Promise(r => { queueMicrotask(r); });
callbacks.onToolCallStart(call);
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
@@ -1351,7 +1634,7 @@ async function handleExecuting(
}, null];
}
- await new Promise(r => requestAnimationFrame(r));
+ await new Promise(r => { queueMicrotask(r); });
if (needsConfirmation(call.function.name)) {
const confirmed = await callbacks.onConfirmTool(call);
@@ -1365,10 +1648,11 @@ async function handleExecuting(
}
}
+ // ── P0-1 修复:区分瞬态错误和永久错误 ──
let lastError = '';
for (let retry = 0; retry <= MAX_RETRIES; retry++) {
// 重试前检查中止信号
- if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
+ if (isAborted()) {
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: '用户中止' },
@@ -1385,12 +1669,23 @@ async function handleExecuting(
}, result.success ? cacheKey : null];
} catch (err) {
lastError = (err as Error).message;
+ // 永久性错误不重试:文件不存在、权限拒绝、路径不可达
+ if (isPermanentError(lastError)) {
+ logWarn(`工具永久错误(不重试): ${call.function.name}`, lastError);
+ return [{
+ name: call.function.name, arguments: call.function.arguments,
+ result: { success: false, error: lastError },
+ status: 'error' as const, timestamp: Date.now()
+ }, null];
+ }
if (retry < MAX_RETRIES) {
- logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}`, lastError);
- await new Promise(r => setTimeout(r, 500));
+ // 瞬态错误:指数退避重试
+ const delay = 500 * Math.pow(2, retry);
+ logWarn(`工具重试 ${retry + 1}/${MAX_RETRIES}: ${call.function.name}(${delay}ms 后)`, lastError);
+ await new Promise(r => setTimeout(r, delay));
continue;
}
- logError(`工具执行失败: ${call.function.name}`, lastError);
+ logError(`工具执行失败(已达最大重试): ${call.function.name}`, lastError);
return [{
name: call.function.name, arguments: call.function.arguments,
result: { success: false, error: lastError },
@@ -1429,9 +1724,9 @@ async function handleExecuting(
spawn_task: '以上 tool 消息是 spawn_task 子代理返回的实际执行结果。严格基于这些数据回答,不要编造。',
};
const nudge = nudges[record.name];
- if (nudge) {
- ctx.messages.push({ role: 'system', content: `⚠️ ${nudge}` });
- }
+ if (nudge) {
+ ctx.messages.push({ role: 'user', content: `⚠️ ${nudge}` });
+ }
}
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
// 记录度量
@@ -1463,7 +1758,7 @@ async function handleObserving(
currentSession: ChatSession | null,
): Promise {
// 中止检查
- if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) return;
+ if (isAborted()) return;
// 硬限制工具消息数量 — 保留最近 40 条
{
const toolIndices: number[] = [];
@@ -1493,13 +1788,13 @@ async function handleObserving(
// ── post_iteration Hook ──
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
- // 增量工具结果截断 — 超过 10 轮的旧结果每 3 轮截断到 500 字符
+ // ── P1-7: 智能工具结果截断 — 保留 JSON 结构,2000 字符软上限 ──
if (ctx.loopCount > 10 && ctx.loopCount % 3 === 0) {
const truncateBefore = ctx.messages.length - 15;
for (let i = 0; i < ctx.messages.length && i < truncateBefore; i++) {
const m = ctx.messages[i];
- if (m.role === 'tool' && m.content && m.content.length > 500) {
- ctx.messages[i] = { ...m, content: m.content.slice(0, 500) + '\n... (已截断旧工具结果)' };
+ if (m.role === 'tool' && m.content && m.content.length > 2000) {
+ ctx.messages[i] = { ...m, content: smartTruncate(m.content, 2000) };
}
}
}
@@ -1525,11 +1820,11 @@ async function handleObserving(
const hasDedupSignal = lastToolContents.some(c => c.includes('⚠️ 重复添加') || c.includes('已跳过') || c.includes('duplicate'));
const recentSuccess = ctx.allToolRecords.filter(r => r.status === 'success').slice(-2);
const allSameTool = recentSuccess.length >= 2 && recentSuccess.every(r => r.name === recentSuccess[0].name);
- if (hasDedupSignal || allSameTool) {
- ctx.messages.push({
- role: 'system',
- content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
- });
+ if (hasDedupSignal || allSameTool) {
+ ctx.messages.push({
+ role: 'user',
+ content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
+ });
logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`);
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
// 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到
@@ -1553,7 +1848,7 @@ async function handleObserving(
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
// 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息
if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) {
- const numCtx = state.get(KEYS.NUM_CTX, 24576);
+ const numCtx = state.get(KEYS.NUM_CTX, 131072);
const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0;
if (usageRatio > 0.7) {
logInfo(`ephemeral 清理跳过: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 压缩即将触发`);
@@ -1567,6 +1862,13 @@ async function handleObserving(
}
}
+ // ── P0-4: 增量压缩 — 消息数达到 120 条时提前触发压缩,防止 Ollama context 超限 ──
+ if (ctx.messages.length >= INCREMENTAL_COMPRESS_AT) {
+ logWarn(`增量压缩触发: ${ctx.messages.length} 条消息 > ${INCREMENTAL_COMPRESS_AT}`);
+ transition(ctx, S.COMPRESSING);
+ return;
+ }
+
transition(ctx, S.REFLECTING);
}
@@ -1582,7 +1884,7 @@ async function handleReflecting(
currentSession: ChatSession | null,
): Promise {
// 中止检查
- if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) return;
+ if (isAborted()) return;
// ── 异常1: 空响应 + 有工具历史 → 模型可能困惑 → 注入提示 ──
if (ctx.toolCalls.length === 0 && !ctx.content.trim() && ctx.loopCount > 1 && ctx.allToolRecords.length > 0) {
logWarn('模型返回空内容(有未处理的工具结果),注入继续提示');
@@ -1749,7 +2051,7 @@ async function handleCompressing(
api: OllamaAPI,
model: string,
): Promise {
- const numCtx = state.get(KEYS.NUM_CTX, 24576);
+ const numCtx = state.get(KEYS.NUM_CTX, 131072);
if (shouldAutoCompress(ctx.messages, numCtx)) {
logInfo('COMPRESSING: 上下文压缩触发');
const compressAC = state.get(KEYS.ABORT_CONTROLLER) || new AbortController();
@@ -1860,7 +2162,7 @@ export async function runAgentLoop(
}
// 检查中止信号
- if (state.get(KEYS.ABORT_CONTROLLER)?.signal.aborted) {
+ if (isAborted()) {
logInfo('ReAct Agent Loop 已中止');
if (ctx.allToolRecords.length > 0) {
state.set('_abortToolRecords', ctx.allToolRecords);
@@ -1869,7 +2171,7 @@ export async function runAgentLoop(
}
// Token 感知的动态迭代预算
- const numCtx = state.get(KEYS.NUM_CTX, 24576);
+ const numCtx = state.get(KEYS.NUM_CTX, 131072);
const usageRatio = estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx;
if (usageRatio > 0.8 && (ctx.maxLoops - ctx.loopCount) > 3) {
const newMax = ctx.loopCount + 3;
@@ -1931,14 +2233,25 @@ export async function runAgentLoop(
}
transition(ctx, S.TERMINATED);
} finally {
- // ── 保存 Plan Mode 进度(中止/异常路径也保留)──
+ // ── P1-8: Plan Mode 断点续传 — 保存完整追踪器到 session,支持跨轮次恢复 ──
if (ctx.mode === 'plan') {
const tracker = getPlanTracker();
if (tracker.active && tracker.steps.length > 0) {
+ // 保存可恢复的完整追踪器(与 formatted status 共存)
+ const resumeData = {
+ steps: tracker.steps.map(s => ({ index: s.index, label: s.label, done: s.done })),
+ total: tracker.total,
+ done: tracker.done,
+ loopCount: ctx.loopCount,
+ savedAt: Date.now(),
+ };
+ state.set('_planResumeData', resumeData);
state.set('_lastPlanStatus', formatPlanStatus());
}
}
clearPlanTracker();
endSessionMetrics();
+ cleanupAbortController();
+ flushAllTraces(); // P3-13: 确保所有轨迹写入 SQLite
}
}
diff --git a/src/renderer/services/agent-metrics.ts b/src/renderer/services/agent-metrics.ts
index 9b6f338..451aa2e 100644
--- a/src/renderer/services/agent-metrics.ts
+++ b/src/renderer/services/agent-metrics.ts
@@ -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';
+}
diff --git a/src/renderer/services/completion-gate.ts b/src/renderer/services/completion-gate.ts
index 9db78e6..897df77 100644
--- a/src/renderer/services/completion-gate.ts
+++ b/src/renderer/services/completion-gate.ts
@@ -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;
}
}
}
diff --git a/src/renderer/services/context-manager.ts b/src/renderer/services/context-manager.ts
index c3f6e6c..0e9092e 100644
--- a/src/renderer/services/context-manager.ts
+++ b/src/renderer/services/context-manager.ts
@@ -133,7 +133,7 @@ export interface ContextBuildOptions {
const DEFAULT_OPTIONS: Required = {
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
];
diff --git a/src/renderer/services/sub-agent.ts b/src/renderer/services/sub-agent.ts
index e7e0890..f2eb349 100644
--- a/src/renderer/services/sub-agent.ts
+++ b/src/renderer/services/sub-agent.ts
@@ -61,7 +61,7 @@ export async function executeSubAgent(
// 如果子代理使用了不同于主 AI 的模型,获取该模型的实际上下文长度
const defaultModel = state.get('_defaultModel', '');
- let numCtx = state.get(KEYS.NUM_CTX, 24576);
+ let numCtx = state.get(KEYS.NUM_CTX, 131072);
if (model !== defaultModel) {
try {
const detail = await api.showModel(model);
diff --git a/src/renderer/services/tool-registry.ts b/src/renderer/services/tool-registry.ts
index ba66fb4..077a772 100644
--- a/src/renderer/services/tool-registry.ts
+++ b/src/renderer/services/tool-registry.ts
@@ -802,8 +802,9 @@ export async function executeTool(toolName: string, args: Record;
+ logToolResult('memory', true, `重复跳过: ${dupErr.existingPreview}`);
+ return { success: true, action, duplicate: true, existing_id: dupErr.existingId, message: `已跳过: 相同内容的记忆已存在 (${dupErr.existingPreview})。不要再重复添加,任务已完成。` };
}
throw err;
}