v0.12.4: 安全豁免 + 搜索质量 + 上下文管控 + 版本纪律 Cleanup
Fix: - 工作空间路径安全豁免 — AppData 拦截自相矛盾,tree/list_directory 恢复正常 - 搜索自动抓取硬上限 MAX_AUTO_FETCH=8,防止上下文爆炸 132% - 搜索结果相关性过滤 — 跳过 Canva/ChatGPT 等无关条目 - 浏览器回退 LRU 缓存 — 同 URL 10min 内不再重复渲染 - Plan Mode 任务描述固化到系统提示词 — 压缩后不丢失目标 - ephemeral 清理时机优化 — 高负载时跳过,避免 Plan 进度丢失 Refactor: - 全项目 v0.12.0 → v0.12.4 版本号同步(7 文件) - 版本号纪律:仅 5 白名单文件保留版本号,其余 14 源码全部清除 - docs/DEVELOPMENT.md 据实重写(目录/架构/规范/SearXNG/Harness) - SearXNG 抓取条数输入框改为 min=1 max=8 数字框 - 代码注释中的版本标记全部移除,仅描述功能
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.12.3-E8734A?style=flat-square" alt="version">
|
<img src="https://img.shields.io/badge/version-v0.12.4-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">
|
||||||
@@ -256,7 +256,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.12.3.exe`
|
产出:`release/Metona Ollama Setup v0.12.4.exe`
|
||||||
|
|
||||||
## 🛠️ 常用命令
|
## 🛠️ 常用命令
|
||||||
|
|
||||||
@@ -507,7 +507,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.12.3.exe`
|
Output: `release/Metona Ollama Setup v0.12.4.exe`
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
|
|||||||
+277
-154
@@ -1,12 +1,20 @@
|
|||||||
# Metona Ollama Desktop — 开发规范
|
# Metona Ollama Desktop — 开发规范
|
||||||
|
|
||||||
> 版本: v0.12.2 | 更新: 2026-06-05 | 维护: 项目团队
|
> 更新: 2026-06-23 | 维护: 项目团队
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、项目概述
|
## 一、项目概述
|
||||||
|
|
||||||
Metona Ollama Desktop 是基于 TypeScript + Electron 的本地 Ollama AI 桌面客户端,面向 Windows 平台。项目采用第三方库本地化策略:成熟的第三方库直接 vendor 到 `src/vendor/` 目录内引用,不依赖 npm 运行时安装(sql.js 因含 WASM 二进制除外)。所有核心功能(SHA-256 等)均内联实现。
|
Metona Ollama Desktop 是基于 TypeScript + Electron 的 Windows 本地 AI 桌面客户端,通过 Ollama API 连接本地大模型。所有 AI 推理在本地完成,数据不离开本机。
|
||||||
|
|
||||||
|
核心架构:
|
||||||
|
- **ReAct Agent Loop** — 8 状态机驱动的智能体循环(INIT→THINKING→PARSING→EXECUTING→OBSERVING→REFLECTING→COMPRESSING→TERMINATED),最大 85 轮(可配置)
|
||||||
|
- **42 个内置工具** — 文件系统(16)、命令执行(1)、联网搜索(2)、浏览器控制(9)、Git(1)、记忆(4)、会话/子代理(3)、系统(6)
|
||||||
|
- **Harness Engineering** — 5 层抗幻觉体系 + 4 阶段 Hook 系统 + Completion Gate(6 项检查)+ Agent Metrics + 渐进式披露
|
||||||
|
- **MCP 协议扩展** — JSON-RPC 2.0 over stdio,动态工具发现
|
||||||
|
- **Plan Mode** — 开关切换,先规划后执行,步骤级进度追踪
|
||||||
|
- **SQLite 存储** — sql.js WASM,6 张表,WAL 模式 + FTS5 全文搜索
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,7 +25,8 @@ Metona Ollama Desktop 是基于 TypeScript + Electron 的本地 Ollama AI 桌面
|
|||||||
| 语言 | TypeScript | ≥5.7,严格模式 (`strict: true`) |
|
| 语言 | TypeScript | ≥5.7,严格模式 (`strict: true`) |
|
||||||
| 桌面框架 | Electron | ≥33 |
|
| 桌面框架 | Electron | ≥33 |
|
||||||
| 构建工具 | Vite (渲染进程) + tsc (主进程) | Vite ≥5 |
|
| 构建工具 | Vite (渲染进程) + tsc (主进程) | Vite ≥5 |
|
||||||
| 数据存储 | sql.js (WASM) | ≥1.11,WAL 模式,FTS5 全文搜索,零原生依赖 |
|
| 数据存储 | sql.js (WASM) | ≥1.11,零原生编译依赖 |
|
||||||
|
| 视频处理 | ffmpeg-static | ≥5.2,asrUnpack 外置 |
|
||||||
| 打包 | electron-builder | NSIS 格式 |
|
| 打包 | electron-builder | NSIS 格式 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -26,79 +35,109 @@ Metona Ollama Desktop 是基于 TypeScript + Electron 的本地 Ollama AI 桌面
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main/ # Electron 主进程
|
├── main/ # Electron 主进程
|
||||||
│ ├── main.ts # 入口、窗口管理
|
│ ├── main.ts # 入口、窗口管理、托盘、生命周期
|
||||||
│ ├── preload.ts # contextBridge API 暴露
|
│ ├── preload.ts # contextBridge API 暴露(白名单)
|
||||||
│ ├── ipc.ts # IPC 处理器(invoke/handle + on/send)
|
│ ├── ipc.ts # IPC 总线(工具调用、数据库、MCP、视频帧)
|
||||||
│ ├── workspace.ts # 子进程管理、流式输出
|
│ ├── workspace.ts # 终端子进程管理、流式输出
|
||||||
│ ├── tool-handlers.ts # Tool Calling re-export(4 个子模块)
|
│ ├── browser.ts # 隐藏 BrowserWindow 实现浏览器控制
|
||||||
│ ├── tool-handlers-fs.ts # 15 个文件系统工具
|
│ ├── menu.ts # 原生菜单
|
||||||
│ ├── tool-handlers-system.ts # 6 个系统网络工具
|
│ ├── tray.ts # 系统托盘
|
||||||
│ ├── tool-handlers-git.ts # 1 个 Git 工具
|
│ ├── utils.ts # 通用工具函数(日志、通知)
|
||||||
│ ├── tool-handlers-shared.ts # 共享工具函数和类型
|
│ ├── mcp-manager.ts # MCP JSON-RPC 2.0 协议管理
|
||||||
│ ├── tool-security.ts # 路径/命令安全检查
|
│ ├── tool-security.ts # 路径/命令安全检查(黑名单 + 豁免机制)
|
||||||
│ ├── browser.ts # 浏览器控制(8 个工具)
|
│ ├── tool-handlers.ts # 工具处理器 re-export 聚合
|
||||||
│ ├── mcp-manager.ts # MCP 协议通信管理
|
│ ├── tool-handlers-fs.ts # 16 个文件系统工具实现
|
||||||
│ ├── menu.ts # 原生菜单
|
│ ├── tool-handlers-system.ts # 系统/网络工具 + 联网搜索(双模式)+ 自动抓取
|
||||||
│ ├── tray.ts # 系统托盘
|
│ ├── tool-handlers-git.ts # Git 全操作
|
||||||
│ ├── utils.ts # 工具函数
|
│ ├── tool-handlers-shared.ts # 共享类型和辅助函数
|
||||||
│ └── db/
|
│ └── db/
|
||||||
│ └── sqlite.ts # SQLite 数据库层(6 张表 + FTS5)
|
│ ├── sqlite.ts # SQLite 数据库层(6 张表 + FTS5)
|
||||||
├── renderer/ # 渲染进程
|
│ └── sql.js.d.ts # sql.js 类型声明
|
||||||
│ ├── main.ts # 入口、全局初始化
|
│
|
||||||
│ ├── types.d.ts # 完整类型定义
|
├── renderer/ # 渲染进程
|
||||||
│ ├── index.html # 入口 HTML
|
│ ├── main.ts # 应用入口、72 步初始化、桌面集成
|
||||||
|
│ ├── types.d.ts # 完整类型定义(消息、会话、Agent 状态机等)
|
||||||
|
│ ├── index.html # 入口 HTML(三栏布局 + 全部模态框)
|
||||||
|
│ ├── public/
|
||||||
|
│ │ ├── AGENT.md # 内置 Agent 行为准则文档
|
||||||
|
│ │ ├── SOUL.md # 内置 AI 人格定义
|
||||||
|
│ │ └── USER.md # 内置用户画像模板
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ └── ollama.ts # Ollama REST API 客户端
|
│ │ └── ollama.ts # Ollama REST API 客户端(流式 + 模型管理)
|
||||||
│ ├── components/ # 14 个 UI 组件
|
│ ├── components/ # 15 个 UI 组件(原生 DOM)
|
||||||
│ ├── services/
|
│ │ ├── chat-area.ts # 聊天消息区域(渲染、自动滚动)
|
||||||
│ │ ├── agent-engine.ts # ReAct Agent Loop 引擎
|
│ │ ├── header.ts # 顶部导航栏 + 连接状态
|
||||||
│ │ ├── tool-registry.ts # 工具注册与调度(42 个内置工具 + MCP 动态)
|
│ │ ├── history-modal.ts # 会话历史(搜索、分页、恢复)
|
||||||
│ │ ├── memory-manager.ts # 记忆管理核心
|
│ │ ├── input-area.ts # 输入框 + 图片/视频/文件上传 + Plan Mode 开关
|
||||||
│ │ ├── vector-memory.ts # 记忆向量索引(IVF)
|
│ │ ├── lightbox.ts # 图片灯箱
|
||||||
│ │ ├── vector-store.ts # 向量存储 + IVF 索引
|
│ │ ├── memory-modal.ts # Agent 记忆管理面板
|
||||||
│ │ ├── context-manager.ts # 上下文窗口管理
|
│ │ ├── model-bar.ts # 模型选择栏 + 能力徽章
|
||||||
│ │ ├── sub-agent.ts # 子代理委派
|
│ │ ├── prompt-modal.ts # 系统提示词查看 + Plan 确认弹窗
|
||||||
│ │ ├── mcp-client.ts # MCP 渲染端客户端
|
│ │ ├── searxng-modal.ts # SearXNG 搜索引擎配置面板
|
||||||
│ │ ├── log-service.ts # 结构化日志
|
│ │ ├── settings-modal.ts # 设置面板(全部配置项)
|
||||||
│ │ └── crypto.ts # AES-256-GCM 加密
|
│ │ ├── toast.ts # Toast 通知
|
||||||
│ ├── utils/
|
│ │ ├── token-dashboard.ts # Token 消耗仪表盘(全局 + 会话统计)
|
||||||
│ │ ├── utils.ts # 工具函数
|
│ │ ├── tool-confirm-modal.ts # 工具执行确认对话框
|
||||||
│ │ ├── sanitizer.ts # HTML 净化器
|
│ │ ├── tools-modal.ts # 工具列表面板(42 个工具卡片)
|
||||||
│ │ └── marked-config.ts # Markdown 渲染
|
│ │ └── workspace-panel.ts # 工作空间面板(终端 + 工具卡片 + 文件浏览)
|
||||||
|
│ ├── services/ # 15 个服务模块
|
||||||
|
│ │ ├── agent-engine.ts # ★ ReAct Agent Loop 核心引擎(8 状态机)
|
||||||
|
│ │ ├── tool-registry.ts # 工具注册与调度中心(42 内置 + MCP 动态 + Plan Mode)
|
||||||
|
│ │ ├── memory-manager.ts # 记忆管理核心(FTS5 + 向量语义搜索)
|
||||||
|
│ │ ├── vector-memory.ts # 向量记忆索引(IVF)
|
||||||
|
│ │ ├── vector-store.ts # 向量存储 + IVF 索引引擎
|
||||||
|
│ │ ├── context-manager.ts # 上下文窗口管理(滑动窗口 + Token 校准 + LLM 压缩)
|
||||||
|
│ │ ├── sub-agent.ts # 子代理委派(独立上下文 + 超时保护)
|
||||||
|
│ │ ├── mcp-client.ts # MCP 渲染端客户端
|
||||||
|
│ │ ├── log-service.ts # 结构化日志(9 级分类)
|
||||||
|
│ │ ├── crypto.ts # AES-256-GCM 加密
|
||||||
|
│ │ ├── hooks.ts # 4 阶段 Hook 系统(pre_tool/post_tool/post_iteration/pre_completion)
|
||||||
|
│ │ ├── completion-gate.ts # 完成门控(6 项检查,阻断/咨询两级)
|
||||||
|
│ │ ├── agent-metrics.ts # Agent 度量采集 + 错误模式识别 + 改进建议
|
||||||
|
│ │ ├── context-indexer.ts # 渐进式披露(索引层→接口层→实现层)
|
||||||
|
│ │ └── verification.ts # 验证系统(DiffAnalyzer + FileChangeAudit)
|
||||||
|
│ ├── db/
|
||||||
|
│ │ └── chat-db.ts # 渲染端数据库接口 + IndexedDB→SQLite 迁移
|
||||||
│ ├── state/
|
│ ├── state/
|
||||||
│ │ └── state.ts # 响应式状态管理
|
│ │ └── state.ts # 响应式状态管理(单例模式)
|
||||||
│ └── db/
|
│ ├── utils/
|
||||||
│ └── chat-db.ts # SQLite 渲染端数据库接口
|
│ │ ├── utils.ts # 通用工具函数
|
||||||
|
│ │ ├── sanitizer.ts # HTML 净化器(白名单 + URI 协议检查)
|
||||||
|
│ │ └── marked-config.ts # Markdown 渲染配置
|
||||||
│ └── styles/
|
│ └── styles/
|
||||||
│ └── style.css # 暖色调亮色主题
|
│ └── style.css # 暖色调亮色主题(完整样式表)
|
||||||
├── vendor/ # 第三方库本地化(ESM + 类型声明 + LICENSE)
|
│
|
||||||
│ ├── marked.js # Markdown 解析库
|
├── vendor/ # 第三方库本地化(ESM + 类型声明)
|
||||||
│ └── dompurify.js # HTML 净化库
|
│ ├── marked.d.ts # Markdown 解析库类型
|
||||||
└── ...
|
│ └── dompurify.d.ts # HTML 净化库类型
|
||||||
|
│
|
||||||
|
├── assets/ # 静态资源
|
||||||
|
│ └── icons/ # 应用图标
|
||||||
|
│
|
||||||
|
└── docs/ # 项目文档
|
||||||
|
├── DEVELOPMENT.md # 本文档
|
||||||
|
└── AI_Agent_ReAct_Harness_Engineering.md # Harness Engineering 学术参考
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、代码规范
|
## 四、代码规范
|
||||||
|
|
||||||
### 4.1 TypeScript 规范
|
### 4.1 命名约定
|
||||||
|
|
||||||
- **严格模式**:所有文件必须通过 `strict: true` 编译
|
| 类型 | 规范 | 示例 |
|
||||||
- **类型定义**:所有接口/类型在 `types.d.ts` 中集中定义,禁止 `any` 滥用
|
|------|------|------|
|
||||||
- **命名约定**:
|
| 文件名 | `kebab-case` | `tool-registry.ts`, `agent-engine.ts` |
|
||||||
- 文件名:`kebab-case`(如 `tool-registry.ts`)
|
| 类型/接口 | `PascalCase` | `ToolCallRecord`, `LoopContext` |
|
||||||
- 类型/接口:`PascalCase`(如 `ToolCallRecord`)
|
| 变量/函数 | `camelCase` | `getEnabledToolDefinitions` |
|
||||||
- 变量/函数:`camelCase`(如 `getEnabledToolDefinitions`)
|
| 常量 | `UPPER_SNAKE_CASE` | `MAX_LOOPS`, `AUTO_COMPRESS_THRESHOLD` |
|
||||||
- 常量:`UPPER_SNAKE_CASE`(如 `MAX_LOOPS`)
|
| 私有成员/模块变量 | `_` 前缀 | `_workspaceDir`, `_planTracker` |
|
||||||
- 私有成员:`_` 前缀(如 `_workspaceDir`)
|
|
||||||
|
|
||||||
### 4.2 日志规范
|
### 4.2 日志规范
|
||||||
|
|
||||||
**核心原则:项目中严禁使用 `console.log/error/warn/debug`。所有日志必须通过 `log-service.ts` 输出。**
|
**核心原则:项目中严禁使用 `console.log/error/warn/debug`。所有日志必须通过 `log-service.ts` 输出。**
|
||||||
|
|
||||||
日志服务提供以下级别:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { logInfo, logSuccess, logWarn, logError, logDebug } from './services/log-service.js';
|
import { logInfo, logSuccess, logWarn, logError, logDebug } from './services/log-service.js';
|
||||||
|
|
||||||
@@ -109,43 +148,30 @@ logError('错误描述', '错误详情');
|
|||||||
logDebug('调试信息', '可选详情');
|
logDebug('调试信息', '可选详情');
|
||||||
```
|
```
|
||||||
|
|
||||||
专用日志函数:
|
主进程日志通过 `mainWindow?.webContents.send('main:log', { level, message, detail })` 推送到渲染进程日志面板。
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
logInit, logSetting, logToolStart, logToolResult,
|
|
||||||
logStream, logAgentLoop, logModelResponse,
|
|
||||||
logSession, logThink, logMemory, logRAG
|
|
||||||
} from './services/log-service.js';
|
|
||||||
```
|
|
||||||
|
|
||||||
**主进程日志**:通过 `mainWindow?.webContents.send('main:log', ...)` 发送到渲染进程日志面板。
|
|
||||||
|
|
||||||
### 4.3 IPC 规范
|
### 4.3 IPC 规范
|
||||||
|
|
||||||
| 模式 | 用途 | 超时 |
|
| 模式 | 用途 | 示例 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `invoke/handle` | 请求-响应,同步等待结果 | 有(默认) |
|
| `invoke/handle` | 请求-响应,需返回值 | 工具调用、数据库操作、设置读写 |
|
||||||
| `on/send` | 单向推送,流式通信 | 无 |
|
| `on/send` | 单向推送,流式通信 | 终端实时输出、视频帧提取进度 |
|
||||||
|
|
||||||
**选择原则**:需要返回值 → `invoke/handle`;流式输出/实时推送 → `on/send`;长时间运行命令 → `on/send`。
|
选择原则:需要返回值 → `invoke/handle`;流式输出/实时推送 → `on/send`。
|
||||||
|
|
||||||
### 4.4 安全规范
|
### 4.4 版本号规范
|
||||||
|
|
||||||
#### 文件系统安全
|
**版本号仅允许出现在以下 5 个文件中,其余所有源码禁止出现版本号:**
|
||||||
- 所有文件路径通过 `tool-security.ts` 的 `checkPathAllowed()` 验证
|
|
||||||
- 写操作仅允许在用户目录下进行
|
|
||||||
- 路径黑名单:`/etc`, `/sys`, `/proc`, `~/.ssh`, `~/.gnupg` 等
|
|
||||||
|
|
||||||
#### 命令执行安全
|
| 文件 | 内容 |
|
||||||
- 所有命令通过 `tool-security.ts` 的 `checkCommandAllowed()` 验证
|
|------|------|
|
||||||
- 命令黑名单:`rm -rf /`, `mkfs`, `dd`, `shutdown`, 反弹 shell 检测等
|
| `package.json` | `"version": "X.Y.Z"` |
|
||||||
- `run_command` 支持三种模式(自动/需确认/禁用),用户可随时切换
|
| `package-lock.json` | 顶层 version 字段(2 处) |
|
||||||
- 其余 24 个工具均为自动执行,无需用户确认
|
| `src/renderer/index.html` | `<span class="app-version">vX.Y.Z</span>` |
|
||||||
|
| `src/main/menu.ts` | 关于对话框 `Metona Ollama Desktop vX.Y.Z` |
|
||||||
|
| `README.md` | 版本徽章、中英文下载文件名 |
|
||||||
|
|
||||||
#### 前端安全
|
代码注释中禁止出现版本号(如 `// vX.Y.Z: xxx`),只描述功能本身。
|
||||||
- 内置 HTML 净化器(白名单标签 + 属性过滤 + URI 协议检查)
|
|
||||||
- `contextIsolation: true` + IPC 白名单
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -155,24 +181,37 @@ import {
|
|||||||
|
|
||||||
```
|
```
|
||||||
① Agent 系统 → agent-engine.ts + tool-registry.ts(42 内置工具 + MCP 动态)
|
① Agent 系统 → agent-engine.ts + tool-registry.ts(42 内置工具 + MCP 动态)
|
||||||
② 记忆系统 → memory-manager.ts + vector-memory.ts
|
② 记忆系统 → memory-manager.ts + vector-memory.ts + vector-store.ts
|
||||||
③ 向量存储 → vector-store.ts(IVF 索引)
|
③ 上下文系统 → context-manager.ts + context-indexer.ts(渐进式披露)
|
||||||
④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程)
|
④ 工作空间 → workspace.ts (主进程) + workspace-panel.ts (渲染进程)
|
||||||
⑤ 数据层 → db/sqlite.ts(SQLite, 6 张表, FTS5)
|
⑤ 数据层 → db/sqlite.ts(SQLite, 6 张表, FTS5)+ chat-db.ts(渲染端接口)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 ReAct Agent Loop
|
### 5.2 ReAct Agent Loop(8 状态机)
|
||||||
|
|
||||||
```
|
```
|
||||||
用户消息 → Thought → Action(tool_calls) → Observation(result) → Reflection → 循环 → Final Answer
|
INIT → THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → TERMINATED
|
||||||
```
|
```
|
||||||
|
|
||||||
- 最大循环次数:85(默认,可在设置中调整)
|
| 状态 | 处理器 | 职责 |
|
||||||
- 全局超时:无(用户控制生命周期)
|
|------|--------|------|
|
||||||
- 工具执行超时:无(所有工具直接 await,无超时限制)
|
| INIT | `handleInit()` | 构建系统提示词、加载 SOUL/AGENT/USER.md、记忆检索、上下文压缩检测 |
|
||||||
- 流式调用超时:无
|
| THINKING | `handleThinking()` | 调用 Ollama 流式 API、工具缓存检查、任务感知注入、Token 预算警告 |
|
||||||
- 自动重试:最多 2 次
|
| PARSING | `handleParsing()` | 解析模型输出、提取 tool_calls、文本兜底解析 |
|
||||||
- **去重机制**:工具调用缓存 + 同轮内重复检测 + 跨轮次重复检测
|
| EXECUTING | `handleExecuting()` | 按批次并行执行工具、重试(最多 2 次)、去重检测、Hook 触发 |
|
||||||
|
| OBSERVING | `handleObserving()` | 收集结果、裁剪旧消息、增量记忆提取、进度锚定、中途幻觉检测 |
|
||||||
|
| REFLECTING | `handleReflecting()` | Plan Mode 确认、空响应处理、Completion Gate、记忆最终提取 |
|
||||||
|
| COMPRESSING | `handleCompressing()` | LLM JSON 结构化压缩、滑动窗口 |
|
||||||
|
| TERMINATED | — | 循环终止、清理状态 |
|
||||||
|
|
||||||
|
关键参数:
|
||||||
|
- 最大轮次:85(默认,设置面板可调)
|
||||||
|
- 自动重试:2 次(MAX_RETRIES)
|
||||||
|
- 看门狗超时:30 分钟(默认,可配)
|
||||||
|
- 上下文硬上限:500 条消息
|
||||||
|
- 流式超时:可配(默认 300s,0=禁用)
|
||||||
|
- HTTP 超时:可配(默认 30s)
|
||||||
|
- MCP 超时:可配(默认 60s)
|
||||||
|
|
||||||
### 5.3 SQLite 数据库
|
### 5.3 SQLite 数据库
|
||||||
|
|
||||||
@@ -181,40 +220,128 @@ import {
|
|||||||
| 表 | 用途 | 关键特性 |
|
| 表 | 用途 | 关键特性 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `sessions` | 会话 | parent_id 父子关系 |
|
| `sessions` | 会话 | parent_id 父子关系 |
|
||||||
| `messages` | 消息 | 外键级联删除,thinking/tool_calls |
|
| `messages` | 消息 | 外键级联删除,thinking/tool_calls/attachments/eval_count |
|
||||||
| `tool_calls` | 工具调用记录 | 按会话+工具名索引 |
|
| `tool_calls` | 工具调用记录 | 按会话+工具名索引 |
|
||||||
| `memories` | Agent 记忆 | FTS5 全文搜索,向量嵌入,容量 500 上限 |
|
| `memories` | Agent 记忆 | FTS5 全文搜索,向量嵌入,容量 500 上限 |
|
||||||
| `settings` | 设置 | JSON 序列化 |
|
| `settings` | 设置 | JSON 序列化 |
|
||||||
| `traces` | ReAct 执行轨迹 | Agent 可观测性 |
|
| `traces` | ReAct 执行轨迹 | Agent 可观测性 |
|
||||||
|
|
||||||
### 5.4 联网搜索联动
|
数据库键路径位于 Electron `userData` 目录(`metona.db`)。写操作采用 temp 文件 + rename 策略防止崩溃损坏。
|
||||||
|
|
||||||
- web_search 返回搜索结果(标题、URL、摘要),默认 15 条
|
### 5.4 Harness Engineering 体系
|
||||||
- 模型在 web_search 后应选择相关 URL 调用 web_fetch 抓取详情
|
|
||||||
- web_fetch 默认返回完整内容(`max_chars=0`),不截断
|
|
||||||
|
|
||||||
### 5.5 工作空间与 AI 集成
|
#### 5.4.1 5 层抗幻觉
|
||||||
|
|
||||||
- AI 通过 `run_command` 工具执行命令,命令在工作空间终端实时显示
|
| 层级 | 实现位置 | 机制 |
|
||||||
- 执行路径:tool-registry.ts → bridge.workspace.execTool() → IPC → handleRunCommand() → spawn
|
|------|---------|------|
|
||||||
- stdout/stderr 通过 `cmd:output` 实时推送到渲染进程终端面板
|
| 提示词加固 | `handleInit()` | [反幻觉铁律] 最高优先级注入 |
|
||||||
- 执行路径(浏览器工具):tool-registry.ts → bridge.callTool('browser_xxx') → IPC → browser.ts → 执行
|
| 任务感知 | `handleThinking()` | 第 2 轮无工具调用时注入提醒 |
|
||||||
|
| 中途检测 | `detectMidTaskHallucination()` | 16 条正则规则,覆盖全部工具类别 |
|
||||||
|
| 进度锚点 | `handleObserving()` | 每 5 轮注入机器生成的工具调用摘要 |
|
||||||
|
| 完成闸门 | `completion-gate.ts` | 6 项检查:幻觉/注入→阻断级,质量/效率→咨询级 |
|
||||||
|
|
||||||
|
#### 5.4.2 Hook 系统(4 阶段)
|
||||||
|
|
||||||
|
| 阶段 | Hook | 优先级 | 行为 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| pre_tool | SecurityCheck | 100 | 命令/路径黑名单拦截 |
|
||||||
|
| post_tool | DiffAnalyzer | 75 | 文件变更差异分析 |
|
||||||
|
| post_tool | FileChangeAudit | 30 | 文件变更审计跟踪 |
|
||||||
|
| post_tool | ResultValidation | 90 | 结果大小告警 |
|
||||||
|
| post_iteration | IterationMetrics | 50 | 迭代度量收集 |
|
||||||
|
|
||||||
|
Hook 异步并行执行,失败不阻塞主流程。可动态注册/移除。
|
||||||
|
|
||||||
|
#### 5.4.3 Completion Gate(6 项检查)
|
||||||
|
|
||||||
|
| 检查项 | 级别 | 不通过行为 |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| toolHallucination | 🔴 阻断 | 强制重新回答 |
|
||||||
|
| promptInjection | 🔴 阻断 | 强制重新回答 |
|
||||||
|
| contentQuality | 🟡 咨询 | 仅记录日志 |
|
||||||
|
| toolResultReview | 🟡 咨询 | 仅记录日志 |
|
||||||
|
| notThinking | 🟡 咨询 | 仅记录日志 |
|
||||||
|
| contextEfficiency | 🟡 咨询 | 仅记录日志 |
|
||||||
|
|
||||||
|
### 5.5 联网搜索体系
|
||||||
|
|
||||||
|
**双模式架构**:
|
||||||
|
|
||||||
|
| 模式 | 实现 | 特性 |
|
||||||
|
|------|------|------|
|
||||||
|
| SearXNG JSON API | `handleWebSearchSearxng()` | 70+ 引擎聚合,JSON/HTML 双格式,认证支持 |
|
||||||
|
| 内置四引擎 HTML 解析 | `handleWebSearch()` | Bing + 百度 + 搜狗 + 360 并行请求 |
|
||||||
|
|
||||||
|
**自动抓取与过滤**:
|
||||||
|
- 硬上限:`MAX_AUTO_FETCH = 8` 条
|
||||||
|
- 相关性过滤:`computeRelevance()` 基于 CJK/英文关键词匹配,跳过无关结果
|
||||||
|
- 浏览器回退缓存:同一 URL 10 分钟内渲染一次
|
||||||
|
- LRU 搜索缓存:200 条/5 分钟 TTL
|
||||||
|
|
||||||
|
### 5.6 工作空间
|
||||||
|
|
||||||
|
- 默认路径:Electron `userData` 下的 `workspace/` 目录
|
||||||
|
- 自定义路径:设置面板可修改
|
||||||
|
- **安全豁免**:工作空间目录通过 `addBlocklistExemptions()` 注册,不受路径黑名单限制
|
||||||
|
- 终端:命令无超时限制,实时流式输出,支持终止
|
||||||
|
- 文件浏览:限工作空间内,支持上级导航、文件预览
|
||||||
|
- 自定义文件:`SOUL.md`(人格,不可压缩)、`AGENT.md`(行为准则)、`USER.md`(用户画像)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、UI/UX 规范
|
## 六、安全规范
|
||||||
|
|
||||||
### 6.1 设计语言
|
### 6.1 文件系统安全
|
||||||
|
|
||||||
- **风格**:暖色调亮色主题
|
- 所有文件路径经 `checkPathAllowed()` 验证
|
||||||
- **背景**:奶白 `#FAF7F2`,卡片白 `#FFFFFF`
|
- 永久黑名单:33 个系统/敏感目录(Linux + Windows)
|
||||||
- **主色调**:珊瑚橙 `#E8734A`
|
- 写入操作额外限制在 `allowedDirs` 白名单内
|
||||||
- **辅助色**:紫色 `#9B7ED8`(Think),金色 `#D4A03C`(Token)
|
- 工作空间目录通过 `addBlocklistExemptions()` 机制豁免
|
||||||
- **字体**:Inter (正文) + JetBrains Mono (代码)
|
- 路径遍历深度检测(`..` 超过 5 层拦截)
|
||||||
- **圆角**:控件 8px,卡片 12px,弹框 16-20px
|
|
||||||
- **终端区域**:暖棕深色 `#2D2016`,保证代码可读性
|
|
||||||
|
|
||||||
### 6.2 Z-Index 层级规范
|
### 6.2 命令执行安全
|
||||||
|
|
||||||
|
- 所有命令经 `checkCommandAllowed()` 验证
|
||||||
|
- 命令黑名单:30 条危险命令(POSIX + Windows)
|
||||||
|
- 反弹 shell 模式检测
|
||||||
|
- `run_command` 三种执行模式:自动 / 需确认 / 禁用
|
||||||
|
|
||||||
|
### 6.3 前端安全
|
||||||
|
|
||||||
|
- HTML 净化器:白名单标签 + 属性过滤 + URI 协议检查
|
||||||
|
- `contextIsolation: true`
|
||||||
|
- IPC 白名单 + 路径验证
|
||||||
|
|
||||||
|
### 6.4 网络安全
|
||||||
|
|
||||||
|
- `web_fetch` 流式体积限制 10MB
|
||||||
|
- 反爬 UA 轮换(5 个)+ 指数退避
|
||||||
|
- 拦截页检测(Cloudflare / 403 / 验证码)
|
||||||
|
- 无 content-length 时防 OOM 保护
|
||||||
|
- 内部 URL(localhost/127.0.0.1/0.0.0.0)拦截
|
||||||
|
|
||||||
|
### 6.5 MCP 安全
|
||||||
|
|
||||||
|
- Shadowing 防护:MCP 工具不可覆盖内置工具
|
||||||
|
- 双下划线分隔符防歧义:`mcp_{server}__{tool}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、UI/UX 规范
|
||||||
|
|
||||||
|
### 7.1 设计语言
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|----|
|
||||||
|
| 风格 | 暖色调亮色主题 |
|
||||||
|
| 背景 | 奶白 `#FAF7F2`,卡片白 `#FFFFFF` |
|
||||||
|
| 主色调 | 珊瑚橙 `#E8734A` |
|
||||||
|
| 辅助色 | 紫色 `#9B7ED8`(Think),金色 `#D4A03C`(Token) |
|
||||||
|
| 终端色 | 暖棕深色 `#2D2016` |
|
||||||
|
| 字体 | Inter (正文) + JetBrains Mono (代码) |
|
||||||
|
| 圆角 | 控件 8px,卡片 12px,弹框 16-20px |
|
||||||
|
|
||||||
|
### 7.2 Z-Index 层级
|
||||||
|
|
||||||
| 层级 | 组件 | 值 |
|
| 层级 | 组件 | 值 |
|
||||||
|------|------|----|
|
|------|------|----|
|
||||||
@@ -226,53 +353,47 @@ import {
|
|||||||
| 低层 | 工作空间面板 | 10 |
|
| 低层 | 工作空间面板 | 10 |
|
||||||
| 基础 | 日志面板 | 5 |
|
| 基础 | 日志面板 | 5 |
|
||||||
|
|
||||||
### 6.3 响应式规则
|
### 7.3 布局
|
||||||
|
|
||||||
- 工作空间面板宽度:固定 480px(flex 子元素)
|
三栏布局(flex):
|
||||||
- 模态框宽度:基础 480px,大号 860px
|
- **左侧**:日志面板(可收起)
|
||||||
|
- **中间**:聊天区(Header + 模型栏 + 消息 + 输入框)
|
||||||
|
- **右侧**:工作空间(480px 固定宽度,3 个 Tab:💻终端 / 🔧工具 / 📁文件)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、构建与发布
|
## 八、构建与发布
|
||||||
|
|
||||||
### 7.1 构建命令
|
### 8.1 构建命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # 完整构建
|
npm run build:renderer # 仅 Vite 构建渲染进程
|
||||||
npm run build:renderer # 仅渲染进程 (Vite)
|
npm run build:main # 仅 tsc 编译主进程
|
||||||
npm run build:main # 仅主进程 (tsc)
|
npm run build # 完整构建
|
||||||
npm start # 构建并运行
|
npm start # 构建并运行(开发调试)
|
||||||
npm run dist # 构建 Windows 安装包
|
npm run dist # 构建 Windows 安装包(NSIS)
|
||||||
|
npm run dev:renderer # Vite watch 模式
|
||||||
|
npm run dev:main # tsc watch 模式
|
||||||
```
|
```
|
||||||
|
|
||||||
### 版本号更新
|
### 8.2 发布流程
|
||||||
|
|
||||||
更新版本号时,需同步修改以下文件:
|
1. 更新版本号(仅修改 5 个白名单文件)
|
||||||
|
|
||||||
| 文件 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| `package.json` | `"version": "X.Y.Z"` |
|
|
||||||
| `package-lock.json` | 顶层 version 字段(2 处) |
|
|
||||||
| `src/renderer/index.html` | `<span class="app-version">vX.Y.Z</span>` |
|
|
||||||
| `README.md` | 版本徽章、下载文件名 |
|
|
||||||
| `src/main/menu.ts` | 关于对话框版本号 |
|
|
||||||
| `docs/BUILD.md` | 构建产物文件名、Gitee release tag |
|
|
||||||
| `docs/DEVELOPMENT.md` | 文件头部版本号 |
|
|
||||||
|
|
||||||
### 7.2 发布流程
|
|
||||||
|
|
||||||
1. 更新版本号(同步上述文件)
|
|
||||||
2. 构建并测试:`npm start`
|
2. 构建并测试:`npm start`
|
||||||
3. 构建安装包:`npm run dist`
|
3. 构建安装包:`npm run dist`
|
||||||
4. 推送到 master
|
4. 推送到 master + Gitee release
|
||||||
|
|
||||||
|
### 8.3 BUILD.md 已移除
|
||||||
|
|
||||||
|
构建指南已整合到 README.md。不再需要独立的 BUILD.md 文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、Git 规范
|
## 九、Git 规范
|
||||||
|
|
||||||
### 分支模型
|
### 分支模型
|
||||||
|
|
||||||
- `master` — 主分支
|
- `master` — 主分支(仅有分支,直接推送)
|
||||||
|
|
||||||
### Commit 规范
|
### Commit 规范
|
||||||
|
|
||||||
@@ -290,10 +411,12 @@ perf: 性能优化
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、注意事项
|
## 十、注意事项
|
||||||
|
|
||||||
1. **第三方库本地化**:新增依赖优先成熟稳定库,vendor 到 `src/vendor/` 目录内引用(ESM + 类型声明 + LICENSE),避免 npm 运行时依赖(sql.js 因含 WASM 除外)
|
1. **安全优先**:所有文件/命令操作必须经过 `tool-security.ts` 安全检查层
|
||||||
2. **安全优先**:所有文件/命令操作必须经过安全检查层
|
2. **日志完整**:关键操作必须通过 `log-service.ts` 记录到执行日志面板
|
||||||
3. **日志完整**:关键操作必须记录到执行日志面板
|
3. **桌面优先**:API 调用必须检查 `bridge.isDesktop`,非桌面环境优雅降级
|
||||||
4. **无超时设计**:工作空间相关操作不设超时,由用户控制生命周期
|
4. **上下文管控**:自动抓取最多 8 条网页,防止上下文爆炸;工具结果超 10 轮自动截断
|
||||||
5. **桌面优先**:API 调用必须检查 `bridge.isDesktop`,非桌面环境优雅降级
|
5. **版本号纪律**:仅 5 个白名单文件允许出现版本号,其余源码一律禁止
|
||||||
|
6. **Vendor 优先**:第三方依赖优先本地化到 `src/vendor/`(sql.js 因含 WASM 二进制除外)
|
||||||
|
7. **无版本号注释**:代码注释中禁止出现 `v0.x.x: xxx` 格式,直接描述功能
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.12.3",
|
"version": "0.12.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ollama-desktop",
|
"name": "metona-ollama-desktop",
|
||||||
"version": "0.12.3",
|
"version": "0.12.4",
|
||||||
"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.12.3",
|
"version": "0.12.4",
|
||||||
"description": "Metona Ollama - TypeScript + Electron \u684c\u9762 AI \u804a\u5929\u5ba2\u6237\u7aef",
|
"description": "Metona Ollama - TypeScript + Electron \u684c\u9762 AI \u804a\u5929\u5ba2\u6237\u7aef",
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export async function initDatabase(): Promise<SQL.Database> {
|
|||||||
// 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON)
|
// 兼容迁移:为已有 messages 表补充 attachments 列(文件/视频等附件 JSON)
|
||||||
try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ }
|
try { db.run('ALTER TABLE messages ADD COLUMN attachments TEXT'); } catch { /* 列已存在,忽略 */ }
|
||||||
|
|
||||||
// 兼容迁移:v0.12.0 — 为已有 traces 表补充 error_pattern 列
|
// 兼容迁移:为已有 traces 表补充 error_pattern 列
|
||||||
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
|
try { db.run('ALTER TABLE traces ADD COLUMN error_pattern TEXT'); } catch { /* 列已存在,忽略 */ }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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.12.3',
|
message: 'Metona Ollama Desktop v0.12.4',
|
||||||
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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -151,6 +151,27 @@ function cacheSet(query: string, entry: CacheEntry): void {
|
|||||||
_searchCache.set(query, entry);
|
_searchCache.set(query, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 浏览器回退结果缓存 ─────────────────────────────
|
||||||
|
// 浏览器渲染耗时数秒,同一 URL 在多次搜索中可能被重复抓取
|
||||||
|
interface BrowserCacheEntry { text: string; time: number; }
|
||||||
|
const _browserFetchCache = new Map<string, BrowserCacheEntry>();
|
||||||
|
const BROWSER_CACHE_MAX = 100;
|
||||||
|
const BROWSER_CACHE_TTL = 10 * 60 * 1000; // 10 分钟
|
||||||
|
|
||||||
|
function browserCacheGet(url: string): string | null {
|
||||||
|
const entry = _browserFetchCache.get(url);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() - entry.time > BROWSER_CACHE_TTL) { _browserFetchCache.delete(url); return null; }
|
||||||
|
return entry.text;
|
||||||
|
}
|
||||||
|
function browserCacheSet(url: string, text: string): void {
|
||||||
|
if (_browserFetchCache.size >= BROWSER_CACHE_MAX) {
|
||||||
|
const firstKey = _browserFetchCache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) _browserFetchCache.delete(firstKey);
|
||||||
|
}
|
||||||
|
_browserFetchCache.set(url, { text, time: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
// ── 反爬请求头 ────────────────────────────────────
|
// ── 反爬请求头 ────────────────────────────────────
|
||||||
const UA_POOL = [
|
const UA_POOL = [
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
@@ -288,6 +309,8 @@ import { browserOpen, browserExtract, browserClose } from './browser.js';
|
|||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
const FETCH_MAX_RETRIES = 3; // fetch 阶段最多重试 3 次
|
const FETCH_MAX_RETRIES = 3; // fetch 阶段最多重试 3 次
|
||||||
const FETCH_RETRY_DELAYS = [2000, 4000, 6000]; // 指数退避 (ms),由 jitter() 随机化
|
const FETCH_RETRY_DELAYS = [2000, 4000, 6000]; // 指数退避 (ms),由 jitter() 随机化
|
||||||
|
/** 自动抓取最大条数硬上限 — 防止上下文爆炸 */
|
||||||
|
const MAX_AUTO_FETCH = 8;
|
||||||
/** fetch 全部失败后自动回退到浏览器渲染 */
|
/** fetch 全部失败后自动回退到浏览器渲染 */
|
||||||
const BROWSER_FALLBACK_ENABLED = true;
|
const BROWSER_FALLBACK_ENABLED = true;
|
||||||
/** 内容过短阈值:小于此字符数且是 HTML 时,自动升级到浏览器渲染 */
|
/** 内容过短阈值:小于此字符数且是 HTML 时,自动升级到浏览器渲染 */
|
||||||
@@ -297,6 +320,15 @@ const BROWSER_FALLBACK_STATUSES = new Set([403, 503, 429, 502]);
|
|||||||
|
|
||||||
/** 浏览器回退:打开 URL → 等待渲染 → 提取内容 */
|
/** 浏览器回退:打开 URL → 等待渲染 → 提取内容 */
|
||||||
async function browserFallback(url: string, maxChars: number): Promise<{ text: string; method: string } | null> {
|
async function browserFallback(url: string, maxChars: number): Promise<{ text: string; method: string } | null> {
|
||||||
|
// 1) 检查浏览器回退缓存
|
||||||
|
const cached = browserCacheGet(url);
|
||||||
|
if (cached) {
|
||||||
|
let text = cached;
|
||||||
|
if (maxChars > 0 && text.length > maxChars) text = text.slice(0, maxChars);
|
||||||
|
sendLog('info', `🌐 browser 缓存命中`, url.slice(0, 60));
|
||||||
|
return { text, method: 'browser(cached)' };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openResult = await browserOpen(url);
|
const openResult = await browserOpen(url);
|
||||||
if (!openResult.success) return null;
|
if (!openResult.success) return null;
|
||||||
@@ -306,6 +338,10 @@ async function browserFallback(url: string, maxChars: number): Promise<{ text: s
|
|||||||
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);
|
||||||
|
// 写入浏览器回退缓存(缓存完整文本,下次命中时再截断)
|
||||||
|
if (extractResult.text.length >= 100) {
|
||||||
|
browserCacheSet(url, extractResult.text);
|
||||||
|
}
|
||||||
return { text, method: 'browser' };
|
return { text, method: 'browser' };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -656,10 +692,10 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
const maxResults = Math.min(params.max_results || 30, 30);
|
const maxResults = Math.min(params.max_results || 30, 30);
|
||||||
const timeRange = params.time_range || '';
|
const timeRange = params.time_range || '';
|
||||||
const enhanceSnippets = params.enhance_snippets !== false;
|
const enhanceSnippets = params.enhance_snippets !== false;
|
||||||
const aiFetchTop = Math.max(3, params.fetch_top || 5);
|
const aiFetchTop = Math.min(Math.max(3, params.fetch_top || 5), MAX_AUTO_FETCH);
|
||||||
|
|
||||||
// 内置引擎默认:使用 AI 指定条数 + 顺序抓取
|
// 内置引擎默认:使用 AI 指定条数 + 顺序抓取
|
||||||
let fetchTop = aiFetchTop;
|
let fetchTop = Math.min(aiFetchTop, MAX_AUTO_FETCH);
|
||||||
let fetchMode: 'sequential' | 'random' = 'sequential';
|
let fetchMode: 'sequential' | 'random' = 'sequential';
|
||||||
|
|
||||||
let result: ToolResult;
|
let result: ToolResult;
|
||||||
@@ -670,7 +706,7 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
// SearXNG 模式下读取用户面板设置的抓取条数和类型
|
// SearXNG 模式下读取用户面板设置的抓取条数和类型
|
||||||
const userFetchCount = getSetting<number>('fetch_count', 0);
|
const userFetchCount = getSetting<number>('fetch_count', 0);
|
||||||
fetchMode = (getSetting<string>('fetch_mode', 'sequential') || 'sequential') as 'sequential' | 'random';
|
fetchMode = (getSetting<string>('fetch_mode', 'sequential') || 'sequential') as 'sequential' | 'random';
|
||||||
fetchTop = userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop;
|
fetchTop = Math.min(userFetchCount > 0 ? Math.max(3, userFetchCount) : aiFetchTop, MAX_AUTO_FETCH);
|
||||||
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}" | 抓取=${fetchTop}条 ${fetchMode === 'random' ? '随机' : '顺序'}`);
|
sendLog('info', `🔍 web_search → SearXNG 模式`, `"${query}" | 抓取=${fetchTop}条 ${fetchMode === 'random' ? '随机' : '顺序'}`);
|
||||||
result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
|
result = await handleWebSearchSearxng(query, maxResults, timeRange, enhanceSnippets);
|
||||||
} else {
|
} else {
|
||||||
@@ -859,31 +895,96 @@ export async function handleWebSearch(params: { query: string; max_results?: num
|
|||||||
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容
|
/** 对搜索结果的前 fetchTop 条自动调用 web_fetch 获取完整内容
|
||||||
* @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条
|
* @param fetchMode 'sequential'=顺序抓取前N条 | 'random'=从结果中随机选取N条
|
||||||
* 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */
|
* 抓取失败时:先依赖 handleWebFetch 内置的浏览器回退,全部失败后随机选 1 条未抓取的补充抓取 */
|
||||||
|
|
||||||
|
/** 计算搜索结果标题与搜索 query 的相关性得分
|
||||||
|
* 提取 query 中的关键词(CJK 字符取 2-4 字片段,英文取单词),
|
||||||
|
* 检查标题中是否包含这些关键词。返回 0-100 的得分 */
|
||||||
|
function computeRelevance(query: string, title: string, snippet: string): number {
|
||||||
|
if (!query) return 50; // 无 query 信息时不做过滤
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const t = title.toLowerCase();
|
||||||
|
const s = (snippet || '').toLowerCase();
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// 1) 提取 query 中的 CJK 双/三字片段
|
||||||
|
const cjkTokens: string[] = [];
|
||||||
|
for (let i = 0; i < q.length; i++) {
|
||||||
|
if (/[\u4e00-\u9fff]/.test(q[i])) {
|
||||||
|
if (i + 1 < q.length && /[\u4e00-\u9fff]/.test(q[i + 1])) {
|
||||||
|
cjkTokens.push(q.slice(i, i + 2));
|
||||||
|
if (i + 2 < q.length && /[\u4e00-\u9fff]/.test(q[i + 2])) {
|
||||||
|
cjkTokens.push(q.slice(i, i + 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 去重
|
||||||
|
const uniqueCJK = [...new Set(cjkTokens)];
|
||||||
|
|
||||||
|
// 2) 提取英文单词(≥2 个字符)
|
||||||
|
const enWords = q.match(/[a-z]{2,}/g) || [];
|
||||||
|
|
||||||
|
// 3) 标题匹配计分
|
||||||
|
for (const token of uniqueCJK) {
|
||||||
|
if (t.includes(token)) { score += 25; break; } // 命中一个 CJK 片段即可
|
||||||
|
}
|
||||||
|
for (const word of enWords) {
|
||||||
|
if (t.includes(word)) score += 15;
|
||||||
|
}
|
||||||
|
// 摘要匹配加成
|
||||||
|
for (const token of uniqueCJK.slice(0, 3)) {
|
||||||
|
if (s.includes(token)) score += 5;
|
||||||
|
}
|
||||||
|
for (const word of enWords.slice(0, 3)) {
|
||||||
|
if (s.includes(word)) score += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, score);
|
||||||
|
}
|
||||||
|
|
||||||
async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise<ToolResult> {
|
async function applyAutoFetch(result: ToolResult, fetchTop: number, fetchMode: 'sequential' | 'random' = 'sequential'): Promise<ToolResult> {
|
||||||
const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
|
const results = (result as any).results as Array<{ url: string; title: string; snippet: string }> | undefined;
|
||||||
if (!results || results.length === 0) return result;
|
if (!results || results.length === 0) return result;
|
||||||
|
|
||||||
const count = Math.min(fetchTop, results.length);
|
const count = Math.min(fetchTop, results.length, MAX_AUTO_FETCH);
|
||||||
|
|
||||||
|
// ── 相关性过滤:只抓取标题与搜索词至少有部分匹配的结果 ──
|
||||||
|
// 从搜索结果中反向获取原始 query(若有)作为过滤依据
|
||||||
|
const searchQuery = (result as any).query as string || '';
|
||||||
|
const scoredResults = results.map((r, idx) => {
|
||||||
|
const relevance = computeRelevance(searchQuery, r.title, r.snippet);
|
||||||
|
return { ...r, _idx: idx, _relevance: relevance };
|
||||||
|
});
|
||||||
|
// 按相关性降序排列(相关性高的优先抓取)
|
||||||
|
scoredResults.sort((a, b) => b._relevance - a._relevance);
|
||||||
|
// 过滤掉明显无关的结果(相关性为 0)
|
||||||
|
const relevantResults = scoredResults.filter(r => r._relevance > 0);
|
||||||
|
const skippedCount = results.length - relevantResults.length;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
sendLog('info', `🔍 相关性过滤: ${skippedCount} 条无关结果已跳过`,
|
||||||
|
results.filter((_, i) => !relevantResults.some(rr => rr._idx === i)).map(r => r.title.slice(0, 30)).join(', '));
|
||||||
|
}
|
||||||
|
const effectiveResults = relevantResults.length > 0 ? relevantResults : scoredResults; // fallback: 全部无关时仍抓取
|
||||||
|
|
||||||
// ── 构建抓取列表 ──
|
// ── 构建抓取列表 ──
|
||||||
let fetchList: Array<{ url: string; title: string; originalIndex: number }>;
|
let fetchList: Array<{ url: string; title: string; originalIndex: number }>;
|
||||||
if (fetchMode === 'random') {
|
if (fetchMode === 'random') {
|
||||||
// Fisher-Yates 洗牌索引,取前 count 个
|
// Fisher-Yates 洗牌索引,取前 count 个
|
||||||
const indices = results.map((_, i) => i);
|
const indices = effectiveResults.slice(0, Math.min(count * 2, effectiveResults.length)).map((_, i) => i);
|
||||||
for (let i = indices.length - 1; i > 0; i--) {
|
for (let i = indices.length - 1; i > 0; i--) {
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||||
}
|
}
|
||||||
fetchList = indices.slice(0, count).map(idx => ({
|
fetchList = indices.slice(0, count).map(idx => ({
|
||||||
url: results[idx].url,
|
url: effectiveResults[idx].url,
|
||||||
title: results[idx].title,
|
title: effectiveResults[idx].title,
|
||||||
originalIndex: idx,
|
originalIndex: effectiveResults[idx]._idx,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
fetchList = results.slice(0, count).map((r, i) => ({
|
fetchList = effectiveResults.slice(0, count).map((r) => ({
|
||||||
url: r.url,
|
url: r.url,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
originalIndex: i,
|
originalIndex: r._idx,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,29 @@ export interface CheckResult {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 路径安全豁免列表:这些路径即使匹配 BLOCKED_DIRS 也放行(如 app 自己的工作空间) */
|
||||||
|
let blocklistExemptions: string[] = [];
|
||||||
|
|
||||||
|
/** 注册路径为安全豁免(不受 BLOCKED_DIRS 限制)。主要用于注册工作空间目录 */
|
||||||
|
export function addBlocklistExemptions(dirs: string[]): void {
|
||||||
|
for (const d of dirs) {
|
||||||
|
const resolved = path.resolve(d);
|
||||||
|
if (!blocklistExemptions.includes(resolved)) {
|
||||||
|
blocklistExemptions.push(resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查路径是否在豁免列表中 */
|
||||||
|
function isBlocklistExempt(resolved: string): boolean {
|
||||||
|
for (const exempt of blocklistExemptions) {
|
||||||
|
if (resolved === exempt || resolved.startsWith(exempt + path.sep)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult {
|
export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'): CheckResult {
|
||||||
const resolved = path.resolve(targetPath);
|
const resolved = path.resolve(targetPath);
|
||||||
|
|
||||||
@@ -77,7 +100,10 @@ export function checkPathAllowed(targetPath: string, operation: 'read' | 'write'
|
|||||||
|
|
||||||
for (const blocked of BLOCKED_DIRS) {
|
for (const blocked of BLOCKED_DIRS) {
|
||||||
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
|
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
|
||||||
return { ok: false, reason: `禁止访问受保护路径: ${blocked}` };
|
// 如果路径在安全豁免列表中(如 app 自己的工作空间),放行
|
||||||
|
if (!isBlocklistExempt(resolved)) {
|
||||||
|
return { ok: false, reason: `禁止访问受保护路径: ${blocked}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { mainWindow } from './main.js';
|
import { mainWindow } from './main.js';
|
||||||
import { checkPathAllowed, checkCommandAllowed } from './tool-security.js';
|
import { checkPathAllowed, checkCommandAllowed, addBlocklistExemptions } from './tool-security.js';
|
||||||
|
|
||||||
/** 发送日志到渲染进程日志面板 */
|
/** 发送日志到渲染进程日志面板 */
|
||||||
function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void {
|
function sendLog(level: 'info' | 'success' | 'warn' | 'error' | 'debug', message: string, detail?: string): void {
|
||||||
@@ -26,6 +26,8 @@ export function ensureWorkspaceDir(): void {
|
|||||||
fs.mkdirSync(DEFAULT_WORKSPACE_DIR, { recursive: true });
|
fs.mkdirSync(DEFAULT_WORKSPACE_DIR, { recursive: true });
|
||||||
sendLog('info', `📁 创建默认工作空间目录`, DEFAULT_WORKSPACE_DIR);
|
sendLog('info', `📁 创建默认工作空间目录`, DEFAULT_WORKSPACE_DIR);
|
||||||
}
|
}
|
||||||
|
// 工作空间目录及其子目录不受安全黑名单限制
|
||||||
|
addBlocklistExemptions([DEFAULT_WORKSPACE_DIR]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前工作空间目录(支持用户自定义) */
|
/** 获取当前工作空间目录(支持用户自定义) */
|
||||||
@@ -41,6 +43,8 @@ export function setWorkspaceDir(dir: string): void {
|
|||||||
fs.mkdirSync(resolved, { recursive: true });
|
fs.mkdirSync(resolved, { recursive: true });
|
||||||
}
|
}
|
||||||
_workspaceDir = resolved;
|
_workspaceDir = resolved;
|
||||||
|
// 用户自定义的工作空间目录也需安全豁免
|
||||||
|
addBlocklistExemptions([resolved]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function initInputArea(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── v0.12.0 Plan Mode 切换 ──
|
// ── Plan Mode 切换 ──
|
||||||
const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement;
|
const planToggleEl = document.querySelector('#togglePlan') as HTMLInputElement;
|
||||||
if (planToggleEl) {
|
if (planToggleEl) {
|
||||||
planToggleCheckbox = planToggleEl;
|
planToggleCheckbox = planToggleEl;
|
||||||
@@ -1268,7 +1268,7 @@ async function sendMessageWithAgentLoop(text: string, currentSession: ChatSessio
|
|||||||
onConfirmTool: async (call) => {
|
onConfirmTool: async (call) => {
|
||||||
return showToolConfirm(call);
|
return showToolConfirm(call);
|
||||||
},
|
},
|
||||||
/** v0.12.1: Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
|
/** Plan Mode — 计划生成后等待用户确认(Markdown 渲染) */
|
||||||
onPlanReady: async (plan: string, steps: string[]) => {
|
onPlanReady: async (plan: string, steps: string[]) => {
|
||||||
try {
|
try {
|
||||||
const { showHtmlConfirm } = await import('./prompt-modal.js');
|
const { showHtmlConfirm } = await import('./prompt-modal.js');
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ const saveSubAgentTimeout = debounce(async () => {
|
|||||||
}, 500);
|
}, 500);
|
||||||
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
document.querySelector('#inputSubAgentTimeout')!.addEventListener('input', saveSubAgentTimeout);
|
||||||
|
|
||||||
// ── v0.12.1 看门狗超时(分钟,0=禁用)──
|
// ── 看门狗超时(分钟,0=禁用)──
|
||||||
const saveLoopWatchdog = debounce(async () => {
|
const saveLoopWatchdog = debounce(async () => {
|
||||||
const db = state.get<ChatDB | null>(KEYS.DB);
|
const db = state.get<ChatDB | null>(KEYS.DB);
|
||||||
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
const val = (document.querySelector('#inputLoopWatchdog') as HTMLInputElement).value.trim();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span class="logo">🦙</span>
|
<span class="logo">🦙</span>
|
||||||
<span class="app-title">Metona Ollama</span>
|
<span class="app-title">Metona Ollama</span>
|
||||||
<span class="app-version">v0.12.3</span>
|
<span class="app-version">v0.12.4</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"/>
|
||||||
@@ -813,7 +813,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">自动抓取条数</label>
|
<label class="setting-label">自动抓取条数</label>
|
||||||
<input class="setting-input" id="searxngFetchCount" type="number" min="0" step="1" placeholder="0=使用AI指定值" style="margin-bottom:0;">
|
<input class="setting-input" id="searxngFetchCount" type="number" min="1" max="8" step="1" placeholder="留空=AI决定(最多8条)" style="margin-bottom:0;">
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<label class="setting-label">抓取类型</label>
|
<label class="setting-label">抓取类型</label>
|
||||||
|
|||||||
@@ -349,13 +349,13 @@ async function init(): Promise<void> {
|
|||||||
await initMemoryManager();
|
await initMemoryManager();
|
||||||
await loadSearxngConfig(db);
|
await loadSearxngConfig(db);
|
||||||
|
|
||||||
// ── v0.12.0: Harness Engineering 系统初始化 ──
|
// ── Harness Engineering 系统初始化 ──
|
||||||
initHarnessHooks();
|
initHarnessHooks();
|
||||||
initVerificationSystem();
|
initVerificationSystem();
|
||||||
// Agent Metrics 无需显式初始化(按需启动)
|
// Agent Metrics 无需显式初始化(按需启动)
|
||||||
// Context Indexer 在 Agent INIT 状态按需构建
|
// Context Indexer 在 Agent INIT 状态按需构建
|
||||||
|
|
||||||
logInit('所有组件已就绪(含 Harness v0.12.0)');
|
logInit('所有组件已就绪(含 Harness)');
|
||||||
|
|
||||||
const savedModel = await db.getSetting('selectedModel', '');
|
const savedModel = await db.getSetting('selectedModel', '');
|
||||||
if (savedModel) {
|
if (savedModel) {
|
||||||
@@ -418,7 +418,7 @@ async function init(): Promise<void> {
|
|||||||
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
|
(document.querySelector('#inputSubAgentMaxLoops') as HTMLInputElement).value = String(subAgentMaxLoops);
|
||||||
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
|
(document.querySelector('#inputSubAgentTimeout') as HTMLInputElement).value = subAgentTimeout >= 0 ? String(subAgentTimeout) : '';
|
||||||
|
|
||||||
// ── v0.12.1 看门狗超时 ──
|
// ── 看门狗超时 ──
|
||||||
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
|
let loopWatchdogMs = await db.getSetting<number>('loopWatchdogMs', 1_800_000);
|
||||||
state.set('loopWatchdogMs', loopWatchdogMs);
|
state.set('loopWatchdogMs', loopWatchdogMs);
|
||||||
const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs;
|
const watchdogMin = loopWatchdogMs > 0 ? Math.round(loopWatchdogMs / 60_000) : loopWatchdogMs;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Agent Engine - ReAct Agent Loop 核心引擎 (v0.12.0 Harness Edition)
|
* Agent Engine - ReAct Agent Loop 核心引擎
|
||||||
* ReAct 模式: Thought → Action → Observation → Reflection
|
* ReAct 模式: Thought → Action → Observation → Reflection
|
||||||
* v0.12.0: 状态机化架构 + Hook 系统 + Completion Gate + Plan Mode
|
* 状态机化架构 + Hook 系统 + Completion Gate + Plan Mode
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { OllamaAPI } from '../api/ollama.js';
|
import { OllamaAPI } from '../api/ollama.js';
|
||||||
@@ -38,7 +38,7 @@ import type {
|
|||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
const MAX_RETRIES = 2; // 工具错误自动重试次数
|
||||||
const MAX_MESSAGES = 500; // v0.12.1: 上下文硬上限,超过则强制压缩
|
const MAX_MESSAGES = 500; // 上下文硬上限,超过则强制压缩
|
||||||
|
|
||||||
/** ── LoopState 常量 ── */
|
/** ── LoopState 常量 ── */
|
||||||
const S = {
|
const S = {
|
||||||
@@ -489,7 +489,7 @@ async function handleInit(
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
if (agentMdContent) {
|
if (agentMdContent) {
|
||||||
// v0.12.0: Token 预算截断(限制 2000 tokens)
|
// Token 预算截断(限制 2000 tokens)
|
||||||
const truncated = truncateByTokenBudget(agentMdContent, 2000);
|
const truncated = truncateByTokenBudget(agentMdContent, 2000);
|
||||||
systemPromptParts.push(`[AGENT.md] ${truncated}`);
|
systemPromptParts.push(`[AGENT.md] ${truncated}`);
|
||||||
}
|
}
|
||||||
@@ -541,9 +541,16 @@ async function handleInit(
|
|||||||
2. 计划批准后,每一次完成一个步骤,必须调用 plan_track(action='mark_done', step_index=N) 标记该步骤已完成。
|
2. 计划批准后,每一次完成一个步骤,必须调用 plan_track(action='mark_done', step_index=N) 标记该步骤已完成。
|
||||||
3. 每轮思考前系统会自动注入当前执行进度。请对照进度确保所有步骤最终都被标记为完成。
|
3. 每轮思考前系统会自动注入当前执行进度。请对照进度确保所有步骤最终都被标记为完成。
|
||||||
4. 所有步骤完成后调用 plan_track(action='mark_all_done') 或直接给出最终回答。`);
|
4. 所有步骤完成后调用 plan_track(action='mark_all_done') 或直接给出最终回答。`);
|
||||||
|
|
||||||
|
// 将用户原始任务描述固化到系统提示词中(不会被压缩或清理)
|
||||||
|
const taskDesc = userContent?.slice(0, 200) || '';
|
||||||
|
if (taskDesc) {
|
||||||
|
systemPromptParts.push(`[当前任务] 用户要求:${taskDesc}
|
||||||
|
⚠️ 以上是你需要完成的任务。即使上下文被压缩或清理,也必须记住并完成这个任务。`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v0.12.0: 渐进式披露 — 项目索引(始终保留在上下文中)──
|
// ── 渐进式披露 — 项目索引(始终保留在上下文中)──
|
||||||
if (workspaceDir) {
|
if (workspaceDir) {
|
||||||
try {
|
try {
|
||||||
const projectIndex = await buildProjectIndex(workspaceDir);
|
const projectIndex = await buildProjectIndex(workspaceDir);
|
||||||
@@ -572,7 +579,7 @@ Shell: ${osInfo.shell}
|
|||||||
- 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /)
|
- 如果是 Linux/macOS,使用 Bash 命令(如 ls、cat、grep,路径用 /)
|
||||||
- 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`);
|
- 严禁在 Windows 上执行 Linux 命令,严禁在 Linux 上执行 Windows 命令。`);
|
||||||
|
|
||||||
// ── v0.12.1: 反幻觉铁律(注入最高优先级)──
|
// ── 反幻觉铁律(注入最高优先级)──
|
||||||
systemPromptParts.push(`[反幻觉铁律 — 最高优先级,不可违反]
|
systemPromptParts.push(`[反幻觉铁律 — 最高优先级,不可违反]
|
||||||
|
|
||||||
⚠️ 以下规则高于一切其他指令,违反将导致任务失败:
|
⚠️ 以下规则高于一切其他指令,违反将导致任务失败:
|
||||||
@@ -645,7 +652,7 @@ Shell: ${osInfo.shell}
|
|||||||
|
|
||||||
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`);
|
logInfo(`ReAct Agent Loop 启动: ${model}`, `工具: ${useTools ? '开启' : '关闭'}, 记忆: ${isMemoryEnabled() ? '开启' : '关闭'}, 模式: ${ctx.mode}, tokens≈${estimateTokens(ctx.messages.map(m => m.content || '').join(''))}`);
|
||||||
|
|
||||||
// ── v0.12.0 Plan Mode: 如果是 plan 模式,先注入计划提示 ──
|
// ── Plan Mode: 如果是 plan 模式,先注入计划提示 ──
|
||||||
if (ctx.mode === 'plan' && ctx.loopCount === 0) {
|
if (ctx.mode === 'plan' && ctx.loopCount === 0) {
|
||||||
ctx.messages.push({
|
ctx.messages.push({
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
@@ -682,7 +689,7 @@ function extractPlanSteps(content: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.12.1: 中途幻觉检测 — 轻量级检查
|
* 中途幻觉检测 — 轻量级检查
|
||||||
* 仅检查最关键的几类幻觉(文件写入、搜索、命令执行)
|
* 仅检查最关键的几类幻觉(文件写入、搜索、命令执行)
|
||||||
* 完整检查在 Completion Gate 中
|
* 完整检查在 Completion Gate 中
|
||||||
*/
|
*/
|
||||||
@@ -766,7 +773,7 @@ function detectPendingActions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.12.1: 通用工具结果核验 — 对所有写类工具执行后验证
|
* 通用工具结果核验 — 对所有写类工具执行后验证
|
||||||
* 读取/搜索类工具已有返回结果作为验证,此处只核验会产生副作用的操作
|
* 读取/搜索类工具已有返回结果作为验证,此处只核验会产生副作用的操作
|
||||||
*/
|
*/
|
||||||
const TOOLS_NEED_VERIFY = new Set([
|
const TOOLS_NEED_VERIFY = new Set([
|
||||||
@@ -903,7 +910,7 @@ async function handleThinking(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v0.12.1: 任务感知 — 检测用户请求需要工具但模型尚未行动 ──
|
// ── 任务感知 — 检测用户请求需要工具但模型尚未行动 ──
|
||||||
if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) {
|
if (ctx.loopCount === 2 && ctx.allToolRecords.length === 0) {
|
||||||
// 从用户第一条消息中检测是否需要工具
|
// 从用户第一条消息中检测是否需要工具
|
||||||
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
const firstUserMsg = ctx.messages.find(m => m.role === 'user');
|
||||||
@@ -1209,7 +1216,7 @@ async function handleExecuting(
|
|||||||
callbacks.onToolCallStart(call);
|
callbacks.onToolCallStart(call);
|
||||||
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
logToolStart(call.function.name, JSON.stringify(call.function.arguments));
|
||||||
|
|
||||||
// ── v0.12.0: pre_tool Hook ──
|
// ── pre_tool Hook ──
|
||||||
await executeHooks('pre_tool', ctx, { toolName: call.function.name, toolArgs: call.function.arguments });
|
await executeHooks('pre_tool', ctx, { toolName: call.function.name, toolArgs: call.function.arguments });
|
||||||
|
|
||||||
await new Promise(r => requestAnimationFrame(r));
|
await new Promise(r => requestAnimationFrame(r));
|
||||||
@@ -1284,11 +1291,11 @@ async function handleExecuting(
|
|||||||
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
|
if (cacheKey) toolResultCache.set(cacheKey, { result: record.result!, timestamp: Date.now() });
|
||||||
// 记录度量
|
// 记录度量
|
||||||
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
|
recordToolCall(record.name, record.status, Date.now() - record.timestamp);
|
||||||
// ── v0.12.1: 通用工具结果核验 — 所有写类工具执行后验证 ──
|
// ── 通用工具结果核验 — 所有写类工具执行后验证 ──
|
||||||
if (record.status === 'success') {
|
if (record.status === 'success') {
|
||||||
verifyToolResult(record.name, record.arguments, record.result!);
|
verifyToolResult(record.name, record.arguments, record.result!);
|
||||||
}
|
}
|
||||||
// ── v0.12.0: 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! });
|
||||||
if (record.status === 'success') {
|
if (record.status === 'success') {
|
||||||
callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!);
|
callbacks.onToolCallResult(record.name, record.result!, batch.find(c => c.function.name === record.name)!);
|
||||||
@@ -1350,7 +1357,7 @@ async function handleObserving(
|
|||||||
// 记录本轮迭代度量
|
// 记录本轮迭代度量
|
||||||
recordIteration(ctx);
|
recordIteration(ctx);
|
||||||
|
|
||||||
// ── v0.12.0: post_iteration Hook ──
|
// ── post_iteration Hook ──
|
||||||
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
|
executeHooks('post_iteration', ctx, { iterationIndex: ctx.loopCount });
|
||||||
|
|
||||||
// 增量工具结果截断 — 超过 10 轮的旧结果每 3 轮截断到 500 字符
|
// 增量工具结果截断 — 超过 10 轮的旧结果每 3 轮截断到 500 字符
|
||||||
@@ -1364,7 +1371,7 @@ async function handleObserving(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v0.12.1: 进度锚定 — 每 5 轮注入机器生成的工具调用摘要 ──
|
// ── 进度锚定 — 每 5 轮注入机器生成的工具调用摘要 ──
|
||||||
if (ctx.loopCount > 1 && ctx.loopCount % 5 === 0 && ctx.allToolRecords.length > 0) {
|
if (ctx.loopCount > 1 && ctx.loopCount % 5 === 0 && ctx.allToolRecords.length > 0) {
|
||||||
const recentRecords = ctx.allToolRecords.slice(-10);
|
const recentRecords = ctx.allToolRecords.slice(-10);
|
||||||
const summary = recentRecords.map(r => {
|
const summary = recentRecords.map(r => {
|
||||||
@@ -1380,7 +1387,7 @@ async function handleObserving(
|
|||||||
logInfo('进度锚点已注入', `第 ${ctx.loopCount} 轮, ${recentRecords.length} 条记录`);
|
logInfo('进度锚点已注入', `第 ${ctx.loopCount} 轮, ${recentRecords.length} 条记录`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v0.12.1: 中途幻觉检测 — 每轮检查模型是否在无工具调用时声称完成了操作 ──
|
// ── 中途幻觉检测 — 每轮检查模型是否在无工具调用时声称完成了操作 ──
|
||||||
const lastAssistantMsg = [...ctx.messages].reverse().find(m => m.role === 'assistant');
|
const lastAssistantMsg = [...ctx.messages].reverse().find(m => m.role === 'assistant');
|
||||||
if (lastAssistantMsg?.content) {
|
if (lastAssistantMsg?.content) {
|
||||||
const midHallucination = detectMidTaskHallucination(lastAssistantMsg.content, ctx.allToolRecords);
|
const midHallucination = detectMidTaskHallucination(lastAssistantMsg.content, ctx.allToolRecords);
|
||||||
@@ -1395,13 +1402,21 @@ async function handleObserving(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 每 10 轮清理累积的 ephemeral 消息
|
// 每 10 轮清理累积的 ephemeral 消息
|
||||||
|
// 但如果上下文使用率已过高(>70%),跳过清理——压缩即将触发,
|
||||||
|
// 此时清理会导致压缩 LLM 看不到 Plan Mode 进度等关键状态信息
|
||||||
if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) {
|
if (ctx.loopCount > 1 && ctx.loopCount % 10 === 0) {
|
||||||
let removed = 0;
|
const numCtx = state.get<number>(KEYS.NUM_CTX, 24576);
|
||||||
ctx.messages = ctx.messages.filter(m => {
|
const usageRatio = numCtx > 0 ? estimateTokens(ctx.messages.map(m => m.content || '').join('')) / numCtx : 0;
|
||||||
if (m.ephemeral) { removed++; return false; }
|
if (usageRatio > 0.7) {
|
||||||
return true;
|
logInfo(`ephemeral 清理跳过: 上下文使用率 ${(usageRatio * 100).toFixed(0)}%, 压缩即将触发`);
|
||||||
});
|
} else {
|
||||||
if (removed > 0) logInfo(`ephemeral 清理: ${removed} 条临时消息已移除`);
|
let removed = 0;
|
||||||
|
ctx.messages = ctx.messages.filter(m => {
|
||||||
|
if (m.ephemeral) { removed++; return false; }
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (removed > 0) logInfo(`ephemeral 清理: ${removed} 条临时消息已移除`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transition(ctx, S.REFLECTING);
|
transition(ctx, S.REFLECTING);
|
||||||
@@ -1639,7 +1654,7 @@ export async function runAgentLoop(
|
|||||||
|
|
||||||
// Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop
|
// Phase 2-7: THINKING → PARSING → EXECUTING → OBSERVING → REFLECTING → (COMPRESSING) → loop
|
||||||
while (ctx.state !== S.TERMINATED) {
|
while (ctx.state !== S.TERMINATED) {
|
||||||
// ── v0.12.1: 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)──
|
// ── 看门狗 — 全局超时熔断(可通过设置 loopWatchdogMs 配置,0=禁用)──
|
||||||
// 默认 30 分钟,用户强调不要随意加超时限制
|
// 默认 30 分钟,用户强调不要随意加超时限制
|
||||||
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 1_800_000);
|
const WATCHDOG_MS = state.get<number>('loopWatchdogMs', 1_800_000);
|
||||||
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
|
if (WATCHDOG_MS > 0 && Date.now() - ctx.startTime > WATCHDOG_MS) {
|
||||||
@@ -1654,7 +1669,7 @@ export async function runAgentLoop(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── v0.12.1: 上下文硬上限 — 消息数超阈值强制压缩 ──
|
// ── 上下文硬上限 — 消息数超阈值强制压缩 ──
|
||||||
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);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Agent Metrics — 治理与度量模块 (v0.12.0)
|
* Agent Metrics — 治理与度量模块
|
||||||
* Harness Engineering Phase 8: 度量驱动的优化循环
|
* Harness Engineering: 度量驱动的优化循环
|
||||||
*
|
*
|
||||||
* 功能:
|
* 功能:
|
||||||
* - 会话级度量采集(迭代效率、工具成功率、完成质量)
|
* - 会话级度量采集(迭代效率、工具成功率、完成质量)
|
||||||
@@ -232,7 +232,7 @@ export function generateImprovementSuggestions(): ImprovementSuggestion[] {
|
|||||||
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
|
export function formatSuggestionsAsRules(suggestions: ImprovementSuggestion[]): string {
|
||||||
if (suggestions.length === 0) return '';
|
if (suggestions.length === 0) return '';
|
||||||
|
|
||||||
let rules = '\n\n## 自动生成的改进规则 (v0.12.0)\n';
|
let rules = '\n\n## 自动生成的改进规则\n';
|
||||||
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
|
rules += '> 以下规则由 Agent Metrics 系统根据历史错误模式自动生成\n\n';
|
||||||
|
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Completion Gate — 完成门控模块 (v0.12.0)
|
* Completion Gate — 完成门控模块
|
||||||
* Harness Engineering Phase 3: 在 Agent 输出最终答案前进行结构化检查
|
* Harness Engineering: 在 Agent 输出最终答案前进行结构化检查
|
||||||
*
|
*
|
||||||
* 设计理念:
|
* 设计理念:
|
||||||
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
|
* - 计算性反馈优先(规则驱动、毫秒级、100% 可靠)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Context Indexer — 渐进式披露模块 (v0.12.0)
|
* Context Indexer — 渐进式披露模块
|
||||||
* Harness Engineering Phase 6: 三级上下文管理
|
* Harness Engineering: 三级上下文管理
|
||||||
*
|
*
|
||||||
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
|
* 索引层 (Index) — 始终保留:项目结构树 + 入口文件地图 + 技术栈摘要
|
||||||
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
|
* 接口层 (Interface) — 按需加载:模块 API 声明 + 类型定义 + 配置文件
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Harness Hook 系统 (v0.12.0)
|
* Harness Hook 系统
|
||||||
* Harness Engineering Phase 4: 可扩展的 Agent 执行钩子架构
|
* Harness Engineering: 可扩展的 Agent 执行钩子架构
|
||||||
*
|
*
|
||||||
* 钩子阶段:
|
* 钩子阶段:
|
||||||
* - pre_tool: 工具执行前(安全检查、缓存检查、权限验证)
|
* - pre_tool: 工具执行前(安全检查、缓存检查、权限验证)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Verification System — 验证系统模块 (v0.12.0)
|
* Verification System — 验证系统模块
|
||||||
* Harness Engineering Phase 5: 计算性反馈管道
|
* Harness Engineering: 计算性反馈管道
|
||||||
*
|
*
|
||||||
* 在 Agent 修改文件后自动运行:
|
* 在 Agent 修改文件后自动运行:
|
||||||
* - Linter(代码风格检查)
|
* - Linter(代码风格检查)
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ html, body {
|
|||||||
background: var(--critical-bg);
|
background: var(--critical-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Plan 按钮 (v0.12.0) ── */
|
/* ── Plan 按钮 ── */
|
||||||
.plan-toggle {
|
.plan-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -395,7 +395,7 @@ export interface ToolCallRecord {
|
|||||||
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
|
export type AgentState = 'idle' | 'sending' | 'accumulating' | 'executing' | 'confirming' | 'done';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Harness Engineering: Agent Loop 状态机 (v0.12.0)
|
// Harness Engineering: Agent Loop 状态机
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/** Agent Loop 状态枚举 */
|
/** Agent Loop 状态枚举 */
|
||||||
|
|||||||
Reference in New Issue
Block a user