feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m38s
CI / 产物编译验证 (push) Successful in 10m15s
CI / 全量测试 (Electron ABI) (push) Failing after 5m27s

P0 安全与工程基线(止血):
- .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401)
- 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web),
  pre-commit 与 CI 门禁恢复拦截能力
- 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、
  agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型
- 补装 v0.4.1 声明但未安装的 node-html-parser 依赖

P1 逻辑缺陷修复(跨模块边界):
- ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离,
  abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染)
- SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入
  ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区)
  ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件)
- main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起)

P2 工程强化:
- CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁;
  GITEA_NPM_AUTH secret 注入说明
- 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons
  (业务代码变更不再使 vendor 缓存失效)
- database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建)

P3 功能补全:
- DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance)
- FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 +
  存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索
  (短语转义防 FTS 运算符注入,按会话聚合展示 snippet)
- 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮

文档一致性大扫除:
- README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、
  删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、
  项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、
  新增 GITEA_NPM_AUTH 配置说明、测试数 207
- 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、
  磁盘文件 2 个现状注记、ipc/*.ts 路径)
- eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型

测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例)
验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
This commit is contained in:
2026-08-21 21:07:01 +08:00
parent 49c9b25538
commit 7e8b4882a0
32 changed files with 3031 additions and 579 deletions
+16 -6
View File
@@ -1,13 +1,20 @@
# MetonaAI Desktop — Gitea Actions CI # MetonaAI Desktop — Gitea Actions CI
# #
# 质量门禁:类型检查 + Lint + 单元测试 + 产物编译验证 # 质量门禁:类型检查 + Lint + 单元测试 + 产物编译验证
# 触发:pushmain/dev 分支)与 Pull Request # 触发:pushmain/dev/master 分支)与 Pull Request
#
# v0.5.0 强化:
# - typecheck 使用双工程真检查(tsconfig.node.json + tsconfig.web.json),
# 修复此前 solution-style 根配置导致 `tsc --noEmit` 空操作、类型错误畅通提交的缺陷
# - electron-test job 从 experimentalcontinue-on-error)转正为阻塞门禁
# - 私有 npm 凭据通过 GITEA_NPM_AUTH secret 注入(需在仓库
# Settings → Actions → Secrets 配置,值为 base64("用户名:密码")
# #
# 说明: # 说明:
# - 主测试使用系统 Nodenpm test)——better-sqlite3 原生模块按系统 Node ABI 编译, # - 主测试使用系统 Nodenpm test)——better-sqlite3 原生模块按系统 Node ABI 编译,
# audit 套件在 ABI 不匹配时自动跳过(skipIf),保证 CI 稳定绿 # audit 套件在 ABI 不匹配时自动跳过(skipIf),保证 CI 稳定绿
# - electron-test job 尝试按 Electron ABI 重建 better-sqlite3 后跑全量测试(含 audit 链式哈希), # - electron-test job 按 Electron ABI 重建 better-sqlite3 后跑全量测试
# 该 job 标记为 experimentalcontinue-on-error),失败不阻塞合并 # (含 audit 链式哈希 + 会话摘要分层),作为阻塞门禁
# - build 验证只跑 electron-vite build(产物编译),不跑 electron-builder 打包—— # - build 验证只跑 electron-vite build(产物编译),不跑 electron-builder 打包——
# NSIS 目标需要 wineLinux runner 上不可用 # NSIS 目标需要 wineLinux runner 上不可用
@@ -22,6 +29,8 @@ on:
env: env:
# Electron 二进制国内镜像(与 .npmrc 注释保持一致,加速 runner 下载) # Electron 二进制国内镜像(与 .npmrc 注释保持一致,加速 runner 下载)
ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/ ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/
# 私有 npm 仓库认证(@metona-team/metona-toast)— 从仓库 Secrets 注入
GITEA_NPM_AUTH: ${{ secrets.GITEA_NPM_AUTH }}
jobs: jobs:
quality: quality:
@@ -41,7 +50,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Typecheck # v0.5.0: 双工程真检查(node + web),修复此前 solution-style 空操作缺陷
- name: Typecheck (node + web)
run: npm run typecheck run: npm run typecheck
- name: Lint - name: Lint
@@ -51,10 +61,9 @@ jobs:
run: npm test run: npm test
electron-test: electron-test:
name: 全量测试 (Electron ABI, experimental) name: 全量测试 (Electron ABI)
runs-on: debian-latest runs-on: debian-latest
timeout-minutes: 25 timeout-minutes: 25
continue-on-error: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -71,6 +80,7 @@ jobs:
- name: Rebuild better-sqlite3 for Electron ABI - name: Rebuild better-sqlite3 for Electron ABI
run: npx @electron/rebuild -f -w better-sqlite3 run: npx @electron/rebuild -f -w better-sqlite3
# v0.5.0: 从 experimental 转正 — 含 SQLite 审计链哈希与摘要分层的全量用例必须通过
- name: Full tests (Electron Node ABI) - name: Full tests (Electron Node ABI)
run: npm run test:electron run: npm run test:electron
+6 -2
View File
@@ -1,7 +1,11 @@
registry=https://registry.npmmirror.com registry=https://registry.npmmirror.com
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/ @metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=dGh6eHg6Mjc5MjQ2MDE5MGJkcW4=
//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true # 私有 npm 仓库认证 — 凭据通过环境变量注入,禁止硬编码到仓库。
# 值为 base64("用户名:密码"),在终端设置后再执行 npm install
# PowerShell: $env:GITEA_NPM_AUTH = "<base64 凭据>"
# CI (Gitea Actions): 在仓库 Settings → Secrets 配置 GITEA_NPM_AUTH
//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=${GITEA_NPM_AUTH}
# 允许包的 postinstall 脚本(electron 需要下载二进制文件) # 允许包的 postinstall 脚本(electron 需要下载二进制文件)
ignore-scripts=false ignore-scripts=false
+91 -67
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.4.1-blue?style=flat-square" alt="Version" /> <img src="https://img.shields.io/badge/version-0.5.0-blue?style=flat-square" alt="Version" />
<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" />
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" /> <img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" /> <img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
@@ -29,7 +29,7 @@
--- ---
<p align="center"> <p align="center">
Metona 是一款<strong>基于 Electron 的本地优先 AI Agent 桌面应用</strong>,内置<strong> ReAct 状态机驱动</strong>的智能体循环引擎、<strong>26 个内置工具</strong>、<strong>三层记忆系统</strong>、<strong>四层纵深安全防线</strong>与<strong>完整可观测性链路</strong>。支持六种 LLM ProviderDeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic),兼容 <strong>MCP 协议</strong>扩展,为开发者提供开箱即用的 AI 编程伙伴。 Metona 是一款<strong>基于 Electron 的本地优先 AI Agent 桌面应用</strong>,内置<strong> ReAct 状态机驱动</strong>的智能体循环引擎、<strong>28 个内置工具</strong>、<strong>三层记忆系统</strong>、<strong>四层纵深安全防线</strong>与<strong>完整可观测性链路</strong>。支持六种 LLM ProviderDeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic),兼容 <strong>MCP 协议</strong>扩展stdio / SSE / Streamable HTTP,为开发者提供开箱即用的 AI 编程伙伴。
</p> </p>
--- ---
@@ -63,7 +63,7 @@
<p>ReAct 八状态闭环、流式对话 (SSE/NDJSON)、Thinking 推理模式、死循环检测、上下文自动压缩、指数退避重试</p> <p>ReAct 八状态闭环、流式对话 (SSE/NDJSON)、Thinking 推理模式、死循环检测、上下文自动压缩、指数退避重试</p>
</td> </td>
<td width="50%"> <td width="50%">
<h3>🔧 26 个内置工具</h3> <h3>🔧 28 个内置工具</h3>
<p>文件系统 · 代码搜索 · 网络搜索 · 浏览器自动化 · Git · Shell 命令 · HTTP 请求 · 记忆存储 · 任务管理</p> <p>文件系统 · 代码搜索 · 网络搜索 · 浏览器自动化 · Git · Shell 命令 · HTTP 请求 · 记忆存储 · 任务管理</p>
</td> </td>
</tr> </tr>
@@ -84,7 +84,7 @@
</td> </td>
<td> <td>
<h3>📡 MCP 协议兼容</h3> <h3>📡 MCP 协议兼容</h3>
<p>支持 stdio / SSE 种传输方式的 MCP Server,动态发现工具,无需重启应用</p> <p>支持 stdio / SSE / Streamable HTTP 三种传输方式的 MCP Server,动态发现工具,无需重启应用</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -115,6 +115,7 @@
| 🛰 MCP | **@modelcontextprotocol/sdk** | 1.12 | 外部工具协议集成 | | 🛰 MCP | **@modelcontextprotocol/sdk** | 1.12 | 外部工具协议集成 |
| 🎨 样式 | **Tailwind CSS** | 4 | 辅助原子化样式 | | 🎨 样式 | **Tailwind CSS** | 4 | 辅助原子化样式 |
| 📝 Markdown | **react-markdown + remark-gfm** | 10 / 4 | 富文本渲染 | | 📝 Markdown | **react-markdown + remark-gfm** | 10 / 4 | 富文本渲染 |
| 🌐 HTML 解析 | **node-html-parser** | 6 | 搜索引擎结果结构化解析 |
| 🔢 UUID | **nanoid** | 5 | 唯一 ID 生成 | | 🔢 UUID | **nanoid** | 5 | 唯一 ID 生成 |
| ✅ 校验 | **Zod** | 3 | 运行时类型校验 | | ✅ 校验 | **Zod** | 3 | 运行时类型校验 |
| 💾 缓存 | **lru-cache** | 11 | 内存缓存 | | 💾 缓存 | **lru-cache** | 11 | 内存缓存 |
@@ -135,20 +136,25 @@
```bash ```bash
# 1. 克隆仓库 # 1. 克隆仓库
git clone https://github.com/your-org/metona-ai-desktop.git git clone https://git.metona.cn/MetonaTeam/metona-ai-desktop.git
cd metona-ai-desktop cd metona-ai-desktop
# 2. 安装依赖 # 2. 配置私有 npm 仓库凭据(@metona-team/metona-toast 需要)
# 值为 base64("用户名:密码"),向仓库管理员申请
$env:GITEA_NPM_AUTH = "<你的 base64 凭据>" # PowerShell
# export GITEA_NPM_AUTH="<你的 base64 凭据>" # bash
# 3. 安装依赖
npm install npm install
# 3. 配置 API Key(可选——推荐启动后在应用内「设置 → LLM 配置」可视化配置) # 4. 配置 API Key(可选——推荐启动后在应用内「设置 → LLM 配置」可视化配置)
cp .env.example .env cp .env.example .env
# 编辑 .env 预置密钥(应用内未配置时自动回退读取,见下方「环境变量」说明) # 编辑 .env 预置密钥(应用内未配置时自动回退读取,见下方「环境变量」说明)
# 4. 启动开发模式 # 5. 启动开发模式
npm run dev npm run dev
# 5. 构建生产包 # 6. 构建生产包
npm run build npm run build
``` ```
@@ -234,7 +240,7 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎
| 🔄 **流式对话** | SSE (DeepSeek/Agnes/MiMo) + NDJSON (Ollama) 双协议流式响应 | | 🔄 **流式对话** | SSE (DeepSeek/Agnes/MiMo) + NDJSON (Ollama) 双协议流式响应 |
| 💭 **Thinking 模式** | 支持 deepseek-v4-pro / agnes-2.0-flash / qwen3 等推理模型的思维链展示 | | 💭 **Thinking 模式** | 支持 deepseek-v4-pro / agnes-2.0-flash / qwen3 等推理模型的思维链展示 |
| 🔁 **死循环检测** | 连续 3 轮相同工具调用签名自动终止 | | 🔁 **死循环检测** | 连续 3 轮相同工具调用签名自动终止 |
| 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,保留最近 10 条消息 | | 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,按 token 预算动态保留近期消息(超长 tool_result 二次截断) |
| 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s | | 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s |
| ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) | | ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) |
| 🧵 **子任务委派** | TaskOrchestrator 支持最大 3 层深度的 SubAgent 编排 | | 🧵 **子任务委派** | TaskOrchestrator 支持最大 3 层深度的 SubAgent 编排 |
@@ -245,26 +251,29 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎
### 工具分类总览 ### 工具分类总览
Metona 内置 **26 个工具**,按安全风险分为五个等级: Metona 内置 **28 个工具**,按安全风险分为五个等级:
``` ```
SAFE (无需确认) LOW (无需确认) MEDIUM (可配自动) HIGH (强制确认) CRITICAL (双人复核) SAFE (14 个) LOW (5 个) MEDIUM (6 个) HIGH (3 个) CRITICAL (预留)
│ │ │ │ │ │ │ │ │ │
├─ read_file ├─ web_search ├─ write_file ├─ delete_file ├─ (预留) ├─ read_file ├─ web_search ├─ write_file* ├─ delete_file ├─ (预留)
├─ list_directory ├─ web_fetch ├─ file_editor ├─ run_command ├─ list_directory ├─ web_fetch ├─ file_editor* ├─ run_command
├─ search_files ├─ http_request ├─ memory_store ─ web_browser ├─ search_files ├─ http_request ├─ file_move* ─ web_browser
├─ code_search ├─ run_tests ├─ delegate_task ├─ git_commit ├─ code_search ├─ run_tests ├─ git_commit*
├─ diff_viewer ─ task_manager ├─ file_move └─ browser actions ├─ diff_viewer ─ task_manager ├─ memory_store
├─ git_status └─ think (open/navigate) ├─ git_status └─ delegate_task
├─ git_diff ├─ git_diff
├─ git_log ├─ git_log
├─ lint_code ├─ lint_code
├─ project_info ├─ project_info
├─ memory_search ├─ memory_search
├─ view_image ├─ view_image
─ file_info ─ file_info
└─ think
``` ```
> `*` 标记的 MEDIUM 工具设置了 `requiresPermission: true`(执行前需用户确认);未标记的 MEDIUM 工具(memory_store / delegate_task)无需确认。
### 详细工具列表 ### 详细工具列表
#### 📂 文件系统 (7 tools) #### 📂 文件系统 (7 tools)
@@ -319,7 +328,7 @@ SAFE (无需确认) LOW (无需确认) MEDIUM (可配自动)
|:---|:---|:---|:---| |:---|:---|:---|:---|
| `git_status` | SAFE | 否 | 工作树状态,porcelain v1 格式解析 | | `git_status` | SAFE | 否 | 工作树状态,porcelain v1 格式解析 |
| `git_diff` | SAFE | 否 | 差异输出,50KB 截断,5MB maxBuffer | | `git_diff` | SAFE | 否 | 差异输出,50KB 截断,5MB maxBuffer |
| `git_log` | SAFE | 否 | 提交历史,支持 --oneline / --stat 等格式 | | `git_log` | SAFE | 否 | 提交历史,--oneline 紧凑格式或 NULL 分隔的完整字段格式 |
| `git_commit` | MEDIUM | 是 | 暂存 + 提交,校验文件在工作空间内 | | `git_commit` | MEDIUM | 是 | 暂存 + 提交,校验文件在工作空间内 |
#### 📋 任务与辅助 (4 tools) #### 📋 任务与辅助 (4 tools)
@@ -343,6 +352,8 @@ Metona 通过统一的 `IMetonaProviderAdapter` 接口抽象了所有 LLM Provid
| **AgnesAdapter** | `agnes` | agnes-2.0-flash | 1M tokens | SSE | `chat_template_kwargs` / Anthropic 兼容 | 是 (URL + Base64) | | **AgnesAdapter** | `agnes` | agnes-2.0-flash | 1M tokens | SSE | `chat_template_kwargs` / Anthropic 兼容 | 是 (URL + Base64) |
| **MimoAdapter** | `mimo` | mimo-v2.5-pro / mimo-v2.5 | 1M tokens | SSE | `thinking.type: enabled` | 是 (URL + Base64) | | **MimoAdapter** | `mimo` | mimo-v2.5-pro / mimo-v2.5 | 1M tokens | SSE | `thinking.type: enabled` | 是 (URL + Base64) |
| **OllamaAdapter** | `ollama` | qwen3 / gemma3 / deepseek-r1 等 | 可配 (num_ctx) | NDJSON | `think` 参数 | 是 (Base64) | | **OllamaAdapter** | `ollama` | qwen3 / gemma3 / deepseek-r1 等 | 可配 (num_ctx) | NDJSON | `think` 参数 | 是 (Base64) |
| **OpenAIAdapter** | `openai` | gpt-4o / gpt-4.1 / o3-mini | 128K~1M tokens | SSE | `reasoning_effort`(o 系列) | 是(o 系列除外) |
| **AnthropicAdapter** | `anthropic` | claude-sonnet-4-5 / claude-opus-4-1 / claude-haiku-4-5 | 200K tokens | SSE(原生事件) | `thinking.budget_tokens` | 是 (Base64) |
### 适配器核心能力 ### 适配器核心能力
@@ -484,14 +495,14 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰:
| 进程 | 运行时 | 职责 | | 进程 | 运行时 | 职责 |
|:---|:---|:---| |:---|:---|:---|
| **Main Process** | Node.js | Agent 引擎、工具调度、SQLite 数据库、MCP 管理、配置服务 | | **Main Process** | Node.js | Agent 引擎、工具调度、SQLite 数据库、MCP 管理、配置服务 |
| **Preload Script** | 沙箱 | `contextBridge` 安全暴露 `window.metona` (15 个 API 命名空间) | | **Preload Script** | 沙箱 | `contextBridge` 安全暴露 `window.metona` (15 个 API 命名空间v0.5.0 新增 llm) |
| **Renderer** | Chromium | React 19 + MUI 9 界面渲染,Zustand 状态管理 | | **Renderer** | Chromium | React 19 + MUI 9 界面渲染,Zustand 状态管理 |
### 核心数据流 ### 核心数据流
``` ```
用户输入 → ChatInput → agent-store.sendMessage 用户输入 → ChatInput → agent-store.sendMessage
→ IPC: agent:sendMessage → handlers.ts → IPC: agent:sendMessage → ipc/agent.ts
→ reloadAdapter (配置签名比对,热重载) → reloadAdapter (配置签名比对,热重载)
→ 保存用户消息到 SQLite messages 表 → 保存用户消息到 SQLite messages 表
→ 加载历史消息 + 注入相关记忆 (MemoryManager.search) → 加载历史消息 + 注入相关记忆 (MemoryManager.search)
@@ -519,29 +530,31 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰:
- **TraceViewer**:按 `runId` 分组可视化展示每轮 ReAct 迭代,包括思考过程、工具调用参数/结果/耗时、状态转换链 - **TraceViewer**:按 `runId` 分组可视化展示每轮 ReAct 迭代,包括思考过程、工具调用参数/结果/耗时、状态转换链
- **Token 用量面板**:输入/输出 Token、当前上下文占用百分比 (按 60%/80% 阈值变色)、压缩节省的 Token - **Token 用量面板**:输入/输出 Token、当前上下文占用百分比 (按 60%/80% 阈值变色)、压缩节省的 Token
- **SubAgent 状态区**v0.5.0):AgentMonitor 实时展示 delegate_task 委派的子任务生命周期(委派/运行/完成/失败、层级深度、耗时、迭代轮数)
### 会话录制 ### 会话录制
- **9 种事件类型**session_start, user_message, assistant_message, tool_call, tool_result, state_change, context_compressed, error, session_end - **9 种事件类型**session_start, context_built, iteration_start, llm_request, llm_response, tool_call, tool_result, iteration_end, session_end
- 录制到 JSONL 文件,支持回放重放 - 录制到 JSONL 文件`{workspace}/logs/session_*.jsonl`),支持事后回放分析
- 录制格式:每行一条结构化 JSON 事件 - v0.5.0: SubAgentdelegate_task)的执行轨迹录制到独立 JSONL 文件(taskId 作为会话标识)
### 审计日志 ### 审计日志
- **链式哈希防篡改**:每条记录哈希 = SHA-256(prev_hash + 记录内容)INSERT-ONLY 触发器 - **链式哈希防篡改**:每条记录哈希 = SHA-256(prev_hash + 记录内容)INSERT-ONLY 触发器
- **完整性校验**`verifyChain()` 逐条验证哈希链 - **完整性校验**`verifyChain()` 逐条验证哈希链
- **导出**:支持只读 JSONL (chmod 0o444)、CSV、归档 (90 天保留) - **导出**:支持 JSONL / CSV 两种格式导出(设置 → 日志与数据)
### SLO 监控 ### SLO 监控
| 指标 | 目标 | SLOMonitor 在 5 分钟滑动窗口内实时统计 Agent 请求质量,燃烧速率超预算时记录告警日志:
| 监控项 | 说明 |
|:---|:---| |:---|:---|
| Agent Loop 成功率 | 95% | | 请求成功率 | 按 complete 事件的 terminationReason 统计错误率 |
| P99 延迟 | < 60s (95%) | | 延迟分布 | P50 / P95 / P99 分位数 + 平均延迟 |
| 工具调用成功率 | 98% | | 吞吐量 | 每秒请求数 |
| 死循环发生率 | < 0.1% (99.9%) | | 燃烧速率 | 实际错误率 / 错误预算(目标 99.9%),>1 时告警 |
| MCP Server 可用性 | 99% | | 健康检查 | 每 60s 检查数据库连通性、系统可用内存、主进程堆内存 |
| 应用崩溃率 | < 0.1% (99.9%) |
--- ---
@@ -553,7 +566,7 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰:
┌──────────┬──────────────────────────────┬──────────────┐ ┌──────────┬──────────────────────────────┬──────────────┐
│ │ Header 顶部栏 │ │ │ │ Header 顶部栏 │ │
│ Sidebar │ Provider · Model · 面板切换 │ DetailPanel │ │ Sidebar │ Provider · Model · 面板切换 │ DetailPanel │
260px ├──────────────────────────────┤ 360px │ 300px ├──────────────────────────────┤ 360px │
│ │ │ │ │ │ │ │
│ 会话列表 │ ChatPanel 聊天区域 │ Trace View │ │ 会话列表 │ ChatPanel 聊天区域 │ Trace View │
│ 搜索 │ │ Memory │ │ 搜索 │ │ Memory │
@@ -627,18 +640,20 @@ OLLAMA_BASE_URL=http://localhost:11434
| 配置项 | 默认值 | 说明 | | 配置项 | 默认值 | 说明 |
|:---|:---|:---| |:---|:---|:---|
| `llm.provider` | `deepseek` | LLM Provider ID | | `llm.provider` | (空) | LLM Provider ID(未配置时回退 .env |
| `llm.model` | `deepseek-v4-pro` | 模型标识符 | | `llm.model` | (空) | 模型标识符 |
| `agent.maxIterations` | `20` | ReAct 最大迭代轮次 | | `agent.maxIterations` | `20` | ReAct 最大迭代轮次 |
| `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) | | `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) |
| `agent.toolTimeoutMs` | `120000` | 单个工具执行超时 (ms) | | `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) |
| `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 | | `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 |
| `agent.thinkingEffort` | `high` | 推理强度 (high / max) | | `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) |
| `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) | | `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) |
| `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 | | `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 |
| `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 | | `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 |
| `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 | | `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 |
| `ollama.numCtx` | `4096` | Ollama num_ctx 参数 | | `openai.contextWindow` | `128000` | OpenAI 上下文窗口 |
| `anthropic.contextWindow` | `200000` | Anthropic 上下文窗口 |
| `ollama.numCtx` | (空) | Ollama num_ctx 参数(空 = 由模型决定) |
### SearXNG 元搜索引擎 (可选) ### SearXNG 元搜索引擎 (可选)
@@ -658,11 +673,13 @@ MetonaAI-Desktop/
│ ├── 📄 main.ts # 应用入口 │ ├── 📄 main.ts # 应用入口
│ ├── 📄 preload.ts # contextBridge 安全桥接 (15 个 API) │ ├── 📄 preload.ts # contextBridge 安全桥接 (15 个 API)
│ ├── 📂 ipc/ # IPC 域模块 (P2 拆分, 50+ 通道) │ ├── 📂 ipc/ # IPC 域模块 (P2 拆分, 50+ 通道)
│ │ ├── 📄 index.ts # 统一注册入口 │ │ ├── 📄 index.ts # 统一注册入口(防重入)
│ │ ├── 📄 agent.ts # Agent 消息/中断/常驻事件管道 │ │ ├── 📄 context.ts # 共享上下文 + broadcast 多窗口广播
│ │ ├── 📄 sessions.ts # 会话 CRUD │ │ ├── 📄 shared.ts # 配置写入共享副作用
│ │ ├── 📄 config.ts # 配置读写 (共享副作用) │ │ ├── 📄 agent.ts # Agent 消息/中断/常驻事件管道/SubAgent 广播与录制
│ │ ├── 📄 tools.ts # 工具列表/确认 │ │ ├── 📄 sessions.ts # 会话 CRUD/消息截断/Trace 持久化
│ │ ├── 📄 config.ts # 配置读写 (get/set/setBatch)
│ │ ├── 📄 tools.ts # 工具列表/确认/自动执行
│ │ └── 📄 ... (mcp/memory/tasks/data/workspace/app) │ │ └── 📄 ... (mcp/memory/tasks/data/workspace/app)
│ ├── 📂 services/ # 业务服务层 (12 个 Service) │ ├── 📂 services/ # 业务服务层 (12 个 Service)
│ │ ├── 📄 agent-engine-manager.service.ts # 每会话独立引擎管理 (P2) │ │ ├── 📄 agent-engine-manager.service.ts # 每会话独立引擎管理 (P2)
@@ -692,7 +709,7 @@ MetonaAI-Desktop/
│ │ │ └── 📂 shared/ # 共享: OpenAI 格式 · SSE 解析 │ │ │ └── 📂 shared/ # 共享: OpenAI 格式 · SSE 解析
│ │ ├── 📂 tools/ # 工具系统 │ │ ├── 📂 tools/ # 工具系统
│ │ │ ├── 📄 registry.ts # 工具注册 · PolicyEngine · 超时管理 │ │ │ ├── 📄 registry.ts # 工具注册 · PolicyEngine · 超时管理
│ │ │ └── 📂 built-in/ # 26 个内置工具实现 │ │ │ └── 📂 built-in/ # 28 个内置工具实现
│ │ │ ├── 📄 filesystem.ts # 文件系统 (7 tools) │ │ │ ├── 📄 filesystem.ts # 文件系统 (7 tools)
│ │ │ ├── 📄 file-editor.ts # 精准编辑 │ │ │ ├── 📄 file-editor.ts # 精准编辑
│ │ │ ├── 📄 file-guard.ts # 路径安全共享工具 │ │ │ ├── 📄 file-guard.ts # 路径安全共享工具
@@ -739,16 +756,16 @@ MetonaAI-Desktop/
│ │ └── 📄 token-estimator.ts # Token 估算 (CJK/ASCII) │ │ └── 📄 token-estimator.ts # Token 估算 (CJK/ASCII)
│ └── 📂 utils/ │ └── 📂 utils/
│ └── 📄 slo.ts # 健康检查 · SLO 监控 │ └── 📄 slo.ts # 健康检查 · SLO 监控
├── 📂 src/ # React 渲染进程 (~42 文件) ├── 📂 src/ # React 渲染进程 (~50 文件)
│ ├── 📄 main.tsx # 渲染进程入口 │ ├── 📄 main.tsx # 渲染进程入口
│ ├── 📄 App.tsx # 根组件 (三栏布局 + Store 连接) │ ├── 📄 App.tsx # 根组件 (三栏布局 + Store 连接)
│ ├── 📂 components/ │ ├── 📂 components/
│ │ ├── 📂 chat/ # 聊天组件 (11 个) │ │ ├── 📂 chat/ # 聊天组件 (11 个)
│ │ │ ├── 📄 ChatPanel.tsx # 聊天主面板 │ │ │ ├── 📄 ChatPanel.tsx # 聊天主面板
│ │ │ ├── 📄 MessageList.tsx # 消息列表 (content-visibility 虚拟滚动) │ │ │ ├── 📄 MessageList.tsx # 消息列表 (react-virtuoso 真虚拟滚动)
│ │ │ ├── 📄 MessageItem.tsx # 消息路由 (memo) │ │ │ ├── 📄 MessageItem.tsx # 消息路由 (memo)
│ │ │ ├── 📄 AssistantMessage.tsx # Agent 回复 (Markdown + 代码高亮) │ │ │ ├── 📄 AssistantMessage.tsx # Agent 回复 (Markdown + 代码高亮)
│ │ │ ├── 📄 UserMessage.tsx # 用户消息 (附件 + 双击编辑) │ │ │ ├── 📄 UserMessage.tsx # 用户消息 (附件 + 双击编辑重发)
│ │ │ ├── 📄 SystemMessage.tsx # 系统通知 │ │ │ ├── 📄 SystemMessage.tsx # 系统通知
│ │ │ ├── 📄 ChatInput.tsx # 输入框 (附件 + / 命令) │ │ │ ├── 📄 ChatInput.tsx # 输入框 (附件 + / 命令)
│ │ │ ├── 📄 ThoughtBlock.tsx # 思考过程 (可折叠) │ │ │ ├── 📄 ThoughtBlock.tsx # 思考过程 (可折叠)
@@ -757,18 +774,20 @@ MetonaAI-Desktop/
│ │ │ └── 📄 StreamingIndicator.tsx # 流式加载指示器 │ │ │ └── 📄 StreamingIndicator.tsx # 流式加载指示器
│ │ ├── 📂 layout/ # 布局组件 (5 个) │ │ ├── 📂 layout/ # 布局组件 (5 个)
│ │ │ ├── 📄 Header.tsx # 顶部栏 (Provider · 面板切换) │ │ │ ├── 📄 Header.tsx # 顶部栏 (Provider · 面板切换)
│ │ │ ├── 📄 Sidebar.tsx # 侧边栏 (会话列表 · 工具管理) │ │ │ ├── 📄 Sidebar.tsx # 侧边栏 (会话列表 · 标题/内容搜索 · 工具管理)
│ │ │ ├── 📄 DetailPanel.tsx # 详情面板 (4 Tab) │ │ │ ├── 📄 DetailPanel.tsx # 详情面板 (4 Tab)
│ │ │ ├── 📄 AgentMonitor.tsx # Agent 状态指示器 │ │ │ ├── 📄 AgentMonitor.tsx # Agent 状态指示器 + SubAgent 状态区
│ │ │ └── 📄 StatusBar.tsx # 底部状态栏 │ │ │ └── 📄 StatusBar.tsx # 底部状态栏
│ │ ├── 📂 settings/ │ │ ├── 📂 settings/ # 设置弹窗 (v0.4.1 拆分, 10 文件)
│ │ │ ── 📄 SettingsModal.tsx # 设置弹窗 (8 标签页) │ │ │ ── 📄 SettingsModal.tsx # 主框架 + 垂直 Tab 导航
│ │ │ ├── 📄 useConfig.ts # 共享配置读写 Hook
│ │ │ └── 📄 ... (LLM/Agent/Tools/MCP/SearXNG/Appearance/Logs/Workspace 8 个 Tab)
│ │ ├── 📂 memory/ │ │ ├── 📂 memory/
│ │ │ └── 📄 MemoryViewer.tsx # 记忆浏览器 (3 类型) │ │ │ └── 📄 MemoryViewer.tsx # 记忆浏览器 (3 类型)
│ │ ├── 📂 tasks/ │ │ ├── 📂 tasks/
│ │ │ └── 📄 TaskList.tsx # 任务列表 │ │ │ └── 📄 TaskList.tsx # 任务列表
│ │ ├── 📂 trace/ # 追踪组件 (3 个) │ │ ├── 📂 trace/ # 追踪组件 (3 个)
│ │ │ ├── 📄 TraceViewer.tsx # ReAct 迭代时间轴 │ │ │ ├── 📄 TraceViewer.tsx # ReAct 迭代时间轴 (按 runId 分组)
│ │ │ ├── 📄 TraceStep.tsx # 单步详情 (Thought + ToolCalls) │ │ │ ├── 📄 TraceStep.tsx # 单步详情 (Thought + ToolCalls)
│ │ │ └── 📄 TokenUsage.tsx # Token 统计面板 │ │ │ └── 📄 TokenUsage.tsx # Token 统计面板
│ │ ├── 📂 workspace/ │ │ ├── 📂 workspace/
@@ -776,23 +795,25 @@ MetonaAI-Desktop/
│ │ ├── 📂 onboarding/ │ │ ├── 📂 onboarding/
│ │ │ └── 📄 OnboardingWizard.tsx # 5 步引导向导 │ │ │ └── 📄 OnboardingWizard.tsx # 5 步引导向导
│ │ ├── 📂 common/ │ │ ├── 📂 common/
│ │ │ └── 📄 ErrorBoundary.tsx # 错误边界 │ │ │ └── 📄 ErrorBoundary.tsx # 错误边界 (上报 error:report)
│ │ ├── 📄 CommandPalette.tsx # Cmd+K 命令面板 │ │ ├── 📄 CommandPalette.tsx # Cmd+K 命令面板
│ │ ├── 📄 ConfirmationDialog.tsx # 工具确认弹窗 (批量审批) │ │ ├── 📄 ConfirmationDialog.tsx # 工具确认弹窗 (批量审批 + 拒绝记忆恢复)
│ │ ├── 📄 ContextMenu.tsx # 右键菜单 │ │ ├── 📄 ContextMenu.tsx # 右键菜单 (5 类对象)
│ │ └── 📄 ToastContainer.tsx # Toast 通知 (metona-toast v2) │ │ └── 📄 ToastContainer.tsx # Toast 通知 (metona-toast v2)
│ ├── 📂 hooks/ │ ├── 📂 hooks/
│ │ ├── 📄 useAgentStream.ts # 流式事件监听 (rAF 批处理) │ │ ├── 📄 useAgentStream.ts # 流式事件监听 (rAF 批处理 + runId 过滤)
│ │ ├── 📄 useKeyboardShortcuts.ts # 键盘快捷键 (16 个) │ │ ├── 📄 useKeyboardShortcuts.ts # 键盘快捷键 (16 个)
│ │ └── 📄 useTheme.ts # 主题管理 │ │ └── 📄 useTheme.ts # 主题管理
│ ├── 📂 stores/ │ ├── 📂 stores/
│ │ ├── 📄 agent-store.ts # Agent 状态 (消息·流·Trace·Token) │ │ ├── 📄 agent-store.ts # Agent 状态 (消息·流·Trace·Token·编辑重发)
│ │ ├── 📄 session-store.ts # 会话列表 (搜索·置顶·归档) │ │ ├── 📄 session-store.ts # 会话列表 (搜索·置顶·归档)
│ │ └── 📄 ui-store.ts # UI 状态 (主题·面板·专注模式) │ │ └── 📄 ui-store.ts # UI 状态 (主题·面板·专注模式)
│ ├── 📂 lib/ │ ├── 📂 lib/
│ │ ├── 📄 constants.ts # 常量 (布局·颜色·快捷键) │ │ ├── 📄 constants.ts # 常量 (布局·颜色·快捷键)
│ │ ├── 📄 formatters.ts # 格式化 (时间·Token·文件大小) │ │ ├── 📄 formatters.ts # 格式化 (时间·Token·文件大小)
│ │ ├── 📄 theme.ts # MUI 暗色/亮色主题 │ │ ├── 📄 theme.ts # MUI 暗色/亮色主题
│ │ ├── 📄 export-markdown.ts # 会话 Markdown 导出
│ │ ├── 📄 tool-result-display.ts # 工具结果显示层裁剪 (剥离 base64)
│ │ └── 📄 cn.ts # className 合并 │ │ └── 📄 cn.ts # className 合并
│ ├── 📂 styles/ │ ├── 📂 styles/
│ │ └── 📄 globals.css # 全局样式 (Tailwind + 动画 + Markdown) │ │ └── 📄 globals.css # 全局样式 (Tailwind + 动画 + Markdown)
@@ -844,7 +865,7 @@ npm run format # Prettier 格式化
# ─── 测试 ───────────────────────────────── # ─── 测试 ─────────────────────────────────
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过) npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过)
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 194 用例全执行, 含 SQLite 审计链哈希) npm run test:electron # 运行全量单元测试 (Electron Node ABI, 199 用例全执行, 含 SQLite 审计链哈希)
npm run test:watch # 测试监听模式 npm run test:watch # 测试监听模式
# ─── 构建 ───────────────────────────────── # ─── 构建 ─────────────────────────────────
@@ -856,17 +877,20 @@ npm run preview # 预览构建产物
### 数据库 Schema ### 数据库 Schema
共 10 张表(v0.4.0 新增 `session_summaries` 用于分层上下文):
| 表名 | 用途 | 关键特性 | | 表名 | 用途 | 关键特性 |
|:---|:---|:---| |:---|:---|:---|
| `sessions` | 会话管理 | title, created_at, updated_at, pinned, archived | | `sessions` | 会话管理 | title, created_at, updated_at, pinned, archived |
| `messages` | 消息持久化 | role, content, reasoning_content, tool_calls (JSON), token_usage | | `messages` | 消息持久化 | role, content, reasoning_content, tool_calls (JSON), attachments (JSON), iteration |
| `app_config` | 应用配置 | 键值对5 大分类 (llm/agent/tools/security/logging) | | `app_config` | 应用配置 | 键值对llm / agent / tools / security / logging / searxng / ui |
| `audit_logs` | 审计日志 | chain_hash (SHA-256), INSERT-ONLY 触发器 | | `audit_logs` | 审计日志 | prev_hash + current_hash 链式哈希 (SHA-256), INSERT-ONLY 触发器 |
| `mcp_servers` | MCP Server 配置 | name, transport (stdio/sse), command, args, env, url | | `mcp_servers` | MCP Server 配置 | name, transport (stdio/sse/streamable-http), command, args, url |
| `episodic_memories` | 情节记忆 | content, importance, access_count, last_accessed, created_at | | `episodic_memories` | 情节记忆 | content, summary, source, importance, tf_cache (分词缓存) |
| `semantic_memories` | 语义记忆 | content, embedding (JSON), importance, decay_factor | | `semantic_memories` | 语义记忆 | key-value (UNIQUE key), category, confidence, access_count, tf_cache |
| `working_memories` | 工作记忆 | key-value, session_id, expires_at | | `working_memories` | 工作记忆 | key-value, session_id + task_id + key 唯一约束 |
| `tasks` | 持久化任务 | title, description, status, priority, parent_id, session_id | | `tasks` | 持久化任务 | title, description, status, priority, parent_id (自引用), order_idx |
| `session_summaries` | 会话滚动摘要 | summary, summarized_until_rowid 游标(分层上下文加载) |
--- ---
@@ -480,7 +480,7 @@ MeToast.<span class="hl-fn">warning</span>(<span class="hl-str">'操作已取消
<tr><th>Tab</th><th>内容</th></tr> <tr><th>Tab</th><th>内容</th></tr>
<tr><td class="f1">LLM 配置</td><td class="f2">Provider 选择、API Key、模型名称、参数滑块(temperature/maxTokens/contextWindow</td></tr> <tr><td class="f1">LLM 配置</td><td class="f2">Provider 选择、API Key、模型名称、参数滑块(temperature/maxTokens/contextWindow</td></tr>
<tr><td class="f1">Agent 配置</td><td class="f2">最大迭代次数、超时、Thinking 开关、反思模式、压缩阈值</td></tr> <tr><td class="f1">Agent 配置</td><td class="f2">最大迭代次数、超时、Thinking 开关、反思模式、压缩阈值</td></tr>
<tr><td class="f1">工具管理</td><td class="f2">27 个内置工具开关、风险级别配置、路径白名单、命令黑名单</td></tr> <tr><td class="f1">工具管理</td><td class="f2">28 个内置工具开关、风险级别配置、路径白名单、命令黑名单</td></tr>
<tr><td class="f1">MCP 服务</td><td class="f2">Server 列表、添加/删除/启停、连接状态指示灯</td></tr> <tr><td class="f1">MCP 服务</td><td class="f2">Server 列表、添加/删除/启停、连接状态指示灯</td></tr>
<tr><td class="f1">外观</td><td class="f2">主题(light/dark/auto)、字体大小、消息密度、动画开关</td></tr> <tr><td class="f1">外观</td><td class="f2">主题(light/dark/auto)、字体大小、消息密度、动画开关</td></tr>
<tr><td class="f1">日志与数据</td><td class="f2">日志级别、数据库位置、数据导出/清理、使用统计</td></tr> <tr><td class="f1">日志与数据</td><td class="f2">日志级别、数据库位置、数据导出/清理、使用统计</td></tr>
@@ -1189,7 +1189,7 @@ electron/harness/adapters/
<tr><td class="field-num">3</td><td>Tool Registry 使用 MetonaToolDef / MetonaToolCall / MetonaToolResult</td><td class="field-type">harness/tools/</td></tr> <tr><td class="field-num">3</td><td>Tool Registry 使用 MetonaToolDef / MetonaToolCall / MetonaToolResult</td><td class="field-type">harness/tools/</td></tr>
<tr><td class="field-num">4</td><td>Memory Manager 使用 MetonaMemoryItem</td><td class="field-type">harness/memory/</td></tr> <tr><td class="field-num">4</td><td>Memory Manager 使用 MetonaMemoryItem</td><td class="field-type">harness/memory/</td></tr>
<tr><td class="field-num">5</td><td>IPC Preload 桥接层只传递 Metona IR 类型</td><td class="field-type">electron/preload.ts</td></tr> <tr><td class="field-num">5</td><td>IPC Preload 桥接层只传递 Metona IR 类型</td><td class="field-type">electron/preload.ts</td></tr>
<tr><td class="field-num">6</td><td>IPC Handlers 的输入输出为 Metona IR 类型</td><td class="field-type">electron/ipc/*.handlers.ts</td></tr> <tr><td class="field-num">6</td><td>IPC Handlers 的输入输出为 Metona IR 类型</td><td class="field-type">electron/ipc/*.ts</td></tr>
<tr><td class="field-num">7</td><td>React 组件/Zustand Store 只读写 Metona IR 类型</td><td class="field-type">src/stores/, src/components/</td></tr> <tr><td class="field-num">7</td><td>React 组件/Zustand Store 只读写 Metona IR 类型</td><td class="field-type">src/stores/, src/components/</td></tr>
<tr><td class="field-num">8</td><td>每个 Provider Adapter 实现 IMetonaProviderAdapter</td><td class="field-type">harness/adapters/</td></tr> <tr><td class="field-num">8</td><td>每个 Provider Adapter 实现 IMetonaProviderAdapter</td><td class="field-type">harness/adapters/</td></tr>
<tr><td class="field-num">9</td><td>流式事件通过 MetonaStreamEvent 推送</td><td class="field-type">hooks/useAgentStream.ts</td></tr> <tr><td class="field-num">9</td><td>流式事件通过 MetonaStreamEvent 推送</td><td class="field-type">hooks/useAgentStream.ts</td></tr>
@@ -141,14 +141,14 @@
<a href="#workspace">&#x1F4C1; 工作空间</a> <a href="#workspace">&#x1F4C1; 工作空间</a>
<a href="#db-config">&#x1F4BE; 数据库配置</a> <a href="#db-config">&#x1F4BE; 数据库配置</a>
<div class="sidebar-section">27 个内置工具</div> <div class="sidebar-section">28 个内置工具</div>
<a href="#tools-overview">&#x1F4CA; 工具总表</a> <a href="#tools-overview">&#x1F4CA; 工具总表</a>
<a href="#tool-filesystem">&#x1F4C4; 文件系统工具</a> <a href="#tool-filesystem">&#x1F4C4; 文件系统工具</a>
<a href="#tool-web">&#x1F310; 网络搜索与抓取</a> <a href="#tool-web">&#x1F310; 网络搜索与抓取</a>
<a href="#tool-memory">&#x1F9E0; 记忆工具</a> <a href="#tool-memory">&#x1F9E0; 记忆工具</a>
<a href="#tool-command">&#x2692;&#xFE0F; 命令工具</a> <a href="#tool-command">&#x2692;&#xFE0F; 命令工具</a>
<div class="sidebar-section">4 个磁盘文件</div> <div class="sidebar-section">磁盘文件(现为 2 个)</div>
<a href="#disk-files">&#x1F4BE; 文件总览</a> <a href="#disk-files">&#x1F4BE; 文件总览</a>
<a href="#file-soul">&#x2728; SOUL.md</a> <a href="#file-soul">&#x2728; SOUL.md</a>
<a href="#file-agents">&#x1F4CB; AGENTS.md</a> <a href="#file-agents">&#x1F4CB; AGENTS.md</a>
@@ -165,14 +165,17 @@
<div class="hero"> <div class="hero">
<h1>MetonaAI-Desktop 架构与交互设计</h1> <h1>MetonaAI-Desktop 架构与交互设计</h1>
<p>基于「生产级通用 AI Agent 桌面应用构建指南」+「Metona 内部 IR 标准」,定义完整的系统架构、27 个内置工具、4 个用户级磁盘文件、工作空间机制、数据库配置规范及全链路可追踪日志体系。</p> <p>基于「生产级通用 AI Agent 桌面应用构建指南」+「Metona 内部 IR 标准」,定义完整的系统架构、28 个内置工具、4 个用户级磁盘文件、工作空间机制、数据库配置规范及全链路可追踪日志体系。</p>
<div class="hero-meta"> <div class="hero-meta">
<span><span class="dot dot-cyan"></span> 版本: <strong>v1.1.0</strong></span> <span><span class="dot dot-cyan"></span> 版本: <strong>v1.1.0</strong></span>
<span><span class="dot dot-green"></span> 技术栈: <strong>React + Material UI (MUI) + Electron + SQLite</strong></span> <span><span class="dot dot-green"></span> 技术栈: <strong>React + Material UI (MUI) + Electron + SQLite</strong></span>
<span><span class="dot dot-amber"></span> 日期: <strong>2026-07-15</strong></span> <span><span class="dot dot-amber"></span> 日期: <strong>2026-07-15</strong></span>
</div> </div>
<div class="note-box" style="margin-top:16px"> <div class="note-box" style="margin-top:16px">
<strong>&#x1F4CB; 文档层级:</strong>本文档是 <strong>工作空间、27 个内置工具、4 个磁盘文件、数据库配置的权威定义</strong>,与《构建指南》第三、五、六章对应。冲突时以本文档为准。 <strong>&#x1F4CB; 文档层级:</strong>本文档是 <strong>工作空间、28 个内置工具、磁盘文件、数据库配置的权威定义</strong>,与《构建指南》第三、五、六章对应。冲突时以本文档为准。
<div class="warn-box" style="margin-top:12px;">
<strong>&#x26A0; 实现状态注记(v0.3.14+):</strong>当前实现中用户级磁盘文件为 <strong>2 个</strong>SOUL.md + MEMORY.md);AGENTS.md 与 USERS.md 已从实现中移除(其职责并入 SOUL.md 与内置安全准则),本文档中相关章节保留为历史设计参考。当前实现共 <strong>28 个内置工具</strong><strong>10 张数据库表</strong>(新增 session_summaries 分层摘要表)。
</div>
</div> </div>
</div> </div>
@@ -184,7 +187,7 @@
<p class="desc"> <p class="desc">
MetonaAI-Desktop 是一个运行在用户本地桌面上的通用 AI Agent 应用。它以<strong>工作空间(Workspace</strong>为基本组织单元, MetonaAI-Desktop 是一个运行在用户本地桌面上的通用 AI Agent 应用。它以<strong>工作空间(Workspace</strong>为基本组织单元,
通过 <strong>4 个 Markdown 磁盘文件</strong> 定义 Agent 的灵魂、行为、记忆和用户画像, 通过 <strong>4 个 Markdown 磁盘文件</strong> 定义 Agent 的灵魂、行为、记忆和用户画像,
提供 <strong>27 个内置工具</strong> 赋予 Agent 操作文件系统、网络、记忆和命令行的能力。 提供 <strong>28 个内置工具</strong> 赋予 Agent 操作文件系统、网络、记忆和命令行的能力。
全链路操作<strong>透明可追踪</strong>,所有决策过程、工具调用、LLM 推理记录在本地 SQLite 日志中。 全链路操作<strong>透明可追踪</strong>,所有决策过程、工具调用、LLM 推理记录在本地 SQLite 日志中。
</p> </p>
@@ -301,7 +304,7 @@
<h3>工作空间生命周期</h3> <h3>工作空间生命周期</h3>
<div class="flow-step"><span class="flow-num">1</span><span class="flow-text">用户选择/创建工作空间目录(或使用默认路径 <code>~/MetonaWorkspaces/default/</code></span></div> <div class="flow-step"><span class="flow-num">1</span><span class="flow-text">用户选择/创建工作空间目录(或使用默认路径 <code>~/MetonaWorkspaces/default/</code></span></div>
<div class="flow-step"><span class="flow-num">2</span><span class="flow-text">校验必需文件,缺失则自动创建(<code>MEMORY.md</code> 带元数据头)</span></div> <div class="flow-step"><span class="flow-num">2</span><span class="flow-text">校验必需文件,缺失则自动创建(<code>MEMORY.md</code> 带元数据头)</span></div>
<div class="flow-step"><span class="flow-num">3</span><span class="flow-text">加载 4 个磁盘文件,构建 System Prompt(空文件不影响启动)</span></div> <div class="flow-step"><span class="flow-num">3</span><span class="flow-text">加载磁盘文件(当前实现为 SOUL.md + MEMORY.md 共 2 个),构建 System Prompt(空文件不影响启动)</span></div>
<div class="flow-step"><span class="flow-num">4</span><span class="flow-text">连接 <code>.metona/agent.db</code>,加载配置、恢复历史会话</span></div> <div class="flow-step"><span class="flow-num">4</span><span class="flow-text">连接 <code>.metona/agent.db</code>,加载配置、恢复历史会话</span></div>
<div class="flow-step"><span class="flow-num">5</span><span class="flow-text">Agent 就绪,开始对话。所有工具操作默认以工作空间为根</span></div> <div class="flow-step"><span class="flow-num">5</span><span class="flow-text">Agent 就绪,开始对话。所有工具操作默认以工作空间为根</span></div>
<div class="flow-step"><span class="flow-num">6</span><span class="flow-text">会话结束后,<code>MEMORY.md</code>(更新时间戳)和 <code>.metona/agent.db</code> 自动更新</span></div> <div class="flow-step"><span class="flow-num">6</span><span class="flow-text">会话结束后,<code>MEMORY.md</code>(更新时间戳)和 <code>.metona/agent.db</code> 自动更新</span></div>
@@ -388,9 +391,9 @@
<hr class="section-divider"> <hr class="section-divider">
<!-- ====== 27 个内置工具 ====== --> <!-- ====== 28 个内置工具 ====== -->
<section class="api-section" id="tools-overview"> <section class="api-section" id="tools-overview">
<h2>&#x1F4CA; 27 个内置工具 — 总表</h2> <h2>&#x1F4CA; 28 个内置工具 — 总表</h2>
<p class="desc">所有工具使用 <strong>Metona IR 的 MetonaToolDef / MetonaToolCall / MetonaToolResult</strong> 结构。内置在 Tool Registry 中,Adaper 为 LLM 生成 JSON Schema 格式的描述。</p> <p class="desc">所有工具使用 <strong>Metona IR 的 MetonaToolDef / MetonaToolCall / MetonaToolResult</strong> 结构。内置在 Tool Registry 中,Adaper 为 LLM 生成 JSON Schema 格式的描述。</p>
<table class="spec"> <table class="spec">
@@ -404,7 +407,7 @@
<tr><td class="f-name">7</td><td class="f-name">memory_store</td><td class="f-type">database</td><td class="f-risk risk-medium">MEDIUM</td><td></td><td>存储一条记忆到 SQLite</td></tr> <tr><td class="f-name">7</td><td class="f-name">memory_store</td><td class="f-type">database</td><td class="f-risk risk-medium">MEDIUM</td><td></td><td>存储一条记忆到 SQLite</td></tr>
<tr><td class="f-name">8</td><td class="f-name">memory_search</td><td class="f-type">database</td><td class="f-risk risk-safe">SAFE</td><td></td><td>检索记忆(关键词匹配)</td></tr> <tr><td class="f-name">8</td><td class="f-name">memory_search</td><td class="f-type">database</td><td class="f-risk risk-safe">SAFE</td><td></td><td>检索记忆(关键词匹配)</td></tr>
<tr><td class="f-name">9</td><td class="f-name">run_command</td><td class="f-type">code_execution</td><td class="f-risk risk-high">HIGH</td><td></td><td>执行 Shell 命令,沙箱限制</td></tr> <tr><td class="f-name">9</td><td class="f-name">run_command</td><td class="f-type">code_execution</td><td class="f-risk risk-high">HIGH</td><td></td><td>执行 Shell 命令,沙箱限制</td></tr>
<tr><td colspan="6" style="padding:14px;color:var(--text-dim);font-size:13px;">完整 27 个工具列表详见 README.md「内置工具」章节。新增工具涵盖:file_editor、code_search、diff_viewer、web_browser、http_request、git_status/git_diff/git_log/git_commit、lint_code、run_tests、project_info、delegate_task、task_manager、todo_write、think、view_image。</td></tr> <tr><td colspan="6" style="padding:14px;color:var(--text-dim);font-size:13px;">完整 28 个工具列表详见 README.md「内置工具」章节。新增工具涵盖:file_editor、code_search、diff_viewer、web_browser、http_request、git_status/git_diff/git_log/git_commit、lint_code、run_tests、project_info、delegate_task、task_manager、think、view_image、delete_file、file_move、file_info</td></tr>
</table> </table>
</section> </section>
@@ -540,10 +543,11 @@
<!-- ====== 4 个用户级磁盘文件 ====== --> <!-- ====== 4 个用户级磁盘文件 ====== -->
<section class="api-section" id="disk-files"> <section class="api-section" id="disk-files">
<h2>&#x1F4BE; 4 个用户级磁盘文件</h2> <h2>&#x1F4BE; 用户级磁盘文件</h2>
<p class="desc"> <p class="desc">
4 个 <code>.md</code> 文件位于工作空间根目录,是工作空间的<strong>必需文件</strong> <code>.md</code> 文件位于工作空间根目录,是工作空间的<strong>必需文件</strong>
其中 <code>SOUL.md</code><code>AGENTS.md</code><code>USERS.md</code> 完全由用户自定义,<code>MEMORY.md</code> 由 Agent 维护但用户可编辑。 其中 <code>SOUL.md</code> 完全由用户自定义,<code>MEMORY.md</code> 由 Agent 维护但用户可编辑。
<strong>当前实现为 2 个文件</strong>;原设计中的 AGENTS.md / USERS.md 已移除(职责并入 SOUL.md 与内置安全准则),下方相关小节保留为历史设计参考。
</p> </p>
<table class="spec"> <table class="spec">
@@ -922,10 +926,10 @@
<div class="flow-step"><span class="flow-num">2</span><span class="flow-text">选择/创建工作空间 → 校验必需文件(缺失则自动创建)</span></div> <div class="flow-step"><span class="flow-num">2</span><span class="flow-text">选择/创建工作空间 → 校验必需文件(缺失则自动创建)</span></div>
<div class="flow-step"><span class="flow-num">3</span><span class="flow-text">连接 SQLite.metona/agent.db)→ 执行 schema 迁移 → 加载配置</span></div> <div class="flow-step"><span class="flow-num">3</span><span class="flow-text">连接 SQLite.metona/agent.db)→ 执行 schema 迁移 → 加载配置</span></div>
<div class="flow-step"><span class="flow-num">4</span><span class="flow-text">初始化 Provider Adapter(根据数据库配置选择)</span></div> <div class="flow-step"><span class="flow-num">4</span><span class="flow-text">初始化 Provider Adapter(根据数据库配置选择)</span></div>
<div class="flow-step"><span class="flow-num">5</span><span class="flow-text">加载 4 个磁盘文件,构建 System Prompt(空文件不影响启动)</span></div> <div class="flow-step"><span class="flow-num">5</span><span class="flow-text">加载磁盘文件(当前实现为 SOUL.md + MEMORY.md 共 2 个),构建 System Prompt(空文件不影响启动)</span></div>
<div class="flow-step"><span class="flow-num">6</span><span class="flow-text">连接启用的 MCP Servers → 动态加载 MCP 工具</span></div> <div class="flow-step"><span class="flow-num">6</span><span class="flow-text">连接启用的 MCP Servers → 动态加载 MCP 工具</span></div>
<div class="flow-step"><span class="flow-num">7</span><span class="flow-text">启动 React UIChromium Renderer),Agent 就绪</span></div> <div class="flow-step"><span class="flow-num">7</span><span class="flow-text">启动 React UIChromium Renderer),Agent 就绪</span></div>
<div class="flow-step"><span class="flow-num">8</span><span class="flow-text">(可选)提示用户编辑 SOUL.md / AGENTS.md / USERS.md 以自定义 Agent</span></div> <div class="flow-step"><span class="flow-num">8</span><span class="flow-text">(可选)提示用户编辑 SOUL.md 以自定义 Agent 人格(当前实现;AGENTS.md/USERS.md 已移除)</span></div>
<h3>对话流程(一次 ReAct 迭代)</h3> <h3>对话流程(一次 ReAct 迭代)</h3>
<div class="flow-step"><span class="flow-num">1</span><span class="flow-text">用户在 ChatInput 输入消息 → IPC <code>agent:sendMessage</code> 发送 MetonaRequest</span></div> <div class="flow-step"><span class="flow-num">1</span><span class="flow-text">用户在 ChatInput 输入消息 → IPC <code>agent:sendMessage</code> 发送 MetonaRequest</span></div>
@@ -1072,11 +1072,17 @@
│ ├── main.ts # 应用入口,生命周期管理 │ ├── main.ts # 应用入口,生命周期管理
│ ├── preload.ts # Preload 安全桥接脚本 │ ├── preload.ts # Preload 安全桥接脚本
│ ├── ipc/ # IPC 通道注册与处理 │ ├── ipc/ # IPC 通道注册与处理
│ │ ├── index.ts # IPC 通道汇总导出 │ │ ├── index.ts # 统一注册入口(防重入)
│ │ ├── agent.handlers.ts # Agent 相关 IPC 处理 │ │ ├── agent.ts # Agent 交互域(消息/中断/事件管道)
│ │ ├── db.handlers.ts # 数据库操作 IPC 处理 │ │ ├── sessions.ts # 会话 CRUD 域
│ │ ├── mcp.handlers.ts # MCP 管理 IPC 处理 │ │ ├── config.ts # 配置管理域
│ │ ── config.handlers.ts # 配置管理 IPC 处理 │ │ ── tools.ts # 工具与确认域
│ │ ├── mcp.ts # MCP 管理域
│ │ ├── memory.ts # 记忆域
│ │ ├── tasks.ts # 任务域
│ │ ├── data.ts # 数据导出与清理域
│ │ ├── workspace.ts # 工作空间域
│ │ └── app.ts # 应用工具域
│ ├── services/ # 主进程业务服务 │ ├── services/ # 主进程业务服务
│ │ ├── database.service.ts # 数据库初始化与连接管理 │ │ ├── database.service.ts # 数据库初始化与连接管理
│ │ ├── agent-engine.service.ts # Agent 引擎服务 │ │ ├── agent-engine.service.ts # Agent 引擎服务
@@ -1092,7 +1098,7 @@
│ │ ├── tools/ # 工具注册与管理 │ │ ├── tools/ # 工具注册与管理
│ │ │ ├── registry.ts # 工具注册表 │ │ │ ├── registry.ts # 工具注册表
│ │ │ ├── base-tool.ts # 工具基类/接口 │ │ │ ├── base-tool.ts # 工具基类/接口
│ │ │ └── built-in/ # 内置工具集(21 个文件,27 个工具) │ │ │ └── built-in/ # 内置工具集(21 个文件,28 个工具)
│ │ │ ├── filesystem.ts # 文件系统(5 工具) │ │ │ ├── filesystem.ts # 文件系统(5 工具)
│ │ │ ├── file-editor.ts # 编辑器 │ │ │ ├── file-editor.ts # 编辑器
│ │ │ ├── code-search.ts # 代码搜索 │ │ │ ├── code-search.ts # 代码搜索
@@ -2677,7 +2683,7 @@ export class ToolRegistry {
} }
} }
</code></pre> </code></pre>
<p><strong>&#x1F527; 备注</strong>v0.3.3 当前已实现 <strong>27 个内置工具</strong>,完整列表详见 <code>README.md</code>「内置工具」章节。下方仅以文件系统工具为示例展示实现规范。</p> <p><strong>&#x1F527; 备注</strong>:当前已实现 <strong>28 个内置工具</strong>,完整列表详见 <code>README.md</code>「内置工具」章节。下方仅以文件系统工具为示例展示实现规范。</p>
<p><strong>内置工具示例——文件系统工具</strong></p> <p><strong>内置工具示例——文件系统工具</strong></p>
<pre><code class="language-typescript">// ====== electron/harness/tools/built-in/filesystem.ts ====== <pre><code class="language-typescript">// ====== electron/harness/tools/built-in/filesystem.ts ======
@@ -3315,7 +3321,7 @@ export interface ValidationIssue {
</tbody> </tbody>
</table> </table>
<h3>6.2 SQLite 存储方案设计</h3> <h3>6.2 SQLite 存储方案设计</h3>
<p><strong>&#x1F4C1; 实际表结构</strong>v0.3.3 实际共 <strong>9 张表</strong>,包含:<code>sessions</code><code>messages</code><code>app_config</code><code>audit_logs</code><code>mcp_servers</code><code>episodic_memories</code><code>semantic_memories</code><code>working_memories</code><code>tasks</code>新增任务表,用于 Agent 子任务管理)。</p> <p><strong>&#x1F4C1; 实际表结构</strong>当前实际共 <strong>10 张表</strong>,包含:<code>sessions</code><code>messages</code><code>app_config</code><code>audit_logs</code><code>mcp_servers</code><code>episodic_memories</code><code>semantic_memories</code><code>working_memories</code><code>tasks</code>(任务表,用于 Agent 子任务管理)<code>session_summaries</code>(会话滚动摘要表,分层上下文加载)</p>
<pre><code class="language-sql">-- ====== database/schema.sql ====== <pre><code class="language-sql">-- ====== database/schema.sql ======
-- ============================================ -- ============================================
+28
View File
@@ -42,6 +42,34 @@ export default defineConfig({
outDir: 'dist', outDir: 'dist',
rollupOptions: { rollupOptions: {
input: { main: resolve(__dirname, 'index.html') }, input: { main: resolve(__dirname, 'index.html') },
output: {
// v0.5.0: 代码分割 — 此前全部打进单个 2.6MB chunk,首屏加载随业务代码线性膨胀。
// 拆出稳定的第三方 vendor chunk(缓存友好:业务代码变更不再使整个 bundle 失效)
manualChunks(id: string): string | undefined {
if (!id.includes('node_modules')) return undefined;
// React 运行时 + 状态管理
if (/[\\/]node_modules[\\/](react|react-dom|scheduler|zustand)[\\/]/.test(id)) {
return 'vendor-react';
}
// MUI 组件体系 + Emotion 样式引擎
if (id.includes('@mui') || id.includes('@emotion')) {
return 'vendor-mui';
}
// Markdown 渲染链(react-markdown/remark/rehype/micromark/highlight.js 等)
if (
/(react-markdown|remark-|rehype-|micromark|mdast-|unified|unist-|hast-|highlight\.js|lowlight|vfile|trough|bail)/.test(
id,
)
) {
return 'vendor-markdown';
}
// 图标库(lucide 按需引入但仍占可观体积)
if (id.includes('lucide-react')) {
return 'vendor-icons';
}
return undefined;
},
},
}, },
}, },
}, },
@@ -26,7 +26,7 @@ const SAFE_DEF: MetonaToolDef = {
name: 'read_file', name: 'read_file',
description: 'Read file (test fixture)', description: 'Read file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] }, parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILE_SYSTEM, category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE, riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false, requiresPermission: false,
timeoutMs: 1_000, timeoutMs: 1_000,
@@ -37,7 +37,7 @@ const HIGH_RISK_DEF_2: MetonaToolDef = {
name: 'delete_file', name: 'delete_file',
description: 'Delete file (test fixture)', description: 'Delete file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] }, parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILE_SYSTEM, category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.HIGH, riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true, requiresPermission: true,
timeoutMs: 1_000, timeoutMs: 1_000,
@@ -315,3 +315,110 @@ describe('ConfirmationHook — clearPending', () => {
expect(hook.getPendingConfirmations()).toHaveLength(0); expect(hook.getPendingConfirmations()).toHaveLength(0);
}); });
}); });
describe('ConfirmationHook — 跨会话隔离(v0.5.0', () => {
it('A 会话记住拒绝,B 会话同工具仍正常询问(产生新 pending)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A 会话记住拒绝
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
expect((await pA).blocked).toBe(true);
// B 会话同工具 — 不受 A 会话拒绝记忆影响,进入询问流程(产生新 pending)
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
const pendingB = hook.getPendingConfirmations();
expect(pendingB).toHaveLength(1);
hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
// A 会话的拒绝记忆仍在(getRememberedDenials 限定 A 会话可见)
expect(hook.getRememberedDenials('sess-a')).toHaveLength(1);
expect(hook.getRememberedDenials('sess-b')).toHaveLength(0);
});
it('A 会话记住批准,B 会话同工具仍需确认', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A 会话记住批准
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
expect((await pA).blocked).toBe(false);
// A 会话后续调用直接放行(无新 pending)
const again = await hook.beforeExecute(makeToolCall(), 'sess-a');
expect(again.blocked).toBe(false);
expect(hook.getPendingConfirmations()).toHaveLength(0);
// B 会话同工具 — 进入询问流程(批准记忆不跨会话共享)
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
expect(hook.getPendingConfirmations()).toHaveLength(1);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
});
it('中断 A 会话(clearPending 按会话)不影响 B 会话等待中的确认', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 两个会话各有一个等待中的确认
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
expect(hook.getPendingConfirmations()).toHaveLength(2);
// 中断 A 会话 — 只拒绝 A 的 pending
hook.clearPending('sess-a');
expect((await pA).blocked).toBe(true);
// B 会话的 pending 仍在等待,用户批准后正常放行
const remaining = hook.getPendingConfirmations();
expect(remaining).toHaveLength(1);
expect(remaining[0].toolCallId).toBeDefined();
hook.resolveConfirmation(remaining[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
});
it('resetRememberedDenial 指定会话时只重置该会话的拒绝记忆', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A、B 两个会话都记住拒绝
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pA;
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pB;
// 重置 A 会话的拒绝记忆 — B 会话的记忆保留
expect(hook.resetRememberedDenial('run_command', 'sess-a')).toBe(true);
expect(hook.getRememberedDenials('sess-a')).toHaveLength(0);
expect(hook.getRememberedDenials('sess-b')).toHaveLength(1);
// 缺省 sessionId — 重置所有会话
expect(hook.resetRememberedDenial('run_command')).toBe(true);
expect(hook.getRememberedDenials()).toHaveLength(0);
});
it('setAutoExecute 启用时清除所有会话的拒绝记忆(全局设置优先)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 两个会话都记住拒绝
for (const sid of ['sess-a', 'sess-b']) {
const p = hook.beforeExecute(makeToolCall(), sid);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await p;
}
expect(hook.getRememberedDenials()).toHaveLength(1); // 聚合去重后同一工具 1 条
// 启用自动执行 — 所有会话的拒绝记忆被清除
hook.setAutoExecute('run_command', true);
expect(hook.getRememberedDenials()).toHaveLength(0);
// 自动执行放行(任意会话)
expect((await hook.beforeExecute(makeToolCall(), 'sess-a')).blocked).toBe(false);
expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
});
});
+98 -39
View File
@@ -40,8 +40,14 @@ export class ConfirmationHook implements PreToolHook {
/** 工具定义缓存(由外部设置) */ /** 工具定义缓存(由外部设置) */
private toolDefs = new Map<string, MetonaToolDef>(); private toolDefs = new Map<string, MetonaToolDef>();
/** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */ /**
private rememberedDecisions = new Map<string, { approved: boolean; at: number }>(); * sessionId toolName
*
* v0.5.0: 从全局 Map Map
* A "记住拒绝/批准" B PolicyEngine
* { approved, at } TTLv0.4.1
*/
private rememberedDecisions = new Map<string, Map<string, { approved: boolean; at: number }>>();
/** /**
* v0.4.1: 会话内拒绝记忆的 TTL10 * v0.4.1: 会话内拒绝记忆的 TTL10
@@ -63,6 +69,8 @@ export class ConfirmationHook implements PreToolHook {
timer: NodeJS.Timeout; timer: NodeJS.Timeout;
toolName: string; toolName: string;
expiresAt: number; expiresAt: number;
/** v0.5.0: 发起确认的会话 IDclearPending 按会话清理的依据) */
sessionId: string;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */ /** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record<string, unknown>; args?: Record<string, unknown>;
riskLevel?: string; riskLevel?: string;
@@ -119,8 +127,8 @@ export class ConfirmationHook implements PreToolHook {
} }
if (enabled) { if (enabled) {
this.autoExecuteTools.add(toolName); this.autoExecuteTools.add(toolName);
// 自动执行时同步清除会话"拒绝"记忆(避免冲突) // 自动执行时同步清除所有会话"拒绝"记忆(避免冲突 — 自动执行是跨会话的全局设置
this.rememberedDecisions.delete(toolName); this.clearDenialMemoryAllSessions(toolName);
} else { } else {
this.autoExecuteTools.delete(toolName); this.autoExecuteTools.delete(toolName);
} }
@@ -194,9 +202,9 @@ export class ConfirmationHook implements PreToolHook {
if (autoExecute && approved) { if (autoExecute && approved) {
this.setAutoExecute(pending.toolName, true); this.setAutoExecute(pending.toolName, true);
} }
// 会话内记忆(v0.4.1: 拒绝记忆带时间戳用于 TTL 过期 // 会话内记忆(v0.4.1: 拒绝记忆带时间戳用于 TTLv0.5.0: 按 pending 所属会话写入
if (remember) { if (remember) {
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() }); this.decisionsFor(pending.sessionId).set(pending.toolName, { approved, at: Date.now() });
} }
this.pendingConfirmations.delete(toolCallId); this.pendingConfirmations.delete(toolCallId);
} }
@@ -242,7 +250,7 @@ export class ConfirmationHook implements PreToolHook {
this.setAutoExecute(pending.toolName, true); this.setAutoExecute(pending.toolName, true);
} }
if (remember) { if (remember) {
this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() }); this.decisionsFor(pending.sessionId).set(pending.toolName, { approved, at: Date.now() });
} }
} }
} }
@@ -275,44 +283,57 @@ export class ConfirmationHook implements PreToolHook {
} }
/** /**
* v0.4.1: 获取本会话内记住"拒绝" * v0.4.1: 获取记住"拒绝"
* v0.5.0: sessionId
* *
* TTL 10 * TTL 10
* "重新询问" * "重新询问"
*
* @param sessionId ID
*/ */
getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> { getRememberedDenials(sessionId?: string): Array<{ toolName: string; expiresInSeconds: number }> {
const now = Date.now(); const now = Date.now();
const result: Array<{ toolName: string; expiresInSeconds: number }> = []; // toolName → 剩余秒数(跨会话聚合时取最长剩余时间)
for (const [toolName, decision] of this.rememberedDecisions) { const merged = new Map<string, number>();
if (decision.approved) continue; for (const [sid, decisions] of this.rememberedDecisions) {
const elapsed = now - decision.at; if (sessionId && sid !== sessionId) continue;
if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) { for (const [toolName, decision] of decisions) {
// 已过期 — 顺手清理,避免列表返回过期条目 if (decision.approved) continue;
this.rememberedDecisions.delete(toolName); const elapsed = now - decision.at;
continue; if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
// 已过期 — 顺手清理,避免列表返回过期条目
decisions.delete(toolName);
continue;
}
const remaining = Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000);
merged.set(toolName, Math.max(merged.get(toolName) ?? 0, remaining));
} }
result.push({
toolName,
expiresInSeconds: Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000),
});
} }
return result; return Array.from(merged.entries()).map(([toolName, expiresInSeconds]) => ({
toolName,
expiresInSeconds,
}));
} }
/** /**
* v0.4.1: 重置指定工具的会话内拒绝记忆 * v0.4.1: 重置指定工具的会话内拒绝记忆
* v0.5.0: sessionId
* @returns true false * @returns true false
*/ */
resetRememberedDenial(toolName: string): boolean { resetRememberedDenial(toolName: string, sessionId?: string): boolean {
const decision = this.rememberedDecisions.get(toolName); let reset = false;
if (decision && !decision.approved) { for (const [sid, decisions] of this.rememberedDecisions) {
this.rememberedDecisions.delete(toolName); if (sessionId && sid !== sessionId) continue;
return true; const decision = decisions.get(toolName);
if (decision && !decision.approved) {
decisions.delete(toolName);
reset = true;
}
} }
return false; return reset;
} }
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> { async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise<HookResult> {
const def = this.toolDefs.get(toolCall.name); const def = this.toolDefs.get(toolCall.name);
if (!def) { if (!def) {
// 未知工具,放行(由 ToolRegistry 处理未知工具错误) // 未知工具,放行(由 ToolRegistry 处理未知工具错误)
@@ -332,14 +353,15 @@ export class ConfirmationHook implements PreToolHook {
return { blocked: false }; return { blocked: false };
} }
// 检查是否有记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问) // 检查本会话记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问v0.5.0: 按会话隔离
const remembered = this.rememberedDecisions.get(toolCall.name); const sessionDecisions = this.rememberedDecisions.get(sessionId);
if (remembered !== undefined) { const remembered = sessionDecisions?.get(toolCall.name);
if (sessionDecisions && remembered !== undefined) {
const isExpiredDenial = const isExpiredDenial =
!remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS; !remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS;
if (isExpiredDenial) { if (isExpiredDenial) {
// 拒绝记忆已过期 — 移除并继续走正常确认流程 // 拒绝记忆已过期 — 移除并继续走正常确认流程
this.rememberedDecisions.delete(toolCall.name); sessionDecisions.delete(toolCall.name);
} else { } else {
if (remembered.approved) return { blocked: false }; if (remembered.approved) return { blocked: false };
return { return {
@@ -366,7 +388,7 @@ export class ConfirmationHook implements PreToolHook {
}; };
// 等待用户响应(带超时) // 等待用户响应(带超时)
const approved = await this.waitForConfirmation(request); const approved = await this.waitForConfirmation(request, sessionId);
if (!approved) { if (!approved) {
return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` }; return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` };
@@ -385,7 +407,7 @@ export class ConfirmationHook implements PreToolHook {
*/ */
private lastTimeoutToastAt = 0; private lastTimeoutToastAt = 0;
private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> { private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise<boolean> {
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
const expiresAt = Date.now() + this.confirmationTimeoutMs; const expiresAt = Date.now() + this.confirmationTimeoutMs;
@@ -430,6 +452,7 @@ export class ConfirmationHook implements PreToolHook {
timer, timer,
toolName: request.toolName, toolName: request.toolName,
expiresAt, expiresAt,
sessionId,
// v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回 // v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回
args: request.args, args: request.args,
riskLevel: request.riskLevel, riskLevel: request.riskLevel,
@@ -447,13 +470,49 @@ export class ConfirmationHook implements PreToolHook {
} }
/** /**
* *
*
* v0.5.0: 支持按会话清理 A A pending
* B
*
* @param sessionId ID pending
*/ */
clearPending(): void { clearPending(sessionId?: string): void {
for (const [, pending] of this.pendingConfirmations) { if (sessionId === undefined) {
for (const [, pending] of this.pendingConfirmations) {
clearTimeout(pending.timer);
pending.resolve(false);
}
this.pendingConfirmations.clear();
return;
}
for (const [id, pending] of this.pendingConfirmations) {
if (pending.sessionId !== sessionId) continue;
clearTimeout(pending.timer); clearTimeout(pending.timer);
pending.resolve(false); pending.resolve(false);
this.pendingConfirmations.delete(id);
}
}
// ===== 私有辅助(v0.5.0: 会话隔离) =====
/** 获取(或创建)指定会话的决策记忆表 */
private decisionsFor(sessionId: string): Map<string, { approved: boolean; at: number }> {
let decisions = this.rememberedDecisions.get(sessionId);
if (!decisions) {
decisions = new Map();
this.rememberedDecisions.set(sessionId, decisions);
}
return decisions;
}
/** 清除所有会话中指定工具的拒绝记忆(setAutoExecute 启用时调用) */
private clearDenialMemoryAllSessions(toolName: string): void {
for (const decisions of this.rememberedDecisions.values()) {
const decision = decisions.get(toolName);
if (decision && !decision.approved) {
decisions.delete(toolName);
}
} }
this.pendingConfirmations.clear();
} }
} }
+94 -10
View File
@@ -24,7 +24,12 @@ import { EventEmitter } from 'events';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { AgentLoopEngine } from '../agent-loop/engine'; import { AgentLoopEngine } from '../agent-loop/engine';
import type { AgentLoopConfig } from '../agent-loop/types'; import type { AgentLoopConfig } from '../agent-loop/types';
import type { MetonaMessage, MetonaSystemPrompt, MetonaToolDef, IMetonaProviderAdapter } from '../types'; import type {
MetonaMessage,
MetonaSystemPrompt,
MetonaToolDef,
IMetonaProviderAdapter,
} from '../types';
import type { ToolRegistry } from '../tools/registry'; import type { ToolRegistry } from '../tools/registry';
import type { PreToolHook } from '../hooks/pre-tool'; import type { PreToolHook } from '../hooks/pre-tool';
import type { PostToolHook } from '../hooks/post-tool'; import type { PostToolHook } from '../hooks/post-tool';
@@ -32,12 +37,28 @@ import log from 'electron-log';
export interface SubAgentResult { export interface SubAgentResult {
taskId: string; taskId: string;
/** v0.5.0: 委派方(父会话)ID — 供事件消费者按会话过滤 */
parentSessionId: string;
result: string; result: string;
success: boolean; success: boolean;
durationMs: number; durationMs: number;
iterations: number; iterations: number;
} }
/**
* v0.5.0: SubAgent 广 AgentMonitor
*/
export interface SubAgentEvent {
taskId: string;
parentSessionId: string;
description: string;
status: 'delegated' | 'running' | 'completed' | 'error';
depth: number;
durationMs?: number;
iterations?: number;
error?: string;
}
/** /**
* P2-10: 引擎供给接口 AgentEngineManager * P2-10: 引擎供给接口 AgentEngineManager
* orchestrator * orchestrator
@@ -115,9 +136,12 @@ export class TaskOrchestrator extends EventEmitter {
// ===== 递归深度检查 ===== // ===== 递归深度检查 =====
const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0; const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
if (currentDepth >= MAX_DELEGATION_DEPTH) { if (currentDepth >= MAX_DELEGATION_DEPTH) {
log.warn(`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`); log.warn(
`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`,
);
return { return {
taskId, taskId,
parentSessionId: params.parentSessionId,
result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`, result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`,
success: false, success: false,
durationMs: 0, durationMs: 0,
@@ -127,7 +151,12 @@ export class TaskOrchestrator extends EventEmitter {
const depth = currentDepth + 1; const depth = currentDepth + 1;
this.sessionDepth.set(params.parentSessionId, depth); this.sessionDepth.set(params.parentSessionId, depth);
this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId, depth }); this.emit('taskDelegated', {
taskId,
description: params.description,
parentSessionId: params.parentSessionId,
depth,
});
// ===== 创建独立的引擎实例(P2-10: 独立 adapter,隔离 abort 信号) ===== // ===== 创建独立的引擎实例(P2-10: 独立 adapter,隔离 abort 信号) =====
const subEngine = new AgentLoopEngine( const subEngine = new AgentLoopEngine(
@@ -147,6 +176,10 @@ export class TaskOrchestrator extends EventEmitter {
subEngine.setFallbackAdapter(this.engines.getFallbackAdapter()); subEngine.setFallbackAdapter(this.engines.getFallbackAdapter());
subEngine.setWorkspacePath(this.engines.getWorkspacePath()); subEngine.setWorkspacePath(this.engines.getWorkspacePath());
// v0.5.0: 转发 SubEngine 的流式/状态事件(供 TRACE 录制与前端可观测)
// 事件以 taskId 为 sessionId,录制到独立 JSONL 文件,不污染父会话的流
this.forwardSubEngineEvents(subEngine, taskId);
// ===== 工具白名单设置 ===== // ===== 工具白名单设置 =====
const allowedTools = this.resolveTools(params.tools); const allowedTools = this.resolveTools(params.tools);
subEngine.setTools(allowedTools); subEngine.setTools(allowedTools);
@@ -163,11 +196,21 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = 'error'; handle.status = 'error';
this.activeSubAgents.delete(taskId); this.activeSubAgents.delete(taskId);
}, },
getStatus: () => ({ taskId, status: handle.status, description: handle.description, depth: handle.depth }), getStatus: () => ({
taskId,
status: handle.status,
description: handle.description,
depth: handle.depth,
}),
}; };
this.activeSubAgents.set(taskId, handle); this.activeSubAgents.set(taskId, handle);
this.emit('taskStarted', { taskId, depth }); this.emit('taskStarted', {
taskId,
description: params.description,
parentSessionId: params.parentSessionId,
depth,
});
try { try {
// 构建用户消息 // 构建用户消息
@@ -184,7 +227,7 @@ export class TaskOrchestrator extends EventEmitter {
const output = await subEngine.runStream( const output = await subEngine.runStream(
userMessage, userMessage,
taskId, taskId,
[], // 子 Agent 无历史 [], // 子 Agent 无历史
systemPrompt, systemPrompt,
); );
@@ -192,6 +235,7 @@ export class TaskOrchestrator extends EventEmitter {
const success = output.terminationReason === 'completed'; const success = output.terminationReason === 'completed';
const result: SubAgentResult = { const result: SubAgentResult = {
taskId, taskId,
parentSessionId: params.parentSessionId,
result: output.finalAnswer, result: output.finalAnswer,
success, success,
durationMs, durationMs,
@@ -202,7 +246,9 @@ export class TaskOrchestrator extends EventEmitter {
handle.result = result; handle.result = result;
this.emit('taskCompleted', result); this.emit('taskCompleted', result);
log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`); log.info(
`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`,
);
return result; return result;
} catch (error) { } catch (error) {
@@ -210,6 +256,7 @@ export class TaskOrchestrator extends EventEmitter {
const errMsg = (error as Error).message; const errMsg = (error as Error).message;
const result: SubAgentResult = { const result: SubAgentResult = {
taskId, taskId,
parentSessionId: params.parentSessionId,
result: errMsg, result: errMsg,
success: false, success: false,
durationMs, durationMs,
@@ -218,7 +265,12 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = 'error'; handle.status = 'error';
handle.result = result; handle.result = result;
this.emit('taskError', { taskId, error: errMsg }); this.emit('taskError', {
taskId,
parentSessionId: params.parentSessionId,
description: params.description,
error: errMsg,
});
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`); log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
@@ -326,13 +378,25 @@ export class TaskOrchestrator extends EventEmitter {
const handle = this.activeSubAgents.get(taskId); const handle = this.activeSubAgents.get(taskId);
if (handle && handle.status === 'running') { if (handle && handle.status === 'running') {
handle.status = success ? 'completed' : 'error'; handle.status = success ? 'completed' : 'error';
handle.result = { taskId, result, success, durationMs: 0, iterations: 0 }; handle.result = {
taskId,
parentSessionId: handle.parentSessionId,
result,
success,
durationMs: 0,
iterations: 0,
};
this.activeSubAgents.delete(taskId); this.activeSubAgents.delete(taskId);
this.emit('taskCompleted', handle.result); this.emit('taskCompleted', handle.result);
} }
} }
getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string; depth: number }> { getActiveAgentsStatus(): Array<{
taskId: string;
status: string;
description: string;
depth: number;
}> {
return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus()); return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus());
} }
@@ -350,4 +414,24 @@ export class TaskOrchestrator extends EventEmitter {
// 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。 // 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。
this.sessionDepth.clear(); this.sessionDepth.clear();
} }
/**
* v0.5.0: 转发 SubEngine orchestrator 线
*
* SubEngine AgentEngineManager
* / subStreamEvent / subStateChange 广
* - IPC TRACE sessionId = taskId
* - taskDelegated 广 AgentMonitor
*
* @param engine SubAgent
* @param taskId IDSubEngine runStream sessionId
*/
private forwardSubEngineEvents(engine: AgentLoopEngine, taskId: string): void {
engine.on('streamEvent', (event: Record<string, unknown>) => {
this.emit('subStreamEvent', { taskId, event });
});
engine.on('stateChange', (data: Record<string, unknown>) => {
this.emit('subStateChange', { taskId, data });
});
}
} }
+3 -1
View File
@@ -135,7 +135,9 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
buildHistoryMessages: vi.fn(() => []), buildHistoryMessages: vi.fn(() => []),
maybeSummarize: vi.fn().mockResolvedValue(undefined), maybeSummarize: vi.fn().mockResolvedValue(undefined),
}, },
orchestrator: { abortByParent: vi.fn() }, // v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制),
// mock 改用 EventEmitter 提供真实 on/emit 行为
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn() }),
confirmationHook: { clearPending: vi.fn() }, confirmationHook: { clearPending: vi.fn() },
reloadAdapter: vi.fn(() => true), reloadAdapter: vi.fn(() => true),
...overrides, ...overrides,
+253 -2
View File
@@ -17,6 +17,7 @@ import { broadcast } from './context';
import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types'; import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types';
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types'; import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
import { estimateMessagesTokens } from '../harness/utils/token-estimator'; import { estimateMessagesTokens } from '../harness/utils/token-estimator';
import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
import log from 'electron-log'; import log from 'electron-log';
/** 单会话的 text_delta 节流状态 */ /** 单会话的 text_delta 节流状态 */
@@ -511,8 +512,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`); log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
// v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声) // v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
// 类型谓词收窄 severity,确保与 MetonaValidationPayload.issues 的类型一致
const visibleIssues = validation.issues const visibleIssues = validation.issues
.filter((i) => i.severity === 'warning' || i.severity === 'error') .filter(
(i): i is typeof i & { severity: 'warning' | 'error' } =>
i.severity === 'warning' || i.severity === 'error',
)
.slice(0, 5); .slice(0, 5);
if (visibleIssues.length > 0) { if (visibleIssues.length > 0) {
broadcast('agent:streamEvent', { broadcast('agent:streamEvent', {
@@ -683,6 +688,27 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}, },
); );
// ===== v0.5.0: DeepSeek 余额查询(复用 DeepSeekAdapter.getBalance,原为死代码) =====
ipcMain.handle('llm:getBalance', async () => {
try {
const adapter = agentEngineManager.getAdapter();
if (!(adapter instanceof DeepSeekAdapter)) {
return {
success: false,
error: 'Balance query is only supported for the DeepSeek provider',
};
}
const balance = await adapter.getBalance();
if (!balance) {
return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
}
return { success: true, data: balance };
} catch (error) {
log.warn('[AGENT] getBalance failed:', (error as Error).message);
return { success: false, error: (error as Error).message };
}
});
// ===== 中断会话 ===== // ===== 中断会话 =====
ipcMain.handle('agent:abortSession', async (_event, sessionId) => { ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
@@ -693,7 +719,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中 // MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
await agentEngineManager.waitForAbort(sessionId); await agentEngineManager.waitForAbort(sessionId);
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出 // v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
confirmationHook.clearPending(); // v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
confirmationHook.clearPending(sessionId);
// TOOL 层:记录中断 // TOOL 层:记录中断
auditService.log({ auditService.log({
@@ -707,4 +734,228 @@ export function registerAgentHandlers(ctx: IPCContext): void {
return { success: true }; return { success: true };
}); });
// ===== v0.5.0: SubAgent 可观测性 =====
// 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区)
// 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流
// 此前 orchestrator 的 6 个事件全项目零消费者,SubAgent 执行过程对 UI 与 TRACE 完全不可见
/** SubAgent 元信息(description/depth,供完成/失败事件广播时补全载荷) */
const subMeta = new Map<string, { description: string; depth: number }>();
/** SubAgent 迭代录制状态(THINKING 驱动新迭代,text 累积供 llm_response */
interface SubTraceState {
iteration: number;
startedAt: number;
text: string;
usage: { input: number; output: number; total: number };
responded: boolean;
}
const subTraces = new Map<string, SubTraceState>();
const finishSubTrace = (
taskId: string,
reason: string,
durationMs: number,
iterations: number,
): void => {
sessionRecorder.stopRecording(taskId, {
totalIterations: iterations,
totalTokens: 0,
durationMs,
terminationReason: reason,
});
subTraces.delete(taskId);
subMeta.delete(taskId);
};
orchestrator.on(
'taskDelegated',
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
broadcast('subagent:event', {
taskId: d.taskId,
parentSessionId: d.parentSessionId,
description: d.description,
status: 'delegated',
depth: d.depth,
});
subMeta.set(d.taskId, { description: d.description, depth: d.depth });
sessionRecorder.startRecording(d.taskId);
},
);
orchestrator.on(
'taskStarted',
(d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
broadcast('subagent:event', {
taskId: d.taskId,
parentSessionId: d.parentSessionId,
description: d.description,
status: 'running',
depth: d.depth,
});
},
);
orchestrator.on(
'taskCompleted',
(r: {
taskId: string;
parentSessionId: string;
success: boolean;
durationMs: number;
iterations: number;
}) => {
const meta = subMeta.get(r.taskId);
broadcast('subagent:event', {
taskId: r.taskId,
parentSessionId: r.parentSessionId,
description: meta?.description ?? '',
status: r.success ? 'completed' : 'error',
depth: meta?.depth ?? 1,
durationMs: r.durationMs,
iterations: r.iterations,
});
finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
},
);
orchestrator.on(
'taskError',
(d: { taskId: string; parentSessionId: string; description: string; error: string }) => {
const meta = subMeta.get(d.taskId);
broadcast('subagent:event', {
taskId: d.taskId,
parentSessionId: d.parentSessionId,
description: d.description ?? meta?.description ?? '',
status: 'error',
depth: meta?.depth ?? 1,
error: d.error,
});
finishSubTrace(d.taskId, 'error', 0, 0);
},
);
// SubEngine 状态事件 → TRACE 迭代录制(与主管道相同的事件形状,sessionId = taskId
orchestrator.on(
'subStateChange',
({
taskId,
data,
}: {
taskId: string;
data: { state?: string; current?: string; iteration?: number };
}) => {
const st = subTraces.get(taskId);
const stateValue = data.state ?? data.current ?? '';
// THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
if (
stateValue === 'THINKING' &&
data.iteration != null &&
(!st || st.iteration !== data.iteration)
) {
if (st && !st.responded) {
sessionRecorder.recordLLMResponse({
sessionId: taskId,
iteration: st.iteration,
content: st.text,
finishReason: 'stop',
tokenUsage: st.usage,
});
sessionRecorder.recordIterationEnd(taskId, {
iteration: st.iteration,
durationMs: Date.now() - st.startedAt,
});
}
subTraces.set(taskId, {
iteration: data.iteration,
startedAt: Date.now(),
text: '',
usage: { input: 0, output: 0, total: 0 },
responded: false,
});
sessionRecorder.recordIterationStart(taskId, data.iteration);
const provider = configService.get<string>('llm.provider') ?? '';
const model = configService.get<string>('llm.model') ?? '';
sessionRecorder.recordLLMRequest({
sessionId: taskId,
iteration: data.iteration,
provider,
model,
messageCount: data.iteration + 1,
});
return;
}
// TERMINATED → 补记最终迭代(正常流程只在下一轮 THINKING 补记,最终轮无后续)
if (stateValue === 'TERMINATED' && st) {
if (!st.responded) {
sessionRecorder.recordLLMResponse({
sessionId: taskId,
iteration: st.iteration,
content: st.text,
finishReason: 'stop',
tokenUsage: st.usage,
});
}
sessionRecorder.recordIterationEnd(taskId, {
iteration: st.iteration,
durationMs: Date.now() - st.startedAt,
});
}
},
);
// SubEngine 流事件 → TRACE 内容录制(文本累积 + 工具调用/结果)
orchestrator.on(
'subStreamEvent',
({ taskId, event }: { taskId: string; event: MetonaStreamEvent }) => {
const st = subTraces.get(taskId);
if (!st) return;
switch (event.type) {
case MetonaStreamEventType.TEXT_DELTA:
if (event.delta) st.text += event.delta;
break;
case MetonaStreamEventType.USAGE:
if (event.usage) {
st.usage = {
input: event.usage.inputTokens ?? 0,
output: event.usage.outputTokens ?? 0,
total: event.usage.totalTokens ?? 0,
};
}
break;
case MetonaStreamEventType.TOOL_CALL_COMPLETE:
if (event.toolCall) {
sessionRecorder.recordToolCall({
sessionId: taskId,
iteration: event.iteration,
toolName: event.toolCall.name,
args: event.toolCall.args,
});
}
break;
case MetonaStreamEventType.TOOL_RESULT:
if (event.toolResult) {
const resultPreview =
typeof event.toolResult.result === 'string'
? event.toolResult.result
: JSON.stringify(event.toolResult.result);
sessionRecorder.recordToolResult({
sessionId: taskId,
iteration: event.iteration,
toolName: event.toolResult.toolName,
success: event.toolResult.success,
durationMs: event.toolResult.durationMs ?? 0,
resultPreview,
error: event.toolResult.error,
});
}
break;
default:
break;
}
},
);
} }
+72 -35
View File
@@ -108,46 +108,56 @@ export function registerAppHandlers(ctx: IPCContext): void {
// ===== SearXNG 连接测试 ===== // ===== SearXNG 连接测试 =====
ipcMain.handle('searxng:testConnection', async (_event, url: string, authKey: string, authType: string) => { ipcMain.handle(
try { 'searxng:testConnection',
if (!url || !/^https?:\/\//.test(url)) { async (_event, url: string, authKey: string, authType: string) => {
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' }; try {
} if (!url || !/^https?:\/\//.test(url)) {
return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
const startTime = Date.now();
const headers: Record<string, string> = {};
if (authKey) {
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${authKey}`;
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
} }
const startTime = Date.now();
const headers: Record<string, string> = {};
if (authKey) {
if (authType === 'bearer') {
headers['Authorization'] = `Bearer ${authKey}`;
} else if (authType === 'basic') {
headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
}
}
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
const response = await fetch(testUrl, {
headers,
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - startTime;
if (response.ok) {
return { success: true, statusCode: response.status, latencyMs };
}
return {
success: false,
statusCode: response.status,
latencyMs,
error: `HTTP ${response.status} ${response.statusText}`,
};
} catch (error) {
return { success: false, error: (error as Error).message };
} }
},
const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`; );
const response = await fetch(testUrl, {
headers,
signal: AbortSignal.timeout(10_000),
});
const latencyMs = Date.now() - startTime;
if (response.ok) {
return { success: true, statusCode: response.status, latencyMs };
}
return { success: false, statusCode: response.status, latencyMs, error: `HTTP ${response.status} ${response.statusText}` };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// ===== v0.2.0: 审计日志链式哈希验证 ===== // ===== v0.2.0: 审计日志链式哈希验证 =====
ipcMain.handle('audit:verifyChain', async () => { ipcMain.handle('audit:verifyChain', async () => {
try { try {
const result = auditService.verifyChain(); const result = auditService.verifyChain();
log.info(`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`); log.info(
`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`,
);
return { success: true, ...result }; return { success: true, ...result };
} catch (error) { } catch (error) {
log.error('[AUDIT] Chain verification failed:', error); log.error('[AUDIT] Chain verification failed:', error);
@@ -155,11 +165,35 @@ export function registerAppHandlers(ctx: IPCContext): void {
} }
}); });
// v0.5.0: 审计日志导出(JSONL / CSV 两种格式)
ipcMain.handle('audit:export', async (_event, format: unknown) => {
const fmt = format === 'csv' ? 'csv' : 'jsonl';
try {
const content = fmt === 'csv' ? auditService.exportCSV() : auditService.exportJSONL();
const recordCount = content.trim()
? fmt === 'csv'
? content.trim().split('\n').length - 1
: content.trim().split('\n').length
: 0;
log.info(`[AUDIT] Exported ${recordCount} records as ${fmt.toUpperCase()}`);
return { success: true, data: content, format: fmt, recordCount };
} catch (error) {
log.error('[AUDIT] Export failed:', error);
return { success: false, error: (error as Error).message };
}
});
ipcMain.handle('audit:query', async (_event, filters?: unknown) => { ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
// M-45 修复: 校验 filters 参数类型和范围 // M-45 修复: 校验 filters 参数类型和范围
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [ const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
'tool_call', 'permission_check', 'error', 'llm_request', 'tool_call',
'llm_response', 'session_start', 'session_end', 'config_change', 'permission_check',
'error',
'llm_request',
'llm_response',
'session_start',
'session_end',
'config_change',
]; ];
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {}; const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
if (filters && typeof filters === 'object') { if (filters && typeof filters === 'object') {
@@ -168,7 +202,10 @@ export function registerAppHandlers(ctx: IPCContext): void {
if (typeof f.eventType === 'string' && f.eventType) { if (typeof f.eventType === 'string' && f.eventType) {
// 校验 eventType 是否在合法枚举内 // 校验 eventType 是否在合法枚举内
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) { if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
return { success: false, error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})` }; return {
success: false,
error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})`,
};
} }
safeFilters.eventType = f.eventType as AuditEventType; safeFilters.eventType = f.eventType as AuditEventType;
} }
+26 -8
View File
@@ -26,7 +26,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => { ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
// M-34 修复: 校验 sessionId 和 title // M-34 修复: 校验 sessionId 和 title
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' }; if (typeof title !== 'string' || !title.trim())
return { success: false, error: 'Invalid title' };
return { success: sessionService.rename(sessionId, title) }; return { success: sessionService.rename(sessionId, title) };
}); });
@@ -55,7 +56,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
}); });
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => { ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' }; if (typeof messageId !== 'string' || !messageId)
return { success: false, error: 'Invalid messageId' };
return { success: sessionService.deleteMessage(messageId) }; return { success: sessionService.deleteMessage(messageId) };
}); });
@@ -65,13 +67,29 @@ export function registerSessionHandlers(ctx: IPCContext): void {
}); });
// P2-11: 消息截断(编辑重发 / 重新生成) // P2-11: 消息截断(编辑重发 / 重新生成)
ipcMain.handle('sessions:truncateAfter', async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => { ipcMain.handle(
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; 'sessions:truncateAfter',
if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' }; async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
const isInclusive = typeof inclusive === 'boolean' ? inclusive : true; if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
if (typeof messageId !== 'string' || !messageId)
return { success: false, error: 'Invalid messageId' };
const isInclusive = typeof inclusive === 'boolean' ? inclusive : true;
try {
const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
return { success: true, truncated };
} catch (error) {
return { success: false, error: (error as Error).message };
}
},
);
// v0.5.0: 全文搜索会话内容(FTS5,跨会话)
ipcMain.handle('sessions:searchContent', async (_event, query: unknown) => {
if (typeof query !== 'string' || !query.trim())
return { success: false, error: 'Invalid query' };
try { try {
const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive); const results = sessionService.searchMessages(query);
return { success: true, truncated }; return { success: true, data: results };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} }
+18 -12
View File
@@ -128,20 +128,26 @@ export function registerToolHandlers(ctx: IPCContext): void {
}); });
// ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) ===== // ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) =====
ipcMain.handle('tool:getRememberedDenials', async () => { // v0.5.0: sessionId 可选参数 — 拒绝记忆按会话隔离,前端应传当前会话 ID
return { success: true, data: confirmationHook.getRememberedDenials() }; ipcMain.handle('tool:getRememberedDenials', async (_event, sessionId?: unknown) => {
const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
return { success: true, data: confirmationHook.getRememberedDenials(sid) };
}); });
ipcMain.handle('tool:resetRememberedDenial', async (_event, toolName: unknown) => { ipcMain.handle(
if (typeof toolName !== 'string' || !toolName) { 'tool:resetRememberedDenial',
return { success: false, error: 'Invalid toolName' }; async (_event, toolName: unknown, sessionId?: unknown) => {
} if (typeof toolName !== 'string' || !toolName) {
const reset = confirmationHook.resetRememberedDenial(toolName); return { success: false, error: 'Invalid toolName' };
if (reset) { }
log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`); const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
} const reset = confirmationHook.resetRememberedDenial(toolName, sid);
return { success: reset }; if (reset) {
}); log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
}
return { success: reset };
},
);
// ===== v0.2.0: 持久化自动执行设置 ===== // ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => { ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
+211 -81
View File
@@ -15,7 +15,7 @@
*/ */
import 'dotenv/config'; import 'dotenv/config';
import { app, shell, Menu, BrowserWindow } from 'electron'; import { app, shell, Menu, BrowserWindow, dialog } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils'; import { electronApp, optimizer } from '@electron-toolkit/utils';
@@ -46,18 +46,29 @@ import { OpenAIAdapter } from './harness/adapters/openai.adapter';
import { AnthropicAdapter } from './harness/adapters/anthropic.adapter'; import { AnthropicAdapter } from './harness/adapters/anthropic.adapter';
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter'; import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
import { import {
ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, ReadFileTool,
WebSearchTool, WebFetchTool, WriteFileTool,
MemoryStoreTool, MemorySearchTool, ListDirectoryTool,
SearchFilesTool,
WebSearchTool,
WebFetchTool,
MemoryStoreTool,
MemorySearchTool,
RunCommandTool, RunCommandTool,
WebBrowserTool, cleanupBrowser, WebBrowserTool,
cleanupBrowser,
DelegateTaskTool, DelegateTaskTool,
FileEditorTool, FileEditorTool,
CodeSearchTool, CodeSearchTool,
TaskManagerTool, TaskManagerTool,
DiffViewerTool, DiffViewerTool,
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool, GitStatusTool,
LintCodeTool, RunTestsTool, ProjectInfoTool, GitDiffTool,
GitLogTool,
GitCommitTool,
LintCodeTool,
RunTestsTool,
ProjectInfoTool,
HttpRequestTool, HttpRequestTool,
ThinkTool, ThinkTool,
ViewImageTool, ViewImageTool,
@@ -65,7 +76,13 @@ import {
FileMoveTool, FileMoveTool,
FileInfoTool, FileInfoTool,
} from './harness/tools/built-in'; } from './harness/tools/built-in';
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook, SecurityScanHook } from './harness/hooks'; import {
AuditLogHook,
MemoryTriggerHook,
PermissionCheckHook,
RateLimitHook,
SecurityScanHook,
} from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook'; import { ConfirmationHook } from './harness/hooks/confirmation-hook';
import { PolicyEngine } from './harness/sandbox/permissions'; import { PolicyEngine } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox'; import { SandboxManager } from './harness/sandbox/sandbox';
@@ -139,7 +156,9 @@ async function initialize(): Promise<void> {
if (savedWorkspacePath !== workspaceInfo.path) { if (savedWorkspacePath !== workspaceInfo.path) {
writeWorkspacePathToFile(workspaceInfo.path); writeWorkspacePathToFile(workspaceInfo.path);
} }
log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`); log.info(
`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`,
);
// ===== 步骤 3: SQLite ===== // ===== 步骤 3: SQLite =====
databaseService = new DatabaseService(workspaceInfo.path); databaseService = new DatabaseService(workspaceInfo.path);
@@ -157,7 +176,10 @@ async function initialize(): Promise<void> {
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON // v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key // 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key
const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>; const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{
key: string;
value: string;
}>;
const workspaceConfig: Record<string, unknown> = {}; const workspaceConfig: Record<string, unknown> = {};
for (const row of configRows) { for (const row of configRows) {
try { try {
@@ -168,7 +190,9 @@ async function initialize(): Promise<void> {
} }
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig); const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
if (migratedCount > 0) { if (migratedCount > 0) {
log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`); log.info(
`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`,
);
} }
// ===== 日志服务 ===== // ===== 日志服务 =====
@@ -185,8 +209,12 @@ async function initialize(): Promise<void> {
const provider = configService.get<string>('llm.provider') ?? ''; const provider = configService.get<string>('llm.provider') ?? '';
const model = configService.get<string>('llm.model') ?? ''; const model = configService.get<string>('llm.model') ?? '';
const env = ENV_FALLBACK[provider] ?? {}; const env = ENV_FALLBACK[provider] ?? {};
const apiKey = configService.get<string>('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || ''; const apiKey =
const baseURL = configService.get<string>('llm.baseURL') || (env.baseURL ? process.env[env.baseURL] : '') || ''; configService.get<string>('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
const baseURL =
configService.get<string>('llm.baseURL') ||
(env.baseURL ? process.env[env.baseURL] : '') ||
'';
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding // 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding
if (!provider || !baseURL || !model) { if (!provider || !baseURL || !model) {
@@ -200,24 +228,35 @@ async function initialize(): Promise<void> {
} }
// 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段) // 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
const contextWindow = provider !== 'ollama' const contextWindow =
? configService.get<number>(`${provider}.contextWindow`) ?? undefined provider !== 'ollama'
: undefined; ? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
: undefined;
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) { switch (provider) {
case 'agnes': return new AgnesAdapter(adapterConfig); case 'agnes':
case 'mimo': return new MimoAdapter(adapterConfig); return new AgnesAdapter(adapterConfig);
case 'ollama': return new OllamaAdapter(adapterConfig); case 'mimo':
case 'openai': return new OpenAIAdapter(adapterConfig); return new MimoAdapter(adapterConfig);
case 'anthropic': return new AnthropicAdapter(adapterConfig); case 'ollama':
default: return new DeepSeekAdapter(adapterConfig); return new OllamaAdapter(adapterConfig);
case 'openai':
return new OpenAIAdapter(adapterConfig);
case 'anthropic':
return new AnthropicAdapter(adapterConfig);
default:
return new DeepSeekAdapter(adapterConfig);
} }
}; };
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见 // 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见
const FALLBACK_ADAPTER = new DeepSeekAdapter({ const FALLBACK_ADAPTER = new DeepSeekAdapter({
provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096, provider: '',
baseURL: '',
apiKey: '',
defaultModel: '',
contextWindow: 4096,
}); });
// ===== 步骤 5: 工作空间文件 + System Prompt ===== // ===== 步骤 5: 工作空间文件 + System Prompt =====
@@ -262,11 +301,16 @@ async function initialize(): Promise<void> {
toolRegistry.registerBuiltin(runCommandTool); toolRegistry.registerBuiltin(runCommandTool);
// P2v0.3.13: 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口 // P2v0.3.13: 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
toolRegistry.registerBuiltin(new TaskManagerTool(() => db, (sessionId) => { toolRegistry.registerBuiltin(
for (const win of BrowserWindow.getAllWindows()) { new TaskManagerTool(
win.webContents.send('task:changed', sessionId); () => db,
} (sessionId) => {
})); for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('task:changed', sessionId);
}
},
),
);
toolRegistry.registerBuiltin(new WebBrowserTool()); toolRegistry.registerBuiltin(new WebBrowserTool());
@@ -308,7 +352,13 @@ async function initialize(): Promise<void> {
contextLength: ollamaNumCtx ?? undefined, contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(), contextWindow: buildAdapter().getContextWindow(),
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true, thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
thinkingEffort: (configService.get<string>('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high', thinkingEffort:
(configService.get<string>('agent.thinkingEffort') as
| 'low'
| 'medium'
| 'high'
| 'max'
| null) ?? 'high',
toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000, toolExecutionTimeoutMs: configService.get<number>('agent.toolExecutionTimeoutMs') ?? 120_000,
}, },
toolRegistry, toolRegistry,
@@ -323,21 +373,34 @@ async function initialize(): Promise<void> {
const model = configService.get<string>('llm.fallbackModel'); const model = configService.get<string>('llm.fallbackModel');
if (!provider || !model) return null; if (!provider || !model) return null;
const env = ENV_FALLBACK[provider] ?? {}; const env = ENV_FALLBACK[provider] ?? {};
const apiKey = configService.get<string>('llm.fallbackApiKey') || (env.apiKey ? process.env[env.apiKey] : '') || ''; const apiKey =
const baseURL = configService.get<string>('llm.fallbackBaseURL') || (env.baseURL ? process.env[env.baseURL] : '') || ''; configService.get<string>('llm.fallbackApiKey') ||
(env.apiKey ? process.env[env.apiKey] : '') ||
'';
const baseURL =
configService.get<string>('llm.fallbackBaseURL') ||
(env.baseURL ? process.env[env.baseURL] : '') ||
'';
if (!baseURL) return null; if (!baseURL) return null;
if (!apiKey && provider !== 'ollama') return null; if (!apiKey && provider !== 'ollama') return null;
const contextWindow = provider !== 'ollama' const contextWindow =
? configService.get<number>(`${provider}.contextWindow`) ?? undefined provider !== 'ollama'
: undefined; ? (configService.get<number>(`${provider}.contextWindow`) ?? undefined)
: undefined;
const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) { switch (provider) {
case 'agnes': return new AgnesAdapter(cfg); case 'agnes':
case 'mimo': return new MimoAdapter(cfg); return new AgnesAdapter(cfg);
case 'ollama': return new OllamaAdapter(cfg); case 'mimo':
case 'openai': return new OpenAIAdapter(cfg); return new MimoAdapter(cfg);
case 'anthropic': return new AnthropicAdapter(cfg); case 'ollama':
default: return new DeepSeekAdapter(cfg); return new OllamaAdapter(cfg);
case 'openai':
return new OpenAIAdapter(cfg);
case 'anthropic':
return new AnthropicAdapter(cfg);
default:
return new DeepSeekAdapter(cfg);
} }
}; };
agentEngineManager.setFallbackAdapter(buildFallbackAdapter()); agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
@@ -345,24 +408,27 @@ async function initialize(): Promise<void> {
// P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) // P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口)
// v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态) // v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 }; const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
mcpManager.initialize().then(() => { mcpManager
agentEngineManager.setToolsAll(toolRegistry.listTools()); .initialize()
log.info('[MCP] Tools registered and synced to all engines'); .then(() => {
toolsReadyRef.ready = true; agentEngineManager.setToolsAll(toolRegistry.listTools());
toolsReadyRef.toolCount = toolRegistry.size; log.info('[MCP] Tools registered and synced to all engines');
// 广播工具就绪事件给所有窗口 toolsReadyRef.ready = true;
for (const win of BrowserWindow.getAllWindows()) { toolsReadyRef.toolCount = toolRegistry.size;
win.webContents.send('tools:ready', { toolCount: toolRegistry.size }); // 广播工具就绪事件给所有窗口
} for (const win of BrowserWindow.getAllWindows()) {
}).catch((err) => { win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
log.warn('MCP Manager initialization error:', err); }
// 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送 })
toolsReadyRef.ready = true; .catch((err) => {
toolsReadyRef.toolCount = toolRegistry.size; log.warn('MCP Manager initialization error:', err);
for (const win of BrowserWindow.getAllWindows()) { // 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
win.webContents.send('tools:ready', { toolCount: toolRegistry.size }); toolsReadyRef.ready = true;
} toolsReadyRef.toolCount = toolRegistry.size;
}); for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
}
});
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md ===== // ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md =====
const memoryConsolidator = new MemoryConsolidator(buildAdapter(), workspaceService); const memoryConsolidator = new MemoryConsolidator(buildAdapter(), workspaceService);
@@ -378,10 +444,19 @@ async function initialize(): Promise<void> {
// ===== Task Orchestrator(子任务委派,P2-10: 适配 EngineProvider ===== // ===== Task Orchestrator(子任务委派,P2-10: 适配 EngineProvider =====
const orchestrator = new TaskOrchestrator( const orchestrator = new TaskOrchestrator(
agentEngineManager, toolRegistry, preToolHooks, postToolHooks, agentEngineManager,
toolRegistry,
preToolHooks,
postToolHooks,
{ {
thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true, thinkingEnabled: configService.get<boolean>('agent.enableThinking') ?? true,
thinkingEffort: (configService.get<string>('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high', thinkingEffort:
(configService.get<string>('agent.thinkingEffort') as
| 'low'
| 'medium'
| 'high'
| 'max'
| null) ?? 'high',
contextLength: ollamaNumCtx ?? undefined, contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(), contextWindow: buildAdapter().getContextWindow(),
}, },
@@ -407,9 +482,10 @@ async function initialize(): Promise<void> {
fallbackModel: configService.get<string>('llm.fallbackModel') ?? '', fallbackModel: configService.get<string>('llm.fallbackModel') ?? '',
fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '', fallbackApiKey: configService.get<string>('llm.fallbackApiKey') ?? '',
fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '', fallbackBaseURL: configService.get<string>('llm.fallbackBaseURL') ?? '',
contextWindow: provider === 'ollama' contextWindow:
? configService.get<number>('ollama.numCtx') provider === 'ollama'
: configService.get<number>(`${provider}.contextWindow`), ? configService.get<number>('ollama.numCtx')
: configService.get<number>(`${provider}.contextWindow`),
}); });
}; };
let lastConfigSig = buildConfigSig(); let lastConfigSig = buildConfigSig();
@@ -425,7 +501,9 @@ async function initialize(): Promise<void> {
// 配置变化 — 校验新配置可构建 adapter // 配置变化 — 校验新配置可构建 adapter
const probe = createAdapter(); const probe = createAdapter();
if (!probe) { if (!probe) {
log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)'); log.warn(
'[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)',
);
const wins = BrowserWindow.getAllWindows(); const wins = BrowserWindow.getAllWindows();
for (const win of wins) { for (const win of wins) {
win.webContents.send('toast:show', { win.webContents.send('toast:show', {
@@ -528,15 +606,23 @@ async function initialize(): Promise<void> {
windowManager.registerGlobalShortcuts(); windowManager.registerGlobalShortcuts();
// ===== Agent 状态同步到托盘(P2-10: 监听 manager 聚合事件) ===== // ===== Agent 状态同步到托盘(P2-10: 监听 manager 聚合事件) =====
agentEngineManager.on('stateChange', (data: { previous?: string; current?: string; state?: string }) => { agentEngineManager.on(
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = { 'stateChange',
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking', (data: { previous?: string; current?: string; state?: string }) => {
EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking', const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
COMPRESSING: 'thinking', TERMINATED: 'idle', INIT: 'idle',
}; THINKING: 'thinking',
const stateValue = (data.current || data.state) ?? ''; PARSING: 'thinking',
trayManager?.setStatus(statusMap[stateValue] ?? 'idle'); EXECUTING: 'executing',
}); OBSERVING: 'thinking',
REFLECTING: 'thinking',
COMPRESSING: 'thinking',
TERMINATED: 'idle',
};
const stateValue = (data.current || data.state) ?? '';
trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
},
);
// ===== Agent 完成时发送系统通知 ===== // ===== Agent 完成时发送系统通知 =====
agentEngineManager.on('complete', (data: { sessionId: string; durationMs: number }) => { agentEngineManager.on('complete', (data: { sessionId: string; durationMs: number }) => {
@@ -560,7 +646,10 @@ async function initialize(): Promise<void> {
try { try {
const report = await healthChecker.check(); const report = await healthChecker.check();
if (!report.healthy) { if (!report.healthy) {
const failed = report.checks.filter((c) => !c.healthy).map((c) => c.name).join(', '); const failed = report.checks
.filter((c) => !c.healthy)
.map((c) => c.name)
.join(', ');
log.warn(`[Health] Unhealthy checks: ${failed}`); log.warn(`[Health] Unhealthy checks: ${failed}`);
trayManager?.setStatus('error'); trayManager?.setStatus('error');
} else { } else {
@@ -571,7 +660,7 @@ async function initialize(): Promise<void> {
if (slo.violated) { if (slo.violated) {
log.warn( log.warn(
`[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` + `[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` +
`P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`, `P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
); );
} }
} catch (err) { } catch (err) {
@@ -613,7 +702,10 @@ async function initialize(): Promise<void> {
// v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待 // v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
const shutdownTimeout = setTimeout(() => { const shutdownTimeout = setTimeout(() => {
log.warn('[Shutdown] Timeout reached, forcing exit'); log.warn('[Shutdown] Timeout reached, forcing exit');
if (databaseService) { databaseService.close(); databaseService = null; } if (databaseService) {
databaseService.close();
databaseService = null;
}
app.exit(0); app.exit(0);
}, 40_000); }, 40_000);
@@ -636,19 +728,36 @@ async function initialize(): Promise<void> {
} }
} }
if (databaseService) { databaseService.close(); databaseService = null; } if (databaseService) {
databaseService.close();
databaseService = null;
}
clearTimeout(shutdownTimeout); clearTimeout(shutdownTimeout);
app.exit(0); app.exit(0);
})().catch((err) => { })().catch((err) => {
log.error('[Shutdown] Async cleanup failed:', err); log.error('[Shutdown] Async cleanup failed:', err);
clearTimeout(shutdownTimeout); clearTimeout(shutdownTimeout);
if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; } if (databaseService) {
try {
databaseService.close();
} catch {
/* ignore */
}
databaseService = null;
}
app.exit(1); app.exit(1);
}); });
}); });
// ===== 应用日志级别配置 ===== // ===== 应用日志级别配置 =====
const logLevel = configService.get<string>('logging.level') as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null; const logLevel = configService.get<string>('logging.level') as
| 'error'
| 'warn'
| 'info'
| 'debug'
| 'verbose'
| 'silly'
| null;
if (logLevel) { if (logLevel) {
log.transports.file.level = logLevel; log.transports.file.level = logLevel;
log.transports.console.level = logLevel; log.transports.console.level = logLevel;
@@ -664,10 +773,31 @@ async function initialize(): Promise<void> {
} }
} }
log.info(`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`); log.info(
`MetonaAI Desktop initialized [Provider: ${configService.get<string>('llm.provider') ?? 'none'}, Model: ${configService.get<string>('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`,
);
} }
app.whenReady().then(initialize); // v0.5.0: 启动链路异常兜底 — DB 损坏/工作空间不可写等初始化失败时,
// 原实现会静默挂起(渲染进程白屏且无任何用户可见错误)。
// 兜底策略:记录日志 + 弹出系统错误对话框 + 退出(exit 1)
app
.whenReady()
.then(initialize)
.catch((err) => {
log.error('[Startup] Initialization failed:', err);
try {
dialog.showErrorBox(
'MetonaAI Desktop 启动失败',
`初始化过程中发生错误,应用即将退出。\n\n${(err as Error)?.message ?? String(err)}\n\n` +
'可能原因:工作空间目录不可写、数据库文件损坏。\n' +
'可尝试在设置中切换工作空间路径后重新启动。',
);
} catch {
// showErrorBox 失败(极端环境)时仅保留日志
}
app.exit(1);
});
app.on('web-contents-created', (_, contents) => { app.on('web-contents-created', (_, contents) => {
// C-8 修复: 全局 web-contents 监听器也校验 URL 协议 // C-8 修复: 全局 web-contents 监听器也校验 URL 协议
+51 -4
View File
@@ -37,6 +37,12 @@ const metonaAPI = {
ipcRenderer.on('agent:providerSwitched', listener); ipcRenderer.on('agent:providerSwitched', listener);
return () => ipcRenderer.removeListener('agent:providerSwitched', listener); return () => ipcRenderer.removeListener('agent:providerSwitched', listener);
}, },
/** v0.5.0: 监听 SubAgent 生命周期事件(委派/运行/完成/失败) */
onSubAgentEvent: (callback: (event: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
ipcRenderer.on('subagent:event', listener);
return () => ipcRenderer.removeListener('subagent:event', listener);
},
}, },
// ===== 会话管理 ===== // ===== 会话管理 =====
@@ -56,6 +62,21 @@ const metonaAPI = {
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */ /** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) => truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive), ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive),
/** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
searchContent: (query: string) =>
ipcRenderer.invoke('sessions:searchContent', query) as Promise<{
success: boolean;
error?: string;
data?: Array<{
sessionId: string;
sessionTitle: string;
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}>;
}>,
saveTrace: (sessionId: string, data: unknown) => saveTrace: (sessionId: string, data: unknown) =>
ipcRenderer.invoke('sessions:saveTrace', sessionId, data), ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId), getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId),
@@ -114,6 +135,15 @@ const metonaAPI = {
verifyChain: () => ipcRenderer.invoke('audit:verifyChain'), verifyChain: () => ipcRenderer.invoke('audit:verifyChain'),
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) => query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
ipcRenderer.invoke('audit:query', filters), ipcRenderer.invoke('audit:query', filters),
/** v0.5.0: 导出审计日志(jsonl / csv 两种格式) */
export: (format: 'jsonl' | 'csv') =>
ipcRenderer.invoke('audit:export', format) as Promise<{
success: boolean;
error?: string;
data?: string;
format?: string;
recordCount?: number;
}>,
}, },
// ===== v0.2.0: 工具确认请求 ===== // ===== v0.2.0: 工具确认请求 =====
@@ -150,13 +180,14 @@ const metonaAPI = {
}>; }>;
}>, }>,
// v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问) // v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问)
getRememberedDenials: () => // v0.5.0: sessionId 参数 — 拒绝记忆按会话隔离,传当前会话只查看/重置本会话的记忆
ipcRenderer.invoke('tool:getRememberedDenials') as Promise<{ getRememberedDenials: (sessionId?: string) =>
ipcRenderer.invoke('tool:getRememberedDenials', sessionId) as Promise<{
success: boolean; success: boolean;
data: Array<{ toolName: string; expiresInSeconds: number }>; data: Array<{ toolName: string; expiresInSeconds: number }>;
}>, }>,
resetRememberedDenial: (toolName: string) => resetRememberedDenial: (toolName: string, sessionId?: string) =>
ipcRenderer.invoke('tool:resetRememberedDenial', toolName) as Promise<{ ipcRenderer.invoke('tool:resetRememberedDenial', toolName, sessionId) as Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
}>, }>,
@@ -237,6 +268,22 @@ const metonaAPI = {
ipcRenderer.invoke('searxng:testConnection', url, authKey, authType), ipcRenderer.invoke('searxng:testConnection', url, authKey, authType),
}, },
// ===== LLM 运行时查询 =====
llm: {
/** v0.5.0: DeepSeek 余额查询(仅 DeepSeek Provider 支持) */
getBalance: () =>
ipcRenderer.invoke('llm:getBalance') as Promise<{
success: boolean;
error?: string;
data?: {
currency: string;
totalBalance: string;
grantedBalance: string;
toppedUpBalance: string;
};
}>,
},
// ===== Toast 通知桥接 ===== // ===== Toast 通知桥接 =====
toast: { toast: {
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => { onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
@@ -0,0 +1,270 @@
/**
* FTS5 + v0.5.0
*
*
* 1. messages_fts INSERT / DELETE
* 2. searchMessagesFTS snippet
* 3. JSONL CSV RFC 4180 //
*
* better-sqlite3 Electron ABI test:electron
* cross-env ELECTRON_RUN_AS_NODE=1 electron ... Node
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
let dbAvailable = true;
let Database: typeof import('better-sqlite3');
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Database = require('better-sqlite3');
const probe = new Database(':memory:');
probe.close();
} catch {
dbAvailable = false;
}
describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
let db: any;
let dir: string;
let sessionService: any;
beforeAll(async () => {
const { SessionService } = await import('../session.service');
dir = mkdtempSync(join(tmpdir(), 'metona-search-'));
db = new Database(':memory:');
// 与 DatabaseService.createTables 一致的最小 schema(含 FTS 虚表 + 触发器)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
title TEXT DEFAULT '新会话',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
metadata TEXT DEFAULT '{}'
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
created_at INTEGER NOT NULL
);
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid
);
CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
`);
sessionService = new SessionService(() => db);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
const insertMessage = (
id: string,
sessionId: string,
role: string,
content: string | null,
ts: number,
): void => {
db.prepare(
`INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
).run(id, sessionId, role, content, ts);
};
it('FTS5 可用且插入消息实时进入索引', () => {
// FTS5 编译可用性(better-sqlite3 预编译含 FTS5,此处防御性验证)
insertMessage('m1', 's1', 'user', 'Hello FTS5 world', 1000);
const rows = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'hello'`)
.all() as Array<{ c: number }>;
expect(rows[0].c).toBe(1);
});
it('searchMessages 按会话聚合返回(最新匹配 + 匹配计数 + snippet', () => {
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s1', '会话一', 1, 1)`,
).run();
db.prepare(
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s2', '会话二', 1, 1)`,
).run();
insertMessage('m2', 's1', 'assistant', 'uniquekeyword 出现了一次', 2000);
insertMessage('m3', 's2', 'user', 'uniquekeyword 在另一个会话', 3000);
insertMessage('m4', 's2', 'assistant', 'uniquekeyword 再次出现', 4000);
const results = sessionService.searchMessages('uniquekeyword');
expect(results).toHaveLength(2);
// s2 的最新匹配(m4)先返回(按时间倒序聚合)
expect(results[0].sessionId).toBe('s2');
expect(results[0].matchCount).toBe(2);
expect(results[0].lastMessageId).toBe('m4');
expect(results[0].sessionTitle).toBe('会话二');
expect(results[0].snippet).toContain('uniquekeyword');
// s1 单条匹配
expect(results[1].sessionId).toBe('s1');
expect(results[1].matchCount).toBe(1);
});
it('删除消息后索引同步(搜索不再命中)', () => {
db.prepare(`DELETE FROM messages WHERE id = 'm2'`).run();
const results = sessionService.searchMessages('uniquekeyword');
expect(results).toHaveLength(1);
expect(results[0].sessionId).toBe('s2');
});
it('FTS 运算符/引号输入安全(不抛错,按短语匹配)', () => {
insertMessage('m5', 's1', 'user', '包含 AND OR NOT 与 "引号" 的消息', 5000);
// 含 FTS 保留字与引号的查询不抛异常
expect(() => sessionService.searchMessages('AND OR NOT')).not.toThrow();
expect(() => sessionService.searchMessages('x"y')).not.toThrow();
// 引号内容作为短语可命中
const hit = sessionService.searchMessages('引号');
expect(hit.some((r: any) => r.lastMessageId === 'm5')).toBe(true);
});
it('空查询与无匹配返回空数组', () => {
expect(sessionService.searchMessages('')).toHaveLength(0);
expect(sessionService.searchMessages(' ')).toHaveLength(0);
expect(sessionService.searchMessages('不存在的关键词xyz')).toHaveLength(0);
});
it('NULL content 消息不进索引(assistant 仅有 tool_calls 场景)', () => {
insertMessage('m6', 's1', 'assistant', null, 6000);
const rows = db
.prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'nulltest'`)
.all() as Array<{ c: number }>;
expect(rows[0].c).toBe(0);
// FTS 行数与 messages 行数一致(NULL 行在 FTS 中占位但不索引内容)
const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
expect(fts).toBe(msg);
});
});
describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0', () => {
let db: any;
let dir: string;
let auditService: any;
beforeAll(async () => {
const { AuditService } = await import('../audit.service');
dir = mkdtempSync(join(tmpdir(), 'metona-audit-export-'));
db = new Database(':memory:');
db.exec(`
CREATE TABLE audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
iteration INTEGER,
event_type TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT NOT NULL,
details TEXT,
outcome TEXT,
duration_ms INTEGER,
created_at INTEGER NOT NULL,
prev_hash TEXT,
current_hash TEXT
);
`);
auditService = new AuditService(() => db);
});
afterAll(() => {
try {
db?.close();
} catch {
/* ignore */
}
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('exportJSONL 每行一条 JSON 记录(含链式哈希字段)', () => {
auditService.log({
sessionId: 's1',
eventType: 'tool_call',
actor: 'agent',
target: 'run_command',
details: { command: 'ls' },
outcome: 'success',
durationMs: 10,
});
auditService.log({
sessionId: 's1',
eventType: 'session_end',
actor: 'agent',
target: 'session',
outcome: 'success',
});
const jsonl = auditService.exportJSONL();
const lines = jsonl.trim().split('\n');
expect(lines).toHaveLength(2);
const first = JSON.parse(lines[0]);
expect(first.event_type).toBe('tool_call');
expect(first.session_id).toBe('s1');
expect(first.current_hash).toMatch(/^[0-9a-f]{64}$/);
// 第二条 prev_hash 等于第一条 current_hash(链完整保留在导出中)
const second = JSON.parse(lines[1]);
expect(second.prev_hash).toBe(first.current_hash);
});
it('exportCSV 含表头且 RFC 4180 转义(逗号/引号/换行字段)', () => {
auditService.log({
sessionId: 's2',
eventType: 'error',
actor: 'agent',
target: 'target,with,commas',
details: { msg: '含"双引号"和\n换行' },
outcome: 'error',
});
const csv = auditService.exportCSV();
const lines = csv.split('\n');
// 表头
expect(lines[0]).toBe(
'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
);
// 含特殊字符的 target 字段被双引号包裹
expect(csv).toContain('"target,with,commas"');
// details 存储为 JSON 字符串(内部双引号为 \" 转义),CSV 再转义后为 \""(反斜杠保留 + 引号翻倍)
expect(csv).toContain('\\""双引号\\""');
// 记录数与 JSONL 一致
const parsed = auditService
.exportJSONL()
.trim()
.split('\n')
.map((l: string) => JSON.parse(l));
expect(parsed.length).toBeGreaterThanOrEqual(3);
});
});
+135 -19
View File
@@ -37,10 +37,34 @@ function stableStringify(obj: unknown): string {
); );
} }
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change'; export type AuditEventType =
| 'tool_call'
| 'permission_check'
| 'error'
| 'llm_request'
| 'llm_response'
| 'session_start'
| 'session_end'
| 'config_change';
export type AuditActor = 'agent' | 'user' | 'system'; export type AuditActor = 'agent' | 'user' | 'system';
export type AuditOutcome = 'success' | 'denied' | 'error'; export type AuditOutcome = 'success' | 'denied' | 'error';
/** v0.5.0: 审计日志行类型(导出/查询共用) */
interface AuditRecordRow {
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}
export interface AuditEntry { export interface AuditEntry {
sessionId: string; sessionId: string;
iteration?: number; iteration?: number;
@@ -64,9 +88,15 @@ export class AuditService {
private computeHash( private computeHash(
prevHash: string, prevHash: string,
entry: { entry: {
sessionId: string; iteration: number | null; eventType: string; sessionId: string;
actor: string; target: string; details: string | null; iteration: number | null;
outcome: string | null; durationMs: number | null; createdAt: number; eventType: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
durationMs: number | null;
createdAt: number;
}, },
): string { ): string {
// #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定 // #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
@@ -94,8 +124,11 @@ export class AuditService {
private getLastHash(): string { private getLastHash(): string {
if (this.cachedLastHash !== null) return this.cachedLastHash; if (this.cachedLastHash !== null) return this.cachedLastHash;
const db = this.getDB(); const db = this.getDB();
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as { current_hash: string | null } | undefined; const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as
this.cachedLastHash = row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000'; | { current_hash: string | null }
| undefined;
this.cachedLastHash =
row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
return this.cachedLastHash; return this.cachedLastHash;
} }
@@ -120,10 +153,12 @@ export class AuditService {
createdAt: now, createdAt: now,
}); });
db.prepare(` db.prepare(
`
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash) INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `,
).run(
entry.sessionId, entry.sessionId,
entry.iteration ?? null, entry.iteration ?? null,
entry.eventType, entry.eventType,
@@ -257,11 +292,7 @@ export class AuditService {
/** /**
* *
*/ */
query(filters?: { query(filters?: { sessionId?: string; eventType?: AuditEventType; limit?: number }): Array<{
sessionId?: string;
eventType?: AuditEventType;
limit?: number;
}): Array<{
id: number; id: number;
session_id: string; session_id: string;
iteration: number | null; iteration: number | null;
@@ -311,6 +342,68 @@ export class AuditService {
}>; }>;
} }
/**
* v0.5.0: 导出审计日志为 JSONL JSON
*
* @returns JSONL id
*/
exportJSONL(): string {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as AuditRecordRow[];
return rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
}
/**
* v0.5.0: 导出审计日志为 CSV
*
* // RFC 4180 +
*
* @returns CSV id
*/
exportCSV(): string {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as AuditRecordRow[];
const headers = [
'id',
'session_id',
'iteration',
'event_type',
'actor',
'target',
'details',
'outcome',
'duration_ms',
'created_at',
'prev_hash',
'current_hash',
];
const escapeCsv = (value: string | number | null): string => {
const s = value === null ? '' : String(value);
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(
headers
.map((h) => escapeCsv((r as unknown as Record<string, string | number | null>)[h]))
.join(','),
);
}
return lines.join('\n') + '\n';
}
/** /**
* v0.2.0: 验证链式哈希完整性 * v0.2.0: 验证链式哈希完整性
* *
@@ -319,12 +412,30 @@ export class AuditService {
* *
* @returns ID * @returns ID
*/ */
verifyChain(): { valid: boolean; totalRecords: number; verifiedRecords: number; tamperedId: number | null } { verifyChain(): {
valid: boolean;
totalRecords: number;
verifiedRecords: number;
tamperedId: number | null;
} {
const db = this.getDB(); const db = this.getDB();
const rows = db.prepare('SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC').all() as Array<{ const rows = db
id: number; session_id: string; iteration: number | null; event_type: string; actor: string; .prepare(
target: string; details: string | null; outcome: string | null; duration_ms: number | null; 'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
created_at: number; prev_hash: string | null; current_hash: string | null; )
.all() as Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}>; }>;
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000'; let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
@@ -369,7 +480,12 @@ export class AuditService {
prevHash = row.current_hash; prevHash = row.current_hash;
continue; continue;
} }
return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id }; return {
valid: false,
totalRecords: rows.length,
verifiedRecords: verified,
tamperedId: row.id,
};
} }
prevHash = row.current_hash; prevHash = row.current_hash;
+67 -4
View File
@@ -211,10 +211,12 @@ export class DatabaseService {
); );
-- ===== MCP ===== -- ===== MCP =====
-- v0.5.0: 建表 CHECK streamable-http 6 schema
-- 6
CREATE TABLE IF NOT EXISTS mcp_servers ( CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse')), transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT, command TEXT,
args TEXT, args TEXT,
url TEXT, url TEXT,
@@ -305,6 +307,28 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx); CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id); CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
-- ===== v0.5.0: 消息全文搜索FTS5 content =====
-- sessions:searchContent IPC 使 MATCH
-- messages INSERT/UPDATE/DELETE
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content=messages,
content_rowid=rowid
);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
`); `);
// 审计日志防篡改触发器(INSERT-ONLY // 审计日志防篡改触发器(INSERT-ONLY
@@ -414,6 +438,28 @@ export class DatabaseService {
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响 // 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
} }
// v0.5.0 迁移 7: 存量库 FTS 索引回填
// messages_fts 虚表由 createTables 创建(IF NOT EXISTS),但升级到 v0.5.0 的存量库
// 已有消息不会自动进入索引(触发器只覆盖新写入)。行数不一致时执行 rebuild 全量回填。
try {
const ftsCount = db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as {
c: number;
};
const msgCount = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number };
if (ftsCount.c !== msgCount.c) {
log.info(
`[DB] Migration: rebuilding messages_fts index (fts=${ftsCount.c}, messages=${msgCount.c})`,
);
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
log.info('[DB] Migration: messages_fts index rebuilt successfully');
}
} catch (error) {
const msg = toErrorMessage(error);
log.warn(`[DB] Migration 7 (messages_fts rebuild) skipped: ${msg}`);
// 非致命 — 索引回填失败仅影响全文搜索结果完整性,不影响消息读写;
// 新写入的消息仍通过触发器正常进入索引
}
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL // C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
// @see project_memory.md — Assistant messages with tool_calls must set content to null // @see project_memory.md — Assistant messages with tool_calls must set content to null
// SQLite 不支持 ALTER COLUMN,需要重建表 // SQLite 不支持 ALTER COLUMN,需要重建表
@@ -457,13 +503,30 @@ export class DatabaseService {
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引, // #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询, // 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp // 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
// v0.5.0: DROP TABLE messages 连带删除了 FTS 触发器,此处必须重建
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role); CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
`);
CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
END;
`);
}); });
rebuildMessages(); rebuildMessages();
// v0.5.0: 表重建后 rowid 全部重排,FTS 索引指向的旧 rowid 已失效 — 无条件全量回填
db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)'); log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
} }
} catch (error) { } catch (error) {
+156 -21
View File
@@ -86,11 +86,15 @@ export class SessionService {
const db = this.getDBFn(); const db = this.getDBFn();
const archived = options?.archived ?? false; const archived = options?.archived ?? false;
const rows = db.prepare(` const rows = db
.prepare(
`
SELECT * FROM sessions SELECT * FROM sessions
WHERE archived = ? WHERE archived = ?
ORDER BY pinned DESC, updated_at DESC ORDER BY pinned DESC, updated_at DESC
`).all(archived ? 1 : 0) as SessionRow[]; `,
)
.all(archived ? 1 : 0) as SessionRow[];
return rows.map((row) => this.toSessionInfo(row)); return rows.map((row) => this.toSessionInfo(row));
} }
@@ -104,10 +108,12 @@ export class SessionService {
const now = Date.now(); const now = Date.now();
const sessionTitle = title ?? '新会话'; const sessionTitle = title ?? '新会话';
db.prepare(` db.prepare(
`
INSERT INTO sessions (id, title, created_at, updated_at) INSERT INTO sessions (id, title, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`).run(id, sessionTitle, now, now); `,
).run(id, sessionTitle, now, now);
log.info(`Session created: ${id} (${sessionTitle})`); log.info(`Session created: ${id} (${sessionTitle})`);
@@ -128,9 +134,13 @@ export class SessionService {
*/ */
rename(sessionId: string, title: string): boolean { rename(sessionId: string, title: string): boolean {
const db = this.getDBFn(); const db = this.getDBFn();
const result = db.prepare(` const result = db
.prepare(
`
UPDATE sessions SET title = ?, updated_at = ? WHERE id = ? UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?
`).run(title, Date.now(), sessionId); `,
)
.run(title, Date.now(), sessionId);
if (result.changes > 0) { if (result.changes > 0) {
log.info(`Session renamed: ${sessionId}${title}`); log.info(`Session renamed: ${sessionId}${title}`);
@@ -158,9 +168,13 @@ export class SessionService {
*/ */
pin(sessionId: string, pinned: boolean): boolean { pin(sessionId: string, pinned: boolean): boolean {
const db = this.getDBFn(); const db = this.getDBFn();
const result = db.prepare(` const result = db
.prepare(
`
UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ? UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?
`).run(pinned ? 1 : 0, Date.now(), sessionId); `,
)
.run(pinned ? 1 : 0, Date.now(), sessionId);
return result.changes > 0; return result.changes > 0;
} }
@@ -170,9 +184,13 @@ export class SessionService {
*/ */
archive(sessionId: string, archived: boolean): boolean { archive(sessionId: string, archived: boolean): boolean {
const db = this.getDBFn(); const db = this.getDBFn();
const result = db.prepare(` const result = db
.prepare(
`
UPDATE sessions SET archived = ?, updated_at = ? WHERE id = ? UPDATE sessions SET archived = ?, updated_at = ? WHERE id = ?
`).run(archived ? 1 : 0, Date.now(), sessionId); `,
)
.run(archived ? 1 : 0, Date.now(), sessionId);
return result.changes > 0; return result.changes > 0;
} }
@@ -252,7 +270,9 @@ export class SessionService {
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?', 'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
).run(sessionId, anchor.rid + (inclusive ? 0 : 1)); ).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
log.info(`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`); log.info(
`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`,
);
} }
return result.changes > 0; return result.changes > 0;
} }
@@ -279,10 +299,12 @@ export class SessionService {
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态) // message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚 // 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
const saveMessageTxn = db.transaction(() => { const saveMessageTxn = db.transaction(() => {
db.prepare(` db.prepare(
`
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at) INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `,
).run(
id, id,
params.sessionId, params.sessionId,
params.role, params.role,
@@ -296,11 +318,13 @@ export class SessionService {
); );
// 更新会话的 updated_at 和 message_count // 更新会话的 updated_at 和 message_count
db.prepare(` db.prepare(
`
UPDATE sessions UPDATE sessions
SET updated_at = ?, message_count = message_count + 1 SET updated_at = ?, message_count = message_count + 1
WHERE id = ? WHERE id = ?
`).run(now, params.sessionId); `,
).run(now, params.sessionId);
}); });
saveMessageTxn(); saveMessageTxn();
@@ -322,9 +346,11 @@ export class SessionService {
*/ */
updateTokenUsage(sessionId: string, tokens: number): void { updateTokenUsage(sessionId: string, tokens: number): void {
const db = this.getDBFn(); const db = this.getDBFn();
db.prepare(` db.prepare(
`
UPDATE sessions SET total_tokens = total_tokens + ? WHERE id = ? UPDATE sessions SET total_tokens = total_tokens + ? WHERE id = ?
`).run(tokens, sessionId); `,
).run(tokens, sessionId);
} }
/** /**
@@ -342,19 +368,126 @@ export class SessionService {
clearMessages(sessionId: string): boolean { clearMessages(sessionId: string): boolean {
const db = this.getDBFn(); const db = this.getDBFn();
const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId); const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(Date.now(), sessionId); db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(
Date.now(),
sessionId,
);
return result.changes > 0; return result.changes > 0;
} }
/**
* v0.5.0: 全文搜索会话内容FTS5
*
* +
* FTS5 +
* FTS AND/OR/NOT/*
*
* @param query
* @param options.maxSessions 20
* @param options.scanLimit 200
*/
searchMessages(
query: string,
options: { maxSessions?: number; scanLimit?: number } = {},
): Array<{
sessionId: string;
sessionTitle: string;
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}> {
const db = this.getDBFn();
const { maxSessions = 20, scanLimit = 200 } = options;
const trimmed = query.trim();
if (!trimmed) return [];
// FTS5 短语转义:内部双引号翻倍,整体作为带引号短语匹配
const phrase = `"${trimmed.replace(/"/g, '""')}"`;
interface MatchRow {
id: string;
session_id: string;
role: string;
created_at: number;
snip: string;
}
let rows: MatchRow[];
try {
rows = db
.prepare(
`
SELECT m.id, m.session_id, m.role, m.created_at,
snippet(messages_fts, 0, '[', '] ', ' … ', 12) AS snip
FROM messages_fts f
JOIN messages m ON m.rowid = f.rowid
WHERE messages_fts MATCH ?
ORDER BY m.created_at DESC
LIMIT ?
`,
)
.all(phrase, scanLimit) as MatchRow[];
} catch (err) {
log.warn('[SessionService] FTS search failed:', (err as Error).message);
return [];
}
// 按会话聚合:取每个会话的最新匹配(rows 已按时间倒序,首次出现即最新)
const aggregated = new Map<
string,
{
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}
>();
for (const row of rows) {
const existing = aggregated.get(row.session_id);
if (existing) {
existing.matchCount++;
} else {
aggregated.set(row.session_id, {
matchCount: 1,
lastMessageId: row.id,
lastRole: row.role,
snippet: row.snip,
timestamp: row.created_at,
});
}
}
if (aggregated.size === 0) return [];
// 补全会话标题(archived 一起搜——搜索不应因归档而缺结果)
const sessionIds = Array.from(aggregated.keys());
const placeholders = sessionIds.map(() => '?').join(',');
const titleRows = db
.prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`)
.all(...sessionIds) as Array<{ id: string; title: string }>;
const titleMap = new Map(titleRows.map((r) => [r.id, r.title]));
return Array.from(aggregated.entries())
.map(([sessionId, info]) => ({
sessionId,
sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)',
...info,
}))
.slice(0, maxSessions);
}
/** /**
* trace token metadata JSON * trace token metadata JSON
*/ */
saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void { saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void {
const db = this.getDBFn(); const db = this.getDBFn();
const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage }); const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage });
db.prepare(` db.prepare(
`
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ? UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
`).run(metadata, Date.now(), sessionId); `,
).run(metadata, Date.now(), sessionId);
} }
/** /**
@@ -362,7 +495,9 @@ export class SessionService {
*/ */
getTraceData(sessionId: string): { traceSteps: unknown[]; tokenUsage: unknown } | null { getTraceData(sessionId: string): { traceSteps: unknown[]; tokenUsage: unknown } | null {
const db = this.getDBFn(); const db = this.getDBFn();
const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as { metadata: string } | undefined; const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as
| { metadata: string }
| undefined;
if (!row?.metadata) return null; if (!row?.metadata) return null;
try { try {
const data = JSON.parse(row.metadata); const data = JSON.parse(row.metadata);
+3 -1
View File
@@ -1,6 +1,8 @@
// ESLint Flat ConfigP1-5 // ESLint Flat ConfigP1-5
// 项目此前有 lint 脚本但无任何配置文件(npm run lint 直接报错)。 // 项目此前有 lint 脚本但无任何配置文件(npm run lint 直接报错)。
// 规则取向:未用变量/any 等降为 warn(存量代码渐进清理),错误级规则保持零容忍。 // 规则取向:部分规则(no-unused-vars / no-explicit-any)配置为 warn 级别以便渐进收紧,
// 但执行标准是零容忍 —— `npm run lint` 必须返回 0 problems0 error / 0 warning),
// 新增代码不允许引入任何 warning(v0.4.0 起确立的工程质量基线)。
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.4.1", "version": "0.5.0",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js", "main": "dist-electron/main/main.js",
"author": "Metona Team", "author": "Metona Team",
@@ -16,7 +16,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\"",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"typecheck:node": "tsc -p tsconfig.node.json --noEmit", "typecheck:node": "tsc -p tsconfig.node.json --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
+7 -2
View File
@@ -42,6 +42,7 @@ import {
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react'; import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
import { useAgentStore } from '@renderer/stores/agent-store';
interface ConfirmationRequest { interface ConfirmationRequest {
toolCallId: string; toolCallId: string;
@@ -87,9 +88,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
>([]); >([]);
// v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步) // v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步)
// v0.5.0: 传当前会话 ID — 拒绝记忆按会话隔离,只展示本会话的记忆
const refreshRememberedDenials = useCallback(async () => { const refreshRememberedDenials = useCallback(async () => {
try { try {
const result = await window.metona?.tool?.getRememberedDenials(); const sessionId = useAgentStore.getState().currentSessionId ?? undefined;
const result = await window.metona?.tool?.getRememberedDenials(sessionId);
setRememberedDenials(result?.success ? (result.data ?? []) : []); setRememberedDenials(result?.success ? (result.data ?? []) : []);
} catch { } catch {
setRememberedDenials([]); setRememberedDenials([]);
@@ -97,9 +100,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
}, []); }, []);
// v0.4.1: 重置某个工具的拒绝记忆(恢复询问) // v0.4.1: 重置某个工具的拒绝记忆(恢复询问)
// v0.5.0: 传当前会话 ID,只重置本会话的记忆
const handleResetDenial = useCallback(async (toolName: string) => { const handleResetDenial = useCallback(async (toolName: string) => {
try { try {
await window.metona?.tool?.resetRememberedDenial(toolName); const sessionId = useAgentStore.getState().currentSessionId ?? undefined;
await window.metona?.tool?.resetRememberedDenial(toolName, sessionId);
setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName)); setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName));
} catch { } catch {
// 重置失败保持现状,TTL 到期后仍会自动恢复 // 重置失败保持现状,TTL 到期后仍会自动恢复
+374 -147
View File
@@ -7,11 +7,31 @@
*/ */
import { import {
Menu, MenuItem, ListItemIcon, ListItemText, Menu,
Dialog, DialogTitle, DialogContent, DialogActions, MenuItem,
Button, TextField, Typography, ListItemIcon,
ListItemText,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Typography,
} from '@mui/material'; } from '@mui/material';
import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react'; import {
Copy,
Quote,
Edit,
Trash2,
RotateCcw,
Eye,
Code,
ExternalLink,
Pin,
Archive,
FileDown,
} from 'lucide-react';
import { create } from 'zustand'; import { create } from 'zustand';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store'; import { useSessionStore } from '@renderer/stores/session-store';
@@ -35,13 +55,33 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element { export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
return ( return (
<Menu open onClose={onClose} anchorReference="anchorPosition" anchorPosition={{ top: y, left: x }} slotProps={{ paper: { sx: { minWidth: 160 } } }}> <Menu
open
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={{ top: y, left: x }}
slotProps={{ paper: { sx: { minWidth: 160 } } }}
>
{items.map((item) => { {items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
return ( return (
<MenuItem key={item.id} onClick={() => { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense> <MenuItem
<ListItemIcon sx={{ minWidth: 28 }}><Icon size={12} /></ListItemIcon> key={item.id}
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>{item.label}</ListItemText> onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
dense
>
<ListItemIcon sx={{ minWidth: 28 }}>
<Icon size={12} />
</ListItemIcon>
<ListItemText slotProps={{ primary: { sx: { fontSize: 12 } } }}>
{item.label}
</ListItemText>
</MenuItem> </MenuItem>
); );
})} })}
@@ -85,12 +125,25 @@ const useDialogStore = create<DialogState>((set, get) => ({
promptLabel: '', promptLabel: '',
promptValue: '', promptValue: '',
promptResolve: null, promptResolve: null,
confirm: (message, title = '确认') => new Promise<boolean>((resolve) => { confirm: (message, title = '确认') =>
set({ confirmOpen: true, confirmTitle: title, confirmMessage: message, confirmResolve: resolve }); new Promise<boolean>((resolve) => {
}), set({
prompt: (label, defaultValue = '', title = '输入') => new Promise<string | null>((resolve) => { confirmOpen: true,
set({ promptOpen: true, promptTitle: title, promptLabel: label, promptValue: defaultValue, promptResolve: resolve }); confirmTitle: title,
}), confirmMessage: message,
confirmResolve: resolve,
});
}),
prompt: (label, defaultValue = '', title = '输入') =>
new Promise<string | null>((resolve) => {
set({
promptOpen: true,
promptTitle: title,
promptLabel: label,
promptValue: defaultValue,
promptResolve: resolve,
});
}),
setPromptValue: (v) => set({ promptValue: v }), setPromptValue: (v) => set({ promptValue: v }),
closeConfirm: (ok) => { closeConfirm: (ok) => {
const { confirmResolve } = get(); const { confirmResolve } = get();
@@ -100,7 +153,13 @@ const useDialogStore = create<DialogState>((set, get) => ({
closePrompt: (value) => { closePrompt: (value) => {
const { promptResolve } = get(); const { promptResolve } = get();
promptResolve?.(value); promptResolve?.(value);
set({ promptOpen: false, promptResolve: null, promptTitle: '', promptLabel: '', promptValue: '' }); set({
promptOpen: false,
promptResolve: null,
promptTitle: '',
promptLabel: '',
promptValue: '',
});
}, },
})); }));
@@ -130,8 +189,18 @@ export function ContextMenuDialogHost(): React.JSX.Element {
<Typography variant="body2">{confirmMessage}</Typography> <Typography variant="body2">{confirmMessage}</Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => closeConfirm(false)} color="inherit" size="small"></Button> <Button onClick={() => closeConfirm(false)} color="inherit" size="small">
<Button onClick={() => closeConfirm(true)} variant="contained" color="error" size="small" autoFocus></Button>
</Button>
<Button
onClick={() => closeConfirm(true)}
variant="contained"
color="error"
size="small"
autoFocus
>
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog open={promptOpen} onClose={() => closePrompt(null)} maxWidth="xs" fullWidth> <Dialog open={promptOpen} onClose={() => closePrompt(null)} maxWidth="xs" fullWidth>
@@ -144,12 +213,18 @@ export function ContextMenuDialogHost(): React.JSX.Element {
autoFocus autoFocus
fullWidth fullWidth
size="small" size="small"
onKeyDown={(e) => { if (e.key === 'Enter') closePrompt(promptValue); }} onKeyDown={(e) => {
if (e.key === 'Enter') closePrompt(promptValue);
}}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => closePrompt(null)} color="inherit" size="small"></Button> <Button onClick={() => closePrompt(null)} color="inherit" size="small">
<Button onClick={() => closePrompt(promptValue)} variant="contained" size="small"></Button>
</Button>
<Button onClick={() => closePrompt(promptValue)} variant="contained" size="small">
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</> </>
@@ -165,7 +240,9 @@ export function ContextMenuDialogHost(): React.JSX.Element {
function copyWithToast(text: string): void { function copyWithToast(text: string): void {
navigator.clipboard.writeText(text).catch((err) => { navigator.clipboard.writeText(text).catch((err) => {
console.error('[Clipboard]', err); console.error('[Clipboard]', err);
import('@metona-team/metona-toast').then((mod) => mod.default.error('复制失败')).catch(() => {}); import('@metona-team/metona-toast')
.then((mod) => mod.default.error('复制失败'))
.catch(() => {});
}); });
} }
@@ -179,10 +256,23 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
const content = d.content ?? ''; const content = d.content ?? '';
const items: ContextMenuItem[] = [ const items: ContextMenuItem[] = [
{ id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) }, { id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) },
{ id: 'quote', icon: Quote, label: '引用回复', action: () => { {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]'); id: 'quote',
if (input) { input.value = content.split('\n').map((l: string) => `> ${l}`).join('\n') + '\n\n'; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); } icon: Quote,
}}, label: '引用回复',
action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-chat-input]');
if (input) {
input.value =
content
.split('\n')
.map((l: string) => `> ${l}`)
.join('\n') + '\n\n';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.focus();
}
},
},
]; ];
// P2-11: assistant 消息支持重新生成(删除最后一条用户消息后的回复并重发) // P2-11: assistant 消息支持重新生成(删除最后一条用户消息后的回复并重发)
if (d.role === 'assistant') { if (d.role === 'assistant') {
@@ -190,7 +280,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
id: 'regenerate', id: 'regenerate',
icon: RotateCcw, icon: RotateCcw,
label: '重新生成', label: '重新生成',
action: () => { void useAgentStore.getState().regenerate(); }, action: () => {
void useAgentStore.getState().regenerate();
},
}); });
} }
return items; return items;
@@ -199,13 +291,42 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
case 'tool-call': { case 'tool-call': {
const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined; const tc = data as { args?: Record<string, unknown>; result?: unknown } | undefined;
return [ return [
{ id: 'view-params', icon: Eye, label: '查看参数', action: () => { if (tc?.args) copyWithToast(JSON.stringify(tc.args, null, 2)); }}, {
{ id: 'view-result', icon: Eye, label: '查看完整结果', action: () => { if (tc?.result) copyWithToast(JSON.stringify(tc.result, null, 2)); }}, id: 'view-params',
{ id: 'copy-result', icon: Copy, label: '复制结果', action: () => { if (tc?.result) copyWithToast(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result)); }}, icon: Eye,
{ id: 're-execute', icon: RotateCcw, label: '重新执行', action: () => { label: '查看参数',
const m = [...useAgentStore.getState().messages].reverse().find((m) => m.role === 'user'); action: () => {
if (m) useAgentStore.getState().sendMessage(m.content); if (tc?.args) copyWithToast(JSON.stringify(tc.args, null, 2));
}}, },
},
{
id: 'view-result',
icon: Eye,
label: '查看完整结果',
action: () => {
if (tc?.result) copyWithToast(JSON.stringify(tc.result, null, 2));
},
},
{
id: 'copy-result',
icon: Copy,
label: '复制结果',
action: () => {
if (tc?.result)
copyWithToast(typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result));
},
},
{
id: 're-execute',
icon: RotateCcw,
label: '重新执行',
action: () => {
const m = [...useAgentStore.getState().messages]
.reverse()
.find((m) => m.role === 'user');
if (m) useAgentStore.getState().sendMessage(m.content);
},
},
]; ];
} }
@@ -217,135 +338,241 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
import('@metona-team/metona-toast').then((mod) => mod.default.error(msg)).catch(() => {}); import('@metona-team/metona-toast').then((mod) => mod.default.error(msg)).catch(() => {});
}; };
return [ return [
{ id: 'rename', icon: Edit, label: '重命名', action: async () => { {
if (!sid) return; id: 'rename',
// 用 MUI Dialog 替代原生 prompt()Electron 下不可靠,且与 MUI 风格不一致) icon: Edit,
const currentTitle = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? ''; label: '重命名',
const t = await useDialogStore.getState().prompt('新会话名称', currentTitle, '重命名会话'); action: async () => {
if (!t?.trim()) return; if (!sid) return;
try { // 用 MUI Dialog 替代原生 prompt()Electron 下不可靠,且与 MUI 风格不一致)
const r = await window.metona?.sessions?.rename(sid, t.trim()); const currentTitle =
if (r?.success) { useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
useSessionStore.getState().updateSession(sid, { title: t.trim() }); const t = await useDialogStore
} else { .getState()
showError(r?.error ?? '重命名失败'); .prompt('新会话名称', currentTitle, '重命名会话');
} if (!t?.trim()) return;
} catch (err) { try {
console.error('[ContextMenu]', err); const r = await window.metona?.sessions?.rename(sid, t.trim());
showError('重命名失败'); if (r?.success) {
} useSessionStore.getState().updateSession(sid, { title: t.trim() });
}}, } else {
{ id: 'pin', icon: Pin, label: '置顶', action: async () => { showError(r?.error ?? '重命名失败');
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
if (!s) return;
const newPinned = !s.pinned;
try {
const r = await window.metona?.sessions?.pin(sid, newPinned);
if (r?.success) {
useSessionStore.getState().pinSession(sid, newPinned);
} else {
showError(r?.error ?? '置顶失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('置顶失败');
}
}},
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
{ id: 'archive', icon: Archive, label: '归档', action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
const newArchived = s ? !s.archived : true;
try {
const r = await window.metona?.sessions?.archive(sid, newArchived);
if (r?.success) {
useSessionStore.getState().archiveSession(sid, newArchived);
} else {
showError(r?.error ?? '归档失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('归档失败');
}
}},
{ id: 'export', icon: FileDown, label: '导出 JSON', action: () => {
if (sid) window.metona?.sessions.getMessages(sid).then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `session-${sid}.json`; a.click();
}).catch((err) => {
console.error('[ContextMenu]', err);
showError('导出失败');
});
}},
// P2-11: 导出 Markdown(人类可读格式)
{ id: 'export-md', icon: FileDown, label: '导出 Markdown', action: async () => {
if (!sid) return;
try {
const msgs = await window.metona?.sessions.getMessages(sid);
const { buildSessionMarkdown, downloadMarkdown } = await import('@renderer/lib/export-markdown');
const title = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '会话导出';
const md = buildSessionMarkdown(title, msgs as Array<{ id: string; role: string; content: string | null; toolCalls?: Array<{ name: string }>; attachments?: Array<{ name: string }>; timestamp: number }>);
downloadMarkdown(`session-${sid}.md`, md);
} catch (err) {
console.error('[ContextMenu]', err);
showError('导出失败');
}
}},
{ id: 'delete', icon: Trash2, label: '删除', action: async () => {
if (!sid) return;
// 用 MUI Dialog 替代原生 confirm()Electron 下不可靠)
// 与 Sidebar.tsx 的 showDeleteDialog 行为一致
const ok = await useDialogStore.getState().confirm('确定删除此会话?此操作不可撤销。', '删除会话');
if (!ok) return;
try {
const r = await window.metona?.sessions?.delete(sid);
if (r?.success) {
useSessionStore.getState().removeSession(sid);
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
// 与 Sidebar.tsx 的 confirmDelete 行为一致
if (useAgentStore.getState().currentSessionId === sid) {
useAgentStore.setState({
currentSessionId: null,
messages: [],
traceSteps: [],
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 },
currentRunId: null,
currentIteration: 0,
});
} }
} else { } catch (err) {
showError(r?.error ?? '删除失败'); console.error('[ContextMenu]', err);
showError('重命名失败');
} }
} catch (err) { },
console.error('[ContextMenu]', err); },
showError('删除失败'); {
} id: 'pin',
}}, icon: Pin,
label: '置顶',
action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
if (!s) return;
const newPinned = !s.pinned;
try {
const r = await window.metona?.sessions?.pin(sid, newPinned);
if (r?.success) {
useSessionStore.getState().pinSession(sid, newPinned);
} else {
showError(r?.error ?? '置顶失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('置顶失败');
}
},
},
// 修复: archive 之前完全未调用 IPC,UI 改了但 DB 没改,重启后状态丢失
{
id: 'archive',
icon: Archive,
label: '归档',
action: async () => {
if (!sid) return;
const s = useSessionStore.getState().sessions.find((x) => x.id === sid);
const newArchived = s ? !s.archived : true;
try {
const r = await window.metona?.sessions?.archive(sid, newArchived);
if (r?.success) {
useSessionStore.getState().archiveSession(sid, newArchived);
} else {
showError(r?.error ?? '归档失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('归档失败');
}
},
},
{
id: 'export',
icon: FileDown,
label: '导出 JSON',
action: () => {
if (sid)
window.metona?.sessions
.getMessages(sid)
.then((msgs) => {
const b = new Blob([JSON.stringify(msgs, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `session-${sid}.json`;
a.click();
})
.catch((err) => {
console.error('[ContextMenu]', err);
showError('导出失败');
});
},
},
// P2-11: 导出 Markdown(人类可读格式)
{
id: 'export-md',
icon: FileDown,
label: '导出 Markdown',
action: async () => {
if (!sid) return;
try {
const msgs = await window.metona?.sessions.getMessages(sid);
const { buildSessionMarkdown, downloadMarkdown } =
await import('@renderer/lib/export-markdown');
const title =
useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '会话导出';
const md = buildSessionMarkdown(
title,
msgs as Array<{
id: string;
role: string;
content: string | null;
toolCalls?: Array<{ name: string }>;
attachments?: Array<{ name: string; type: string }>;
timestamp: number;
}>,
);
downloadMarkdown(`session-${sid}.md`, md);
} catch (err) {
console.error('[ContextMenu]', err);
showError('导出失败');
}
},
},
{
id: 'delete',
icon: Trash2,
label: '删除',
action: async () => {
if (!sid) return;
// 用 MUI Dialog 替代原生 confirm()Electron 下不可靠)
// 与 Sidebar.tsx 的 showDeleteDialog 行为一致
const ok = await useDialogStore
.getState()
.confirm('确定删除此会话?此操作不可撤销。', '删除会话');
if (!ok) return;
try {
const r = await window.metona?.sessions?.delete(sid);
if (r?.success) {
useSessionStore.getState().removeSession(sid);
// 修复: 删除当前会话时同步清空 agent-store,否则 ChatPanel 和 DetailPanel 仍显示已删除会话的内容
// 与 Sidebar.tsx 的 confirmDelete 行为一致
if (useAgentStore.getState().currentSessionId === sid) {
useAgentStore.setState({
currentSessionId: null,
messages: [],
traceSteps: [],
tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
currentRunId: null,
currentIteration: 0,
});
}
} else {
showError(r?.error ?? '删除失败');
}
} catch (err) {
console.error('[ContextMenu]', err);
showError('删除失败');
}
},
},
]; ];
} }
case 'code-block': { case 'code-block': {
const code = (data as { code?: string })?.code; const code = (data as { code?: string })?.code;
return [ return [
{ id: 'copy-code', icon: Copy, label: '复制代码', action: () => { if (code) copyWithToast(code); }}, {
{ id: 'open-editor', icon: Code, label: '在编辑器中打开', action: () => { if (code) window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank'); }}, id: 'copy-code',
icon: Copy,
label: '复制代码',
action: () => {
if (code) copyWithToast(code);
},
},
{
id: 'open-editor',
icon: Code,
label: '在编辑器中打开',
action: () => {
if (code)
window.open(URL.createObjectURL(new Blob([code], { type: 'text/plain' })), '_blank');
},
},
]; ];
} }
case 'trace-step': { case 'trace-step': {
const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> } | undefined; const step = data as
| { thought?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> }
| undefined;
return [ return [
{ id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { if (step?.thought) copyWithToast(step.thought); }}, {
{ id: 'copy-params', icon: Copy, label: '复制工具参数', action: () => { id: 'copy-thought',
if (step?.toolCalls) copyWithToast(step.toolCalls.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`).join('\n')); icon: Copy,
}}, label: '复制 Thought',
{ id: 'export', icon: ExternalLink, label: '导出步骤详情', action: () => { action: () => {
if (step) { const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = `trace-step-${Date.now()}.json`; a.click(); } if (step?.thought) copyWithToast(step.thought);
}}, },
},
{
id: 'copy-params',
icon: Copy,
label: '复制工具参数',
action: () => {
if (step?.toolCalls)
copyWithToast(
step.toolCalls
.map((tc) => `${tc.name}: ${JSON.stringify(tc.args, null, 2)}`)
.join('\n'),
);
},
},
{
id: 'export',
icon: ExternalLink,
label: '导出步骤详情',
action: () => {
if (step) {
const b = new Blob([JSON.stringify(step, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `trace-step-${Date.now()}.json`;
a.click();
}
},
},
]; ];
} }
default: return []; default:
return [];
} }
} }
+274 -22
View File
@@ -4,14 +4,48 @@
* 使 MUI Table - * 使 MUI Table -
*/ */
import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material'; import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
import { Activity, Cpu } from 'lucide-react'; import { Activity, Cpu, Bot } from 'lucide-react';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store'; import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants'; import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
import { formatDuration } from '@renderer/lib/formatters'; import { formatDuration } from '@renderer/lib/formatters';
const STATUS_ICONS: Record<AgentStatus, typeof Activity> = { idle: Activity, thinking: Cpu, executing: Cpu, error: Activity }; const STATUS_ICONS: Record<AgentStatus, typeof Activity> = {
idle: Activity,
thinking: Cpu,
executing: Cpu,
error: Activity,
};
/** v0.5.0: SubAgent 状态区条目 */
interface SubAgentItem {
taskId: string;
description: string;
status: 'delegated' | 'running' | 'completed' | 'error';
depth: number;
durationMs?: number;
iterations?: number;
error?: string;
updatedAt: number;
}
/** SubAgent 状态展示上限(防长会话无限累积) */
const MAX_SUBAGENT_ITEMS = 20;
const SUB_STATUS_COLORS: Record<SubAgentItem['status'], string> = {
delegated: '#fbbf24',
running: '#a855f7',
completed: '#34d399',
error: '#f87171',
};
const SUB_STATUS_LABELS: Record<SubAgentItem['status'], string> = {
delegated: '已委派',
running: '运行中',
completed: '已完成',
error: '失败',
};
export function AgentMonitor(): React.JSX.Element { export function AgentMonitor(): React.JSX.Element {
const agentStatus = useAgentStore((s) => s.agentStatus); const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -21,20 +55,57 @@ export function AgentMonitor(): React.JSX.Element {
const maxIterations = useAgentStore((s) => s.maxIterations); const maxIterations = useAgentStore((s) => s.maxIterations);
const traceSteps = useAgentStore((s) => s.traceSteps); const traceSteps = useAgentStore((s) => s.traceSteps);
const setMaxIterations = useAgentStore((s) => s.setMaxIterations); const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
const sessionId = useAgentStore((s) => s.currentSessionId);
// v0.5.0: SubAgent 状态(监听主进程生命周期事件,按父会话过滤)
const [subAgents, setSubAgents] = useState<SubAgentItem[]>([]);
useEffect(() => {
if (!window.metona?.agent?.onSubAgentEvent) return;
const unsubscribe = window.metona.agent.onSubAgentEvent((e) => {
// 只展示当前会话派生的 SubAgent
if (!sessionId || e.parentSessionId !== sessionId) return;
setSubAgents((prev) => {
const next = prev.filter((item) => item.taskId !== e.taskId);
next.unshift({
taskId: e.taskId,
description: e.description,
status: e.status,
depth: e.depth,
durationMs: e.durationMs,
iterations: e.iterations,
error: e.error,
updatedAt: Date.now(),
});
return next.slice(0, MAX_SUBAGENT_ITEMS);
});
});
return unsubscribe;
}, [sessionId]);
// 切换会话时清空(新会话的 SubAgent 从零开始)
useEffect(() => {
setSubAgents([]);
}, [sessionId]);
useEffect(() => { useEffect(() => {
// M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录 // M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
let cancelled = false; let cancelled = false;
if (window.metona?.config?.get) { if (window.metona?.config?.get) {
window.metona.config.get('agent.maxIterations').then((v) => { window.metona.config
if (cancelled) return; .get('agent.maxIterations')
if (typeof v === 'number' && v > 0) setMaxIterations(v); .then((v) => {
}).catch((err) => { if (cancelled) return;
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常 if (typeof v === 'number' && v > 0) setMaxIterations(v);
console.error('[AgentMonitor] Failed to load maxIterations:', err); })
}); .catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
console.error('[AgentMonitor] Failed to load maxIterations:', err);
});
} }
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [setMaxIterations]); }, [setMaxIterations]);
// L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组 // L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组
@@ -51,24 +122,56 @@ export function AgentMonitor(): React.JSX.Element {
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}> <Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Cpu size={14} style={{ color: '#818cf8' }} /> <Cpu size={14} style={{ color: '#818cf8' }} />
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1, color: 'text.secondary' }}> <Typography
variant="caption"
sx={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
color: 'text.secondary',
}}
>
Agent Agent
</Typography> </Typography>
</Stack> </Stack>
{/* 状态指示 */} {/* 状态指示 */}
<Box sx={{ px: 1.5, py: 1, borderRadius: 1.5, mb: 1.5, bgcolor: 'background.default', border: '1px solid', borderColor: 'divider' }}> <Box
sx={{
px: 1.5,
py: 1,
borderRadius: 1.5,
mb: 1.5,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}> <Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<StatusIcon <StatusIcon
size={14} size={14}
style={{ style={{
color: statusColor, color: statusColor,
animation: (agentStatus === 'thinking' || agentStatus === 'executing') ? 'pulse 2s infinite' : 'none', animation:
agentStatus === 'thinking' || agentStatus === 'executing'
? 'pulse 2s infinite'
: 'none',
}} }}
/> />
<Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>{statusLabel}</Typography> <Typography variant="caption" sx={{ fontWeight: 500, color: statusColor }}>
{statusLabel}
</Typography>
{(agentStatus === 'thinking' || agentStatus === 'executing') && ( {(agentStatus === 'thinking' || agentStatus === 'executing') && (
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: statusColor, animation: 'pulse 1.5s infinite', ml: 'auto' }} /> <Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: statusColor,
animation: 'pulse 1.5s infinite',
ml: 'auto',
}}
/>
)} )}
</Stack> </Stack>
</Box> </Box>
@@ -77,33 +180,182 @@ export function AgentMonitor(): React.JSX.Element {
<Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}> <Table size="small" sx={{ '& .MuiTableCell-root': { border: 0, py: 0.5, px: 0.5 } }}>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>Provider</TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11, width: '40%' }}>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.primary', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}> Provider
</TableCell>
<TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.primary',
textAlign: 'right',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 0,
}}
>
{PROVIDER_LABELS[provider] ?? provider} {PROVIDER_LABELS[provider] ?? provider}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.primary', textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}> <TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.primary',
textAlign: 'right',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 0,
}}
>
{model} {model}
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}> <TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{currentIteration} / {maxIterations} {currentIteration} / {maxIterations}
</TableCell> </TableCell>
</TableRow> </TableRow>
{totalDuration > 0 && ( {totalDuration > 0 && (
<TableRow> <TableRow>
<TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell> <TableCell sx={{ color: 'text.secondary', fontSize: 11 }}></TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 11, color: 'text.secondary', textAlign: 'right' }}> <TableCell
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 11,
color: 'text.secondary',
textAlign: 'right',
}}
>
{formatDuration(totalDuration)} {formatDuration(totalDuration)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
{/* v0.5.0: SubAgent 状态区 — delegate_task 委派的子任务生命周期 */}
{subAgents.length > 0 && (
<Box sx={{ mt: 2, pt: 2, borderTop: 1, borderColor: 'divider', flexShrink: 0 }}>
<Stack direction="row" spacing={1} sx={{ mb: 1.5, alignItems: 'center' }}>
<Bot size={14} style={{ color: '#a855f7' }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 1,
color: 'text.secondary',
}}
>
SubAgent
</Typography>
<Typography variant="caption" sx={{ ml: 'auto', color: 'text.disabled', fontSize: 10 }}>
{subAgents.length}
</Typography>
</Stack>
<Stack spacing={0.5} sx={{ maxHeight: 220, overflowY: 'auto' }}>
{subAgents.map((sub) => (
<Box
key={sub.taskId}
sx={{
px: 1,
py: 0.75,
borderRadius: 1,
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
borderLeft: `2px solid ${SUB_STATUS_COLORS[sub.status]}`,
}}
>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
flexShrink: 0,
bgcolor: SUB_STATUS_COLORS[sub.status],
animation: sub.status === 'running' ? 'pulse 1.5s infinite' : 'none',
}}
/>
<Typography
variant="caption"
sx={{
flex: 1,
minWidth: 0,
fontSize: 11,
color: 'text.primary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={sub.description}
>
{sub.description || sub.taskId}
</Typography>
{sub.depth > 1 && (
<Typography variant="caption" sx={{ fontSize: 9, color: 'text.disabled' }}>
L{sub.depth}
</Typography>
)}
<Chip
label={SUB_STATUS_LABELS[sub.status]}
size="small"
sx={{
height: 16,
fontSize: 9,
flexShrink: 0,
bgcolor: SUB_STATUS_COLORS[sub.status] + '22',
color: SUB_STATUS_COLORS[sub.status],
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Stack>
{(sub.durationMs != null || sub.iterations != null) && (
<Typography
variant="caption"
sx={{ display: 'block', fontSize: 9, color: 'text.disabled', mt: 0.25 }}
>
{sub.durationMs != null ? formatDuration(sub.durationMs) : ''}
{sub.iterations != null ? ` · ${sub.iterations}` : ''}
</Typography>
)}
{sub.error && (
<Typography
variant="caption"
sx={{
display: 'block',
fontSize: 9,
color: 'error.main',
mt: 0.25,
wordBreak: 'break-word',
}}
>
{sub.error}
</Typography>
)}
</Box>
))}
</Stack>
</Box>
)}
</Box> </Box>
); );
} }
+423 -62
View File
@@ -5,9 +5,35 @@
*/ */
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Box, Typography, Button, IconButton, TextField, Collapse, List, ListItemButton, ListItemIcon, ListItemText, Badge, Divider, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'; import {
Box,
Typography,
Button,
IconButton,
TextField,
Collapse,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Badge,
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { Plus, Search, MessageSquare, Pin, Wrench, ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; import {
Plus,
Search,
MessageSquare,
Pin,
Wrench,
ChevronDown,
ChevronRight,
Trash2,
} from 'lucide-react';
import { useSessionStore, type Session } from '@renderer/stores/session-store'; import { useSessionStore, type Session } from '@renderer/stores/session-store';
import { useAgentStore } from '@renderer/stores/agent-store'; import { useAgentStore } from '@renderer/stores/agent-store';
import { formatRelativeTime } from '@renderer/lib/formatters'; import { formatRelativeTime } from '@renderer/lib/formatters';
@@ -21,34 +47,125 @@ export function Sidebar(): React.JSX.Element {
const searchQuery = useSessionStore((s) => s.searchQuery); const searchQuery = useSessionStore((s) => s.searchQuery);
const setSearchQuery = useSessionStore((s) => s.setSearchQuery); const setSearchQuery = useSessionStore((s) => s.setSearchQuery);
const agentStatus = useAgentStore((s) => s.agentStatus); const agentStatus = useAgentStore((s) => s.agentStatus);
// v0.5.0: 内容搜索结果(FTS5),按会话聚合 { sessionId → snippet }
const [contentMatches, setContentMatches] = useState<
Map<string, { snippet: string; matchCount: number }>
>(new Map());
useEffect(() => { useEffect(() => {
// M-27 修复: 添加 cancelled 标志防止组件卸载后 setState // M-27 修复: 添加 cancelled 标志防止组件卸载后 setState
let cancelled = false; let cancelled = false;
if (window.metona?.sessions?.list) { if (window.metona?.sessions?.list) {
window.metona.sessions.list().then((list) => { window.metona.sessions
if (cancelled) return; .list()
useSessionStore.getState().setSessions((list as Array<{ id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }>).map((s) => ({ id: s.id, title: s.title, createdAt: s.createdAt, updatedAt: s.updatedAt, messageCount: s.messageCount, pinned: s.pinned, archived: s.archived }))); .then((list) => {
}).catch((err) => { if (cancelled) return;
// M-11 修复: 记录错误而非静默吞掉,便于诊断 useSessionStore
console.error('[Sidebar] Failed to load sessions:', err); .getState()
}); .setSessions(
(
list as Array<{
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
pinned: boolean;
archived: boolean;
}>
).map((s) => ({
id: s.id,
title: s.title,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
messageCount: s.messageCount,
pinned: s.pinned,
archived: s.archived,
})),
);
})
.catch((err) => {
// M-11 修复: 记录错误而非静默吞掉,便于诊断
console.error('[Sidebar] Failed to load sessions:', err);
});
} }
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
// v0.5.0: 搜索词变化时防抖查询会话内容(FTS5 全文搜索,300ms 防抖减少 IPC 频率)
useEffect(() => {
const q = searchQuery.trim();
if (!q || !window.metona?.sessions?.searchContent) {
setContentMatches(new Map());
return;
}
const timer = window.setTimeout(async () => {
try {
const r = await window.metona.sessions.searchContent(q);
if (r.success && r.data) {
const map = new Map<string, { snippet: string; matchCount: number }>();
for (const item of r.data) {
map.set(item.sessionId, { snippet: item.snippet, matchCount: item.matchCount });
}
setContentMatches(map);
} else {
setContentMatches(new Map());
}
} catch (err) {
console.error('[Sidebar] Content search failed:', err);
setContentMatches(new Map());
}
}, 300);
return () => window.clearTimeout(timer);
}, [searchQuery]);
// L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引 // L-13 修复: 使用 useMemo 缓存 filteredSessions,避免每次渲染都重建 Fuse 索引
// v0.5.0: 标题匹配(Fuse 模糊)∪ 内容匹配(FTS5 精确短语),标题匹配优先排序
const filteredSessions = useMemo(() => { const filteredSessions = useMemo(() => {
let list = sessions.filter((s) => !s.archived); const list = sessions.filter((s) => !s.archived);
if (searchQuery) { const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); list = fuse.search(searchQuery).map((r) => r.item); } if (searchQuery) {
return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1); const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true });
}, [sessions, searchQuery]); const titleMatched = new Set(fuse.search(searchQuery).map((r) => r.item.id));
// 内容匹配的会话并入结果(已含标题匹配的不重复)
const merged = list.filter((s) => titleMatched.has(s.id) || contentMatches.has(s.id));
// 标题匹配优先,内容匹配其次(各按置顶/更新时间排序)
return merged.sort((a, b) => {
const aTitle = titleMatched.has(a.id) ? 0 : 1;
const bTitle = titleMatched.has(b.id) ? 0 : 1;
if (aTitle !== bTitle) return aTitle - bTitle;
return a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1;
});
}
return list.sort((a, b) =>
a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1,
);
}, [sessions, searchQuery, contentMatches]);
const handleNewSession = async () => { const handleNewSession = async () => {
if (window.metona?.sessions?.create) { if (window.metona?.sessions?.create) {
try { try {
const r = await window.metona.sessions.create() as { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; pinned: boolean; archived: boolean }; const r = (await window.metona.sessions.create()) as {
useSessionStore.getState().addSession({ id: r.id, title: r.title, createdAt: r.createdAt, updatedAt: r.updatedAt, messageCount: r.messageCount, pinned: r.pinned, archived: r.archived }); id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
pinned: boolean;
archived: boolean;
};
useSessionStore
.getState()
.addSession({
id: r.id,
title: r.title,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
messageCount: r.messageCount,
pinned: r.pinned,
archived: r.archived,
});
setCurrentSession(r.id); setCurrentSession(r.id);
loadSessionMessages(r.id); loadSessionMessages(r.id);
return; return;
@@ -56,22 +173,50 @@ export function Sidebar(): React.JSX.Element {
// M-9 修复: 显示错误提示而非静默吞错后创建本地假会话 // M-9 修复: 显示错误提示而非静默吞错后创建本地假会话
// 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失 // 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失
console.error('[Sidebar] Failed to create session:', err); console.error('[Sidebar] Failed to create session:', err);
import('@metona-team/metona-toast').then((mod) => { import('@metona-team/metona-toast')
mod.default.error('创建会话失败,请检查数据库状态'); .then((mod) => {
}).catch(() => {}); mod.default.error('创建会话失败,请检查数据库状态');
return; // 不创建本地假会话 })
.catch(() => {});
return; // 不创建本地假会话
} }
} }
const newSession: Session = { id: `s_${Date.now()}`, title: '新会话', createdAt: Date.now(), updatedAt: Date.now(), messageCount: 0, pinned: false, archived: false }; const newSession: Session = {
id: `s_${Date.now()}`,
title: '新会话',
createdAt: Date.now(),
updatedAt: Date.now(),
messageCount: 0,
pinned: false,
archived: false,
};
useSessionStore.getState().addSession(newSession); useSessionStore.getState().addSession(newSession);
setCurrentSession(newSession.id); setCurrentSession(newSession.id);
loadSessionMessages(newSession.id); loadSessionMessages(newSession.id);
}; };
return ( return (
<Box component="aside" sx={{ flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider', width: LAYOUT.SIDEBAR_WIDTH }}> <Box
component="aside"
sx={{
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: 'background.paper',
borderRight: 1,
borderColor: 'divider',
width: LAYOUT.SIDEBAR_WIDTH,
}}
>
<Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <Box sx={{ p: 1.5, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Button variant="outlined" fullWidth size="small" onClick={handleNewSession} sx={{ mb: 1.5, fontSize: 12 }}> <Button
variant="outlined"
fullWidth
size="small"
onClick={handleNewSession}
sx={{ mb: 1.5, fontSize: 12 }}
>
<Plus size={14} style={{ marginRight: 8 }} /> <Plus size={14} style={{ marginRight: 8 }} />
</Button> </Button>
@@ -79,21 +224,44 @@ export function Sidebar(): React.JSX.Element {
size="small" size="small"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索会话..." placeholder="搜索会话标题与内容..."
slotProps={{ slotProps={{
input: { input: {
startAdornment: <Search size={14} style={{ color: '#8b8fa7', marginRight: 8 }} />, startAdornment: <Search size={14} style={{ color: '#8b8fa7', marginRight: 8 }} />,
}, },
}} }}
sx={{ mb: 1.5, '& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 } }} sx={{
mb: 1.5,
'& .MuiOutlinedInput-root': { fontSize: 12, borderRadius: 1.5, height: 34 },
}}
/> />
<Box sx={{ flex: 1, overflowY: 'auto' }}> <Box sx={{ flex: 1, overflowY: 'auto' }}>
{filteredSessions.length === 0 ? ( {filteredSessions.length === 0 ? (
<Typography variant="caption" sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}>{searchQuery ? '无匹配结果' : '暂无会话'}</Typography> <Typography
) : filteredSessions.map((session) => ( variant="caption"
<SessionItem key={session.id} session={session} isActive={session.id === currentSessionId} isAgentActive={session.id === currentSessionId && (agentStatus === 'thinking' || agentStatus === 'executing')} onClick={() => { setCurrentSession(session.id); loadSessionMessages(session.id); }} /> sx={{ textAlign: 'center', py: 4, display: 'block', color: 'text.secondary' }}
))} >
{searchQuery ? '无匹配结果' : '暂无会话'}
</Typography>
) : (
filteredSessions.map((session) => (
<SessionItem
key={session.id}
session={session}
isActive={session.id === currentSessionId}
isAgentActive={
session.id === currentSessionId &&
(agentStatus === 'thinking' || agentStatus === 'executing')
}
contentMatch={contentMatches.get(session.id)}
onClick={() => {
setCurrentSession(session.id);
loadSessionMessages(session.id);
}}
/>
))
)}
</Box> </Box>
<Divider sx={{ mt: 'auto', mb: 1 }} /> <Divider sx={{ mt: 'auto', mb: 1 }} />
@@ -103,7 +271,19 @@ export function Sidebar(): React.JSX.Element {
); );
} }
function SessionItem({ session, isActive, isAgentActive, onClick }: { session: Session; isActive: boolean; isAgentActive: boolean; onClick: () => void }) { function SessionItem({
session,
isActive,
isAgentActive,
contentMatch,
onClick,
}: {
session: Session;
isActive: boolean;
isAgentActive: boolean;
contentMatch?: { snippet: string; matchCount: number };
onClick: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDelete = (e: React.MouseEvent) => { const handleDelete = (e: React.MouseEvent) => {
@@ -119,9 +299,11 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
await window.metona.sessions.delete(session.id); await window.metona.sessions.delete(session.id);
} catch (err) { } catch (err) {
console.error('[Sidebar] Failed to delete session:', err); console.error('[Sidebar] Failed to delete session:', err);
import('@metona-team/metona-toast').then((mod) => { import('@metona-team/metona-toast')
mod.default.error('删除会话失败,请重试'); .then((mod) => {
}).catch(() => {}); mod.default.error('删除会话失败,请重试');
})
.catch(() => {});
// 不调用 removeSession,保留会话在 UI 中(与数据库状态一致) // 不调用 removeSession,保留会话在 UI 中(与数据库状态一致)
setShowDeleteDialog(false); setShowDeleteDialog(false);
return; return;
@@ -135,7 +317,13 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
currentSessionId: null, currentSessionId: null,
messages: [], messages: [],
traceSteps: [], traceSteps: [],
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, lastInputTokens: 0, lastCompressedSaved: 0 }, tokenUsage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
lastInputTokens: 0,
lastCompressedSaved: 0,
},
currentRunId: null, currentRunId: null,
currentIteration: 0, currentIteration: 0,
}); });
@@ -145,24 +333,101 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
return ( return (
<> <>
<ListItemButton onClick={onClick} selected={isActive} dense sx={{ borderRadius: 1.5, mb: 0.25, px: 1.5, py: 0.75, borderRight: isActive ? '2px solid' : '2px solid transparent', borderColor: isActive ? 'primary.main' : 'transparent', '&:hover .delete-btn': { opacity: 1 } }}> <ListItemButton
onClick={onClick}
selected={isActive}
dense
sx={{
borderRadius: 1.5,
mb: 0.25,
px: 1.5,
py: 0.75,
borderRight: isActive ? '2px solid' : '2px solid transparent',
borderColor: isActive ? 'primary.main' : 'transparent',
'&:hover .delete-btn': { opacity: 1 },
}}
>
<ListItemIcon sx={{ minWidth: 24 }}> <ListItemIcon sx={{ minWidth: 24 }}>
{session.pinned ? <Pin size={10} style={{ color: '#818cf8' }} /> : isAgentActive ? <Badge color="success" variant="dot" sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}><MessageSquare size={12} style={{ color: '#8b8fa7' }} /></Badge> : <MessageSquare size={12} style={{ color: '#8b8fa7' }} />} {session.pinned ? (
<Pin size={10} style={{ color: '#818cf8' }} />
) : isAgentActive ? (
<Badge
color="success"
variant="dot"
sx={{ '& .MuiBadge-dot': { animation: 'pulse 2s infinite', width: 8, height: 8 } }}
>
<MessageSquare size={12} style={{ color: '#8b8fa7' }} />
</Badge>
) : (
<MessageSquare size={12} style={{ color: '#8b8fa7' }} />
)}
</ListItemIcon> </ListItemIcon>
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: isActive ? 'text.primary' : 'text.secondary' }}>{session.title}</Typography>} <ListItemText
secondary={<Typography variant="caption" sx={{ fontSize: 10 }}>{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount}` : ''}</Typography>} primary={
<Typography
variant="body2"
sx={{
fontSize: 12,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: isActive ? 'text.primary' : 'text.secondary',
}}
>
{session.title}
</Typography>
}
secondary={
<>
<Typography variant="caption" sx={{ fontSize: 10 }}>
{formatRelativeTime(session.updatedAt)}
{session.messageCount > 0 ? ` · ${session.messageCount}` : ''}
</Typography>
{/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配] */}
{contentMatch && (
<Typography
variant="caption"
sx={{
display: 'block',
fontSize: 10,
color: 'text.disabled',
mt: 0.25,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={contentMatch.snippet}
>
{contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''}
{contentMatch.snippet}
</Typography>
)}
</>
}
/> />
<IconButton <IconButton
className="delete-btn" className="delete-btn"
size="small" size="small"
onClick={handleDelete} onClick={handleDelete}
sx={{ opacity: 0, transition: 'opacity 150ms', color: 'text.disabled', '&:hover': { color: 'error.main' }, width: 20, height: 20 }} sx={{
opacity: 0,
transition: 'opacity 150ms',
color: 'text.disabled',
'&:hover': { color: 'error.main' },
width: 20,
height: 20,
}}
> >
<Trash2 size={12} /> <Trash2 size={12} />
</IconButton> </IconButton>
</ListItemButton> </ListItemButton>
<Dialog open={showDeleteDialog} onClose={() => setShowDeleteDialog(false)} maxWidth="xs" fullWidth> <Dialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ fontSize: 14 }}></DialogTitle> <DialogTitle sx={{ fontSize: 14 }}></DialogTitle>
<DialogContent> <DialogContent>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
@@ -170,8 +435,16 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
</Typography> </Typography>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button size="small" onClick={() => setShowDeleteDialog(false)} sx={{ color: 'text.secondary' }}></Button> <Button
<Button size="small" color="error" variant="contained" onClick={confirmDelete}></Button> size="small"
onClick={() => setShowDeleteDialog(false)}
sx={{ color: 'text.secondary' }}
>
</Button>
<Button size="small" color="error" variant="contained" onClick={confirmDelete}>
</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</> </>
@@ -180,41 +453,129 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S
function ToolManagerPanel() { function ToolManagerPanel() {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [tools, setTools] = useState<Array<{ name: string; description: string; category: string; riskLevel: string; requiresPermission: boolean; enabled: boolean }>>([]); const [tools, setTools] = useState<
Array<{
name: string;
description: string;
category: string;
riskLevel: string;
requiresPermission: boolean;
enabled: boolean;
}>
>([]);
useEffect(() => { useEffect(() => {
// M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState // M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState
let cancelled = false; let cancelled = false;
if (window.metona?.tools?.list) { if (window.metona?.tools?.list) {
window.metona.tools.list().then((list) => { window.metona.tools
if (!cancelled) setTools(list as MetonaToolInfo[]); .list()
}).catch((err) => { .then((list) => {
console.error('[Sidebar]', err); if (!cancelled) setTools(list as MetonaToolInfo[]);
if (!cancelled) { })
import('@metona-team/metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {}); .catch((err) => {
} console.error('[Sidebar]', err);
}); if (!cancelled) {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error('加载工具列表失败'))
.catch(() => {});
}
});
} }
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, []); }, []);
const readyCount = tools.filter((t) => t.enabled).length; const readyCount = tools.filter((t) => t.enabled).length;
const riskColors: Record<string, string> = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' }; const riskColors: Record<string, string> = {
const riskLabels: Record<string, string> = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' }; safe: 'success.main',
low: 'info.main',
medium: 'warning.main',
high: 'error.main',
};
const riskLabels: Record<string, string> = {
safe: 'SAFE',
low: 'LOW',
medium: 'MEDIUM',
high: 'HIGH',
};
return ( return (
<Box> <Box>
<ListItemButton onClick={() => setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}> <ListItemButton
<ListItemIcon sx={{ minWidth: 24 }}>{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}<Wrench size={12} style={{ marginLeft: 4 }} /></ListItemIcon> onClick={() => setExpanded(!expanded)}
<ListItemText primary={<Typography variant="body2" sx={{ fontSize: 12 }}></Typography>} /> dense
<Typography variant="caption" sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} </Typography> sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}
>
<ListItemIcon sx={{ minWidth: 24 }}>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Wrench size={12} style={{ marginLeft: 4 }} />
</ListItemIcon>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontSize: 12 }}>
</Typography>
}
/>
<Typography
variant="caption"
sx={{ color: readyCount > 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}
>
{readyCount}
</Typography>
</ListItemButton> </ListItemButton>
<Collapse in={expanded}> <Collapse in={expanded}>
<List dense disablePadding sx={{ pl: 3, maxHeight: 200, overflowY: 'auto', pr: 0.5, '&::-webkit-scrollbar': { width: 6 }, '&::-webkit-scrollbar-track': { borderRadius: 3 }, '&::-webkit-scrollbar-thumb': { bgcolor: 'divider', borderRadius: 3, '&:hover': { bgcolor: 'action.hover' } } }}> <List
dense
disablePadding
sx={{
pl: 3,
maxHeight: 200,
overflowY: 'auto',
pr: 0.5,
'&::-webkit-scrollbar': { width: 6 },
'&::-webkit-scrollbar-track': { borderRadius: 3 },
'&::-webkit-scrollbar-thumb': {
bgcolor: 'divider',
borderRadius: 3,
'&:hover': { bgcolor: 'action.hover' },
},
}}
>
{tools.map((t) => ( {tools.map((t) => (
<ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}> <ListItemButton key={t.name} dense sx={{ py: 0.25, px: 1, borderRadius: 1 }}>
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: t.enabled ? riskColors[t.riskLevel] ?? 'text.disabled' : 'text.disabled', mr: 1, flexShrink: 0 }} /> <Box
<Typography variant="caption" sx={{ flex: 1, fontSize: 11, color: t.enabled ? 'text.secondary' : 'text.disabled' }}>{t.name}</Typography> sx={{
<Typography variant="caption" sx={{ fontSize: 9, color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled' }}>{riskLabels[t.riskLevel] ?? t.riskLevel}</Typography> width: 6,
height: 6,
borderRadius: '50%',
bgcolor: t.enabled
? (riskColors[t.riskLevel] ?? 'text.disabled')
: 'text.disabled',
mr: 1,
flexShrink: 0,
}}
/>
<Typography
variant="caption"
sx={{
flex: 1,
fontSize: 11,
color: t.enabled ? 'text.secondary' : 'text.disabled',
}}
>
{t.name}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: 9,
color: t.enabled ? (riskColors[t.riskLevel] ?? 'text.disabled') : 'text.disabled',
}}
>
{riskLabels[t.riskLevel] ?? t.riskLevel}
</Typography>
</ListItemButton> </ListItemButton>
))} ))}
</List> </List>
+90 -1
View File
@@ -5,7 +5,7 @@
* Provider / Provider / * Provider / Provider /
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Button, Button,
TextField, TextField,
@@ -50,6 +50,46 @@ export function LLMSettings() {
const [showFbKey, setShowFbKey] = useState(false); const [showFbKey, setShowFbKey] = useState(false);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// v0.5.0: DeepSeek 余额显示(复用主进程 getBalance,原为适配器死代码)
const [balance, setBalance] = useState<{
currency: string;
totalBalance: string;
grantedBalance: string;
toppedUpBalance: string;
} | null>(null);
const [balanceError, setBalanceError] = useState<string | null>(null);
const [balanceLoading, setBalanceLoading] = useState(false);
const loadBalance = useCallback(async () => {
if (!window.metona?.llm?.getBalance) return;
setBalanceLoading(true);
setBalanceError(null);
try {
const r = await window.metona.llm.getBalance();
if (r.success && r.data) {
setBalance(r.data);
} else {
setBalance(null);
setBalanceError(r.error ?? '查询失败');
}
} catch (err) {
setBalance(null);
setBalanceError((err as Error).message);
} finally {
setBalanceLoading(false);
}
}, []);
// DeepSeek Provider 且配置加载完成后自动查询一次(仅一次,避免频繁请求 API)
// 依赖故意不含 apiKey —— 仅在加载完成与 Provider 切换时查询,apiKey 输入变化不重复请求
useEffect(() => {
if (loaded && provider === 'deepseek' && apiKey.trim()) {
void loadBalance();
} else {
setBalance(null);
setBalanceError(null);
}
}, [loaded, provider]);
// 初始化:一次性加载所有 LLM 配置字段 // 初始化:一次性加载所有 LLM 配置字段
useEffect(() => { useEffect(() => {
@@ -349,6 +389,55 @@ export function LLMSettings() {
helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'}
/> />
)} )}
{/* v0.5.0: DeepSeek 账户余额显示 */}
{provider === 'deepseek' && (
<Stack
direction="row"
spacing={1}
sx={{
alignItems: 'center',
px: 1.5,
py: 1,
borderRadius: 1.5,
bgcolor: 'secondary.main',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
</Typography>
{balanceLoading ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
...
</Typography>
) : balance ? (
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{balance.totalBalance} {balance.currency}
<Typography component="span" variant="caption" sx={{ color: 'text.disabled', ml: 1 }}>
{balance.grantedBalance} + {balance.toppedUpBalance}
</Typography>
</Typography>
) : (
<Typography
variant="caption"
sx={{ color: balanceError ? 'error.main' : 'text.disabled' }}
>
{balanceError ?? '未查询(需已保存 API Key'}
</Typography>
)}
<Button
size="small"
variant="outlined"
onClick={loadBalance}
disabled={balanceLoading || !apiKey.trim()}
sx={{ ml: 'auto', minWidth: 64, fontSize: 11 }}
>
</Button>
</Stack>
)}
{provider === 'agnes' && ( {provider === 'agnes' && (
<TextField <TextField
size="small" size="small"
+55
View File
@@ -119,6 +119,40 @@ export function LogsSettings() {
.catch(() => {}); .catch(() => {});
} }
}; };
// v0.5.0: 审计日志导出(JSONL / CSV,复用主进程 AuditService 导出,含链式哈希字段)
const [auditExporting, setAuditExporting] = useState<string | null>(null);
const handleAuditExport = async (format: 'jsonl' | 'csv') => {
if (!window.metona?.audit?.export) return;
setAuditExporting(format);
try {
const r = await window.metona.audit.export(format);
if (r.success && r.data != null) {
const mime = format === 'csv' ? 'text/csv;charset=utf-8' : 'application/x-ndjson';
const b = new Blob([r.data], { type: mime });
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `metona-audit-${Date.now()}.${format}`;
a.click();
// 释放 Blob URL,避免内存泄漏
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
import('@metona-team/metona-toast')
.then((mod) => mod.default.success(`已导出 ${r.recordCount ?? 0} 条审计记录`))
.catch(() => {});
} else {
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`审计导出失败: ${r.error ?? '未知错误'}`))
.catch(() => {});
}
} catch (err) {
console.error('[LogsSettings] Audit export failed:', err);
import('@metona-team/metona-toast')
.then((mod) => mod.default.error(`审计导出失败: ${(err as Error).message}`))
.catch(() => {});
} finally {
setAuditExporting(null);
}
};
const labels: Record<string, string> = { const labels: Record<string, string> = {
sessions: '所有会话', sessions: '所有会话',
memories: '所有记忆', memories: '所有记忆',
@@ -229,6 +263,27 @@ export function LogsSettings() {
<Button variant="outlined" fullWidth size="small" onClick={handleExport}> <Button variant="outlined" fullWidth size="small" onClick={handleExport}>
📦 JSON 📦 JSON
</Button> </Button>
{/* v0.5.0: 审计日志导出(含链式哈希字段,可离线验证完整性) */}
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
fullWidth
size="small"
onClick={() => handleAuditExport('jsonl')}
disabled={auditExporting !== null}
>
{auditExporting === 'jsonl' ? '导出中...' : '📋 导出审计 (JSONL)'}
</Button>
<Button
variant="outlined"
fullWidth
size="small"
onClick={() => handleAuditExport('csv')}
disabled={auditExporting !== null}
>
{auditExporting === 'csv' ? '导出中...' : '📊 导出审计 (CSV)'}
</Button>
</Stack>
<Button <Button
variant="outlined" variant="outlined"
fullWidth fullWidth
+64 -4
View File
@@ -55,6 +55,18 @@ interface MetonaStreamEventData {
state?: string; state?: string;
} }
/** v0.5.0: SubAgent 生命周期事件(AgentMonitor 的 SubAgent 状态区展示) */
interface MetonaSubAgentEvent {
taskId: string;
parentSessionId: string;
description: string;
status: 'delegated' | 'running' | 'completed' | 'error';
depth: number;
durationMs?: number;
iterations?: number;
error?: string;
}
interface MetonaAgentAPI { interface MetonaAgentAPI {
/** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */ /** 发送 MetonaMessageIR 类型)+ sessionId 到主进程 */
sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>; sendMessage: (message: MetonaMessageInput, sessionId: string) => Promise<{ success: boolean }>;
@@ -72,6 +84,8 @@ interface MetonaAgentAPI {
onProviderSwitched: ( onProviderSwitched: (
callback: (data: { from?: string; to?: string; reason?: string }) => void, callback: (data: { from?: string; to?: string; reason?: string }) => void,
) => () => void; ) => () => void;
/** v0.5.0: 监听 SubAgent 生命周期事件(委派/运行/完成/失败) */
onSubAgentEvent: (callback: (event: MetonaSubAgentEvent) => void) => () => void;
} }
// ===== Sessions API ===== // ===== Sessions API =====
@@ -121,6 +135,20 @@ interface MetonaSessionsAPI {
messageId: string, messageId: string,
inclusive?: boolean, inclusive?: boolean,
) => Promise<{ success: boolean; truncated?: number; error?: string }>; ) => Promise<{ success: boolean; truncated?: number; error?: string }>;
/** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
searchContent: (query: string) => Promise<{
success: boolean;
error?: string;
data?: Array<{
sessionId: string;
sessionTitle: string;
matchCount: number;
lastMessageId: string;
lastRole: string;
snippet: string;
timestamp: number;
}>;
}>;
saveTrace: ( saveTrace: (
sessionId: string, sessionId: string,
data: { traceSteps: unknown[]; tokenUsage: unknown }, data: { traceSteps: unknown[]; tokenUsage: unknown },
@@ -267,6 +295,22 @@ interface MetonaWorkspaceAPI {
}>; }>;
} }
// ===== LLM 运行时查询 API =====
interface MetonaLLMAPI {
/** v0.5.0: DeepSeek 余额查询(仅 DeepSeek Provider 支持) */
getBalance: () => Promise<{
success: boolean;
error?: string;
data?: {
currency: string;
totalBalance: string;
grantedBalance: string;
toppedUpBalance: string;
};
}>;
}
// ===== Toast API ===== // ===== Toast API =====
interface MetonaToastAPI { interface MetonaToastAPI {
@@ -386,6 +430,14 @@ interface MetonaAuditAPI {
eventType?: string; eventType?: string;
limit?: number; limit?: number;
}) => Promise<{ success: boolean; data?: unknown[]; error?: string }>; }) => Promise<{ success: boolean; data?: unknown[]; error?: string }>;
/** v0.5.0: 导出审计日志(jsonl / csv 两种格式) */
export: (format: 'jsonl' | 'csv') => Promise<{
success: boolean;
error?: string;
data?: string;
format?: string;
recordCount?: number;
}>;
} }
// ===== v0.2.0: Tool Confirmation API ===== // ===== v0.2.0: Tool Confirmation API =====
@@ -424,15 +476,22 @@ interface MetonaToolAPI {
data: MetonaConfirmationRequest[]; data: MetonaConfirmationRequest[];
}>; }>;
/** /**
* v0.4.1: 获取本会话内记住"拒绝" 10 TTL * v0.4.1: 获取记住"拒绝" 10 TTL
* "重新询问" * "重新询问"
* v0.5.0: sessionId
*/ */
getRememberedDenials: () => Promise<{ getRememberedDenials: (sessionId?: string) => Promise<{
success: boolean; success: boolean;
data: Array<{ toolName: string; expiresInSeconds: number }>; data: Array<{ toolName: string; expiresInSeconds: number }>;
}>; }>;
/** v0.4.1: 重置指定工具的会话内拒绝记忆(立即恢复询问) */ /**
resetRememberedDenial: (toolName: string) => Promise<{ success: boolean; error?: string }>; * v0.4.1: 重置指定工具的拒绝记忆
* v0.5.0: sessionId
*/
resetRememberedDenial: (
toolName: string,
sessionId?: string,
) => Promise<{ success: boolean; error?: string }>;
/** 设置/取消工具的持久化自动执行(跨会话不再询问) */ /** 设置/取消工具的持久化自动执行(跨会话不再询问) */
setAutoExecute: ( setAutoExecute: (
toolName: string, toolName: string,
@@ -456,6 +515,7 @@ interface MetonaBridge {
tools: MetonaToolsAPI; tools: MetonaToolsAPI;
data: MetonaDataAPI; data: MetonaDataAPI;
searxng: MetonaSearXNGAPI; searxng: MetonaSearXNGAPI;
llm: MetonaLLMAPI;
tasks: MetonaTasksAPI; tasks: MetonaTasksAPI;
audit: MetonaAuditAPI; audit: MetonaAuditAPI;
tool: MetonaToolAPI; tool: MetonaToolAPI;
+1 -1
View File
@@ -205,7 +205,7 @@
| 功能 | 推荐库 | 备选 | | 功能 | 推荐库 | 备选 |
|------|--------|------| |------|--------|------|
| SQLite | sql.js | better-sqlite3 | | SQLite | better-sqlite3(主进程同步 API,当前项目选型) | sql.js |
| 键值存储 | electron-store | conf | | 键值存储 | electron-store | conf |
| 缓存 | lru-cache | - | | 缓存 | lru-cache | - |