diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 428a549..3546fce 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,13 +1,20 @@ # MetonaAI Desktop — Gitea Actions CI # # 质量门禁:类型检查 + Lint + 单元测试 + 产物编译验证 -# 触发:push(main/dev 分支)与 Pull Request +# 触发:push(main/dev/master 分支)与 Pull Request +# +# v0.5.0 强化: +# - typecheck 使用双工程真检查(tsconfig.node.json + tsconfig.web.json), +# 修复此前 solution-style 根配置导致 `tsc --noEmit` 空操作、类型错误畅通提交的缺陷 +# - electron-test job 从 experimental(continue-on-error)转正为阻塞门禁 +# - 私有 npm 凭据通过 GITEA_NPM_AUTH secret 注入(需在仓库 +# Settings → Actions → Secrets 配置,值为 base64("用户名:密码")) # # 说明: # - 主测试使用系统 Node(npm test)——better-sqlite3 原生模块按系统 Node ABI 编译, # audit 套件在 ABI 不匹配时自动跳过(skipIf),保证 CI 稳定绿 -# - electron-test job 尝试按 Electron ABI 重建 better-sqlite3 后跑全量测试(含 audit 链式哈希), -# 该 job 标记为 experimental(continue-on-error),失败不阻塞合并 +# - electron-test job 按 Electron ABI 重建 better-sqlite3 后跑全量测试 +# (含 audit 链式哈希 + 会话摘要分层),作为阻塞门禁 # - build 验证只跑 electron-vite build(产物编译),不跑 electron-builder 打包—— # NSIS 目标需要 wine,Linux runner 上不可用 @@ -22,6 +29,8 @@ on: env: # Electron 二进制国内镜像(与 .npmrc 注释保持一致,加速 runner 下载) ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/ + # 私有 npm 仓库认证(@metona-team/metona-toast)— 从仓库 Secrets 注入 + GITEA_NPM_AUTH: ${{ secrets.GITEA_NPM_AUTH }} jobs: quality: @@ -41,7 +50,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Typecheck + # v0.5.0: 双工程真检查(node + web),修复此前 solution-style 空操作缺陷 + - name: Typecheck (node + web) run: npm run typecheck - name: Lint @@ -51,10 +61,9 @@ jobs: run: npm test electron-test: - name: 全量测试 (Electron ABI, experimental) + name: 全量测试 (Electron ABI) runs-on: debian-latest timeout-minutes: 25 - continue-on-error: true steps: - name: Checkout uses: actions/checkout@v4 @@ -71,6 +80,7 @@ jobs: - name: Rebuild better-sqlite3 for Electron ABI run: npx @electron/rebuild -f -w better-sqlite3 + # v0.5.0: 从 experimental 转正 — 含 SQLite 审计链哈希与摘要分层的全量用例必须通过 - name: Full tests (Electron Node ABI) run: npm run test:electron diff --git a/.npmrc b/.npmrc index dbf4943..bc4f12c 100644 --- a/.npmrc +++ b/.npmrc @@ -1,7 +1,11 @@ registry=https://registry.npmmirror.com @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 = "" +# CI (Gitea Actions): 在仓库 Settings → Secrets 配置 GITEA_NPM_AUTH +//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=${GITEA_NPM_AUTH} # 允许包的 postinstall 脚本(electron 需要下载二进制文件) ignore-scripts=false diff --git a/README.md b/README.md index 11b9c73..27309da 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -29,7 +29,7 @@ ---

- Metona 是一款基于 Electron 的本地优先 AI Agent 桌面应用,内置 ReAct 状态机驱动的智能体循环引擎、26 个内置工具三层记忆系统四层纵深安全防线完整可观测性链路。支持六种 LLM Provider(DeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic),兼容 MCP 协议扩展,为开发者提供开箱即用的 AI 编程伙伴。 + Metona 是一款基于 Electron 的本地优先 AI Agent 桌面应用,内置 ReAct 状态机驱动的智能体循环引擎、28 个内置工具三层记忆系统四层纵深安全防线完整可观测性链路。支持六种 LLM Provider(DeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic),兼容 MCP 协议扩展(stdio / SSE / Streamable HTTP),为开发者提供开箱即用的 AI 编程伙伴。

--- @@ -63,7 +63,7 @@

ReAct 八状态闭环、流式对话 (SSE/NDJSON)、Thinking 推理模式、死循环检测、上下文自动压缩、指数退避重试

-

🔧 26 个内置工具

+

🔧 28 个内置工具

文件系统 · 代码搜索 · 网络搜索 · 浏览器自动化 · Git · Shell 命令 · HTTP 请求 · 记忆存储 · 任务管理

@@ -84,7 +84,7 @@

📡 MCP 协议兼容

-

支持 stdio / SSE 两种传输方式的 MCP Server,动态发现工具,无需重启应用

+

支持 stdio / SSE / Streamable HTTP 三种传输方式的 MCP Server,动态发现工具,无需重启应用

@@ -115,6 +115,7 @@ | 🛰 MCP | **@modelcontextprotocol/sdk** | 1.12 | 外部工具协议集成 | | 🎨 样式 | **Tailwind CSS** | 4 | 辅助原子化样式 | | 📝 Markdown | **react-markdown + remark-gfm** | 10 / 4 | 富文本渲染 | +| 🌐 HTML 解析 | **node-html-parser** | 6 | 搜索引擎结果结构化解析 | | 🔢 UUID | **nanoid** | 5 | 唯一 ID 生成 | | ✅ 校验 | **Zod** | 3 | 运行时类型校验 | | 💾 缓存 | **lru-cache** | 11 | 内存缓存 | @@ -135,20 +136,25 @@ ```bash # 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 -# 2. 安装依赖 +# 2. 配置私有 npm 仓库凭据(@metona-team/metona-toast 需要) +# 值为 base64("用户名:密码"),向仓库管理员申请 +$env:GITEA_NPM_AUTH = "<你的 base64 凭据>" # PowerShell +# export GITEA_NPM_AUTH="<你的 base64 凭据>" # bash + +# 3. 安装依赖 npm install -# 3. 配置 API Key(可选——推荐启动后在应用内「设置 → LLM 配置」可视化配置) +# 4. 配置 API Key(可选——推荐启动后在应用内「设置 → LLM 配置」可视化配置) cp .env.example .env # 编辑 .env 预置密钥(应用内未配置时自动回退读取,见下方「环境变量」说明) -# 4. 启动开发模式 +# 5. 启动开发模式 npm run dev -# 5. 构建生产包 +# 6. 构建生产包 npm run build ``` @@ -234,7 +240,7 @@ Metona 的核心是一个 **ReAct (Reasoning + Acting)** 状态机驱动引擎 | 🔄 **流式对话** | SSE (DeepSeek/Agnes/MiMo) + NDJSON (Ollama) 双协议流式响应 | | 💭 **Thinking 模式** | 支持 deepseek-v4-pro / agnes-2.0-flash / qwen3 等推理模型的思维链展示 | | 🔁 **死循环检测** | 连续 3 轮相同工具调用签名自动终止 | -| 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,保留最近 10 条消息 | +| 📦 **上下文压缩** | 80% 阈值触发 LLM 摘要压缩,按 token 预算动态保留近期消息(超长 tool_result 二次截断) | | 🔄 **错误重试** | 指数退避 (1s/2s/4s) + ±20% jitter,上限 30s | | ⏱️ **可配置迭代** | 最大迭代次数 (默认 20)、总超时 (默认 600s)、工具执行超时 (默认 120s) | | 🧵 **子任务委派** | 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 ├─ (预留) - ├─ list_directory ├─ web_fetch ├─ file_editor ├─ run_command - ├─ search_files ├─ http_request ├─ memory_store ├─ web_browser - ├─ code_search ├─ run_tests ├─ delegate_task ├─ git_commit - ├─ diff_viewer ├─ task_manager ├─ file_move └─ browser actions - ├─ git_status └─ think (open/navigate) + ├─ read_file ├─ web_search ├─ write_file* ├─ delete_file ├─ (预留) + ├─ list_directory ├─ web_fetch ├─ file_editor* ├─ run_command + ├─ search_files ├─ http_request ├─ file_move* └─ web_browser + ├─ code_search ├─ run_tests ├─ git_commit* + ├─ diff_viewer └─ task_manager ├─ memory_store + ├─ git_status └─ delegate_task ├─ git_diff ├─ git_log ├─ lint_code ├─ project_info ├─ memory_search ├─ view_image - └─ file_info + ├─ file_info + └─ think ``` +> `*` 标记的 MEDIUM 工具设置了 `requiresPermission: true`(执行前需用户确认);未标记的 MEDIUM 工具(memory_store / delegate_task)无需确认。 + ### 详细工具列表 #### 📂 文件系统 (7 tools) @@ -319,7 +328,7 @@ SAFE (无需确认) LOW (无需确认) MEDIUM (可配自动) |:---|:---|:---|:---| | `git_status` | SAFE | 否 | 工作树状态,porcelain v1 格式解析 | | `git_diff` | SAFE | 否 | 差异输出,50KB 截断,5MB maxBuffer | -| `git_log` | SAFE | 否 | 提交历史,支持 --oneline / --stat 等格式 | +| `git_log` | SAFE | 否 | 提交历史,--oneline 紧凑格式或 NULL 分隔的完整字段格式 | | `git_commit` | MEDIUM | 是 | 暂存 + 提交,校验文件在工作空间内 | #### 📋 任务与辅助 (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) | | **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) | +| **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 管理、配置服务 | -| **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 状态管理 | ### 核心数据流 ``` 用户输入 → ChatInput → agent-store.sendMessage - → IPC: agent:sendMessage → handlers.ts + → IPC: agent:sendMessage → ipc/agent.ts → reloadAdapter (配置签名比对,热重载) → 保存用户消息到 SQLite messages 表 → 加载历史消息 + 注入相关记忆 (MemoryManager.search) @@ -519,29 +530,31 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰: - **TraceViewer**:按 `runId` 分组可视化展示每轮 ReAct 迭代,包括思考过程、工具调用参数/结果/耗时、状态转换链 - **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 -- 录制到 JSONL 文件,支持回放重放 -- 录制格式:每行一条结构化 JSON 事件 +- **9 种事件类型**:session_start, context_built, iteration_start, llm_request, llm_response, tool_call, tool_result, iteration_end, session_end +- 录制到 JSONL 文件(`{workspace}/logs/session_*.jsonl`),支持事后回放分析 +- v0.5.0: SubAgent(delegate_task)的执行轨迹录制到独立 JSONL 文件(taskId 作为会话标识) ### 审计日志 - **链式哈希防篡改**:每条记录哈希 = SHA-256(prev_hash + 记录内容),INSERT-ONLY 触发器 - **完整性校验**:`verifyChain()` 逐条验证哈希链 -- **导出**:支持只读 JSONL (chmod 0o444)、CSV、归档 (90 天保留) +- **导出**:支持 JSONL / CSV 两种格式导出(设置 → 日志与数据) ### SLO 监控 -| 指标 | 目标 | +SLOMonitor 在 5 分钟滑动窗口内实时统计 Agent 请求质量,燃烧速率超预算时记录告警日志: + +| 监控项 | 说明 | |:---|:---| -| Agent Loop 成功率 | 95% | -| P99 延迟 | < 60s (95%) | -| 工具调用成功率 | 98% | -| 死循环发生率 | < 0.1% (99.9%) | -| MCP Server 可用性 | 99% | -| 应用崩溃率 | < 0.1% (99.9%) | +| 请求成功率 | 按 complete 事件的 terminationReason 统计错误率 | +| 延迟分布 | P50 / P95 / P99 分位数 + 平均延迟 | +| 吞吐量 | 每秒请求数 | +| 燃烧速率 | 实际错误率 / 错误预算(目标 99.9%),>1 时告警 | +| 健康检查 | 每 60s 检查数据库连通性、系统可用内存、主进程堆内存 | --- @@ -553,7 +566,7 @@ Metona 的 Agent 引擎采用分层架构,每层职责清晰: ┌──────────┬──────────────────────────────┬──────────────┐ │ │ Header 顶部栏 │ │ │ Sidebar │ Provider · Model · 面板切换 │ DetailPanel │ -│ 260px ├──────────────────────────────┤ 360px │ +│ 300px ├──────────────────────────────┤ 360px │ │ │ │ │ │ 会话列表 │ ChatPanel 聊天区域 │ Trace View │ │ 搜索 │ │ Memory │ @@ -627,18 +640,20 @@ OLLAMA_BASE_URL=http://localhost:11434 | 配置项 | 默认值 | 说明 | |:---|:---|:---| -| `llm.provider` | `deepseek` | LLM Provider ID | -| `llm.model` | `deepseek-v4-pro` | 模型标识符 | +| `llm.provider` | (空) | LLM Provider ID(未配置时回退 .env) | +| `llm.model` | (空) | 模型标识符 | | `agent.maxIterations` | `20` | ReAct 最大迭代轮次 | | `agent.totalTimeoutMs` | `600000` | Agent 总超时 (ms) | -| `agent.toolTimeoutMs` | `120000` | 单个工具执行超时 (ms) | +| `agent.toolExecutionTimeoutMs` | `120000` | 单个工具执行超时 (ms) | | `agent.thinkingEnabled` | `true` | 启用 Thinking 推理模式 | -| `agent.thinkingEffort` | `high` | 推理强度 (high / max) | +| `agent.thinkingEffort` | `high` | 推理强度 (low / medium / high / max) | | `agent.confirmationTimeoutMs` | `120000` | 确认弹窗超时 (30s ~ 600s) | | `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 | | `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 | | `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 | -| `ollama.numCtx` | `4096` | Ollama num_ctx 参数 | +| `openai.contextWindow` | `128000` | OpenAI 上下文窗口 | +| `anthropic.contextWindow` | `200000` | Anthropic 上下文窗口 | +| `ollama.numCtx` | (空) | Ollama num_ctx 参数(空 = 由模型决定) | ### SearXNG 元搜索引擎 (可选) @@ -658,11 +673,13 @@ MetonaAI-Desktop/ │ ├── 📄 main.ts # 应用入口 │ ├── 📄 preload.ts # contextBridge 安全桥接 (15 个 API) │ ├── 📂 ipc/ # IPC 域模块 (P2 拆分, 50+ 通道) -│ │ ├── 📄 index.ts # 统一注册入口 -│ │ ├── 📄 agent.ts # Agent 消息/中断/常驻事件管道 -│ │ ├── 📄 sessions.ts # 会话 CRUD -│ │ ├── 📄 config.ts # 配置读写 (共享副作用) -│ │ ├── 📄 tools.ts # 工具列表/确认 +│ │ ├── 📄 index.ts # 统一注册入口(防重入) +│ │ ├── 📄 context.ts # 共享上下文 + broadcast 多窗口广播 +│ │ ├── 📄 shared.ts # 配置写入共享副作用 +│ │ ├── 📄 agent.ts # Agent 消息/中断/常驻事件管道/SubAgent 广播与录制 +│ │ ├── 📄 sessions.ts # 会话 CRUD/消息截断/Trace 持久化 +│ │ ├── 📄 config.ts # 配置读写 (get/set/setBatch) +│ │ ├── 📄 tools.ts # 工具列表/确认/自动执行 │ │ └── 📄 ... (mcp/memory/tasks/data/workspace/app) │ ├── 📂 services/ # 业务服务层 (12 个 Service) │ │ ├── 📄 agent-engine-manager.service.ts # 每会话独立引擎管理 (P2) @@ -692,7 +709,7 @@ MetonaAI-Desktop/ │ │ │ └── 📂 shared/ # 共享: OpenAI 格式 · SSE 解析 │ │ ├── 📂 tools/ # 工具系统 │ │ │ ├── 📄 registry.ts # 工具注册 · PolicyEngine · 超时管理 -│ │ │ └── 📂 built-in/ # 26 个内置工具实现 +│ │ │ └── 📂 built-in/ # 28 个内置工具实现 │ │ │ ├── 📄 filesystem.ts # 文件系统 (7 tools) │ │ │ ├── 📄 file-editor.ts # 精准编辑 │ │ │ ├── 📄 file-guard.ts # 路径安全共享工具 @@ -739,16 +756,16 @@ MetonaAI-Desktop/ │ │ └── 📄 token-estimator.ts # Token 估算 (CJK/ASCII) │ └── 📂 utils/ │ └── 📄 slo.ts # 健康检查 · SLO 监控 -├── 📂 src/ # React 渲染进程 (~42 文件) +├── 📂 src/ # React 渲染进程 (~50 文件) │ ├── 📄 main.tsx # 渲染进程入口 │ ├── 📄 App.tsx # 根组件 (三栏布局 + Store 连接) │ ├── 📂 components/ │ │ ├── 📂 chat/ # 聊天组件 (11 个) │ │ │ ├── 📄 ChatPanel.tsx # 聊天主面板 -│ │ │ ├── 📄 MessageList.tsx # 消息列表 (content-visibility 虚拟滚动) +│ │ │ ├── 📄 MessageList.tsx # 消息列表 (react-virtuoso 真虚拟滚动) │ │ │ ├── 📄 MessageItem.tsx # 消息路由 (memo) │ │ │ ├── 📄 AssistantMessage.tsx # Agent 回复 (Markdown + 代码高亮) -│ │ │ ├── 📄 UserMessage.tsx # 用户消息 (附件 + 双击编辑) +│ │ │ ├── 📄 UserMessage.tsx # 用户消息 (附件 + 双击编辑重发) │ │ │ ├── 📄 SystemMessage.tsx # 系统通知 │ │ │ ├── 📄 ChatInput.tsx # 输入框 (附件 + / 命令) │ │ │ ├── 📄 ThoughtBlock.tsx # 思考过程 (可折叠) @@ -757,18 +774,20 @@ MetonaAI-Desktop/ │ │ │ └── 📄 StreamingIndicator.tsx # 流式加载指示器 │ │ ├── 📂 layout/ # 布局组件 (5 个) │ │ │ ├── 📄 Header.tsx # 顶部栏 (Provider · 面板切换) -│ │ │ ├── 📄 Sidebar.tsx # 侧边栏 (会话列表 · 工具管理) +│ │ │ ├── 📄 Sidebar.tsx # 侧边栏 (会话列表 · 标题/内容搜索 · 工具管理) │ │ │ ├── 📄 DetailPanel.tsx # 详情面板 (4 Tab) -│ │ │ ├── 📄 AgentMonitor.tsx # Agent 状态指示器 +│ │ │ ├── 📄 AgentMonitor.tsx # Agent 状态指示器 + SubAgent 状态区 │ │ │ └── 📄 StatusBar.tsx # 底部状态栏 -│ │ ├── 📂 settings/ -│ │ │ └── 📄 SettingsModal.tsx # 设置弹窗 (8 标签页) +│ │ ├── 📂 settings/ # 设置弹窗 (v0.4.1 拆分, 10 文件) +│ │ │ ├── 📄 SettingsModal.tsx # 主框架 + 垂直 Tab 导航 +│ │ │ ├── 📄 useConfig.ts # 共享配置读写 Hook +│ │ │ └── 📄 ... (LLM/Agent/Tools/MCP/SearXNG/Appearance/Logs/Workspace 8 个 Tab) │ │ ├── 📂 memory/ │ │ │ └── 📄 MemoryViewer.tsx # 记忆浏览器 (3 类型) │ │ ├── 📂 tasks/ │ │ │ └── 📄 TaskList.tsx # 任务列表 │ │ ├── 📂 trace/ # 追踪组件 (3 个) -│ │ │ ├── 📄 TraceViewer.tsx # ReAct 迭代时间轴 +│ │ │ ├── 📄 TraceViewer.tsx # ReAct 迭代时间轴 (按 runId 分组) │ │ │ ├── 📄 TraceStep.tsx # 单步详情 (Thought + ToolCalls) │ │ │ └── 📄 TokenUsage.tsx # Token 统计面板 │ │ ├── 📂 workspace/ @@ -776,23 +795,25 @@ MetonaAI-Desktop/ │ │ ├── 📂 onboarding/ │ │ │ └── 📄 OnboardingWizard.tsx # 5 步引导向导 │ │ ├── 📂 common/ -│ │ │ └── 📄 ErrorBoundary.tsx # 错误边界 +│ │ │ └── 📄 ErrorBoundary.tsx # 错误边界 (上报 error:report) │ │ ├── 📄 CommandPalette.tsx # Cmd+K 命令面板 -│ │ ├── 📄 ConfirmationDialog.tsx # 工具确认弹窗 (批量审批) -│ │ ├── 📄 ContextMenu.tsx # 右键菜单 +│ │ ├── 📄 ConfirmationDialog.tsx # 工具确认弹窗 (批量审批 + 拒绝记忆恢复) +│ │ ├── 📄 ContextMenu.tsx # 右键菜单 (5 类对象) │ │ └── 📄 ToastContainer.tsx # Toast 通知 (metona-toast v2) │ ├── 📂 hooks/ -│ │ ├── 📄 useAgentStream.ts # 流式事件监听 (rAF 批处理) +│ │ ├── 📄 useAgentStream.ts # 流式事件监听 (rAF 批处理 + runId 过滤) │ │ ├── 📄 useKeyboardShortcuts.ts # 键盘快捷键 (16 个) │ │ └── 📄 useTheme.ts # 主题管理 │ ├── 📂 stores/ -│ │ ├── 📄 agent-store.ts # Agent 状态 (消息·流·Trace·Token) +│ │ ├── 📄 agent-store.ts # Agent 状态 (消息·流·Trace·Token·编辑重发) │ │ ├── 📄 session-store.ts # 会话列表 (搜索·置顶·归档) │ │ └── 📄 ui-store.ts # UI 状态 (主题·面板·专注模式) │ ├── 📂 lib/ │ │ ├── 📄 constants.ts # 常量 (布局·颜色·快捷键) │ │ ├── 📄 formatters.ts # 格式化 (时间·Token·文件大小) │ │ ├── 📄 theme.ts # MUI 暗色/亮色主题 +│ │ ├── 📄 export-markdown.ts # 会话 Markdown 导出 +│ │ ├── 📄 tool-result-display.ts # 工具结果显示层裁剪 (剥离 base64) │ │ └── 📄 cn.ts # className 合并 │ ├── 📂 styles/ │ │ └── 📄 globals.css # 全局样式 (Tailwind + 动画 + Markdown) @@ -844,7 +865,7 @@ npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── 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 # 测试监听模式 # ─── 构建 ───────────────────────────────── @@ -856,17 +877,20 @@ npm run preview # 预览构建产物 ### 数据库 Schema +共 10 张表(v0.4.0 新增 `session_summaries` 用于分层上下文): + | 表名 | 用途 | 关键特性 | |:---|:---|:---| | `sessions` | 会话管理 | title, created_at, updated_at, pinned, archived | -| `messages` | 消息持久化 | role, content, reasoning_content, tool_calls (JSON), token_usage | -| `app_config` | 应用配置 | 键值对,5 大分类 (llm/agent/tools/security/logging) | -| `audit_logs` | 审计日志 | chain_hash (SHA-256), INSERT-ONLY 触发器 | -| `mcp_servers` | MCP Server 配置 | name, transport (stdio/sse), command, args, env, url | -| `episodic_memories` | 情节记忆 | content, importance, access_count, last_accessed, created_at | -| `semantic_memories` | 语义记忆 | content, embedding (JSON), importance, decay_factor | -| `working_memories` | 工作记忆 | key-value, session_id, expires_at | -| `tasks` | 持久化任务 | title, description, status, priority, parent_id, session_id | +| `messages` | 消息持久化 | role, content, reasoning_content, tool_calls (JSON), attachments (JSON), iteration | +| `app_config` | 应用配置 | 键值对(llm / agent / tools / security / logging / searxng / ui) | +| `audit_logs` | 审计日志 | prev_hash + current_hash 链式哈希 (SHA-256), INSERT-ONLY 触发器 | +| `mcp_servers` | MCP Server 配置 | name, transport (stdio/sse/streamable-http), command, args, url | +| `episodic_memories` | 情节记忆 | content, summary, source, importance, tf_cache (分词缓存) | +| `semantic_memories` | 语义记忆 | key-value (UNIQUE key), category, confidence, access_count, tf_cache | +| `working_memories` | 工作记忆 | key-value, session_id + task_id + key 唯一约束 | +| `tasks` | 持久化任务 | title, description, status, priority, parent_id (自引用), order_idx | +| `session_summaries` | 会话滚动摘要 | summary, summarized_until_rowid 游标(分层上下文加载) | --- diff --git a/docs/MetonaAI-Desktop UI UX 设计集成方案.html b/docs/MetonaAI-Desktop UI UX 设计集成方案.html index 1654084..3fe38a4 100644 --- a/docs/MetonaAI-Desktop UI UX 设计集成方案.html +++ b/docs/MetonaAI-Desktop UI UX 设计集成方案.html @@ -480,7 +480,7 @@ MeToast.warning('操作已取消 Tab内容 LLM 配置Provider 选择、API Key、模型名称、参数滑块(temperature/maxTokens/contextWindow) Agent 配置最大迭代次数、超时、Thinking 开关、反思模式、压缩阈值 - 工具管理27 个内置工具开关、风险级别配置、路径白名单、命令黑名单 + 工具管理28 个内置工具开关、风险级别配置、路径白名单、命令黑名单 MCP 服务Server 列表、添加/删除/启停、连接状态指示灯 外观主题(light/dark/auto)、字体大小、消息密度、动画开关 日志与数据日志级别、数据库位置、数据导出/清理、使用统计 diff --git a/docs/MetonaAI-Desktop 内部API请求与响应标准.html b/docs/MetonaAI-Desktop 内部API请求与响应标准.html index 698accb..33b1d3a 100644 --- a/docs/MetonaAI-Desktop 内部API请求与响应标准.html +++ b/docs/MetonaAI-Desktop 内部API请求与响应标准.html @@ -1189,7 +1189,7 @@ electron/harness/adapters/ 3Tool Registry 使用 MetonaToolDef / MetonaToolCall / MetonaToolResultharness/tools/ 4Memory Manager 使用 MetonaMemoryItemharness/memory/ 5IPC Preload 桥接层只传递 Metona IR 类型electron/preload.ts - 6IPC Handlers 的输入输出为 Metona IR 类型electron/ipc/*.handlers.ts + 6IPC Handlers 的输入输出为 Metona IR 类型electron/ipc/*.ts 7React 组件/Zustand Store 只读写 Metona IR 类型src/stores/, src/components/ 8每个 Provider Adapter 实现 IMetonaProviderAdapterharness/adapters/ 9流式事件通过 MetonaStreamEvent 推送hooks/useAgentStream.ts diff --git a/docs/MetonaAI-Desktop 架构与交互设计.html b/docs/MetonaAI-Desktop 架构与交互设计.html index 3f28708..dd05b4b 100644 --- a/docs/MetonaAI-Desktop 架构与交互设计.html +++ b/docs/MetonaAI-Desktop 架构与交互设计.html @@ -141,14 +141,14 @@ 📁 工作空间 💾 数据库配置 - + 📊 工具总表 📄 文件系统工具 🌐 网络搜索与抓取 🧠 记忆工具 ⚒️ 命令工具 - + 💾 文件总览 ✨ SOUL.md 📋 AGENTS.md @@ -165,14 +165,17 @@

MetonaAI-Desktop 架构与交互设计

-

基于「生产级通用 AI Agent 桌面应用构建指南」+「Metona 内部 IR 标准」,定义完整的系统架构、27 个内置工具、4 个用户级磁盘文件、工作空间机制、数据库配置规范及全链路可追踪日志体系。

+

基于「生产级通用 AI Agent 桌面应用构建指南」+「Metona 内部 IR 标准」,定义完整的系统架构、28 个内置工具、4 个用户级磁盘文件、工作空间机制、数据库配置规范及全链路可追踪日志体系。

版本: v1.1.0 技术栈: React + Material UI (MUI) + Electron + SQLite 日期: 2026-07-15
- 📋 文档层级:本文档是 工作空间、27 个内置工具、4 个磁盘文件、数据库配置的权威定义,与《构建指南》第三、五、六章对应。冲突时以本文档为准。 + 📋 文档层级:本文档是 工作空间、28 个内置工具、磁盘文件、数据库配置的权威定义,与《构建指南》第三、五、六章对应。冲突时以本文档为准。 +
+ ⚠ 实现状态注记(v0.3.14+):当前实现中用户级磁盘文件为 2 个(SOUL.md + MEMORY.md);AGENTS.md 与 USERS.md 已从实现中移除(其职责并入 SOUL.md 与内置安全准则),本文档中相关章节保留为历史设计参考。当前实现共 28 个内置工具10 张数据库表(新增 session_summaries 分层摘要表)。 +
@@ -184,7 +187,7 @@

MetonaAI-Desktop 是一个运行在用户本地桌面上的通用 AI Agent 应用。它以工作空间(Workspace)为基本组织单元, 通过 4 个 Markdown 磁盘文件 定义 Agent 的灵魂、行为、记忆和用户画像, - 提供 27 个内置工具 赋予 Agent 操作文件系统、网络、记忆和命令行的能力。 + 提供 28 个内置工具 赋予 Agent 操作文件系统、网络、记忆和命令行的能力。 全链路操作透明可追踪,所有决策过程、工具调用、LLM 推理记录在本地 SQLite 日志中。

@@ -301,7 +304,7 @@

工作空间生命周期

1用户选择/创建工作空间目录(或使用默认路径 ~/MetonaWorkspaces/default/
2校验必需文件,缺失则自动创建(MEMORY.md 带元数据头)
-
3加载 4 个磁盘文件,构建 System Prompt(空文件不影响启动)
+
3加载磁盘文件(当前实现为 SOUL.md + MEMORY.md 共 2 个),构建 System Prompt(空文件不影响启动)
4连接 .metona/agent.db,加载配置、恢复历史会话
5Agent 就绪,开始对话。所有工具操作默认以工作空间为根
6会话结束后,MEMORY.md(更新时间戳)和 .metona/agent.db 自动更新
@@ -388,9 +391,9 @@
- +
-

📊 27 个内置工具 — 总表

+

📊 28 个内置工具 — 总表

所有工具使用 Metona IR 的 MetonaToolDef / MetonaToolCall / MetonaToolResult 结构。内置在 Tool Registry 中,Adaper 为 LLM 生成 JSON Schema 格式的描述。

@@ -404,7 +407,7 @@ - +
7memory_storedatabaseMEDIUM存储一条记忆到 SQLite
8memory_searchdatabaseSAFE检索记忆(关键词匹配)
9run_commandcode_executionHIGH执行 Shell 命令,沙箱限制
完整 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。
完整 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。
@@ -540,10 +543,11 @@
-

💾 4 个用户级磁盘文件

+

💾 用户级磁盘文件

- 4 个 .md 文件位于工作空间根目录,是工作空间的必需文件。 - 其中 SOUL.mdAGENTS.mdUSERS.md 完全由用户自定义,MEMORY.md 由 Agent 维护但用户可编辑。 + .md 文件位于工作空间根目录,是工作空间的必需文件。 + 其中 SOUL.md 完全由用户自定义,MEMORY.md 由 Agent 维护但用户可编辑。 + 当前实现为 2 个文件;原设计中的 AGENTS.md / USERS.md 已移除(职责并入 SOUL.md 与内置安全准则),下方相关小节保留为历史设计参考。

@@ -922,10 +926,10 @@
2选择/创建工作空间 → 校验必需文件(缺失则自动创建)
3连接 SQLite(.metona/agent.db)→ 执行 schema 迁移 → 加载配置
4初始化 Provider Adapter(根据数据库配置选择)
-
5加载 4 个磁盘文件,构建 System Prompt(空文件不影响启动)
+
5加载磁盘文件(当前实现为 SOUL.md + MEMORY.md 共 2 个),构建 System Prompt(空文件不影响启动)
6连接启用的 MCP Servers → 动态加载 MCP 工具
7启动 React UI(Chromium Renderer),Agent 就绪
-
8(可选)提示用户编辑 SOUL.md / AGENTS.md / USERS.md 以自定义 Agent
+
8(可选)提示用户编辑 SOUL.md 以自定义 Agent 人格(当前实现;AGENTS.md/USERS.md 已移除)

对话流程(一次 ReAct 迭代)

1用户在 ChatInput 输入消息 → IPC agent:sendMessage 发送 MetonaRequest
diff --git a/docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html b/docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html index 36279a5..0c9376a 100644 --- a/docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html +++ b/docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html @@ -1072,11 +1072,17 @@ │ ├── main.ts # 应用入口,生命周期管理 │ ├── preload.ts # Preload 安全桥接脚本 │ ├── ipc/ # IPC 通道注册与处理 -│ │ ├── index.ts # IPC 通道汇总导出 -│ │ ├── agent.handlers.ts # Agent 相关 IPC 处理 -│ │ ├── db.handlers.ts # 数据库操作 IPC 处理 -│ │ ├── mcp.handlers.ts # MCP 管理 IPC 处理 -│ │ └── config.handlers.ts # 配置管理 IPC 处理 +│ │ ├── index.ts # 统一注册入口(防重入) +│ │ ├── agent.ts # Agent 交互域(消息/中断/事件管道) +│ │ ├── sessions.ts # 会话 CRUD 域 +│ │ ├── config.ts # 配置管理域 +│ │ ├── tools.ts # 工具与确认域 +│ │ ├── mcp.ts # MCP 管理域 +│ │ ├── memory.ts # 记忆域 +│ │ ├── tasks.ts # 任务域 +│ │ ├── data.ts # 数据导出与清理域 +│ │ ├── workspace.ts # 工作空间域 +│ │ └── app.ts # 应用工具域 │ ├── services/ # 主进程业务服务 │ │ ├── database.service.ts # 数据库初始化与连接管理 │ │ ├── agent-engine.service.ts # Agent 引擎服务 @@ -1092,7 +1098,7 @@ │ │ ├── tools/ # 工具注册与管理 │ │ │ ├── registry.ts # 工具注册表 │ │ │ ├── base-tool.ts # 工具基类/接口 -│ │ │ └── built-in/ # 内置工具集(21 个文件,27 个工具) +│ │ │ └── built-in/ # 内置工具集(21 个文件,28 个工具) │ │ │ ├── filesystem.ts # 文件系统(5 工具) │ │ │ ├── file-editor.ts # 编辑器 │ │ │ ├── code-search.ts # 代码搜索 @@ -2677,7 +2683,7 @@ export class ToolRegistry { } } -

🔧 备注:v0.3.3 当前已实现 27 个内置工具,完整列表详见 README.md「内置工具」章节。下方仅以文件系统工具为示例展示实现规范。

+

🔧 备注:当前已实现 28 个内置工具,完整列表详见 README.md「内置工具」章节。下方仅以文件系统工具为示例展示实现规范。

内置工具示例——文件系统工具

// ====== electron/harness/tools/built-in/filesystem.ts ======
 
@@ -3315,7 +3321,7 @@ export interface ValidationIssue {
 
 

6.2 SQLite 存储方案设计

-

📁 实际表结构:v0.3.3 实际共 9 张表,包含:sessionsmessagesapp_configaudit_logsmcp_serversepisodic_memoriessemantic_memoriesworking_memoriestasks(新增任务表,用于 Agent 子任务管理)。

+

📁 实际表结构:当前实际共 10 张表,包含:sessionsmessagesapp_configaudit_logsmcp_serversepisodic_memoriessemantic_memoriesworking_memoriestasks(任务表,用于 Agent 子任务管理)、session_summaries(会话滚动摘要表,分层上下文加载)。

-- ====== database/schema.sql ======
 
 -- ============================================
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 13638d1..993d42f 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -42,6 +42,34 @@ export default defineConfig({
       outDir: 'dist',
       rollupOptions: {
         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;
+          },
+        },
       },
     },
   },
diff --git a/electron/harness/hooks/__tests__/confirmation-hook.test.ts b/electron/harness/hooks/__tests__/confirmation-hook.test.ts
index 35b1d9e..27138c5 100644
--- a/electron/harness/hooks/__tests__/confirmation-hook.test.ts
+++ b/electron/harness/hooks/__tests__/confirmation-hook.test.ts
@@ -26,7 +26,7 @@ const SAFE_DEF: MetonaToolDef = {
   name: 'read_file',
   description: 'Read file (test fixture)',
   parameters: { type: 'object', properties: {}, required: [] },
-  category: MetonaToolCategory.FILE_SYSTEM,
+  category: MetonaToolCategory.FILESYSTEM,
   riskLevel: MetonaRiskLevel.SAFE,
   requiresPermission: false,
   timeoutMs: 1_000,
@@ -37,7 +37,7 @@ const HIGH_RISK_DEF_2: MetonaToolDef = {
   name: 'delete_file',
   description: 'Delete file (test fixture)',
   parameters: { type: 'object', properties: {}, required: [] },
-  category: MetonaToolCategory.FILE_SYSTEM,
+  category: MetonaToolCategory.FILESYSTEM,
   riskLevel: MetonaRiskLevel.HIGH,
   requiresPermission: true,
   timeoutMs: 1_000,
@@ -315,3 +315,110 @@ describe('ConfirmationHook — clearPending', () => {
     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);
+  });
+});
diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts
index 8916bd5..7971cdf 100644
--- a/electron/harness/hooks/confirmation-hook.ts
+++ b/electron/harness/hooks/confirmation-hook.ts
@@ -40,8 +40,14 @@ export class ConfirmationHook implements PreToolHook {
   /** 工具定义缓存(由外部设置) */
   private toolDefs = new Map();
 
-  /** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */
-  private rememberedDecisions = new Map();
+  /**
+   * 用户选择记忆(sessionId → toolName → 决策)
+   *
+   * v0.5.0: 从全局 Map 改为按会话隔离的两级 Map — 多会话并发时,
+   * A 会话的"记住拒绝/批准"不再影响 B 会话(与 PolicyEngine 频率限制的会话隔离策略一致)。
+   * 值为 { approved, at } 以支持拒绝记忆 TTL(v0.4.1)。
+   */
+  private rememberedDecisions = new Map>();
 
   /**
    * v0.4.1: 会话内拒绝记忆的 TTL(10 分钟)
@@ -63,6 +69,8 @@ export class ConfirmationHook implements PreToolHook {
       timer: NodeJS.Timeout;
       toolName: string;
       expiresAt: number;
+      /** v0.5.0: 发起确认的会话 ID(clearPending 按会话清理的依据) */
+      sessionId: string;
       /** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
       args?: Record;
       riskLevel?: string;
@@ -119,8 +127,8 @@ export class ConfirmationHook implements PreToolHook {
     }
     if (enabled) {
       this.autoExecuteTools.add(toolName);
-      // 自动执行时同步清除会话内"拒绝"记忆(避免冲突)
-      this.rememberedDecisions.delete(toolName);
+      // 自动执行时同步清除所有会话的"拒绝"记忆(避免冲突 — 自动执行是跨会话的全局设置)
+      this.clearDenialMemoryAllSessions(toolName);
     } else {
       this.autoExecuteTools.delete(toolName);
     }
@@ -194,9 +202,9 @@ export class ConfirmationHook implements PreToolHook {
       if (autoExecute && approved) {
         this.setAutoExecute(pending.toolName, true);
       }
-      // 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期)
+      // 会话内记忆(v0.4.1: 拒绝记忆带时间戳用于 TTL;v0.5.0: 按 pending 所属会话写入)
       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);
     }
@@ -242,7 +250,7 @@ export class ConfirmationHook implements PreToolHook {
           this.setAutoExecute(pending.toolName, true);
         }
         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 分钟),到期自动恢复询问;
    * 此方法返回未过期的拒绝记忆,前端可提供"重新询问"按钮主动重置。
+   *
+   * @param sessionId 会话 ID(前端应传当前会话;缺省时聚合全部会话)
    */
-  getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> {
+  getRememberedDenials(sessionId?: string): Array<{ toolName: string; expiresInSeconds: number }> {
     const now = Date.now();
-    const result: Array<{ toolName: string; expiresInSeconds: number }> = [];
-    for (const [toolName, decision] of this.rememberedDecisions) {
-      if (decision.approved) continue;
-      const elapsed = now - decision.at;
-      if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
-        // 已过期 — 顺手清理,避免列表返回过期条目
-        this.rememberedDecisions.delete(toolName);
-        continue;
+    // toolName → 剩余秒数(跨会话聚合时取最长剩余时间)
+    const merged = new Map();
+    for (const [sid, decisions] of this.rememberedDecisions) {
+      if (sessionId && sid !== sessionId) continue;
+      for (const [toolName, decision] of decisions) {
+        if (decision.approved) continue;
+        const elapsed = now - decision.at;
+        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.5.0: sessionId 指定会话;未指定时重置所有会话中该工具的拒绝记忆
    * @returns true 表示重置成功(存在该工具的拒绝记忆);false 表示没有可重置的记忆
    */
-  resetRememberedDenial(toolName: string): boolean {
-    const decision = this.rememberedDecisions.get(toolName);
-    if (decision && !decision.approved) {
-      this.rememberedDecisions.delete(toolName);
-      return true;
+  resetRememberedDenial(toolName: string, sessionId?: string): boolean {
+    let reset = false;
+    for (const [sid, decisions] of this.rememberedDecisions) {
+      if (sessionId && sid !== sessionId) continue;
+      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 {
+  async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise {
     const def = this.toolDefs.get(toolCall.name);
     if (!def) {
       // 未知工具,放行(由 ToolRegistry 处理未知工具错误)
@@ -332,14 +353,15 @@ export class ConfirmationHook implements PreToolHook {
       return { blocked: false };
     }
 
-    // 检查是否有记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问)
-    const remembered = this.rememberedDecisions.get(toolCall.name);
-    if (remembered !== undefined) {
+    // 检查本会话记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问;v0.5.0: 按会话隔离)
+    const sessionDecisions = this.rememberedDecisions.get(sessionId);
+    const remembered = sessionDecisions?.get(toolCall.name);
+    if (sessionDecisions && remembered !== undefined) {
       const isExpiredDenial =
         !remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS;
       if (isExpiredDenial) {
         // 拒绝记忆已过期 — 移除并继续走正常确认流程
-        this.rememberedDecisions.delete(toolCall.name);
+        sessionDecisions.delete(toolCall.name);
       } else {
         if (remembered.approved) return { blocked: false };
         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) {
       return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` };
@@ -385,7 +407,7 @@ export class ConfirmationHook implements PreToolHook {
    */
   private lastTimeoutToastAt = 0;
 
-  private waitForConfirmation(request: ConfirmationRequest): Promise {
+  private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise {
     return new Promise((resolve) => {
       const expiresAt = Date.now() + this.confirmationTimeoutMs;
 
@@ -430,6 +452,7 @@ export class ConfirmationHook implements PreToolHook {
         timer,
         toolName: request.toolName,
         expiresAt,
+        sessionId,
         // v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回
         args: request.args,
         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 {
-    for (const [, pending] of this.pendingConfirmations) {
+  clearPending(sessionId?: string): void {
+    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);
       pending.resolve(false);
+      this.pendingConfirmations.delete(id);
+    }
+  }
+
+  // ===== 私有辅助(v0.5.0: 会话隔离) =====
+
+  /** 获取(或创建)指定会话的决策记忆表 */
+  private decisionsFor(sessionId: string): Map {
+    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();
   }
 }
diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts
index fe6a35f..44af6b1 100644
--- a/electron/harness/orchestration/orchestrator.ts
+++ b/electron/harness/orchestration/orchestrator.ts
@@ -24,7 +24,12 @@ import { EventEmitter } from 'events';
 import { nanoid } from 'nanoid';
 import { AgentLoopEngine } from '../agent-loop/engine';
 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 { PreToolHook } from '../hooks/pre-tool';
 import type { PostToolHook } from '../hooks/post-tool';
@@ -32,12 +37,28 @@ import log from 'electron-log';
 
 export interface SubAgentResult {
   taskId: string;
+  /** v0.5.0: 委派方(父会话)ID — 供事件消费者按会话过滤 */
+  parentSessionId: string;
   result: string;
   success: boolean;
   durationMs: 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 实现)
  * orchestrator 不再持有单个引擎引用,而是按需创建独立实例。
@@ -115,9 +136,12 @@ export class TaskOrchestrator extends EventEmitter {
     // ===== 递归深度检查 =====
     const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
     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 {
         taskId,
+        parentSessionId: params.parentSessionId,
         result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`,
         success: false,
         durationMs: 0,
@@ -127,7 +151,12 @@ export class TaskOrchestrator extends EventEmitter {
     const depth = currentDepth + 1;
     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 信号) =====
     const subEngine = new AgentLoopEngine(
@@ -147,6 +176,10 @@ export class TaskOrchestrator extends EventEmitter {
     subEngine.setFallbackAdapter(this.engines.getFallbackAdapter());
     subEngine.setWorkspacePath(this.engines.getWorkspacePath());
 
+    // v0.5.0: 转发 SubEngine 的流式/状态事件(供 TRACE 录制与前端可观测)
+    // 事件以 taskId 为 sessionId,录制到独立 JSONL 文件,不污染父会话的流
+    this.forwardSubEngineEvents(subEngine, taskId);
+
     // ===== 工具白名单设置 =====
     const allowedTools = this.resolveTools(params.tools);
     subEngine.setTools(allowedTools);
@@ -163,11 +196,21 @@ export class TaskOrchestrator extends EventEmitter {
         handle.status = 'error';
         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.emit('taskStarted', { taskId, depth });
+    this.emit('taskStarted', {
+      taskId,
+      description: params.description,
+      parentSessionId: params.parentSessionId,
+      depth,
+    });
 
     try {
       // 构建用户消息
@@ -184,7 +227,7 @@ export class TaskOrchestrator extends EventEmitter {
       const output = await subEngine.runStream(
         userMessage,
         taskId,
-        [],  // 子 Agent 无历史
+        [], // 子 Agent 无历史
         systemPrompt,
       );
 
@@ -192,6 +235,7 @@ export class TaskOrchestrator extends EventEmitter {
       const success = output.terminationReason === 'completed';
       const result: SubAgentResult = {
         taskId,
+        parentSessionId: params.parentSessionId,
         result: output.finalAnswer,
         success,
         durationMs,
@@ -202,7 +246,9 @@ export class TaskOrchestrator extends EventEmitter {
       handle.result = 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;
     } catch (error) {
@@ -210,6 +256,7 @@ export class TaskOrchestrator extends EventEmitter {
       const errMsg = (error as Error).message;
       const result: SubAgentResult = {
         taskId,
+        parentSessionId: params.parentSessionId,
         result: errMsg,
         success: false,
         durationMs,
@@ -218,7 +265,12 @@ export class TaskOrchestrator extends EventEmitter {
 
       handle.status = 'error';
       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}`);
 
@@ -326,13 +378,25 @@ export class TaskOrchestrator extends EventEmitter {
     const handle = this.activeSubAgents.get(taskId);
     if (handle && handle.status === 'running') {
       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.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());
   }
 
@@ -350,4 +414,24 @@ export class TaskOrchestrator extends EventEmitter {
     // 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。
     this.sessionDepth.clear();
   }
+
+  /**
+   * v0.5.0: 转发 SubEngine 的事件到 orchestrator 事件总线
+   *
+   * SubEngine 不经过 AgentEngineManager 的转发管道(刻意隔离,避免污染父会话流),
+   * 此处将其流式/状态事件重新包装为 subStreamEvent / subStateChange 广播:
+   * - IPC 层监听后录制到独立 TRACE 文件(sessionId = taskId)
+   * - 生命周期事件(taskDelegated 等)另行广播给前端 AgentMonitor
+   *
+   * @param engine SubAgent 的独立引擎实例
+   * @param taskId 子任务 ID(SubEngine runStream 的 sessionId)
+   */
+  private forwardSubEngineEvents(engine: AgentLoopEngine, taskId: string): void {
+    engine.on('streamEvent', (event: Record) => {
+      this.emit('subStreamEvent', { taskId, event });
+    });
+    engine.on('stateChange', (data: Record) => {
+      this.emit('subStateChange', { taskId, data });
+    });
+  }
 }
diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts
index f884bfa..93a2bf5 100644
--- a/electron/ipc/__tests__/agent.test.ts
+++ b/electron/ipc/__tests__/agent.test.ts
@@ -135,7 +135,9 @@ function makeCtx(overrides: Record = {}) {
       buildHistoryMessages: vi.fn(() => []),
       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() },
     reloadAdapter: vi.fn(() => true),
     ...overrides,
diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts
index 2113e4f..d39adb6 100644
--- a/electron/ipc/agent.ts
+++ b/electron/ipc/agent.ts
@@ -17,6 +17,7 @@ import { broadcast } from './context';
 import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types';
 import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
 import { estimateMessagesTokens } from '../harness/utils/token-estimator';
+import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
 import log from 'electron-log';
 
 /** 单会话的 text_delta 节流状态 */
@@ -511,8 +512,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
           log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
 
           // v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
+          // 类型谓词收窄 severity,确保与 MetonaValidationPayload.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);
           if (visibleIssues.length > 0) {
             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) => {
@@ -693,7 +719,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
     // MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
     await agentEngineManager.waitForAbort(sessionId);
     // v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
-    confirmationHook.clearPending();
+    // v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
+    confirmationHook.clearPending(sessionId);
 
     // TOOL 层:记录中断
     auditService.log({
@@ -707,4 +734,228 @@ export function registerAgentHandlers(ctx: IPCContext): void {
 
     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();
+  /** 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();
+
+  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('llm.provider') ?? '';
+        const model = configService.get('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;
+      }
+    },
+  );
 }
diff --git a/electron/ipc/app.ts b/electron/ipc/app.ts
index 37955ad..5c32e32 100644
--- a/electron/ipc/app.ts
+++ b/electron/ipc/app.ts
@@ -108,46 +108,56 @@ export function registerAppHandlers(ctx: IPCContext): void {
 
   // ===== SearXNG 连接测试 =====
 
-  ipcMain.handle('searxng:testConnection', async (_event, url: string, authKey: string, authType: string) => {
-    try {
-      if (!url || !/^https?:\/\//.test(url)) {
-        return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
-      }
-
-      const startTime = Date.now();
-      const headers: Record = {};
-
-      if (authKey) {
-        if (authType === 'bearer') {
-          headers['Authorization'] = `Bearer ${authKey}`;
-        } else if (authType === 'basic') {
-          headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
+  ipcMain.handle(
+    'searxng:testConnection',
+    async (_event, url: string, authKey: string, authType: string) => {
+      try {
+        if (!url || !/^https?:\/\//.test(url)) {
+          return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
         }
+
+        const startTime = Date.now();
+        const headers: Record = {};
+
+        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: 审计日志链式哈希验证 =====
 
   ipcMain.handle('audit:verifyChain', async () => {
     try {
       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 };
     } catch (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) => {
     // M-45 修复: 校验 filters 参数类型和范围
     const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
-      'tool_call', 'permission_check', 'error', 'llm_request',
-      'llm_response', 'session_start', 'session_end', 'config_change',
+      'tool_call',
+      'permission_check',
+      'error',
+      'llm_request',
+      'llm_response',
+      'session_start',
+      'session_end',
+      'config_change',
     ];
     const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
     if (filters && typeof filters === 'object') {
@@ -168,7 +202,10 @@ export function registerAppHandlers(ctx: IPCContext): void {
       if (typeof f.eventType === 'string' && f.eventType) {
         // 校验 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;
       }
diff --git a/electron/ipc/sessions.ts b/electron/ipc/sessions.ts
index ce5c52d..6bb3855 100644
--- a/electron/ipc/sessions.ts
+++ b/electron/ipc/sessions.ts
@@ -26,7 +26,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
   ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
     // M-34 修复: 校验 sessionId 和 title
     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) };
   });
 
@@ -55,7 +56,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
   });
 
   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) };
   });
 
@@ -65,13 +67,29 @@ export function registerSessionHandlers(ctx: IPCContext): void {
   });
 
   // P2-11: 消息截断(编辑重发 / 重新生成)
-  ipcMain.handle('sessions:truncateAfter', async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
-    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;
+  ipcMain.handle(
+    'sessions:truncateAfter',
+    async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
+      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 {
-      const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
-      return { success: true, truncated };
+      const results = sessionService.searchMessages(query);
+      return { success: true, data: results };
     } catch (error) {
       return { success: false, error: (error as Error).message };
     }
diff --git a/electron/ipc/tools.ts b/electron/ipc/tools.ts
index aa020d4..cdec627 100644
--- a/electron/ipc/tools.ts
+++ b/electron/ipc/tools.ts
@@ -128,20 +128,26 @@ export function registerToolHandlers(ctx: IPCContext): void {
   });
 
   // ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) =====
-  ipcMain.handle('tool:getRememberedDenials', async () => {
-    return { success: true, data: confirmationHook.getRememberedDenials() };
+  // v0.5.0: sessionId 可选参数 — 拒绝记忆按会话隔离,前端应传当前会话 ID
+  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) => {
-    if (typeof toolName !== 'string' || !toolName) {
-      return { success: false, error: 'Invalid toolName' };
-    }
-    const reset = confirmationHook.resetRememberedDenial(toolName);
-    if (reset) {
-      log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
-    }
-    return { success: reset };
-  });
+  ipcMain.handle(
+    'tool:resetRememberedDenial',
+    async (_event, toolName: unknown, sessionId?: unknown) => {
+      if (typeof toolName !== 'string' || !toolName) {
+        return { success: false, error: 'Invalid toolName' };
+      }
+      const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
+      const reset = confirmationHook.resetRememberedDenial(toolName, sid);
+      if (reset) {
+        log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
+      }
+      return { success: reset };
+    },
+  );
 
   // ===== v0.2.0: 持久化自动执行设置 =====
   ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
diff --git a/electron/main.ts b/electron/main.ts
index e7926b3..48c2663 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -15,7 +15,7 @@
  */
 
 import 'dotenv/config';
-import { app, shell, Menu, BrowserWindow } from 'electron';
+import { app, shell, Menu, BrowserWindow, dialog } from 'electron';
 import { join } from 'path';
 import { existsSync, readFileSync, writeFileSync } from 'fs';
 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 type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
 import {
-  ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
-  WebSearchTool, WebFetchTool,
-  MemoryStoreTool, MemorySearchTool,
+  ReadFileTool,
+  WriteFileTool,
+  ListDirectoryTool,
+  SearchFilesTool,
+  WebSearchTool,
+  WebFetchTool,
+  MemoryStoreTool,
+  MemorySearchTool,
   RunCommandTool,
-  WebBrowserTool, cleanupBrowser,
+  WebBrowserTool,
+  cleanupBrowser,
   DelegateTaskTool,
   FileEditorTool,
   CodeSearchTool,
   TaskManagerTool,
   DiffViewerTool,
-  GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
-  LintCodeTool, RunTestsTool, ProjectInfoTool,
+  GitStatusTool,
+  GitDiffTool,
+  GitLogTool,
+  GitCommitTool,
+  LintCodeTool,
+  RunTestsTool,
+  ProjectInfoTool,
   HttpRequestTool,
   ThinkTool,
   ViewImageTool,
@@ -65,7 +76,13 @@ import {
   FileMoveTool,
   FileInfoTool,
 } 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 { PolicyEngine } from './harness/sandbox/permissions';
 import { SandboxManager } from './harness/sandbox/sandbox';
@@ -139,7 +156,9 @@ async function initialize(): Promise {
   if (savedWorkspacePath !== 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 =====
   databaseService = new DatabaseService(workspaceInfo.path);
@@ -157,7 +176,10 @@ async function initialize(): Promise {
 
   // v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
   // 幂等设计: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 = {};
   for (const row of configRows) {
     try {
@@ -168,7 +190,9 @@ async function initialize(): Promise {
   }
   const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
   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 {
     const provider = configService.get('llm.provider') ?? '';
     const model = configService.get('llm.model') ?? '';
     const env = ENV_FALLBACK[provider] ?? {};
-    const apiKey = configService.get('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
-    const baseURL = configService.get('llm.baseURL') || (env.baseURL ? process.env[env.baseURL] : '') || '';
+    const apiKey =
+      configService.get('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
+    const baseURL =
+      configService.get('llm.baseURL') ||
+      (env.baseURL ? process.env[env.baseURL] : '') ||
+      '';
 
     // 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding)
     if (!provider || !baseURL || !model) {
@@ -200,24 +228,35 @@ async function initialize(): Promise {
     }
 
     // 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
-    const contextWindow = provider !== 'ollama'
-      ? configService.get(`${provider}.contextWindow`) ?? undefined
-      : undefined;
+    const contextWindow =
+      provider !== 'ollama'
+        ? (configService.get(`${provider}.contextWindow`) ?? undefined)
+        : undefined;
 
     const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
     switch (provider) {
-      case 'agnes': return new AgnesAdapter(adapterConfig);
-      case 'mimo': return new MimoAdapter(adapterConfig);
-      case 'ollama': return new OllamaAdapter(adapterConfig);
-      case 'openai': return new OpenAIAdapter(adapterConfig);
-      case 'anthropic': return new AnthropicAdapter(adapterConfig);
-      default: return new DeepSeekAdapter(adapterConfig);
+      case 'agnes':
+        return new AgnesAdapter(adapterConfig);
+      case 'mimo':
+        return new MimoAdapter(adapterConfig);
+      case 'ollama':
+        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 会报错但前端可见
   const FALLBACK_ADAPTER = new DeepSeekAdapter({
-    provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096,
+    provider: '',
+    baseURL: '',
+    apiKey: '',
+    defaultModel: '',
+    contextWindow: 4096,
   });
 
   // ===== 步骤 5: 工作空间文件 + System Prompt =====
@@ -262,11 +301,16 @@ async function initialize(): Promise {
   toolRegistry.registerBuiltin(runCommandTool);
 
   // P2(v0.3.13): 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
-  toolRegistry.registerBuiltin(new TaskManagerTool(() => db, (sessionId) => {
-    for (const win of BrowserWindow.getAllWindows()) {
-      win.webContents.send('task:changed', sessionId);
-    }
-  }));
+  toolRegistry.registerBuiltin(
+    new TaskManagerTool(
+      () => db,
+      (sessionId) => {
+        for (const win of BrowserWindow.getAllWindows()) {
+          win.webContents.send('task:changed', sessionId);
+        }
+      },
+    ),
+  );
 
   toolRegistry.registerBuiltin(new WebBrowserTool());
 
@@ -308,7 +352,13 @@ async function initialize(): Promise {
       contextLength: ollamaNumCtx ?? undefined,
       contextWindow: buildAdapter().getContextWindow(),
       thinkingEnabled: configService.get('agent.enableThinking') ?? true,
-      thinkingEffort: (configService.get('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high',
+      thinkingEffort:
+        (configService.get('agent.thinkingEffort') as
+          | 'low'
+          | 'medium'
+          | 'high'
+          | 'max'
+          | null) ?? 'high',
       toolExecutionTimeoutMs: configService.get('agent.toolExecutionTimeoutMs') ?? 120_000,
     },
     toolRegistry,
@@ -323,21 +373,34 @@ async function initialize(): Promise {
     const model = configService.get('llm.fallbackModel');
     if (!provider || !model) return null;
     const env = ENV_FALLBACK[provider] ?? {};
-    const apiKey = configService.get('llm.fallbackApiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
-    const baseURL = configService.get('llm.fallbackBaseURL') || (env.baseURL ? process.env[env.baseURL] : '') || '';
+    const apiKey =
+      configService.get('llm.fallbackApiKey') ||
+      (env.apiKey ? process.env[env.apiKey] : '') ||
+      '';
+    const baseURL =
+      configService.get('llm.fallbackBaseURL') ||
+      (env.baseURL ? process.env[env.baseURL] : '') ||
+      '';
     if (!baseURL) return null;
     if (!apiKey && provider !== 'ollama') return null;
-    const contextWindow = provider !== 'ollama'
-      ? configService.get(`${provider}.contextWindow`) ?? undefined
-      : undefined;
+    const contextWindow =
+      provider !== 'ollama'
+        ? (configService.get(`${provider}.contextWindow`) ?? undefined)
+        : undefined;
     const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
     switch (provider) {
-      case 'agnes': return new AgnesAdapter(cfg);
-      case 'mimo': return new MimoAdapter(cfg);
-      case 'ollama': return new OllamaAdapter(cfg);
-      case 'openai': return new OpenAIAdapter(cfg);
-      case 'anthropic': return new AnthropicAdapter(cfg);
-      default: return new DeepSeekAdapter(cfg);
+      case 'agnes':
+        return new AgnesAdapter(cfg);
+      case 'mimo':
+        return new MimoAdapter(cfg);
+      case 'ollama':
+        return new OllamaAdapter(cfg);
+      case 'openai':
+        return new OpenAIAdapter(cfg);
+      case 'anthropic':
+        return new AnthropicAdapter(cfg);
+      default:
+        return new DeepSeekAdapter(cfg);
     }
   };
   agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
@@ -345,24 +408,27 @@ async function initialize(): Promise {
   // P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口)
   // v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
   const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
-  mcpManager.initialize().then(() => {
-    agentEngineManager.setToolsAll(toolRegistry.listTools());
-    log.info('[MCP] Tools registered and synced to all engines');
-    toolsReadyRef.ready = true;
-    toolsReadyRef.toolCount = toolRegistry.size;
-    // 广播工具就绪事件给所有窗口
-    for (const win of BrowserWindow.getAllWindows()) {
-      win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
-    }
-  }).catch((err) => {
-    log.warn('MCP Manager initialization error:', err);
-    // 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
-    toolsReadyRef.ready = true;
-    toolsReadyRef.toolCount = toolRegistry.size;
-    for (const win of BrowserWindow.getAllWindows()) {
-      win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
-    }
-  });
+  mcpManager
+    .initialize()
+    .then(() => {
+      agentEngineManager.setToolsAll(toolRegistry.listTools());
+      log.info('[MCP] Tools registered and synced to all engines');
+      toolsReadyRef.ready = true;
+      toolsReadyRef.toolCount = toolRegistry.size;
+      // 广播工具就绪事件给所有窗口
+      for (const win of BrowserWindow.getAllWindows()) {
+        win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
+      }
+    })
+    .catch((err) => {
+      log.warn('MCP Manager initialization error:', err);
+      // 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
+      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) =====
   const memoryConsolidator = new MemoryConsolidator(buildAdapter(), workspaceService);
@@ -378,10 +444,19 @@ async function initialize(): Promise {
 
   // ===== Task Orchestrator(子任务委派,P2-10: 适配 EngineProvider) =====
   const orchestrator = new TaskOrchestrator(
-    agentEngineManager, toolRegistry, preToolHooks, postToolHooks,
+    agentEngineManager,
+    toolRegistry,
+    preToolHooks,
+    postToolHooks,
     {
       thinkingEnabled: configService.get('agent.enableThinking') ?? true,
-      thinkingEffort: (configService.get('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high',
+      thinkingEffort:
+        (configService.get('agent.thinkingEffort') as
+          | 'low'
+          | 'medium'
+          | 'high'
+          | 'max'
+          | null) ?? 'high',
       contextLength: ollamaNumCtx ?? undefined,
       contextWindow: buildAdapter().getContextWindow(),
     },
@@ -407,9 +482,10 @@ async function initialize(): Promise {
       fallbackModel: configService.get('llm.fallbackModel') ?? '',
       fallbackApiKey: configService.get('llm.fallbackApiKey') ?? '',
       fallbackBaseURL: configService.get('llm.fallbackBaseURL') ?? '',
-      contextWindow: provider === 'ollama'
-        ? configService.get('ollama.numCtx')
-        : configService.get(`${provider}.contextWindow`),
+      contextWindow:
+        provider === 'ollama'
+          ? configService.get('ollama.numCtx')
+          : configService.get(`${provider}.contextWindow`),
     });
   };
   let lastConfigSig = buildConfigSig();
@@ -425,7 +501,9 @@ async function initialize(): Promise {
       // 配置变化 — 校验新配置可构建 adapter
       const probe = createAdapter();
       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();
         for (const win of wins) {
           win.webContents.send('toast:show', {
@@ -528,15 +606,23 @@ async function initialize(): Promise {
   windowManager.registerGlobalShortcuts();
 
   // ===== Agent 状态同步到托盘(P2-10: 监听 manager 聚合事件) =====
-  agentEngineManager.on('stateChange', (data: { previous?: string; current?: string; state?: string }) => {
-    const statusMap: Record = {
-      INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
-      EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking',
-      COMPRESSING: 'thinking', TERMINATED: 'idle',
-    };
-    const stateValue = (data.current || data.state) ?? '';
-    trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
-  });
+  agentEngineManager.on(
+    'stateChange',
+    (data: { previous?: string; current?: string; state?: string }) => {
+      const statusMap: Record = {
+        INIT: 'idle',
+        THINKING: 'thinking',
+        PARSING: 'thinking',
+        EXECUTING: 'executing',
+        OBSERVING: 'thinking',
+        REFLECTING: 'thinking',
+        COMPRESSING: 'thinking',
+        TERMINATED: 'idle',
+      };
+      const stateValue = (data.current || data.state) ?? '';
+      trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
+    },
+  );
 
   // ===== Agent 完成时发送系统通知 =====
   agentEngineManager.on('complete', (data: { sessionId: string; durationMs: number }) => {
@@ -560,7 +646,10 @@ async function initialize(): Promise {
     try {
       const report = await healthChecker.check();
       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}`);
         trayManager?.setStatus('error');
       } else {
@@ -571,7 +660,7 @@ async function initialize(): Promise {
       if (slo.violated) {
         log.warn(
           `[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) {
@@ -613,7 +702,10 @@ async function initialize(): Promise {
     // v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
     const shutdownTimeout = setTimeout(() => {
       log.warn('[Shutdown] Timeout reached, forcing exit');
-      if (databaseService) { databaseService.close(); databaseService = null; }
+      if (databaseService) {
+        databaseService.close();
+        databaseService = null;
+      }
       app.exit(0);
     }, 40_000);
 
@@ -636,19 +728,36 @@ async function initialize(): Promise {
         }
       }
 
-      if (databaseService) { databaseService.close(); databaseService = null; }
+      if (databaseService) {
+        databaseService.close();
+        databaseService = null;
+      }
       clearTimeout(shutdownTimeout);
       app.exit(0);
     })().catch((err) => {
       log.error('[Shutdown] Async cleanup failed:', err);
       clearTimeout(shutdownTimeout);
-      if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; }
+      if (databaseService) {
+        try {
+          databaseService.close();
+        } catch {
+          /* ignore */
+        }
+        databaseService = null;
+      }
       app.exit(1);
     });
   });
 
   // ===== 应用日志级别配置 =====
-  const logLevel = configService.get('logging.level') as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null;
+  const logLevel = configService.get('logging.level') as
+    | 'error'
+    | 'warn'
+    | 'info'
+    | 'debug'
+    | 'verbose'
+    | 'silly'
+    | null;
   if (logLevel) {
     log.transports.file.level = logLevel;
     log.transports.console.level = logLevel;
@@ -664,10 +773,31 @@ async function initialize(): Promise {
     }
   }
 
-  log.info(`MetonaAI Desktop initialized [Provider: ${configService.get('llm.provider') ?? 'none'}, Model: ${configService.get('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`);
+  log.info(
+    `MetonaAI Desktop initialized [Provider: ${configService.get('llm.provider') ?? 'none'}, Model: ${configService.get('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) => {
   // C-8 修复: 全局 web-contents 监听器也校验 URL 协议
diff --git a/electron/preload.ts b/electron/preload.ts
index c4489b1..1285683 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -37,6 +37,12 @@ const metonaAPI = {
       ipcRenderer.on('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: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
     truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
       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) =>
       ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
     getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId),
@@ -114,6 +135,15 @@ const metonaAPI = {
     verifyChain: () => ipcRenderer.invoke('audit:verifyChain'),
     query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
       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: 工具确认请求 =====
@@ -150,13 +180,14 @@ const metonaAPI = {
         }>;
       }>,
     // v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问)
-    getRememberedDenials: () =>
-      ipcRenderer.invoke('tool:getRememberedDenials') as Promise<{
+    // v0.5.0: sessionId 参数 — 拒绝记忆按会话隔离,传当前会话只查看/重置本会话的记忆
+    getRememberedDenials: (sessionId?: string) =>
+      ipcRenderer.invoke('tool:getRememberedDenials', sessionId) as Promise<{
         success: boolean;
         data: Array<{ toolName: string; expiresInSeconds: number }>;
       }>,
-    resetRememberedDenial: (toolName: string) =>
-      ipcRenderer.invoke('tool:resetRememberedDenial', toolName) as Promise<{
+    resetRememberedDenial: (toolName: string, sessionId?: string) =>
+      ipcRenderer.invoke('tool:resetRememberedDenial', toolName, sessionId) as Promise<{
         success: boolean;
         error?: string;
       }>,
@@ -237,6 +268,22 @@ const metonaAPI = {
       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: {
     onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
diff --git a/electron/services/__tests__/session-search.test.ts b/electron/services/__tests__/session-search.test.ts
new file mode 100644
index 0000000..38e897e
--- /dev/null
+++ b/electron/services/__tests__/session-search.test.ts
@@ -0,0 +1,270 @@
+/**
+ * FTS5 会话内容搜索 + 审计日志导出 回归测试(v0.5.0)
+ *
+ * 覆盖:
+ * 1. messages_fts 虚表创建与触发器实时同步(INSERT / DELETE)
+ * 2. searchMessages:短语命中、跨会话聚合、FTS 运算符输入安全、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);
+  });
+});
diff --git a/electron/services/audit.service.ts b/electron/services/audit.service.ts
index da5a25e..3e8e0a9 100644
--- a/electron/services/audit.service.ts
+++ b/electron/services/audit.service.ts
@@ -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 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 {
   sessionId: string;
   iteration?: number;
@@ -64,9 +88,15 @@ export class AuditService {
   private computeHash(
     prevHash: string,
     entry: {
-      sessionId: string; iteration: number | null; eventType: string;
-      actor: string; target: string; details: string | null;
-      outcome: string | null; durationMs: number | null; createdAt: number;
+      sessionId: string;
+      iteration: number | null;
+      eventType: string;
+      actor: string;
+      target: string;
+      details: string | null;
+      outcome: string | null;
+      durationMs: number | null;
+      createdAt: number;
     },
   ): string {
     // #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
@@ -94,8 +124,11 @@ export class AuditService {
   private getLastHash(): string {
     if (this.cachedLastHash !== null) return this.cachedLastHash;
     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;
-    this.cachedLastHash = row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
+    const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as
+      | { current_hash: string | null }
+      | undefined;
+    this.cachedLastHash =
+      row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
     return this.cachedLastHash;
   }
 
@@ -120,10 +153,12 @@ export class AuditService {
         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)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-      `).run(
+      `,
+      ).run(
         entry.sessionId,
         entry.iteration ?? null,
         entry.eventType,
@@ -257,11 +292,7 @@ export class AuditService {
   /**
    * 查询审计日志
    */
-  query(filters?: {
-    sessionId?: string;
-    eventType?: AuditEventType;
-    limit?: number;
-  }): Array<{
+  query(filters?: { sessionId?: string; eventType?: AuditEventType; limit?: number }): Array<{
     id: number;
     session_id: string;
     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)[h]))
+          .join(','),
+      );
+    }
+    return lines.join('\n') + '\n';
+  }
+
   /**
    * v0.2.0: 验证链式哈希完整性
    *
@@ -319,12 +412,30 @@ export class AuditService {
    *
    * @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 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<{
-      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;
+    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<{
+      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';
@@ -369,7 +480,12 @@ export class AuditService {
           prevHash = row.current_hash;
           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;
diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts
index f887cc4..13bc8c5 100644
--- a/electron/services/database.service.ts
+++ b/electron/services/database.service.ts
@@ -211,10 +211,12 @@ export class DatabaseService {
       );
 
       -- ===== MCP 服务配置表 =====
+      -- v0.5.0: 建表 CHECK 直接含 streamable-http(与迁移 6 的重建后 schema 对齐,
+      -- 新库不再依赖迁移 6 立即重建一次表)
       CREATE TABLE IF NOT EXISTS mcp_servers (
         id              TEXT PRIMARY KEY,
         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,
         args            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_status ON tasks(session_id, status);
       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)
@@ -414,6 +438,28 @@ export class DatabaseService {
         // 非致命 — 迁移失败时仅无法添加 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
       // @see project_memory.md — Assistant messages with tool_calls must set content to null
       // SQLite 不支持 ALTER COLUMN,需要重建表
@@ -457,13 +503,30 @@ export class DatabaseService {
             // #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
             //   覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
             //   工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
+            // v0.5.0: DROP TABLE messages 连带删除了 FTS 触发器,此处必须重建
             db.exec(`
-              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_session ON messages(session_id, created_at);
+            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();
 
+          // 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)');
         }
       } catch (error) {
diff --git a/electron/services/session.service.ts b/electron/services/session.service.ts
index 097e268..2b70e58 100644
--- a/electron/services/session.service.ts
+++ b/electron/services/session.service.ts
@@ -86,11 +86,15 @@ export class SessionService {
     const db = this.getDBFn();
     const archived = options?.archived ?? false;
 
-    const rows = db.prepare(`
+    const rows = db
+      .prepare(
+        `
       SELECT * FROM sessions
       WHERE archived = ?
       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));
   }
@@ -104,10 +108,12 @@ export class SessionService {
     const now = Date.now();
     const sessionTitle = title ?? '新会话';
 
-    db.prepare(`
+    db.prepare(
+      `
       INSERT INTO sessions (id, title, created_at, updated_at)
       VALUES (?, ?, ?, ?)
-    `).run(id, sessionTitle, now, now);
+    `,
+    ).run(id, sessionTitle, now, now);
 
     log.info(`Session created: ${id} (${sessionTitle})`);
 
@@ -128,9 +134,13 @@ export class SessionService {
    */
   rename(sessionId: string, title: string): boolean {
     const db = this.getDBFn();
-    const result = db.prepare(`
+    const result = db
+      .prepare(
+        `
       UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?
-    `).run(title, Date.now(), sessionId);
+    `,
+      )
+      .run(title, Date.now(), sessionId);
 
     if (result.changes > 0) {
       log.info(`Session renamed: ${sessionId} → ${title}`);
@@ -158,9 +168,13 @@ export class SessionService {
    */
   pin(sessionId: string, pinned: boolean): boolean {
     const db = this.getDBFn();
-    const result = db.prepare(`
+    const result = db
+      .prepare(
+        `
       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;
   }
@@ -170,9 +184,13 @@ export class SessionService {
    */
   archive(sessionId: string, archived: boolean): boolean {
     const db = this.getDBFn();
-    const result = db.prepare(`
+    const result = db
+      .prepare(
+        `
       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;
   }
@@ -252,7 +270,9 @@ export class SessionService {
         'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
       ).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;
   }
@@ -279,10 +299,12 @@ export class SessionService {
     //   message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
     //   事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
     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)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
-      `).run(
+      `,
+      ).run(
         id,
         params.sessionId,
         params.role,
@@ -296,11 +318,13 @@ export class SessionService {
       );
 
       // 更新会话的 updated_at 和 message_count
-      db.prepare(`
+      db.prepare(
+        `
         UPDATE sessions
         SET updated_at = ?, message_count = message_count + 1
         WHERE id = ?
-      `).run(now, params.sessionId);
+      `,
+      ).run(now, params.sessionId);
     });
     saveMessageTxn();
 
@@ -322,9 +346,11 @@ export class SessionService {
    */
   updateTokenUsage(sessionId: string, tokens: number): void {
     const db = this.getDBFn();
-    db.prepare(`
+    db.prepare(
+      `
       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 {
     const db = this.getDBFn();
     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;
   }
 
+  /**
+   * 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)
    */
   saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void {
     const db = this.getDBFn();
     const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage });
-    db.prepare(`
+    db.prepare(
+      `
       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 {
     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;
     try {
       const data = JSON.parse(row.metadata);
diff --git a/eslint.config.js b/eslint.config.js
index 58cb0d2..184ef95 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,6 +1,8 @@
 // ESLint Flat Config(P1-5)
 // 项目此前有 lint 脚本但无任何配置文件(npm run lint 直接报错)。
-// 规则取向:未用变量/any 等降为 warn(存量代码渐进清理),错误级规则保持零容忍。
+// 规则取向:部分规则(no-unused-vars / no-explicit-any)配置为 warn 级别以便渐进收紧,
+// 但执行标准是零容忍 —— `npm run lint` 必须返回 0 problems(0 error / 0 warning),
+// 新增代码不允许引入任何 warning(v0.4.0 起确立的工程质量基线)。
 
 import js from '@eslint/js';
 import tseslint from 'typescript-eslint';
diff --git a/package.json b/package.json
index 01e5ef7..a482eba 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "metona-ai-desktop",
-  "version": "0.4.1",
+  "version": "0.5.0",
   "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
   "main": "dist-electron/main/main.js",
   "author": "Metona Team",
@@ -16,7 +16,7 @@
     "lint": "eslint .",
     "lint:fix": "eslint . --fix",
     "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",
     "test": "vitest run",
     "test:watch": "vitest",
diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx
index 2bae594..16187e7 100644
--- a/src/components/ConfirmationDialog.tsx
+++ b/src/components/ConfirmationDialog.tsx
@@ -42,6 +42,7 @@ import {
   Divider,
 } from '@mui/material';
 import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
+import { useAgentStore } from '@renderer/stores/agent-store';
 
 interface ConfirmationRequest {
   toolCallId: string;
@@ -87,9 +88,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
   >([]);
 
   // v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步)
+  // v0.5.0: 传当前会话 ID — 拒绝记忆按会话隔离,只展示本会话的记忆
   const refreshRememberedDenials = useCallback(async () => {
     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 ?? []) : []);
     } catch {
       setRememberedDenials([]);
@@ -97,9 +100,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
   }, []);
 
   // v0.4.1: 重置某个工具的拒绝记忆(恢复询问)
+  // v0.5.0: 传当前会话 ID,只重置本会话的记忆
   const handleResetDenial = useCallback(async (toolName: string) => {
     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));
     } catch {
       // 重置失败保持现状,TTL 到期后仍会自动恢复
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx
index c811e8d..e5b5fb5 100644
--- a/src/components/ContextMenu.tsx
+++ b/src/components/ContextMenu.tsx
@@ -7,11 +7,31 @@
  */
 
 import {
-  Menu, MenuItem, ListItemIcon, ListItemText,
-  Dialog, DialogTitle, DialogContent, DialogActions,
-  Button, TextField, Typography,
+  Menu,
+  MenuItem,
+  ListItemIcon,
+  ListItemText,
+  Dialog,
+  DialogTitle,
+  DialogContent,
+  DialogActions,
+  Button,
+  TextField,
+  Typography,
 } 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 { useAgentStore } from '@renderer/stores/agent-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 {
   return (
-    
+    
       {items.map((item) => {
         const Icon = item.icon;
         return (
-           { if (!item.disabled) { item.action(); onClose(); } }} disabled={item.disabled} dense>
-            
-            {item.label}
+           {
+              if (!item.disabled) {
+                item.action();
+                onClose();
+              }
+            }}
+            disabled={item.disabled}
+            dense
+          >
+            
+              
+            
+            
+              {item.label}
+            
           
         );
       })}
@@ -85,12 +125,25 @@ const useDialogStore = create((set, get) => ({
   promptLabel: '',
   promptValue: '',
   promptResolve: null,
-  confirm: (message, title = '确认') => new Promise((resolve) => {
-    set({ confirmOpen: true, confirmTitle: title, confirmMessage: message, confirmResolve: resolve });
-  }),
-  prompt: (label, defaultValue = '', title = '输入') => new Promise((resolve) => {
-    set({ promptOpen: true, promptTitle: title, promptLabel: label, promptValue: defaultValue, promptResolve: resolve });
-  }),
+  confirm: (message, title = '确认') =>
+    new Promise((resolve) => {
+      set({
+        confirmOpen: true,
+        confirmTitle: title,
+        confirmMessage: message,
+        confirmResolve: resolve,
+      });
+    }),
+  prompt: (label, defaultValue = '', title = '输入') =>
+    new Promise((resolve) => {
+      set({
+        promptOpen: true,
+        promptTitle: title,
+        promptLabel: label,
+        promptValue: defaultValue,
+        promptResolve: resolve,
+      });
+    }),
   setPromptValue: (v) => set({ promptValue: v }),
   closeConfirm: (ok) => {
     const { confirmResolve } = get();
@@ -100,7 +153,13 @@ const useDialogStore = create((set, get) => ({
   closePrompt: (value) => {
     const { promptResolve } = get();
     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 {
           {confirmMessage}
         
         
-          
-          
+          
+          
         
       
        closePrompt(null)} maxWidth="xs" fullWidth>
@@ -144,12 +213,18 @@ export function ContextMenuDialogHost(): React.JSX.Element {
             autoFocus
             fullWidth
             size="small"
-            onKeyDown={(e) => { if (e.key === 'Enter') closePrompt(promptValue); }}
+            onKeyDown={(e) => {
+              if (e.key === 'Enter') closePrompt(promptValue);
+            }}
           />
         
         
-          
-          
+          
+          
         
       
     
@@ -165,7 +240,9 @@ export function ContextMenuDialogHost(): React.JSX.Element {
 function copyWithToast(text: string): void {
   navigator.clipboard.writeText(text).catch((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 items: ContextMenuItem[] = [
         { id: 'copy', icon: Copy, label: '复制', action: () => copyWithToast(content) },
-        { id: 'quote', icon: Quote, label: '引用回复', action: () => {
-          const input = document.querySelector('[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(); }
-        }},
+        {
+          id: 'quote',
+          icon: Quote,
+          label: '引用回复',
+          action: () => {
+            const input = document.querySelector('[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 消息支持重新生成(删除最后一条用户消息后的回复并重发)
       if (d.role === 'assistant') {
@@ -190,7 +280,9 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
           id: 'regenerate',
           icon: RotateCcw,
           label: '重新生成',
-          action: () => { void useAgentStore.getState().regenerate(); },
+          action: () => {
+            void useAgentStore.getState().regenerate();
+          },
         });
       }
       return items;
@@ -199,13 +291,42 @@ export function createContextMenuItems(type: ContextMenuType, data?: unknown): C
     case 'tool-call': {
       const tc = data as { args?: Record; result?: unknown } | undefined;
       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: '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);
-        }},
+        {
+          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: '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(() => {});
       };
       return [
-        { id: 'rename', icon: Edit, label: '重命名', action: async () => {
-          if (!sid) return;
-          // 用 MUI Dialog 替代原生 prompt()(Electron 下不可靠,且与 MUI 风格不一致)
-          const currentTitle = useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
-          const t = await useDialogStore.getState().prompt('新会话名称', currentTitle, '重命名会话');
-          if (!t?.trim()) return;
-          try {
-            const r = await window.metona?.sessions?.rename(sid, t.trim());
-            if (r?.success) {
-              useSessionStore.getState().updateSession(sid, { title: t.trim() });
-            } else {
-              showError(r?.error ?? '重命名失败');
-            }
-          } 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 }>; 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,
-                });
+        {
+          id: 'rename',
+          icon: Edit,
+          label: '重命名',
+          action: async () => {
+            if (!sid) return;
+            // 用 MUI Dialog 替代原生 prompt()(Electron 下不可靠,且与 MUI 风格不一致)
+            const currentTitle =
+              useSessionStore.getState().sessions.find((x) => x.id === sid)?.title ?? '';
+            const t = await useDialogStore
+              .getState()
+              .prompt('新会话名称', currentTitle, '重命名会话');
+            if (!t?.trim()) return;
+            try {
+              const r = await window.metona?.sessions?.rename(sid, t.trim());
+              if (r?.success) {
+                useSessionStore.getState().updateSession(sid, { title: t.trim() });
+              } else {
+                showError(r?.error ?? '重命名失败');
               }
-            } else {
-              showError(r?.error ?? '删除失败');
+            } catch (err) {
+              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': {
       const code = (data as { code?: string })?.code;
       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': {
-      const step = data as { thought?: string; toolCalls?: Array<{ name: string; args: Record }> } | undefined;
+      const step = data as
+        | { thought?: string; toolCalls?: Array<{ name: string; args: Record }> }
+        | undefined;
       return [
-        { id: 'copy-thought', icon: Copy, label: '复制 Thought', action: () => { 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(); }
-        }},
+        {
+          id: 'copy-thought',
+          icon: Copy,
+          label: '复制 Thought',
+          action: () => {
+            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 [];
   }
 }
diff --git a/src/components/layout/AgentMonitor.tsx b/src/components/layout/AgentMonitor.tsx
index 501388a..0d913f1 100644
--- a/src/components/layout/AgentMonitor.tsx
+++ b/src/components/layout/AgentMonitor.tsx
@@ -4,14 +4,48 @@
  * 完全使用 MUI 组件,Table 布局标签-值对。
  */
 
-import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell } from '@mui/material';
-import { Activity, Cpu } from 'lucide-react';
-import { useEffect, useMemo } from 'react';
+import { Box, Typography, Stack, Table, TableBody, TableRow, TableCell, Chip } from '@mui/material';
+import { Activity, Cpu, Bot } from 'lucide-react';
+import { useEffect, useMemo, useState } from 'react';
 import { useAgentStore, type AgentStatus } from '@renderer/stores/agent-store';
 import { AGENT_STATUS_COLORS, AGENT_STATUS_LABELS, PROVIDER_LABELS } from '@renderer/lib/constants';
 import { formatDuration } from '@renderer/lib/formatters';
 
-const STATUS_ICONS: Record = { idle: Activity, thinking: Cpu, executing: Cpu, error: Activity };
+const STATUS_ICONS: Record = {
+  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 = {
+  delegated: '#fbbf24',
+  running: '#a855f7',
+  completed: '#34d399',
+  error: '#f87171',
+};
+
+const SUB_STATUS_LABELS: Record = {
+  delegated: '已委派',
+  running: '运行中',
+  completed: '已完成',
+  error: '失败',
+};
 
 export function AgentMonitor(): React.JSX.Element {
   const agentStatus = useAgentStore((s) => s.agentStatus);
@@ -21,20 +55,57 @@ export function AgentMonitor(): React.JSX.Element {
   const maxIterations = useAgentStore((s) => s.maxIterations);
   const traceSteps = useAgentStore((s) => s.traceSteps);
   const setMaxIterations = useAgentStore((s) => s.setMaxIterations);
+  const sessionId = useAgentStore((s) => s.currentSessionId);
+
+  // v0.5.0: SubAgent 状态(监听主进程生命周期事件,按父会话过滤)
+  const [subAgents, setSubAgents] = useState([]);
+
+  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(() => {
     // M-11/M-28 修复: 添加 cancelled 标志 + 错误日志记录
     let cancelled = false;
     if (window.metona?.config?.get) {
-      window.metona.config.get('agent.maxIterations').then((v) => {
-        if (cancelled) return;
-        if (typeof v === 'number' && v > 0) setMaxIterations(v);
-      }).catch((err) => {
-        // M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
-        console.error('[AgentMonitor] Failed to load maxIterations:', err);
-      });
+      window.metona.config
+        .get('agent.maxIterations')
+        .then((v) => {
+          if (cancelled) return;
+          if (typeof v === 'number' && v > 0) setMaxIterations(v);
+        })
+        .catch((err) => {
+          // M-11 修复: 记录错误而非静默吞掉,便于诊断配置加载异常
+          console.error('[AgentMonitor] Failed to load maxIterations:', err);
+        });
     }
-    return () => { cancelled = true; };
+    return () => {
+      cancelled = true;
+    };
   }, [setMaxIterations]);
 
   // L-14 修复: 使用 useMemo 缓存 totalDuration,避免每次渲染都遍历 traceSteps 数组
@@ -51,24 +122,56 @@ export function AgentMonitor(): React.JSX.Element {
     
       
         
-        
+        
           Agent 状态
         
       
 
       {/* 状态指示 */}
-      
+      
         
           
-          {statusLabel}
+          
+            {statusLabel}
+          
           {(agentStatus === 'thinking' || agentStatus === 'executing') && (
-            
+            
           )}
         
       
@@ -77,33 +180,182 @@ export function AgentMonitor(): React.JSX.Element {
       
           
-            Provider
-            
+            
+              Provider
+            
+            
               {PROVIDER_LABELS[provider] ?? provider}
             
           
           
             模型
-            
+            
               {model}
             
           
           
             迭代
-            
+            
               {currentIteration} / {maxIterations}
             
           
           {totalDuration > 0 && (
             
               总耗时
-              
+              
                 {formatDuration(totalDuration)}
               
             
           )}
         
+ + {/* v0.5.0: SubAgent 状态区 — delegate_task 委派的子任务生命周期 */} + {subAgents.length > 0 && ( + + + + + SubAgent 任务 + + + {subAgents.length} 个 + + + + {subAgents.map((sub) => ( + + + + + {sub.description || sub.taskId} + + {sub.depth > 1 && ( + + L{sub.depth} + + )} + + + {(sub.durationMs != null || sub.iterations != null) && ( + + {sub.durationMs != null ? formatDuration(sub.durationMs) : ''} + {sub.iterations != null ? ` · ${sub.iterations} 轮` : ''} + + )} + {sub.error && ( + + {sub.error} + + )} + + ))} + + + )}
); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5733b33..9414a14 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -5,9 +5,35 @@ */ 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 { 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 { useAgentStore } from '@renderer/stores/agent-store'; import { formatRelativeTime } from '@renderer/lib/formatters'; @@ -21,34 +47,125 @@ export function Sidebar(): React.JSX.Element { const searchQuery = useSessionStore((s) => s.searchQuery); const setSearchQuery = useSessionStore((s) => s.setSearchQuery); const agentStatus = useAgentStore((s) => s.agentStatus); + // v0.5.0: 内容搜索结果(FTS5),按会话聚合 { sessionId → snippet } + const [contentMatches, setContentMatches] = useState< + Map + >(new Map()); useEffect(() => { // M-27 修复: 添加 cancelled 标志防止组件卸载后 setState let cancelled = false; if (window.metona?.sessions?.list) { - window.metona.sessions.list().then((list) => { - if (cancelled) return; - 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 }))); - }).catch((err) => { - // M-11 修复: 记录错误而非静默吞掉,便于诊断 - console.error('[Sidebar] Failed to load sessions:', err); - }); + window.metona.sessions + .list() + .then((list) => { + if (cancelled) return; + 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, + })), + ); + }) + .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(); + 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 索引 + // v0.5.0: 标题匹配(Fuse 模糊)∪ 内容匹配(FTS5 精确短语),标题匹配优先排序 const filteredSessions = useMemo(() => { - let 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); } - return list.sort((a, b) => a.pinned === b.pinned ? b.updatedAt - a.updatedAt : a.pinned ? -1 : 1); - }, [sessions, searchQuery]); + const list = sessions.filter((s) => !s.archived); + if (searchQuery) { + const fuse = new Fuse(list, { keys: ['title'], threshold: 0.4, ignoreLocation: true }); + 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 () => { if (window.metona?.sessions?.create) { try { - const r = await window.metona.sessions.create() as { 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 }); + const r = (await window.metona.sessions.create()) as { + 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); loadSessionMessages(r.id); return; @@ -56,22 +173,50 @@ export function Sidebar(): React.JSX.Element { // M-9 修复: 显示错误提示而非静默吞错后创建本地假会话 // 之前的行为:catch 后继续创建本地 s_${Date.now()} 会话,但该会话在主进程不存在,下次刷新消失 console.error('[Sidebar] Failed to create session:', err); - import('@metona-team/metona-toast').then((mod) => { - mod.default.error('创建会话失败,请检查数据库状态'); - }).catch(() => {}); - return; // 不创建本地假会话 + import('@metona-team/metona-toast') + .then((mod) => { + mod.default.error('创建会话失败,请检查数据库状态'); + }) + .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); setCurrentSession(newSession.id); loadSessionMessages(newSession.id); }; return ( - + - @@ -79,21 +224,44 @@ export function Sidebar(): React.JSX.Element { size="small" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - placeholder="搜索会话..." + placeholder="搜索会话标题与内容..." slotProps={{ input: { startAdornment: , }, }} - 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 }, + }} /> {filteredSessions.length === 0 ? ( - {searchQuery ? '无匹配结果' : '暂无会话'} - ) : filteredSessions.map((session) => ( - { setCurrentSession(session.id); loadSessionMessages(session.id); }} /> - ))} + + {searchQuery ? '无匹配结果' : '暂无会话'} + + ) : ( + filteredSessions.map((session) => ( + { + setCurrentSession(session.id); + loadSessionMessages(session.id); + }} + /> + )) + )} @@ -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 handleDelete = (e: React.MouseEvent) => { @@ -119,9 +299,11 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S await window.metona.sessions.delete(session.id); } catch (err) { console.error('[Sidebar] Failed to delete session:', err); - import('@metona-team/metona-toast').then((mod) => { - mod.default.error('删除会话失败,请重试'); - }).catch(() => {}); + import('@metona-team/metona-toast') + .then((mod) => { + mod.default.error('删除会话失败,请重试'); + }) + .catch(() => {}); // 不调用 removeSession,保留会话在 UI 中(与数据库状态一致) setShowDeleteDialog(false); return; @@ -135,7 +317,13 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S currentSessionId: null, messages: [], 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, currentIteration: 0, }); @@ -145,24 +333,101 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S return ( <> - + - {session.pinned ? : isAgentActive ? : } + {session.pinned ? ( + + ) : isAgentActive ? ( + + + + ) : ( + + )} - {session.title}} - secondary={{formatRelativeTime(session.updatedAt)}{session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''}} + + {session.title} + + } + secondary={ + <> + + {formatRelativeTime(session.updatedAt)} + {session.messageCount > 0 ? ` · ${session.messageCount} 条` : ''} + + {/* v0.5.0: 内容匹配摘要(FTS5 snippet,高亮标记为 [匹配]) */} + {contentMatch && ( + + {contentMatch.matchCount > 1 ? `(${contentMatch.matchCount} 处) ` : ''} + {contentMatch.snippet} + + )} + + } /> - setShowDeleteDialog(false)} maxWidth="xs" fullWidth> + setShowDeleteDialog(false)} + maxWidth="xs" + fullWidth + > 删除会话 @@ -170,8 +435,16 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S - - + + @@ -180,41 +453,129 @@ function SessionItem({ session, isActive, isAgentActive, onClick }: { session: S function ToolManagerPanel() { const [expanded, setExpanded] = useState(false); - const [tools, setTools] = useState>([]); + const [tools, setTools] = useState< + Array<{ + name: string; + description: string; + category: string; + riskLevel: string; + requiresPermission: boolean; + enabled: boolean; + }> + >([]); useEffect(() => { // M-54 修复: 添加 cancelled 标志,防止组件卸载后 setState let cancelled = false; if (window.metona?.tools?.list) { - window.metona.tools.list().then((list) => { - if (!cancelled) setTools(list as MetonaToolInfo[]); - }).catch((err) => { - console.error('[Sidebar]', err); - if (!cancelled) { - import('@metona-team/metona-toast').then((mod) => mod.default.error('加载工具列表失败')).catch(() => {}); - } - }); + window.metona.tools + .list() + .then((list) => { + if (!cancelled) setTools(list as MetonaToolInfo[]); + }) + .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 riskColors: Record = { safe: 'success.main', low: 'info.main', medium: 'warning.main', high: 'error.main' }; - const riskLabels: Record = { safe: 'SAFE', low: 'LOW', medium: 'MEDIUM', high: 'HIGH' }; + const riskColors: Record = { + safe: 'success.main', + low: 'info.main', + medium: 'warning.main', + high: 'error.main', + }; + const riskLabels: Record = { + safe: 'SAFE', + low: 'LOW', + medium: 'MEDIUM', + high: 'HIGH', + }; return ( - setExpanded(!expanded)} dense sx={{ borderRadius: 1, px: 1.5, py: 0.75 }}> - {expanded ? : } - 工具管理} /> - 0 ? 'success.main' : 'text.disabled', fontSize: 10 }}>{readyCount} 就绪 + setExpanded(!expanded)} + dense + sx={{ borderRadius: 1, px: 1.5, py: 0.75 }} + > + + {expanded ? : } + + + + 工具管理 + + } + /> + 0 ? 'success.main' : 'text.disabled', fontSize: 10 }} + > + {readyCount} 就绪 + - + {tools.map((t) => ( - - {t.name} - {riskLabels[t.riskLevel] ?? t.riskLevel} + + + {t.name} + + + {riskLabels[t.riskLevel] ?? t.riskLevel} + ))} diff --git a/src/components/settings/LLMSettings.tsx b/src/components/settings/LLMSettings.tsx index 9732666..9b756e0 100644 --- a/src/components/settings/LLMSettings.tsx +++ b/src/components/settings/LLMSettings.tsx @@ -5,7 +5,7 @@ * 功能:主 Provider / 故障转移 Provider / 上下文窗口配置,批量保存。 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Button, TextField, @@ -50,6 +50,46 @@ export function LLMSettings() { const [showFbKey, setShowFbKey] = useState(false); const [loaded, setLoaded] = 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(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 配置字段 useEffect(() => { @@ -349,6 +389,55 @@ export function LLMSettings() { helperText={dsCtxError ? '最小值为 4096' : '用于上下文压缩判断,不传给 API'} /> )} + + {/* v0.5.0: DeepSeek 账户余额显示 */} + {provider === 'deepseek' && ( + + + 账户余额 + + {balanceLoading ? ( + + 查询中... + + ) : balance ? ( + + {balance.totalBalance} {balance.currency} + + (赠送 {balance.grantedBalance} + 充值 {balance.toppedUpBalance}) + + + ) : ( + + {balanceError ?? '未查询(需已保存 API Key)'} + + )} + + + )} {provider === 'agnes' && ( {}); } }; + + // v0.5.0: 审计日志导出(JSONL / CSV,复用主进程 AuditService 导出,含链式哈希字段) + const [auditExporting, setAuditExporting] = useState(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 = { sessions: '所有会话', memories: '所有记忆', @@ -229,6 +263,27 @@ export function LogsSettings() { + {/* v0.5.0: 审计日志导出(含链式哈希字段,可离线验证完整性) */} + + + +