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++) {
|
||||
appendMessageDOM(msgs[i], i);
|
||||
// P1-1: 基于 Set 的稳定增量 diff
|
||||
const wasExisting = _renderedMsgIndices.size > 0;
|
||||
let isNewMessage = false;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
if (!_renderedMsgIndices.has(i)) {
|
||||
appendMessageDOM(msgs[i], i);
|
||||
_renderedMsgIndices.add(i);
|
||||
isNewMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理超出范围的索引(消息被 undo/retry 移除时)
|
||||
for (const idx of _renderedMsgIndices) {
|
||||
if (idx >= msgs.length) _renderedMsgIndices.delete(idx);
|
||||
}
|
||||
|
||||
if (isNewMessage) {
|
||||
scrollToBottom();
|
||||
} else if (msgs.length > 0) {
|
||||
} else if (msgs.length > 0 && !wasExisting) {
|
||||
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
@@ -314,6 +327,8 @@ export function updateLastAssistantMessage(
|
||||
const lastMsg = currentPlaceholder;
|
||||
if (!lastMsg) return;
|
||||
|
||||
const isFinal = toolCalls !== undefined || timestamp !== undefined;
|
||||
|
||||
if (lastMsg.classList.contains('loading')) {
|
||||
lastMsg.classList.remove('loading');
|
||||
const loadingDots = lastMsg.querySelector('.loading-dots');
|
||||
@@ -351,7 +366,12 @@ export function updateLastAssistantMessage(
|
||||
const contentDiv = lastMsg.querySelector('.msg-content');
|
||||
const safeContent = (content != null) ? String(content) : '';
|
||||
if (contentDiv && safeContent) {
|
||||
contentDiv.innerHTML = safeMarkdown(safeContent);
|
||||
// P0-1: 流式期间纯文本追加(避免每 chunk 全量 Markdown + DOMPurify),最终渲染时才做 Markdown
|
||||
if (isFinal) {
|
||||
contentDiv.innerHTML = safeMarkdown(safeContent);
|
||||
} else {
|
||||
contentDiv.textContent = safeContent;
|
||||
}
|
||||
}
|
||||
|
||||
let thinkBlock = lastMsg.querySelector('.think-block');
|
||||
@@ -484,11 +504,13 @@ export function appendSystemMessage(text: string, tempClass?: string): void {
|
||||
/** v4.1: 清除所有消息 DOM 元素(用于 undo/compress 后强制重建) */
|
||||
export function clearMessagesDOM(): void {
|
||||
messagesContainerEl.innerHTML = '';
|
||||
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
|
||||
}
|
||||
|
||||
export function clearMessages(): void {
|
||||
messagesContainerEl.innerHTML = '';
|
||||
currentPlaceholder = null;
|
||||
_renderedMsgIndices.clear(); // P1-1: 重置 diff 状态
|
||||
}
|
||||
|
||||
/** 清理 currentPlaceholder 引用(当 placeholder 从外部被移除时调用) */
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function loadModels(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据能力为下拉框选项添加图标标记 */
|
||||
/** 根据能力为下拉框选项添加图标标记 + 模型自身上下文长度 */
|
||||
function updateOptionIcons(): void {
|
||||
for (const opt of Array.from(modelSelectEl.options)) {
|
||||
const caps = modelCapabilityCache.get(opt.value);
|
||||
@@ -118,7 +118,9 @@ function updateOptionIcons(): void {
|
||||
if (caps.think) icons.push('🧠');
|
||||
if (caps.vision) icons.push('👁️');
|
||||
if (caps.tools) icons.push('🔧');
|
||||
opt.textContent = icons.length > 0 ? `${base} ${icons.join('')}` : base;
|
||||
// 显示模型自身的上下文长度
|
||||
const modelCtx = caps.contextLength > 0 ? ` [${formatCtxLen(caps.contextLength)}]` : '';
|
||||
opt.textContent = `${base}${modelCtx}${icons.length > 0 ? ' ' + icons.join('') : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,22 +134,22 @@ async function filterEmbedModels(models: Array<{ name: string }>): Promise<void>
|
||||
const caps = info.capabilities || [];
|
||||
const isCompletion = caps.includes('completion');
|
||||
|
||||
// 从 model_info 提取上下文长度
|
||||
let contextLength = 2048;
|
||||
for (const key of Object.keys(info.model_info || {})) {
|
||||
if (key.endsWith('.context_length')) {
|
||||
contextLength = Number(info.model_info![key]) || 2048;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 从 model_info 提取上下文长度(仅用于显示,不再覆盖用户设置)
|
||||
let contextLength = 2048;
|
||||
for (const key of Object.keys(info.model_info || {})) {
|
||||
if (key.endsWith('.context_length')) {
|
||||
contextLength = Number(info.model_info![key]) || 2048;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
modelCapabilityCache.set(m.name, {
|
||||
think: caps.includes('thinking'),
|
||||
vision: caps.includes('vision'),
|
||||
tools: caps.includes('tools'),
|
||||
completion: isCompletion,
|
||||
contextLength,
|
||||
});
|
||||
modelCapabilityCache.set(m.name, {
|
||||
think: caps.includes('thinking'),
|
||||
vision: caps.includes('vision'),
|
||||
tools: caps.includes('tools'),
|
||||
completion: isCompletion,
|
||||
contextLength,
|
||||
});
|
||||
if (!isCompletion) {
|
||||
const opt = modelSelectEl.querySelector(`option[value="${CSS.escape(m.name)}"]`);
|
||||
if (opt) opt.remove();
|
||||
@@ -173,8 +175,6 @@ async function checkModelCapability(modelName: string): Promise<void> {
|
||||
|
||||
if (modelCapabilityCache.has(modelName)) {
|
||||
const cached = modelCapabilityCache.get(modelName)!;
|
||||
state.set(KEYS.NUM_CTX, cached.contextLength);
|
||||
updateCtxTotal(cached.contextLength);
|
||||
applyCapability(modelName, cached);
|
||||
return;
|
||||
}
|
||||
@@ -206,14 +206,6 @@ async function checkModelCapability(modelName: string): Promise<void> {
|
||||
modelCapabilityCache.set(modelName, caps);
|
||||
logDebug(`能力检测: ${modelName}`, `ctx: ${contextLength}, ` + capabilities.join(', '));
|
||||
|
||||
// 自动设置上下文长度为模型实际支持的值
|
||||
state.set(KEYS.NUM_CTX, contextLength);
|
||||
const db = state.get<any>(KEYS.DB);
|
||||
if (db) await db.saveSetting('numCtx', contextLength);
|
||||
|
||||
// 更新模型栏上下文长度显示
|
||||
updateCtxTotal(contextLength);
|
||||
|
||||
applyCapability(modelName, caps);
|
||||
} catch (err) {
|
||||
logWarn(`能力检测失败: ${modelName}`, (err as Error).message);
|
||||
@@ -296,15 +288,21 @@ export function isToolCallingSupported(): boolean {
|
||||
return caps?.tools === true;
|
||||
}
|
||||
|
||||
function formatCtx(n: number): string {
|
||||
export function formatCtxLen(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(0)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/** 更新模型栏上下文长度显示(显示用户配置的值,而非模型自身值) */
|
||||
function updateCtxTotal(contextLength: number): void {
|
||||
const el = document.querySelector('#ctxTotal') as HTMLElement;
|
||||
if (!el) return;
|
||||
el.style.display = 'inline-flex';
|
||||
el.textContent = formatCtx(contextLength);
|
||||
el.textContent = formatCtxLen(contextLength);
|
||||
}
|
||||
|
||||
/** 外部调用:更新上下文长度显示 */
|
||||
export function updateCtxTotalExternal(contextLength: number): void {
|
||||
updateCtxTotal(contextLength);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 处理 \r 进度覆盖:删除 \r 前到上一换行之间的内容
|
||||
text = text.replace(/\r(?!\n)/g, (match, offset) => {
|
||||
const prevNewline = text.lastIndexOf('\n', offset);
|
||||
return prevNewline >= 0 ? '\n' : '';
|
||||
});
|
||||
|
||||
// 处理 ANSI 转义序列
|
||||
// \x1b[XXm 颜色, \x1b[0m 重置, \x1b[1m 加粗, \x1b[K 清除行尾
|
||||
// 批量替换 ANSI 序列为 span(连续同色合并)
|
||||
let result = '';
|
||||
let openSpan = false;
|
||||
let i = 0;
|
||||
const seqRe = /\x1b\[([\d;]*)m/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while (i < text.length) {
|
||||
// 匹配 \x1b[...m 或 \x1b[...K
|
||||
if (text[i] === '\x1b' && text[i + 1] === '[') {
|
||||
const end = text.indexOf('m', i + 2);
|
||||
const endK = text.indexOf('K', i + 2);
|
||||
const seqEnd = end !== -1 && (endK === -1 || end < endK) ? end : endK;
|
||||
while ((match = seqRe.exec(text)) !== null) {
|
||||
// 输出序列之前的文本
|
||||
result += text.slice(lastIndex, match.index);
|
||||
const codes = match[1].split(';');
|
||||
|
||||
// 关闭之前的 span(如果有)
|
||||
// 简化:每个颜色序列独立包裹,不复用 span
|
||||
// 查找对应的文本片段:从当前位置到下一个 ANSI 序列或文本末尾
|
||||
const nextSeq = seqRe.exec(text);
|
||||
const contentStart = match.index + match[0].length;
|
||||
const contentEnd = nextSeq ? nextSeq.index : text.length;
|
||||
seqRe.lastIndex = nextSeq ? nextSeq.index : contentEnd; // 回退
|
||||
|
||||
if (seqEnd !== -1 && seqEnd - i < 20) {
|
||||
const codes = text.substring(i + 2, seqEnd).split(';');
|
||||
const content = text.slice(contentStart, contentEnd);
|
||||
if (!content) { lastIndex = contentEnd; continue; }
|
||||
|
||||
// 忽略 \x1b[K (清除行)
|
||||
if (text[seqEnd] === 'K') {
|
||||
i = seqEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (openSpan) {
|
||||
result += '</span>';
|
||||
openSpan = false;
|
||||
}
|
||||
|
||||
// 检查是否有颜色代码
|
||||
let color = '';
|
||||
let bold = false;
|
||||
for (const code of codes) {
|
||||
if (code === '0' || code === '') {
|
||||
// reset
|
||||
} else if (code === '1') {
|
||||
bold = true;
|
||||
} else if (ANSI_COLORS[code]) {
|
||||
color = ANSI_COLORS[code];
|
||||
}
|
||||
}
|
||||
|
||||
if (color || bold) {
|
||||
const styles: string[] = [];
|
||||
if (color) styles.push(`color:${color}`);
|
||||
if (bold) styles.push('font-weight:bold');
|
||||
result += `<span style="${styles.join(';')}">`;
|
||||
openSpan = true;
|
||||
}
|
||||
|
||||
i = seqEnd + 1;
|
||||
continue;
|
||||
}
|
||||
let color = '';
|
||||
let bold = false;
|
||||
for (const code of codes) {
|
||||
if (code === '1') bold = true;
|
||||
else if (code === '0' || code === '') { /* reset */ }
|
||||
else if (ANSI_COLORS[code]) color = ANSI_COLORS[code];
|
||||
}
|
||||
|
||||
// 处理回车符(ollama pull 等用 \r 更新进度)
|
||||
if (text[i] === '\r' && text[i + 1] !== '\n') {
|
||||
// \r 后面不是 \n → 进度更新,忽略之前的内容
|
||||
// 找到上一个 \n 或开头
|
||||
const lastNewline = result.lastIndexOf('\n');
|
||||
if (lastNewline !== -1) {
|
||||
result = result.substring(0, lastNewline + 1);
|
||||
} else {
|
||||
// 清除当前行(通过移除到最后一个 <span> 之后的内容太复杂,简化处理)
|
||||
const spanStart = result.lastIndexOf('><');
|
||||
if (spanStart !== -1) {
|
||||
// 不处理,保留
|
||||
}
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
if (color || bold) {
|
||||
const style = [color ? `color:${color}` : '', bold ? 'font-weight:bold' : ''].filter(Boolean).join(';');
|
||||
result += `<span style="${style}">${content}</span>`;
|
||||
} else {
|
||||
result += content;
|
||||
}
|
||||
|
||||
result += text[i];
|
||||
i++;
|
||||
lastIndex = contentEnd;
|
||||
}
|
||||
|
||||
if (openSpan) result += '</span>';
|
||||
result += text.slice(lastIndex);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -346,9 +322,11 @@ function appendToolStreamOutput(command: string, type: 'stdout' | 'stderr', data
|
||||
}
|
||||
}
|
||||
|
||||
// 限制最大行数
|
||||
// P1-3: 限制最大行数,但保留系统消息(命令头/退出状态等)
|
||||
if (session.lines.length > 3000) {
|
||||
session.lines = session.lines.slice(-2000);
|
||||
const systemLines = session.lines.filter(l => l.type === 'system');
|
||||
const otherLines = session.lines.filter(l => l.type !== 'system');
|
||||
session.lines = [...systemLines.slice(-10), ...otherLines.slice(-1990)];
|
||||
}
|
||||
|
||||
renderTerminal();
|
||||
@@ -405,9 +383,11 @@ function appendTerminalOutput(sessionId: string, type: 'stdout' | 'stderr', data
|
||||
}
|
||||
}
|
||||
|
||||
// 限制最大行数
|
||||
// P1-3: 限制最大行数,保留系统消息
|
||||
if (session.lines.length > 3000) {
|
||||
session.lines = session.lines.slice(-2000);
|
||||
const systemLines = session.lines.filter(l => l.type === 'system');
|
||||
const otherLines = session.lines.filter(l => l.type !== 'system');
|
||||
session.lines = [...systemLines.slice(-10), ...otherLines.slice(-1990)];
|
||||
}
|
||||
|
||||
renderTerminal();
|
||||
@@ -465,6 +445,8 @@ function clearTerminal(): void {
|
||||
const session = getActiveSession();
|
||||
if (!session) return;
|
||||
session.lines = [];
|
||||
_termRenderedCount = 0; // P0-2: 重置增量计数器
|
||||
if (_termContainer) _termContainer.innerHTML = '';
|
||||
renderTerminal();
|
||||
}
|
||||
|
||||
@@ -544,17 +526,20 @@ export function renderPanel(): void {
|
||||
}
|
||||
|
||||
function renderTerminal(): void {
|
||||
const container = document.querySelector('#wsTermOutput') as HTMLElement;
|
||||
if (!_termContainer) _termContainer = document.querySelector('#wsTermOutput') as HTMLElement;
|
||||
const container = _termContainer;
|
||||
if (!container) return;
|
||||
|
||||
const session = getActiveSession();
|
||||
if (!session) {
|
||||
container.innerHTML = '<div class="ws-term-placeholder">无终端会话</div>';
|
||||
_termRenderedCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 空闲状态:显示 Logo + 信息 + 贴士
|
||||
if (session.lines.length === 0 && !session.running) {
|
||||
_termRenderedCount = 0;
|
||||
renderIdleState(container);
|
||||
startTipRotation(container);
|
||||
return;
|
||||
@@ -563,28 +548,49 @@ function renderTerminal(): void {
|
||||
// 有内容时停止贴士轮转
|
||||
stopTipRotation();
|
||||
|
||||
// 使用 DocumentFragment 优化渲染
|
||||
const wrapper = document.createElement('div');
|
||||
|
||||
for (const line of session.lines) {
|
||||
const div = document.createElement('div');
|
||||
div.className = `ws-term-line ws-term-${line.type}`;
|
||||
div.innerHTML = ansiToHtml(line.text);
|
||||
wrapper.appendChild(div);
|
||||
// ── P0-2: 终端增量渲染 — 只追加新行,不重建已有 DOM ──
|
||||
// 检测重置(行数骤减说明被清空)
|
||||
if (_termRenderedCount > session.lines.length) {
|
||||
container.innerHTML = '';
|
||||
_termRenderedCount = 0;
|
||||
}
|
||||
|
||||
container.innerHTML = wrapper.innerHTML;
|
||||
// 增量追加新行
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = _termRenderedCount; i < session.lines.length; i++) {
|
||||
const line = session.lines[i];
|
||||
const div = document.createElement('div');
|
||||
div.className = `ws-term-line ws-term-${line.type}`;
|
||||
div.innerHTML = ansiToHtml(line.text); // P1-2: ANSI 颜色,正则批量替换
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
if (fragment.childNodes.length > 0) {
|
||||
container.appendChild(fragment);
|
||||
_termRenderedCount = session.lines.length;
|
||||
}
|
||||
|
||||
// 行数超限时清理旧 DOM 节点并同步计数
|
||||
if (container.children.length > 3000) {
|
||||
const excess = container.children.length - 2000;
|
||||
for (let i = 0; i < excess && container.firstChild; i++) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
_termRenderedCount = 2000;
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
if (session.autoScroll) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
container.onscroll = () => {
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
||||
session.autoScroll = atBottom;
|
||||
};
|
||||
// 监听滚动事件(只绑定一次)
|
||||
if (!(container as any)._scrollBound) {
|
||||
(container as any)._scrollBound = true;
|
||||
container.addEventListener('scroll', () => {
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
||||
session.autoScroll = atBottom;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染空闲状态 */
|
||||
@@ -1066,9 +1072,11 @@ function renderToolCard(tc: ToolCallRecord): string {
|
||||
} else if (tc.name === 'create_directory') {
|
||||
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||
} else {
|
||||
// 通用成功显示
|
||||
const summary = JSON.stringify(r).slice(0, 300);
|
||||
resultHtml = `<pre class="tool-result-content">${escapeHtml(summary)}${JSON.stringify(r).length > 300 ? '\n...' : ''}</pre>`;
|
||||
// P2-1: 通用成功显示,统一截断到 500 字符
|
||||
const maxResultChars = 500;
|
||||
const raw = JSON.stringify(r);
|
||||
const summary = raw.length > maxResultChars ? raw.slice(0, maxResultChars) : raw;
|
||||
resultHtml = `<pre class="tool-result-content">${escapeHtml(summary)}${raw.length > maxResultChars ? `\n... (${raw.length - maxResultChars} 字符已截断)` : ''}</pre>`;
|
||||
}
|
||||
} else {
|
||||
resultHtml = `<div class="tool-result-error">${escapeHtml(String(r.error || '未知错误'))}</div>`;
|
||||
|
||||
+12
-5
@@ -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);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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