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:
@@ -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 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
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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++) {
|
||||
// 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) {
|
||||
// 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 从外部被移除时调用) */
|
||||
|
||||
@@ -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,7 +134,7 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
|
||||
const caps = info.capabilities || [];
|
||||
const isCompletion = caps.includes('completion');
|
||||
|
||||
// 从 model_info 提取上下文长度
|
||||
// 从 model_info 提取上下文长度(仅用于显示,不再覆盖用户设置)
|
||||
let contextLength = 2048;
|
||||
for (const key of Object.keys(info.model_info || {})) {
|
||||
if (key.endsWith('.context_length')) {
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (code === '1') bold = true;
|
||||
else if (code === '0' || code === '') { /* reset */ }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理回车符(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);
|
||||
const style = [color ? `color:${color}` : '', bold ? 'font-weight:bold' : ''].filter(Boolean).join(';');
|
||||
result += `<span style="${style}">${content}</span>`;
|
||||
} else {
|
||||
// 清除当前行(通过移除到最后一个 <span> 之后的内容太复杂,简化处理)
|
||||
const spanStart = result.lastIndexOf('><');
|
||||
if (spanStart !== -1) {
|
||||
// 不处理,保留
|
||||
result += content;
|
||||
}
|
||||
lastIndex = contentEnd;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += text[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
// 监听滚动事件(只绑定一次)
|
||||
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
@@ -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 个):git(17 个子操作,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>:可配置超时(默认300s),Ollama 卡死不再永久阻塞</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>🔌 MCP(Model 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>支持认证 Key(HTTP 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
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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<string, { result: ToolResult; timestamp: number }>();
|
||||
|
||||
/** 工具缓存 TTL(毫秒),按工具类型设定 */
|
||||
/** 工具缓存 TTL(毫秒),按工具类型设定。P1-6: default 从 Infinity 改为 60s */
|
||||
const CACHE_TTL_MAP: Record<string, number> = {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<boolean>;
|
||||
}
|
||||
|
||||
/** 保存执行轨迹到 SQLite */
|
||||
async function saveTrace(trace: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
/** P3-13: 带缓冲的轨迹保存 — 批量写入 SQLite,重试失败条目 */
|
||||
const _traceBuffer: Array<Record<string, any>> = [];
|
||||
const TRACE_FLUSH_INTERVAL = 5000; // 5 秒刷新一次
|
||||
const TRACE_MAX_RETRIES = 3;
|
||||
let _traceFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function flushTraces(): Promise<void> {
|
||||
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<string, any>): Promise<void> {
|
||||
error_pattern: trace.errorPattern || null,
|
||||
created_at: trace.createdAt
|
||||
};
|
||||
// 重试最多 TRACE_MAX_RETRIES 次
|
||||
for (let attempt = 0; attempt < TRACE_MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await bridge.db.saveTrace(entry);
|
||||
} catch { /* 不阻塞主流程 */ }
|
||||
break;
|
||||
} catch {
|
||||
if (attempt < TRACE_MAX_RETRIES - 1) {
|
||||
await new Promise(r => setTimeout(r, 200 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存执行轨迹到 SQLite(缓冲写入) */
|
||||
async function saveTrace(trace: Record<string, any>): Promise<void> {
|
||||
_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<void> {
|
||||
if (_traceFlushTimer) { clearTimeout(_traceFlushTimer); _traceFlushTimer = null; }
|
||||
await flushTraces();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// P0-3: AbortController 集中管理 — 防泄漏、防竞态
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** 注册新的 AbortController,自动清理旧实例 */
|
||||
function registerAbortController(): AbortController {
|
||||
const old = state.get<AbortController | null>(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<AbortController | null>(KEYS.ABORT_CONTROLLER)?.signal.aborted === true;
|
||||
}
|
||||
|
||||
/** 清理 AbortController(在 finally 块中调用) */
|
||||
function cleanupAbortController(): void {
|
||||
const ac = state.get<AbortController | null>(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<LoopContext> | null {
|
||||
// Harness: 状态处理器
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** P2-11: 加载自定义文件(SOUL.md / AGENT.md / USER.md),返回 system prompt 片段 */
|
||||
async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]): Promise<void> {
|
||||
// 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<number>(KEYS.NUM_CTX, 24576);
|
||||
const numCtx = state.get<number>(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<number>('streamTimeout', 300_000); // 总超时 300s
|
||||
const IDLE_TIMEOUT_MS = 30_000; // 空闲超时 30s(无新 token)
|
||||
|
||||
let totalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | 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<number>('streamTimeout', 300_000);
|
||||
let streamTimer: ReturnType<typeof setTimeout> | 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<typeof setInterval> | null = null;
|
||||
|
||||
@@ -1118,7 +1401,7 @@ async function handleThinking(
|
||||
stream: true,
|
||||
think: state.get<boolean>('thinkEnabled', false),
|
||||
options: {
|
||||
num_ctx: state.get<number>(KEYS.NUM_CTX, 24576),
|
||||
num_ctx: state.get<number>(KEYS.NUM_CTX, 131072),
|
||||
temperature: state.get<number>('temperature', 0.7)
|
||||
},
|
||||
...(getEnabledToolDefinitions().length > 0 && { tools: getEnabledToolDefinitions() })
|
||||
@@ -1131,6 +1414,7 @@ async function handleThinking(
|
||||
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; }
|
||||
@@ -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,25 +1576,21 @@ async function handleExecuting(
|
||||
currentBatch.push(call);
|
||||
} else if (ALWAYS_PARALLEL.has(call.function.name)) {
|
||||
currentBatch.push(call);
|
||||
} else {
|
||||
const needsPrevResult = currentBatch.some(prev =>
|
||||
TOOLS_WITH_DATA_DEPS.has(call.function.name) && prev.function.name !== call.function.name
|
||||
);
|
||||
if (needsPrevResult) {
|
||||
} else if (hasPathDependency(call, currentBatch)) {
|
||||
// 存在路径依赖 → 新开批次(串行化)
|
||||
batches.push(currentBatch);
|
||||
currentBatch = [call];
|
||||
} else {
|
||||
currentBatch.push(call);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentBatch.length > 0) batches.push(currentBatch);
|
||||
|
||||
if (batches.length > 1) {
|
||||
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<void>(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<void>(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<AbortController | null>(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 },
|
||||
@@ -1430,7 +1725,7 @@ async function handleExecuting(
|
||||
};
|
||||
const nudge = nudges[record.name];
|
||||
if (nudge) {
|
||||
ctx.messages.push({ role: 'system', content: `⚠️ ${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<void> {
|
||||
// 中止检查
|
||||
if (state.get<AbortController | null>(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) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1527,7 +1822,7 @@ async function handleObserving(
|
||||
const allSameTool = recentSuccess.length >= 2 && recentSuccess.every(r => r.name === recentSuccess[0].name);
|
||||
if (hasDedupSignal || allSameTool) {
|
||||
ctx.messages.push({
|
||||
role: 'system',
|
||||
role: 'user',
|
||||
content: `⛔ 检测到重复的工具调用${hasDedupSignal ? '(系统已跳过重复内容)' : ''}。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
|
||||
});
|
||||
logInfo(`重复调用检测: ${recentSuccess[0]?.name || 'memory'}${hasDedupSignal ? ' (去重信号)' : ''},注入⛔终止信号`);
|
||||
@@ -1553,7 +1848,7 @@ async function handleObserving(
|
||||
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
|
||||
// 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息
|
||||
if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) {
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||
const numCtx = state.get<number>(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<void> {
|
||||
// 中止检查
|
||||
if (state.get<AbortController | null>(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<void> {
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||
const numCtx = state.get<number>(KEYS.NUM_CTX, 131072);
|
||||
if (shouldAutoCompress(ctx.messages, numCtx)) {
|
||||
logInfo('COMPRESSING: 上下文压缩触发');
|
||||
const compressAC = state.get<AbortController | null>(KEYS.ABORT_CONTROLLER) || new AbortController();
|
||||
@@ -1860,7 +2162,7 @@ export async function runAgentLoop(
|
||||
}
|
||||
|
||||
// 检查中止信号
|
||||
if (state.get<AbortController | null>(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<number>(KEYS.NUM_CTX, 24576);
|
||||
const numCtx = state.get<number>(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user