v0.16.7: 引擎修复 + 工具面板统一 + AGENT.md 改为仅工作空间加载
核心引擎修复: - 状态转换表补全 THINKING/PARSING/EXECUTING -> COMPRESSING,修复紧急压缩成为死代码的 P1 问题 - 新增跨轮次死循环检测器(软性提示 + 硬性熔断),防止模型陷入重复工具调用死循环 - handleCompressing 空响应回到 THINKING 而非 REFLECTING,避免错误终止 - executeHooks 添加 .catch() 防止未处理的 Promise 拒绝 - ALWAYS_PARALLEL 移除 git 和 browser_evaluate(有副作用的工具不应并行) - thinking fallback:content 为空但有 thinking 时,用 [推理过程] 作为 content 保留上下文 - 8个写类工具添加专用格式化器(含 success + message 字段) - 清理死代码:3个未使用函数 + 3个未使用 import 工具面板统一: - 10个工具独立下拉框统一为1个全局执行模式选择器 - FIFO 队列防止并行 showToolConfirm 导致静默取消 - delete_file 支持 paths 数组参数批量删除 工具定义与实现一致性修复: - run_command 移除未使用的 timeout 参数,描述改为"超时可配置" - list_directory 添加 2000 条截断逻辑 + filter_extension 参数 - calculator 正则移除 ^ 字符(parser 用 ** 替代) - search_files/tree/web_search/fetch_top 描述与实现对齐 消息传递修复: - trimByTokenLimit 改为原子组选择(assistant+tool_calls 与后续 tool 消息作为一组) - 历史工具结果复用 formatToolResultForModel,与当前格式一致 AGENT.md 加载策略变更: - 删除内置 AGENT.md 文件 - 仅从工作空间加载:有则注入,无则跳过 其他修复: - 修复初始化失败 "Cannot convert undefined or null to object"(saveSetting null 导致 JSON.parse 陷阱) - 修复工作空间命令行标签页 idle 状态残留导致样式错乱 版本号: 0.16.5 -> 0.16.7
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-v0.16.2-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.16.7-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/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/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">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license">
|
||||||
@@ -253,7 +253,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
产出:`release/Metona Ollama Setup v0.16.2.exe`
|
产出:`release/Metona Ollama Setup v0.16.7.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -501,7 +501,7 @@ npm start
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run dist
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `release/Metona Ollama Setup v0.16.2.exe`
|
Output: `release/Metona Ollama Setup v0.16.7.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
+15
-17
@@ -10,11 +10,11 @@ Metona Ollama Desktop 是基于 TypeScript + Electron 的 Windows 本地 AI 桌
|
|||||||
|
|
||||||
核心架构:
|
核心架构:
|
||||||
- **ReAct Agent Loop** — 8 状态机驱动的智能体循环(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置)
|
- **ReAct Agent Loop** — 8 状态机驱动的智能体循环(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置)
|
||||||
- **42 个内置工具** — 文件系统(16)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(4)、会话/子代理(3)、系统(6)
|
- **40 个内置工具** — 文件系统(16)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(1)、会话/子代理(3)、系统(6)、Plan Mode(1)
|
||||||
- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate(6 项检查)+ Agent Metrics + 渐进式披露
|
- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate(5 项检查)+ Agent Metrics + 渐进式披露
|
||||||
- **MCP 协议扩展** — JSON-RPC 2.0 over stdio,动态工具发现
|
- **MCP 协议扩展** — JSON-RPC 2.0 over stdio,动态工具发现
|
||||||
- **Plan Mode** — 开关切换,先规划后执行,步骤级进度追踪
|
- **Plan Mode** — 开关切换,先规划后执行,步骤级进度追踪
|
||||||
- **SQLite 存储** — sql.js WASM,6 张表,WAL 模式 + FTS5 全文搜索
|
- **SQLite 存储** — sql.js WASM,5 张表,WAL 模式
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ src/
|
|||||||
│ ├── tool-handlers-git.ts # Git 全操作
|
│ ├── tool-handlers-git.ts # Git 全操作
|
||||||
│ ├── tool-handlers-shared.ts # 共享类型和辅助函数
|
│ ├── tool-handlers-shared.ts # 共享类型和辅助函数
|
||||||
│ └── db/
|
│ └── db/
|
||||||
│ ├── sqlite.ts # SQLite 数据库层(6 张表 + FTS5)
|
│ ├── sqlite.ts # SQLite 数据库层(5 张表)
|
||||||
│ └── sql.js.d.ts # sql.js 类型声明
|
│ └── sql.js.d.ts # sql.js 类型声明
|
||||||
│
|
│
|
||||||
├── renderer/ # 渲染进程
|
├── renderer/ # 渲染进程
|
||||||
@@ -78,24 +78,23 @@ src/
|
|||||||
│ │ ├── toast.ts # Toast 通知
|
│ │ ├── toast.ts # Toast 通知
|
||||||
│ │ ├── token-dashboard.ts # Token 消耗仪表盘(全局 + 会话统计)
|
│ │ ├── token-dashboard.ts # Token 消耗仪表盘(全局 + 会话统计)
|
||||||
│ │ ├── tool-confirm-modal.ts # 工具执行确认对话框
|
│ │ ├── tool-confirm-modal.ts # 工具执行确认对话框
|
||||||
│ │ ├── tools-modal.ts # 工具列表面板(42 个工具卡片)
|
│ │ ├── tools-modal.ts # 工具列表面板(40 个工具卡片)
|
||||||
│ │ └── workspace-panel.ts # 工作空间面板(终端 + 工具卡片 + 文件浏览)
|
│ │ └── workspace-panel.ts # 工作空间面板(终端 + 工具卡片 + 文件浏览)
|
||||||
│ ├── services/ # 15 个服务模块
|
│ ├── services/ # 14 个服务模块
|
||||||
│ │ ├── agent-engine.ts # ★ ReAct Agent Loop 核心引擎(8 状态机)
|
│ │ ├── agent-engine.ts # ★ ReAct Agent Loop 核心引擎(8 状态机)
|
||||||
│ │ ├── tool-registry.ts # 工具注册与调度中心(42 内置 + MCP 动态 + Plan Mode)
|
│ │ ├── tool-registry.ts # 工具注册与调度中心(40 内置 + MCP 动态 + Plan Mode)
|
||||||
│ │ ├── memory-manager.ts # 记忆管理核心(FTS5 + 向量语义搜索)
|
│ │ ├── memory-service.ts # 记忆管理(MEMORY.md 读写 + 格式校验 + 自动提取)
|
||||||
│ │ ├── vector-memory.ts # 向量记忆索引(IVF)
|
|
||||||
│ │ ├── vector-store.ts # 向量存储 + IVF 索引引擎
|
|
||||||
│ │ ├── context-manager.ts # 上下文窗口管理(滑动窗口 + Token 校准 + LLM 压缩)
|
│ │ ├── context-manager.ts # 上下文窗口管理(滑动窗口 + Token 校准 + LLM 压缩)
|
||||||
│ │ ├── sub-agent.ts # 子代理委派(独立上下文 + 超时保护)
|
│ │ ├── sub-agent.ts # 子代理委派(独立上下文 + 超时保护)
|
||||||
│ │ ├── mcp-client.ts # MCP 渲染端客户端
|
│ │ ├── mcp-client.ts # MCP 渲染端客户端
|
||||||
│ │ ├── log-service.ts # 结构化日志(9 级分类)
|
│ │ ├── log-service.ts # 结构化日志(9 级分类)
|
||||||
│ │ ├── crypto.ts # AES-256-GCM 加密
|
│ │ ├── crypto.ts # AES-256-GCM 加密
|
||||||
│ │ ├── hooks.ts # 4 阶段 Hook 系统(pre_tool/post_tool/post_iteration/pre_completion)
|
│ │ ├── hooks.ts # 4 阶段 Hook 系统(pre_tool/post_tool/post_iteration/pre_completion)
|
||||||
│ │ ├── completion-gate.ts # 完成门控(6 项检查,阻断/咨询两级)
|
│ │ ├── completion-gate.ts # 完成门控(5 项检查,阻断/咨询两级)
|
||||||
│ │ ├── agent-metrics.ts # Agent 度量采集 + 错误模式识别 + 改进建议
|
│ │ ├── agent-metrics.ts # Agent 度量采集 + 错误模式识别 + 改进建议
|
||||||
|
│ │ ├── agent-safety.ts # Agent 安全防护(工具阴影检测 + 路径校验)
|
||||||
│ │ ├── context-indexer.ts # 渐进式披露(索引层→接口层→实现层)
|
│ │ ├── context-indexer.ts # 渐进式披露(索引层→接口层→实现层)
|
||||||
│ │ └── verification.ts # 验证系统(DiffAnalyzer + FileChangeAudit)
|
│ │ └── infra-service.ts # 基础设施(全局错误处理 + 配置校验)
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ └── chat-db.ts # 渲染端数据库接口 + IndexedDB→SQLite 迁移
|
│ │ └── chat-db.ts # 渲染端数据库接口 + IndexedDB→SQLite 迁移
|
||||||
│ ├── state/
|
│ ├── state/
|
||||||
@@ -179,11 +178,11 @@ logDebug('调试信息', '可选详情');
|
|||||||
### 5.1 五大子系统
|
### 5.1 五大子系统
|
||||||
|
|
||||||
```
|
```
|
||||||
① Agent 系统 → agent-engine.ts + tool-registry.ts(42 内置工具 + MCP 动态)
|
① Agent 系统 → agent-engine.ts + tool-registry.ts(40 内置工具 + MCP 动态)
|
||||||
② 记忆系统 → memory-manager.ts + vector-memory.ts + vector-store.ts
|
② 记忆系统 → memory-service.ts(MEMORY.md 文件存储 + 格式校验 + 自动提取)
|
||||||
③ 上下文系统 → context-manager.ts + context-indexer.ts(渐进式披露)
|
③ 上下文系统 → context-manager.ts + context-indexer.ts(渐进式披露)
|
||||||
④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程)
|
④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程)
|
||||||
⑤ 数据层 → db/sqlite.ts(SQLite, 6 张表, FTS5)+ chat-db.ts(渲染端接口)
|
⑤ 数据层 → db/sqlite.ts(SQLite, 5 张表)+ chat-db.ts(渲染端接口)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 ReAct Agent Loop(8 状态机)
|
### 5.2 ReAct Agent Loop(8 状态机)
|
||||||
@@ -214,14 +213,13 @@ INIT → THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (CO
|
|||||||
|
|
||||||
### 5.3 SQLite 数据库
|
### 5.3 SQLite 数据库
|
||||||
|
|
||||||
6 张表,WAL 模式 + NORMAL 同步:
|
5 张表,WAL 模式 + NORMAL 同步:
|
||||||
|
|
||||||
| 表 | 用途 | 关键特性 |
|
| 表 | 用途 | 关键特性 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sessions` | 会话 | parent_id 父子关系 |
|
| `sessions` | 会话 | parent_id 父子关系 |
|
||||||
| `messages` | 消息 | 外键级联删除,thinking/tool_calls/attachments/eval_count |
|
| `messages` | 消息 | 外键级联删除,thinking/tool_calls/attachments/eval_count |
|
||||||
| `tool_calls` | 工具调用记录 | 按会话+工具名索引 |
|
| `tool_calls` | 工具调用记录 | 按会话+工具名索引 |
|
||||||
| `memories` | Agent 记忆 | FTS5 全文搜索,向量嵌入,容量 500 上限 |
|
|
||||||
| `settings` | 设置 | JSON 序列化 |
|
| `settings` | 设置 | JSON 序列化 |
|
||||||
| `traces` | ReAct 执行轨迹 | Agent 可观测性 |
|
| `traces` | ReAct 执行轨迹 | Agent 可观测性 |
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.16.2",
|
"version": "0.16.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.16.2",
|
"version": "0.16.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.16.2",
|
"version": "0.16.7",
|
||||||
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
"description": "Metona Ollama - TypeScript + Electron 桌面 AI 聊天客户端",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
|
|||||||
+26
-6
@@ -3,7 +3,7 @@
|
|||||||
* 通过隐藏 BrowserWindow 实现网页加载、截图、JS 执行
|
* 通过隐藏 BrowserWindow 实现网页加载、截图、JS 执行
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
import { BrowserWindow, session } from 'electron';
|
||||||
import { mainWindow } from './main.js';
|
import { mainWindow } from './main.js';
|
||||||
|
|
||||||
function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string, detail?: string): void {
|
function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string, detail?: string): void {
|
||||||
@@ -12,6 +12,12 @@ function sendLog(level: 'info' | 'success' | 'warn' | 'error', message: string,
|
|||||||
|
|
||||||
let agentBrowser: BrowserWindow | null = null;
|
let agentBrowser: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
/** Agent 浏览器使用的独立 session partition(内存隔离,重启清空) */
|
||||||
|
const AGENT_PARTITION = 'agent';
|
||||||
|
|
||||||
|
/** 伪装的 User-Agent — 移除 Electron 字样,使用常见 Chrome UA */
|
||||||
|
const AGENT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
/** 浏览器是否已打开且可用 */
|
/** 浏览器是否已打开且可用 */
|
||||||
export function isBrowserOpen(): boolean {
|
export function isBrowserOpen(): boolean {
|
||||||
return agentBrowser !== null && !agentBrowser.isDestroyed();
|
return agentBrowser !== null && !agentBrowser.isDestroyed();
|
||||||
@@ -41,10 +47,14 @@ function getAgentBrowser(): BrowserWindow {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: false,
|
webSecurity: true, // 开启同源策略(防跨域攻击)
|
||||||
|
partition: AGENT_PARTITION, // 独立 session:与主窗口隔离 cookie/storage/缓存
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 设置伪装 User-Agent
|
||||||
|
agentBrowser.webContents.setUserAgent(AGENT_UA);
|
||||||
|
|
||||||
agentBrowser.on('closed', () => { agentBrowser = null; });
|
agentBrowser.on('closed', () => { agentBrowser = null; });
|
||||||
return agentBrowser;
|
return agentBrowser;
|
||||||
}
|
}
|
||||||
@@ -385,11 +395,21 @@ export async function browserWait(params: { selector?: string; time_ms?: number
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭浏览器 */
|
/** 关闭浏览器并清理 session 资源 */
|
||||||
export function browserClose(): void {
|
export async function browserClose(): Promise<void> {
|
||||||
if (agentBrowser && !agentBrowser.isDestroyed()) {
|
if (agentBrowser && !agentBrowser.isDestroyed()) {
|
||||||
agentBrowser.close();
|
agentBrowser.close();
|
||||||
sendLog('info', '🌐 browser 已关闭');
|
agentBrowser = null;
|
||||||
|
// 清理 Agent session 的缓存和存储数据(内存 partition 关闭后自动清空,这里做显式清理确保彻底)
|
||||||
|
try {
|
||||||
|
const agentSession = session.fromPartition(AGENT_PARTITION);
|
||||||
|
await Promise.all([
|
||||||
|
agentSession.clearCache(),
|
||||||
|
agentSession.clearStorageData({ storages: ['cookies', 'localstorage', 'indexdb', 'shadercache', 'serviceworkers', 'cachestorage'] }),
|
||||||
|
]);
|
||||||
|
} catch { /* 清理失败不阻塞 */ }
|
||||||
|
sendLog('info', '🌐 browser 已关闭,session 已清理');
|
||||||
|
} else {
|
||||||
|
agentBrowser = null;
|
||||||
}
|
}
|
||||||
agentBrowser = null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function persist(): void {
|
|||||||
fs.writeFileSync(tmpPath, buf);
|
fs.writeFileSync(tmpPath, buf);
|
||||||
fs.renameSync(tmpPath, dbPath);
|
fs.renameSync(tmpPath, dbPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 记录到启动日志文件(console.error 会被 main.ts 的 uncaughtException 捕获)
|
// 主进程无 log-service,使用 console.error 输出到 stderr(会被 Electron 日志捕获)
|
||||||
console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`);
|
console.error(`[SQLite persist] 写入失败: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-94
@@ -75,11 +75,11 @@ function summarizeResult(toolName: string, result: Record<string, unknown>): str
|
|||||||
case 'list_directory': return `${result.path} (${result.total} 项${result.truncated ? ', 已截断' : ''})`;
|
case 'list_directory': return `${result.path} (${result.total} 项${result.truncated ? ', 已截断' : ''})`;
|
||||||
case 'search_files': return `"${result.query}" → ${result.total_matches} 匹配 / ${result.total_files} 文件`;
|
case 'search_files': return `"${result.query}" → ${result.total_matches} 匹配 / ${result.total_files} 文件`;
|
||||||
case 'create_directory': return String(result.path);
|
case 'create_directory': return String(result.path);
|
||||||
case 'delete_file': return String(result.path);
|
case 'delete_file': return result.batch ? `批量删除 ${result.successCount}/${result.totalPaths} 个路径` : String(result.path);
|
||||||
case 'move_file': return `${result.source} → ${result.destination}`;
|
case 'move_file': return `${result.source} → ${result.destination}`;
|
||||||
case 'copy_file': return `${result.source} → ${result.destination}`;
|
case 'copy_file': return `${result.source} → ${result.destination}`;
|
||||||
case 'web_fetch': return `${result.status} | ${result.length} chars`;
|
case 'web_fetch': return `${result.status} | ${result.length} chars`;
|
||||||
case 'web_search': return `[${(result as any)._mode === 'searxng' ? 'SearXNG' : '内置'}] "${result.query}" → ${result.total} 条结果`;
|
case 'web_search': return `[${String(result._mode) === 'searxng' ? 'SearXNG' : '内置'}] "${result.query}" → ${result.total} 条结果`;
|
||||||
case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`;
|
case 'edit_file': return `${result.path} (${result.replaceCount} 处替换)`;
|
||||||
case 'get_file_info': return `${result.name} (${result.type}, ${result.size}B)`;
|
case 'get_file_info': return `${result.name} (${result.type}, ${result.size}B)`;
|
||||||
case 'tree': return `${result.path} (${result.fileCount} 文件 / ${result.dirCount} 目录)`;
|
case 'tree': return `${result.path} (${result.fileCount} 文件 / ${result.dirCount} 目录)`;
|
||||||
@@ -89,12 +89,12 @@ function summarizeResult(toolName: string, result: Record<string, unknown>): str
|
|||||||
case 'read_multiple_files': return `${result.total} 个文件`;
|
case 'read_multiple_files': return `${result.total} 个文件`;
|
||||||
case 'git': return `${result.action} ✓`;
|
case 'git': return `${result.action} ✓`;
|
||||||
case 'compress': return `${result.action} → ${result.archive || result.destination}`;
|
case 'compress': return `${result.action} → ${result.archive || result.destination}`;
|
||||||
case 'datetime': return `${(result as any).date || (result as any).iso}`;
|
case 'datetime': return `${result.date || result.iso}`;
|
||||||
case 'calculator': return `${(result as any).expression} = ${(result as any).result}`;
|
case 'calculator': return `${result.expression} = ${result.result}`;
|
||||||
case 'random': return `${(result as any).type === 'pick' ? '🎲 ' + String((result as any).result) : String((result as any).result)}`;
|
case 'random': return `${String(result.type) === 'pick' ? '🎲 ' : ''}${result.result}`;
|
||||||
case 'uuid': return `${(result as any).result}`;
|
case 'uuid': return `${result.result}`;
|
||||||
case 'json_format': return `${(result as any).keys || 0} keys, ${(result as any).formatted_size}B`;
|
case 'json_format': return `${Number(result.keys) || 0} keys, ${result.formatted_size}B`;
|
||||||
case 'hash': return `${(result as any).algorithm}: ${(result as any).hash?.slice(0, 16)}...`;
|
case 'hash': return `${result.algorithm}: ${String(result.hash ?? '').slice(0, 16)}...`;
|
||||||
default: return '完成';
|
default: return '完成';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,8 +206,8 @@ export async function setupIPC(): Promise<void> {
|
|||||||
case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; limit?: number; offset?: number }); break;
|
case 'list_directory': result = await handleListDir(args as { path: string; recursive?: boolean; max_depth?: number; include_hidden?: boolean; filter_extension?: string; limit?: number; offset?: number }); break;
|
||||||
case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; use_regex?: boolean; max_results?: number; file_extensions?: string[] }); break;
|
case 'search_files': result = await handleSearchFiles(args as { path: string; query: string; search_type?: string; case_sensitive?: boolean; use_regex?: boolean; max_results?: number; file_extensions?: string[] }); break;
|
||||||
case 'create_directory': result = await handleCreateDir(args as { path: string }); break;
|
case 'create_directory': result = await handleCreateDir(args as { path: string }); break;
|
||||||
case 'delete_file': result = await handleDeleteFile(args as { path: string; recursive?: boolean }); break;
|
case 'delete_file': result = await handleDeleteFile(args as { path?: string; paths?: string[]; recursive?: boolean }); break;
|
||||||
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string; timeout?: number }); // run_command 自身管理日志
|
case 'run_command': return await handleRunCommand(args as { command: string; cwd?: string }); // run_command 自身管理日志
|
||||||
case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break;
|
case 'move_file': result = await handleMoveFile(args as { source: string; destination: string }); break;
|
||||||
case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break;
|
case 'copy_file': result = await handleCopyFile(args as { source: string; destination: string; recursive?: boolean }); break;
|
||||||
case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }); break;
|
case 'web_fetch': result = await handleWebFetch(args as { url: string; max_chars?: number; extract_mode?: string; mobile_ua?: boolean; retry?: boolean }); break;
|
||||||
@@ -236,7 +236,7 @@ export async function setupIPC(): Promise<void> {
|
|||||||
case 'browser_type': result = await browserType(args.selector as string, args.text as string, args.clear !== false, args.submit as boolean || false); break;
|
case 'browser_type': result = await browserType(args.selector as string, args.text as string, args.clear !== false, args.submit as boolean || false); break;
|
||||||
case 'browser_scroll': result = await browserScroll({ direction: args.direction as string, selector: args.selector as string }); break;
|
case 'browser_scroll': result = await browserScroll({ direction: args.direction as string, selector: args.selector as string }); break;
|
||||||
case 'browser_wait': result = await browserWait({ selector: args.selector as string, time_ms: args.time_ms as number }); break;
|
case 'browser_wait': result = await browserWait({ selector: args.selector as string, time_ms: args.time_ms as number }); break;
|
||||||
case 'browser_close': browserClose(); result = { success: true }; break;
|
case 'browser_close': await browserClose(); result = { success: true }; break;
|
||||||
default: return { success: false, error: `未知工具: ${toolName}` };
|
default: return { success: false, error: `未知工具: ${toolName}` };
|
||||||
}
|
}
|
||||||
// 结果日志
|
// 结果日志
|
||||||
@@ -588,14 +588,12 @@ function validateMemoryContent(content: string): boolean {
|
|||||||
const firstLine = lines[0]?.trim();
|
const firstLine = lines[0]?.trim();
|
||||||
if (firstLine !== '# METONA MEMORY') return false;
|
if (firstLine !== '# METONA MEMORY') return false;
|
||||||
|
|
||||||
// 检查是否有 ## 条目头,且格式正确
|
// 检查 ## 条目头格式是否正确
|
||||||
let entryCount = 0;
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed || trimmed.startsWith('>')) continue;
|
if (!trimmed || trimmed.startsWith('>')) continue;
|
||||||
const match = trimmed.match(/^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i);
|
const match = trimmed.match(/^##\s+(fact|preference|rule)\s*\|\s*id:\s*(mem_\d{8}_\d{3})\s*\|\s*importance:\s*(\d{1,2})\s*\|\s*tags:\s*(.+)$/i);
|
||||||
if (match) {
|
if (match) {
|
||||||
entryCount++;
|
|
||||||
const importance = parseInt(match[3], 10);
|
const importance = parseInt(match[3], 10);
|
||||||
if (importance < 1 || importance > 10) return false;
|
if (importance < 1 || importance > 10) return false;
|
||||||
const tagsStr = match[4]?.trim();
|
const tagsStr = match[4]?.trim();
|
||||||
@@ -635,89 +633,86 @@ function parseVideoInfo(stderr: string): { duration: number; width: number; heig
|
|||||||
return { duration, width, height };
|
return { duration, width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> {
|
async function extractVideoFrames(filePath: string, maxFrames: number, maxWidth: number): Promise<{ success: boolean; frames?: ExtractedFrame[]; videoInfo?: VideoInfo; error?: string }> {
|
||||||
return new Promise(async (resolve) => {
|
const tmpDir = path.join(os.tmpdir(), `metona-video-${crypto.randomBytes(6).toString('hex')}`);
|
||||||
const tmpDir = path.join(os.tmpdir(), `metona-video-${crypto.randomBytes(6).toString('hex')}`);
|
try {
|
||||||
try {
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
||||||
|
|
||||||
// ffmpeg 提取帧:1fps,同时捕获 stderr 解析进度和视频信息
|
// ffmpeg 提取帧:1fps,同时捕获 stderr 解析进度和视频信息
|
||||||
const outPattern = path.join(tmpDir, 'frame_%04d.jpg');
|
const outPattern = path.join(tmpDir, 'frame_%04d.jpg');
|
||||||
let ffmpegStderr = '';
|
let ffmpegStderr = '';
|
||||||
let lastProgressFrame = 0;
|
let lastProgressFrame = 0;
|
||||||
let lastProgressTime = Date.now();
|
let lastProgressTime = Date.now();
|
||||||
|
|
||||||
await new Promise<void>((ffResolve, ffReject) => {
|
await new Promise<void>((ffResolve, ffReject) => {
|
||||||
const child = execFile(getFFmpegPath(), [
|
const child = execFile(getFFmpegPath(), [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', filePath,
|
'-i', filePath,
|
||||||
'-vf', `scale=${maxWidth}:-1,fps=1`,
|
'-vf', `scale=${maxWidth}:-1,fps=1`,
|
||||||
'-q:v', '3',
|
'-q:v', '3',
|
||||||
'-frames:v', String(maxFrames),
|
'-frames:v', String(maxFrames),
|
||||||
'-threads', '2',
|
'-threads', '2',
|
||||||
'-progress', 'pipe:1', // 结构化进度输出到 stdout
|
'-progress', 'pipe:1', // 结构化进度输出到 stdout
|
||||||
outPattern
|
outPattern
|
||||||
], { timeout: 120_000 }, (err) => {
|
], { timeout: 120_000 }, (err) => {
|
||||||
if (err) { ffReject(new Error(`ffmpeg 执行失败: ${err.message}`)); return; }
|
if (err) { ffReject(new Error(`ffmpeg 执行失败: ${err.message}`)); return; }
|
||||||
ffResolve();
|
ffResolve();
|
||||||
});
|
|
||||||
child.stderr?.on('data', (data: Buffer) => {
|
|
||||||
ffmpegStderr += data.toString();
|
|
||||||
// 实时推送进度到渲染进程(每秒一次,覆盖更新)
|
|
||||||
const match = ffmpegStderr.match(/frame=\s*(\d+)/g);
|
|
||||||
if (match) {
|
|
||||||
const last = match[match.length - 1];
|
|
||||||
const currentFrame = parseInt(last.replace(/\D/g, ''));
|
|
||||||
if (currentFrame > lastProgressFrame && Date.now() - lastProgressTime > 1000) {
|
|
||||||
lastProgressFrame = currentFrame;
|
|
||||||
lastProgressTime = Date.now();
|
|
||||||
mainWindow?.webContents.send('video:progress', { current: currentFrame });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
child.stderr?.on('data', (data: Buffer) => {
|
||||||
|
ffmpegStderr += data.toString();
|
||||||
|
// 实时推送进度到渲染进程(每秒一次,覆盖更新)
|
||||||
|
const match = ffmpegStderr.match(/frame=\s*(\d+)/g);
|
||||||
|
if (match) {
|
||||||
|
const last = match[match.length - 1];
|
||||||
|
const currentFrame = parseInt(last.replace(/\D/g, ''));
|
||||||
|
if (currentFrame > lastProgressFrame && Date.now() - lastProgressTime > 1000) {
|
||||||
|
lastProgressFrame = currentFrame;
|
||||||
|
lastProgressTime = Date.now();
|
||||||
|
mainWindow?.webContents.send('video:progress', { current: currentFrame });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 从 stderr 解析视频信息,失败则从帧数估算
|
// 从 stderr 解析视频信息,失败则从帧数估算
|
||||||
let videoInfo = parseVideoInfo(ffmpegStderr);
|
let videoInfo = parseVideoInfo(ffmpegStderr);
|
||||||
if (!videoInfo) {
|
if (!videoInfo) {
|
||||||
videoInfo = { duration: 0, width: 0, height: 0 };
|
videoInfo = { duration: 0, width: 0, height: 0 };
|
||||||
}
|
|
||||||
|
|
||||||
// 读取提取的帧
|
|
||||||
const files = await fs.promises.readdir(tmpDir);
|
|
||||||
const jpegs = files.filter(f => f.endsWith('.jpg')).sort();
|
|
||||||
if (jpegs.length === 0) {
|
|
||||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
resolve({ success: false, error: '没有提取到视频帧' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没解析到时长,从帧数估算(fps=1)
|
|
||||||
if (videoInfo.duration <= 0) {
|
|
||||||
videoInfo.duration = jpegs.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frames: ExtractedFrame[] = [];
|
|
||||||
for (let i = 0; i < jpegs.length; i++) {
|
|
||||||
const f = jpegs[i];
|
|
||||||
const buf = await fs.promises.readFile(path.join(tmpDir, f));
|
|
||||||
if (buf.length < 200) continue;
|
|
||||||
const timestampSec = i; // fps=1,帧索引 = 秒数
|
|
||||||
frames.push({
|
|
||||||
name: `frame_${String(i + 1).padStart(2, '0')}_${timestampSec.toFixed(1)}s.jpg`,
|
|
||||||
base64: buf.toString('base64'),
|
|
||||||
timestampSeconds: timestampSec
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
|
|
||||||
resolve({ success: true, frames, videoInfo });
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
sendLog('error', '视频帧提取失败', (err as Error).message);
|
|
||||||
resolve({ success: false, error: (err as Error).message });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 读取提取的帧
|
||||||
|
const files = await fs.promises.readdir(tmpDir);
|
||||||
|
const jpegs = files.filter(f => f.endsWith('.jpg')).sort();
|
||||||
|
if (jpegs.length === 0) {
|
||||||
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
return { success: false, error: '没有提取到视频帧' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没解析到时长,从帧数估算(fps=1)
|
||||||
|
if (videoInfo.duration <= 0) {
|
||||||
|
videoInfo.duration = jpegs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames: ExtractedFrame[] = [];
|
||||||
|
for (let i = 0; i < jpegs.length; i++) {
|
||||||
|
const f = jpegs[i];
|
||||||
|
const buf = await fs.promises.readFile(path.join(tmpDir, f));
|
||||||
|
if (buf.length < 200) continue;
|
||||||
|
const timestampSec = i; // fps=1,帧索引 = 秒数
|
||||||
|
frames.push({
|
||||||
|
name: `frame_${String(i + 1).padStart(2, '0')}_${timestampSec.toFixed(1)}s.jpg`,
|
||||||
|
base64: buf.toString('base64'),
|
||||||
|
timestampSeconds: timestampSec
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
return { success: true, frames, videoInfo };
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
sendLog('error', '视频帧提取失败', (err as Error).message);
|
||||||
|
return { success: false, error: (err as Error).message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -197,7 +197,7 @@ app.on('window-all-closed', () => {
|
|||||||
app.on('before-quit', async () => {
|
app.on('before-quit', async () => {
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
// 清理浏览器
|
// 清理浏览器
|
||||||
browserClose();
|
browserClose().catch(() => {});
|
||||||
// 清理 MCP 服务器
|
// 清理 MCP 服务器
|
||||||
stopAllServers();
|
stopAllServers();
|
||||||
// 清理所有工作空间进程
|
// 清理所有工作空间进程
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ export function createMenu(): void {
|
|||||||
dialog.showMessageBox(mainWindow!, {
|
dialog.showMessageBox(mainWindow!, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: '关于 Metona Ollama',
|
title: '关于 Metona Ollama',
|
||||||
message: 'Metona Ollama Desktop v0.16.2',
|
message: 'Metona Ollama Desktop v0.16.7',
|
||||||
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
detail: 'TypeScript + Electron Ollama AI 聊天客户端\n\nhttps://gitee.com/thzxx/metona-ollama',
|
||||||
icon: getIconPath()
|
icon: getIconPath()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -243,21 +243,24 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
|
|||||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||||
|
|
||||||
const startOffset = params.offset || 0;
|
const startOffset = params.offset || 0;
|
||||||
|
const MAX_ENTRIES = 2000;
|
||||||
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
|
const entries: Array<{ name: string; type: string; size: number | null; modified: string }> = [];
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
async function scanDir(dir: string, depth: number): Promise<void> {
|
async function scanDir(dir: string, depth: number): Promise<void> {
|
||||||
|
if (truncated) return;
|
||||||
if (params.recursive && params.max_depth && depth > params.max_depth) return;
|
if (params.recursive && params.max_depth && depth > params.max_depth) return;
|
||||||
|
|
||||||
const items = await fs.readdir(dir, { withFileTypes: true });
|
const items = await fs.readdir(dir, { withFileTypes: true });
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (truncated) return;
|
||||||
if (!params.include_hidden && item.name.startsWith('.')) continue;
|
if (!params.include_hidden && item.name.startsWith('.')) continue;
|
||||||
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
|
if (params.filter_extension && item.isFile() && !item.name.endsWith(params.filter_extension)) continue;
|
||||||
|
|
||||||
const fullPath = path.join(dir, item.name);
|
const fullPath = path.join(dir, item.name);
|
||||||
|
|
||||||
// P0 修复:递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树)
|
// 递归模式下先递归子目录,再做分页跳过(避免跳过目录时丢失子树)
|
||||||
if (params.recursive && item.isDirectory()) {
|
if (params.recursive && item.isDirectory()) {
|
||||||
await scanDir(fullPath, depth + 1);
|
await scanDir(fullPath, depth + 1);
|
||||||
continue;
|
continue;
|
||||||
@@ -266,6 +269,9 @@ export async function handleListDir(params: { path: string; recursive?: boolean;
|
|||||||
// 分页:跳过前 offset 个条目(仅对非目录条目计数)
|
// 分页:跳过前 offset 个条目(仅对非目录条目计数)
|
||||||
if (skipped < startOffset) { skipped++; continue; }
|
if (skipped < startOffset) { skipped++; continue; }
|
||||||
|
|
||||||
|
// 截断保护:超过 2000 条标记 truncated
|
||||||
|
if (entries.length >= MAX_ENTRIES) { truncated = true; return; }
|
||||||
|
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(fullPath);
|
||||||
entries.push({
|
entries.push({
|
||||||
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
|
name: params.recursive ? path.relative(dirPath, fullPath) : item.name,
|
||||||
@@ -413,9 +419,67 @@ export async function handleCreateDir(params: { path: string }): Promise<ToolRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDeleteFile(params: { path: string; recursive?: boolean }): Promise<ToolResult> {
|
export async function handleDeleteFile(params: { path?: string; paths?: string[]; recursive?: boolean }): Promise<ToolResult> {
|
||||||
|
// 收集所有要删除的路径
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
if (params.paths && Array.isArray(params.paths) && params.paths.length > 0) {
|
||||||
|
allPaths.push(...params.paths);
|
||||||
|
}
|
||||||
|
if (params.path) {
|
||||||
|
allPaths.push(params.path);
|
||||||
|
}
|
||||||
|
if (allPaths.length === 0) {
|
||||||
|
return { success: false, error: '未提供 path 或 paths 参数' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个路径 — 保持原逻辑(返回单个结果)
|
||||||
|
if (allPaths.length === 1) {
|
||||||
|
return handleDeleteSingle(allPaths[0], params.recursive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const results: Array<{ path: string; success: boolean; error?: string; type?: string; deletedSize?: number; filesDeleted?: number }> = [];
|
||||||
|
let totalDeletedSize = 0;
|
||||||
|
let totalFilesDeleted = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
for (const p of allPaths) {
|
||||||
|
const result = await handleDeleteSingle(p, params.recursive);
|
||||||
|
if (result.success) {
|
||||||
|
successCount++;
|
||||||
|
totalDeletedSize += (result as Record<string, unknown>).deletedSize as number || 0;
|
||||||
|
totalFilesDeleted += (result as Record<string, unknown>).filesDeleted as number || 1;
|
||||||
|
results.push({
|
||||||
|
path: (result as Record<string, unknown>).path as string,
|
||||||
|
success: true,
|
||||||
|
type: (result as Record<string, unknown>).type as string,
|
||||||
|
deletedSize: (result as Record<string, unknown>).deletedSize as number,
|
||||||
|
...((result as Record<string, unknown>).filesDeleted !== undefined && { filesDeleted: (result as Record<string, unknown>).filesDeleted as number }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
results.push({ path: p, success: false, error: (result as Record<string, unknown>).error as string });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: failCount === 0,
|
||||||
|
batch: true,
|
||||||
|
totalPaths: allPaths.length,
|
||||||
|
successCount,
|
||||||
|
failCount,
|
||||||
|
results,
|
||||||
|
deletedSize: totalDeletedSize,
|
||||||
|
...(totalFilesDeleted > 0 && { filesDeleted: totalFilesDeleted }),
|
||||||
|
...(failCount > 0 && { error: `${failCount} 个路径删除失败` }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单个文件或目录 */
|
||||||
|
async function handleDeleteSingle(rawPath: string, recursive?: boolean): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const filePath = resolvePath(params.path);
|
const filePath = resolvePath(rawPath);
|
||||||
const allowed = checkPathAllowed(filePath, 'write');
|
const allowed = checkPathAllowed(filePath, 'write');
|
||||||
if (!allowed.ok) return { success: false, error: allowed.reason };
|
if (!allowed.ok) return { success: false, error: allowed.reason };
|
||||||
|
|
||||||
@@ -442,7 +506,7 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole
|
|||||||
await countDir(filePath);
|
await countDir(filePath);
|
||||||
deletedCount = fileCount;
|
deletedCount = fileCount;
|
||||||
|
|
||||||
if (params.recursive) {
|
if (recursive) {
|
||||||
await fs.rm(filePath, { recursive: true, force: true });
|
await fs.rm(filePath, { recursive: true, force: true });
|
||||||
} else {
|
} else {
|
||||||
await fs.rmdir(filePath);
|
await fs.rmdir(filePath);
|
||||||
@@ -453,6 +517,7 @@ export async function handleDeleteFile(params: { path: string; recursive?: boole
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true, path: filePath, deleted: true,
|
success: true, path: filePath, deleted: true,
|
||||||
|
message: `已删除${isDir ? '目录' : '文件'}:${filePath}`,
|
||||||
type: isDir ? 'directory' : 'file',
|
type: isDir ? 'directory' : 'file',
|
||||||
...(isDir && { filesDeleted: deletedCount }),
|
...(isDir && { filesDeleted: deletedCount }),
|
||||||
deletedSize,
|
deletedSize,
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { mainWindow } from './main.js';
|
|||||||
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
import { sendLog, resolvePath, type ToolResult } from './tool-handlers-shared.js';
|
||||||
import { getWorkspaceDir } from './workspace.js';
|
import { getWorkspaceDir } from './workspace.js';
|
||||||
import { getSetting } from './db/sqlite.js';
|
import { getSetting } from './db/sqlite.js';
|
||||||
|
import { browserOpen, browserExtract, browserClose } from './browser.js';
|
||||||
|
|
||||||
/** 当前工具命令进程(用于用户手动终止) */
|
/** 当前工具命令进程(用于用户手动终止) */
|
||||||
let _toolProc: ReturnType<typeof spawn> | null = null;
|
let _toolProc: ReturnType<typeof spawn> | null = null;
|
||||||
|
|
||||||
export async function handleRunCommand(params: { command: string; cwd?: string; timeout?: number }): Promise<ToolResult> {
|
export async function handleRunCommand(params: { command: string; cwd?: string }): Promise<ToolResult> {
|
||||||
try {
|
try {
|
||||||
const cmdCheck = checkCommandAllowed(params.command);
|
const cmdCheck = checkCommandAllowed(params.command);
|
||||||
if (!cmdCheck.ok) {
|
if (!cmdCheck.ok) {
|
||||||
@@ -375,8 +376,6 @@ function htmlToMarkdown(html: string): string {
|
|||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
import { browserOpen, browserExtract, browserClose } from './browser.js';
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// web_fetch 重试配置
|
// web_fetch 重试配置
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
@@ -407,7 +406,7 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s
|
|||||||
if (!openResult.success) return null;
|
if (!openResult.success) return null;
|
||||||
await new Promise(r => setTimeout(r, 2500)); // 等待 JS 渲染
|
await new Promise(r => setTimeout(r, 2500)); // 等待 JS 渲染
|
||||||
const extractResult = await browserExtract();
|
const extractResult = await browserExtract();
|
||||||
browserClose();
|
await browserClose();
|
||||||
if (extractResult.success && extractResult.text && extractResult.text.length > 0) {
|
if (extractResult.success && extractResult.text && extractResult.text.length > 0) {
|
||||||
let text = extractResult.text;
|
let text = extractResult.text;
|
||||||
if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars);
|
if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars);
|
||||||
@@ -419,7 +418,7 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
try { browserClose(); } catch { /* */ }
|
try { await browserClose(); } catch { /* */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1649,7 +1648,7 @@ export function handleCalculator(params: { expression: string }): ToolResult {
|
|||||||
|
|
||||||
function safeCalc(expr: string): number {
|
function safeCalc(expr: string): number {
|
||||||
expr = expr.replace(/\s+/g, '');
|
expr = expr.replace(/\s+/g, '');
|
||||||
if (!/^[\d+\-*/().%^]+$/.test(expr)) {
|
if (!/^[\d+\-*/().%]+$/.test(expr)) {
|
||||||
throw new Error('表达式包含非法字符');
|
throw new Error('表达式包含非法字符');
|
||||||
}
|
}
|
||||||
let pos = 0;
|
let pos = 0;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { formatSize } from '../utils/utils.js';
|
import { formatSize } from '../utils/utils.js';
|
||||||
import { OllamaAPI } from '../api/ollama.js';
|
import { OllamaAPI } from '../api/ollama.js';
|
||||||
import { logConnection, logError, logDebug } from '../services/log-service.js';
|
import { logConnection, logError, logDebug, logInfo } from '../services/log-service.js';
|
||||||
|
|
||||||
let connStatusEl: HTMLElement;
|
let connStatusEl: HTMLElement;
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export async function updateConnectionInfo(): Promise<void> {
|
|||||||
badge.className = 'status-badge success';
|
badge.className = 'status-badge success';
|
||||||
verEl.textContent = `v${version.version}`;
|
verEl.textContent = `v${version.version}`;
|
||||||
corsHint.style.display = 'none';
|
corsHint.style.display = 'none';
|
||||||
logConnection('info', `Ollama v${version.version}`);
|
logInfo(`Ollama 服务器版本: v${version.version}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
badge.textContent = '✗ 未连接';
|
badge.textContent = '✗ 未连接';
|
||||||
badge.className = 'status-badge error';
|
badge.className = 'status-badge error';
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { showToast } from './toast.js';
|
|||||||
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
|
import { addToolCard, startToolCard, updateToolCard, clearToolCardsExternal, clearTerminalExternal, getWorkspaceDirPath, hasActiveCards, showWorkingHint, clearWorkingHint } from './workspace-panel.js';
|
||||||
import { ChatDB } from '../db/chat-db.js';
|
import { ChatDB } from '../db/chat-db.js';
|
||||||
import { OllamaAPI } from '../api/ollama.js';
|
import { OllamaAPI } from '../api/ollama.js';
|
||||||
import { runAgentLoop } from '../services/agent-engine.js';
|
import { runAgentLoop, formatToolResultForModel } from '../services/agent-engine.js';
|
||||||
import { estimateTokens } from '../services/context-manager.js';
|
import { estimateTokens } from '../services/context-manager.js';
|
||||||
import { showToolConfirm } from './tool-confirm-modal.js';
|
import { showToolConfirm } from './tool-confirm-modal.js';
|
||||||
|
import { showConfirm } from './prompt-modal.js';
|
||||||
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
import { logInfo, logStream, logError, logSuccess, logWarn, resetVideoProgress, updateVideoProgress } from '../services/log-service.js';
|
||||||
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
import type { ChatSession, ChatMessage, OllamaStreamChunk, OllamaMessage, FileContent, ChatFile, ToolCallRecord, AgentMode } from '../types.js';
|
||||||
|
|
||||||
@@ -95,9 +96,9 @@ export function initInputArea(): void {
|
|||||||
// Ctrl+K: 清空聊天(需要确认)
|
// Ctrl+K: 清空聊天(需要确认)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (confirm('确定要清空当前对话吗?')) {
|
showConfirm('确定要清空当前对话吗?', '清空对话').then(ok => {
|
||||||
clearMessages();
|
if (ok) clearMessages();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
// Escape: 停止生成(当正在流式输出时)
|
// Escape: 停止生成(当正在流式输出时)
|
||||||
if (e.key === 'Escape' && state.get<boolean>(KEYS.IS_STREAMING)) {
|
if (e.key === 'Escape' && state.get<boolean>(KEYS.IS_STREAMING)) {
|
||||||
@@ -1023,17 +1024,21 @@ function buildHistoryMessages(msgs: ChatMessage[], maxCount = 20): OllamaMessage
|
|||||||
result.push(assistantMsg);
|
result.push(assistantMsg);
|
||||||
|
|
||||||
// ── 注入 tool 结果消息(role: 'tool')──
|
// ── 注入 tool 结果消息(role: 'tool')──
|
||||||
|
// 复用 formatToolResultForModel 保持与当前 Loop 格式一致
|
||||||
if (msg.toolCalls?.length) {
|
if (msg.toolCalls?.length) {
|
||||||
for (const tc of msg.toolCalls) {
|
for (const tc of msg.toolCalls) {
|
||||||
if (!tc.result) continue;
|
if (!tc.result) continue;
|
||||||
if (result.length >= maxCount) break;
|
if (result.length >= maxCount) break;
|
||||||
const resultContent = tc.status === 'success'
|
const formattedResult = formatToolResultForModel(tc.name, tc.result);
|
||||||
? JSON.stringify(tc.result)
|
// R92: 工具结果格式标准化 — 添加统一头信息 + 数据边界标记
|
||||||
: JSON.stringify({ success: false, error: tc.result.error || '工具执行失败' });
|
const resultDuration = Date.now() - tc.timestamp;
|
||||||
|
const resultSize = formattedResult.length;
|
||||||
|
const sizeCategory = resultSize > 10000 ? 'large' : resultSize > 2000 ? 'medium' : 'small';
|
||||||
|
const r92Header = `[工具:${tc.name} 状态:${tc.status} 耗时:${resultDuration}ms 大小:${sizeCategory}(${resultSize}字符)]`;
|
||||||
result.push({
|
result.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
tool_name: tc.name,
|
tool_name: tc.name,
|
||||||
content: resultContent,
|
content: `<<<TOOL_RESULT_START name="${tc.name}">>>\n${r92Header}\n${formattedResult}\n<<<TOOL_RESULT_END>>>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,51 @@ export function initSettingsModal(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 子代理相关设置(原顶层事件监听器移入此处,确保 DOM 已就绪)──
|
||||||
|
// 子代理模型设置保存
|
||||||
|
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
if (!db) return;
|
||||||
|
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
|
||||||
|
await db.saveSetting('subAgentModel', val);
|
||||||
|
logSetting('子代理默认模型', val || '跟随当前模型');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 子代理最大轮次
|
||||||
|
const saveSubAgentMaxLoops = debounce(async () => {
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
|
||||||
|
const maxLoops = val ? parseInt(val) : 10;
|
||||||
|
state.set('subAgentMaxLoops', maxLoops);
|
||||||
|
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
|
||||||
|
logSetting('子代理最大轮次', `${maxLoops} 轮`);
|
||||||
|
}, 500);
|
||||||
|
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
|
||||||
|
|
||||||
|
// 子代理超时(秒)
|
||||||
|
const saveSubAgentTimeout = debounce(async () => {
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
|
||||||
|
const sec = val ? parseInt(val) : -1; // -1=默认
|
||||||
|
const ms = sec >= 0 ? sec * 1000 : 300_000;
|
||||||
|
state.set('subAgentTimeout', ms);
|
||||||
|
if (db) await db.saveSetting('subAgentTimeout', sec);
|
||||||
|
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
|
||||||
|
}, 500);
|
||||||
|
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
||||||
|
|
||||||
|
// ── 看门狗超时(分钟,0=禁用)──
|
||||||
|
const saveLoopWatchdog = debounce(async () => {
|
||||||
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
||||||
|
const minutes = val ? parseInt(val) : -1;
|
||||||
|
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
|
||||||
|
state.set('loopWatchdogMs', ms);
|
||||||
|
if (db) await db.saveSetting('loopWatchdogMs', ms);
|
||||||
|
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
|
||||||
|
}, 500);
|
||||||
|
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openSettingsModal(): void {
|
export function openSettingsModal(): void {
|
||||||
@@ -429,50 +474,6 @@ async function populateSubAgentModels(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 子代理模型设置保存
|
|
||||||
document.querySelector('#selectSubAgentModel')?.addEventListener('change', async () => {
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
|
||||||
if (!db) return;
|
|
||||||
const val = (document.querySelector('#selectSubAgentModel') as HTMLSelectElement).value;
|
|
||||||
await db.saveSetting('subAgentModel', val);
|
|
||||||
logSetting('子代理默认模型', val || '跟随当前模型');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 子代理最大轮次
|
|
||||||
const saveSubAgentMaxLoops = debounce(async () => {
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
|
||||||
const val = (document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value.trim();
|
|
||||||
const maxLoops = val ? parseInt(val) : 10;
|
|
||||||
state.set('subAgentMaxLoops', maxLoops);
|
|
||||||
if (db) await db.saveSetting('subAgentMaxLoops', maxLoops);
|
|
||||||
logSetting('子代理最大轮次', `${maxLoops} 轮`);
|
|
||||||
}, 500);
|
|
||||||
document.querySelector('#inputSubAgentMaxLoops')!.addEventListener('input', saveSubAgentMaxLoops);
|
|
||||||
|
|
||||||
// 子代理超时(秒)
|
|
||||||
const saveSubAgentTimeout = debounce(async () => {
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
|
||||||
const val = (document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value.trim();
|
|
||||||
const sec = val ? parseInt(val) : -1; // -1=默认
|
|
||||||
const ms = sec >= 0 ? sec * 1000 : 300_000;
|
|
||||||
state.set('subAgentTimeout', ms);
|
|
||||||
if (db) await db.saveSetting('subAgentTimeout', sec);
|
|
||||||
logSetting('子代理超时', sec >= 0 ? (sec === 0 ? '已禁用' : `${sec}s`) : '默认(300s)');
|
|
||||||
}, 500);
|
|
||||||
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
|
||||||
|
|
||||||
// ── 看门狗超时(分钟,0=禁用)──
|
|
||||||
const saveLoopWatchdog = debounce(async () => {
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
|
||||||
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
|
||||||
const minutes = val ? parseInt(val) : -1;
|
|
||||||
const ms = minutes > 0 ? minutes * 60_000 : (minutes === 0 ? 0 : 1_800_000); // 默认30分钟
|
|
||||||
state.set('loopWatchdogMs', ms);
|
|
||||||
if (db) await db.saveSetting('loopWatchdogMs', ms);
|
|
||||||
logSetting('看门狗超时', minutes >= 0 ? (minutes === 0 ? '已禁用' : `${minutes}分钟`) : '默认(30分钟)');
|
|
||||||
}, 500);
|
|
||||||
document.querySelector('#inputLoopWatchdog')!.addEventListener('input', saveLoopWatchdog);
|
|
||||||
|
|
||||||
async function importSessions(filePath: string): Promise<void> {
|
async function importSessions(filePath: string): Promise<void> {
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { formatTime } from '../utils/utils.js';
|
import { formatTime, escapeHtml } from '../utils/utils.js';
|
||||||
import { logError } from '../services/log-service.js';
|
import { logError } from '../services/log-service.js';
|
||||||
import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js';
|
import { getEffectiveNumCtx, getModelContextLength } from './model-bar.js';
|
||||||
import { estimateTokens } from '../services/context-manager.js';
|
import { estimateTokens } from '../services/context-manager.js';
|
||||||
@@ -39,17 +39,6 @@ interface AllSessionsTokenStats {
|
|||||||
totals: GlobalTokenTotals;
|
totals: GlobalTokenTotals;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P1 #5: HTML 转义,防止 XSS */
|
|
||||||
function escapeHtml(s: unknown): string {
|
|
||||||
if (s == null) return '';
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开仪表盘 */
|
/** 打开仪表盘 */
|
||||||
export function openTokenDashboard(): void {
|
export function openTokenDashboard(): void {
|
||||||
if (!dashboardModalEl) return;
|
if (!dashboardModalEl) return;
|
||||||
@@ -198,7 +187,7 @@ async function fetchGlobalStats(): Promise<AllSessionsTokenStats | null> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('Token Dashboard: 获取全局统计失败', String(err));
|
logError('Token Dashboard: 获取全局统计失败', (err as Error).message);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ToolConfirmModal - 工具调用确认对话框
|
* ToolConfirmModal - 工具调用确认对话框
|
||||||
|
*
|
||||||
|
* 队列模式:当多个工具并行执行需要确认时,按 FIFO 队列逐个确认,
|
||||||
|
* 避免单例竞态导致前一个确认被自动取消。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ToolCall } from '../types.js';
|
import type { ToolCall } from '../types.js';
|
||||||
@@ -8,9 +11,18 @@ import { logDebug, logInfo } from '../services/log-service.js';
|
|||||||
import { escapeHtml } from '../utils/utils.js';
|
import { escapeHtml } from '../utils/utils.js';
|
||||||
|
|
||||||
let modalEl: HTMLElement | null = null;
|
let modalEl: HTMLElement | null = null;
|
||||||
let resolveConfirm: ((confirmed: boolean) => void) | null = null;
|
let confirmBtn: HTMLButtonElement | null = null;
|
||||||
|
let cancelBtn: HTMLButtonElement | null = null;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
|
/** 确认请求队列 */
|
||||||
|
interface ConfirmRequest {
|
||||||
|
call: ToolCall;
|
||||||
|
resolve: (confirmed: boolean) => void;
|
||||||
|
}
|
||||||
|
const confirmQueue: ConfirmRequest[] = [];
|
||||||
|
let isShowing = false;
|
||||||
|
|
||||||
export function initToolConfirmModal(): void {
|
export function initToolConfirmModal(): void {
|
||||||
modalEl = document.querySelector('#toolConfirmModal')!;
|
modalEl = document.querySelector('#toolConfirmModal')!;
|
||||||
if (!modalEl || initialized) return;
|
if (!modalEl || initialized) return;
|
||||||
@@ -18,7 +30,7 @@ export function initToolConfirmModal(): void {
|
|||||||
|
|
||||||
modalEl.addEventListener('click', (e) => {
|
modalEl.addEventListener('click', (e) => {
|
||||||
if (e.target === modalEl) {
|
if (e.target === modalEl) {
|
||||||
cancelConfirm();
|
cancelCurrent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -31,91 +43,105 @@ export function showToolConfirm(call: ToolCall): Promise<boolean> {
|
|||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
modalEl.addEventListener('click', (e) => {
|
modalEl.addEventListener('click', (e) => {
|
||||||
if (e.target === modalEl) cancelConfirm();
|
if (e.target === modalEl) cancelCurrent();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有未完成的确认,先取消
|
// 加入队列,如果当前没有显示的确认框,则立即显示
|
||||||
if (resolveConfirm) {
|
confirmQueue.push({ call, resolve });
|
||||||
const oldResolve = resolveConfirm;
|
if (!isShowing) {
|
||||||
resolveConfirm = null;
|
showNext();
|
||||||
oldResolve(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveConfirm = resolve;
|
|
||||||
|
|
||||||
const icon = getToolIcon(call.function.name);
|
|
||||||
const name = formatToolName(call.function.name);
|
|
||||||
logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments));
|
|
||||||
|
|
||||||
const titleEl = modalEl.querySelector('#toolConfirmTitle')!;
|
|
||||||
const bodyEl = modalEl.querySelector('#toolConfirmBody')!;
|
|
||||||
const confirmBtn = modalEl.querySelector('#toolConfirmBtn')! as HTMLButtonElement;
|
|
||||||
const cancelBtn = modalEl.querySelector('#toolCancelBtn')! as HTMLButtonElement;
|
|
||||||
|
|
||||||
titleEl.textContent = `${icon} 确认操作:${name}`;
|
|
||||||
|
|
||||||
let bodyHtml = `<div class="tool-confirm-info">`;
|
|
||||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">操作:</span><span>${name}</span></div>`;
|
|
||||||
|
|
||||||
const args = call.function.arguments || {};
|
|
||||||
if (call.function.name === 'read_file' || call.function.name === 'write_file' ||
|
|
||||||
call.function.name === 'list_directory' || call.function.name === 'search_files' ||
|
|
||||||
call.function.name === 'create_directory' || call.function.name === 'delete_file') {
|
|
||||||
if (args.path) {
|
|
||||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径:</span><code>${escapeHtml(String(args.path))}</code></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (call.function.name === 'run_command') {
|
|
||||||
if (args.command) {
|
|
||||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">命令:</span><code>${escapeHtml(String(args.command))}</code></div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (call.function.name === 'write_file' && args.content) {
|
|
||||||
const preview = String(args.content).slice(0, 500);
|
|
||||||
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">内容预览:</span></div>`;
|
|
||||||
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}</pre>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyHtml += `</div>`;
|
|
||||||
bodyEl.innerHTML = bodyHtml;
|
|
||||||
|
|
||||||
modalEl.style.display = '';
|
|
||||||
|
|
||||||
const onConfirm = (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const r = resolveConfirm;
|
|
||||||
cleanup();
|
|
||||||
logInfo(`已确认: ${call.function.name}`);
|
|
||||||
r?.(true);
|
|
||||||
};
|
|
||||||
const onCancel = (e: Event) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const r = resolveConfirm;
|
|
||||||
cleanup();
|
|
||||||
logInfo(`已取消: ${call.function.name}`);
|
|
||||||
r?.(false);
|
|
||||||
};
|
|
||||||
const cleanup = () => {
|
|
||||||
confirmBtn.removeEventListener('click', onConfirm);
|
|
||||||
cancelBtn.removeEventListener('click', onCancel);
|
|
||||||
if (modalEl) modalEl.style.display = 'none';
|
|
||||||
resolveConfirm = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
confirmBtn.addEventListener('click', onConfirm);
|
|
||||||
cancelBtn.addEventListener('click', onCancel);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelConfirm(): void {
|
/** 显示队列中的下一个确认 */
|
||||||
if (resolveConfirm) {
|
function showNext(): void {
|
||||||
const r = resolveConfirm;
|
if (confirmQueue.length === 0) {
|
||||||
resolveConfirm = null;
|
isShowing = false;
|
||||||
if (modalEl) modalEl.style.display = 'none';
|
return;
|
||||||
r(false);
|
|
||||||
}
|
}
|
||||||
|
isShowing = true;
|
||||||
|
|
||||||
|
const { call, resolve } = confirmQueue.shift()!;
|
||||||
|
const icon = getToolIcon(call.function.name);
|
||||||
|
const name = formatToolName(call.function.name);
|
||||||
|
logInfo(`等待确认: ${call.function.name}`, JSON.stringify(call.function.arguments));
|
||||||
|
|
||||||
|
const titleEl = modalEl!.querySelector('#toolConfirmTitle')!;
|
||||||
|
const bodyEl = modalEl!.querySelector('#toolConfirmBody')!;
|
||||||
|
confirmBtn = modalEl!.querySelector('#toolConfirmBtn') as HTMLButtonElement;
|
||||||
|
cancelBtn = modalEl!.querySelector('#toolCancelBtn') as HTMLButtonElement;
|
||||||
|
|
||||||
|
titleEl.textContent = `${icon} 确认操作:${name}`;
|
||||||
|
|
||||||
|
let bodyHtml = `<div class="tool-confirm-info">`;
|
||||||
|
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">操作:</span><span>${name}</span></div>`;
|
||||||
|
|
||||||
|
const args = call.function.arguments || {};
|
||||||
|
if (call.function.name === 'read_file' || call.function.name === 'write_file' ||
|
||||||
|
call.function.name === 'list_directory' || call.function.name === 'search_files' ||
|
||||||
|
call.function.name === 'create_directory' || call.function.name === 'delete_file') {
|
||||||
|
// 支持 paths 数组(批量删除)和单个 path
|
||||||
|
if (args.paths && Array.isArray(args.paths) && args.paths.length > 0) {
|
||||||
|
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径(${args.paths.length} 项):</span></div>`;
|
||||||
|
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(args.paths.map((p: string) => String(p)).join('\n'))}</pre>`;
|
||||||
|
} else if (args.path) {
|
||||||
|
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">路径:</span><code>${escapeHtml(String(args.path))}</code></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (call.function.name === 'run_command') {
|
||||||
|
if (args.command) {
|
||||||
|
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">命令:</span><code>${escapeHtml(String(args.command))}</code></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (call.function.name === 'write_file' && args.content) {
|
||||||
|
const preview = String(args.content).slice(0, 500);
|
||||||
|
bodyHtml += `<div class="tool-confirm-row"><span class="tool-confirm-label">内容预览:</span></div>`;
|
||||||
|
bodyHtml += `<pre class="tool-confirm-preview">${escapeHtml(preview)}${String(args.content).length > 500 ? '\n...' : ''}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyHtml += `</div>`;
|
||||||
|
bodyEl.innerHTML = bodyHtml;
|
||||||
|
|
||||||
|
// 队列计数提示
|
||||||
|
if (confirmQueue.length > 0) {
|
||||||
|
bodyEl.querySelector('.tool-confirm-info')!.insertAdjacentHTML('beforeend',
|
||||||
|
`<div class="tool-confirm-row" style="margin-top:8px;color:var(--text-muted);font-size:12px;">队列中还有 ${confirmQueue.length} 个待确认</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalEl!.style.display = '';
|
||||||
|
|
||||||
|
const onConfirm = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
cleanup();
|
||||||
|
logInfo(`已确认: ${call.function.name}`);
|
||||||
|
resolve(true);
|
||||||
|
showNext();
|
||||||
|
};
|
||||||
|
const onCancel = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
cleanup();
|
||||||
|
logInfo(`已取消: ${call.function.name}`);
|
||||||
|
resolve(false);
|
||||||
|
showNext();
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
confirmBtn?.removeEventListener('click', onConfirm);
|
||||||
|
cancelBtn?.removeEventListener('click', onCancel);
|
||||||
|
if (modalEl) modalEl.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', onConfirm);
|
||||||
|
cancelBtn.addEventListener('click', onCancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 取消当前显示的确认(不影响队列中的后续确认) */
|
||||||
|
function cancelCurrent(): void {
|
||||||
|
if (!isShowing) return;
|
||||||
|
// 当前显示的确认已从队列 shift 出来,直接触发取消按钮
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,43 @@
|
|||||||
/**
|
/**
|
||||||
* ToolsModal - 工具面板模态框
|
* ToolsModal - 工具面板模态框
|
||||||
* 展示所有可用工具的详细信息。Tool Calling 始终开启,不可关闭。
|
* 展示所有可用工具的详细信息。Tool Calling 始终开启,不可关闭。
|
||||||
* 支持三档开关(auto/confirm/disabled)的工具统一管理。
|
* 全局执行模式统一管理所有工具的确认行为。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { state, KEYS } from '../state/state.js';
|
import { state, KEYS } from '../state/state.js';
|
||||||
import { setToolMode, getToolMode, type ToolMode } from '../services/tool-registry.js';
|
import { setToolMode, type ToolMode } from '../services/tool-registry.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
import { logInfo } from '../services/log-service.js';
|
import { logInfo } from '../services/log-service.js';
|
||||||
import type { ChatDB } from '../db/chat-db.js';
|
import type { ChatDB } from '../db/chat-db.js';
|
||||||
|
|
||||||
let modalEl: HTMLElement;
|
let modalEl: HTMLElement;
|
||||||
|
|
||||||
const MODE_NAMES: Record<ToolMode, string> = {
|
const MODE_NAMES: Record<string, string> = {
|
||||||
auto: '自动执行',
|
auto: '自动执行',
|
||||||
confirm: '需确认',
|
confirm: '需确认',
|
||||||
disabled: '已禁用',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BADGE_MAP: Record<ToolMode, { text: string; cls: string }> = {
|
// 需要全局模式管理的工具列表(与 tool-registry.ts MODE_TOOLS 一致)
|
||||||
auto: { text: '自动', cls: 'auto' },
|
const MANAGED_TOOLS = [
|
||||||
confirm: { text: '需确认', cls: 'confirm' },
|
'run_command',
|
||||||
disabled: { text: '禁用', cls: 'disabled' },
|
'write_file', 'create_directory', 'delete_file',
|
||||||
};
|
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
|
||||||
|
'download_file', 'compress',
|
||||||
|
];
|
||||||
|
|
||||||
function updateToolBadge(toolName: string, mode: ToolMode): void {
|
function updateAllBadges(mode: string): void {
|
||||||
const badge = modalEl.querySelector(`#badge_${toolName}`) as HTMLElement;
|
const badgeText = mode === 'auto' ? '自动' : '需确认';
|
||||||
if (!badge) return;
|
const badgeCls = mode === 'auto' ? 'auto' : 'confirm';
|
||||||
const info = BADGE_MAP[mode];
|
// 更新所有被管理工具的 badge
|
||||||
badge.textContent = info.text;
|
const badges = modalEl.querySelectorAll<HTMLElement>('.tool-card-badge');
|
||||||
badge.className = `tool-card-badge ${info.cls}`;
|
badges.forEach(badge => {
|
||||||
|
// 只更新被管理工具的 badge(通过 tool-card-name 文本匹配)
|
||||||
|
const nameEl = badge.closest('.tool-card')?.querySelector('.tool-card-name');
|
||||||
|
if (nameEl && MANAGED_TOOLS.includes(nameEl.textContent || '')) {
|
||||||
|
badge.textContent = badgeText;
|
||||||
|
badge.className = `tool-card-badge ${badgeCls}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePlanBadge(autoConfirm: boolean): void {
|
function updatePlanBadge(autoConfirm: boolean): void {
|
||||||
@@ -47,36 +55,33 @@ function updatePlanBadge(autoConfirm: boolean): void {
|
|||||||
export function initToolsModal(): void {
|
export function initToolsModal(): void {
|
||||||
modalEl = document.querySelector('#toolsModal')!;
|
modalEl = document.querySelector('#toolsModal')!;
|
||||||
|
|
||||||
// Header 按钮
|
|
||||||
document.querySelector('#btnTools')!.addEventListener('click', openToolsModal);
|
document.querySelector('#btnTools')!.addEventListener('click', openToolsModal);
|
||||||
document.querySelector('#btnCloseTools')!.addEventListener('click', closeToolsModal);
|
document.querySelector('#btnCloseTools')!.addEventListener('click', closeToolsModal);
|
||||||
modalEl.addEventListener('click', (e) => {
|
modalEl.addEventListener('click', (e) => {
|
||||||
if (e.target === modalEl) closeToolsModal();
|
if (e.target === modalEl) closeToolsModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 所有带 .tool-mode-select 的下拉框统一绑定 change 事件
|
// 全局工具执行模式
|
||||||
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
|
const globalSelect = modalEl.querySelector<HTMLSelectElement>('#select_global_tool_mode');
|
||||||
selects.forEach(selectEl => {
|
if (globalSelect) {
|
||||||
selectEl.addEventListener('change', async () => {
|
globalSelect.addEventListener('change', async () => {
|
||||||
const toolName = selectEl.dataset.tool;
|
const mode = globalSelect.value as ToolMode;
|
||||||
if (!toolName) return;
|
|
||||||
const mode = selectEl.value as ToolMode;
|
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
|
|
||||||
// 更新运行时模式
|
// 更新所有被管理工具的运行时模式
|
||||||
setToolMode(toolName, mode);
|
for (const toolName of MANAGED_TOOLS) {
|
||||||
updateToolBadge(toolName, mode);
|
setToolMode(toolName, mode);
|
||||||
|
}
|
||||||
|
|
||||||
// 持久化到数据库(所有工具模式合并存储为 JSON)
|
// 持久化全局模式
|
||||||
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
|
state.set('globalToolMode', mode);
|
||||||
allModes[toolName] = mode;
|
if (db) await db.saveSetting('globalToolMode', mode);
|
||||||
state.set('toolModes', allModes);
|
|
||||||
if (db) await db.saveSetting('toolModes', allModes);
|
|
||||||
|
|
||||||
showToast(`${toolName}:${MODE_NAMES[mode]}`, 'info');
|
updateAllBadges(mode);
|
||||||
logInfo(`工具模式切换: ${toolName} → ${mode}`);
|
showToast(`工具执行模式:${MODE_NAMES[mode]}`, 'info');
|
||||||
|
logInfo(`全局工具模式切换: → ${mode}`);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Plan 确认模式下拉框
|
// Plan 确认模式下拉框
|
||||||
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
|
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
|
||||||
@@ -96,18 +101,15 @@ export function initToolsModal(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function openToolsModal(): void {
|
export function openToolsModal(): void {
|
||||||
// 从 state 读取所有工具模式,同步 UI
|
// 同步全局工具执行模式
|
||||||
const allModes = state.get<Record<string, ToolMode>>('toolModes', {});
|
const globalMode = state.get<string>('globalToolMode', 'confirm');
|
||||||
const selects = modalEl.querySelectorAll<HTMLSelectElement>('.tool-mode-select');
|
const globalSelect = modalEl.querySelector<HTMLSelectElement>('#select_global_tool_mode');
|
||||||
selects.forEach(selectEl => {
|
if (globalSelect) {
|
||||||
const toolName = selectEl.dataset.tool;
|
globalSelect.value = globalMode;
|
||||||
if (!toolName) return;
|
updateAllBadges(globalMode);
|
||||||
const mode = allModes[toolName] || getToolMode(toolName);
|
}
|
||||||
selectEl.value = mode;
|
|
||||||
updateToolBadge(toolName, mode);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 同步 Plan 确认模式下拉框
|
// 同步 Plan 确认模式
|
||||||
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
|
const planSelect = modalEl.querySelector<HTMLSelectElement>('#select_plan_confirm');
|
||||||
if (planSelect) {
|
if (planSelect) {
|
||||||
const autoConfirm = state.get<boolean>('planAutoConfirm', false);
|
const autoConfirm = state.get<boolean>('planAutoConfirm', false);
|
||||||
|
|||||||
@@ -555,6 +555,12 @@ function renderTerminal(): void {
|
|||||||
_termRenderedCount = 0;
|
_termRenderedCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 idle 状态切换到有内容时,清空 idle DOM
|
||||||
|
// idle 状态用 position:absolute 覆盖在内容上方,不清空会同时显示
|
||||||
|
if (_termRenderedCount === 0 && container.querySelector('.ws-idle-state')) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
// 增量追加新行
|
// 增量追加新行
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = _termRenderedCount; i < session.lines.length; i++) {
|
for (let i = _termRenderedCount; i < session.lines.length; i++) {
|
||||||
@@ -1068,7 +1074,16 @@ function renderToolCard(tc: ToolCallRecord): string {
|
|||||||
} else if (tc.name === 'write_file') {
|
} else if (tc.name === 'write_file') {
|
||||||
resultHtml = `<div class="tool-result-entry">✅ 已写入 ${escapeHtml(String(r.path || ''))}</div>`;
|
resultHtml = `<div class="tool-result-entry">✅ 已写入 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||||
} else if (tc.name === 'delete_file') {
|
} else if (tc.name === 'delete_file') {
|
||||||
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
|
if (r.batch) {
|
||||||
|
resultHtml = `<div class="tool-result-entry">✅ 批量删除 ${r.successCount}/${r.totalPaths} 个路径</div>`;
|
||||||
|
if (r.results) {
|
||||||
|
for (const res of r.results) {
|
||||||
|
resultHtml += `<div class="tool-result-entry">${res.success ? '✅' : '❌'} ${escapeHtml(String(res.path || ''))}${res.success ? '' : ' — ' + escapeHtml(String(res.error || ''))}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resultHtml = `<div class="tool-result-entry">✅ 已删除 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||||
|
}
|
||||||
} else if (tc.name === 'create_directory') {
|
} else if (tc.name === 'create_directory') {
|
||||||
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
|
resultHtml = `<div class="tool-result-entry">✅ 已创建 ${escapeHtml(String(r.path || ''))}</div>`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+25
-93
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
<img class="logo" src="./assets/icons/llama.png" alt="logo" />
|
||||||
<span class="app-title">Metona Ollama</span>
|
<span class="app-title">Metona Ollama</span>
|
||||||
<span class="app-version">v0.16.2</span>
|
<span class="app-version">v0.16.7</span>
|
||||||
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
<button class="icon-btn help-btn" id="btnHelp" title="使用帮助">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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"/>
|
<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||||
@@ -445,7 +445,7 @@
|
|||||||
<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>:可配置(默认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>🤖 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>:可配置(默认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>🔌 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>🔍 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>
|
<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>
|
||||||
<div class="help-section"><h4>📊 Token 实时监控</h4><ul><li>点击顶部 📊 按钮打开 Token 监控仪表盘</li><li><strong>全局统计</strong> — 跨会话累计 Token 消耗,柱状图按会话展示趋势</li><li><strong>当前会话</strong> — 实时显示本轮对话的 Token 消耗,按轮次展示明细</li><li>每 2 秒自动刷新数据,支持输入/输出分色显示</li></ul></div>
|
<div class="help-section"><h4>📊 Token 实时监控</h4><ul><li>点击顶部 📊 按钮打开 Token 监控仪表盘</li><li><strong>全局统计</strong> — 跨会话累计 Token 消耗,柱状图按会话展示趋势</li><li><strong>当前会话</strong> — 实时显示本轮对话的 Token 消耗,按轮次展示明细</li><li>每 2 秒自动刷新数据,支持输入/输出分色显示</li></ul></div>
|
||||||
<div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含 3 个页签:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),单一终端进程</li><li><strong>🔧 工具 Tab</strong> — 展示本轮对话的工具调用卡片,含状态统计(总数 ✅ 成功 ❌ 失败),AI 执行命令时自动切到此页签</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容(带行号),支持上级目录导航</li></ul></li><li>工作空间目录可在设置中修改</li></ul></div>
|
<div class="help-section"><h4>🖥️ 布局说明</h4><ul><li><strong>左侧面板</strong> — 执行日志,实时显示应用运行日志(连接、模型加载、工具调用等)</li><li><strong>中间区域</strong> — 聊天消息,顶部 Header + 模型栏,底部输入框</li><li><strong>右侧面板</strong> — 工作空间(常驻显示),包含 3 个页签:<ul><li><strong>💻 命令行 Tab</strong> — 终端界面,实时流式输出,支持长时间运行(无超时),单一终端进程</li><li><strong>🔧 工具 Tab</strong> — 展示本轮对话的工具调用卡片,含状态统计(总数 ✅ 成功 ❌ 失败),AI 执行命令时自动切到此页签</li><li><strong>📁 文件 Tab</strong> — 浏览工作空间目录,点击文件预览内容(带行号),支持上级目录导航</li></ul></li><li>工作空间目录可在设置中修改</li></ul></div>
|
||||||
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 SQLite</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 JSON / <code>.metona</code> 加密备份</li></ul></div>
|
<div class="help-section"><h4>🕐 历史记录</h4><ul><li>所有会话自动保存到本地 SQLite</li><li>点击顶部 🕐 按钮查看、搜索、恢复历史会话</li><li>支持导出 JSON / <code>.metona</code> 加密备份</li></ul></div>
|
||||||
@@ -467,22 +467,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">AI 始终可在对话中自主调用以下工具(Tool Calling 永久开启)。高风险操作需用户确认。</p>
|
<p class="text-muted" style="font-size:12px;margin-bottom:12px;">AI 始终可在对话中自主调用以下工具(Tool Calling 永久开启)。高风险操作需用户确认。</p>
|
||||||
|
<div style="margin-bottom:16px;padding:12px;background:var(--bg-card);border:1px solid var(--border-default);border-radius:8px;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:14px;">⚙️ 全局工具执行模式</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-secondary);margin-top:2px;">统一管理所有工具的执行模式,不区分工具</div>
|
||||||
|
</div>
|
||||||
|
<select id="select_global_tool_mode" style="font-size:13px;padding:6px 12px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:6px;min-width:140px;">
|
||||||
|
<option value="confirm">需确认</option>
|
||||||
|
<option value="auto">自动执行</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tools-grid">
|
<div class="tools-grid">
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
<div class="tool-card-desc">读取文件(文本/binary/base64),2000行默认,截断自动提示续读</div>
|
<div class="tool-card-desc">读取文件(文本/binary/base64),2000行默认,截断自动提示续读</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">write_file</span><span class="tool-card-badge confirm" id="badge_write_file">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">write_file</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">写入/追加文件,支持base64二进制,最大10MB</div>
|
<div class="tool-card-desc">写入/追加文件,支持base64二进制,最大10MB</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="write_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">📁</span><span class="tool-card-name">list_directory</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">📁</span><span class="tool-card-name">list_directory</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
@@ -493,80 +497,32 @@
|
|||||||
<div class="tool-card-desc">按文件名或内容搜索,支持正则表达式</div>
|
<div class="tool-card-desc">按文件名或内容搜索,支持正则表达式</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">📂</span><span class="tool-card-name">create_directory</span><span class="tool-card-badge confirm" id="badge_create_directory">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">📂</span><span class="tool-card-name">create_directory</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">创建目录,自动创建父级</div>
|
<div class="tool-card-desc">创建目录,自动创建父级</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="create_directory" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">delete_file</span><span class="tool-card-badge confirm" id="badge_delete_file">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">🗑️</span><span class="tool-card-name">delete_file</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">删除文件或目录,返回被删文件数和总大小</div>
|
<div class="tool-card-desc">删除文件或目录,返回被删文件数和总大小</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="delete_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">📦</span><span class="tool-card-name">move_file</span><span class="tool-card-badge confirm" id="badge_move_file">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">📦</span><span class="tool-card-name">move_file</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">移动或重命名文件/目录</div>
|
<div class="tool-card-desc">移动或重命名文件/目录</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="move_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">copy_file</span><span class="tool-card-badge confirm" id="badge_copy_file">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">📋</span><span class="tool-card-name">copy_file</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">复制文件或目录到新位置</div>
|
<div class="tool-card-desc">复制文件或目录到新位置</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="copy_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">🌐</span><span class="tool-card-name">web_fetch</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">🌐</span><span class="tool-card-name">web_fetch</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
<div class="tool-card-desc">抓取网页内容,反爬headers+UA切换+自动回退浏览器渲染</div>
|
<div class="tool-card-desc">抓取网页内容,反爬headers+UA切换+自动回退浏览器渲染</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">✂️</span><span class="tool-card-name">edit_file</span><span class="tool-card-badge confirm" id="badge_edit_file">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">✂️</span><span class="tool-card-name">edit_file</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">查找替换(支持正则use_regex),全部或单次替换</div>
|
<div class="tool-card-desc">查找替换(支持正则use_regex),全部或单次替换</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="edit_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">💻</span><span class="tool-card-name">run_command</span><span class="tool-card-badge confirm" id="badge_run_command">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">💻</span><span class="tool-card-name">run_command</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">执行 shell 命令,无超时限制</div>
|
<div class="tool-card-desc">执行 shell 命令,支持超时配置</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="run_command" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">ℹ️</span><span class="tool-card-name">get_file_info</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">ℹ️</span><span class="tool-card-name">get_file_info</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
@@ -577,32 +533,16 @@
|
|||||||
<div class="tool-card-desc">以树形结构展示目录内容</div>
|
<div class="tool-card-desc">以树形结构展示目录内容</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">⬇️</span><span class="tool-card-name">download_file</span><span class="tool-card-badge confirm" id="badge_download_file">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">⬇️</span><span class="tool-card-name">download_file</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">从 URL 下载文件到本地</div>
|
<div class="tool-card-desc">从 URL 下载文件到本地</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="download_file" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">🔀</span><span class="tool-card-name">diff_files</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">🔀</span><span class="tool-card-name">diff_files</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
<div class="tool-card-desc">对比两个文件,显示差异</div>
|
<div class="tool-card-desc">对比两个文件,显示差异</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">🔄</span><span class="tool-card-name">replace_in_files</span><span class="tool-card-badge confirm" id="badge_replace_in_files">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">🔄</span><span class="tool-card-name">replace_in_files</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">按 glob 模式批量查找替换多个文件</div>
|
<div class="tool-card-desc">按 glob 模式批量查找替换多个文件</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="replace_in_files" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">📚</span><span class="tool-card-name">read_multiple_files</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">📚</span><span class="tool-card-name">read_multiple_files</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
@@ -613,16 +553,8 @@
|
|||||||
<div class="tool-card-desc">Git 全操作(17个子命令:status/log/diff/add/commit/push等)</div>
|
<div class="tool-card-desc">Git 全操作(17个子命令:status/log/diff/add/commit/push等)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge confirm" id="badge_compress">需确认</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">🗜️</span><span class="tool-card-name">compress</span><span class="tool-card-badge confirm">需确认</span></div>
|
||||||
<div class="tool-card-desc">创建或解压 zip/tar.gz 归档</div>
|
<div class="tool-card-desc">创建或解压 zip/tar.gz 归档</div>
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px;">
|
|
||||||
<label style="margin:0;font-size:11px;color:var(--text-secondary);">执行模式:</label>
|
|
||||||
<select class="tool-mode-select" data-tool="compress" style="font-size:11px;padding:2px 6px;background:var(--bg-card);color:var(--text-primary);border:1px solid var(--border-default);border-radius:4px;">
|
|
||||||
<option value="confirm">需确认</option>
|
|
||||||
<option value="auto">自动执行</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-card">
|
<div class="tool-card">
|
||||||
<div class="tool-card-header"><span class="tool-card-icon">🔍</span><span class="tool-card-name">web_search</span><span class="tool-card-badge auto">自动</span></div>
|
<div class="tool-card-header"><span class="tool-card-icon">🔍</span><span class="tool-card-name">web_search</span><span class="tool-card-badge auto">自动</span></div>
|
||||||
|
|||||||
+70
-78
@@ -22,7 +22,7 @@ import { initHistoryModal, closeHistoryModal } from './components/history-modal.
|
|||||||
import { initMemoryModal } from './components/memory-modal.js';
|
import { initMemoryModal } from './components/memory-modal.js';
|
||||||
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
|
import { initToolsModal, closeToolsModal } from './components/tools-modal.js';
|
||||||
import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js';
|
import { initTokenDashboard, closeTokenDashboard } from './components/token-dashboard.js';
|
||||||
import { setToolEnabled, setToolMode, refreshMCPTools } from './services/tool-registry.js';
|
import { initGlobalToolMode, refreshMCPTools } from './services/tool-registry.js';
|
||||||
import { startAllMCPServers } from './services/mcp-client.js';
|
import { startAllMCPServers } from './services/mcp-client.js';
|
||||||
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
import { initToolConfirmModal } from './components/tool-confirm-modal.js';
|
||||||
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
|
import { initWorkspacePanel, clearToolCardsExternal, clearTerminalExternal, switchToTab } from './components/workspace-panel.js';
|
||||||
@@ -296,6 +296,52 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
logInit('启动...');
|
logInit('启动...');
|
||||||
|
|
||||||
|
// ── UI 组件初始化(不依赖数据库,先初始化以避免 catch 路径重复绑定)──
|
||||||
|
initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示
|
||||||
|
initGlobalErrorHandler(); // R42: 全局错误处理器(紧随日志面板之后)
|
||||||
|
initToast();
|
||||||
|
initLightbox();
|
||||||
|
initHeader();
|
||||||
|
initModelBar();
|
||||||
|
initChatArea();
|
||||||
|
initInputArea();
|
||||||
|
initSettingsModal();
|
||||||
|
initHistoryModal();
|
||||||
|
initHelpModal();
|
||||||
|
initToolConfirmModal();
|
||||||
|
initMemoryModal();
|
||||||
|
initToolsModal();
|
||||||
|
initTokenDashboard();
|
||||||
|
initWorkspacePanel();
|
||||||
|
setupDesktopIntegration();
|
||||||
|
initSearxngModal();
|
||||||
|
|
||||||
|
bindGlobalEvents();
|
||||||
|
|
||||||
|
// ── Harness Engineering 系统初始化(幂等,UI 就绪后即可注册)──
|
||||||
|
initHarnessHooks();
|
||||||
|
// Agent Metrics 无需显式初始化(按需启动)
|
||||||
|
// Context Indexer 在 Agent INIT 状态按需构建
|
||||||
|
|
||||||
|
// ── 状态默认值(不依赖数据库)──
|
||||||
|
state.set(KEYS.CURRENT_SESSION, createNewSession());
|
||||||
|
state.set(KEYS.IS_STREAMING, false);
|
||||||
|
state.set(KEYS.IS_HISTORY_VIEW, false);
|
||||||
|
state.set(KEYS.NUM_CTX, 131072); // 默认 128K
|
||||||
|
|
||||||
|
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
|
||||||
|
state.set('toolCallingEnabled', true);
|
||||||
|
state.set('runCommandMode', 'confirm'); // 兼容旧代码读取
|
||||||
|
state.set('thinkEnabled', false);
|
||||||
|
|
||||||
|
// ── 注入应用版本号到 Agent Metrics ──
|
||||||
|
const _bridge = window.metonaDesktop;
|
||||||
|
if (_bridge?.info) {
|
||||||
|
_bridge.info().then((info: { version?: string }) => {
|
||||||
|
if (info?.version) setAppVersion(info.version);
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db = new ChatDB();
|
db = new ChatDB();
|
||||||
await db.init();
|
await db.init();
|
||||||
@@ -309,37 +355,6 @@ async function init(): Promise<void> {
|
|||||||
state.set(KEYS.API, api);
|
state.set(KEYS.API, api);
|
||||||
logInit(`Ollama 地址: ${serverUrl}`);
|
logInit(`Ollama 地址: ${serverUrl}`);
|
||||||
|
|
||||||
state.set(KEYS.CURRENT_SESSION, createNewSession());
|
|
||||||
state.set(KEYS.IS_STREAMING, false);
|
|
||||||
state.set(KEYS.IS_HISTORY_VIEW, false);
|
|
||||||
state.set(KEYS.NUM_CTX, 131072); // 默认 128K
|
|
||||||
|
|
||||||
// ── 防御性重置:确保首次启动不会遗留脏状态 ──
|
|
||||||
state.set('toolCallingEnabled', true);
|
|
||||||
state.set('runCommandMode', 'confirm'); // 兼容旧代码读取
|
|
||||||
state.set('thinkEnabled', false);
|
|
||||||
|
|
||||||
initLogPanel(); // ← 日志面板最先初始化,确保后续 init 日志能显示
|
|
||||||
initGlobalErrorHandler(); // R42: 全局错误处理器(紧随日志面板之后)
|
|
||||||
initToast();
|
|
||||||
initLightbox();
|
|
||||||
initHeader();
|
|
||||||
initModelBar();
|
|
||||||
initChatArea();
|
|
||||||
initInputArea();
|
|
||||||
initSettingsModal();
|
|
||||||
initHistoryModal();
|
|
||||||
initHelpModal();
|
|
||||||
initToolConfirmModal();
|
|
||||||
initMemoryModal();
|
|
||||||
initToolsModal();
|
|
||||||
initTokenDashboard();
|
|
||||||
initWorkspacePanel();
|
|
||||||
setupDesktopIntegration();
|
|
||||||
initSearxngModal();
|
|
||||||
|
|
||||||
bindGlobalEvents();
|
|
||||||
|
|
||||||
await checkConnection();
|
await checkConnection();
|
||||||
await loadModels();
|
await loadModels();
|
||||||
await loadSearxngConfig(db);
|
await loadSearxngConfig(db);
|
||||||
@@ -349,19 +364,6 @@ async function init(): Promise<void> {
|
|||||||
initMemoryFile().catch(() => {});
|
initMemoryFile().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Harness Engineering 系统初始化 ──
|
|
||||||
initHarnessHooks();
|
|
||||||
// Agent Metrics 无需显式初始化(按需启动)
|
|
||||||
// Context Indexer 在 Agent INIT 状态按需构建
|
|
||||||
|
|
||||||
// ── 注入应用版本号到 Agent Metrics ──
|
|
||||||
const _bridge = window.metonaDesktop;
|
|
||||||
if (_bridge?.info) {
|
|
||||||
_bridge.info().then((info: { version?: string }) => {
|
|
||||||
if (info?.version) setAppVersion(info.version);
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
logInit('所有组件已就绪(含 Harness)');
|
logInit('所有组件已就绪(含 Harness)');
|
||||||
|
|
||||||
const savedModel = await db.getSetting('selectedModel', '');
|
const savedModel = await db.getSetting('selectedModel', '');
|
||||||
@@ -444,22 +446,29 @@ async function init(): Promise<void> {
|
|||||||
|
|
||||||
// ── Tool Calling 设置(永久开启,不可关闭)──
|
// ── Tool Calling 设置(永久开启,不可关闭)──
|
||||||
state.set('toolCallingEnabled', true);
|
state.set('toolCallingEnabled', true);
|
||||||
// 统一加载所有工具的执行模式(auto/confirm/disabled)
|
// 全局工具执行模式(统一管理所有工具,不区分工具)
|
||||||
{
|
{
|
||||||
// 优先读取合并存储的 toolModes JSON
|
// 读取全局模式,默认 confirm
|
||||||
const savedModes = await db.getSetting<Record<string, 'auto' | 'confirm' | 'disabled'>>('toolModes', {});
|
let globalMode = await db.getSetting<'auto' | 'confirm'>('globalToolMode', 'confirm');
|
||||||
// 兼容旧版:如果 toolModes 中没有 run_command 但有独立的 runCommandMode,则迁移
|
// 兼容旧版:从旧的 toolModes 迁移(如果存在)
|
||||||
if (!savedModes['run_command']) {
|
// 注意:之前用 saveSetting('toolModes', null) 清除旧键,JSON.parse("null") 返回 null
|
||||||
const legacy = await db.getSetting('runCommandMode', 'confirm') as 'auto' | 'confirm' | 'disabled';
|
// 所以这里必须做 null 防御,否则 Object.keys(null) 会抛出 "Cannot convert undefined or null to object"
|
||||||
savedModes['run_command'] = legacy;
|
const savedModes = await db.getSetting<Record<string, 'auto' | 'confirm' | 'disabled'>>('toolModes', {}) ?? {};
|
||||||
await db.saveSetting('toolModes', savedModes);
|
if (savedModes && Object.keys(savedModes).length > 0) {
|
||||||
await db.saveSetting('runCommandMode', null); // 清除旧键
|
// 如果旧 toolModes 中所有被管理工具都是 auto,则迁移为 auto;否则 confirm
|
||||||
}
|
const allAuto = [
|
||||||
state.set('toolModes', savedModes);
|
'run_command', 'write_file', 'create_directory', 'delete_file',
|
||||||
// 为每个工具应用模式
|
'edit_file', 'replace_in_files', 'move_file', 'copy_file',
|
||||||
for (const [toolName, mode] of Object.entries(savedModes)) {
|
'download_file', 'compress',
|
||||||
setToolMode(toolName, mode);
|
].every(t => savedModes[t] === 'auto');
|
||||||
|
globalMode = allAuto ? 'auto' : 'confirm';
|
||||||
|
await db.saveSetting('globalToolMode', globalMode);
|
||||||
|
// 清除旧键:用空对象而非 null,避免 JSON.parse("null") 返回 null 的陷阱
|
||||||
|
await db.saveSetting('toolModes', {});
|
||||||
}
|
}
|
||||||
|
state.set('globalToolMode', globalMode);
|
||||||
|
// 应用全局模式到所有被管理工具
|
||||||
|
initGlobalToolMode(globalMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plan 自动确认设置 ──
|
// ── Plan 自动确认设置 ──
|
||||||
@@ -478,7 +487,8 @@ async function init(): Promise<void> {
|
|||||||
logSuccess('应用初始化完成');
|
logSuccess('应用初始化完成');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('初始化失败', (err as Error).message);
|
logError('初始化失败', (err as Error).message);
|
||||||
if (!api!) {
|
// UI 组件已在 try 块之前初始化,此处不重复调用以避免重复绑定事件监听器
|
||||||
|
if (!api) {
|
||||||
api = new OllamaAPI();
|
api = new OllamaAPI();
|
||||||
state.set(KEYS.API, api);
|
state.set(KEYS.API, api);
|
||||||
}
|
}
|
||||||
@@ -486,24 +496,6 @@ async function init(): Promise<void> {
|
|||||||
state.set(KEYS.IS_STREAMING, false);
|
state.set(KEYS.IS_STREAMING, false);
|
||||||
state.set(KEYS.IS_HISTORY_VIEW, false);
|
state.set(KEYS.IS_HISTORY_VIEW, false);
|
||||||
state.set(KEYS.CURRENT_SESSION, createNewSession());
|
state.set(KEYS.CURRENT_SESSION, createNewSession());
|
||||||
initLogPanel();
|
|
||||||
bindGlobalEvents();
|
|
||||||
initToast();
|
|
||||||
initLightbox();
|
|
||||||
initHeader();
|
|
||||||
initModelBar();
|
|
||||||
initChatArea();
|
|
||||||
initInputArea();
|
|
||||||
initSettingsModal();
|
|
||||||
initHistoryModal();
|
|
||||||
initHelpModal();
|
|
||||||
initToolConfirmModal();
|
|
||||||
initMemoryModal();
|
|
||||||
initToolsModal();
|
|
||||||
initTokenDashboard();
|
|
||||||
initWorkspacePanel();
|
|
||||||
setupDesktopIntegration();
|
|
||||||
initHarnessHooks();
|
|
||||||
// ── MEMORY.md 初始化(fallback 路径)──
|
// ── MEMORY.md 初始化(fallback 路径)──
|
||||||
import('./services/memory-service.js').then(({ initMemoryFile }) => {
|
import('./services/memory-service.js').then(({ initMemoryFile }) => {
|
||||||
initMemoryFile().catch(() => {});
|
initMemoryFile().catch(() => {});
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# AGENTS.md — Agent 行为规范准则(10 条)
|
|
||||||
## 1. 敏感信息红线
|
|
||||||
禁止读取、复制、修改、输出或提交以下任何内容:`.env` 文件、API Key、Token、数据库密码、云服务密钥、私钥文件。调试配置问题时,只能查看脱敏示例文件(如 `.env.example`)。不确定某文件是否属于敏感信息时,必须先询问用户,禁止自行打开。
|
|
||||||
## 2. 先读后改
|
|
||||||
收到任何修改任务后,必须先阅读项目目录结构、相关模块和现有代码,再动手修改。修改代码前必须确认已理解该模块的上下文、命名约定和分层关系。禁止在未阅读相关代码的情况下直接编写或修改。
|
|
||||||
## 3. 需求模糊时先澄清
|
|
||||||
任务存在歧义、信息不足或多种合理解读时,必须向用户提出澄清问题,禁止基于假设自行推进。涉及技术选型、范围边界或优先级取舍时,必须先获得用户确认。仅在小范围、低风险且意图明确的情况下,才可直接执行。
|
|
||||||
## 4. 高风险改动先出方案
|
|
||||||
涉及以下任一领域的修改,必须先给出方案并获得用户确认后才能动手:架构调整、数据库结构变更、接口契约变更、权限逻辑修改、安全相关代码、跨模块改动。方案中必须列出影响范围和至少两种可选路径的优缺点。
|
|
||||||
## 5. 最小改动
|
|
||||||
只修改与当前任务直接相关的代码。禁止做以下任何行为:无关重构、全局格式化、顺手调整命名风格、修改与任务无关的文件。发现额外问题时,记录为建议并告知用户,禁止直接扩大修改范围。
|
|
||||||
## 6. 修改前列文件清单
|
|
||||||
正式修改前,必须列出预计会创建、修改或删除的全部文件。以下操作必须等待用户明确确认后才能执行:删除文件、迁移目录、批量格式化、修改配置文件、修改数据库脚本。实际修改范围超出原计划时,必须暂停并重新说明。
|
|
||||||
## 7. 不臆造信息
|
|
||||||
禁止编造不存在的函数、接口、依赖、配置项或文档引用。对不确定的事实,必须明确标注"不确定"或主动检索验证。禁止伪造测试结果或验证证据。发现自己之前的回答有误时,必须立即说明错误并给出修正。
|
|
||||||
## 8. 完成后必须验证
|
|
||||||
每次修改完成后,必须运行项目已有的测试命令、构建命令或类型检查命令进行验证。无法运行测试时,必须说明原因并给出可手动验证的具体步骤。禁止在未提供任何验证方式的情况下声称"已修复""完全可用"或"已完成"。
|
|
||||||
## 9. 遵循项目既有规范
|
|
||||||
修改或新增代码时,必须遵循项目已有的代码风格、目录结构、命名约定和分层设计。禁止引入与项目现有风格冲突的个人习惯。项目存在 lint 规则或格式化配置时,必须严格遵守。新增代码必须复用项目中已有的工具类、组件和封装,禁止重复造轮子。
|
|
||||||
## 10. 规约不越界
|
|
||||||
本文件的规则在不违反系统安全策略、平台权限限制和用户当前明确指令的前提下生效。本文件与上述任何一项冲突时,以安全策略、平台限制和用户当前指令为准。禁止为了满足本文件的规则而执行破坏性操作或绕过安全机制。
|
|
||||||
@@ -18,11 +18,7 @@ import {
|
|||||||
} from './tool-registry.js';
|
} from './tool-registry.js';
|
||||||
import {
|
import {
|
||||||
compactOldToolResult,
|
compactOldToolResult,
|
||||||
detectOscillation,
|
|
||||||
recordToolCallHistory,
|
|
||||||
detectConsecutiveIdentical,
|
|
||||||
setUserGoal,
|
setUserGoal,
|
||||||
checkGoalAlignment,
|
|
||||||
resetAllSafetyState,
|
resetAllSafetyState,
|
||||||
checkRateLimit,
|
checkRateLimit,
|
||||||
classifyError,
|
classifyError,
|
||||||
@@ -39,16 +35,12 @@ import {
|
|||||||
isDuplicateToolResult,
|
isDuplicateToolResult,
|
||||||
// R109: 工具参数消毒
|
// R109: 工具参数消毒
|
||||||
sanitizeToolArgs,
|
sanitizeToolArgs,
|
||||||
// R112: 诊断系统
|
|
||||||
collectDiagnostics,
|
|
||||||
// R113: 命令安全检查
|
// R113: 命令安全检查
|
||||||
checkCommandSafety,
|
checkCommandSafety,
|
||||||
// R95: 按工具类型智能截断
|
// R95: 按工具类型智能截断
|
||||||
smartTruncateByToolType,
|
smartTruncateByToolType,
|
||||||
// R99: 工具结果引用解析
|
|
||||||
checkArchivedReferences,
|
|
||||||
} from './agent-safety.js';
|
} from './agent-safety.js';
|
||||||
import { search, formatMemoryContext, loadAllEntries } from './memory-service.js';
|
import { search, formatMemoryContext } from './memory-service.js';
|
||||||
|
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
|
import { logInfo, logWarn, logSuccess, logError, logToolStart, logToolResult, logAgentLoop, logModelResponse, logStreamProgress, resetStreamProgress } from './log-service.js';
|
||||||
@@ -95,17 +87,6 @@ const MAX_PARALLEL_TOOLS = 5; // R6: 单批次最大并行工具数
|
|||||||
// R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算
|
// R55: 语义工具检索 — 缓存当前轮过滤后的工具定义,避免 handleThinking 重复计算
|
||||||
let _filteredTools: import('../types.js').ToolDefinition[] = [];
|
let _filteredTools: import('../types.js').ToolDefinition[] = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算增量压缩触发阈值(按 token 数而非消息条数)
|
|
||||||
* 当上下文 token 占比超过 numCtx 的 30% 时触发压缩
|
|
||||||
*/
|
|
||||||
function getIncrementalCompressThreshold(numCtx: number): number {
|
|
||||||
// 30% of numCtx 转换为估算字符数(token * 1.5 近似)
|
|
||||||
// 同时保留一个最低消息条数兜底(避免极少消息但单条极大的情况漏触发)
|
|
||||||
const tokenThreshold = Math.floor(numCtx * 0.3);
|
|
||||||
return Math.max(20, Math.min(120, Math.floor(tokenThreshold / 100)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** S1/S6: 清洗不可信文本,移除提示词注入模式 */
|
/** S1/S6: 清洗不可信文本,移除提示词注入模式 */
|
||||||
function sanitizeUntrustedInput(text: string): string {
|
function sanitizeUntrustedInput(text: string): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
@@ -138,13 +119,14 @@ const S = {
|
|||||||
function getValidTransitions(state: LoopState): LoopState[] {
|
function getValidTransitions(state: LoopState): LoopState[] {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'INIT': return ['THINKING', 'TERMINATED'];
|
case 'INIT': return ['THINKING', 'TERMINATED'];
|
||||||
case 'THINKING': return ['PARSING', 'TERMINATED'];
|
case 'THINKING': return ['PARSING', 'COMPRESSING', 'TERMINATED'];
|
||||||
case 'PARSING': return ['EXECUTING', 'REFLECTING', 'THINKING', 'TERMINATED'];
|
case 'PARSING': return ['EXECUTING', 'REFLECTING', 'THINKING', 'COMPRESSING', 'TERMINATED'];
|
||||||
case 'EXECUTING': return ['OBSERVING', 'THINKING', 'TERMINATED'];
|
case 'EXECUTING': return ['OBSERVING', 'THINKING', 'COMPRESSING', 'TERMINATED'];
|
||||||
case 'OBSERVING': return ['COMPRESSING', 'REFLECTING', 'THINKING', 'TERMINATED'];
|
case 'OBSERVING': return ['COMPRESSING', 'REFLECTING', 'THINKING', 'TERMINATED'];
|
||||||
case 'REFLECTING': return ['THINKING', 'COMPRESSING', 'TERMINATED'];
|
case 'REFLECTING': return ['THINKING', 'COMPRESSING', 'TERMINATED'];
|
||||||
case 'COMPRESSING': return ['THINKING', 'REFLECTING', 'TERMINATED'];
|
case 'COMPRESSING': return ['THINKING', 'REFLECTING', 'TERMINATED'];
|
||||||
case 'TERMINATED': return [];
|
case 'TERMINATED': return [];
|
||||||
|
default: return ['THINKING', 'TERMINATED']; // 未知状态允许恢复到 THINKING 或终止
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,9 +159,9 @@ function getOSEnvironment() {
|
|||||||
/** 始终可并行的只读/独立工具 */
|
/** 始终可并行的只读/独立工具 */
|
||||||
const ALWAYS_PARALLEL = new Set([
|
const ALWAYS_PARALLEL = new Set([
|
||||||
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
|
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
|
||||||
'web_search', 'browser_screenshot', 'browser_extract', 'browser_evaluate',
|
'web_search', 'browser_screenshot', 'browser_extract',
|
||||||
'memory', 'session_list', 'session_read',
|
'memory', 'session_list', 'session_read',
|
||||||
'diff_files', 'git',
|
'diff_files',
|
||||||
'datetime', 'calculator',
|
'datetime', 'calculator',
|
||||||
'random', 'uuid', 'json_format', 'hash',
|
'random', 'uuid', 'json_format', 'hash',
|
||||||
]);
|
]);
|
||||||
@@ -192,6 +174,57 @@ const SIDE_EFFECT_TOOLS = new Set([
|
|||||||
'browser_open', 'browser_click', 'browser_type', 'browser_close',
|
'browser_open', 'browser_click', 'browser_type', 'browser_close',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 跨轮次死循环检测
|
||||||
|
// 追踪每轮工具调用签名,检测连续重复以防止模型陷入死循环
|
||||||
|
// 与 R104(仅记录日志)不同,本机制在达到阈值时主动干预:
|
||||||
|
// - 连续 3 轮相同 → 注入事实性提示(不修改模型输出)
|
||||||
|
// - 注入提示后仍连续 5 轮相同 → 硬性熔断,强制终止
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
const _loopToolSignatures: string[] = [];
|
||||||
|
const LOOP_HISTORY_MAX = 10;
|
||||||
|
let _deadlockHintInjected = false;
|
||||||
|
|
||||||
|
/** 记录本轮工具调用签名(每轮 handleThinking 成功后调用一次) */
|
||||||
|
function recordLoopSignature(toolCalls: ToolCall[]): void {
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
_loopToolSignatures.push('__no_tools__');
|
||||||
|
_deadlockHintInjected = false; // 无工具调用,模型已改变行为,重置提示标志
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sig = toolCalls.map(tc =>
|
||||||
|
`${tc.function.name}:${JSON.stringify(tc.function.arguments, Object.keys(tc.function.arguments || {}).sort())}`
|
||||||
|
).join('|');
|
||||||
|
// 签名变化时重置提示标志(模型已改变行为)
|
||||||
|
if (_loopToolSignatures.length > 0 && _loopToolSignatures[_loopToolSignatures.length - 1] !== sig) {
|
||||||
|
_deadlockHintInjected = false;
|
||||||
|
}
|
||||||
|
_loopToolSignatures.push(sig);
|
||||||
|
if (_loopToolSignatures.length > LOOP_HISTORY_MAX) {
|
||||||
|
_loopToolSignatures.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检测连续 N 轮相同的工具调用签名 */
|
||||||
|
function detectLoopDeadlock(minCount: number): { detected: boolean; toolName: string; count: number } {
|
||||||
|
if (_loopToolSignatures.length < minCount) return { detected: false, toolName: '', count: 0 };
|
||||||
|
const recent = _loopToolSignatures.slice(-minCount);
|
||||||
|
if (recent[0] === '__no_tools__') return { detected: false, toolName: '', count: 0 };
|
||||||
|
const allSame = recent.every(s => s === recent[0]);
|
||||||
|
if (allSame) {
|
||||||
|
const firstSig = recent[0];
|
||||||
|
const toolName = firstSig.split(':')[0] || 'unknown';
|
||||||
|
return { detected: true, toolName, count: minCount };
|
||||||
|
}
|
||||||
|
return { detected: false, toolName: '', count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置死循环检测状态(新会话开始时调用) */
|
||||||
|
function resetLoopDeadlockDetector(): void {
|
||||||
|
_loopToolSignatures.length = 0;
|
||||||
|
_deadlockHintInjected = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* R3: 工具执行超时配置(毫秒)
|
* R3: 工具执行超时配置(毫秒)
|
||||||
* 防止单个工具调用挂起导致整个 Agent Loop 卡死
|
* 防止单个工具调用挂起导致整个 Agent Loop 卡死
|
||||||
@@ -317,13 +350,6 @@ async function executeToolWithTimeout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** D2/D3: 只读工具白名单 — 允许同参数重复调用(验证场景:写文件后重读、git status、run_command 监控等) */
|
|
||||||
const RE_READ_ALLOWED = new Set([
|
|
||||||
'read_file', 'list_directory', 'search_files', 'get_file_info', 'tree',
|
|
||||||
'web_search', 'git', 'session_list', 'session_read', 'diff_files',
|
|
||||||
'browser_screenshot', 'browser_extract', 'run_command',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** P0-2: 检测工具调用之间是否存在路径依赖(写入 → 读取/写入同一路径)
|
/** P0-2: 检测工具调用之间是否存在路径依赖(写入 → 读取/写入同一路径)
|
||||||
* R27: 增强 — 也检测读-写依赖(读取刚写入的文件需要串行化)
|
* R27: 增强 — 也检测读-写依赖(读取刚写入的文件需要串行化)
|
||||||
*/
|
*/
|
||||||
@@ -386,7 +412,7 @@ function extractWritePath(call: ToolCall): string | null {
|
|||||||
switch (call.function.name) {
|
switch (call.function.name) {
|
||||||
case 'write_file': return String(args.path || '');
|
case 'write_file': return String(args.path || '');
|
||||||
case 'edit_file': return String(args.path || '');
|
case 'edit_file': return String(args.path || '');
|
||||||
case 'delete_file': return String(args.path || '');
|
case 'delete_file': return args.paths && Array.isArray(args.paths) && args.paths.length > 0 ? String(args.paths[0] || '') : String(args.path || '');
|
||||||
case 'create_directory': return String(args.path || '');
|
case 'create_directory': return String(args.path || '');
|
||||||
case 'move_file': return String(args.destination || args.source || '');
|
case 'move_file': return String(args.destination || args.source || '');
|
||||||
case 'copy_file': return String(args.destination || '');
|
case 'copy_file': return String(args.destination || '');
|
||||||
@@ -403,7 +429,7 @@ function extractAffectedPath(call: ToolCall): string | null {
|
|||||||
case 'read_file': return String(args.path || '');
|
case 'read_file': return String(args.path || '');
|
||||||
case 'write_file': return String(args.path || '');
|
case 'write_file': return String(args.path || '');
|
||||||
case 'edit_file': return String(args.path || '');
|
case 'edit_file': return String(args.path || '');
|
||||||
case 'delete_file': return String(args.path || '');
|
case 'delete_file': return args.paths && Array.isArray(args.paths) && args.paths.length > 0 ? String(args.paths[0] || '') : String(args.path || '');
|
||||||
case 'list_directory': return String(args.path || '');
|
case 'list_directory': return String(args.path || '');
|
||||||
case 'search_files': return String(args.path || '');
|
case 'search_files': return String(args.path || '');
|
||||||
case 'create_directory': return String(args.path || '');
|
case 'create_directory': return String(args.path || '');
|
||||||
@@ -429,42 +455,6 @@ function pathsConflict(a: string, b: string): boolean {
|
|||||||
return na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/');
|
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}字符 已截断)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代 */
|
/** P2-4: 工具参数前置校验 — 轻量级参数检查,避免无效参数浪费一轮迭代 */
|
||||||
function validateToolArgs(toolName: string, args: Record<string, unknown>): string | null {
|
function validateToolArgs(toolName: string, args: Record<string, unknown>): string | null {
|
||||||
const getString = (key: string): string | null => {
|
const getString = (key: string): string | null => {
|
||||||
@@ -771,7 +761,7 @@ function formatDefaultToolResult(toolName: string, result: ToolResult): string {
|
|||||||
/**
|
/**
|
||||||
* 格式化工具结果,生成模型友好的简洁表示
|
* 格式化工具结果,生成模型友好的简洁表示
|
||||||
*/
|
*/
|
||||||
function formatToolResultForModel(toolName: string, result: ToolResult): string {
|
export function formatToolResultForModel(toolName: string, result: ToolResult): string {
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return JSON.stringify({ success: false, error: result.error || '工具执行失败' });
|
return JSON.stringify({ success: false, error: result.error || '工具执行失败' });
|
||||||
}
|
}
|
||||||
@@ -921,6 +911,102 @@ function formatToolResultForModel(toolName: string, result: ToolResult): string
|
|||||||
return formatDefaultToolResult(toolName, result);
|
return formatDefaultToolResult(toolName, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'delete_file': {
|
||||||
|
// 批量删除
|
||||||
|
if ((result as any).batch) {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `批量删除完成:成功 ${result.successCount}/${result.totalPaths} 个路径`,
|
||||||
|
batch: true,
|
||||||
|
totalPaths: result.totalPaths,
|
||||||
|
successCount: result.successCount,
|
||||||
|
failCount: result.failCount,
|
||||||
|
results: result.results,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `已删除${(result as any).type === 'directory' ? '目录' : '文件'}:${result.path}`,
|
||||||
|
path: result.path,
|
||||||
|
deleted: true,
|
||||||
|
type: (result as any).type,
|
||||||
|
deletedSize: result.deletedSize,
|
||||||
|
...((result as any).filesDeleted !== undefined && { filesDeleted: (result as any).filesDeleted }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create_directory': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `目录已创建:${result.path}`,
|
||||||
|
path: result.path,
|
||||||
|
created: (result as any).created,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move_file': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `已移动:${(result as any).source} → ${(result as any).destination}`,
|
||||||
|
source: (result as any).source,
|
||||||
|
destination: (result as any).destination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'copy_file': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `已复制:${(result as any).source} → ${(result as any).destination}`,
|
||||||
|
source: (result as any).source,
|
||||||
|
destination: (result as any).destination,
|
||||||
|
bytesCopied: (result as any).bytesCopied,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'download_file': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `已下载:${(result as any).url} → ${(result as any).destination}`,
|
||||||
|
url: (result as any).url,
|
||||||
|
destination: (result as any).destination,
|
||||||
|
bytesDownloaded: (result as any).bytesDownloaded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'compress': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `已压缩:${(result as any).outputPath}`,
|
||||||
|
outputPath: (result as any).outputPath,
|
||||||
|
originalSize: (result as any).originalSize,
|
||||||
|
compressedSize: (result as any).compressedSize,
|
||||||
|
filesProcessed: (result as any).filesProcessed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'get_file_info': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `${(result as any).type === 'directory' ? '目录' : '文件'}:${result.path}`,
|
||||||
|
path: result.path,
|
||||||
|
type: (result as any).type,
|
||||||
|
size: (result as any).size,
|
||||||
|
modified: (result as any).modified,
|
||||||
|
created: (result as any).created,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tree': {
|
||||||
|
return JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `目录树:${result.path}(${(result as any).totalEntries} 项)`,
|
||||||
|
path: result.path,
|
||||||
|
entries: result.entries,
|
||||||
|
totalEntries: (result as any).totalEntries,
|
||||||
|
truncated: result.truncated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return formatDefaultToolResult(toolName, result);
|
return formatDefaultToolResult(toolName, result);
|
||||||
}
|
}
|
||||||
@@ -1116,7 +1202,8 @@ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]
|
|||||||
}
|
}
|
||||||
if (soulMdContent) systemPromptParts.unshift(`[SOUL.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(soulMdContent)}\n<<<REFERENCE_DATA_END>>>`);
|
if (soulMdContent) systemPromptParts.unshift(`[SOUL.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(soulMdContent)}\n<<<REFERENCE_DATA_END>>>`);
|
||||||
|
|
||||||
// AGENT.md — 工作空间优先,内置 fallback,Token 预算截断
|
// AGENT.md — 仅从工作空间加载,无内置 fallback,Token 预算截断
|
||||||
|
// 有则注入,无则跳过(不注入任何 AGENT.md 内容)
|
||||||
let agentMdContent = '';
|
let agentMdContent = '';
|
||||||
if (workspaceDir) {
|
if (workspaceDir) {
|
||||||
try {
|
try {
|
||||||
@@ -1124,12 +1211,6 @@ async function loadCustomFiles(workspaceDir: string, systemPromptParts: string[]
|
|||||||
if (r?.success && r.content) { agentMdContent = r.content; logInfo('AGENT.md 已从工作空间加载', `${r.lines || 0} 行`); }
|
if (r?.success && r.content) { agentMdContent = r.content; logInfo('AGENT.md 已从工作空间加载', `${r.lines || 0} 行`); }
|
||||||
} catch { /* ignore */ }
|
} 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]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(truncateByTokenBudget(agentMdContent, 2000))}\n<<<REFERENCE_DATA_END>>>`);
|
if (agentMdContent) systemPromptParts.push(`[AGENT.md]\n<<<REFERENCE_DATA_START>>>\n${sanitizeUntrustedInput(truncateByTokenBudget(agentMdContent, 2000))}\n<<<REFERENCE_DATA_END>>>`);
|
||||||
|
|
||||||
// USER.md — 仅工作空间,无内置 fallback
|
// USER.md — 仅工作空间,无内置 fallback
|
||||||
@@ -1183,6 +1264,7 @@ async function handleInit(
|
|||||||
// 新一轮对话,清空工具缓存
|
// 新一轮对话,清空工具缓存
|
||||||
toolResultCache.clear();
|
toolResultCache.clear();
|
||||||
resetAllSafetyState(); // R51-R56: 重置所有安全状态
|
resetAllSafetyState(); // R51-R56: 重置所有安全状态
|
||||||
|
resetLoopDeadlockDetector(); // 重置跨轮次死循环检测
|
||||||
resetTokenBudget(); // R93: 重置 Token 预算追踪
|
resetTokenBudget(); // R93: 重置 Token 预算追踪
|
||||||
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
|
setTokenBudgetNumCtx(getEffectiveNumCtx()); // R93: 设置当前预算 numCtx
|
||||||
setUserGoal(userContent); // R56: 设置用户原始目标
|
setUserGoal(userContent); // R56: 设置用户原始目标
|
||||||
@@ -1440,12 +1522,19 @@ async function verifyToolResult(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'delete_file': {
|
case 'delete_file': {
|
||||||
const path = String(args.path || '');
|
// 支持批量删除核验:检查 paths 数组和单个 path
|
||||||
const info = await bridge.tool.execute('get_file_info', { path });
|
const pathsToCheck: string[] = [];
|
||||||
if (info.success) {
|
if (args.paths && Array.isArray(args.paths)) {
|
||||||
logWarn(`核验 delete_file: ${path} 仍然存在,删除未生效`);
|
pathsToCheck.push(...args.paths.map((p: unknown) => String(p)));
|
||||||
} else {
|
}
|
||||||
logInfo(`核验 delete_file ✅: ${path} 已不存在`);
|
if (args.path) pathsToCheck.push(String(args.path));
|
||||||
|
for (const p of pathsToCheck) {
|
||||||
|
const info = await bridge.tool.execute('get_file_info', { path: p });
|
||||||
|
if (info.success) {
|
||||||
|
logWarn(`核验 delete_file: ${p} 仍然存在,删除未生效`);
|
||||||
|
} else {
|
||||||
|
logInfo(`核验 delete_file ✅: ${p} 已不存在`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1515,6 +1604,33 @@ async function handleThinking(
|
|||||||
ctx.thinking = '';
|
ctx.thinking = '';
|
||||||
ctx.toolCalls.length = 0;
|
ctx.toolCalls.length = 0;
|
||||||
|
|
||||||
|
// ── 跨轮次死循环检测 ──
|
||||||
|
// 硬性熔断:已注入提示后仍连续 5 轮相同 → 强制终止
|
||||||
|
if (_deadlockHintInjected) {
|
||||||
|
const hardBreak = detectLoopDeadlock(5);
|
||||||
|
if (hardBreak.detected) {
|
||||||
|
logWarn(`死循环熔断: 连续 ${hardBreak.count} 轮相同工具调用 (${hardBreak.toolName}),已注入提示但仍未改善,强制终止`);
|
||||||
|
const fallbackReply = `已执行工具调用但模型未能生成最终回复。最后执行的工具: ${hardBreak.toolName}。请尝试重新描述任务或切换模型。`;
|
||||||
|
ctx.content = fallbackReply;
|
||||||
|
callbacks.onDone(fallbackReply, ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
||||||
|
transition(ctx, S.TERMINATED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 软性提示:连续 3 轮相同工具调用 → 注入事实性提示(不修改模型输出)
|
||||||
|
if (!_deadlockHintInjected) {
|
||||||
|
const softWarn = detectLoopDeadlock(3);
|
||||||
|
if (softWarn.detected) {
|
||||||
|
logWarn(`死循环提示: 连续 ${softWarn.count} 轮相同工具调用 (${softWarn.toolName}),注入事实性提示`);
|
||||||
|
ctx.messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `[系统提示] 你已连续 ${softWarn.count} 轮调用相同的工具 (${softWarn.toolName}) 并获得相同结果。任务可能已完成,请基于已有工具结果直接回复用户,不要再调用工具。`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
_deadlockHintInjected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Plan Mode 执行进度注入 — 仅在进度变化时注入,避免每轮重复 ──
|
// ── Plan Mode 执行进度注入 — 仅在进度变化时注入,避免每轮重复 ──
|
||||||
if (ctx.mode === 'plan' && ctx.loopCount >= 1) {
|
if (ctx.mode === 'plan' && ctx.loopCount >= 1) {
|
||||||
const tracker = getPlanTracker();
|
const tracker = getPlanTracker();
|
||||||
@@ -1530,48 +1646,6 @@ async function handleThinking(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// P1-1 优化:合并任务感知、待办追踪、Token 预算警告为1条结构化提醒
|
|
||||||
// 减少上下文中合成 user 消息数量,降低模型注意力分散
|
|
||||||
const _reminders: string[] = [];
|
|
||||||
|
|
||||||
// 任务感知 — 检测用户请求需要工具但模型尚未行动
|
|
||||||
// P1-2 优化:用更精确的短语替代单字匹配,避免"查无此人"等误报
|
|
||||||
if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) {
|
|
||||||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
|
||||||
const userText = (firstUserMsg?.content || '').toLowerCase();
|
|
||||||
const actionVerbs = [
|
|
||||||
'搜索', '查找', '查一下', '搜一下', '检索', '查询',
|
|
||||||
'写入文件', '创建文件', '生成文件', '保存文件',
|
|
||||||
'运行命令', '执行命令', '运行脚本', '编译',
|
|
||||||
'抓取网页', '获取内容', '抓取全文',
|
|
||||||
'下载文件', '提交代码', '推送代码', '克隆仓库',
|
|
||||||
'读取文件', '删除文件', '移动文件', '复制文件', '压缩文件',
|
|
||||||
'search', 'find', 'write', 'create', 'run', 'execute', 'fetch', 'download', 'clone',
|
|
||||||
];
|
|
||||||
const needsTools = actionVerbs.some(v => userText.includes(v)) && userText.length > 30;
|
|
||||||
if (needsTools) {
|
|
||||||
_reminders.push('请直接调用工具完成任务,不要只描述做法');
|
|
||||||
logInfo('任务感知: 检测到需要工具');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务感知和待办追踪仅在 Plan Mode 下通过 Plan Tracker 统一管理
|
|
||||||
// 非 Plan 模式下信任 AI 自主根据用户请求和工具结果判断进度
|
|
||||||
|
|
||||||
// Token 预算警告
|
|
||||||
const remaining = ctx.maxLoops - ctx.loopCount + 1;
|
|
||||||
if (remaining <= 3 && remaining > 0) {
|
|
||||||
_reminders.push(`剩余 ${remaining} 轮,尽快完成`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并为1条结构化提醒(减少上下文噪音)
|
|
||||||
if (_reminders.length > 0) {
|
|
||||||
const reminderContent = _reminders.length === 1
|
|
||||||
? `[系统提醒] ${_reminders[0]}`
|
|
||||||
: `[系统提醒]\n${_reminders.map((r, i) => `${i + 1}. ${r}`).join('\n')}`;
|
|
||||||
ctx.messages.push({ role: 'user', content: reminderContent, ephemeral: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const abortController = registerAbortController();
|
const abortController = registerAbortController();
|
||||||
|
|
||||||
// ── 流式超时保护 — 0=禁用, 正数=自定义, -1/未设置=动态计算 ──
|
// ── 流式超时保护 — 0=禁用, 正数=自定义, -1/未设置=动态计算 ──
|
||||||
@@ -1824,6 +1898,9 @@ async function handleThinking(
|
|||||||
ctx.loopPromptEvalCount = 0;
|
ctx.loopPromptEvalCount = 0;
|
||||||
ctx.loopInferenceNs = 0;
|
ctx.loopInferenceNs = 0;
|
||||||
|
|
||||||
|
// 记录本轮工具调用签名(用于跨轮次死循环检测)
|
||||||
|
recordLoopSignature(ctx.toolCalls);
|
||||||
|
|
||||||
transition(ctx, S.PARSING);
|
transition(ctx, S.PARSING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1832,10 +1909,19 @@ async function handleThinking(
|
|||||||
*/
|
*/
|
||||||
async function handleParsing(ctx: LoopContext): Promise<void> {
|
async function handleParsing(ctx: LoopContext): Promise<void> {
|
||||||
// 保存 assistant 消息
|
// 保存 assistant 消息
|
||||||
|
// 当 content 为空但有 thinking 时,将 thinking 作为 content fallback
|
||||||
|
// 原因:Ollama 对历史消息中 thinking 字段的支持不确定,空 content 会导致
|
||||||
|
// AI 丢失推理上下文(特别是 tool_calls 前的推理过程)
|
||||||
|
const hasThinking = !!ctx.thinking && ctx.thinking.trim().length > 0;
|
||||||
|
const hasContent = !!ctx.content && ctx.content.trim().length > 0;
|
||||||
|
const fallbackContent = !hasContent && hasThinking
|
||||||
|
? `[推理过程] ${ctx.thinking!.trim()}`
|
||||||
|
: ctx.content;
|
||||||
|
|
||||||
const assistantMsg: OllamaMessage = {
|
const assistantMsg: OllamaMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: ctx.content,
|
content: fallbackContent,
|
||||||
...(ctx.thinking && { thinking: ctx.thinking })
|
...(hasThinking && { thinking: ctx.thinking })
|
||||||
};
|
};
|
||||||
if (ctx.toolCalls.length > 0) {
|
if (ctx.toolCalls.length > 0) {
|
||||||
assistantMsg.tool_calls = ctx.toolCalls;
|
assistantMsg.tool_calls = ctx.toolCalls;
|
||||||
@@ -1866,36 +1952,6 @@ async function handleExecuting(
|
|||||||
ctx: LoopContext,
|
ctx: LoopContext,
|
||||||
callbacks: AgentCallbacks,
|
callbacks: AgentCallbacks,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 跨轮次去重检测
|
|
||||||
const currentLoopKeys = ctx.toolCalls.map(c => getToolCacheKey(c.function.name, c.function.arguments)).sort();
|
|
||||||
const currentKeysStr = JSON.stringify(currentLoopKeys);
|
|
||||||
const prevKeysStr = JSON.stringify([...ctx.prevLoopSuccessKeys].sort());
|
|
||||||
if (currentKeysStr === prevKeysStr && currentLoopKeys.length > 0) {
|
|
||||||
const hasFailedInPrev = ctx.allToolRecords
|
|
||||||
.filter(r => ctx.prevLoopSuccessKeys.includes(getToolCacheKey(r.name, r.arguments)))
|
|
||||||
.some(r => r.status !== 'success');
|
|
||||||
// D2: 只读工具全部相同时不阻断(如反复 git status、read_file 验证)
|
|
||||||
const allReadOnly = ctx.toolCalls.every(c => RE_READ_ALLOWED.has(c.function.name));
|
|
||||||
if (!hasFailedInPrev && !allReadOnly) {
|
|
||||||
logWarn('检测到连续两轮工具调用完全相同且全部成功,注入提醒而非终止');
|
|
||||||
// ── 取消工具卡片:卡片已在 THINKING 阶段设为 pending,这里需要收尾 ──
|
|
||||||
for (const call of ctx.toolCalls) {
|
|
||||||
callbacks.onToolCallError(call.function.name, '系统跳过(与上一轮完全相同)', call);
|
|
||||||
}
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: '⚠️ 检测到你连续调用了与上一轮完全相同的工具。请基于已有结果给出最终回答,或者调用不同的工具获取新信息。如果任务已完成,请给出最终回答。'
|
|
||||||
});
|
|
||||||
transition(ctx, S.THINKING);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!hasFailedInPrev && allReadOnly) {
|
|
||||||
logInfo('跨轮次重复但全部为只读工具,允许执行');
|
|
||||||
} else {
|
|
||||||
logInfo('检测到重复调用但上一轮有失败,允许重试');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 工具并行执行:智能批次划分(P0修复:检测实际数据依赖)──
|
// ── 工具并行执行:智能批次划分(P0修复:检测实际数据依赖)──
|
||||||
const batches: ToolCall[][] = [];
|
const batches: ToolCall[][] = [];
|
||||||
let currentBatch: ToolCall[] = [];
|
let currentBatch: ToolCall[] = [];
|
||||||
@@ -2172,11 +2228,11 @@ async function handleExecuting(
|
|||||||
ctx.allToolRecords.push(record);
|
ctx.allToolRecords.push(record);
|
||||||
// R88: 为工具结果添加元数据提示(token 估算)
|
// R88: 为工具结果添加元数据提示(token 估算)
|
||||||
const formattedResult = addResultMetadata(formatToolResultForModel(record.name, record.result!));
|
const formattedResult = addResultMetadata(formatToolResultForModel(record.name, record.result!));
|
||||||
// R104: 检测重复工具结果
|
// R104: 检测重复工具结果(仅记录日志,不修改结果内容)
|
||||||
if (record.status === 'success') {
|
if (record.status === 'success') {
|
||||||
const dedup = isDuplicateToolResult(record.name, formattedResult);
|
const dedup = isDuplicateToolResult(record.name, formattedResult);
|
||||||
if (dedup.duplicate) {
|
if (dedup.duplicate) {
|
||||||
logInfo(`R104: 检测到重复工具结果: ${record.name},添加标记`);
|
logInfo(`R104: 检测到重复工具结果: ${record.name},已记录`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// R92: 工具结果格式标准化 — 添加统一头信息(工具名、状态、耗时、结果大小)
|
// R92: 工具结果格式标准化 — 添加统一头信息(工具名、状态、耗时、结果大小)
|
||||||
@@ -2200,7 +2256,7 @@ async function handleExecuting(
|
|||||||
verifyToolResult(record.name, record.arguments, record.result!);
|
verifyToolResult(record.name, record.arguments, record.result!);
|
||||||
}
|
}
|
||||||
// ── post_tool Hook ──
|
// ── post_tool Hook ──
|
||||||
executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! });
|
executeHooks('post_tool', ctx, { toolName: record.name, toolArgs: record.arguments, toolResult: record.result! }).catch(err => logWarn('post_tool hook 异常', String(err)));
|
||||||
// P0 修复: 使用参数匹配而非仅工具名匹配,避免同批次同名工具的错误关联
|
// P0 修复: 使用参数匹配而非仅工具名匹配,避免同批次同名工具的错误关联
|
||||||
const matchedCall = batch.find(c =>
|
const matchedCall = batch.find(c =>
|
||||||
c.function.name === record.name &&
|
c.function.name === record.name &&
|
||||||
@@ -2233,6 +2289,8 @@ async function handleObserving(
|
|||||||
const pressureInfo = getContextPressureLevel(ctx.messages, numCtx);
|
const pressureInfo = getContextPressureLevel(ctx.messages, numCtx);
|
||||||
{
|
{
|
||||||
// R91: 根据压力等级动态调整工具消息保留数量
|
// R91: 根据压力等级动态调整工具消息保留数量
|
||||||
|
// 原子组裁剪:删除 tool 消息时同步处理其对应的 assistant(带 tool_calls),
|
||||||
|
// 避免出现"有 tool_calls 无 tool 结果"的断裂状态
|
||||||
const maxToolMsgs = pressureInfo.level === 'critical' ? 30
|
const maxToolMsgs = pressureInfo.level === 'critical' ? 30
|
||||||
: pressureInfo.level === 'high' ? 40
|
: pressureInfo.level === 'high' ? 40
|
||||||
: pressureInfo.level === 'medium' ? 60
|
: pressureInfo.level === 'medium' ? 60
|
||||||
@@ -2242,47 +2300,52 @@ async function handleObserving(
|
|||||||
if (ctx.messages[i].role === 'tool') toolIndices.push(i);
|
if (ctx.messages[i].role === 'tool') toolIndices.push(i);
|
||||||
}
|
}
|
||||||
if (toolIndices.length > maxToolMsgs) {
|
if (toolIndices.length > maxToolMsgs) {
|
||||||
const toRemove = toolIndices.slice(0, toolIndices.length - maxToolMsgs);
|
const toRemoveToolIndices = new Set(toolIndices.slice(0, toolIndices.length - maxToolMsgs));
|
||||||
for (let i = toRemove.length - 1; i >= 0; i--) {
|
const toRemove = new Set<number>(toRemoveToolIndices);
|
||||||
ctx.messages.splice(toRemove[i], 1);
|
const toStripToolCalls = new Set<number>();
|
||||||
|
|
||||||
|
// 检查每个带 tool_calls 的 assistant:如果其所有 tool 消息都被删除,处理 assistant
|
||||||
|
for (let i = 0; i < ctx.messages.length; i++) {
|
||||||
|
const msg = ctx.messages[i];
|
||||||
|
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
||||||
|
const followingToolIndices: number[] = [];
|
||||||
|
for (let j = i + 1; j < ctx.messages.length && ctx.messages[j].role === 'tool'; j++) {
|
||||||
|
followingToolIndices.push(j);
|
||||||
|
}
|
||||||
|
if (followingToolIndices.length === 0) continue;
|
||||||
|
const allRemoved = followingToolIndices.every(idx => toRemoveToolIndices.has(idx));
|
||||||
|
if (allRemoved) {
|
||||||
|
if (msg.content && msg.content.trim()) {
|
||||||
|
// 有内容:保留 assistant 但移除 tool_calls
|
||||||
|
toStripToolCalls.add(i);
|
||||||
|
} else {
|
||||||
|
// 无内容:删除整个 assistant
|
||||||
|
toRemove.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logInfo(`R91: 工具消息裁剪: ${toRemove.length} 条旧结果已移除 (压力:${pressureInfo.level}, 保留 ${maxToolMsgs} 条)`);
|
|
||||||
|
// 先移除 tool_calls(在删除消息之前,索引尚未变化)
|
||||||
|
for (const idx of toStripToolCalls) {
|
||||||
|
const msg = ctx.messages[idx];
|
||||||
|
const newMsg: OllamaMessage = { ...msg };
|
||||||
|
delete newMsg.tool_calls;
|
||||||
|
ctx.messages[idx] = newMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再删除消息
|
||||||
|
if (toRemove.size > 0) {
|
||||||
|
ctx.messages = ctx.messages.filter((_, idx) => !toRemove.has(idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo(`R91: 工具消息裁剪: ${toRemoveToolIndices.size} 条旧 tool 结果已移除, ${toRemove.size - toRemoveToolIndices.size} 条空 assistant 删除, ${toStripToolCalls.size} 条 assistant 移除 tool_calls (压力:${pressureInfo.level}, 保留上限 ${maxToolMsgs} 条)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新跨轮去重
|
|
||||||
ctx.prevLoopSuccessKeys = ctx.allToolRecords
|
|
||||||
.filter(r => r.status === 'success')
|
|
||||||
.map(r => getToolCacheKey(r.name, r.arguments));
|
|
||||||
|
|
||||||
// 保存本轮工具调用
|
// 保存本轮工具调用
|
||||||
ctx.prevToolCalls = [...ctx.toolCalls];
|
ctx.prevToolCalls = [...ctx.toolCalls];
|
||||||
|
|
||||||
// R52: 状态震荡检测 — 检测 A→B→A→B 模式
|
|
||||||
for (const call of ctx.toolCalls) {
|
|
||||||
recordToolCallHistory(call.function.name, call.function.arguments);
|
|
||||||
}
|
|
||||||
if (detectOscillation()) {
|
|
||||||
logWarn('R52: 检测到工具调用震荡模式(A→B→A→B),注入终止信号');
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: '⚠️ 检测到你在两种操作之间来回切换但没有进展。请停下来分析当前状态,换一种策略,或基于已有结果给出最终回答。',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// R54: 增强死循环检测 — 连续 3 次完全相同的工具调用(文档标准)
|
|
||||||
const consecutive = detectConsecutiveIdentical(3);
|
|
||||||
if (consecutive.detected) {
|
|
||||||
logWarn(`R54: 检测到连续 ${consecutive.count} 次相同调用: ${consecutive.toolName},强制终止`);
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `⛔ 检测到连续 ${consecutive.count} 次用完全相同参数调用「${consecutive.toolName}」。这已被系统识别为死循环。立即停止重复调用,基于已有结果给出最终回答。`,
|
|
||||||
});
|
|
||||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// R53: 主动上下文压缩 — 使用预测系统提前触发压缩
|
// R53: 主动上下文压缩 — 使用预测系统提前触发压缩
|
||||||
{
|
{
|
||||||
const numCtx = getEffectiveNumCtx();
|
const numCtx = getEffectiveNumCtx();
|
||||||
@@ -2312,44 +2375,6 @@ async function handleObserving(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// R56: 目标对齐验证 — 每 8 轮检查 Agent 是否偏离用户原始目标
|
|
||||||
if (ctx.loopCount > 0 && ctx.loopCount % 8 === 0) {
|
|
||||||
const recentToolNames = ctx.allToolRecords.slice(-8).map(r => r.name);
|
|
||||||
const recentContent = ctx.messages.slice(-10).map(m => m.content || '').join(' ').slice(0, 2000);
|
|
||||||
const alignment = checkGoalAlignment(recentToolNames, recentContent, ctx.loopCount);
|
|
||||||
if (!alignment.aligned && alignment.suggestion) {
|
|
||||||
logWarn(`R56: 目标对齐检测 — ${alignment.reason}`);
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `[目标对齐提醒] ${alignment.suggestion}`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// P2-1: 工具调用失败恢复指引 — 同一工具同一参数连续失败2次以上时注入恢复建议
|
|
||||||
{
|
|
||||||
const errorRecords = ctx.allToolRecords.filter(r => r.status === 'error');
|
|
||||||
if (errorRecords.length > 0) {
|
|
||||||
const lastError = errorRecords[errorRecords.length - 1];
|
|
||||||
const lastErrorKey = getToolCacheKey(lastError.name, lastError.arguments);
|
|
||||||
// 检查是否连续失败同一工具同一参数
|
|
||||||
const sameFailures = errorRecords.filter(r =>
|
|
||||||
r.name === lastError.name &&
|
|
||||||
getToolCacheKey(r.name, r.arguments) === lastErrorKey
|
|
||||||
);
|
|
||||||
if (sameFailures.length >= 2) {
|
|
||||||
const errorMsg = lastError.result?.error || '未知错误';
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `[失败恢复] "${lastError.name}" 已连续失败 ${sameFailures.length} 次(${errorMsg})。建议:1) 检查参数是否正确 2) 换一种方法 3) 如果无法解决,说明原因并给出替代方案。不要重复调用相同参数。`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
logInfo(`失败恢复指引: ${lastError.name} 连续失败 ${sameFailures.length} 次`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
|
// R112: 每 15 轮输出诊断报告 + R100: Token 使用统计报告
|
||||||
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
|
if (ctx.loopCount > 0 && ctx.loopCount % 15 === 0) {
|
||||||
try {
|
try {
|
||||||
@@ -2366,7 +2391,7 @@ async function handleObserving(
|
|||||||
recordIteration(ctx);
|
recordIteration(ctx);
|
||||||
|
|
||||||
// ── post_iteration Hook ──
|
// ── post_iteration Hook ──
|
||||||
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
|
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount }).catch(err => logWarn('post_iteration hook 异常', String(err)));
|
||||||
|
|
||||||
// R95: 按工具类型智能截断 — 根据压力等级和工具类型选择截断策略
|
// R95: 按工具类型智能截断 — 根据压力等级和工具类型选择截断策略
|
||||||
if (ctx.loopCount > 10 && ctx.loopCount % 5 === 0) {
|
if (ctx.loopCount > 10 && ctx.loopCount % 5 === 0) {
|
||||||
@@ -2390,31 +2415,6 @@ async function handleObserving(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 通用重复调用检测:同工具同参数连续成功 2+ 次 ──
|
|
||||||
// D1: 移除 hasDedupSignal 触发路径 — memory duplicate 改为软提醒,不强制终止
|
|
||||||
const recentSuccess = ctx.allToolRecords.filter(r => r.status === 'success').slice(-2);
|
|
||||||
// P0 修复:仅当工具名 AND 参数完全相同时才判定为重复(避免读两个不同文件被误杀)
|
|
||||||
const allSameTool = recentSuccess.length >= 2
|
|
||||||
&& recentSuccess.every(r => r.name === recentSuccess[0].name)
|
|
||||||
&& getToolCacheKey(recentSuccess[0].name, recentSuccess[0].arguments) === getToolCacheKey(recentSuccess[1].name, recentSuccess[1].arguments);
|
|
||||||
|
|
||||||
// P0-1 优化:只读工具允许同名同参数重复调用(验证场景:写文件后重读、git status 等)
|
|
||||||
// D3: 添加 run_command 到白名单(监控场景:反复执行同命令检查状态变化)
|
|
||||||
const isReadOnlyRepeat = allSameTool && recentSuccess.length > 0 && RE_READ_ALLOWED.has(recentSuccess[0].name);
|
|
||||||
|
|
||||||
if (allSameTool && !isReadOnlyRepeat) {
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `⛔ 检测到重复的工具调用。「${recentSuccess[0]?.name || 'memory'}」操作已经完成,立即输出最终回答,绝对不要再调用任何工具。`,
|
|
||||||
});
|
|
||||||
logInfo(`重复调用检测: ${recentSuccess[0]?.name},注入⛔终止信号`);
|
|
||||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
|
|
||||||
// 不要 transition 到 REFLECTING,直接在这里就已经注入了信号,下一轮 THINKING 会看到
|
|
||||||
} else if (isReadOnlyRepeat) {
|
|
||||||
// 只读工具重复调用是合法的验证场景(如写文件后重读确认),不注入⛔
|
|
||||||
logInfo(`只读工具重复调用(允许验证场景): ${recentSuccess[0].name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每 10 轮清理累积的 ephemeral 消息
|
// 每 10 轮清理累积的 ephemeral 消息
|
||||||
// C7: 保留 Plan Mode 关键 ephemeral 消息(含进度、计划批准等),只清理纯提醒类
|
// C7: 保留 Plan Mode 关键 ephemeral 消息(含进度、计划批准等),只清理纯提醒类
|
||||||
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
|
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
|
||||||
@@ -2474,23 +2474,6 @@ async function handleReflecting(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 中止检查
|
// 中止检查
|
||||||
if (isAborted()) return;
|
if (isAborted()) return;
|
||||||
// ── 异常1: 空响应 + 有工具历史 → 模型可能困惑 → 注入提示 ──
|
|
||||||
if (ctx.toolCalls.length === 0 && !ctx.content.trim() && ctx.loopCount > 1 && ctx.allToolRecords.length > 0) {
|
|
||||||
logWarn('模型返回空内容(有未处理的工具结果),注入继续提示');
|
|
||||||
// 精准移除最后一条 assistant 消息(而非盲目 pop)
|
|
||||||
for (let i = ctx.messages.length - 1; i >= 0; i--) {
|
|
||||||
if (ctx.messages[i].role === 'assistant') {
|
|
||||||
ctx.messages.splice(i, 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: '请根据上面的工具调用结果继续回答。如果需要更多信息,可以继续调用工具。如果已有足够信息,请给出最终回答。'
|
|
||||||
});
|
|
||||||
transition(ctx, S.THINKING);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
|
// ── Plan Mode: 第一轮回复 → 检测是否为计划 → 等待用户确认 ──
|
||||||
// P1 修复:移除 ctx.toolCalls.length === 0 条件 — 模型可能忽略 Plan Mode 指令直接调用工具,
|
// P1 修复:移除 ctx.toolCalls.length === 0 条件 — 模型可能忽略 Plan Mode 指令直接调用工具,
|
||||||
@@ -2599,71 +2582,6 @@ async function handleReflecting(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 异常3: 模型说了要做但没调用工具 → 注入提醒再给一次机会 ──
|
|
||||||
// 场景:AI 回复"我来写入文件"但未发出 write_file 工具调用
|
|
||||||
// 优化:区分"意图声明"(将来时)与"结果描述"(完成时),避免对总结性回复误判
|
|
||||||
if (ctx.toolCalls.length === 0 && ctx.content.trim() && ctx.loopCount <= 3) {
|
|
||||||
const text = ctx.content.toLowerCase();
|
|
||||||
const contentLen = ctx.content.trim().length;
|
|
||||||
|
|
||||||
// 结果描述迹象 — 回复中包含这些,说明模型在总结/描述而非空谈
|
|
||||||
const resultIndicators = [
|
|
||||||
/根据.*(结果|内容|返回|输出)/, /搜索结果/, /查询结果/, /查找结果/,
|
|
||||||
/结果显示/, /结果如下/, /已找到/, /已获取/, /已完成/, /已经.*(写入|保存|创建|搜索|执行)/,
|
|
||||||
/从.*获取/, /已写入/, /已保存/, /已创建/, /已执行/,
|
|
||||||
/search result/, /found that/, /according to/i,
|
|
||||||
];
|
|
||||||
const isDescribingResult = resultIndicators.some(p => p.test(text));
|
|
||||||
|
|
||||||
// 意图声明模式 — 只有明确表达"将要做"时才视为"说了没做"
|
|
||||||
const intentPatterns = [
|
|
||||||
/我来.*(写入|写文件|创建|生成|保存|搜索|查找|抓取|运行|执行|下载)/,
|
|
||||||
/让我.*(写入|写文件|创建|生成|保存|搜索|查找|抓取|运行|执行|下载)/,
|
|
||||||
/我将.*(写入|写文件|创建|生成|保存|搜索|查找|抓取|运行|执行|下载)/,
|
|
||||||
/接下来.*(写入|写文件|创建|生成|保存|搜索|查找|抓取|运行|执行|下载)/,
|
|
||||||
/现在.*(写入|写文件|创建|生成|保存|搜索|查找|抓取|运行|执行|下载)/,
|
|
||||||
/需要.*(写入|写文件|创建|生成|保存|搜索|查找|抓取|运行|执行|下载)/,
|
|
||||||
/i('?)ll (write|create|search|fetch|run|execute|download)/i,
|
|
||||||
/let me (write|create|search|fetch|run|execute|download)/i,
|
|
||||||
/i will (write|create|search|fetch|run|execute|download)/i,
|
|
||||||
/i need to (write|create|search|fetch|run|execute|download)/i,
|
|
||||||
];
|
|
||||||
const hasIntentStatement = intentPatterns.some(p => p.test(text));
|
|
||||||
|
|
||||||
// 上一轮已调用的工具 — 本轮可能在总结结果,不触发
|
|
||||||
const lastLoopToolNames = (ctx.prevToolCalls || []).map(c => c.function.name);
|
|
||||||
|
|
||||||
// 触发条件:有意图声明(高置信度),或短回复中匹配关键词且非结果描述(低置信度)
|
|
||||||
const shouldCheck = hasIntentStatement || (contentLen < 300 && !isDescribingResult);
|
|
||||||
|
|
||||||
if (shouldCheck) {
|
|
||||||
const actionHints: Array<{ pattern: RegExp; tool: string; hint: string }> = [
|
|
||||||
{ pattern: /写入|写文件|写入文件|保存.*文件|创建.*文件|生成.*文件|输出.*文件|write.*file/i, tool: 'write_file', hint: '你提到了写入/创建文件,但没有调用 write_file 工具。请直接调用 write_file 工具执行写入操作。' },
|
|
||||||
// web_search 正则保持宽泛,由 resultIndicators + lastLoopToolNames 两层防护避免误判
|
|
||||||
{ pattern: /搜索|搜一下|检索|search/i, tool: 'web_search', hint: '你提到了搜索,但没有调用 web_search 工具。请直接调用 web_search 工具执行搜索。' },
|
|
||||||
{ pattern: /抓取|获取.*内容|fetch|获取.*网页/i, tool: 'web_fetch', hint: '你提到了抓取网页,但没有调用 web_fetch 工具。请直接调用 web_fetch 工具获取网页内容。' },
|
|
||||||
{ pattern: /运行.*命令|执行.*命令|run.*command/i, tool: 'run_command', hint: '你提到了运行命令,但没有调用 run_command 工具。请直接调用 run_command 工具执行命令。' },
|
|
||||||
{ pattern: /下载/i, tool: 'download_file', hint: '你提到了下载,但没有调用 download_file 工具。请直接调用 download_file 工具执行下载。' },
|
|
||||||
];
|
|
||||||
const matched = actionHints.find(h => {
|
|
||||||
if (!h.pattern.test(text)) return false;
|
|
||||||
if (ctx.allToolRecords.some(r => r.name === h.tool)) return false; // 整个会话已调用过
|
|
||||||
if (lastLoopToolNames.includes(h.tool)) return false; // 上一轮调用过,本轮可能在总结
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (matched) {
|
|
||||||
logWarn(`Reflecting: 检测到"说了做但没做" → ${matched.tool},注入提醒`);
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: matched.hint,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
transition(ctx, S.THINKING);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 本轮调用了工具 → 模型需要处理工具结果 → 继续循环 ──
|
// ── 本轮调用了工具 → 模型需要处理工具结果 → 继续循环 ──
|
||||||
if (ctx.toolCalls.length > 0) {
|
if (ctx.toolCalls.length > 0) {
|
||||||
transition(ctx, S.THINKING);
|
transition(ctx, S.THINKING);
|
||||||
@@ -2752,10 +2670,10 @@ async function handleCompressing(
|
|||||||
logWarn('COMPRESSING: 失败', (err as Error).message);
|
logWarn('COMPRESSING: 失败', (err as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// P0 修复: 如果压缩未实际发生(如 shouldAutoCompress 返回 false 或压缩未减小体积),
|
// P1 修复: 如果压缩后内容/工具调用仍为空(说明是从主循环紧急压缩路径进入,
|
||||||
// 且当前内容/工具调用为空(说明是从主循环紧急压缩路径进入,而非从 OBSERVING 正常进入),
|
// 而非从 OBSERVING 正常进入),则回到 THINKING 而非 REFLECTING,
|
||||||
// 则回到 THINKING 而非 REFLECTING,避免模型尚未思考就被错误终止
|
// 避免模型尚未思考就被错误终止(handleReflecting 中 toolCalls=0 会直接终止)
|
||||||
if (!actuallyCompressed && ctx.toolCalls.length === 0 && !ctx.content.trim()) {
|
if (ctx.toolCalls.length === 0 && !ctx.content.trim()) {
|
||||||
transition(ctx, S.THINKING);
|
transition(ctx, S.THINKING);
|
||||||
} else {
|
} else {
|
||||||
transition(ctx, S.REFLECTING);
|
transition(ctx, S.REFLECTING);
|
||||||
@@ -2838,13 +2756,6 @@ export async function runAgentLoop(
|
|||||||
const { setPlanModeActive } = await import('./tool-registry.js');
|
const { setPlanModeActive } = await import('./tool-registry.js');
|
||||||
setPlanModeActive(mode === 'plan');
|
setPlanModeActive(mode === 'plan');
|
||||||
|
|
||||||
// ── R2: 无进展循环检测 — 跟踪连续无新内容/无新工具调用的迭代 ──
|
|
||||||
let _lastProgressContentLen = 0;
|
|
||||||
let _lastProgressToolCount = 0;
|
|
||||||
let _lastProgressMsgCount = 0;
|
|
||||||
let _noProgressCount = 0;
|
|
||||||
const MAX_NO_PROGRESS = 5; // 连续 5 轮无进展则注入提醒
|
|
||||||
|
|
||||||
// ── 状态机主循环 ──
|
// ── 状态机主循环 ──
|
||||||
try {
|
try {
|
||||||
// Phase 1: INIT
|
// Phase 1: INIT
|
||||||
@@ -2858,11 +2769,6 @@ export async function runAgentLoop(
|
|||||||
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 3_600_000);
|
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 3_600_000);
|
||||||
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
|
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
|
||||||
logWarn(`看门狗触发: Agent Loop 运行超过 ${WATCHDOG_MS / 60000} 分钟,强制终止`);
|
logWarn(`看门狗触发: Agent Loop 运行超过 ${WATCHDOG_MS / 60000} 分钟,强制终止`);
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `⚠️ 任务运行时间超过 ${Math.round(WATCHDOG_MS / 60000)} 分钟限制,系统自动终止。请简化任务或分批执行。`,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
callbacks.onDone(ctx.content || '(看门狗超时终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
callbacks.onDone(ctx.content || '(看门狗超时终止)', ctx.allToolRecords.length > 0 ? ctx.allToolRecords : undefined, makeStats(ctx));
|
||||||
transition(ctx, S.TERMINATED);
|
transition(ctx, S.TERMINATED);
|
||||||
break;
|
break;
|
||||||
@@ -2871,9 +2777,7 @@ export async function runAgentLoop(
|
|||||||
// ── 上下文硬上限 — 消息数超阈值强制压缩 ──
|
// ── 上下文硬上限 — 消息数超阈值强制压缩 ──
|
||||||
if (ctx.messages.length > MAX_MESSAGES) {
|
if (ctx.messages.length > MAX_MESSAGES) {
|
||||||
logWarn(`上下文硬上限触发: ${ctx.messages.length} 条消息 > ${MAX_MESSAGES},强制压缩`);
|
logWarn(`上下文硬上限触发: ${ctx.messages.length} 条消息 > ${MAX_MESSAGES},强制压缩`);
|
||||||
transition(ctx, S.COMPRESSING);
|
transition(ctx, S.COMPRESSING); // transition 内部会同步 state 和 _loopState
|
||||||
ctx.state = S.COMPRESSING; // 跳过 transition 验证直接进入(压缩态允许)
|
|
||||||
state.set('_loopState', S.COMPRESSING); // 同步全局状态
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查中止信号
|
// 检查中止信号
|
||||||
@@ -2892,9 +2796,7 @@ export async function runAgentLoop(
|
|||||||
// 上下文使用率过高时触发紧急压缩(不限制轮次,而是回收上下文空间)
|
// 上下文使用率过高时触发紧急压缩(不限制轮次,而是回收上下文空间)
|
||||||
if (usageRatio > 0.8 && ctx.state === S.THINKING) {
|
if (usageRatio > 0.8 && ctx.state === S.THINKING) {
|
||||||
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 触发紧急压缩`);
|
logWarn(`上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 触发紧急压缩`);
|
||||||
transition(ctx, S.COMPRESSING);
|
transition(ctx, S.COMPRESSING); // transition 内部会同步 state 和 _loopState
|
||||||
ctx.state = S.COMPRESSING;
|
|
||||||
state.set('_loopState', S.COMPRESSING);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知)
|
// 仅在 THINKING 阶段通知 UI(一次迭代只通知一次,不是每个状态切换都通知)
|
||||||
@@ -2940,8 +2842,7 @@ default:
|
|||||||
// 尝试恢复到 THINKING(最安全的状态,会重新调用 LLM)
|
// 尝试恢复到 THINKING(最安全的状态,会重新调用 LLM)
|
||||||
if (ctx.loopCount < ctx.maxLoops && ctx.messages.length > 0) {
|
if (ctx.loopCount < ctx.maxLoops && ctx.messages.length > 0) {
|
||||||
logInfo(`R7: 从未知状态 ${ctx.state} 恢复到 THINKING`);
|
logInfo(`R7: 从未知状态 ${ctx.state} 恢复到 THINKING`);
|
||||||
ctx.state = S.THINKING;
|
transition(ctx, S.THINKING); // 使用 transition 而非直接赋值,保持状态机封装
|
||||||
state.set('_loopState', S.THINKING);
|
|
||||||
} else {
|
} else {
|
||||||
logWarn(`R7: 恢复条件不满足,强制终止`);
|
logWarn(`R7: 恢复条件不满足,强制终止`);
|
||||||
transition(ctx, S.TERMINATED);
|
transition(ctx, S.TERMINATED);
|
||||||
@@ -2949,34 +2850,6 @@ default:
|
|||||||
}
|
}
|
||||||
|
|
||||||
persistLoopContext(ctx);
|
persistLoopContext(ctx);
|
||||||
|
|
||||||
// ── R2: 无进展循环检测 ──
|
|
||||||
// 每轮结束时检查是否有新内容生成、新工具调用或新消息
|
|
||||||
const currentContentLen = ctx.content.length;
|
|
||||||
const currentToolCount = ctx.allToolRecords.length;
|
|
||||||
const currentMsgCount = ctx.messages.length;
|
|
||||||
const hasProgress =
|
|
||||||
currentContentLen > _lastProgressContentLen ||
|
|
||||||
currentToolCount > _lastProgressToolCount ||
|
|
||||||
currentMsgCount > _lastProgressMsgCount + 2; // +2 容差:允许少量系统注入
|
|
||||||
if (hasProgress) {
|
|
||||||
_noProgressCount = 0;
|
|
||||||
_lastProgressContentLen = currentContentLen;
|
|
||||||
_lastProgressToolCount = currentToolCount;
|
|
||||||
_lastProgressMsgCount = currentMsgCount;
|
|
||||||
} else {
|
|
||||||
_noProgressCount++;
|
|
||||||
if (_noProgressCount >= MAX_NO_PROGRESS) {
|
|
||||||
logWarn(`R2: 检测到连续 ${_noProgressCount} 轮无进展,注入强制终止信号`);
|
|
||||||
ctx.messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: '⚠️ 系统检测到连续多轮无新进展。请基于已有结果立即给出最终回答,不要再重复之前的操作。',
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
ctx.maxLoops = Math.min(ctx.maxLoops, ctx.loopCount + 1);
|
|
||||||
_noProgressCount = 0; // 重置,避免重复注入
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === 'AbortError') {
|
if ((err as Error).name === 'AbortError') {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* Agent Safety — Agent 安全防护与行为治理模块
|
* Agent Safety — Agent 安全防护与行为治理模块
|
||||||
*
|
*
|
||||||
* 从 agent-engine.ts 提取的 R51-R56 功能:
|
* 从 agent-engine.ts 提取的 R51-R56 功能:
|
||||||
@@ -1355,12 +1355,17 @@ export function inferToolDependencies(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// read_file/edit_file/delete_file 消费文件
|
// read_file/edit_file/delete_file 消费文件
|
||||||
if (['read_file', 'edit_file', 'delete_file'].includes(call.name) && call.arguments.path) {
|
if (['read_file', 'edit_file', 'delete_file'].includes(call.name)) {
|
||||||
const path = String(call.arguments.path);
|
// 支持 path 单个路径和 paths 数组
|
||||||
dep.consumes.push(path);
|
const pathsToCheck: string[] = [];
|
||||||
const producer = fileProducers.get(path);
|
if (call.arguments.path) pathsToCheck.push(String(call.arguments.path));
|
||||||
if (producer) {
|
if (Array.isArray(call.arguments.paths)) pathsToCheck.push(...(call.arguments.paths as unknown[]).map(p => String(p)));
|
||||||
dep.dependsOn.push(producer);
|
for (const path of pathsToCheck) {
|
||||||
|
dep.consumes.push(path);
|
||||||
|
const producer = fileProducers.get(path);
|
||||||
|
if (producer) {
|
||||||
|
dep.dependsOn.push(producer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,6 @@ import type { LoopContext, CompletionCheck } from '../types.js';
|
|||||||
// 内置检查项
|
// 内置检查项
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/** 内容质量检查:回复不应过短或不完整 */
|
|
||||||
const contentQualityCheck: CompletionCheck = {
|
|
||||||
name: 'contentQuality',
|
|
||||||
description: '检查回复长度和质量是否达到最低标准',
|
|
||||||
check: async (ctx: LoopContext) => {
|
|
||||||
if (ctx.content.length < 50) {
|
|
||||||
return { passed: false, reason: '回复内容过短(<50字),可能不完整。请给出更详细的最终回答。' };
|
|
||||||
}
|
|
||||||
return { passed: true, reason: '' };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 工具结果审查:是否存在未处理的工具错误 */
|
/** 工具结果审查:是否存在未处理的工具错误 */
|
||||||
const toolResultReviewCheck: CompletionCheck = {
|
const toolResultReviewCheck: CompletionCheck = {
|
||||||
name: 'toolResultReview',
|
name: 'toolResultReview',
|
||||||
@@ -137,7 +125,6 @@ const planModeCompletionCheck: CompletionCheck = {
|
|||||||
|
|
||||||
/** 默认启用的检查项 */
|
/** 默认启用的检查项 */
|
||||||
const DEFAULT_ENABLED_CHECKS = new Set([
|
const DEFAULT_ENABLED_CHECKS = new Set([
|
||||||
'contentQuality',
|
|
||||||
'toolResultReview',
|
'toolResultReview',
|
||||||
'notThinking',
|
'notThinking',
|
||||||
'planModeCompletion',
|
'planModeCompletion',
|
||||||
@@ -145,7 +132,6 @@ const DEFAULT_ENABLED_CHECKS = new Set([
|
|||||||
|
|
||||||
/** 注册表 */
|
/** 注册表 */
|
||||||
const checkRegistry = new Map<string, CompletionCheck>([
|
const checkRegistry = new Map<string, CompletionCheck>([
|
||||||
['contentQuality', contentQualityCheck],
|
|
||||||
['toolResultReview', toolResultReviewCheck],
|
['toolResultReview', toolResultReviewCheck],
|
||||||
['notThinking', notThinkingCheck],
|
['notThinking', notThinkingCheck],
|
||||||
['contextEfficiency', contextEfficiencyCheck],
|
['contextEfficiency', contextEfficiencyCheck],
|
||||||
@@ -203,7 +189,6 @@ export interface GateResult {
|
|||||||
|
|
||||||
/** R127: 各检查项的权重分配 */
|
/** R127: 各检查项的权重分配 */
|
||||||
const CHECK_WEIGHTS: Record<string, number> = {
|
const CHECK_WEIGHTS: Record<string, number> = {
|
||||||
contentQuality: 30,
|
|
||||||
toolResultReview: 25,
|
toolResultReview: 25,
|
||||||
notThinking: 20,
|
notThinking: 20,
|
||||||
contextEfficiency: 15,
|
contextEfficiency: 15,
|
||||||
@@ -259,20 +244,23 @@ export async function runCompletionGate(ctx: LoopContext): Promise<GateResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 检查项本身抛异常:容错不阻塞流程,但语义上标记为未通过且不得分
|
||||||
const weight = CHECK_WEIGHTS[check.name] ?? 10;
|
const weight = CHECK_WEIGHTS[check.name] ?? 10;
|
||||||
maxScore += weight;
|
maxScore += weight;
|
||||||
|
const errMsg = `检查异常: ${(err as Error).message}`;
|
||||||
|
logWarn(`Completion Gate: "${check.name}" 检查项抛出异常 — ${errMsg}`);
|
||||||
details.push({
|
details.push({
|
||||||
name: check.name,
|
name: check.name,
|
||||||
passed: true,
|
passed: false,
|
||||||
reason: `检查异常: ${(err as Error).message}`,
|
reason: errMsg,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
});
|
});
|
||||||
scoreBreakdown.push({
|
scoreBreakdown.push({
|
||||||
check: check.name,
|
check: check.name,
|
||||||
passed: true,
|
passed: false,
|
||||||
points: weight,
|
points: 0,
|
||||||
});
|
});
|
||||||
totalScore += weight;
|
// 注意:此处不 return,继续执行后续检查项(容错设计)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -881,14 +881,44 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
|
|||||||
const recentMsgs = nonSystemMsgs.slice(-PROTECT_RECENT);
|
const recentMsgs = nonSystemMsgs.slice(-PROTECT_RECENT);
|
||||||
const olderMsgs = nonSystemMsgs.slice(0, -PROTECT_RECENT);
|
const olderMsgs = nonSystemMsgs.slice(0, -PROTECT_RECENT);
|
||||||
|
|
||||||
// 计算每条消息的 token + 重要性
|
// 原子组分组:assistant(带 tool_calls) + 其后续的 tool 消息作为一组
|
||||||
const scored = olderMsgs.map(m => ({
|
// 避免裁剪时破坏 assistant.tool_calls 与 tool 结果的配对关系
|
||||||
msg: m,
|
interface MsgGroup {
|
||||||
tokens: estimateTokens(m.content || '') +
|
msgs: OllamaMessage[];
|
||||||
(m.images ? m.images.length * 100 : 0) +
|
tokens: number;
|
||||||
(m.tool_calls ? m.tool_calls.length * 50 : 0),
|
importance: number;
|
||||||
importance: scoreMessageImportance(m),
|
originalIndex: number;
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
const groups: MsgGroup[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < olderMsgs.length) {
|
||||||
|
const msg = olderMsgs[i];
|
||||||
|
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
||||||
|
// 原子组:assistant(带 tool_calls) + 后续连续的 tool 消息
|
||||||
|
const groupMsgs: OllamaMessage[] = [msg];
|
||||||
|
let tokens = estimateTokens(msg.content || '') +
|
||||||
|
(msg.images ? msg.images.length * 100 : 0) +
|
||||||
|
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
||||||
|
let maxImportance = scoreMessageImportance(msg);
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < olderMsgs.length && olderMsgs[j].role === 'tool') {
|
||||||
|
const toolMsg = olderMsgs[j];
|
||||||
|
groupMsgs.push(toolMsg);
|
||||||
|
tokens += estimateTokens(toolMsg.content || '');
|
||||||
|
maxImportance = Math.max(maxImportance, scoreMessageImportance(toolMsg));
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
groups.push({ msgs: groupMsgs, tokens, importance: maxImportance, originalIndex: i });
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
const tokens = estimateTokens(msg.content || '') +
|
||||||
|
(msg.images ? msg.images.length * 100 : 0) +
|
||||||
|
(msg.tool_calls ? msg.tool_calls.length * 50 : 0);
|
||||||
|
groups.push({ msgs: [msg], tokens, importance: scoreMessageImportance(msg), originalIndex: i });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// system 消息的 token 消耗
|
// system 消息的 token 消耗
|
||||||
const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0);
|
const systemTokens = systemMsgs.reduce((sum, m) => sum + estimateTokens(m.content || ''), 0);
|
||||||
@@ -904,27 +934,29 @@ function trimByTokenLimit(messages: OllamaMessage[], maxTokens: number): OllamaM
|
|||||||
}
|
}
|
||||||
|
|
||||||
// R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
|
// R94: 按综合评分降序排列(重要性 + 时近性),取能装下的最大数量
|
||||||
const totalOlder = olderMsgs.length;
|
const totalGroups = groups.length;
|
||||||
scored.forEach((s, idx) => {
|
groups.forEach((g, idx) => {
|
||||||
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
|
// R94: 时近性因子 — 越靠近最近窗口的消息得分越高(0~2 分加成)
|
||||||
const recencyRatio = totalOlder > 1 ? idx / (totalOlder - 1) : 1;
|
const recencyRatio = totalGroups > 1 ? idx / (totalGroups - 1) : 1;
|
||||||
s.importance += Math.round(recencyRatio * 2);
|
g.importance += Math.round(recencyRatio * 2);
|
||||||
});
|
});
|
||||||
scored.sort((a, b) => b.importance - a.importance);
|
// 按重要性排序(降序),但保留原始索引用于重建
|
||||||
|
const sortedGroups = [...groups].sort((a, b) => b.importance - a.importance);
|
||||||
|
|
||||||
let usedTokens = 0;
|
let usedTokens = 0;
|
||||||
const kept = new Set<number>(); // 保留的索引
|
const keptOriginalIndices = new Set<number>();
|
||||||
for (let i = 0; i < scored.length; i++) {
|
for (let i = 0; i < sortedGroups.length; i++) {
|
||||||
if (usedTokens + scored[i].tokens > availableTokens && kept.size >= 2) break;
|
if (usedTokens + sortedGroups[i].tokens > availableTokens && keptOriginalIndices.size >= 2) break;
|
||||||
usedTokens += scored[i].tokens;
|
usedTokens += sortedGroups[i].tokens;
|
||||||
kept.add(i);
|
keptOriginalIndices.add(sortedGroups[i].originalIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按原始顺序重建:system → 按重要性保留的旧消息 → 最近的 protected 消息
|
// 按原始顺序重建:system → 按重要性保留的旧消息组 → 最近的 protected 消息
|
||||||
const result: OllamaMessage[] = [...systemMsgs];
|
const result: OllamaMessage[] = [...systemMsgs];
|
||||||
const keptSet = new Set(scored.filter((_, i) => kept.has(i)).map(s => s.msg));
|
for (const g of groups) {
|
||||||
for (const m of olderMsgs) {
|
if (keptOriginalIndices.has(g.originalIndex)) {
|
||||||
if (keptSet.has(m)) result.push(m);
|
result.push(...g.msgs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.push(...recentMsgs);
|
result.push(...recentMsgs);
|
||||||
|
|
||||||
|
|||||||
@@ -147,12 +147,18 @@ export const securityCheckHook: HarnessHook = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 受保护路径不可删除
|
// 受保护路径不可删除
|
||||||
if (toolName === 'delete_file' && typeof toolArgs?.path === 'string') {
|
if (toolName === 'delete_file') {
|
||||||
const p = (toolArgs.path as string).toLowerCase();
|
|
||||||
const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg'];
|
const protectedPaths = ['/etc', '/sys', '/proc', '/dev', '/boot', 'c:\\windows', '.ssh', '.gnupg'];
|
||||||
for (const pp of protectedPaths) {
|
// 检查 path 和 paths 数组中的所有路径
|
||||||
if (p.includes(pp)) {
|
const pathsToCheck: string[] = [];
|
||||||
return { passed: false, message: `受保护路径不可删除: ${p}` };
|
if (typeof toolArgs?.path === 'string') pathsToCheck.push(toolArgs.path);
|
||||||
|
if (Array.isArray(toolArgs?.paths)) pathsToCheck.push(...(toolArgs.paths as unknown[]).map(p => String(p)));
|
||||||
|
for (const p of pathsToCheck) {
|
||||||
|
const lower = p.toLowerCase();
|
||||||
|
for (const pp of protectedPaths) {
|
||||||
|
if (lower.includes(pp)) {
|
||||||
|
return { passed: false, message: `受保护路径不可删除: ${p}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
|
|||||||
return { valid: false, error: `文件必须以 "# METONA MEMORY" 开头,当前: "${firstLine?.slice(0, 50) || '(空)'}"`, line: 1 };
|
return { valid: false, error: `文件必须以 "# METONA MEMORY" 开头,当前: "${firstLine?.slice(0, 50) || '(空)'}"`, line: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
let entryCount = 0;
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
@@ -228,8 +227,6 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
entryCount++;
|
|
||||||
|
|
||||||
// 验证类型
|
// 验证类型
|
||||||
const type = headerMatch[1].toLowerCase();
|
const type = headerMatch[1].toLowerCase();
|
||||||
if (!VALID_TYPES.includes(type as MemoryType)) {
|
if (!VALID_TYPES.includes(type as MemoryType)) {
|
||||||
@@ -259,10 +256,6 @@ export function validateMemoryMd(content: string): { valid: boolean; error?: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entryCount === 0 && content.includes('## ')) {
|
|
||||||
// 有 ## 但没匹配到,格式可能有问题
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
recursive: { type: 'boolean', description: 'List recursively. Default: false.' },
|
recursive: { type: 'boolean', description: 'List recursively. Default: false.' },
|
||||||
max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' },
|
max_depth: { type: 'integer', description: 'Max recursion depth. Default: 3.' },
|
||||||
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' },
|
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' },
|
||||||
|
filter_extension: { type: 'string', description: 'Filter files by extension (e.g. ".txt"). Only files matching this extension are returned. Default: no filter.' },
|
||||||
offset: { type: 'integer', description: 'Skip first N entries for pagination. Default: 0.' }
|
offset: { type: 'integer', description: 'Skip first N entries for pagination. Default: 0.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +78,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
query: { type: 'string', description: 'Search query (glob, text, or regex).' },
|
query: { type: 'string', description: 'Search query (glob, text, or regex).' },
|
||||||
search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' },
|
search_type: { type: 'string', enum: ['filename', 'content', 'both'], description: 'Search target. Default: both.' },
|
||||||
case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' },
|
case_sensitive: { type: 'boolean', description: 'Case sensitive. Default: false.' },
|
||||||
max_results: { type: 'integer', description: 'Max results. Default: 50.' },
|
max_results: { type: 'integer', description: 'Max results. Default: 0 (no limit).' },
|
||||||
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' },
|
file_extensions: { type: 'array', items: { type: 'string' }, description: 'Filter extensions, e.g. [".ts", ".js"]' },
|
||||||
use_regex: { type: 'boolean', description: 'Treat query as regular expression. Default: false.' }
|
use_regex: { type: 'boolean', description: 'Treat query as regular expression. Default: false.' }
|
||||||
}
|
}
|
||||||
@@ -102,12 +103,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'delete_file',
|
name: 'delete_file',
|
||||||
description: 'Delete a file or directory. Returns deleted item count and total size. Use recursive=true for non-empty directories.',
|
description: 'Delete a file or directory. Supports batch deletion via paths parameter. At least one of path or paths must be provided. Returns deleted item count and total size. Use recursive=true for non-empty directories.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['path'],
|
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string', description: 'Path to delete.' },
|
path: { type: 'string', description: 'Single path to delete. Use this for deleting one file/directory.' },
|
||||||
|
paths: { type: 'array', items: { type: 'string' }, description: 'Multiple paths to delete in batch. Use this when deleting multiple files at once.' },
|
||||||
recursive: { type: 'boolean', description: 'Recursive delete for directories. Returns filesDeleted count and total deletedSize.' }
|
recursive: { type: 'boolean', description: 'Recursive delete for directories. Returns filesDeleted count and total deletedSize.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
name: 'run_command',
|
name: 'run_command',
|
||||||
description: 'Execute a shell command via workspace. No timeout limit — runs until the process exits. Requires user confirmation. Output is streamed in real-time through the workspace process.',
|
description: 'Execute a shell command via workspace. Timeout configurable (default 300s for general, 600s for long-running). cwd defaults to workspace directory. Requires user confirmation. Output is streamed in real-time through the workspace process.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['command'],
|
required: ['command'],
|
||||||
@@ -219,7 +220,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|||||||
required: ['path'],
|
required: ['path'],
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string', description: 'Root directory path.' },
|
path: { type: 'string', description: 'Root directory path.' },
|
||||||
max_depth: { type: 'integer', description: 'Max depth. Default: 3.' },
|
max_depth: { type: 'integer', description: 'Max depth. Default: 5.' },
|
||||||
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' }
|
include_hidden: { type: 'boolean', description: 'Include hidden files. Default: false.' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -717,6 +718,13 @@ export function setRunCommandMode(mode: ToolMode): void {
|
|||||||
setToolMode('run_command', mode);
|
setToolMode('run_command', mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 初始化全局工具模式(从数据库加载) */
|
||||||
|
export function initGlobalToolMode(mode: ToolMode): void {
|
||||||
|
for (const toolName of MODE_TOOLS) {
|
||||||
|
setToolMode(toolName, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let enabledTools: Set<string> = new Set([
|
let enabledTools: Set<string> = new Set([
|
||||||
'read_file', 'list_directory', 'search_files',
|
'read_file', 'list_directory', 'search_files',
|
||||||
'write_file', 'create_directory', 'delete_file',
|
'write_file', 'create_directory', 'delete_file',
|
||||||
|
|||||||
@@ -111,8 +111,7 @@ class AppState {
|
|||||||
private _notify(key: string, value: unknown, old: unknown): void {
|
private _notify(key: string, value: unknown, old: unknown): void {
|
||||||
if (!this._listeners.has(key)) return;
|
if (!this._listeners.has(key)) return;
|
||||||
for (const cb of this._listeners.get(key)!) {
|
for (const cb of this._listeners.get(key)!) {
|
||||||
try { cb(value, old); } catch (e) {
|
try { cb(value, old); } catch { /* 回调异常不影响其他监听器 */ }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -290,7 +290,7 @@ export type StateKey =
|
|||||||
| '_planResumeData'
|
| '_planResumeData'
|
||||||
| '_lastPlanStatus'
|
| '_lastPlanStatus'
|
||||||
| '_planLastInjectedDone'
|
| '_planLastInjectedDone'
|
||||||
| 'toolModes'
|
| 'globalToolMode'
|
||||||
| 'planAutoConfirm';
|
| 'planAutoConfirm';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export function formatSize(bytes: number): string {
|
|||||||
return `${size.toFixed(1)} ${units[i]}`;
|
return `${size.toFixed(1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTML 转义 */
|
/** HTML 转义(兼容 unknown 输入,null/undefined 返回空串) */
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: unknown): string {
|
||||||
return str.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
|
if (str == null) return '';
|
||||||
|
return String(str).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
|
||||||
}
|
}
|
||||||
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user