Compare commits

..
Author SHA1 Message Date
yxxandClaude Code 3898530585 docs: 补齐 PR #11 评审要求的文档同步
- 技术栈说明实施状态:RAG Benchmark 标记为已完成、Agent Benchmark 暂缓
- 新增 Benchmark 开发说明,并登记到文档索引
- README 回归基线更新为后端 157 / 前端 29

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 22:35:45 +08:00
yxxandClaude Code fcc601fcf3 fix(backend): 落实 PR #11 第二轮评审意见
- Benchmark 容量淘汰只删终态 run,满容量且全活动时返回 BENCHMARK_CAPACITY_EXCEEDED
- 创建 run 前校验索引兼容性(BENCHMARK_INDEX_INCOMPATIBLE)
- 取消 run 补发 RunCancelled 终止事件;失败分支脱敏(BENCHMARK_RUN_FAILED)
- 失败样本计入汇总分母,报告输出 total/successful/failed/failure_rate
- load_dataset 按文件名隔离无关损坏文件,顶层非对象拒绝
- FTS score_threshold 先于计数/分页,total 与 items 一致
- Benchmark SSE 支持 Last-Event-ID 游标
- 移除 Agent Benchmark 501 占位接口
- 同步第二阶段接口契约与开发说明文档

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 22:20:11 +08:00
yxxandClaude Code c6cde2500b fix(backend): 落实 PR #9 评审意见
- 检索调优参数(rrf_k/rerank/rerank_candidates/score_threshold)透传到引擎实际执行
- Recall 去重,避免同一 Note 多 Block 重复导致 Recall 超 1
- RAG 运行改为后台异步执行:创建即 queued + 202,支持取消与 SSE 实时事件
- 数据集元数据校验,坏文件隔离跳过;citation_required 语义修正
- modes 空/重复校验;配置快照记录模型版本与索引元信息

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-02 23:20:34 +08:00
yxxandClaude Code 0006e91e67 chore(backend): 移除误提交的验收笔记
验收笔记此前被误纳入 benchmark 提交,现摘除跟踪,文件保留在本地磁盘。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-01 23:35:11 +08:00
yxx 866febec21 feat: benchmark功能开发完成 2026-09-01 23:30:11 +08:00
yxx 9b50b8f0ce feat: 完成benchmark后端功能 2026-09-01 23:19:28 +08:00
Kronecker eb940e6590 Merge pull request 'feat(extension): 接入 stdio MCP Bridge 与 Plugin Host' (#8) from feat/mcp-plugin-host into main
Reviewed-on: #8
2026-09-01 22:10:56 +08:00
admin e37ac7b0a4 fix(extension): 强化 MCP 参数与生产运行门禁 2026-09-01 21:25:05 +08:00
admin 574b113827 fix(extension): 完善 MCP 参数与运行安全边界 2026-09-01 16:06:23 +08:00
admin 1132a4cece fix(extension): 修复 MCP Host 资源与协议边界 2026-09-01 12:11:30 +08:00
admin aedb1c1267 docs(extension): 补充阶段C MCP开发说明 2026-09-01 11:32:47 +08:00
admin fc4b7b9495 feat(extension): 接入 stdio MCP Plugin Host 2026-09-01 11:32:05 +08:00
Kronecker 83782f1d0a Merge pull request 'feat(agent): 持久化 Agent Trace 并支持 SSE 断点恢复' (#7) from feat/agent-trace-persistence into main
Reviewed-on: #7
2026-09-01 10:36:43 +08:00
admin 0e8d4b7b9f fix(agent): 保留持久化Run完整内容 2026-09-01 10:05:58 +08:00
admin a5b709a46f docs: 重组文档目录并补充CI/CD细则 2026-09-01 09:55:40 +08:00
admin 49dbacb296 docs(agent): 记录Trace问题与修复方案 2026-09-01 00:49:30 +08:00
admin 3cb197aafe feat(agent): 持久化Trace并支持SSE恢复 2026-09-01 00:39:37 +08:00
admin 8da75d4420 feat(workspace): 接入真实Vault数据链路 2026-08-31 21:38:56 +08:00
admin 84077feb18 docs: 清理接口文档行尾格式 2026-08-31 20:27:23 +08:00
admin bb8091f2e6 docs(api): 规划第二阶段统一接口契约 2026-08-31 20:26:53 +08:00
admin 0b20bad0a8 docs: 更新第二阶段技术栈基线 2026-08-31 20:08:14 +08:00
admin 9559fda5f9 docx:添加第二阶段团队分工表
添加详细的第二阶段开发计划文档,包括:

- 阶段目标和总体分工安排
- 各成员具体职责和任务分配(范涵宇、杨星萱、吉海燕)
- 技术实现方案和架构设计
- 跨模块协作关系和接口定义
- 优先级划分(P0/P1/P2)和验收标准
- 项目演示Demo规划和完成定义
2026-08-31 19:53:44 +08:00
admin fb1da4d00a merge: 补充前后端代码注释与TODO约定 2026-08-30 23:03:33 +08:00
admin 2becaf0c65 docs: 建立代码注释与TODO维护约定 2026-08-30 23:00:11 +08:00
admin 49c856a69c chore(frontend): 补充状态与服务边界注释 2026-08-30 22:59:58 +08:00
admin 629a6bda9c chore(backend): 补充核心流程注释与待办 2026-08-30 22:59:45 +08:00
Kronecker 8e4f4e6d1a Merge pull request 'feat(frontend): 统一页面视觉并完善 GitHub 代码主题' (#6) from feat/frontend-visual-polish into main
Reviewed-on: #6
2026-08-30 22:39:15 +08:00
admin df044d7888 docs(frontend): 补充 Shiki 预览与选择器回归说明 2026-08-30 22:31:51 +08:00
admin 9bcb3bf3a6 fix(frontend): 修复 Shiki 主题选择器并添加真实预览 2026-08-30 22:31:21 +08:00
admin 206a8f5b0a docs(frontend): 记录 GitHub 代码主题配置 2026-08-30 20:42:00 +08:00
admin 3595021d80 feat(frontend): 添加 GitHub 代码块主题设置 2026-08-30 20:41:42 +08:00
admin 7803ae5110 chore(frontend): 将暂定品牌名统一为 NotesAgent 2026-08-30 20:25:51 +08:00
admin 668139706b fix(frontend): 提升 Markdown 表格与列表对比度 2026-08-30 20:23:22 +08:00
admin 7fa2e9c404 docs(frontend): 记录视觉优化与动效约束 2026-08-30 20:14:22 +08:00
admin ef03a8f745 feat(frontend): 统一页面视觉与轻量动效 2026-08-30 20:13:29 +08:00
admin 2c84a98474 docs: 添加第一阶段测试验证手册 2026-08-30 15:20:12 +08:00
Kronecker f75d486e00 Merge pull request 'docs: 同步当前工程实现与验证基线' (#5) from fix/frontend-review-findings into main
Reviewed-on: #5
2026-08-30 15:16:17 +08:00
Kronecker 82cb396685 Merge branch 'main' into fix/frontend-review-findings 2026-08-30 15:16:07 +08:00
admin 3872ef3304 docs: 同步当前工程实现与验证基线 2026-08-30 15:15:00 +08:00
Kronecker b70aac1934 Merge pull request 'Fix/frontend review findings' (#4) from fix/frontend-review-findings into main
Reviewed-on: #4
2026-08-30 15:06:05 +08:00
admin bec308b5d1 feat(frontend): 完成智能体页面汉化 2026-08-30 11:04:13 +08:00
admin 0bb78e004b docs(provider): 记录本地密钥加密边界 2026-08-30 10:57:14 +08:00
admin 352975d753 feat(frontend): 支持直接配置模型API密钥 2026-08-30 10:57:05 +08:00
admin 0836807aa2 feat(provider): 添加API密钥加密存储 2026-08-30 10:56:57 +08:00
admin 0842413d29 docs(provider): 补充DeepSeek开发凭据配置 2026-08-30 10:48:35 +08:00
admin 31e29f25bb fix(frontend): 提示模型凭据缺失与鉴权失败 2026-08-30 10:48:25 +08:00
admin dc5ee76bed fix(provider): 修复DeepSeek开发凭据解析 2026-08-30 10:48:12 +08:00
admin 340bfbbd07 docs(provider): 补充模型发现与凭据边界说明 2026-08-30 10:44:13 +08:00
admin 0f6938c6a2 feat(frontend): 支持提供商预设与自动获取模型 2026-08-30 10:44:04 +08:00
admin f7d864bc4d feat(provider): 添加厂商预设与模型发现接口 2026-08-30 10:43:55 +08:00
admin 11cb384115 docs(frontend): 记录文件切换与代码块调整 2026-08-30 10:29:23 +08:00
admin f993ee9657 fix(frontend): 默认展开代码块编辑器 2026-08-30 10:29:15 +08:00
admin 1564df434d fix(frontend): 修复文件树点击切换竞态 2026-08-30 10:29:08 +08:00
admin 99a0595dbc docs(frontend): 记录编辑器交互修复与回归验证 2026-08-30 10:25:10 +08:00
admin 5a4084de1e fix(frontend): 修复Markdown工具栏选区交互 2026-08-30 10:25:00 +08:00
admin 693c86c24d fix(frontend): 修复可视化编辑器文件切换时序 2026-08-30 10:24:47 +08:00
admin 9bbe4d3c86 docs(frontend): 补充Markdown插入工具说明 2026-08-30 10:15:35 +08:00
admin d7a640a147 feat(frontend): 扩展Markdown插入工具并汉化块菜单 2026-08-30 10:15:27 +08:00
admin 9b55ccb02a docs(frontend): 记录Markdown工具栏增强 2026-08-30 10:07:40 +08:00
admin 8ee3adae6a feat(frontend): 完善Markdown选区格式工具栏 2026-08-30 10:07:31 +08:00
admin 9d223be5ca docs(frontend): 补充写作体验优化说明 2026-08-30 09:57:31 +08:00
admin c5193626a5 feat(frontend): 引入可视化Markdown写作与Shiki高亮 2026-08-30 09:57:22 +08:00
admin c33610a295 feat(frontend): 统一界面图标体系 2026-08-30 09:57:04 +08:00
Kronecker 32f6bd6468 Merge pull request 'Fix/frontend review findings' (#3) from fix/frontend-review-findings into main
Reviewed-on: #3
2026-08-30 00:16:34 +08:00
admin bba3041867 docs(frontend): 补充合并终审修复记录 2026-08-30 00:14:21 +08:00
admin 0dbd32a757 fix(frontend): 防止切换文件丢稿并修复主题恢复 2026-08-30 00:14:10 +08:00
admin a7772430d5 docs(frontend): 记录全页面审阅问题与修复 2026-08-29 23:59:22 +08:00
admin cd405da21c fix(frontend): 接通设置状态与搜索降级 2026-08-29 23:59:22 +08:00
admin d6f6b7e6e2 fix(frontend): 修复任务关联字段持久化 2026-08-29 23:59:11 +08:00
admin 4f9ebc55ec fix(frontend): 清理Agent跨Run权限状态 2026-08-29 23:59:11 +08:00
admin afd49ba00f fix(frontend): 修复文件树与编辑器状态一致性 2026-08-29 23:59:11 +08:00
admin 5aa0026bb9 fix(frontend): 完善Markdown与Chat事件渲染 2026-08-29 23:59:01 +08:00
admin e5f803c364 feat(frontend): 接通完整页面路由与桌面壳层 2026-08-29 22:49:53 +08:00
admin 5192a8b4e8 feat(frontend): 完成主题与设置页面 2026-08-29 22:49:44 +08:00
admin 3239bab696 feat(frontend): 完成Skill与Plugin管理页面 2026-08-29 22:49:44 +08:00
admin 3c9142533e feat(frontend): 完成任务管理页面 2026-08-29 22:49:33 +08:00
admin 6b362ccb6f feat(frontend): 完成Agent运行与Trace页面 2026-08-29 22:49:32 +08:00
admin 48dc6ce75b feat(frontend): 完成知识对话页面 2026-08-29 22:49:32 +08:00
admin 02e19be8bb feat(frontend): 完成知识库搜索页面 2026-08-29 22:48:58 +08:00
admin 610fc77b0f fix(frontend): 修复合并审阅发现的构建与契约问题
恢复 Vue TypeScript 生产构建,补齐可运行页面壳子,并修复文件树与 SSE 状态问题。

按 FastAPI Wire Contract 统一 Service DTO 映射,同时补充前端开发说明和问题修复复盘。
2026-08-29 12:10:33 +08:00
Kronecker c6c28e4ebe Merge pull request 'feat(frontend): 搭建桌面端基础界面与 Workspace' (#2) from feat/frontend-workspace into main
Reviewed-on: #2
2026-08-29 11:47:07 +08:00
saint f9efc4f4a2 feat(frontend): 搭建桌面端基础界面与 Workspace
- App Shell 壳层:主导航、次导航、Title Bar、状态栏
- Vault 入口页:最近 Vault、打开/创建 Vault、AI Core 状态
- Workspace 与文件树:浏览、打开、创建笔记/文件夹
- Design Token:浅色/深色主题 CSS Variables
- Contracts / Service / Store / Router 基础架构
- 统一 ApiClient 与 SseClient,对接后端 API 契约
2026-08-29 10:36:16 +08:00
admin 694c1b27c1 docs(backend): 记录全面审阅问题与修复方案
按原因、后果、解决思路、落地方案和回归测试复盘 10 类后端问题。

同步更新接口实现状态、Plugin 权限接口、真实 Streaming、Citation 偏移约定、第一阶段 Tool 列表和测试数量。
2026-08-28 09:57:02 +08:00
admin 36bc1022f1 fix(backend): 修复全面审阅发现的核心问题
修复索引首次失败回滚、Vault 扫描边界、Markdown 代码围栏和 UTF-16 Citation 偏移。

收紧 Plugin 权限与 JSON Schema 校验,补齐 Note Move、Task、Attachment 和 Transcript Tool。

接入 OpenAI SSE 与 Ollama JSONL 真流式输出,修正 Provider PATCH 语义并限制运行时内存保留。

新增对应回归测试,后端测试增至 62 项。
2026-08-28 09:56:07 +08:00
admin 167ae24796 docs(agent): 更新 Agent 与 Extension Core 开发说明 2026-08-27 23:53:31 +08:00
admin cc17070e9e feat(agent): 接入 Skill Plugin 与知识库工具 2026-08-27 23:53:04 +08:00
admin e045b9ce8b feat(extension): 实现 Skill 与 Plugin Runtime 基础 2026-08-27 23:52:29 +08:00
admin 4371052a89 docs: 添加 Knowledge Core 与 Retrieval Core PR 审阅问题与修复复盘文档
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。

详细记录了 10 个关键问题的分析与解决方案:
- P-01:folder 路径逃逸问题及安全校验
- P-02 与 P-03:伪 upsert 和索引部分提交问题
- P-04:失效向量残留清理机制
- P-05:PATCH tags 语义错误修复
- P-06:候选池、Metadata Filter 与分页优化
- P-07:rebuild 请求语义与失败恢复
- P-08:index_meta 事务一致性保证
- P-09:POST 同路径静默覆盖防护
- P-10:删除操作部分提交问题

包含完整的测试策略和可复用的工程经验总结。
2026-08-27 23:38:54 +08:00
admin 32f249c43a fix(retrieval): 保证删除一致性并完善 FTS 分页 2026-08-27 23:28:37 +08:00
admin af8ccb0f18 fix(knowledge): 保证笔记创建与索引更新一致性 2026-08-27 23:24:38 +08:00
admin 9348225e16 Merge branch 'feat/knowledge-retrieval-core' 2026-08-27 23:14:13 +08:00
yxxandClaude 6cf531f2a8 fix(retrieval): 修复原子性、过滤漏召回、tags 语义与 rebuild 回滚
- 元数据 + 向量单事务提交,避免 PATCH 半提交(审阅 #2)
- vectorstore upsert 改 delete-then-insert 幂等,支持共享 conn
- FTS 取全量 + 过滤 oversample,修复 metadata 过滤漏召回(审阅 #4)
- PATCH tags 区分 None/[]/非空:保留/清空/替换(审阅 #5)
- rebuild 拒绝增量 scope/note_ids,扫描先行 + 失败回滚旧索引(审阅 #6)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 22:48:55 +08:00
yxxandClaude 87717450fd fix(retrieval): 修复路径逃逸、部分提交、失效向量与分页问题
- 路径逃逸:清洗 folder(拒绝 ..、绝对路径/盘符),_abs_path 增加 Vault 边界校验
- 部分提交:create/update 索引失败时回滚文件
- 失效向量残留:replace_note_metadata 返回旧 id,index_note 清理旧向量
- 分页不完整:fts_count 返回真实命中总数,候选池覆盖 offset+limit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 21:38:43 +08:00
yxxandClaude 39d1aa0fe9 docs(retrieval): 增加 Knowledge/Retrieval Core 开发说明
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 19:26:58 +08:00
yxxandClaude dbcb94b413 test(retrieval): 增加检索测试数据与单元端到端测试
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 19:24:20 +08:00
yxxandClaude 6b16ad1a08 feat(retrieval): 接入 Note/Search/Index 服务与路由
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 19:22:56 +08:00
yxxandClaude 02bb38bb1d feat(retrieval): 实现 Embedding/Vector/RRF/Reranker 混合检索引擎
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 19:21:01 +08:00
yxxandClaude d4b472b009 feat(knowledge): 实现 SQLite/FTS5 数据层与 Markdown Block 解析
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 19:20:12 +08:00
yxxandClaude 2eed940bb0 chore(backend): 引入 sqlite-vec 依赖并配置数据目录
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 19:19:44 +08:00
admin 41286fca6c 添加Git使用细则文档链接
- 在README.md中添加了Git使用细则文档的链接
- 新增docs/Git使用细则-团队开发版.md文档,包含:
  - 仓库与远程管理规范
  - 分支约定和命名规则
  - 模块分工与改动边界
  - 开发流程和提交原则
  - Pull Request和Review要求
  - 冲突处理和版本发布规范
```
2026-08-27 14:30:05 +08:00
admin f18dca41c9 添加前端页面需求说明文档
- 在 README.md 中添加了前端页面需求说明文档的链接
- 新增 docs/前端页面需求说明-开发版.md 文件,包含:
  - 第一阶段目标和验收要求
  - 技术与交互基线规范
  - 页面信息架构和路由约定
  - App Shell、Workspace、Search、AI Chat、Agent Trace 等各模块详细需求
  - 公共状态管理、Service 层要求、公共组件规范
  - Design Token、可访问性、错误降级处理方案
  - 开发优先级和协作边界说明
2026-08-27 14:20:19 +08:00
admin 1741f7b1aa 添加provider工厂和Ollama支持
- 实现ProviderFactory用于构建不同类型的provider适配器
- 添加EnvironmentCredentialResolver用于解析环境变量中的凭证
- 实现OllamaProvider支持本地模型调用
- 实现OpenAICompatibleProvider支持OpenAI兼容接口
- 在AgentRuntime中添加对ProviderError的处理
- 更新Message结构体添加tool_calls字段
- 实现provider配置的增删改查API端点
- 添加provider注册表的replace方法
- 添加HTTP基础类和工具参数解码功能
- 更新依赖添加httpx库
- 添加相关单元测试验证provider适配器功能
```
2026-08-27 14:16:57 +08:00
admin b71984d951 实现 AI Core 与 Agent Core 基础功能
- 更新 README 描述从后端壳子到 AI Core/Agent Core
- 添加 ToolCall 和 ToolResult 数据结构定义
- 扩展 AgentRun 模型增加输出、错误码、工具调用结果等字段
- 添加 mock 提供商类型支持
- 实现聊天、代理运行、工具调用和提供商管理的核心路由逻辑
- 集成容器化依赖注入和错误处理机制
- 更新 API 接口契约和文档说明
2026-08-27 13:55:00 +08:00
admin ce155b27f4 feat(api): 建立前后端接口契约壳子 2026-08-27 11:37:17 +08:00
admin 08e4aeea0c 初始化项目基础结构
添加了完整的前后端开发环境配置,包括:
- 创建 .gitignore 文件忽略本地生成目录和环境文件
- 添加详细的 README.md 开发指南文档
- 配置 backend 目录结构和 FastAPI 应用基础框架
- 实现应用配置管理、健康检查和状态接口
- 设置 uv 依赖管理和虚拟环境配置
- 完成 CORS 中间件配置支持前端开发联调
2026-08-27 11:19:36 +08:00
54 changed files with 13722 additions and 113 deletions
+2 -1
View File
@@ -118,7 +118,7 @@ cd frontend
pnpm test pnpm test
``` ```
当前回归基线为后端 81 项测试、前端 27 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。 当前回归基线为后端 157 项测试、前端 29 项测试,且生产构建通过。测试数量会随功能增长,以本地实际输出和 CI 为准。
构建产物位于 `frontend/dist`,该目录不提交到 Git。 构建产物位于 `frontend/dist`,该目录不提交到 Git。
@@ -132,6 +132,7 @@ pnpm test
| [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 | | [后端接口契约](docs/contracts/后端接口契约-开发版.md) | HTTP/SSE 接口、错误和当前实现状态 |
| [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 | | [第二阶段接口契约](docs/contracts/第二阶段接口契约-开发版.md) | 第二阶段公共 DTO、计划接口、SSE、错误码与联调顺序 |
| [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core | | [AI Core 与 Agent Core](docs/development/AI-Core与Agent-Core开发说明.md) | Provider、Agent、Tool、Permission 与 Extension Core |
| [MCP Bridge 与 Plugin Host](docs/development/MCP-Bridge与Plugin-Host开发说明.md) | stdio MCP、隔离进程、Tool 映射、状态与错误边界 |
| [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 | | [Git 使用细则](docs/guides/Git使用细则-团队开发版.md) | 分支、提交、PR、Review 与合并流程 |
| [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 | | [CI/CD 细则](docs/guides/CI-CD细则-团队开发版.md) | Gitea 流水线、质量门禁、产物、发布与回滚规则 |
| [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 | | [Agent Trace 复盘](docs/retrospectives/Agent-Core第二阶段问题与修复复盘.md) | Agent 持久化、SSE 恢复、事件契约与脱敏问题复盘 |
+1 -1
View File
@@ -23,7 +23,7 @@ uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
uv run pytest uv run pytest
``` ```
当前基线为 81 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。 当前基线为 92 项测试通过。Provider API Key 可通过前端设置页写入,也可用 `OPENAI_API_KEY``DEEPSEEK_API_KEY``AINOTE_CREDENTIAL_<ID>` 注入;不要把真实密钥写入仓库。
团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。 团队接口清单见 `../docs/contracts/后端接口契约-开发版.md`,机器可读契约以运行时的 `/openapi.json` 为准。
+7 -1
View File
@@ -491,7 +491,13 @@ class AgentRuntime:
async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult: async def _invoke_tool(self, record: RunRecord, call: ToolCall) -> ToolResult:
try: try:
return await asyncio.wait_for( return await asyncio.wait_for(
self.tools.execute(call, ToolExecutionContext(run_id=record.run.run_id)), self.tools.execute(
call,
ToolExecutionContext(
run_id=record.run.run_id,
tool_call_id=call.tool_call_id,
),
),
timeout=record.request.tool_timeout_seconds, timeout=record.request.tool_timeout_seconds,
) )
except TimeoutError: except TimeoutError:
+44 -18
View File
@@ -1,6 +1,7 @@
"""Agent 工具注册与执行边界。""" """Agent 工具注册与执行边界。"""
import inspect import inspect
import threading
from dataclasses import dataclass from dataclasses import dataclass
from time import perf_counter from time import perf_counter
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
@@ -17,6 +18,7 @@ ToolExecutor = Callable[[BaseModel, "ToolExecutionContext"], Any | Awaitable[Any
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ToolExecutionContext: class ToolExecutionContext:
run_id: str run_id: str
tool_call_id: str | None = None
@dataclass(slots=True) @dataclass(slots=True)
@@ -30,11 +32,21 @@ class ToolNotFoundError(LookupError):
pass pass
class ToolExecutionError(RuntimeError):
"""Executor 可预期失败,保留领域错误码而不是折叠成通用异常。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
class ToolRegistry: class ToolRegistry:
"""统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。""" """统一校验工具入参并隔离执行异常,避免单个工具击穿 Agent 主循环。"""
def __init__(self) -> None: def __init__(self) -> None:
self._tools: dict[str, RegisteredTool] = {} self._tools: dict[str, RegisteredTool] = {}
self._lock = threading.RLock()
def register( def register(
self, self,
@@ -42,33 +54,38 @@ class ToolRegistry:
arguments_model: type[BaseModel], arguments_model: type[BaseModel],
executor: ToolExecutor, executor: ToolExecutor,
) -> None: ) -> None:
if definition.name in self._tools: with self._lock:
raise ValueError(f"Tool already registered: {definition.name}") if definition.name in self._tools:
self._tools[definition.name] = RegisteredTool( raise ValueError(f"Tool already registered: {definition.name}")
definition=definition, self._tools[definition.name] = RegisteredTool(
arguments_model=arguments_model, definition=definition,
executor=executor, arguments_model=arguments_model,
) executor=executor,
)
def unregister(self, name: str) -> None: def unregister(self, name: str) -> None:
self._tools.pop(name, None) with self._lock:
self._tools.pop(name, None)
def contains(self, name: str) -> bool: def contains(self, name: str) -> bool:
return name in self._tools with self._lock:
return name in self._tools
def get(self, name: str) -> RegisteredTool: def get(self, name: str) -> RegisteredTool:
try: with self._lock:
return self._tools[name] try:
except KeyError as exc: return self._tools[name]
raise ToolNotFoundError(name) from exc except KeyError as exc:
raise ToolNotFoundError(name) from exc
def definitions(self, allowed: list[str] | None = None) -> list[ToolDefinition]: def definitions(self, allowed: list[str] | None = None) -> list[ToolDefinition]:
names = set(allowed) if allowed is not None else None names = set(allowed) if allowed is not None else None
return [ with self._lock:
item.definition.model_copy(deep=True) return [
for name, item in self._tools.items() item.definition.model_copy(deep=True)
if names is None or name in names for name, item in self._tools.items()
] if names is None or name in names
]
async def execute(self, call: ToolCall, context: ToolExecutionContext) -> ToolResult: async def execute(self, call: ToolCall, context: ToolExecutionContext) -> ToolResult:
started = perf_counter() started = perf_counter()
@@ -108,6 +125,15 @@ class ToolRegistry:
output=output, output=output,
duration_ms=round((perf_counter() - started) * 1000), duration_ms=round((perf_counter() - started) * 1000),
) )
except ToolExecutionError as exc:
return ToolResult(
tool_call_id=call.tool_call_id,
name=call.name,
success=False,
error_code=exc.code,
error_message=exc.message,
duration_ms=round((perf_counter() - started) * 1000),
)
except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。 except Exception as exc: # 工具失败转换成结构化结果,由模型决定是否降级或重试。
return ToolResult( return ToolResult(
tool_call_id=call.tool_call_id, tool_call_id=call.tool_call_id,
+8
View File
@@ -0,0 +1,8 @@
"""Benchmark 服务:RAG / Agent 数据集注册、指标计算与运行管理。
模块划分:
- metrics.py 纯函数指标(Hit@K / Recall@K / MRR / CitationHit / 分位数)
- datasets.py 受控目录的 Dataset 注册与校验
- rag.py RAG Benchmark Runner(调用 retrieval.engine.search
- service.py 运行注册表、配置快照与报告组装
"""
+198
View File
@@ -0,0 +1,198 @@
"""Benchmark Dataset 注册:从受控目录加载 JSON 数据集并校验。
Dataset 只能来自配置目录(settings.benchmark_datasets_path),API 不接受调用方提交
任意文件路径。目录不存在或为空时按「无数据集」处理,不报错。
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from pydantic import BaseModel, Field, ValidationError
from app.config import get_settings
from app.contracts import (
BenchmarkDatasetInfo,
BenchmarkKind,
RAGDatasetCase,
)
from app.errors import ApiError
@dataclass
class RAGDataset:
"""内存中的 RAG 数据集:元信息 + 已校验的 Case 列表 + 内容哈希。"""
dataset_id: str
kind: BenchmarkKind
version: str
description: str
cases: list[RAGDatasetCase] = field(default_factory=list)
content_hash: str = ""
class _DatasetMeta(BaseModel):
"""Dataset 元数据的最小校验模型。
list_datasets 用它逐文件校验元信息字段结构,把「合法 JSON 但字段类型错误」
(如 cases: 42)这类损坏文件隔离掉,而不是让 len() 抛 TypeError 拖垮整个列表。
"""
dataset_id: str = Field(min_length=1)
kind: str = ""
version: str = ""
description: str = ""
cases: list = Field(default_factory=list)
def _datasets_dir() -> Path:
return get_settings().benchmark_datasets_path
def _dataset_files() -> list[Path]:
directory = _datasets_dir()
if not directory.is_dir():
return []
return sorted(directory.glob("*.json"))
def _content_hash(raw: bytes) -> str:
return "sha256:" + hashlib.sha256(raw).hexdigest()
def _read_json(path: Path) -> tuple[dict, bytes]:
"""读取并解析 JSON 文件,返回 (dict, 原始字节);非法 JSON 抛 BENCHMARK_DATASET_INVALID。"""
try:
raw_bytes = path.read_bytes()
return json.loads(raw_bytes.decode("utf-8")), raw_bytes
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset file is not valid JSON: {path.name}",
{"path": str(path)},
) from exc
def _dataset_from_raw(raw: dict, raw_bytes: bytes, kind: BenchmarkKind) -> RAGDataset:
"""把单个数据集 JSON 解析为 RAGDataset,非法结构抛 BENCHMARK_DATASET_INVALID。"""
dataset_id = raw.get("dataset_id")
if not isinstance(dataset_id, str) or not dataset_id:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
"Dataset must declare a non-empty string 'dataset_id'.",
{},
)
file_kind = raw.get("kind", kind.value)
if file_kind != kind.value:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset kind mismatch: expected '{kind.value}', got '{file_kind}'.",
{"dataset_id": dataset_id},
)
raw_cases = raw.get("cases")
if not isinstance(raw_cases, list) or not raw_cases:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
"Dataset 'cases' must be a non-empty list.",
{"dataset_id": dataset_id},
)
cases: list[RAGDatasetCase] = []
for index, case in enumerate(raw_cases):
try:
parsed = RAGDatasetCase.model_validate(case)
except ValidationError as exc:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset case #{index} is invalid.",
{"dataset_id": dataset_id, "case_index": index, "errors": exc.errors()},
) from exc
# 每个 Case 至少要声明一个期望 id,否则无法计算命中/召回
if not parsed.expected_note_ids and not parsed.expected_block_ids:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset case '{parsed.case_id}' must declare expected_note_ids or expected_block_ids.",
{"dataset_id": dataset_id, "case_id": parsed.case_id},
)
# citation_required=true 时必须声明 expected_block_ids,否则无法计算 Citation Hit Rate
if parsed.citation_required and not parsed.expected_block_ids:
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
f"Dataset case '{parsed.case_id}' requires expected_block_ids when citation_required is true.",
{"dataset_id": dataset_id, "case_id": parsed.case_id},
)
cases.append(parsed)
return RAGDataset(
dataset_id=dataset_id,
kind=kind,
version=str(raw.get("version", "")),
description=str(raw.get("description", "")),
cases=cases,
content_hash=_content_hash(raw_bytes),
)
def list_datasets(kind: BenchmarkKind) -> list[BenchmarkDatasetInfo]:
"""枚举受控目录下指定 kind 的数据集元信息(不含 Case 内容)。
逐文件用 _DatasetMeta 校验元信息字段结构,单个损坏文件隔离跳过而非整体失败,
保证列表接口健壮;损坏细节由 load_dataset 抛出。
"""
infos: list[BenchmarkDatasetInfo] = []
for path in _dataset_files():
try:
raw, raw_bytes = _read_json(path)
meta = _DatasetMeta.model_validate(raw)
except (ApiError, ValidationError):
continue
if meta.kind not in ("", kind.value):
continue
infos.append(
BenchmarkDatasetInfo(
dataset_id=meta.dataset_id,
kind=kind,
version=meta.version,
description=meta.description,
case_count=len(meta.cases),
content_hash=_content_hash(raw_bytes),
)
)
return infos
def load_dataset(dataset_id: str, kind: BenchmarkKind) -> RAGDataset:
"""按文件名加载并校验数据集;找不到抛 BENCHMARK_DATASET_NOT_FOUND。
只读取与请求 dataset_id 同名的文件({dataset_id}.json),无关文件的损坏(JSON 语法
错误、UTF-8 解码错误、顶层非对象)不会阻断目标数据集加载;只有目标文件本身损坏
才抛 BENCHMARK_DATASET_INVALID。按现有文件 stem 精确匹配,不拼接调用方传入的路径。
"""
for path in _dataset_files():
if path.stem != dataset_id:
continue
raw, raw_bytes = _read_json(path)
if not isinstance(raw, dict):
raise ApiError(
422,
"BENCHMARK_DATASET_INVALID",
"Dataset top-level must be a JSON object.",
{"dataset_id": dataset_id, "path": path.name},
)
return _dataset_from_raw(raw, raw_bytes, kind)
raise ApiError(
404,
"BENCHMARK_DATASET_NOT_FOUND",
f"Benchmark dataset does not exist: {dataset_id}",
{"dataset_id": dataset_id, "kind": kind.value},
)
+58
View File
@@ -0,0 +1,58 @@
"""Benchmark 指标纯函数。
所有指标只依赖「按相关性降序的 retrieved id 列表」和「期望 id 集合」,不接触任何
外部状态,便于单元测试与未来 Agent Benchmark 复用。retrieved 顺序越靠前越相关。
"""
from __future__ import annotations
def hit_at_k(retrieved: list[str], expected: set[str], k: int) -> bool:
"""前 k 个结果里是否命中任意期望 id(用于 Hit@1 / Hit@5)。"""
return any(item in expected for item in retrieved[:k])
def recall_at_k(retrieved: list[str], expected: set[str], k: int) -> float:
"""前 k 个结果召回的期望 id 占比;期望为空时视为 0。
结果先去重:检索结果是 Block 级,同一 Note 可能经多个 Block 重复出现,
直接逐项计数会把同一 Note 算多次、导致 Recall 超过 1。
"""
if not expected:
return 0.0
return len(set(retrieved[:k]) & expected) / len(expected)
def reciprocal_rank(retrieved: list[str], expected: set[str]) -> float:
"""首个命中的倒数排名;未命中返回 0。rank 从 1 开始。"""
for rank, item in enumerate(retrieved, start=1):
if item in expected:
return 1.0 / rank
return 0.0
def citation_hit(retrieved_block_ids: list[str], expected: set[str]) -> bool:
"""首条结果的 block_id 是否为期望引用块(Citation Hit Rate 的逐 Case 判据)。"""
if not retrieved_block_ids or not expected:
return False
return retrieved_block_ids[0] in expected
def mean(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
def percentile(values: list[float], p: float) -> float:
"""线性插值分位数(p ∈ [0, 100]),用于 P50 / P95 延迟。空列表返回 0。"""
if not values:
return 0.0
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * (p / 100.0)
lo = int(rank)
hi = lo + 1
if hi >= len(ordered):
return ordered[-1]
frac = rank - lo
return ordered[lo] + (ordered[hi] - ordered[lo]) * frac
+143
View File
@@ -0,0 +1,143 @@
"""RAG Benchmark Runner:调用检索引擎对数据集逐 Case 求值并聚合指标。
只读操作,直接复用 app.retrieval.engine 的 search(),不旁路检索链路。指标按
(mode, case, repeat) 逐样本计算,再按 mode 聚合;失败样本按零分计入质量指标分母,
避免把执行失败误判为检索质量(同时保留 total/successful/failed/failure_rate)。
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from app.benchmarks import metrics as m
from app.benchmarks.datasets import RAGDataset
from app.contracts import (
RAGCaseResult,
RAGDatasetCase,
RAGMetrics,
RAGRunRequest,
SearchMode,
SearchRequest,
)
from app.retrieval.engine import engine
logger = logging.getLogger(__name__)
class BenchmarkCancelled(Exception):
"""运行在 Case 之间被取消时抛出,用于中断后台执行并标记 cancelled。"""
async def run_rag(
dataset: RAGDataset,
request: RAGRunRequest,
on_case: Callable[[RAGCaseResult, int, int], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> tuple[dict[str, RAGMetrics], list[RAGCaseResult]]:
"""执行 RAG Benchmark,返回 (按 mode 聚合的指标, 全部逐样本结果)。
on_case 在每个样本求值完成后回调 (result, done, total),供上层更新进度与事件。
should_cancel 在每个样本开始前被检查;返回 True 时抛出 BenchmarkCancelled 中断运行。
"""
total = len(request.modes) * len(dataset.cases) * request.repeat
done = 0
results: list[RAGCaseResult] = []
for mode in request.modes:
for case in dataset.cases:
for repeat in range(request.repeat):
if should_cancel is not None and should_cancel():
raise BenchmarkCancelled()
result = await _evaluate_one(case, mode, request, repeat)
results.append(result)
done += 1
if on_case is not None:
on_case(result, done, total)
metrics_by_mode = {mode.value: _aggregate(results, mode) for mode in request.modes}
return metrics_by_mode, results
async def _evaluate_one(
case: RAGDatasetCase, mode: SearchMode, request: RAGRunRequest, repeat: int
) -> RAGCaseResult:
search_request = SearchRequest(
query=case.query,
mode=mode,
limit=request.retrieval.top_k,
include_snippet=False,
rrf_k=request.retrieval.rrf_k,
rerank=request.retrieval.rerank,
rerank_candidates=request.retrieval.rerank_candidates,
score_threshold=request.retrieval.score_threshold,
)
start = time.perf_counter()
try:
response = await engine.search(search_request)
latency_ms = (time.perf_counter() - start) * 1000.0
except Exception as exc: # 单个样本失败不中断整个 Benchmark
# 详细异常只进日志,公开响应只带项目错误码与安全消息,避免泄露路径/SQL 等敏感信息
logger.warning(
"RAG case evaluation failed: case=%s mode=%s", case.case_id, mode.value,
exc_info=exc,
)
return RAGCaseResult(
case_id=case.case_id,
mode=mode,
repeat=repeat,
latency_ms=(time.perf_counter() - start) * 1000.0,
citation_applicable=case.citation_required,
error="RAG case evaluation failed.",
error_code="BENCHMARK_CASE_EVALUATION_FAILED",
)
retrieved_note_ids = [item.note_id for item in response.items]
retrieved_block_ids = [item.block_id for item in response.items]
expected_notes = set(case.expected_note_ids)
expected_blocks = set(case.expected_block_ids)
k = request.retrieval.top_k
return RAGCaseResult(
case_id=case.case_id,
mode=mode,
repeat=repeat,
latency_ms=latency_ms,
retrieved_note_ids=retrieved_note_ids,
retrieved_block_ids=retrieved_block_ids,
hit_at_1=m.hit_at_k(retrieved_note_ids, expected_notes, 1),
hit_at_5=m.hit_at_k(retrieved_note_ids, expected_notes, 5),
recall=m.recall_at_k(retrieved_note_ids, expected_notes, k),
reciprocal_rank=m.reciprocal_rank(retrieved_note_ids, expected_notes),
citation_hit=m.citation_hit(retrieved_block_ids, expected_blocks),
citation_applicable=case.citation_required,
)
def _aggregate(cases: list[RAGCaseResult], mode: SearchMode) -> RAGMetrics:
samples = [c for c in cases if c.mode == mode]
total = len(samples)
failed = sum(1 for c in samples if c.error is not None)
successful = total - failed
if total == 0:
return RAGMetrics()
# 延迟只统计成功样本;失败样本按零分计入质量指标分母,避免汇总虚高
latencies = [c.latency_ms for c in samples if c.error is None]
citation_samples = [c for c in samples if c.citation_applicable]
return RAGMetrics(
hit_at_1=m.mean([1.0 if (c.error is None and c.hit_at_1) else 0.0 for c in samples]),
hit_at_5=m.mean([1.0 if (c.error is None and c.hit_at_5) else 0.0 for c in samples]),
recall_at_k=m.mean([c.recall if c.error is None else 0.0 for c in samples]),
mrr=m.mean([c.reciprocal_rank if c.error is None else 0.0 for c in samples]),
citation_hit_rate=m.mean(
[1.0 if (c.error is None and c.citation_hit) else 0.0 for c in citation_samples]
),
p50_latency_ms=m.percentile(latencies, 50.0),
p95_latency_ms=m.percentile(latencies, 95.0),
total_cases=total,
successful_cases=successful,
failed_cases=failed,
failure_rate=failed / total,
)
+348
View File
@@ -0,0 +1,348 @@
"""Benchmark 服务:运行注册表、配置快照与报告组装。
RAG Benchmark 采用「创建即返回 queued、后台 Task 异步执行」的模式(与 index_service
的 rebuild 一致):POST 创建后立即返回 202 queued 的 BenchmarkRun,由受管 asyncio.Task
在后台逐 Case 求值,进度与事件实时写入内存注册表,供 SSE 订阅。运行记录、事件与报告
暂存内存(_runs/_events/_reports),不持久化到 SQLite;后续接入异步任务队列时再落库。
"""
from __future__ import annotations
import asyncio
import logging
import sys
from datetime import datetime, timezone
from uuid import uuid4
from app import repository
from app.benchmarks import datasets
from app.benchmarks.datasets import RAGDataset
from app.benchmarks.rag import BenchmarkCancelled, run_rag
from app.config import get_settings
from app.contracts import (
BenchmarkEvent,
BenchmarkEventType,
BenchmarkKind,
BenchmarkReport,
BenchmarkRun,
BenchmarkStatus,
RAGCaseResult,
RAGMetrics,
RAGRunRequest,
SearchMode,
)
from app.errors import ApiError
from app.retrieval.engine import engine
logger = logging.getLogger(__name__)
_runs: dict[str, BenchmarkRun] = {}
_events: dict[str, list[BenchmarkEvent]] = {}
_reports: dict[str, BenchmarkReport] = {}
_tasks: dict[str, asyncio.Task] = {}
_subscribers: dict[str, list[asyncio.Queue[BenchmarkEvent]]] = {}
_cancel_flags: dict[str, asyncio.Event] = {}
MAX_RUNS = 100
def _now() -> datetime:
return datetime.now(timezone.utc)
def _forget(run_id: str) -> None:
"""移除一条 run 的全部内存态;仅在 run 处于终态时调用,避免打断活动任务。"""
_runs.pop(run_id, None)
_events.pop(run_id, None)
_reports.pop(run_id, None)
_tasks.pop(run_id, None)
_subscribers.pop(run_id, None)
_cancel_flags.pop(run_id, None)
def _evict_terminal() -> bool:
"""超过容量时淘汰最旧的终态 run;全部为活动 run 无法淘汰时返回 False。
绝不能删除仍在运行(queued/running)的 run:那会连带移除其 _cancel_flags 与
_subscribers,使后台 Task 访问时抛出 KeyError。
"""
terminal = (BenchmarkStatus.completed, BenchmarkStatus.failed, BenchmarkStatus.cancelled)
while len(_runs) >= MAX_RUNS:
victim = next(
(rid for rid, run in _runs.items() if run.status in terminal), None
)
if victim is None:
return False
_forget(victim)
return True
def _config_snapshot(request: RAGRunRequest, dataset: RAGDataset) -> dict:
"""记录运行时的模型 / 索引 / 环境信息,保证报告可解释、可复现。"""
settings = get_settings()
return {
"dataset_id": dataset.dataset_id,
"dataset_hash": dataset.content_hash,
"dataset_version": dataset.version,
"modes": [m.value for m in request.modes],
"retrieval": request.retrieval.model_dump(),
"repeat": request.repeat,
"embedding": {
"model_id": engine.embedding.model_id,
"version": engine.embedding.version,
"dim": engine.embedding.dim,
},
"reranker": {
"model_id": engine.reranker.model_id,
"version": engine.reranker.version,
},
"index_meta": repository.get_index_meta(),
"app": {"version": settings.version, "environment": settings.environment},
"python": sys.version.split()[0],
"metadata": request.metadata,
}
async def _validate_index_compatibility(request: RAGRunRequest) -> None:
"""创建 RAG Run 前校验索引已建立且与当前 Embedding 模型/维度兼容。
空索引或不兼容索引会让所有模式得到全 0 指标,把环境/索引错误误判为检索质量差,
故在创建时即拒绝,返回 BENCHMARK_INDEX_INCOMPATIBLE。
"""
stats = repository.stats()
meta = repository.get_index_meta()
needs_vector = any(m in (SearchMode.vector, SearchMode.hybrid) for m in request.modes)
reasons: list[str] = []
if stats["blocks"] == 0:
reasons.append("index is empty (no indexed blocks; run /api/index/rebuild first)")
if needs_vector:
if meta.get("embedding_model") != engine.embedding.model_id:
reasons.append(
f"embedding model mismatch: index={meta.get('embedding_model')!r}, "
f"engine={engine.embedding.model_id!r}"
)
if meta.get("embedding_dim") != str(engine.embedding.dim):
reasons.append(
f"embedding dimension mismatch: index={meta.get('embedding_dim')!r}, "
f"engine={engine.embedding.dim}"
)
if await engine.vector_store.count() == 0:
reasons.append("vector index is empty")
if reasons:
raise ApiError(
409,
"BENCHMARK_INDEX_INCOMPATIBLE",
"Benchmark index is not built or is incompatible with the current retrieval engine.",
{"reasons": reasons},
)
async def create_rag_run(request: RAGRunRequest) -> BenchmarkRun:
"""创建一次 RAG Benchmark,立即返回 queued 的 BenchmarkRun,由后台 Task 执行。"""
dataset = datasets.load_dataset(request.dataset_id, BenchmarkKind.rag)
await _validate_index_compatibility(request)
# 容量检查:先淘汰终态 run 腾空间;满容量且全为活动 run 时拒绝创建
if not _evict_terminal():
raise ApiError(
429,
"BENCHMARK_CAPACITY_EXCEEDED",
"Benchmark run capacity exceeded; wait for active runs to finish.",
{},
)
run_id = "benchmark_" + uuid4().hex[:12]
snapshot = _config_snapshot(request, dataset)
run = BenchmarkRun(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.queued,
progress=0.0,
config_snapshot=snapshot,
created_at=_now(),
)
_runs[run_id] = run
_events[run_id] = []
_subscribers[run_id] = []
_cancel_flags[run_id] = asyncio.Event()
_tasks[run_id] = asyncio.create_task(_execute_rag(run_id, request, dataset, snapshot))
return run
async def _execute_rag(
run_id: str, request: RAGRunRequest, dataset: RAGDataset, snapshot: dict
) -> None:
"""后台执行 RAG Benchmark,实时更新进度/事件,结束后写入报告并关闭订阅。"""
cancel_event = _cancel_flags[run_id]
def emit(event_type: BenchmarkEventType, data: dict) -> None:
sequence = len(_events[run_id])
event = BenchmarkEvent(
event=event_type, run_id=run_id, sequence=sequence, data=data, timestamp=_now()
)
_events[run_id].append(event)
for queue in _subscribers.get(run_id, []):
queue.put_nowait(event)
def finish() -> None:
_subscribers.pop(run_id, None)
_cancel_flags.pop(run_id, None)
_runs[run_id] = _runs[run_id].model_copy(
update={"status": BenchmarkStatus.running, "started_at": _now()}
)
emit(
BenchmarkEventType.run_started,
{"dataset_id": dataset.dataset_id, "modes": [m.value for m in request.modes]},
)
total = len(request.modes) * len(dataset.cases) * request.repeat
def on_case(result: RAGCaseResult, done: int, _total: int) -> None:
progress = done / total if total else 1.0
_runs[run_id] = _runs[run_id].model_copy(update={"progress": progress})
emit(BenchmarkEventType.case_completed, result.model_dump(mode="json"))
try:
metrics_by_mode, results = await run_rag(
dataset,
request,
on_case=on_case,
should_cancel=cancel_event.is_set,
)
except BenchmarkCancelled:
_runs[run_id] = _runs[run_id].model_copy(
update={
"status": BenchmarkStatus.cancelled,
"progress": 1.0,
"completed_at": _now(),
}
)
emit(BenchmarkEventType.run_cancelled, {"status": BenchmarkStatus.cancelled.value})
_reports[run_id] = BenchmarkReport(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.cancelled,
config_snapshot=snapshot,
)
finish()
return
except Exception as exc: # 单次运行失败不拖垮服务,记录错误后结束
# 详细异常只进日志,公开响应仅带项目错误码与安全消息,避免泄露路径/SQL 等敏感信息
logger.exception("Benchmark run failed: run_id=%s", run_id)
_runs[run_id] = _runs[run_id].model_copy(
update={
"status": BenchmarkStatus.failed,
"progress": 1.0,
"error": "Benchmark run failed.",
"error_code": "BENCHMARK_RUN_FAILED",
"completed_at": _now(),
}
)
emit(
BenchmarkEventType.run_failed,
{"error": "Benchmark run failed.", "error_code": "BENCHMARK_RUN_FAILED"},
)
_reports[run_id] = BenchmarkReport(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.failed,
config_snapshot=snapshot,
error="Benchmark run failed.",
error_code="BENCHMARK_RUN_FAILED",
)
finish()
return
metrics = {mode: m.model_dump() for mode, m in metrics_by_mode.items()}
_runs[run_id] = _runs[run_id].model_copy(
update={
"status": BenchmarkStatus.completed,
"progress": 1.0,
"metrics": metrics,
"completed_at": _now(),
}
)
emit(BenchmarkEventType.run_completed, {"metrics": metrics})
_reports[run_id] = BenchmarkReport(
run_id=run_id,
kind=BenchmarkKind.rag,
dataset_id=dataset.dataset_id,
dataset_hash=dataset.content_hash,
status=BenchmarkStatus.completed,
config_snapshot=snapshot,
metrics=metrics,
cases=results,
)
finish()
def list_runs(
kind: BenchmarkKind | None = None,
status: BenchmarkStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[BenchmarkRun], int]:
runs = list(_runs.values())
if kind is not None:
runs = [r for r in runs if r.kind == kind]
if status is not None:
runs = [r for r in runs if r.status == status]
runs.sort(key=lambda r: r.created_at, reverse=True)
total = len(runs)
return runs[offset : offset + limit], total
def get_run(run_id: str) -> BenchmarkRun | None:
return _runs.get(run_id)
def get_report(run_id: str) -> BenchmarkReport | None:
return _reports.get(run_id)
def get_events(run_id: str) -> list[BenchmarkEvent]:
return _events.get(run_id, [])
def cancel_run(run_id: str) -> BenchmarkRun | None:
"""取消运行:对 queued/running 设置取消标志,后台 Task 在 Case 边界检查后置为 cancelled。"""
run = _runs.get(run_id)
if run is None:
return None
if run.status in (BenchmarkStatus.queued, BenchmarkStatus.running):
_cancel_flags[run_id].set()
return run
def subscribe(run_id: str) -> asyncio.Queue[BenchmarkEvent] | None:
"""订阅运行事件流;运行已结束(completed/failed/cancelled)时返回 None。"""
run = _runs.get(run_id)
if run is None or run.status in (
BenchmarkStatus.completed,
BenchmarkStatus.failed,
BenchmarkStatus.cancelled,
):
return None
queue: asyncio.Queue[BenchmarkEvent] = asyncio.Queue()
_subscribers.setdefault(run_id, []).append(queue)
return queue
def unsubscribe(run_id: str, queue: asyncio.Queue[BenchmarkEvent]) -> None:
subscribers = _subscribers.get(run_id)
if subscribers and queue in subscribers:
subscribers.remove(queue)
async def wait_for_run(run_id: str) -> BenchmarkRun:
"""等待后台任务结束(测试/轮询用);无任务时直接返回当前状态。"""
task = _tasks.get(run_id)
if task is not None:
await task
return _runs.get(run_id)
+4
View File
@@ -24,6 +24,7 @@ class Settings:
db_path: Path db_path: Path
vault_path: Path vault_path: Path
attachments_path: Path attachments_path: Path
benchmark_datasets_path: Path
@lru_cache @lru_cache
@@ -41,4 +42,7 @@ def get_settings() -> Settings:
attachments_path=Path( attachments_path=Path(
os.getenv("APP_ATTACHMENTS_PATH", str(data_dir / "attachments")) os.getenv("APP_ATTACHMENTS_PATH", str(data_dir / "attachments"))
), ),
benchmark_datasets_path=Path(
os.getenv("APP_BENCHMARK_DATASETS_PATH", str(data_dir / "benchmarks"))
),
) )
+8 -2
View File
@@ -3,7 +3,7 @@ from dataclasses import dataclass
from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry from app.agent import AgentRuntime, PermissionManager, PermissionPolicy, ToolRegistry
from app.agent.builtin_tools import register_builtin_tools from app.agent.builtin_tools import register_builtin_tools
from app.contracts import ModelCapability, ProviderConfig, ProviderType from app.contracts import ModelCapability, ProviderConfig, ProviderType
from app.config import BACKEND_DIR from app.config import BACKEND_DIR, get_settings
from app.extensions import PluginRuntime, SkillRuntime from app.extensions import PluginRuntime, SkillRuntime
from app.providers import MockProvider, ProviderFactory, ProviderRegistry from app.providers import MockProvider, ProviderFactory, ProviderRegistry
from app.providers.credentials import ( from app.providers.credentials import (
@@ -26,6 +26,7 @@ class ApplicationContainer:
def build_container() -> ApplicationContainer: def build_container() -> ApplicationContainer:
settings = get_settings()
credentials = EncryptedCredentialStore() credentials = EncryptedCredentialStore()
provider_factory = ProviderFactory( provider_factory = ProviderFactory(
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver()) ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
@@ -50,7 +51,12 @@ def build_container() -> ApplicationContainer:
tools = ToolRegistry() tools = ToolRegistry()
register_builtin_tools(tools) register_builtin_tools(tools)
plugins = PluginRuntime(tools) plugins = PluginRuntime(
tools,
# 当前 Python Host 尚无 OS 沙箱。生产构建必须保持关闭,直到
# Tauri/Rust Host 能签发绑定命令摘要的可信启动许可。
allow_unsandboxed_mcp=settings.environment == "development",
)
plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools") plugins.install(BACKEND_DIR / "extensions" / "plugins" / "text-tools")
plugins.enable("text-tools") plugins.enable("text-tools")
+181 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from enum import Enum from enum import Enum
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
class Contract(BaseModel): class Contract(BaseModel):
@@ -144,6 +144,12 @@ class SearchRequest(Contract):
limit: int = Field(default=20, ge=1, le=100) limit: int = Field(default=20, ge=1, le=100)
offset: int = Field(default=0, ge=0) offset: int = Field(default=0, ge=0)
include_snippet: bool = True include_snippet: bool = True
# 检索调优参数(Benchmark 与 Skill 共用):控制 RRF / 精排 / 候选池 / 分数阈值。
# rerank_candidates=None 表示对全部候选精排(保留原有行为),Benchmark 传显式值。
rrf_k: int = Field(default=60, ge=1)
rerank: bool = True
rerank_candidates: int | None = Field(default=None, ge=1)
score_threshold: float = Field(default=0.0, ge=0.0)
class Citation(Contract): class Citation(Contract):
@@ -417,6 +423,10 @@ class ExtensionInstallRequest(Contract):
class PluginBackend(Contract): class PluginBackend(Contract):
type: Literal["mcp", "internal_rpc", "none"] = "none" type: Literal["mcp", "internal_rpc", "none"] = "none"
transport: Literal["stdio", "http", "none"] = "none" transport: Literal["stdio", "http", "none"] = "none"
command: str | None = None
args: list[str] = Field(default_factory=list)
startup_timeout_seconds: int = Field(default=10, ge=1, le=60)
tool_timeout_seconds: int = Field(default=30, ge=1, le=600)
class PluginContribution(Contract): class PluginContribution(Contract):
@@ -460,6 +470,28 @@ class PluginListResponse(Contract):
items: list[Plugin] = Field(default_factory=list) items: list[Plugin] = Field(default_factory=list)
class PluginHostState(str, Enum):
stopped = "stopped"
starting = "starting"
ready = "ready"
unhealthy = "unhealthy"
error = "error"
class PluginHostStatus(Contract):
plugin_id: str
backend_type: Literal["mcp", "internal_rpc", "none"]
transport: Literal["stdio", "http", "none"]
status: PluginHostState
tools_count: int = 0
started_at: datetime | None = None
last_seen_at: datetime | None = None
protocol_version: str | None = None
server_name: str | None = None
server_version: str | None = None
error: str | None = None
class PluginPermissionGrantRequest(Contract): class PluginPermissionGrantRequest(Contract):
permissions: list[str] = Field(default_factory=list) permissions: list[str] = Field(default_factory=list)
@@ -626,3 +658,151 @@ class IndexJob(Contract):
status: Literal["queued", "running", "completed", "failed"] status: Literal["queued", "running", "completed", "failed"]
scope: Literal["all", "notes", "vectors"] scope: Literal["all", "notes", "vectors"]
created_at: datetime created_at: datetime
# Benchmark
class BenchmarkKind(str, Enum):
rag = "rag"
agent = "agent"
class BenchmarkStatus(str, Enum):
queued = "queued"
running = "running"
completed = "completed"
failed = "failed"
cancelled = "cancelled"
class RAGDatasetCase(Contract):
case_id: str
query: str = Field(min_length=1)
expected_note_ids: list[str] = Field(default_factory=list)
expected_block_ids: list[str] = Field(default_factory=list)
citation_required: bool = False
tags: list[str] = Field(default_factory=list)
class RAGRetrievalConfig(Contract):
"""RAG Benchmark 的检索参数。top_k 映射到 SearchRequest.limit
其余参数透传到 SearchRequest,由检索引擎实际执行。"""
top_k: int = Field(default=10, ge=1, le=100)
rrf_k: int = Field(default=60, ge=1)
rerank: bool = True
rerank_candidates: int = Field(default=20, ge=1)
score_threshold: float = Field(default=0.0, ge=0.0)
class RAGRunRequest(Contract):
dataset_id: str = Field(min_length=1)
modes: list[SearchMode] = Field(
default_factory=lambda: [SearchMode.fts, SearchMode.vector, SearchMode.hybrid],
min_length=1,
)
retrieval: RAGRetrievalConfig = Field(default_factory=RAGRetrievalConfig)
repeat: int = Field(default=1, ge=1, le=10)
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("modes")
@classmethod
def _no_duplicate_modes(cls, value: list[SearchMode]) -> list[SearchMode]:
if len(value) != len(set(value)):
raise ValueError("modes must not contain duplicates")
return value
class RAGMetrics(Contract):
hit_at_1: float = 0.0
hit_at_5: float = 0.0
recall_at_k: float = 0.0
mrr: float = 0.0
citation_hit_rate: float = 0.0
p50_latency_ms: float = 0.0
p95_latency_ms: float = 0.0
# 样本构成:失败样本按零分计入质量指标,汇总不虚高;报告据此可知实际分母
total_cases: int = 0
successful_cases: int = 0
failed_cases: int = 0
failure_rate: float = 0.0
class BenchmarkDatasetInfo(Contract):
dataset_id: str
kind: BenchmarkKind
version: str
description: str = ""
case_count: int
content_hash: str
class BenchmarkDatasetListResponse(Contract):
items: list[BenchmarkDatasetInfo] = Field(default_factory=list)
class BenchmarkRun(Contract):
run_id: str
kind: BenchmarkKind
dataset_id: str
dataset_hash: str
status: BenchmarkStatus
progress: float | None = None
metrics: dict[str, Any] | None = None
config_snapshot: dict[str, Any] = Field(default_factory=dict)
error: str | None = None
error_code: str | None = None
created_at: datetime
started_at: datetime | None = None
completed_at: datetime | None = None
class BenchmarkRunListResponse(Contract):
items: list[BenchmarkRun] = Field(default_factory=list)
page: PageMeta = Field(default_factory=PageMeta)
class BenchmarkEventType(str, Enum):
run_started = "RunStarted"
case_completed = "CaseCompleted"
run_completed = "RunCompleted"
run_failed = "RunFailed"
run_cancelled = "RunCancelled"
class BenchmarkEvent(Contract):
event: BenchmarkEventType
run_id: str
sequence: int
data: dict[str, Any] = Field(default_factory=dict)
timestamp: datetime
class RAGCaseResult(Contract):
case_id: str
mode: SearchMode
repeat: int
latency_ms: float
retrieved_note_ids: list[str] = Field(default_factory=list)
retrieved_block_ids: list[str] = Field(default_factory=list)
hit_at_1: bool = False
hit_at_5: bool = False
recall: float = 0.0
reciprocal_rank: float = 0.0
citation_hit: bool = False
# 该 Case 是否声明了 expected_block_ids(决定是否计入 citation_hit_rate 分母)
citation_applicable: bool = False
error: str | None = None
error_code: str | None = None
class BenchmarkReport(Contract):
run_id: str
kind: BenchmarkKind
dataset_id: str
dataset_hash: str
status: BenchmarkStatus
config_snapshot: dict[str, Any] = Field(default_factory=dict)
metrics: dict[str, Any] = Field(default_factory=dict)
cases: list[RAGCaseResult] = Field(default_factory=list)
error: str | None = None
error_code: str | None = None
+9 -1
View File
@@ -4,5 +4,13 @@ from app.extensions.runtime import (
PluginRuntime, PluginRuntime,
SkillRuntime, SkillRuntime,
) )
from app.extensions.mcp import McpBridge, McpBridgeError
__all__ = ["AgentConfiguration", "ExtensionError", "PluginRuntime", "SkillRuntime"] __all__ = [
"AgentConfiguration",
"ExtensionError",
"McpBridge",
"McpBridgeError",
"PluginRuntime",
"SkillRuntime",
]
+785
View File
@@ -0,0 +1,785 @@
"""本地 stdio MCP Bridge。
第三方 Server 始终运行在子进程中。Bridge 只把通过校验的 MCP Tool 转换为项目内部
ToolDefinition/ToolResult,不把 MCP 原始协议泄露给 Agent Runtime 或前端。
"""
from __future__ import annotations
import asyncio
import json
import os
import queue
import subprocess
import threading
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from app.agent.permissions import KNOWN_PERMISSIONS
from app.agent.tools import ToolExecutionError
from app.contracts import (
PluginBackend,
PluginHostState,
PluginHostStatus,
ToolDefinition,
)
MCP_PROTOCOL_VERSION = "2025-11-25"
SUPPORTED_PROTOCOL_VERSIONS = {
MCP_PROTOCOL_VERSION,
"2025-06-18",
"2025-03-26",
"2024-11-05",
}
MAX_MCP_MESSAGE_BYTES = 2 * 1024 * 1024
MAX_MCP_TOOL_RESULT_BYTES = 256 * 1024
MAX_MCP_TOOLS = 500
MAX_MCP_LIST_PAGES = 100
class McpBridgeError(RuntimeError):
def __init__(self, code: str, message: str, *, status_code: int = 502) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
@dataclass(frozen=True, slots=True)
class McpDiscoveredTool:
remote_name: str
definition: ToolDefinition
@dataclass(slots=True)
class _PendingRequest:
response: queue.Queue[dict[str, Any] | BaseException]
class McpStdioClient:
"""线程驱动的换行分隔 JSON-RPC 客户端,避免阻塞 FastAPI 事件循环。"""
def __init__(
self,
command: list[str],
*,
cwd: Path,
on_seen: Callable[[], None],
on_broken: Callable[[str], None],
on_tools_changed: Callable[[], None],
) -> None:
self.command = command
self.cwd = cwd
self.on_seen = on_seen
self.on_broken = on_broken
self.on_tools_changed = on_tools_changed
self.process: subprocess.Popen[str] | None = None
self._write_lock = threading.Lock()
self._pending_lock = threading.Lock()
self._pending: dict[int, _PendingRequest] = {}
self._next_id = 1
self._stopping = False
# stderr 只在 Host 内部保留有限尾部,不进入 API、Trace 或普通日志。
self._stderr_tail: deque[str] = deque(maxlen=50)
def start(self) -> None:
if self.process is not None and self.process.poll() is None:
return
# TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
environment = _subprocess_environment()
environment.setdefault("PYTHONUNBUFFERED", "1")
try:
self.process = subprocess.Popen(
self.command,
cwd=self.cwd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
shell=False,
env=environment,
creationflags=creation_flags,
)
except OSError as exc:
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED",
f"Cannot start MCP server process: {exc}",
status_code=503,
) from exc
threading.Thread(target=self._stdout_loop, daemon=True).start()
threading.Thread(target=self._stderr_loop, daemon=True).start()
def request(
self,
method: str,
params: dict[str, Any],
*,
timeout: float,
timeout_code: str,
response_error_code: str = "MCP_TOOL_CALL_FAILED",
) -> dict[str, Any]:
request_id, pending = self.begin_request(method, params)
return self.wait_response(
request_id,
pending,
timeout=timeout,
timeout_code=timeout_code,
response_error_code=response_error_code,
)
def begin_request(
self, method: str, params: dict[str, Any]
) -> tuple[int, _PendingRequest]:
self._ensure_running()
with self._pending_lock:
request_id = self._next_id
self._next_id += 1
pending = _PendingRequest(response=queue.Queue(maxsize=1))
self._pending[request_id] = pending
try:
self._send(
{
"jsonrpc": "2.0",
"id": request_id,
"method": method,
"params": params,
}
)
except BaseException:
with self._pending_lock:
self._pending.pop(request_id, None)
raise
return request_id, pending
def wait_response(
self,
request_id: int,
pending: _PendingRequest,
*,
timeout: float,
timeout_code: str,
response_error_code: str = "MCP_TOOL_CALL_FAILED",
) -> dict[str, Any]:
try:
response = pending.response.get(timeout=timeout)
except queue.Empty as exc:
self.cancel(request_id, "Request timed out.")
self.abandon(request_id)
raise McpBridgeError(timeout_code, "MCP request timed out.", status_code=504) from exc
if isinstance(response, BaseException):
raise response
if "error" in response:
error = response.get("error")
message = (
str(error.get("message", "MCP JSON-RPC error."))
if isinstance(error, dict)
else "MCP JSON-RPC error."
)
raise McpBridgeError(response_error_code, message)
result = response.get("result")
if not isinstance(result, dict):
raise McpBridgeError(
response_error_code, "MCP response result must be an object."
)
return result
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
if params is not None:
payload["params"] = params
self._send(payload)
def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None:
try:
self.notify(
"notifications/cancelled",
{"requestId": request_id, "reason": reason},
)
except McpBridgeError:
pass
def abandon(
self, request_id: int, wake_error: BaseException | None = None
) -> None:
with self._pending_lock:
pending = self._pending.pop(request_id, None)
# asyncio.to_thread 被取消时不会停止底层线程;主动唤醒 Queue,避免线程
# 一直占用默认线程池直至远端超时。
if pending is not None and wake_error is not None:
try:
pending.response.put_nowait(wake_error)
except queue.Full:
pass
def stop(self) -> None:
process = self.process
if process is None:
return
self._stopping = True
try:
if process.stdin:
try:
process.stdin.close()
except (BrokenPipeError, OSError, ValueError):
pass
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
finally:
self._fail_pending(
McpBridgeError("PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503)
)
self.process = None
def _send(self, message: dict[str, Any]) -> None:
self._ensure_running()
encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
if len(encoded.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES:
raise McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request is too large.")
process = self.process
assert process is not None and process.stdin is not None
try:
with self._write_lock:
process.stdin.write(encoded + "\n")
process.stdin.flush()
except (BrokenPipeError, OSError, ValueError) as exc:
raise McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", "MCP host input is closed.", status_code=503
) from exc
def _stdout_loop(self) -> None:
process = self.process
assert process is not None and process.stdout is not None
failure: str | None = None
try:
while True:
# readline(size) 在换行缺失时仍有硬上限,不能先把任意大的
# 第三方 stdout 行完整读入宿主内存再检查。
raw_line = process.stdout.readline(MAX_MCP_MESSAGE_BYTES + 1)
if raw_line == "":
break
if not raw_line.endswith("\n"):
failure = "MCP server emitted an oversized or unterminated message."
break
if len(raw_line.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES:
failure = "MCP server emitted an oversized protocol message."
break
try:
message = json.loads(raw_line)
except json.JSONDecodeError:
failure = "MCP server emitted invalid JSON on stdout."
break
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
failure = "MCP server emitted an invalid JSON-RPC message."
break
self.on_seen()
if "id" in message and ("result" in message or "error" in message):
request_id = message.get("id")
if isinstance(request_id, int):
with self._pending_lock:
pending = self._pending.pop(request_id, None)
if pending:
pending.response.put(message)
continue
method = message.get("method")
if method == "notifications/tools/list_changed":
self.on_tools_changed()
elif isinstance(method, str) and "id" in message:
self._send(
{
"jsonrpc": "2.0",
"id": message["id"],
"error": {"code": -32601, "message": "Method not supported."},
}
)
except (McpBridgeError, OSError, ValueError) as exc:
failure = f"MCP stdout closed unexpectedly: {type(exc).__name__}."
finally:
if failure and process.poll() is None:
process.terminate()
exit_code = process.poll()
if exit_code is None:
try:
exit_code = process.wait(timeout=1)
except subprocess.TimeoutExpired:
exit_code = None
if not self._stopping:
message = failure or f"MCP host exited unexpectedly with code {exit_code}."
error = McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", message, status_code=503
)
self._fail_pending(error)
self.on_broken(message)
def _stderr_loop(self) -> None:
process = self.process
assert process is not None and process.stderr is not None
try:
while True:
# stderr 不是协议通道,但同样按块读取,避免无换行日志造成
# 宿主侧的无界字符串分配。
line = process.stderr.readline(1025)
if line == "":
break
self._stderr_tail.append(line.rstrip()[:1024])
except (OSError, ValueError):
return
def _ensure_running(self) -> None:
if self.process is None or self.process.poll() is not None:
raise McpBridgeError(
"PLUGIN_HOST_UNAVAILABLE", "MCP host is not running.", status_code=503
)
def _fail_pending(self, error: BaseException) -> None:
with self._pending_lock:
pending = list(self._pending.values())
self._pending.clear()
for item in pending:
item.response.put(error)
@dataclass(slots=True)
class _McpHost:
backend: PluginBackend
client: McpStdioClient
status: PluginHostStatus
class McpBridge:
"""管理每个 Plugin 的独立 MCP Client,并执行 Contract 转换。"""
def __init__(self) -> None:
self._hosts: dict[str, _McpHost] = {}
self._statuses: dict[str, PluginHostStatus] = {}
self._calls: dict[tuple[str, str], int] = {}
self._lock = threading.RLock()
def start(
self,
plugin_id: str,
backend: PluginBackend,
package_path: Path,
declared_permissions: list[str],
on_unavailable: Callable[[str, str], None],
) -> list[McpDiscoveredTool]:
if backend.transport != "stdio":
raise McpBridgeError(
"MCP_CAPABILITY_UNSUPPORTED",
"Phase C only supports the MCP stdio transport.",
status_code=501,
)
command = self._resolve_command(package_path, backend)
now = datetime.now(timezone.utc)
status = PluginHostStatus(
plugin_id=plugin_id,
backend_type="mcp",
transport="stdio",
status=PluginHostState.starting,
started_at=now,
last_seen_at=now,
)
host_ref: dict[str, _McpHost] = {}
def seen() -> None:
host = host_ref.get("host")
if host:
host.status.last_seen_at = datetime.now(timezone.utc)
def broken(message: str) -> None:
host = host_ref.get("host")
if host:
host.status.status = PluginHostState.unhealthy
host.status.error = message
on_unavailable(plugin_id, message)
def tools_changed() -> None:
broken("MCP tool list changed; restart the Plugin Host to revalidate tools.")
client = McpStdioClient(
command,
cwd=package_path,
on_seen=seen,
on_broken=broken,
on_tools_changed=tools_changed,
)
host = _McpHost(backend=backend, client=client, status=status)
host_ref["host"] = host
with self._lock:
if plugin_id in self._hosts:
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED",
f"MCP host is already running: {plugin_id}",
status_code=409,
)
self._hosts[plugin_id] = host
self._statuses[plugin_id] = status
try:
client.start()
initialize = client.request(
"initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "NotesAgent", "version": "0.1.0"},
},
timeout=backend.startup_timeout_seconds,
timeout_code="MCP_INITIALIZE_FAILED",
response_error_code="MCP_INITIALIZE_FAILED",
)
version = initialize.get("protocolVersion")
if version not in SUPPORTED_PROTOCOL_VERSIONS:
raise McpBridgeError(
"MCP_INITIALIZE_FAILED",
f"Unsupported MCP protocol version: {version}",
)
capabilities = initialize.get("capabilities")
if not isinstance(capabilities, dict) or not isinstance(
capabilities.get("tools"), dict
):
raise McpBridgeError(
"MCP_CAPABILITY_UNSUPPORTED",
"MCP server does not declare the tools capability.",
)
server_info = initialize.get("serverInfo")
if not isinstance(server_info, dict):
server_info = {}
status.protocol_version = str(version)
status.server_name = _optional_string(server_info.get("name"))
status.server_version = _optional_string(server_info.get("version"))
client.notify("notifications/initialized")
discovered = self._discover_tools(
plugin_id, client, backend, declared_permissions
)
status.status = PluginHostState.ready
status.tools_count = len(discovered)
status.last_seen_at = datetime.now(timezone.utc)
status.error = None
return discovered
except McpBridgeError as exc:
status.status = PluginHostState.error
status.error = exc.message
client.stop()
with self._lock:
self._hosts.pop(plugin_id, None)
raise
except Exception as exc:
status.status = PluginHostState.error
status.error = f"MCP initialization failed: {type(exc).__name__}."
client.stop()
with self._lock:
self._hosts.pop(plugin_id, None)
raise McpBridgeError("MCP_INITIALIZE_FAILED", status.error) from exc
async def call_tool(
self,
plugin_id: str,
remote_name: str,
arguments: dict[str, Any],
*,
request_id: str,
) -> Any:
host = self._host(plugin_id)
rpc_id, pending = host.client.begin_request(
"tools/call", {"name": remote_name, "arguments": arguments}
)
call_key = (plugin_id, request_id)
with self._lock:
self._calls[call_key] = rpc_id
try:
result = await asyncio.to_thread(
host.client.wait_response,
rpc_id,
pending,
timeout=host.backend.tool_timeout_seconds,
timeout_code="MCP_TOOL_CALL_FAILED",
)
except asyncio.CancelledError:
host.client.cancel(rpc_id)
host.client.abandon(
rpc_id,
McpBridgeError(
"MCP_TOOL_CALL_FAILED", "MCP request was cancelled."
),
)
raise
except McpBridgeError as exc:
raise ToolExecutionError(exc.code, exc.message) from exc
finally:
with self._lock:
self._calls.pop(call_key, None)
encoded_size = len(
json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
)
if encoded_size > MAX_MCP_TOOL_RESULT_BYTES:
raise ToolExecutionError(
"MCP_TOOL_RESULT_TOO_LARGE",
"MCP tool result exceeds the configured size limit.",
)
if result.get("isError") is True:
raise ToolExecutionError(
"MCP_TOOL_CALL_FAILED", _mcp_error_message(result.get("content"))
)
structured = result.get("structuredContent")
if structured is not None:
if not isinstance(structured, dict):
raise ToolExecutionError(
"MCP_TOOL_CALL_FAILED",
"MCP structuredContent must be an object.",
)
return structured
content = result.get("content", [])
if not isinstance(content, list):
raise ToolExecutionError(
"MCP_TOOL_CALL_FAILED", "MCP tool content must be an array."
)
return {"content": content}
def cancel(self, plugin_id: str, request_id: str) -> None:
with self._lock:
rpc_id = self._calls.get((plugin_id, request_id))
host = self._hosts.get(plugin_id)
if rpc_id is not None and host is not None:
host.client.cancel(rpc_id)
def stop(self, plugin_id: str) -> None:
with self._lock:
host = self._hosts.pop(plugin_id, None)
if host:
host.client.stop()
host.status.status = PluginHostState.stopped
host.status.tools_count = 0
host.status.error = None
def remove(self, plugin_id: str) -> None:
"""停止 Host,并清除卸载后不应跨安装保留的状态与调用索引。"""
self.stop(plugin_id)
with self._lock:
self._statuses.pop(plugin_id, None)
stale_calls = [key for key in self._calls if key[0] == plugin_id]
for key in stale_calls:
self._calls.pop(key, None)
def status(self, plugin_id: str, backend: PluginBackend) -> PluginHostStatus:
with self._lock:
status = self._statuses.get(plugin_id)
if status:
return status.model_copy(deep=True)
return PluginHostStatus(
plugin_id=plugin_id,
backend_type=backend.type,
transport=backend.transport,
status=PluginHostState.stopped,
)
def _discover_tools(
self,
plugin_id: str,
client: McpStdioClient,
backend: PluginBackend,
declared_permissions: list[str],
) -> list[McpDiscoveredTool]:
discovered: list[McpDiscoveredTool] = []
cursor: str | None = None
for _ in range(MAX_MCP_LIST_PAGES):
params = {"cursor": cursor} if cursor else {}
result = client.request(
"tools/list",
params,
timeout=backend.startup_timeout_seconds,
timeout_code="MCP_INITIALIZE_FAILED",
response_error_code="MCP_INITIALIZE_FAILED",
)
raw_tools = result.get("tools")
if not isinstance(raw_tools, list):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tools/list must return a tools array."
)
for raw in raw_tools:
discovered.append(
self._map_tool(plugin_id, raw, declared_permissions)
)
if len(discovered) > MAX_MCP_TOOLS:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP server exposes more than {MAX_MCP_TOOLS} tools.",
)
next_cursor = result.get("nextCursor")
if next_cursor is None:
break
if not isinstance(next_cursor, str) or not next_cursor:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP nextCursor must be a non-empty string."
)
cursor = next_cursor
else:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tools/list exceeded the page limit."
)
names = [item.definition.name for item in discovered]
if len(names) != len(set(names)):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP server returned duplicate tool names."
)
return discovered
@staticmethod
def _map_tool(
plugin_id: str, raw: Any, declared_permissions: list[str]
) -> McpDiscoveredTool:
if not isinstance(raw, dict):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tool definition must be an object."
)
remote_name = raw.get("name")
if not isinstance(remote_name, str) or not remote_name:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID", "MCP tool name must be a non-empty string."
)
if (
len(remote_name) > 128
or not remote_name[0].isalnum()
or not all(
character.islower()
or character.isdigit()
or character in "._-"
for character in remote_name
)
):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool name is not a valid NotesAgent id: {remote_name}",
)
schema = raw.get("inputSchema", {"type": "object", "properties": {}})
if not isinstance(schema, dict) or schema.get("type", "object") != "object":
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool inputSchema must be an object schema: {remote_name}",
)
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"Invalid MCP tool schema for {remote_name}: {exc.message}",
) from exc
metadata = raw.get("_meta")
permission = (
metadata.get("notesagent/permission") if isinstance(metadata, dict) else None
)
if permission is not None and (
not isinstance(permission, str) or permission not in KNOWN_PERMISSIONS
):
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool declares an unknown permission: {remote_name}",
)
if permission and permission not in declared_permissions:
raise McpBridgeError(
"MCP_TOOL_SCHEMA_INVALID",
f"MCP tool permission is missing from Plugin manifest: {permission}",
)
description = raw.get("description")
return McpDiscoveredTool(
remote_name=remote_name,
definition=ToolDefinition(
name=f"{plugin_id}.{remote_name}",
description=description if isinstance(description, str) else remote_name,
parameters=schema,
permission=permission,
source="plugin",
),
)
def _host(self, plugin_id: str) -> _McpHost:
with self._lock:
host = self._hosts.get(plugin_id)
if host is None or host.status.status != PluginHostState.ready:
raise ToolExecutionError(
"PLUGIN_HOST_UNAVAILABLE", f"MCP Plugin Host is not ready: {plugin_id}"
)
return host
@staticmethod
def _resolve_command(root: Path, backend: PluginBackend) -> list[str]:
if not backend.command or not backend.command.strip():
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED", "MCP stdio backend requires a command."
)
command = backend.command.strip()
if Path(command).is_absolute() or "/" in command or "\\" in command:
executable = (
(root / command).resolve()
if not Path(command).is_absolute()
else Path(command).resolve()
)
try:
executable.relative_to(root)
except ValueError as exc:
raise McpBridgeError(
"PLUGIN_HOST_START_FAILED",
"MCP executable path must stay inside the Plugin package.",
) from exc
command = str(executable)
return [command, *backend.args]
def _mcp_error_message(content: Any) -> str:
if isinstance(content, list):
texts = [
item.get("text")
for item in content
if isinstance(item, dict)
and item.get("type") == "text"
and isinstance(item.get("text"), str)
]
if texts:
return "\n".join(texts)[:4096]
return "MCP tool returned an error result."
def _optional_string(value: Any) -> str | None:
return value if isinstance(value, str) else None
def _subprocess_environment() -> dict[str, str]:
"""只传递启动进程所需的系统变量,隔离 Provider Key、Vault 路径等宿主状态。"""
allowed = {
"PATH",
"PATHEXT",
"SYSTEMROOT",
"WINDIR",
"COMSPEC",
"TEMP",
"TMP",
"TMPDIR",
"LANG",
"LC_ALL",
"VIRTUAL_ENV",
}
environment = {
key: value for key, value in os.environ.items() if key.upper() in allowed
}
environment["PYTHONUNBUFFERED"] = "1"
environment["PYTHONIOENCODING"] = "utf-8"
return environment
+252 -59
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import threading
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
@@ -16,6 +17,7 @@ from app.contracts import (
ModelCapability, ModelCapability,
Plugin, Plugin,
PluginManifest, PluginManifest,
PluginHostStatus,
PluginStatus, PluginStatus,
RetrievalConfig, RetrievalConfig,
Skill, Skill,
@@ -23,6 +25,7 @@ from app.contracts import (
SkillStatus, SkillStatus,
ToolDefinition, ToolDefinition,
) )
from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
_EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$") _EXTENSION_ID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
@@ -242,18 +245,29 @@ class _PluginRecord:
tools: list[DeclarativeToolSpec] tools: list[DeclarativeToolSpec]
package_path: Path package_path: Path
registered_tools: list[str] registered_tools: list[str]
mcp_remote_names: dict[str, str]
class PluginRuntime: class PluginRuntime:
"""Plugin Manifest、生命周期及 Tool Contribution 注册。""" """Plugin Manifest、生命周期及 Tool Contribution 注册。"""
def __init__(self, tools: ToolRegistry, host: DeclarativePluginHost | None = None) -> None: def __init__(
self,
tools: ToolRegistry,
host: DeclarativePluginHost | None = None,
mcp_bridge: McpBridge | None = None,
*,
allow_unsandboxed_mcp: bool = False,
) -> None:
self.registry = tools self.registry = tools
self.host = host or DeclarativePluginHost() self.host = host or DeclarativePluginHost()
self.mcp = mcp_bridge or McpBridge()
self.allow_unsandboxed_mcp = allow_unsandboxed_mcp
self._records: dict[str, _PluginRecord] = {} self._records: dict[str, _PluginRecord] = {}
self._lock = threading.RLock()
def install(self, package_path: str | Path) -> Plugin: def install(self, package_path: str | Path) -> Plugin:
# 当前只加载声明式清单,不导入或执行插件包中的任意 Python 代码 # 安装阶段只读取清单;MCP 子进程必须在权限授予后的 enable 阶段启动
root = _package_dir(package_path) root = _package_dir(package_path)
raw = _read_yaml(root / "plugin.yaml") raw = _read_yaml(root / "plugin.yaml")
if "id" in raw and "plugin_id" not in raw: if "id" in raw and "plugin_id" not in raw:
@@ -271,15 +285,17 @@ class PluginRuntime:
status_code=409, status_code=409,
) )
specs = self._load_tools(root) _validate_backend(manifest)
declared = set(manifest.contributes.tools) specs = [] if manifest.backend.type == "mcp" else self._load_tools(root)
actual = {spec.name for spec in specs} if manifest.backend.type != "mcp":
if declared != actual: declared = set(manifest.contributes.tools)
raise ExtensionError( actual = {spec.name for spec in specs}
"PLUGIN_CONTRIBUTION_INVALID", if declared != actual:
"plugin.yaml tool contributions must exactly match tools.yaml", raise ExtensionError(
details={"declared": sorted(declared), "actual": sorted(actual)}, "PLUGIN_CONTRIBUTION_INVALID",
) "plugin.yaml tool contributions must exactly match tools.yaml",
details={"declared": sorted(declared), "actual": sorted(actual)},
)
for spec in specs: for spec in specs:
_validate_id("tool", spec.name) _validate_id("tool", spec.name)
_validate_tool_schema(spec) _validate_tool_schema(spec)
@@ -302,6 +318,7 @@ class PluginRuntime:
tools=specs, tools=specs,
package_path=root, package_path=root,
registered_tools=[], registered_tools=[],
mcp_remote_names={},
) )
self._records[manifest.plugin_id] = record self._records[manifest.plugin_id] = record
return record.plugin.model_copy(deep=True) return record.plugin.model_copy(deep=True)
@@ -313,18 +330,14 @@ class PluginRuntime:
return self._record(plugin_id).plugin.model_copy(deep=True) return self._record(plugin_id).plugin.model_copy(deep=True)
def enable(self, plugin_id: str) -> Plugin: def enable(self, plugin_id: str) -> Plugin:
# Host 启动和 Tool 批量注册必须串行,避免并发 enable 产生重复进程或半注册状态。
with self._lock:
return self._enable(plugin_id)
def _enable(self, plugin_id: str) -> Plugin:
record = self._record(plugin_id) record = self._record(plugin_id)
if record.plugin.enabled: if record.plugin.enabled:
return record.plugin.model_copy(deep=True) return record.plugin.model_copy(deep=True)
if record.plugin.manifest.backend.type == "mcp":
# TODO(extension): 第二阶段以隔离进程实现 MCP Host,并补充签名与来源校验。
record.plugin.status = PluginStatus.dependency_missing
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
"MCP Plugin Host is reserved for the second development phase.",
status_code=501,
details={"plugin_id": plugin_id, "backend": "mcp"},
)
missing_grants = sorted( missing_grants = sorted(
set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions) set(record.plugin.manifest.permissions) - set(record.plugin.granted_permissions)
) )
@@ -336,7 +349,18 @@ class PluginRuntime:
status_code=409, status_code=409,
details={"plugin_id": plugin_id, "permissions": missing_grants}, details={"plugin_id": plugin_id, "permissions": missing_grants},
) )
conflicts = [spec.name for spec in record.tools if self.registry.contains(spec.name)] if (
record.plugin.manifest.backend.type == "mcp"
and not self.allow_unsandboxed_mcp
):
raise ExtensionError(
"MCP_TRUST_APPROVAL_REQUIRED",
"Unsandboxed MCP Hosts are disabled outside development mode.",
status_code=403,
details={"plugin_id": plugin_id},
)
declared_tools = list(record.plugin.manifest.contributes.tools)
conflicts = [name for name in declared_tools if self.registry.contains(name)]
if conflicts: if conflicts:
raise ExtensionError( raise ExtensionError(
"PLUGIN_TOOL_CONFLICT", "PLUGIN_TOOL_CONFLICT",
@@ -346,42 +370,75 @@ class PluginRuntime:
) )
record.plugin.status = PluginStatus.starting record.plugin.status = PluginStatus.starting
try: try:
for spec in record.tools: if record.plugin.manifest.backend.type == "mcp":
arguments_model = _arguments_model(spec) discovered = self._start_mcp(record)
actual = {item.definition.name for item in discovered}
declared = set(declared_tools)
if actual != declared:
raise ExtensionError(
"PLUGIN_CONTRIBUTION_INVALID",
"Discovered MCP tools must exactly match Plugin contributions.",
details={"declared": sorted(declared), "actual": sorted(actual)},
)
for item in discovered:
self._register_mcp_tool(record, item)
else:
for spec in record.tools:
arguments_model = _arguments_model(spec)
async def executor( async def executor(
arguments: BaseModel, arguments: BaseModel,
context: ToolExecutionContext, context: ToolExecutionContext,
_handler: str = spec.handler, _handler: str = spec.handler,
) -> Any: ) -> Any:
return await self.host.execute(_handler, arguments, context) return await self.host.execute(_handler, arguments, context)
self.registry.register( self.registry.register(
ToolDefinition( ToolDefinition(
name=spec.name, name=spec.name,
description=spec.description, description=spec.description,
parameters=spec.parameters, parameters=spec.parameters,
permission=spec.permission, permission=spec.permission,
source="plugin", source="plugin",
), ),
arguments_model, arguments_model,
executor, executor,
) )
record.registered_tools.append(spec.name) record.registered_tools.append(spec.name)
except Exception as exc: except Exception as exc:
# 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。 # 注册过程必须具备回滚语义,防止半启用插件污染全局工具表。
for name in record.registered_tools: for name in record.registered_tools:
self.registry.unregister(name) self.registry.unregister(name)
record.registered_tools.clear() record.registered_tools.clear()
record.mcp_remote_names.clear()
self.mcp.stop(plugin_id)
record.plugin.status = PluginStatus.error record.plugin.status = PluginStatus.error
record.plugin.error_message = str(exc) record.plugin.error_message = _safe_extension_message(exc)
raise if isinstance(exc, ExtensionError):
raise
if isinstance(exc, McpBridgeError):
raise ExtensionError(
exc.code,
exc.message,
status_code=exc.status_code,
details={"plugin_id": plugin_id},
) from exc
raise ExtensionError(
"PLUGIN_HOST_START_FAILED",
record.plugin.error_message,
status_code=503,
details={"plugin_id": plugin_id},
) from exc
record.plugin.enabled = True record.plugin.enabled = True
record.plugin.status = PluginStatus.ready record.plugin.status = PluginStatus.ready
record.plugin.error_message = None record.plugin.error_message = None
return record.plugin.model_copy(deep=True) return record.plugin.model_copy(deep=True)
def set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin: def set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
with self._lock:
return self._set_permissions(plugin_id, permissions)
def _set_permissions(self, plugin_id: str, permissions: list[str]) -> Plugin:
record = self._record(plugin_id) record = self._record(plugin_id)
requested = set(permissions) requested = set(permissions)
declared = set(record.plugin.manifest.permissions) declared = set(record.plugin.manifest.permissions)
@@ -403,15 +460,125 @@ class PluginRuntime:
return record.plugin.model_copy(deep=True) return record.plugin.model_copy(deep=True)
def disable(self, plugin_id: str) -> Plugin: def disable(self, plugin_id: str) -> Plugin:
with self._lock:
return self._disable(plugin_id)
def _disable(self, plugin_id: str) -> Plugin:
record = self._record(plugin_id) record = self._record(plugin_id)
for name in record.registered_tools: for name in record.registered_tools:
self.registry.unregister(name) self.registry.unregister(name)
record.registered_tools.clear() record.registered_tools.clear()
record.mcp_remote_names.clear()
if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id)
record.plugin.enabled = False record.plugin.enabled = False
record.plugin.status = PluginStatus.disabled record.plugin.status = PluginStatus.disabled
return record.plugin.model_copy(deep=True) return record.plugin.model_copy(deep=True)
def get_host_status(self, plugin_id: str) -> PluginHostStatus:
record = self._record(plugin_id)
return self.mcp.status(plugin_id, record.plugin.manifest.backend)
def restart_host(self, plugin_id: str) -> PluginHostStatus:
with self._lock:
return self._restart_host(plugin_id)
def _restart_host(self, plugin_id: str) -> PluginHostStatus:
record = self._record(plugin_id)
if record.plugin.manifest.backend.type != "mcp":
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
"Plugin does not use an MCP Host.",
status_code=409,
details={"plugin_id": plugin_id},
)
if record.plugin.status in {
PluginStatus.installed,
PluginStatus.disabled,
PluginStatus.permission_required,
}:
raise ExtensionError(
"PLUGIN_HOST_UNAVAILABLE",
"Disabled or inactive MCP Plugins must be started with Enable.",
status_code=409,
details={"plugin_id": plugin_id, "status": record.plugin.status.value},
)
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
record.mcp_remote_names.clear()
self.mcp.stop(plugin_id)
record.plugin.enabled = False
record.plugin.status = PluginStatus.installed
record.plugin.error_message = None
self.enable(plugin_id)
return self.get_host_status(plugin_id)
def shutdown(self) -> None:
"""关闭所有隔离 Host;用于 FastAPI lifespan 和测试清理。"""
with self._lock:
for plugin_id, record in list(self._records.items()):
if record.plugin.manifest.backend.type == "mcp":
self.mcp.stop(plugin_id)
def _start_mcp(self, record: _PluginRecord) -> list[McpDiscoveredTool]:
manifest = record.plugin.manifest
return self.mcp.start(
manifest.plugin_id,
manifest.backend,
record.package_path,
manifest.permissions,
self._handle_mcp_unavailable,
)
def _register_mcp_tool(
self, record: _PluginRecord, discovered: McpDiscoveredTool
) -> None:
definition = discovered.definition
arguments_model = _arguments_model_from_schema(
definition.name, definition.parameters
)
plugin_id = record.plugin.manifest.plugin_id
remote_name = discovered.remote_name
async def executor(
arguments: BaseModel,
context: ToolExecutionContext,
) -> Any:
return await self.mcp.call_tool(
plugin_id,
remote_name,
# 省略的可选字段不能被补成 null;显式传入的 null 仍由
# model_fields_set 保留并交给 MCP Server。
arguments.model_dump(exclude_unset=True),
request_id=context.tool_call_id or f"{context.run_id}:{definition.name}",
)
self.registry.register(definition, arguments_model, executor)
record.registered_tools.append(definition.name)
record.mcp_remote_names[definition.name] = remote_name
def _handle_mcp_unavailable(self, plugin_id: str, message: str) -> None:
with self._lock:
record = self._records.get(plugin_id)
if record is None:
return
for name in record.registered_tools:
self.registry.unregister(name)
record.registered_tools.clear()
record.mcp_remote_names.clear()
record.plugin.enabled = False
record.plugin.status = PluginStatus.error
record.plugin.error_message = message
def uninstall(self, plugin_id: str, dependent_skills: list[str] | None = None) -> None: def uninstall(self, plugin_id: str, dependent_skills: list[str] | None = None) -> None:
with self._lock:
self._uninstall(plugin_id, dependent_skills)
def _uninstall(
self, plugin_id: str, dependent_skills: list[str] | None = None
) -> None:
record = self._record(plugin_id) record = self._record(plugin_id)
if dependent_skills: if dependent_skills:
raise ExtensionError( raise ExtensionError(
@@ -420,8 +587,13 @@ class PluginRuntime:
status_code=409, status_code=409,
details={"plugin_id": plugin_id, "skills": dependent_skills}, details={"plugin_id": plugin_id, "skills": dependent_skills},
) )
is_mcp = record.plugin.manifest.backend.type == "mcp"
if record.plugin.enabled: if record.plugin.enabled:
self.disable(plugin_id) self.disable(plugin_id)
if is_mcp:
# stop 只结束本次进程并保留状态供故障诊断;真正卸载时必须连同
# 历史状态一起遗忘,避免同 ID 重装继承旧协商信息。
self.mcp.remove(plugin_id)
del self._records[plugin_id] del self._records[plugin_id]
def _record(self, plugin_id: str) -> _PluginRecord: def _record(self, plugin_id: str) -> _PluginRecord:
@@ -498,24 +670,18 @@ def _manifest_error(kind: str, exc: ValidationError) -> ExtensionError:
def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]: def _arguments_model(spec: DeclarativeToolSpec) -> type[BaseModel]:
schema = spec.parameters or {"type": "object", "properties": {}} schema = spec.parameters or {"type": "object", "properties": {}}
return _arguments_model_from_schema(spec.name, schema)
def _arguments_model_from_schema(
tool_name: str, schema: dict[str, Any]
) -> type[BaseModel]:
if schema.get("type", "object") != "object": if schema.get("type", "object") != "object":
raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.") raise ExtensionError("PLUGIN_TOOL_SCHEMA_INVALID", "Tool parameters must be an object schema.")
properties = schema.get("properties", {}) model_name = "PluginArgs_" + re.sub(r"\W+", "_", tool_name)
required = set(schema.get("required", [])) # 完整 JSON Schema 已在 ToolRegistry 中先行校验。参数载体不重复声明字段,
fields: dict[str, tuple[Any, Any]] = {} # 从而完整保留 model_dump、连字符键、联合类型和动态属性等合法 JSON 键值。
types = { return create_model(model_name, __config__=ConfigDict(extra="allow"))
"string": str,
"number": float,
"integer": int,
"boolean": bool,
"array": list[Any],
"object": dict[str, Any],
}
for name, field_schema in properties.items():
annotation = types.get(field_schema.get("type"), Any)
fields[name] = (annotation, ... if name in required else None)
model_name = "PluginArgs_" + re.sub(r"\W+", "_", spec.name)
return create_model(model_name, __config__=ConfigDict(extra="forbid"), **fields)
def _validate_tool_schema(spec: DeclarativeToolSpec) -> None: def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
@@ -536,3 +702,30 @@ def _validate_tool_schema(spec: DeclarativeToolSpec) -> None:
"Tool parameters must be an object schema with object properties.", "Tool parameters must be an object schema with object properties.",
details={"tool": spec.name}, details={"tool": spec.name},
) )
def _validate_backend(manifest: PluginManifest) -> None:
backend = manifest.backend
if backend.type == "mcp":
if backend.transport != "stdio":
raise ExtensionError(
"MCP_CAPABILITY_UNSUPPORTED",
"Phase C MCP Plugins must use stdio transport.",
status_code=501,
)
if not backend.command or not backend.command.strip():
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"MCP stdio backend requires a command.",
)
elif backend.command is not None or backend.args:
raise ExtensionError(
"EXTENSION_MANIFEST_INVALID",
"Only MCP stdio backends may declare command or args.",
)
def _safe_extension_message(exc: Exception) -> str:
if isinstance(exc, (ExtensionError, McpBridgeError)):
return exc.message
return f"Plugin Host operation failed: {type(exc).__name__}."
+12
View File
@@ -1,19 +1,31 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.exceptions import HTTPException as StarletteHttpException from starlette.exceptions import HTTPException as StarletteHttpException
from app.config import get_settings from app.config import get_settings
from app.container import container
from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler from app.errors import ApiError, api_error_handler, http_error_handler, validation_error_handler
from app.routes import router as api_router from app.routes import router as api_router
from app.schemas import HealthResponse, ServiceStatusResponse from app.schemas import HealthResponse, ServiceStatusResponse
settings = get_settings() settings = get_settings()
@asynccontextmanager
async def lifespan(_: FastAPI):
yield
# 第三方 MCP Server 必须跟随 AI Core 退出,不能遗留孤儿进程。
container.plugins.shutdown()
app = FastAPI( app = FastAPI(
title=settings.name, title=settings.name,
version=settings.version, version=settings.version,
description="AI 笔记软件的本地 AI Core 与 Agent Core 服务。", description="AI 笔记软件的本地 AI Core 与 Agent Core 服务。",
lifespan=lifespan,
) )
app.add_middleware( app.add_middleware(
+2
View File
@@ -19,6 +19,7 @@ class EmbeddingProvider(Protocol):
"""统一 Embedding 接口(与文档一致)。""" """统一 Embedding 接口(与文档一致)。"""
model_id: str model_id: str
version: str
dim: int dim: int
async def embed_documents(self, texts: list[str]) -> list[list[float]]: ... async def embed_documents(self, texts: list[str]) -> list[list[float]]: ...
@@ -33,6 +34,7 @@ class HashEmbeddingProvider:
""" """
model_id = "hash-v1" model_id = "hash-v1"
version = "1"
dim = EMBEDDING_DIM dim = EMBEDDING_DIM
async def embed_documents(self, texts: list[str]) -> list[list[float]]: async def embed_documents(self, texts: list[str]) -> list[list[float]]:
+39 -22
View File
@@ -29,6 +29,8 @@ from app.textutils import make_snippet, match_query
CANDIDATE_POOL = 50 CANDIDATE_POOL = 50
# 分页窗口上限:候选池至少覆盖 offset+limit,但设上限防止超大 offset 撑爆内存 # 分页窗口上限:候选池至少覆盖 offset+limit,但设上限防止超大 offset 撑爆内存
MAX_CANDIDATE_POOL = 200 MAX_CANDIDATE_POOL = 200
# FTS 全量取回上限:统一归一化 + 阈值过滤后再分页,保证阈值语义跨页一致
FTS_FETCH_LIMIT = 5000
# 带 metadata 过滤时放大召回倍数,缓解「先截断候选池再过滤」造成的漏召回 # 带 metadata 过滤时放大召回倍数,缓解「先截断候选池再过滤」造成的漏召回
OVERSCAN_FACTOR = 4 OVERSCAN_FACTOR = 4
@@ -84,7 +86,7 @@ class RetrievalEngine:
elif request.mode == SearchMode.vector: elif request.mode == SearchMode.vector:
candidate_scores = vec_scores candidate_scores = vec_scores
else: # hybridRRF 融合 else: # hybridRRF 融合
candidate_scores = rrf_fuse([fts_ranked, vec_ranked]) candidate_scores = rrf_fuse([fts_ranked, vec_ranked], k=request.rrf_k)
if not candidate_scores: if not candidate_scores:
return self._empty(request) return self._empty(request)
@@ -97,14 +99,23 @@ class RetrievalEngine:
if not filtered: if not filtered:
return self._empty(request) return self._empty(request)
# 4. 排序 / 精排 # 4. 排序 / 精排:hybrid 先按融合分预排序,再对前 rerank_candidates 个候选做精排,
# 剩余候选按融合分排在精排结果之后;rerank=False 时跳过精排直接按融合分排序。
if request.mode == SearchMode.hybrid: if request.mode == SearchMode.hybrid:
candidates = [ pre_sorted = sorted(filtered, key=lambda h: -candidate_scores[h.block_id])
RankedCandidate(block_id=h.block_id, score=candidate_scores[h.block_id], text=h.content) if request.rerank:
for h in filtered limit = request.rerank_candidates
] pool = pre_sorted if limit is None else pre_sorted[:limit]
ranked = await self.reranker.rerank(request.query, candidates) rest = [] if limit is None else pre_sorted[limit:]
ordered = [(c.block_id, c.score) for c in ranked] candidates = [
RankedCandidate(block_id=h.block_id, score=candidate_scores[h.block_id], text=h.content)
for h in pool
]
ranked = await self.reranker.rerank(request.query, candidates)
ordered = [(c.block_id, c.score) for c in ranked]
ordered += [(h.block_id, candidate_scores[h.block_id]) for h in rest]
else:
ordered = [(h.block_id, candidate_scores[h.block_id]) for h in pre_sorted]
else: else:
ordered = sorted( ordered = sorted(
((h.block_id, candidate_scores[h.block_id]) for h in filtered), ((h.block_id, candidate_scores[h.block_id]) for h in filtered),
@@ -112,6 +123,8 @@ class RetrievalEngine:
) )
ordered = normalize_scores(ordered) ordered = normalize_scores(ordered)
# score_threshold:归一化后过滤低分结果(默认 0 不过滤)
ordered = [(bid, score) for bid, score in ordered if score >= request.score_threshold]
# 5. 分页:total = 过滤后候选集大小。fts 已取全量(≤FTS_FETCH_LIMIT)故为真实命中数; # 5. 分页:total = 过滤后候选集大小。fts 已取全量(≤FTS_FETCH_LIMIT)故为真实命中数;
# vector/hybrid 为 KNN 候选集,无全局 total。 # vector/hybrid 为 KNN 候选集,无全局 total。
@@ -126,15 +139,18 @@ class RetrievalEngine:
) )
def _search_fts(self, request: SearchRequest) -> SearchResponse: def _search_fts(self, request: SearchRequest) -> SearchResponse:
"""FTS 专用路径:过滤、COUNT 与分页全部在 SQLite 中完成。""" """FTS 专用路径:先取全量命中(≤FTS_FETCH_LIMIT),统一归一化 + 阈值过滤后再分页。
阈值过滤必须在计数与分页之前完成,否则 score_threshold 只作用于当前页,
且返回的 total 与 items 数量不一致(如 items 为空但 total 非零)。"""
match = match_query(request.query) match = match_query(request.query)
if not match: if not match:
return self._empty(request) return self._empty(request)
fts_hits, total = repository.fts_search_page( fts_hits, _ = repository.fts_search_page(
match=match, match=match,
limit=request.limit, limit=FTS_FETCH_LIMIT,
offset=request.offset, offset=0,
folders=request.folders, folders=request.folders,
note_ids=request.note_ids, note_ids=request.note_ids,
tags=request.tags, tags=request.tags,
@@ -144,17 +160,18 @@ class RetrievalEngine:
updated_to=request.updated_to, updated_to=request.updated_to,
) )
if not fts_hits: if not fts_hits:
return SearchResponse( return self._empty(request)
query=request.query,
mode=request.mode,
page=PageMeta(total=total, limit=request.limit, offset=request.offset),
)
hits = {h.block_id: h for h in repository.get_block_hits([hit.block_id for hit in fts_hits])} ordered = normalize_scores([(hit.block_id, -hit.bm25) for hit in fts_hits])
ordered = normalize_scores( ordered = [(bid, score) for bid, score in ordered if score >= request.score_threshold]
[(hit.block_id, -hit.bm25) for hit in fts_hits if hit.block_id in hits] total = len(ordered)
) page = ordered[request.offset : request.offset + request.limit]
items = [self._build_result(hits[block_id], request, score) for block_id, score in ordered] hits = {h.block_id: h for h in repository.get_block_hits([bid for bid, _ in page])}
items = [
self._build_result(hits[block_id], request, score)
for block_id, score in page
if block_id in hits
]
return SearchResponse( return SearchResponse(
query=request.query, query=request.query,
mode=request.mode, mode=request.mode,
+2
View File
@@ -24,6 +24,7 @@ class RerankerProvider(Protocol):
"""统一 Reranker 接口:输入候选块,输出按相关性重排后的候选块。""" """统一 Reranker 接口:输入候选块,输出按相关性重排后的候选块。"""
model_id: str model_id: str
version: str
async def rerank(self, query: str, candidates: list[RankedCandidate]) -> list[RankedCandidate]: ... async def rerank(self, query: str, candidates: list[RankedCandidate]) -> list[RankedCandidate]: ...
@@ -32,6 +33,7 @@ class LexicalReranker:
"""轻量精排:query 与块正文的词面重叠度,与归一化后的原始分数加权求和。""" """轻量精排:query 与块正文的词面重叠度,与归一化后的原始分数加权求和。"""
model_id = "lexical-v1" model_id = "lexical-v1"
version = "1"
def __init__(self, lexical_weight: float = 0.5) -> None: def __init__(self, lexical_weight: float = 0.5) -> None:
self.lexical_weight = lexical_weight self.lexical_weight = lexical_weight
+8
View File
@@ -35,6 +35,7 @@ class VectorStore(Protocol):
async def upsert(self, records: list[VectorRecord]) -> None: ... async def upsert(self, records: list[VectorRecord]) -> None: ...
async def delete(self, ids: list[str]) -> None: ... async def delete(self, ids: list[str]) -> None: ...
async def search(self, vector: list[float], *, top_k: int) -> list[VectorHit]: ... async def search(self, vector: list[float], *, top_k: int) -> list[VectorHit]: ...
async def count(self) -> int: ...
class SqliteVecStore: class SqliteVecStore:
@@ -92,3 +93,10 @@ class SqliteVecStore:
conn.execute("DELETE FROM vec_blocks") conn.execute("DELETE FROM vec_blocks")
finally: finally:
conn.close() conn.close()
async def count(self) -> int:
conn = connect()
try:
return conn.execute("SELECT COUNT(*) FROM vec_blocks").fetchone()[0]
finally:
conn.close()
+210 -4
View File
@@ -1,3 +1,4 @@
import asyncio
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
@@ -11,6 +12,14 @@ from app.contracts import (
AgentRunListResponse, AgentRunListResponse,
AgentTraceResponse, AgentTraceResponse,
ChatRequest, ChatRequest,
BenchmarkDatasetListResponse,
BenchmarkEventType,
BenchmarkKind,
BenchmarkReport,
BenchmarkRun,
BenchmarkRunListResponse,
BenchmarkStatus,
RAGRunRequest,
CredentialStatus, CredentialStatus,
CredentialWriteRequest, CredentialWriteRequest,
ExtensionInstallRequest, ExtensionInstallRequest,
@@ -32,6 +41,7 @@ from app.contracts import (
PageMeta, PageMeta,
PermissionDecisionRequest, PermissionDecisionRequest,
Plugin, Plugin,
PluginHostStatus,
PluginListResponse, PluginListResponse,
PluginPermissionGrantRequest, PluginPermissionGrantRequest,
ProviderConfig, ProviderConfig,
@@ -59,6 +69,8 @@ from app.contracts import (
WorkspaceSnapshot, WorkspaceSnapshot,
) )
from app.agent import AgentCapacityError, AgentRunNotFoundError from app.agent import AgentCapacityError, AgentRunNotFoundError
from app.benchmarks import datasets as benchmark_datasets
from app.benchmarks import service as benchmark_service
from app.container import container from app.container import container
from app.errors import ApiError from app.errors import ApiError
from app.extensions import ExtensionError from app.extensions import ExtensionError
@@ -130,6 +142,15 @@ def extension_call(operation):
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
async def extension_call_async(operation):
"""进程启动/关闭可能等待 stdio Host,移出 FastAPI 事件循环。"""
try:
return await asyncio.to_thread(operation)
except ExtensionError as exc:
raise ApiError(exc.status_code, exc.code, exc.message, exc.details) from exc
# Workspace (single configured Vault in Web development mode) # Workspace (single configured Vault in Web development mode)
@router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"]) @router.get("/workspace", response_model=WorkspaceInfo, tags=["Workspace"])
async def get_workspace() -> WorkspaceInfo: async def get_workspace() -> WorkspaceInfo:
@@ -483,7 +504,7 @@ async def install_plugin(request: ExtensionInstallRequest) -> Plugin:
tags=["Plugins"], tags=["Plugins"],
) )
async def enable_plugin(plugin_id: str) -> Plugin: async def enable_plugin(plugin_id: str) -> Plugin:
return extension_call(lambda: container.plugins.enable(plugin_id)) return await extension_call_async(lambda: container.plugins.enable(plugin_id))
@router.post( @router.post(
@@ -492,7 +513,7 @@ async def enable_plugin(plugin_id: str) -> Plugin:
tags=["Plugins"], tags=["Plugins"],
) )
async def disable_plugin(plugin_id: str) -> Plugin: async def disable_plugin(plugin_id: str) -> Plugin:
return extension_call(lambda: container.plugins.disable(plugin_id)) return await extension_call_async(lambda: container.plugins.disable(plugin_id))
@router.put( @router.put(
@@ -503,11 +524,37 @@ async def disable_plugin(plugin_id: str) -> Plugin:
async def set_plugin_permissions( async def set_plugin_permissions(
plugin_id: str, request: PluginPermissionGrantRequest plugin_id: str, request: PluginPermissionGrantRequest
) -> Plugin: ) -> Plugin:
return extension_call( return await extension_call_async(
lambda: container.plugins.set_permissions(plugin_id, request.permissions) lambda: container.plugins.set_permissions(plugin_id, request.permissions)
) )
@router.get(
"/plugins/{plugin_id}/host",
response_model=PluginHostStatus,
tags=["Plugins"],
)
async def get_plugin_host_status(plugin_id: str) -> PluginHostStatus:
return extension_call(lambda: container.plugins.get_host_status(plugin_id))
@router.post(
"/plugins/{plugin_id}/host/restart",
response_model=OperationResponse,
status_code=202,
tags=["Plugins"],
)
async def restart_plugin_host(plugin_id: str) -> OperationResponse:
status = await extension_call_async(
lambda: container.plugins.restart_host(plugin_id)
)
return OperationResponse(
status="accepted",
resource_id=plugin_id,
message=f"Plugin Host status: {status.status.value}",
)
@router.delete( @router.delete(
"/plugins/{plugin_id}", "/plugins/{plugin_id}",
response_model=OperationResponse, response_model=OperationResponse,
@@ -516,7 +563,9 @@ async def set_plugin_permissions(
async def uninstall_plugin(plugin_id: str) -> OperationResponse: async def uninstall_plugin(plugin_id: str) -> OperationResponse:
plugin = extension_call(lambda: container.plugins.get(plugin_id)) plugin = extension_call(lambda: container.plugins.get(plugin_id))
dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools) dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools)
extension_call(lambda: container.plugins.uninstall(plugin_id, dependent_skills)) await extension_call_async(
lambda: container.plugins.uninstall(plugin_id, dependent_skills)
)
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled") return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
@@ -795,3 +844,160 @@ async def get_index_job(job_id: str) -> IndexJob:
if job is None: if job is None:
raise ApiError(404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id}) raise ApiError(404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id})
return job return job
# Benchmark
@router.get(
"/benchmarks/datasets",
response_model=BenchmarkDatasetListResponse,
tags=["Benchmark"],
)
async def list_benchmark_datasets(
kind: BenchmarkKind = Query(default=BenchmarkKind.rag),
) -> BenchmarkDatasetListResponse:
return BenchmarkDatasetListResponse(items=benchmark_datasets.list_datasets(kind))
@router.post(
"/benchmarks/rag/runs",
response_model=BenchmarkRun,
status_code=202,
tags=["Benchmark"],
)
async def create_rag_benchmark(request: RAGRunRequest) -> BenchmarkRun:
return await benchmark_service.create_rag_run(request)
@router.get(
"/benchmarks/runs",
response_model=BenchmarkRunListResponse,
tags=["Benchmark"],
)
async def list_benchmark_runs(
kind: BenchmarkKind | None = Query(default=None),
status: BenchmarkStatus | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> BenchmarkRunListResponse:
items, total = benchmark_service.list_runs(
kind=kind, status=status, limit=limit, offset=offset
)
return BenchmarkRunListResponse(
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
)
@router.get(
"/benchmarks/runs/{run_id}",
response_model=BenchmarkRun,
tags=["Benchmark"],
)
async def get_benchmark_run(run_id: str) -> BenchmarkRun:
run = benchmark_service.get_run(run_id)
if run is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run not found", {"run_id": run_id}
)
return run
@router.post(
"/benchmarks/runs/{run_id}/cancel",
response_model=OperationResponse,
tags=["Benchmark"],
)
async def cancel_benchmark_run(run_id: str) -> OperationResponse:
run = benchmark_service.cancel_run(run_id)
if run is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run not found", {"run_id": run_id}
)
return OperationResponse(
status="accepted",
resource_id=run_id,
message=f"Benchmark run status: {run.status.value}",
)
@router.get(
"/benchmarks/runs/{run_id}/events",
response_class=StreamingResponse,
responses={
200: {
"description": "BenchmarkEvent Server-Sent Events stream",
"content": {"text/event-stream": {}},
}
},
tags=["Benchmark"],
)
async def benchmark_events(
run_id: str,
after_sequence: int = Query(default=-1, ge=-1),
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
) -> StreamingResponse:
if benchmark_service.get_run(run_id) is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark run not found", {"run_id": run_id}
)
# SSE 断线重连:Last-Event-ID 优先于 after_sequence,用于从上次收到的事件继续
cursor = after_sequence
if last_event_id is not None:
try:
cursor = int(last_event_id)
except ValueError as exc:
raise ApiError(
400,
"BENCHMARK_EVENT_CURSOR_INVALID",
"Last-Event-ID must be an integer sequence.",
{"last_event_id": last_event_id},
) from exc
if cursor < -1:
raise ApiError(
400,
"BENCHMARK_EVENT_CURSOR_INVALID",
"Last-Event-ID must be greater than or equal to -1.",
)
async def stream() -> AsyncIterator[str]:
# 先订阅(保证订阅之后产生的事件也能收到),再回放历史事件,最后实时输出新事件
queue = benchmark_service.subscribe(run_id)
last_sequence = cursor
for event in benchmark_service.get_events(run_id):
if event.sequence <= cursor:
continue
yield as_sse(event.event.value, event.model_dump_json(), event_id=event.sequence)
last_sequence = event.sequence
if queue is None:
return
try:
while True:
event = await queue.get()
if event.sequence <= last_sequence:
continue
yield as_sse(event.event.value, event.model_dump_json(), event_id=event.sequence)
last_sequence = event.sequence
if event.event in (
BenchmarkEventType.run_completed,
BenchmarkEventType.run_failed,
BenchmarkEventType.run_cancelled,
):
break
finally:
benchmark_service.unsubscribe(run_id, queue)
return StreamingResponse(stream(), media_type="text/event-stream")
@router.get(
"/benchmarks/runs/{run_id}/report",
response_model=BenchmarkReport,
tags=["Benchmark"],
)
async def get_benchmark_report(run_id: str) -> BenchmarkReport:
report = benchmark_service.get_report(run_id)
if report is None:
raise ApiError(
404, "BENCHMARK_RUN_NOT_FOUND", "benchmark report not found", {"run_id": run_id}
)
return report
+48
View File
@@ -0,0 +1,48 @@
{
"dataset_id": "rag-core-v1",
"kind": "rag",
"version": "1.0.0",
"description": "基础中文笔记检索集(对应 backend/data/vault 内置语料,重建索引后即可复现)",
"cases": [
{
"case_id": "rag-vector-sim",
"query": "向量数据库如何进行相似度检索",
"expected_note_ids": ["note_c1454740a0e55ef5"],
"expected_block_ids": ["blk_07c4c6bce0ec4d12", "blk_605fb3593809f224"],
"citation_required": true,
"tags": ["向量数据库", "检索"]
},
{
"case_id": "rag-python-func",
"query": "Python 如何定义函数",
"expected_note_ids": ["note_424c3742c6f0e555"],
"expected_block_ids": ["blk_45d48cae2fed40fe", "blk_0768d9c25c2ecf07"],
"citation_required": true,
"tags": ["python"]
},
{
"case_id": "rag-citation",
"query": "搜索结果如何定位到原文位置",
"expected_note_ids": ["note_0c619caa30b1614c"],
"expected_block_ids": ["blk_3f6fcead71c25fc6", "blk_9af7b12e9ce909fc"],
"citation_required": true,
"tags": ["RAG"]
},
{
"case_id": "rag-hybrid",
"query": "混合检索怎么融合全文和向量",
"expected_note_ids": ["note_c1454740a0e55ef5"],
"expected_block_ids": ["blk_82b45418dba9f720"],
"citation_required": true,
"tags": ["检索"]
},
{
"case_id": "rag-tech-stack",
"query": "这个项目用什么后端和检索技术",
"expected_note_ids": ["note_3327e6cf18f3701f"],
"expected_block_ids": ["blk_feb2a9c42e7d31ad"],
"citation_required": false,
"tags": ["项目"]
}
]
}
@@ -0,0 +1,21 @@
id: mcp-fixture
name: MCP Fixture
version: 1.0.0
description: 阶段 C 离线联调 Fixture,覆盖 MCP Tool 生命周期与错误边界。
permissions:
- notes.read
contributes:
tools:
- mcp-fixture.echo
- mcp-fixture.fail
- mcp-fixture.sleep
- mcp-fixture.large
- mcp-fixture.environment
- mcp-fixture.exit
backend:
type: mcp
transport: stdio
command: python
args: [server.py]
startup_timeout_seconds: 5
tool_timeout_seconds: 1
@@ -0,0 +1,204 @@
"""确定性的 MCP stdio 测试 Server;仅使用标准库,不依赖产品代码。"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
from typing import Any
WRITE_LOCK = threading.Lock()
CANCELLED: dict[int, threading.Event] = {}
MODE = sys.argv[1] if len(sys.argv) > 1 else "normal"
def send(message: dict[str, Any]) -> None:
with WRITE_LOCK:
sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def respond(request_id: int, result: dict[str, Any]) -> None:
send({"jsonrpc": "2.0", "id": request_id, "result": result})
def tool(name: str, description: str, properties: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": properties or {},
"required": list(properties or {}),
"additionalProperties": False,
},
}
TOOLS = {
"echo": {
**tool(
"echo",
"Return the provided text.",
{
"text": {"type": "string"},
"suffix": {"type": ["string", "null"]},
},
),
"_meta": {"notesagent/permission": "notes.read"},
},
"fail": tool("fail", "Return an MCP business error."),
"sleep": tool("sleep", "Wait until completed or cancelled.", {"seconds": {"type": "number"}}),
"large": tool("large", "Return a result larger than the host limit."),
"environment": tool("environment", "Report whether host secrets leaked into the process."),
"exit": tool("exit", "Terminate the fixture process."),
}
# suffix 是可选字段,用于验证 Host 不会把缺省值擅自补成 null。
TOOLS["echo"]["inputSchema"]["required"] = ["text"]
def call_tool(request_id: int, params: dict[str, Any]) -> None:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "echo":
text = str(arguments.get("text", ""))
structured_content = {"echo": text}
if "suffix" in arguments:
structured_content["suffix"] = arguments["suffix"]
respond(
request_id,
{
"content": [{"type": "text", "text": text}],
"structuredContent": structured_content,
"isError": False,
},
)
return
if name == "fail":
respond(
request_id,
{
"content": [{"type": "text", "text": "fixture failure"}],
"isError": True,
},
)
return
if name == "large":
respond(
request_id,
{
"content": [{"type": "text", "text": "x" * 300_000}],
"isError": False,
},
)
return
if name == "environment":
respond(
request_id,
{
"content": [{"type": "text", "text": "environment checked"}],
"structuredContent": {
"has_openai_key": "OPENAI_API_KEY" in os.environ,
"has_app_db_path": "APP_DB_PATH" in os.environ,
},
"isError": False,
},
)
return
if name == "exit":
os._exit(17)
if name == "sleep":
cancelled = CANCELLED.setdefault(request_id, threading.Event())
seconds = max(0.0, min(float(arguments.get("seconds", 0)), 30.0))
if cancelled.wait(seconds):
respond(
request_id,
{
"content": [{"type": "text", "text": "cancelled"}],
"isError": True,
},
)
else:
respond(
request_id,
{
"content": [{"type": "text", "text": "completed"}],
"structuredContent": {"slept": seconds},
"isError": False,
},
)
CANCELLED.pop(request_id, None)
return
send(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": -32602, "message": f"Unknown tool: {name}"},
}
)
def main() -> None:
for line in sys.stdin:
message = json.loads(line)
method = message.get("method")
request_id = message.get("id")
params = message.get("params") or {}
if method == "initialize" and isinstance(request_id, int):
if MODE == "invalid-result":
send({"jsonrpc": "2.0", "id": request_id, "result": None})
continue
if MODE == "oversized-stdout":
# 不带换行,验证 Host 在读取完整内容前执行硬上限。
sys.stdout.write("x" * (2 * 1024 * 1024 + 1))
sys.stdout.flush()
time.sleep(10)
return
respond(
request_id,
{
"protocolVersion": params.get("protocolVersion"),
"capabilities": (
{} if MODE == "no-tools" else {"tools": {"listChanged": False}}
),
"serverInfo": {"name": "notesagent-mcp-fixture", "version": "1.0.0"},
},
)
elif method == "tools/list" and isinstance(request_id, int):
if MODE == "invalid-schema":
respond(
request_id,
{
"tools": [
{
"name": "broken",
"description": "invalid schema",
"inputSchema": {"type": "string"},
}
]
},
)
elif params.get("cursor") == "page-2":
respond(
request_id,
{"tools": [TOOLS["large"], TOOLS["environment"], TOOLS["exit"]]},
)
else:
respond(
request_id,
{"tools": [TOOLS["echo"], TOOLS["fail"], TOOLS["sleep"]], "nextCursor": "page-2"},
)
elif method == "tools/call" and isinstance(request_id, int):
threading.Thread(target=call_tool, args=(request_id, params), daemon=True).start()
elif method == "notifications/cancelled":
cancelled_id = params.get("requestId")
if isinstance(cancelled_id, int):
CANCELLED.setdefault(cancelled_id, threading.Event()).set()
elif method == "ping" and isinstance(request_id, int):
respond(request_id, {})
if __name__ == "__main__":
main()
+2
View File
@@ -93,6 +93,8 @@ def test_openapi_contains_documented_frontend_interfaces() -> None:
"/api/skills", "/api/skills",
"/api/plugins", "/api/plugins",
"/api/plugins/install", "/api/plugins/install",
"/api/plugins/{plugin_id}/host",
"/api/plugins/{plugin_id}/host/restart",
"/api/plugins/{plugin_id}/enable", "/api/plugins/{plugin_id}/enable",
"/api/plugins/{plugin_id}/disable", "/api/plugins/{plugin_id}/disable",
"/api/providers/test", "/api/providers/test",
+441
View File
@@ -0,0 +1,441 @@
"""Benchmark 服务的单元与端到端测试。
沿用 conftest 的隔离机制APP_DATA_DIR / DB / Vault 都指向临时目录benchmark
数据集也落在临时目录settings.benchmark_datasets_path不读写真实数据
运行采用创建即 queued + 后台 Task 执行的异步模型测试通过 _run 在同一事件循环内
创建并等待后台任务结束得到终态 BenchmarkRun 后再断言
"""
from __future__ import annotations
import asyncio
import json
import pytest
from pydantic import ValidationError
from app.benchmarks import datasets, metrics as m, service
from app.config import get_settings
from app.contracts import (
BenchmarkKind,
BenchmarkRun,
BenchmarkStatus,
RAGRunRequest,
SearchMode,
)
from app.errors import ApiError
def _write_dataset(dataset_id: str, cases: list[dict], *, kind: str = "rag") -> None:
directory = get_settings().benchmark_datasets_path
directory.mkdir(parents=True, exist_ok=True)
payload = {
"dataset_id": dataset_id,
"kind": kind,
"version": "1.0.0",
"description": "test dataset",
"cases": cases,
}
(directory / f"{dataset_id}.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
def _write_raw(dataset_id: str, raw: dict) -> None:
directory = get_settings().benchmark_datasets_path
directory.mkdir(parents=True, exist_ok=True)
(directory / f"{dataset_id}.json").write_text(
json.dumps(raw, ensure_ascii=False), encoding="utf-8"
)
def _run(request: RAGRunRequest):
"""创建运行并在同一事件循环内等待后台任务结束,返回终态 BenchmarkRun。"""
from app.contracts import BenchmarkRun
async def _execute() -> BenchmarkRun:
run = await service.create_rag_run(request)
return await service.wait_for_run(run.run_id)
return asyncio.run(_execute())
# --------------------------------------------------------------------------- #
# 指标纯函数
# --------------------------------------------------------------------------- #
def test_hit_at_k_and_recall() -> None:
retrieved = ["a", "b", "c"]
expected = {"b", "z"}
assert m.hit_at_k(retrieved, expected, 1) is False
assert m.hit_at_k(retrieved, expected, 2) is True
assert m.recall_at_k(retrieved, expected, 5) == 0.5 # 只召回 b
def test_recall_at_k_dedups_duplicate_notes() -> None:
# 同一 Note 经多个 Block 重复出现,去重后 Recall 不应超过 1
assert m.recall_at_k(["note-a", "note-a"], {"note-a"}, 2) == 1.0
assert m.recall_at_k(["note-a", "note-a", "note-b"], {"note-a"}, 3) == 1.0
def test_reciprocal_rank_and_citation_hit() -> None:
assert m.reciprocal_rank(["x", "a", "b"], {"b"}) == 1 / 3
assert m.reciprocal_rank(["x"], {"b"}) == 0.0
assert m.citation_hit(["blk_1"], {"blk_1"}) is True
assert m.citation_hit(["blk_2"], {"blk_1"}) is False
assert m.citation_hit([], {"blk_1"}) is False
def test_percentile() -> None:
assert m.percentile([1.0, 2.0, 3.0, 4.0], 50.0) == 2.5
assert m.percentile([], 50.0) == 0.0
assert m.percentile([7.0], 95.0) == 7.0
# --------------------------------------------------------------------------- #
# Dataset 注册与校验
# --------------------------------------------------------------------------- #
def test_list_datasets_empty_by_default() -> None:
assert datasets.list_datasets(BenchmarkKind.rag) == []
def test_load_missing_dataset_raises() -> None:
with pytest.raises(ApiError) as exc:
datasets.load_dataset("does-not-exist", BenchmarkKind.rag)
assert exc.value.status_code == 404
assert exc.value.code == "BENCHMARK_DATASET_NOT_FOUND"
def test_dataset_without_expected_ids_is_invalid() -> None:
_write_dataset("bad-v1", [{"case_id": "x", "query": "q", "citation_required": False}])
with pytest.raises(ApiError) as exc:
datasets.load_dataset("bad-v1", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
def test_dataset_kind_mismatch_is_invalid() -> None:
_write_dataset("agent-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}], kind="agent")
with pytest.raises(ApiError) as exc:
datasets.load_dataset("agent-v1", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
def test_citation_required_requires_expected_block_ids() -> None:
# citation_required=true 却没有 expected_block_ids,无法计算 Citation Hit Rate,应拒绝
_write_dataset(
"cit-req-v1",
[{"case_id": "x", "query": "q", "expected_note_ids": ["n"], "citation_required": True}],
)
with pytest.raises(ApiError) as exc:
datasets.load_dataset("cit-req-v1", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
def test_list_datasets_skips_corrupted_structure() -> None:
# 合法 JSON 但字段结构错误(cases: 42),列表接口应隔离该文件而非整体 500
_write_raw("bad-structure", {"dataset_id": "bad-structure", "kind": "rag", "cases": 42})
_write_dataset("good-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}])
infos = datasets.list_datasets(BenchmarkKind.rag)
ids = {info.dataset_id for info in infos}
assert "good-v1" in ids
assert "bad-structure" not in ids
# --------------------------------------------------------------------------- #
# 请求校验(空 / 重复 modes)
# --------------------------------------------------------------------------- #
def test_empty_modes_rejected() -> None:
with pytest.raises(ValidationError):
RAGRunRequest(dataset_id="x", modes=[])
def test_duplicate_modes_rejected() -> None:
with pytest.raises(ValidationError):
RAGRunRequest(dataset_id="x", modes=[SearchMode.fts, SearchMode.fts])
# --------------------------------------------------------------------------- #
# RAG Benchmark 端到端
# --------------------------------------------------------------------------- #
def _single_note_case() -> tuple[str, str, dict]:
from app.services import note_service
note = asyncio.run(
note_service.create_note(
title="向量库",
markdown="向量数据库用于存储高维向量并支持近似最近邻检索。",
folder="",
tags=["向量"],
)
)
case = {
"case_id": "c1",
"query": "向量数据库相似度检索",
"expected_note_ids": [note.note_id],
"expected_block_ids": [note.blocks[0].block_id],
"citation_required": True,
"tags": ["向量"],
}
return note.note_id, note.blocks[0].block_id, case
def test_rag_benchmark_end_to_end() -> None:
_, _, case = _single_note_case()
_write_dataset("e2e-v1", [case])
run = _run(RAGRunRequest(dataset_id="e2e-v1", modes=[SearchMode.fts]))
assert run.status.value == "completed"
assert run.dataset_hash.startswith("sha256:")
assert run.metrics is not None
fts = run.metrics["fts"]
assert fts["hit_at_1"] == 1.0
assert fts["recall_at_k"] == 1.0
assert fts["mrr"] == 1.0
assert fts["citation_hit_rate"] == 1.0
assert fts["p50_latency_ms"] >= 0.0
assert fts["p95_latency_ms"] >= fts["p50_latency_ms"]
def test_rag_benchmark_all_modes_produce_metrics() -> None:
_, _, case = _single_note_case()
_write_dataset("e2e-modes-v1", [case])
run = _run(RAGRunRequest(dataset_id="e2e-modes-v1"))
assert run.status.value == "completed"
for mode in ("fts", "vector", "hybrid"):
assert mode in run.metrics
for key in ("hit_at_1", "hit_at_5", "recall_at_k", "mrr", "citation_hit_rate"):
assert 0.0 <= run.metrics[mode][key] <= 1.0
def test_config_snapshot_records_index_and_models() -> None:
_, _, case = _single_note_case()
_write_dataset("snapshot-v1", [case])
run = _run(RAGRunRequest(dataset_id="snapshot-v1", modes=[SearchMode.fts]))
snapshot = run.config_snapshot
assert snapshot["index_meta"] is not None
assert snapshot["embedding"]["version"]
assert snapshot["embedding"]["dim"]
assert snapshot["reranker"]["version"]
assert snapshot["retrieval"]["rrf_k"] == 60
def test_benchmark_report_and_events() -> None:
_, _, case = _single_note_case()
_write_dataset("report-v1", [case])
run = _run(RAGRunRequest(dataset_id="report-v1", modes=[SearchMode.fts]))
report = service.get_report(run.run_id)
events = service.get_events(run.run_id)
assert report is not None
assert report.run_id == run.run_id
assert len(report.cases) == 1
assert report.cases[0].case_id == "c1"
assert report.cases[0].hit_at_1 is True
assert events, "运行应产生事件"
assert events[0].event.value == "RunStarted"
assert events[-1].event.value == "RunCompleted"
def test_cancel_completed_run_keeps_status() -> None:
_, _, case = _single_note_case()
_write_dataset("cancel-v1", [case])
run = _run(RAGRunRequest(dataset_id="cancel-v1", modes=[SearchMode.fts]))
assert run.status.value == "completed"
cancelled = service.cancel_run(run.run_id)
assert cancelled.status.value == "completed" # 已结束,不再变 cancelled
def test_cancel_queued_run_marks_cancelled() -> None:
_, _, case = _single_note_case()
_write_dataset("cancel-queued-v1", [case])
async def _scenario():
run = await service.create_rag_run(
RAGRunRequest(dataset_id="cancel-queued-v1", modes=[SearchMode.fts])
)
service.cancel_run(run.run_id)
return await service.wait_for_run(run.run_id)
run = asyncio.run(_scenario())
assert run.status.value == "cancelled"
# --------------------------------------------------------------------------- #
# 指标聚合:Citation Hit Rate 只统计 citation_required 样本
# --------------------------------------------------------------------------- #
def test_citation_hit_rate_only_counts_citation_required() -> None:
from app.benchmarks import rag as rag_module
from app.contracts import RAGCaseResult
cases = [
RAGCaseResult(
case_id="a", mode=SearchMode.fts, repeat=0, latency_ms=1.0,
citation_hit=True, citation_applicable=True,
),
RAGCaseResult(
case_id="b", mode=SearchMode.fts, repeat=0, latency_ms=1.0,
citation_hit=False, citation_applicable=False,
),
]
metrics = rag_module._aggregate(cases, SearchMode.fts)
# 只有 citation_applicablecitation_required=true)的样本计入分母
assert metrics.citation_hit_rate == 1.0
# --------------------------------------------------------------------------- #
# 路由接入
# --------------------------------------------------------------------------- #
def test_benchmark_routes_wired() -> None:
from app import routes
_, _, case = _single_note_case()
_write_dataset("route-v1", [case])
async def _scenario():
listed = await routes.list_benchmark_datasets(BenchmarkKind.rag)
assert any(item.dataset_id == "route-v1" for item in listed.items)
run = await routes.create_rag_benchmark(
RAGRunRequest(dataset_id="route-v1", modes=[SearchMode.fts])
)
assert run.status.value == "queued"
return await service.wait_for_run(run.run_id)
run = asyncio.run(_scenario())
assert run.status.value == "completed"
got = asyncio.run(routes.get_benchmark_run(run.run_id))
assert got.run_id == run.run_id
report = asyncio.run(routes.get_benchmark_report(run.run_id))
assert report.cases[0].case_id == "c1"
def test_benchmark_run_not_found_raises() -> None:
from app import routes
with pytest.raises(ApiError) as exc:
asyncio.run(routes.get_benchmark_run("benchmark_missing"))
assert exc.value.code == "BENCHMARK_RUN_NOT_FOUND"
# --------------------------------------------------------------------------- #
# 审阅回归:索引兼容 / 容量 / 失败样本 / 取消事件 / 数据集隔离
# --------------------------------------------------------------------------- #
def test_create_rag_run_requires_built_index() -> None:
# 空索引(无已索引 block)会让所有模式得到全 0 指标,应在创建时拒绝而非跑出误导结果
_write_dataset("empty-index-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}])
with pytest.raises(ApiError) as exc:
asyncio.run(
service.create_rag_run(
RAGRunRequest(dataset_id="empty-index-v1", modes=[SearchMode.fts])
)
)
assert exc.value.status_code == 409
assert exc.value.code == "BENCHMARK_INDEX_INCOMPATIBLE"
def test_capacity_exceeded_when_all_runs_active(monkeypatch) -> None:
# 满容量且全为活动(非终态)run 时,无法淘汰,应拒绝创建而非删掉正在运行的 run
_, _, case = _single_note_case()
_write_dataset("capacity-v1", [case])
monkeypatch.setattr(service, "MAX_RUNS", 1)
fake_id = "benchmark_fake_active"
service._runs[fake_id] = BenchmarkRun(
run_id=fake_id,
kind=BenchmarkKind.rag,
dataset_id="capacity-v1",
dataset_hash="sha256:fake",
status=BenchmarkStatus.queued,
created_at=service._now(),
)
try:
with pytest.raises(ApiError) as exc:
asyncio.run(
service.create_rag_run(
RAGRunRequest(dataset_id="capacity-v1", modes=[SearchMode.fts])
)
)
assert exc.value.status_code == 429
assert exc.value.code == "BENCHMARK_CAPACITY_EXCEEDED"
finally:
service._runs.pop(fake_id, None)
def test_failed_samples_counted_as_zero_in_aggregate() -> None:
from app.benchmarks import rag as rag_module
from app.contracts import RAGCaseResult
cases = [
RAGCaseResult(
case_id="ok", mode=SearchMode.fts, repeat=0, latency_ms=10.0,
hit_at_1=True, recall=1.0, reciprocal_rank=1.0,
citation_hit=True, citation_applicable=True,
),
RAGCaseResult(
case_id="boom", mode=SearchMode.fts, repeat=0, latency_ms=0.0,
error="RAG case evaluation failed.",
error_code="BENCHMARK_CASE_EVALUATION_FAILED",
),
]
metrics = rag_module._aggregate(cases, SearchMode.fts)
assert metrics.total_cases == 2
assert metrics.successful_cases == 1
assert metrics.failed_cases == 1
assert metrics.failure_rate == 0.5
# 失败样本按零分计入质量指标分母,汇总不虚高
assert metrics.hit_at_1 == 0.5
assert metrics.recall_at_k == 0.5
# 延迟只统计成功样本
assert metrics.p50_latency_ms == 10.0
def test_cancel_emits_run_cancelled_event() -> None:
_, _, case = _single_note_case()
_write_dataset("cancel-event-v1", [case])
async def _scenario():
run = await service.create_rag_run(
RAGRunRequest(dataset_id="cancel-event-v1", modes=[SearchMode.fts])
)
service.cancel_run(run.run_id)
return await service.wait_for_run(run.run_id)
run = asyncio.run(_scenario())
assert run.status.value == "cancelled"
events = service.get_events(run.run_id)
assert events[-1].event.value == "RunCancelled"
def test_load_dataset_ignores_corrupted_unrelated_files() -> None:
# 无关文件损坏(非法 JSON / 顶层非对象)不应阻断目标数据集加载
directory = get_settings().benchmark_datasets_path
directory.mkdir(parents=True, exist_ok=True)
(directory / "broken.json").write_text("{ not valid json", encoding="utf-8")
(directory / "array.json").write_text('["a", "b"]', encoding="utf-8")
_write_dataset("ok-v1", [{"case_id": "x", "query": "q", "expected_note_ids": ["n"]}])
dataset = datasets.load_dataset("ok-v1", BenchmarkKind.rag)
assert dataset.dataset_id == "ok-v1"
assert len(dataset.cases) == 1
def test_load_dataset_top_level_must_be_object() -> None:
_write_raw("array-top", ["a", "b"])
with pytest.raises(ApiError) as exc:
datasets.load_dataset("array-top", BenchmarkKind.rag)
assert exc.value.code == "BENCHMARK_DATASET_INVALID"
+318 -1
View File
@@ -1,4 +1,7 @@
import asyncio import asyncio
import shutil
import threading
import time
import pytest import pytest
@@ -12,14 +15,31 @@ from app.contracts import (
ToolCall, ToolCall,
) )
from app.extensions import ExtensionError from app.extensions import ExtensionError
from app.extensions.mcp import McpStdioClient
from app.extensions.runtime import _arguments_model_from_schema
from app.services import note_service from app.services import note_service
from app.config import get_settings from app.config import BACKEND_DIR, get_settings
MCP_FIXTURE = BACKEND_DIR / "extensions" / "fixtures" / "mcp-echo"
def run(coroutine): def run(coroutine):
return asyncio.run(coroutine) return asyncio.run(coroutine)
@pytest.fixture
def mcp_container():
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
try:
yield container
finally:
container.plugins.shutdown()
def test_bundled_plugin_registers_tool_and_skill_is_ready() -> None: def test_bundled_plugin_registers_tool_and_skill_is_ready() -> None:
async def scenario() -> None: async def scenario() -> None:
container = build_container() container = build_container()
@@ -297,3 +317,300 @@ def test_attachment_and_transcription_tools_use_host_storage() -> None:
assert transcription.output["text"] == "会议转写内容" assert transcription.output["text"] == "会议转写内容"
run(scenario()) run(scenario())
def test_mcp_stdio_host_discovers_namespaced_tools_and_maps_results(
mcp_container, monkeypatch
) -> None:
async def scenario() -> None:
monkeypatch.setenv("OPENAI_API_KEY", "must-not-enter-plugin-host")
enabled = mcp_container.plugins.enable("mcp-fixture")
status = mcp_container.plugins.get_host_status("mcp-fixture")
definition = mcp_container.tools.get("mcp-fixture.echo").definition
result = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_mcp_echo",
name="mcp-fixture.echo",
arguments={"text": "hello mcp"},
),
ToolExecutionContext(
run_id="run_mcp_fixture", tool_call_id="call_mcp_echo"
),
)
assert enabled.status == "ready" and enabled.enabled is True
assert status.status == "ready"
environment = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_mcp_environment",
name="mcp-fixture.environment",
arguments={},
),
ToolExecutionContext(run_id="run_mcp_fixture"),
)
assert status.tools_count == 6
assert status.protocol_version == "2025-11-25"
assert status.server_name == "notesagent-mcp-fixture"
assert definition.permission == "notes.read"
assert result.success is True
assert result.output == {"echo": "hello mcp"}
explicit_null = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_mcp_explicit_null",
name="mcp-fixture.echo",
arguments={"text": "null stays explicit", "suffix": None},
),
ToolExecutionContext(run_id="run_mcp_fixture"),
)
assert explicit_null.success is True
assert explicit_null.output == {
"echo": "null stays explicit",
"suffix": None,
}
assert environment.success is True
assert environment.output == {
"has_openai_key": False,
"has_app_db_path": False,
}
disabled = mcp_container.plugins.disable("mcp-fixture")
assert disabled.status == "disabled"
assert mcp_container.plugins.get_host_status("mcp-fixture").status == "stopped"
assert not mcp_container.tools.contains("mcp-fixture.echo")
with pytest.raises(ExtensionError) as exc:
mcp_container.plugins.restart_host("mcp-fixture")
assert exc.value.code == "PLUGIN_HOST_UNAVAILABLE"
assert mcp_container.plugins.get("mcp-fixture").status == "disabled"
assert not mcp_container.tools.contains("mcp-fixture.echo")
mcp_container.plugins.uninstall("mcp-fixture")
reinstalled = mcp_container.plugins.install(MCP_FIXTURE)
fresh_status = mcp_container.plugins.get_host_status("mcp-fixture")
assert reinstalled.status == "permission_required"
assert fresh_status.status == "stopped"
assert fresh_status.started_at is None
assert fresh_status.protocol_version is None
assert fresh_status.server_name is None
run(scenario())
def test_agent_calls_mcp_tool_through_registry_and_writes_trace(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
created = await mcp_container.agent.create_run(
AgentRunCreateRequest(
input='/tool mcp-fixture.echo {"text":"agent mcp"}',
provider_id="mock",
model="mock-1",
allowed_tools=["mcp-fixture.echo"],
)
)
completed = await mcp_container.agent.wait(created.run_id)
trace = mcp_container.agent.get_trace(
created.run_id, after_sequence=-1, limit=100
)
assert completed.status == AgentRunStatus.completed
assert completed.tool_results[0].success is True
assert completed.tool_results[0].output == {"echo": "agent mcp"}
assert any(
item.event == "ToolCall" and item.data.get("name") == "mcp-fixture.echo"
for item in trace.items
)
run(scenario())
def test_mcp_business_error_size_limit_and_timeout_are_structured(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
context = ToolExecutionContext(run_id="run_mcp_errors")
failed = await mcp_container.tools.execute(
ToolCall(tool_call_id="call_fail", name="mcp-fixture.fail", arguments={}),
context,
)
oversized = await mcp_container.tools.execute(
ToolCall(tool_call_id="call_large", name="mcp-fixture.large", arguments={}),
context,
)
timed_out = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_sleep",
name="mcp-fixture.sleep",
arguments={"seconds": 5},
),
ToolExecutionContext(
run_id="run_mcp_errors", tool_call_id="call_sleep"
),
)
recovered = await mcp_container.tools.execute(
ToolCall(
tool_call_id="call_after_timeout",
name="mcp-fixture.echo",
arguments={"text": "still ready"},
),
context,
)
assert failed.success is False
assert failed.error_code == "MCP_TOOL_CALL_FAILED"
assert failed.error_message == "fixture failure"
assert oversized.success is False
assert oversized.error_code == "MCP_TOOL_RESULT_TOO_LARGE"
assert timed_out.success is False
assert timed_out.error_code == "MCP_TOOL_CALL_FAILED"
assert recovered.success is True
assert mcp_container.plugins.get_host_status("mcp-fixture").status == "ready"
run(scenario())
def test_mcp_cancel_releases_blocking_response_thread(
mcp_container, monkeypatch
) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
released = threading.Event()
original_wait = McpStdioClient.wait_response
def tracked_wait(self, *args, **kwargs):
try:
return original_wait(self, *args, **kwargs)
finally:
released.set()
monkeypatch.setattr(McpStdioClient, "wait_response", tracked_wait)
task = asyncio.create_task(
mcp_container.plugins.mcp.call_tool(
"mcp-fixture",
"sleep",
{"seconds": 5},
request_id="call_cancel_release",
)
)
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
deadline = time.monotonic() + 0.5
while not released.is_set() and time.monotonic() < deadline:
await asyncio.sleep(0.01)
assert released.is_set(), "cancelled MCP wait must not occupy a worker until timeout"
run(scenario())
def test_mcp_argument_model_preserves_json_schema_additional_properties() -> None:
arguments_model = _arguments_model_from_schema(
"mcp-fixture.dynamic",
{
"type": "object",
"properties": {"model_dump": {"type": "string"}},
"required": ["model_dump"],
"additionalProperties": {"type": "string"},
},
)
arguments = arguments_model.model_validate(
{"model_dump": "method name remains data", "dynamic-key": "value"}
)
assert arguments.model_dump() == {
"model_dump": "method name remains data",
"dynamic-key": "value",
}
def test_production_rejects_unsandboxed_mcp_host(monkeypatch) -> None:
monkeypatch.setenv("APP_ENVIRONMENT", "production")
get_settings.cache_clear()
container = build_container()
installed = container.plugins.install(MCP_FIXTURE)
assert installed.status == "permission_required"
container.plugins.set_permissions("mcp-fixture", ["notes.read"])
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-fixture")
assert exc.value.code == "MCP_TRUST_APPROVAL_REQUIRED"
assert container.plugins.get_host_status("mcp-fixture").status == "stopped"
assert not container.tools.contains("mcp-fixture.echo")
finally:
container.plugins.shutdown()
get_settings.cache_clear()
def test_mcp_abnormal_exit_unregisters_tools_and_restart_recovers(mcp_container) -> None:
async def scenario() -> None:
mcp_container.plugins.enable("mcp-fixture")
crashed = await mcp_container.tools.execute(
ToolCall(tool_call_id="call_exit", name="mcp-fixture.exit", arguments={}),
ToolExecutionContext(run_id="run_mcp_exit", tool_call_id="call_exit"),
)
deadline = time.monotonic() + 2
while mcp_container.tools.contains("mcp-fixture.echo") and time.monotonic() < deadline:
await asyncio.sleep(0.02)
plugin = mcp_container.plugins.get("mcp-fixture")
status = mcp_container.plugins.get_host_status("mcp-fixture")
assert crashed.success is False
assert crashed.error_code == "PLUGIN_HOST_UNAVAILABLE"
assert plugin.status == "error" and plugin.enabled is False
assert status.status == "unhealthy"
assert not mcp_container.tools.contains("mcp-fixture.echo")
restarted = mcp_container.plugins.restart_host("mcp-fixture")
assert restarted.status == "ready"
assert restarted.tools_count == 6
assert mcp_container.tools.contains("mcp-fixture.echo")
run(scenario())
@pytest.mark.parametrize(
("mode", "contributions", "expected_code"),
[
("no-tools", "[]", "MCP_CAPABILITY_UNSUPPORTED"),
("invalid-schema", "[mcp-invalid.broken]", "MCP_TOOL_SCHEMA_INVALID"),
("invalid-result", "[]", "MCP_INITIALIZE_FAILED"),
("oversized-stdout", "[]", "PLUGIN_HOST_UNAVAILABLE"),
],
)
def test_mcp_rejects_invalid_initialization_and_discovery(
tmp_path, mode, contributions, expected_code
) -> None:
package = tmp_path / f"mcp-{mode}"
package.mkdir()
shutil.copyfile(MCP_FIXTURE / "server.py", package / "server.py")
(package / "plugin.yaml").write_text(
f"""
id: mcp-invalid
name: Invalid MCP Fixture
version: 1.0.0
contributes:
tools: {contributions}
backend:
type: mcp
transport: stdio
command: python
args: [server.py, {mode}]
startup_timeout_seconds: 5
tool_timeout_seconds: 1
""".strip(),
encoding="utf-8",
)
container = build_container()
container.plugins.install(package)
try:
with pytest.raises(ExtensionError) as exc:
container.plugins.enable("mcp-invalid")
assert exc.value.code == expected_code
assert container.plugins.get("mcp-invalid").status == "error"
assert container.plugins.get_host_status("mcp-invalid").status == "error"
assert not container.tools.contains("mcp-invalid.broken")
finally:
container.plugins.shutdown()
+38
View File
@@ -436,6 +436,44 @@ def test_fts_pagination_is_not_truncated_at_one_thousand(vault) -> None:
assert len(response.items) == 10 assert len(response.items) == 10
def test_fts_score_threshold_filters_before_total(vault) -> None:
"""score_threshold 先于计数与分页生效:total 反映过滤后数量,与 items 一致。
高阈值过滤掉全部结果时 total==0 items 为空杜绝空页但 total>0
不一致审阅 P2-7
"""
from app.retrieval.engine import engine
from app.services import note_service
# 10 个 block,含「目标」次数递增,bm25 分数各异,min-max 归一化后分数落在 [0,1]
markdown = "\n\n".join(f"{'目标' * i} 分隔内容" for i in range(1, 11))
asyncio.run(
note_service.create_note(title="阈值过滤", markdown=markdown, folder="", tags=[])
)
all_hits = asyncio.run(
engine.search(
SearchRequest(query="目标", mode=SearchMode.fts, limit=20, score_threshold=0.0)
)
)
filtered = asyncio.run(
engine.search(
SearchRequest(query="目标", mode=SearchMode.fts, limit=20, score_threshold=0.5)
)
)
none = asyncio.run(
engine.search(
SearchRequest(query="目标", mode=SearchMode.fts, limit=20, score_threshold=2.0)
)
)
assert all_hits.page.total >= 10
assert 0 < filtered.page.total < all_hits.page.total # 阈值过滤掉部分而非全部
assert filtered.page.total == len(filtered.items)
assert none.page.total == 0
assert none.items == []
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 审阅回归:PATCH tags 语义 / 向量-块一致性 / 过滤漏召回 / rebuild 语义与回滚 # 审阅回归:PATCH tags 语义 / 向量-块一致性 / 过滤漏召回 / rebuild 语义与回滚
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+71
View File
@@ -0,0 +1,71 @@
# NotesAgent 文档索引
本目录集中保存团队开发期间需要长期维护的架构、接口、实现、协作和问题复盘文档。文档按用途分类,避免设计约束、开发记录与故障复盘混放。
## 目录分类
| 目录 | 内容 | 适用场景 |
| --- | --- | --- |
| [`architecture/`](architecture/) | 技术栈、阶段目标与团队分工 | 确认整体边界、模块依赖和阶段范围 |
| [`contracts/`](contracts/) | 前后端接口契约与页面需求 | 开发前对齐 DTO、路由、事件和交互 |
| [`development/`](development/) | 各模块的实现说明 | 阅读现有代码、联调和扩展功能 |
| [`guides/`](guides/) | Git、测试、注释和 CI/CD 规范 | 日常开发、提交、审阅和发布 |
| [`retrospectives/`](retrospectives/) | 审阅发现的问题与修复复盘 | 排查同类问题、撰写总结或博客 |
## architecture:架构与分工
- [AI 笔记软件技术栈说明](architecture/AI笔记软件技术栈说明-团队版-v2.3.md)
- [第一阶段分工表](architecture/第一阶段分工表.md)
- [第二阶段团队分工表](architecture/第二阶段团队分工表.md)
## contracts:契约与需求
- [后端接口契约](contracts/后端接口契约-开发版.md)
- [第二阶段接口契约](contracts/第二阶段接口契约-开发版.md)
- [前端页面需求说明](contracts/前端页面需求说明-开发版.md)
运行中的后端以 `/openapi.json` 为机器可读事实来源。接口契约用于描述设计意图、联调约束和实现状态;两者不一致时,应先确认代码行为,再在同一个 PR 中同步修正文档或实现。
## development:开发说明
- [AI Core 与 Agent Core 开发说明](development/AI-Core与Agent-Core开发说明.md)
- [Knowledge 与 Retrieval Core 开发说明](development/Knowledge与Retrieval-Core开发说明.md)
- [Benchmark 开发说明](development/Benchmark开发说明.md)
- [模型提供商与模型发现开发说明](development/模型提供商与模型发现开发说明.md)
- [MCP Bridge 与 Plugin Host 开发说明](development/MCP-Bridge与Plugin-Host开发说明.md)
- [前端壳子与接口层开发说明](development/前端壳子与接口层开发说明.md)
- [前端写作体验优化开发说明](development/前端写作体验优化开发说明.md)
- [前端视觉与轻量动效优化开发说明](development/前端视觉与轻量动效优化开发说明.md)
## guides:团队协作规范
- [Git 使用细则](guides/Git使用细则-团队开发版.md)
- [CI/CD 细则](guides/CI-CD细则-团队开发版.md)
- [代码注释与 TODO 约定](guides/代码注释与TODO约定.md)
- [第一阶段测试验证操作手册](guides/第一阶段测试验证操作手册.md)
## retrospectives:问题与修复复盘
- [后端全面审阅问题与修复复盘](retrospectives/后端全面审阅问题与修复复盘.md)
- [Agent Core 第二阶段问题与修复复盘](retrospectives/Agent-Core第二阶段问题与修复复盘.md)
- [Knowledge 与 Retrieval Core 问题与修复复盘](retrospectives/Knowledge与Retrieval-Core问题与修复复盘.md)
- [前端合并审阅问题与修复复盘](retrospectives/前端合并审阅问题与修复复盘.md)
## 推荐阅读顺序
新成员或新阶段开始时,建议按以下顺序阅读:
1. 技术栈说明和当前阶段分工表;
2. 所负责功能对应的接口契约;
3. 对应模块的开发说明;
4. Git、CI/CD、测试及注释规范;
5. 与当前任务相关的问题复盘。
## 维护规则
- 新文档先判断用途,再放入对应分类目录,不在 `docs/` 根目录继续堆放业务文档。
- 移动或重命名文档时,同步修正仓库内全部链接,并执行本地链接检查。
- 接口、数据结构或事件格式发生变化时,同一个 PR 内同步更新契约和相关开发说明。
- 问题复盘至少写清原因、后果、解决思路、实际方案和验证结果。
- `.local-plans/` 只保存个人或阶段性的本地计划,不属于正式团队文档,不应提交到远程仓库。
- 文档中的“计划实现”和“已经实现”必须明确区分;实现状态以代码、测试和运行时契约为准。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
# 第一阶段分工表
> 状态更新:2026-08-30。本文保留原始职责划分,同时记录当前交付状态。第一阶段后端目标已完成,前端 Web 联调页面已完成;尚未纳入本阶段完成项的是 Tauri Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
## 当前交付状态
| 领域 | 当前状态 | 说明 |
| --- | --- | --- |
| Desktop Frontend / UI | Web 联调版已完成 | 全部业务路由、Markdown 写作/源码编辑、Chat、Search、Agent、扩展管理、设置页和 Provider 配置已落地 |
| Knowledge / Retrieval Core | 第一阶段已完成 | Markdown Block、SQLite/FTS5、sqlite-vec、Hybrid/RRF/Reranker、Citation 与事务修复均已覆盖测试 |
| AI / Agent Core | 第一阶段已完成 | Streaming、Agent Loop、Tool、Permission、Trace、Skill/Plugin 与 Knowledge/Retrieval 调用链已落地 |
| Model Core | 第一阶段已完成 | Mock、OpenAI Chat/OpenAI-Compatible、DeepSeek 预设、Ollama、模型发现和加密凭据链路可用 |
| Desktop Host / Sync | 后续阶段 | Tauri、Stronghold、系统文件访问、Sidecar 生命周期和云同步尚未实现 |
## 总分工表
| 成员 | 主要职责 | 第一阶段负责模块 | 具体工作内容 | 主要交付物 |
| ------ | -------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| 吉海燕 | 前端设计与交互实现 | Desktop Frontend / UI | 负责 Vue 3 + TypeScript 前端界面设计与实现;完成 Workspace、Markdown Editor、AI Chat、Search、Agent Trace、Skill/Plugin 管理、Theme Manager、Settings 等页面;维护 Design Token 与主题系统;负责前端状态管理与接口联调 | 前端页面、组件库、交互逻辑、主题系统、前端 Store、API Client、前端演示界面 |
| 范涵宇 | 主要程序开发、整体架构与代码审阅 | Agent Core / Extension Core / Model Core / 后端基础框架 | 负责 Python AI Core 与 FastAPI 后端壳子搭建;负责 Agent Runtime、Tool Registry、Permission、Trace;负责 Skill Runtime、Plugin Runtime、Plugin Host/MCP Bridge 等扩展体系;负责 Provider Adapter、多模型协议统一与 API Key 调用链;负责核心接口设计、模块集成、工程规范与代码 Review | FastAPI Core、Agent Runtime、Tool Registry、Skill Runtime、Plugin Runtime、Provider Adapter、公共接口、代码审阅与集成版本 |
| 杨星萱 | 本地知识库与检索系统开发 | Knowledge Core / Retrieval Core | 负责 Markdown Vault、Note/Block 数据模型、Markdown 解析与增量索引;负责 SQLite 元数据、FTS5 全文检索、sqlite-vec 向量检索;负责 Embedding、Hybrid Retrieval、RRF、Reranker、Metadata Filter、Citation 与笔记定位;负责 RAG 检索 Benchmark | Knowledge Core、Note Block、SQLite/FTS5、VectorStore、Embedding、Hybrid RAG、Reranker、Citation、检索测试数据 |
## 细则:
| 协作事项 | 主负责人 | 配合人员 |
| ------------------------------ | -------- | -------------- |
| 前后端 API Contract | 范涵宇 | 吉海燕 |
| Markdown 编辑与 Note Core 联动 | 杨星萱 | 吉海燕 |
| Search / Citation 前端展示 | 杨星萱 | 吉海燕 |
| Agent 调用 RAG | 范涵宇 | 杨星萱 |
| Agent 调用笔记 Tool | 范涵宇 | 杨星萱 |
| Skill 调用 Plugin Tool | 范涵宇 | 吉海燕 |
| Provider Streaming 前端显示 | 范涵宇 | 吉海燕 |
| Agent Trace 前端显示 | 范涵宇 | 吉海燕 |
| RAG Benchmark | 杨星萱 | 范涵宇 |
| Agent Benchmark | 范涵宇 | 杨星萱 |
| 整体 Demo 联调 | 范涵宇 | 吉海燕、杨星萱 |
| UI/UX 最终统一 | 吉海燕 | 全员 |
| PR / 核心代码 Review | 范涵宇 | 对应模块负责人 |
## 验收:
尽量通过VibeCoding在开学前把demo跑出来
### 分别交付:
吉海燕:能够完整操作 Workspace、编辑 Markdown、搜索、AI Chat,并展示 Citation 和 Agent Trace。
范涵宇:Agent 能调用 Tool、Skill 能加载、Plugin 能注册 Tool、至少两个 Provider 可以切换运行。
杨星萱:Markdown 能完成 Block 化索引,FTS5 + Vector + RRF + Reranker 能完成检索,并返回可定位 Citation。
### 总体验收:
```markdown
→ 自动建立本地知识索引
→ 用户向 Agent 提问
→ Agent 调用 RAG
→ Retrieval Core 找到 Note Block
→ Provider 生成回答
→ 前端显示 Citation
→ 点击 Citation
→ 跳转并高亮原笔记
```
@@ -0,0 +1,821 @@
# 第二阶段团队分工表
## 一、阶段目标
第二阶段延续第一阶段已经形成的模块边界,重点推进多模态输入、MCP 与 Plugin 扩展、更多 Provider、RAG / Agent Benchmark、多格式导出、主题社区格式、Agent Trace 可视化、Mermaid 渲染和函数图像绘制。
第二阶段继续保持第一阶段的模块 ownership:
- 范涵宇:Agent Core、Extension Core、Model Core、Multimodal、整体架构与代码审阅。
- 杨星萱:Knowledge Core、Retrieval Core、Benchmark、文档导出、函数图像绘制。
- 吉海燕:Frontend、Theme、Agent Trace、Plugin UI Contribution、Mermaid 渲染。
---
## 二、总分工表
| 成员 | 主要负责方向 | 第二阶段任务 | 配合事项 |
| --- | --- | --- | --- |
| 范涵宇 | Agent Core / Extension Core / Model Core / Multimodal / 总体架构 | faster-whisper、pyannote.audio、MCP Bridge、Plugin Command Contribution 后端、Plugin Settings Contribution 后端、更多 Provider、整体集成、代码审阅与统筹 | 与吉海燕联调 Plugin Command / Settings 前端;为杨星萱的 Agent Benchmark 提供 Agent Trace、Tool Call 等测试接口 |
| 杨星萱 | Knowledge Core / Retrieval Core / Benchmark / Export / 数学内容渲染 | RAG Benchmark、Agent Benchmark 基础设施、Markdown → HTML / PDF / DOCX、函数图像绘制与渲染支持、Retrieval 调优 | 与范涵宇确认 Agent Benchmark 事件和测试数据结构;与吉海燕联调函数图像在编辑器和预览区中的展示 |
| 吉海燕 | Frontend / Theme / Visualization | Theme Import、Theme Manifest、社区主题格式、Agent Trace 可视化、Plugin Command / Settings 前端、Mermaid 渲染支持 | 与范涵宇联调 Plugin Contribution Contract 与 AgentEvent;与杨星萱联调函数图像及导出预览 |
---
## 三、范涵宇
### 3.1 faster-whisper
负责接入 faster-whisper,完成真实音频转写。
目标链路:
```text
Audio
faster-whisper
Timestamped Transcript
Knowledge Core
```
输出至少包含:
```text
text
start_time
end_time
language
```
### 3.2 pyannote.audio
负责说话人分离,并与 faster-whisper 组合。
```text
Audio
pyannote.audio
Speaker Segments
faster-whisper
Timestamped + Speaker Transcript
```
输出至少包含:
```text
speaker
start_time
end_time
text
```
### 3.3 MCP Bridge
负责实现 MCP Bridge,使外部 MCP Server 可以进入现有 Plugin / Tool 体系。
```text
External MCP Server
MCP Bridge
Plugin Runtime
Tool Registry
Agent Runtime
```
Agent Runtime 继续使用项目内部的:
```text
ToolDefinition
ToolCall
ToolResult
```
MCP Bridge 负责协议转换。
### 3.4 Plugin Command Contribution
负责 Plugin Runtime 中 Command Contribution 的解析、注册和执行接口。
```text
Plugin Manifest
Plugin Runtime
Command Registry
Frontend Contract
```
吉海燕负责 Command Palette 等前端展示。
### 3.5 Plugin Settings Contribution
负责 Plugin Manifest 中 Settings Schema 的解析和后端配置接口。
首批支持:
```text
string
number
boolean
select
secret reference
```
范涵宇负责 Manifest Parsing、Settings Schema、Plugin Storage、Secret Reference 和 Runtime Contract。
### 3.6 更多 Provider
继续完善 Provider Adapter。
第二阶段优先验证:
```text
Normal Chat
Streaming
Tool Calling
Reasoning Event
Cancellation
Usage
Error Mapping
```
主要协议目标:
```text
OpenAI Responses
OpenAI Chat Completions
OpenAI-Compatible
Anthropic Messages
Ollama
```
Provider 数量不作为主要验收指标,优先保证协议适配稳定。
### 3.7 审阅与统筹
持续负责:
- 公共 Contract 审阅;
- Agent / Skill / Plugin / Provider 接口审阅;
- 跨模块 PR Review
- 第二阶段整体架构一致性检查;
- Demo 链路集成;
- 公共错误类型和事件格式统一;
- 第二阶段版本合并与发布前检查。
---
## 四、杨星萱
### 4.1 RAG Benchmark
建立正式 RAG Benchmark。
比较:
```text
FTS5
Vector Search
Hybrid Retrieval
Hybrid + RRF
Hybrid + RRF + Reranker
```
核心指标:
```text
Hit@1
Hit@5
Recall@K
MRR
Citation Hit Rate
P50 Latency
P95 Latency
```
Dataset 至少记录:
```text
query
expected_note_id
expected_block_id
expected_citation
tags
```
### 4.2 Agent Benchmark
负责 Agent Benchmark 测试框架、Dataset、指标统计和报告生成。
范涵宇提供:
```text
Agent Run
Agent Event
Tool Call
Tool Result
Trace
```
主要指标:
```text
Task Success Rate
Tool Selection Accuracy
Tool Argument Accuracy
Invalid Tool Call Rate
Average Steps
Average Latency
Token Usage
```
示例:
```yaml
id: agent-os-review-001
prompt: >
找出操作系统笔记中关于死锁的内容,
生成总结并创建三个复习任务。
expected_tools:
- rag.search
- notes.read
- tasks.create
expected_conditions:
citation_required: true
tasks_created: 3
```
### 4.3 多格式文档导出
负责建立统一 Export Service
```text
Document AST
DocumentExporter
├── HtmlExporter
├── PdfExporter
└── DocxExporter
```
统一接口示意:
```python
class DocumentExporter(Protocol):
async def export(
self,
document: Document,
options: ExportOptions,
) -> ExportResult:
...
```
第二阶段完成:
```text
Markdown → HTML
Markdown → PDF
Markdown → DOCX
```
导出时尽量保持:
- 标题;
- 段落;
- 列表;
- 表格;
- 图片;
- 引用;
- 代码块;
- 数学公式;
- Mermaid
- 函数图像。
Exporter 接口保留未来由 Plugin 增加 EPUB、LaTeX 等格式的扩展空间。
### 4.4 函数图像绘制与渲染支持
负责函数图像相关的数据解析、表达、绘制和渲染支持。
目标示例:
```text
y = x^2
y = sin(x)
y = 2x + 1
```
内部建议抽象:
```text
FunctionPlot
├── expressions
├── domain
├── range
├── axis config
└── render config
```
处理链:
```text
Markdown / Structured Block
Function Expression Parser
Function Plot Model
Renderer
Editor / Preview
```
需要考虑:
- 二维函数;
- 多函数同图;
- 定义域;
- 坐标轴;
- 缩放;
- 图像刷新;
- Markdown 中的持久化格式;
- HTML / PDF / DOCX 导出时的静态渲染。
可设计独立 fenced block,例如:
````markdown
```function-plot
y = x^2
y = sin(x)
```
````
具体语法在实现阶段冻结。
函数图像的数据结构、解析和渲染逻辑由杨星萱负责;吉海燕配合完成前端容器、布局和交互。
### 4.5 Retrieval 调优
根据 RAG Benchmark 数据继续调整:
```text
Block Chunking
FTS Query
Vector Top-K
RRF Parameters
Reranker Candidate Count
Score Threshold
Metadata Filter
```
所有调优保留 Benchmark 前后对比数据。
---
## 五、吉海燕
### 5.1 Theme Import
负责 Theme Package 导入。
建议结构:
```text
my-theme/
├── theme.yaml
├── theme.css
├── preview.png
└── README.md
```
流程:
```text
Import
Manifest Validation
Preview
Install
Enable
Disable
Uninstall
```
### 5.2 Theme Community Format
负责制定和实现社区主题格式。
示例:
```yaml
id: example-theme
name: Example Theme
version: 1.0.0
author: example
min_app_version: 0.2.0
```
主题继续基于:
```text
Design Token
CSS Variables
Theme Manifest
```
第二阶段优先完成本地导入和社区包格式,不要求立即实现联网 Theme Marketplace。
### 5.3 Agent Trace 可视化
负责将 Agent Runtime 执行过程可视化。
建议结构:
```text
User Request
├── Model Call
├── rag.search
│ ├── arguments
│ ├── duration
│ └── result
├── notes.read
├── Model Call
└── Completed
```
Trace Node 可展开查看:
```text
Arguments
Result
Duration
Permission
Usage
Error
```
需要支持:
- Agent Run 状态;
- Tool Call 顺序;
- Tool 调用耗时;
- Tool Result 摘要;
- Provider 调用;
- Token Usage
- 错误状态;
- 用户取消;
- Citation 跳转。
### 5.4 Plugin Command 前端
负责 Command Contribution 在前端呈现。
主要挂载:
```text
Command Palette
Context Menu
Toolbar Action
```
前端使用 Plugin Contribution Contract,不直接解析插件后端 Manifest。
### 5.5 Plugin Settings 前端
根据范涵宇提供的 Plugin Settings Schema 动态生成设置表单。
支持:
```text
Input
Number
Switch
Select
Secret Input
```
Secret 类型通过 Secret API 提交,不写入 Pinia 持久化或普通配置文件。
### 5.6 Mermaid 渲染支持
负责在 Markdown 编辑器、预览区域和相关展示界面加入 Mermaid 渲染。
目标 Markdown
````markdown
```mermaid
flowchart LR
A[Markdown] --> B[Renderer]
B --> C[Diagram]
```
````
处理链:
```text
Markdown
Mermaid Code Block
Mermaid Renderer
SVG
Editor / Preview
```
需要支持:
- Flowchart
- Sequence Diagram
- Class Diagram
- State Diagram
- ER Diagram
- Gantt 等常用 Mermaid 图;
- Light / Dark Theme
- 编辑后重新渲染;
- 渲染错误提示;
- SVG 缩放与查看;
- 导出时的静态图处理。
Mermaid 渲染需要与 Theme Design Token 联动。
与杨星萱负责的 Export Service 联调时,前端或渲染层需要提供 Mermaid → SVG / Image 的稳定输出,使 HTML、PDF 和 DOCX 导出能够保留图表。
---
## 六、跨成员协作表
| 协作事项 | 主负责人 | 配合人员 |
| --- | --- | --- |
| MCP Bridge → Agent Tool | 范涵宇 | 杨星萱 |
| Plugin Command Runtime | 范涵宇 | 吉海燕 |
| Plugin Command UI | 吉海燕 | 范涵宇 |
| Plugin Settings Runtime | 范涵宇 | 吉海燕 |
| Plugin Settings UI | 吉海燕 | 范涵宇 |
| Agent Benchmark Framework | 杨星萱 | 范涵宇 |
| Agent Trace Event Contract | 范涵宇 | 吉海燕、杨星萱 |
| Agent Trace Visualization | 吉海燕 | 范涵宇 |
| RAG Benchmark | 杨星萱 | 范涵宇 |
| Markdown Export | 杨星萱 | 吉海燕 |
| Mermaid Editor Rendering | 吉海燕 | 杨星萱 |
| Mermaid Export Rendering | 杨星萱 | 吉海燕 |
| Function Plot Core / Rendering | 杨星萱 | 吉海燕 |
| Function Plot Frontend Integration | 吉海燕 | 杨星萱 |
| Theme Community Format | 吉海燕 | 范涵宇 |
| Provider Streaming UI | 范涵宇 | 吉海燕 |
| Multimodal → Knowledge Core | 范涵宇 | 杨星萱 |
| 第二阶段整体 Demo | 范涵宇 | 吉海燕、杨星萱 |
| 核心代码 Review | 范涵宇 | 对应模块负责人 |
---
## 七、第二阶段优先级
### P0
#### 范涵宇
```text
MCP Bridge
faster-whisper
pyannote.audio
```
#### 杨星萱
```text
RAG Benchmark
Agent Benchmark Infrastructure
Markdown → HTML
函数图像基础渲染
```
#### 吉海燕
```text
Agent Trace Visualization
Theme Manifest / Theme Import
Mermaid Rendering
```
### P1
#### 范涵宇
```text
Plugin Command Contribution
Plugin Settings Contribution
Provider Adapter 完善
```
#### 杨星萱
```text
Markdown → PDF
Markdown → DOCX
Retrieval 调优
函数图像导出适配
```
#### 吉海燕
```text
Theme Community Format
Plugin Command UI
Plugin Settings UI
Mermaid Theme Adaptation
```
### P2
#### 范涵宇
```text
更多 Provider
MCP 兼容性增强
Multimodal Pipeline 优化
```
#### 杨星萱
```text
复杂格式导出
Benchmark 报告自动生成
函数图像高级配置
```
#### 吉海燕
```text
Theme 浏览与管理体验
Agent Trace 高级筛选
Mermaid 高级交互
```
---
## 八、第二阶段共同验收 Demo
建议使用“课堂学习全过程”作为第二阶段总 Demo:
```text
导入课堂录音
pyannote.audio
说话人分离
faster-whisper
带时间戳 Transcript
生成 Markdown
Knowledge Core 建立索引
用户询问课程内容
Agent 调用 RAG
返回 Citation
Agent Trace 可视化
```
随后展示:
```text
选择 Skill
调用 Agent
通过 MCP Bridge 使用外部 Tool
生成整理后的 Markdown
```
Markdown 中展示:
```text
普通文本
数学公式
Mermaid 图
函数图像
```
最后:
```text
Markdown
├── Export HTML
├── Export PDF
└── Export DOCX
```
并切换一个导入的 Community Theme。
---
## 九、Definition of Done
### 范涵宇
- [ ] faster-whisper 能完成真实音频转写;
- [ ] pyannote.audio 能生成说话人分段;
- [ ] 两者能组合生成带时间戳和 Speaker 的 Transcript
- [x] MCP Server 能通过 MCP Bridge 注册 Tool
- [x] Agent 能调用 MCP Tool
- [ ] Plugin Command Contribution 后端可注册;
- [ ] Plugin Settings Contribution 后端可解析;
- [ ] Provider Adapter 的 Streaming / Tool Calling / Error Mapping 稳定;
- [ ] 完成跨模块接口审阅和第二阶段集成。
### 杨星萱
- [ ] RAG Benchmark Dataset 可以稳定运行;
- [ ] 能输出 Hit@K、Recall@K、MRR、Latency 等指标;
- [ ] Agent Benchmark Framework 可以执行标准 Case
- [ ] Markdown 可以导出 HTML
- [ ] Markdown 可以导出 PDF
- [ ] Markdown 可以导出 DOCX
- [ ] Mermaid 在导出链路中可以保留为静态图;
- [ ] 函数图像能够由结构化表达生成;
- [ ] 函数图像能够进入预览和导出链路;
- [ ] Retrieval 调优结果有 Benchmark 数据支撑。
### 吉海燕
- [ ] Theme Package 可以导入;
- [ ] Theme Manifest 可以校验;
- [ ] Theme 可以启用、停用和卸载;
- [ ] Agent Trace 可以展示完整 Tool Call 顺序;
- [ ] Trace Node 可以查看参数、结果、耗时和错误;
- [ ] Plugin Command 可以显示在前端;
- [ ] Plugin Settings 可以动态生成设置项;
- [ ] Markdown Mermaid Code Block 可以渲染;
- [ ] Mermaid 支持主题切换;
- [ ] Mermaid 渲染错误可以明确展示;
- [ ] Mermaid 图能够提供给 Export Service。
---
## 十、分工摘要
```text
范涵宇
├── faster-whisper
├── pyannote.audio
├── MCP Bridge
├── Plugin Command Runtime
├── Plugin Settings Runtime
├── Provider Adapter
├── Code Review
└── Integration / Coordination
杨星萱
├── RAG Benchmark
├── Agent Benchmark Infrastructure
├── Retrieval Optimization
├── Export Service
│ ├── Markdown → HTML
│ ├── Markdown → PDF
│ └── Markdown → DOCX
└── Function Plot
├── Expression / Data Model
├── Rendering
└── Export Integration
吉海燕
├── Theme Import
├── Theme Community Format
├── Agent Trace Visualization
├── Plugin Command UI
├── Plugin Settings UI
└── Mermaid
├── Markdown Rendering
├── Theme Adaptation
└── Export Rendering Interface
```
@@ -0,0 +1,913 @@
# 前端页面需求说明(开发版)
> 文档用途:供团队在第一阶段进行页面设计、Vue 开发、前后端联调和验收。
> 文档性质:开发需求基线,不是最终视觉规范或产品宣传文档。
> 依据:`../architecture/第一阶段分工表.md``../architecture/AI笔记软件技术栈说明-团队版-v2.3.md``后端接口契约-开发版.md`
> 实现状态:更新至 2026-08-31。全部已注册业务路由均已有真实页面;Markdown 写作/源码模式、Search、Chat、智能体执行轨迹、扩展管理、设置、Provider 预设、模型发现和开发阶段加密凭据输入均已落地。Web Workspace 已连接 FastAPI 管理的真实单 VaultTauri 原生目录选择和多 Vault 尚未接入。
## 1. 第一阶段目标
桌面端需要形成一条可以完整演示的本地知识工作流:
```text
选择本地 Vault
→ 浏览并编辑 Markdown
→ 自动保存和建立索引
→ 搜索或向 AI 提问
→ Agent 调用 Tool / RAG
→ 展示回答、Citation 和 Agent Trace
→ 点击 Citation 定位并高亮原笔记
```
前端交付重点:
- 能完整操作 Workspace 和 Markdown 编辑器;
- 能使用全文、向量和混合模式搜索;
- 能使用普通 AI Chat 和 Agent 模式;
- 能展示 Streaming、Citation、Tool Call、权限确认和 Agent Trace
- 能管理 Skill、Plugin、Theme 和 Provider
- AI Core 不可用时,文件浏览与 Markdown 编辑仍可使用;
- 页面只调用 Service,不直接访问数据库、模型厂商协议或 Stronghold。
## 2. 技术与交互基线
| 项目 | 约定 |
| --- | --- |
| 框架 | Vue 3、Composition API、TypeScript、Vite |
| 状态管理 | Pinia,只保存跨组件或跨页面状态 |
| UI 基础 | 当前为项目公共组件、CSS Design Token 与 Element Plus 图标;复杂无障碍 Headless 组件后续按需引入 Reka UI |
| 编辑器 | Milkdown 为默认编辑模式,CodeMirror 6 为源码模式 |
| 桌面容器 | Tauri 2;文件、密钥、Sidecar 和系统能力通过 Rust Command |
| 本地 AI API | FastAPI;普通请求使用 HTTP JSON,流式数据使用 SSE |
| 样式 | 所有主题相关属性使用 Design Token / CSS Variables |
| 时间 | API 使用 UTC ISO 8601,页面按系统时区显示 |
| 错误 | 根据后端 `error.code` 处理,不解析第三方异常文本 |
## 3. 页面信息架构
建议使用一个常驻桌面壳层,避免主要功能之间频繁跳转和丢失编辑状态。
```text
App Shell
├── Title Bar / Window Controls
├── Primary Sidebar
│ ├── Workspace
│ ├── Search
│ ├── AI Chat
│ ├── Tasks
│ └── Extensions
├── Secondary Sidebar
│ ├── File Tree
│ ├── Search Filters
│ ├── Conversation List
│ └── Extension List
├── Main Content
│ ├── Markdown Editor
│ ├── Search Results
│ ├── Chat Conversation
│ ├── Agent Trace
│ └── Settings
├── Optional Right Panel
│ ├── AI Chat
│ ├── Agent Trace
│ ├── Outline
│ └── Backlinks
└── Status Bar
├── Save Status
├── Index Status
├── AI Core Status
└── Provider / Model
```
### 3.1 建议路由
路由用于页面定位,不代表必须将编辑器销毁后重新创建。
| 路由 | 页面 | 优先级 |
| --- | --- | --- |
| `/` | 启动与 Vault 入口 | P0 |
| `/workspace` | Workspace 与 Markdown Editor | P0 |
| `/search` | Search | P0 |
| `/chat` | AI Chat | P0 |
| `/agent/runs/:runId` | Agent Run 与 Trace | P0 |
| `/tasks` | Tasks | P1 |
| `/extensions/skills` | Skill Manager | P0 |
| `/extensions/plugins` | Plugin Manager | P0 |
| `/themes` | Theme Manager | P1 |
| `/settings` | Settings | P0 |
## 4. App Shell 公共需求
### 4.1 左侧主导航
- 固定展示 Workspace、Search、AI、Tasks、Extensions 和 Settings 入口;
- 当前入口具有明确选中状态;
- 支持收起,只显示图标;
- 导航切换不得丢失未保存的编辑内容;
- Plugin 提供的 Sidebar Panel 只能挂载到预留区域,不能任意修改导航结构。
### 4.2 顶部与窗口区域
- 显示当前 Vault 名称和当前文件名;
- 文件有未保存修改时显示状态标识;
- Tauri 环境下预留窗口拖动区和窗口控制区域;
- Web 开发模式下不得依赖 Tauri API 才能完成基础页面渲染。
### 4.3 状态栏
至少显示:
- `已保存 / 保存中 / 保存失败 / 外部文件已变化`
- `索引空闲 / 排队 / 处理中 / 失败`
- `AI Core 正常 / 启动中 / 不可用`
- 当前 Provider 和 Model
- Agent 运行时显示运行、等待权限或取消状态。
状态栏中的异常项可点击进入对应恢复操作,例如重试保存、查看索引任务、重启 AI Core 或打开 Provider 设置。
### 4.4 Command Palette
第一阶段至少支持:
```text
打开文件
创建笔记
全局搜索
切换编辑模式
打开 AI Chat
运行当前 Skill
打开 Settings
执行已注册 Plugin Command
```
Command Palette 使用受控命令注册表,Plugin 不直接操作 Router 或 Pinia 内部状态。
## 5. 启动与 Vault 入口
### 5.1 页面目标
负责建立桌面工作区和 AI Core 连接。用户未选择 Vault 时,不进入空白编辑器。
### 5.2 页面内容
- 最近打开的 Vault
- “打开本地 Vault”按钮;
- “创建 Vault”按钮;
- AI Core 启动状态;
- 最近一次打开失败的恢复提示;
- 开发模式下可显示后端地址和健康检查结果。
### 5.3 交互流程
```text
选择 Vault
→ Rust Host 校验目录权限
→ 启动或连接 AI Core
→ 轮询 /health
→ 建立带 Session Token 的 API Client
→ 读取目录树
→ 进入 Workspace
```
### 5.4 异常状态
- Vault 不存在:从最近列表移除或重新定位;
- Vault 无权限:提示重新授权,不自动更改目录;
- AI Core 启动失败:允许进入 Workspace,并明确标记“AI 功能不可用”;
- 索引未建立:进入 Workspace 后在后台启动索引任务。
## 6. Workspace 与 Markdown Editor
### 6.1 页面目标
提供本地 Markdown 笔记的浏览、创建、打开、编辑、保存、移动和删除能力,是应用的主工作区。
### 6.2 页面布局
| 区域 | 内容 |
| --- | --- |
| Secondary Sidebar | Vault 文件树、创建笔记/文件夹、刷新、折叠目录 |
| Editor Header | 文件名、路径、标签、保存状态、编辑模式切换 |
| Main Editor | Milkdown 或 CodeMirror 6 |
| Right Panel | Outline、Backlinks、AI Chat、Agent Trace |
| Status Bar | 字数、光标位置、保存、索引、AI Core 状态 |
### 6.3 文件树需求
- 展示 `Notes` 和允许展示的用户目录,不展示 `.ainote` 内部目录;
- 支持创建、重命名、移动和删除 Markdown 文件及文件夹;
- 删除前必须确认,并明确是否可恢复;
- 支持右键菜单和键盘操作;
- 外部编辑器修改文件后刷新对应节点;
- 当前文件、已打开文件和外部变化文件具有不同状态;
- 所有文件操作通过 `WorkspaceService` 调用 Rust Command,不在组件中直接散布文件系统调用。
### 6.4 编辑器需求
- 默认使用 Milkdown
- 支持切换到 CodeMirror 6 Markdown 源码模式;
- 两种模式共享同一份 Markdown 字符串;
- 切换模式前同步当前内容,不能覆盖较新的编辑结果;
- 支持自动保存、手动保存和保存失败重试;
- 自动保存需要 debounce,并显示明确状态;
- 支持标题、列表、任务列表、引用、链接、图片、表格、代码块和数学公式的基础 Markdown 展示;
- 打开 Citation 时根据 `block_id` 或 offset 滚动定位并高亮;
- 高亮为临时状态,用户继续编辑后可自动取消;
- 外部文件发生变化且本地存在未保存内容时,不自动覆盖,需展示冲突处理提示。
### 6.5 笔记状态
```text
idle
dirty
saving
saved
save_failed
external_changed
conflict
```
### 6.6 接口与服务
| 操作 | 调用 |
| --- | --- |
| 文件读取与保存 | `WorkspaceService` / Tauri IPC |
| 笔记元数据 | `GET /api/notes``GET /api/notes/{note_id}` |
| 创建笔记 | `POST /api/notes` |
| 更新笔记 | `PATCH /api/notes/{note_id}` |
| 移动笔记 | `POST /api/notes/{note_id}/move` |
| 删除笔记 | `DELETE /api/notes/{note_id}` |
Markdown 正文以本地文件为持久化载体。前端不得把 Pinia 或浏览器存储当作正文的最终存储。
## 7. Search 页面
### 7.1 页面目标
统一提供全文、向量和 Hybrid Search,并允许用户从结果直接回到原笔记位置。
### 7.2 查询区
- 搜索输入框;
- 模式选择:`fts / vector / hybrid`
- 文件夹范围;
- 指定笔记;
- 标签;
- 创建和更新时间范围;
- 分页或继续加载;
- 是否显示命中片段。
### 7.3 结果项
每项至少显示:
- 笔记标题和文件路径;
- Heading Path
- 命中片段;
- 检索分数;
- 标签或必要元数据;
- Citation 定位信息。
点击结果执行:
```text
打开对应 Note
→ 切换到 Workspace
→ 定位 block_id / offset
→ 高亮命中内容
```
### 7.4 页面状态
- 初始状态:显示搜索说明或最近查询;
- 加载状态:保留旧结果并标记正在更新;
- 空结果:给出调整关键词、范围或模式的建议;
- Vector 不可用:保留 FTS 搜索,并提示向量索引状态;
- Reranker 不可用:展示降级结果,不阻断搜索;
- 索引重建中:提示结果可能不完整。
### 7.5 接口
```text
POST /api/search
GET /api/index/status
POST /api/index/rebuild
GET /api/index/jobs/{job_id}
```
## 8. AI Chat 页面与侧边面板
### 8.1 页面目标
支持普通模型对话和基于当前 Vault 的知识问答。独立页面与 Workspace 右侧面板复用相同 Chat 组件和 Store。
### 8.2 页面布局
- 会话列表;
- 消息时间线;
- Provider / Model 选择器;
- RAG 开关及检索范围;
- Skill 选择器;
- 附件入口;
- 输入框、发送、停止生成;
- Citation 区域;
- 打开对应 Agent Trace 的入口。
### 8.3 Streaming 展示
前端只处理统一 ModelEvent
| 事件 | 页面行为 |
| --- | --- |
| `TextDelta` | 追加回答正文 |
| `ThinkingDelta` | 写入可折叠思考区域,不与正文混合 |
| `ToolCallStart` | 创建 Tool Call 占位卡片 |
| `ToolCallDelta` | 更新参数预览 |
| `ToolCallEnd` | 标记 Tool Call 参数接收完成 |
| `Usage` | 更新 Token Usage |
| `Error` | 结束加载并显示按 code 分类的恢复操作 |
| `Done` | 完成消息并释放 Streaming 状态 |
页面不能把第三方 Provider 的原始流事件直接写入组件。
### 8.4 Citation 展示
- 回答正文中显示编号引用;
- 消息底部展示 Citation 列表;
- Citation 包含笔记标题、路径、Heading Path 和片段;
- 点击后打开笔记并高亮 Block
- 音频 Citation 额外显示时间范围和说话人,并支持跳转到音频时间;
- Citation 丢失或文件已移动时显示不可定位状态,不导致整条回答消失。
### 8.5 接口
```text
POST /api/chat ModelEvent SSE
GET /api/providers
GET /api/providers/{provider_id}/models
GET /api/skills
```
## 9. Agent Run 与 Trace 页面
### 9.1 页面目标
展示 Agent 的运行状态、模型步骤、Tool Call、Tool Result、权限确认、Token Usage、Citation 和最终结果。
### 9.2 创建 Agent Run
创建前允许配置:
- 用户任务;
- Provider 和 Model
- Skill
- 允许使用的 Tool
- `max_steps`
- Tool Timeout
- Run Timeout
- Token Budget
- 是否允许网络;
- 最大并发 Tool 数量。
### 9.3 Trace 时间线
`sequence` 排序展示 AgentEvent
```text
RunStarted
ThinkingDelta / TextDelta
ToolCall
PermissionRequired
ToolResult
Usage
Citation
RunCompleted / RunFailed / RunCancelled
```
Tool Call 卡片至少显示:
- Tool 名称与来源;
- 参数摘要,默认折叠长文本;
- 权限名称和授权状态;
- 开始时间、耗时;
- 成功或失败;
- Tool Result 摘要;
- 可展开的错误 code 和 message。
Trace 不显示完整 API Key、Authorization Header 或未经用户允许的完整笔记正文。
### 9.4 权限确认
收到 `PermissionRequired` 后显示阻塞式确认组件,内容包括:
- 请求的 Tool
- 权限命名空间;
- 操作影响;
- 参数摘要;
- `仅本次允许 / 本次会话允许 / 拒绝`
提交接口:
```text
POST /api/agent/runs/{run_id}/permissions/{request_id}
```
不得默认批准 `notes.delete``notes.write``network.request``secrets.use` 等高影响权限。
### 9.5 Run 状态
```text
queued
running
waiting_permission
completed
failed
cancelled
```
页面刷新后先读取 Run,再重新订阅 SSE。收到重复事件时按 `run_id + sequence` 去重。
### 9.6 接口
```text
GET /api/agent/runs
POST /api/agent/runs
GET /api/agent/runs/{run_id}
POST /api/agent/runs/{run_id}/cancel
GET /api/agent/runs/{run_id}/events
POST /api/agent/runs/{run_id}/permissions/{request_id}
GET /api/tools
```
## 10. Skill Manager
### 10.1 页面目标
查看、安装、启用、停用和卸载 Skill,并在运行前展示 Tool、权限、检索配置和模型能力要求。
### 10.2 列表与详情
列表项显示:
- 名称、版本、描述;
- 启用状态;
- 状态:`installed / disabled / ready / dependency_missing / permission_required / error`
- 缺失依赖数量;
- 所需权限摘要。
详情显示:
- Manifest
- Prompt 说明;
- Tool 列表及来源;
- Retrieval 配置;
- Required Capabilities
- 缺失的 Plugin 或 Tool
- 安装或升级时新增的权限。
### 10.3 交互规则
- 安装前显示权限确认;
- 更新后新增权限必须重新确认;
- `dependency_missing` 时禁用“运行”;
- Provider 不满足 Required Capabilities 时提示切换 Provider/Model
- Skill 不执行 Python 或 JavaScript,不展示“运行脚本”入口。
### 10.4 接口
```text
GET /api/skills
POST /api/skills/install
GET /api/skills/{skill_id}
POST /api/skills/{skill_id}/enable
POST /api/skills/{skill_id}/disable
DELETE /api/skills/{skill_id}
```
## 11. Plugin Manager
### 11.1 页面目标
管理 Plugin 生命周期、权限和 Contribution,并展示 Plugin 为 Agent/Skill 提供的 Tool。
### 11.2 列表状态
```text
installed
disabled
starting
ready
error
dependency_missing
permission_required
```
列表项显示名称、版本、描述、状态、启用开关、权限数量和 Contribution 数量。
### 11.3 详情内容
- Plugin Manifest
- Backend 类型与 Transport
- 权限列表;
- Tool、Command、Importer、Exporter
- Sidebar Panel、Settings Section
- 依赖它的 Skill
- Host 健康状态和最近错误;
- 启用、停用和卸载操作。
### 11.4 安全要求
- 安装和权限升级必须确认;
- 停用后立即从 UI 隐藏其 Contribution
- 卸载前展示受影响的 Skill
- Plugin UI 只能使用 Plugin Bridge,不得直接访问 Rust Command、Stronghold、Pinia 内部状态或任意本地路径;
- Plugin 错误不能阻断 Workspace 和其他 Plugin。
### 11.5 接口
```text
GET /api/plugins
POST /api/plugins/install
GET /api/plugins/{plugin_id}
POST /api/plugins/{plugin_id}/enable
POST /api/plugins/{plugin_id}/disable
DELETE /api/plugins/{plugin_id}
```
## 12. Theme Manager
### 12.1 页面目标
查看内置和已安装主题、预览 Design Token、切换主题,并在主题异常时恢复默认主题。
### 12.2 页面内容
- 主题卡片:名称、作者、版本、明暗类型、缩略预览;
- 当前主题标识;
- 实时预览区域;
- 编辑器字体、字号等允许用户覆盖的 Token;
- 恢复默认主题;
- 导入主题入口作为后续能力预留。
### 12.3 约束
- 业务组件只能引用 Design Token,不写主题专用颜色;
- 主题 CSS 只能覆盖允许开放的变量和样式;
- 主题资源路径需要由 Host 校验,不能读取 Vault 外任意文件;
- 主题加载失败时立即回退默认主题,并保留错误提示。
## 13. Tasks 页面
### 13.1 页面目标
统一展示 Markdown 中的任务和 Agent 创建的任务,支持基本筛选和状态更新。
### 13.2 页面内容
- `todo / in_progress / done / cancelled` 分组或筛选;
- 标题、描述、关联 Note、截止时间;
- 来源标识:用户创建、笔记解析或 Agent 创建;
- 打开关联笔记;
- 创建、编辑、完成和删除任务。
### 13.3 接口
```text
GET /api/tasks
POST /api/tasks
GET /api/tasks/{task_id}
PATCH /api/tasks/{task_id}
DELETE /api/tasks/{task_id}
```
## 14. Settings
Settings 使用分区导航,不把全部配置堆在一个表单中。
### 14.1 General
- 启动时恢复上次 Vault
- 自动保存间隔;
- 界面语言和时间显示;
- 日志目录入口;
- 应用版本与 AI Core 版本。
### 14.2 Editor
- 默认编辑模式;
- 编辑器字体与字号;
- 行宽、行高、自动换行;
- Markdown 预览行为;
- 拼写检查和代码块选项。
### 14.3 Providers
列表展示 Provider 类型、名称、Base URL、默认模型、启用状态和 Capability。
支持:
- 新建、编辑、启用、禁用和删除 Provider;
- 拉取模型列表;
- 选择默认模型;
- 测试连接并显示耗时;
- 根据 Capability 标记是否支持 Chat、Tool Calling、Vision、Streaming 等。
当前 Web 联调阶段,API Key 通过 Credential API 交给 FastAPI,由 Fernet 加密保存;前端仅在提交期间持有明文,Provider 和 Store 只保留 `credential_id`。页面只能回显“已配置/未配置”,不得回显完整密钥。Tauri 集成后由 Stronghold 替换后端开发存储实现,接口边界保持不变。
接口:
```text
GET /api/providers
GET /api/providers/presets
POST /api/providers
GET /api/providers/{provider_id}
PATCH /api/providers/{provider_id}
DELETE /api/providers/{provider_id}
GET /api/providers/{provider_id}/models
POST /api/providers/test
GET /api/credentials/{credential_id}
PUT /api/credentials/{credential_id}
DELETE /api/credentials/{credential_id}
```
### 14.4 Index 与 Models
- 当前索引状态;
- Pending Job 数量;
- Embedding Provider / Model
- Reranker
- 重建全部、文本或向量索引;
- 模型下载、失败和磁盘占用状态;
- FTS 可用但向量不可用时展示降级说明。
### 14.5 Permissions
- 展示 Skill 和 Plugin 已授权权限;
- 撤销持续授权;
- 配置高影响 Tool 的确认策略;
- 明确 `allow / confirm / deny`
- 不显示 Stronghold 中的明文 Secret。
### 14.6 AI Core Diagnostics
- `/health``/api/status`
- Sidecar 状态和重启按钮;
- 当前 API 地址仅在开发或诊断模式显示;
- 日志入口;
- 最近启动失败信息;
- “AI Core 不可用时仍可编辑 Markdown”的说明。
## 15. 公共状态管理
| Store | 负责状态 | 不应负责 |
| --- | --- | --- |
| `workspaceStore` | Vault、目录树、打开文件、监听状态 | Markdown 编辑器内部文档状态 |
| `noteStore` | Note 元数据、当前 Note、最近访问 | 直接读写 SQLite |
| `editorStore` | 模式、活动编辑器、保存状态 | 保存完整历史版本 |
| `searchStore` | 查询条件、模式、结果、分页 | 执行 FTS/Vector 算法 |
| `chatStore` | 会话、消息、SSE、Citation | 拼接厂商请求体 |
| `agentStore` | Run、Event、Tool Call、权限请求 | 执行 Tool |
| `skillStore` | Skill 列表、状态、依赖 | 运行独立 Agent Loop |
| `themeStore` | 当前主题、Manifest、Token 覆盖 | 任意加载本地 CSS |
| `taskStore` | Task 列表、筛选和编辑状态 | 直接修改 Markdown |
| `providerStore` | Provider、Model、Capability | 保存 API Key 明文 |
| `settingsStore` | 应用级非敏感设置 | 保存 Secret |
Store 通过 Service 调用外部能力;组件通过 Store 或 Feature Service 组织交互。
## 16. Service 层要求
建议至少建立:
```text
ApiClient
SseClient
WorkspaceService
EditorDocumentService
NoteService
SearchService
ChatService
AgentService
SkillService
PluginService
ProviderService
TaskService
IndexService
SecretService
```
### 16.1 ApiClient
- Base URL 和 Session Token 由 Rust Sidecar Manager 提供;
- 统一添加认证 Header、Request ID 和 JSON Header
- 统一解析 `ErrorResponse`
- 不在业务组件中重复拼接 URL
- 支持开发环境固定端口和正式环境随机端口。
### 16.2 SseClient
- 支持 ModelEvent 和 AgentEvent
- 支持取消订阅;
- 网络断开时展示状态,不重复提交原请求;
- Agent Event 根据 `run_id + sequence` 去重;
- 收到终止事件后主动释放连接;
- 页面卸载时清理连接和 AbortController。
### 16.3 WorkspaceService
- 统一封装 FastAPI Workspace/Note API,并为 Tauri 文件命令保留适配边界;
- 规范化路径;
- 处理文件锁、自动保存和冲突;
- Web 开发模式连接后端配置的单一 Vault,禁止失败后回退 Mock;
- 不把任意本地路径直接暴露给 Plugin UI。
## 17. 公共组件
第一阶段建议优先沉淀:
```text
AppShell
PrimarySidebar
SecondarySidebar
StatusBar
CommandPalette
ServiceStatusBadge
EmptyState
ErrorState
LoadingSkeleton
ConfirmDialog
PermissionDialog
ProviderModelSelect
MarkdownEditorAdapter
CitationChip / CitationList
AgentTraceTimeline
ToolCallCard
StreamingMessage
ExtensionStatusBadge
IndexStatusIndicator
```
Dialog、Popover、Menu、Tabs、Select、Tooltip 和 Command Palette 优先基于 Reka UI / Headless Components 封装。
## 18. Design Token 与可访问性
### 18.1 Token 分类
```text
color.background.*
color.text.*
color.border.*
color.accent.*
color.status.*
font.ui.*
font.editor.*
space.*
radius.*
shadow.*
motion.*
```
- 页面不得散布无法被主题覆盖的颜色值;
- Loading、Success、Warning、Error、Disabled 需要统一 Token
- 编辑器 Token 与普通 UI Token 分开;
- 默认同时提供浅色和深色基础 Token。
### 18.2 可访问性
- 所有主要功能可通过键盘操作;
- 图标按钮提供可读名称和 Tooltip;
- 焦点状态清晰可见;
- 不能只通过颜色表达错误或状态;
- Dialog 打开时管理焦点,关闭后返回触发点;
- Streaming 更新使用合适的 `aria-live`,避免每个 Token 都触发朗读;
- 尊重系统的 reduced motion 设置。
## 19. 错误与降级
| 错误场景 | 页面行为 |
| --- | --- |
| `PROVIDER_AUTH_FAILED` | 引导到 Provider Credential 设置 |
| `PROVIDER_RATE_LIMITED` | 显示稍后重试,不自动无限重试 |
| `PROVIDER_TIMEOUT` | 保留已有 Streaming 内容并允许重试 |
| `PROVIDER_UNAVAILABLE` | 提示切换 Provider 或检查本地服务 |
| `MODEL_NOT_FOUND` | 刷新模型列表并提示重新选择 |
| `MODEL_CAPABILITY_MISMATCH` | 展示缺失 Capability |
| `VALIDATION_ERROR` | 定位到对应表单字段 |
| `PERMISSION_DENIED` | 在 Trace 中记录拒绝,不显示为程序崩溃 |
| `TOOL_TIMEOUT` | 标记对应 Tool Call 失败 |
| `PLUGIN_UNAVAILABLE` | 隐藏失效 Contribution,并显示 Plugin 状态 |
| AI Core 不可用 | 禁用 AI、Search、Index 等入口,但保留 Workspace/Editor |
| Vector / Reranker 不可用 | 降级到 FTS,不阻断搜索 |
全局通知用于跨页面或需要立即关注的结果;字段错误、页面加载错误和 Tool 错误应在其上下文内展示,避免所有错误都使用 Toast。
## 20. 响应式与桌面窗口
项目以桌面端为主,不以手机页面为第一阶段目标。
- 推荐最小窗口宽度:`960px`
- 宽窗口显示主导航、Secondary Sidebar、Main Content 和 Right Panel
- 中等窗口自动收起 Right Panel,通过按钮打开;
- 接近最小宽度时收起主导航文字和 Secondary Sidebar
- Editor、Chat 和 Trace 的滚动区域独立,不让整个窗口出现多层不可控滚动;
- 面板尺寸调整后保存非敏感布局设置。
## 21. 开发优先级
### P0:第一阶段 Demo 必须完成
1. App Shell、Vault 入口和 Workspace
2. 文件树与 Milkdown / CodeMirror 编辑;
3. 自动保存和 AI Core 离线降级;
4. Search 与 Citation 定位;
5. AI Chat Streaming
6. Agent Run、Trace、Tool Call 和 Permission
7. Skill / Plugin 基础管理;
8. Provider 设置和模型切换;
9. 基础 Design Token、错误和加载状态。
### P1:第一阶段完善项
1. Tasks
2. Theme Manager
3. Outline 与 Backlinks
4. Command Palette
5. Index 和 AI Core Diagnostics
6. 简单 Plugin Sidebar Panel / Settings Section。
### P2:后续阶段
1. 复杂 Plugin UI API
2. 音频转写完整工作台;
3. 主题社区与扩展分发;
4. Sync Client 页面;
5. 多设备版本历史与冲突合并;
6. 高级 Agent Trace 可视化和 Benchmark 页面。
## 22. 第一阶段验收标准
### 22.1 Workspace / Editor
- 可以选择 Vault、浏览目录、创建并打开 Markdown;
- 可以在 Milkdown 和 CodeMirror 间切换且内容不丢失;
- 保存状态清晰,保存失败可恢复;
- AI Core 不可用时仍可编辑和保存。
### 22.2 Search / Citation
- 可以选择检索模式和范围;
- 可以展示 SearchResult
- 点击 Citation 能打开对应 Note 并定位、高亮 Block;
- Vector 不可用时能降级到 FTS。
### 22.3 Chat / Agent
- Provider 和 Model 可以切换;
- Streaming 文本正常追加并可停止;
- Agent 可以创建、取消并查看状态;
- Tool Call、Tool Result、Usage 和错误可以在 Trace 中查看;
- 高风险 Tool 会等待用户确认;
- 页面刷新后可以重新读取 Run 并恢复 Trace 展示。
### 22.4 Extension / Settings
- Skill 和 Plugin 可以查看状态并执行安装、启用、停用和卸载入口;
- 缺少依赖和权限时不能错误地显示为 Ready;
- Provider 可以新增、编辑、测试、启用和删除;
- API Key 不出现在普通配置、日志和 Pinia 持久化中;
- 主题异常时能恢复默认主题。
### 22.5 总体验收
```text
用户编辑 Markdown
→ 后端建立索引
→ 用户发起知识库问题
→ Agent 调用 RAG Tool
→ 返回回答与 Citation
→ 前端展示 Agent Trace
→ 用户点击 Citation
→ 编辑器定位并高亮原笔记
```
## 23. 协作边界
| 内容 | 主负责人 | 前端配合点 |
| --- | --- | --- |
| 页面、组件、交互、Store、Design Token | 吉海燕 | 前端主实现 |
| API Contract、Agent、Provider、Permission、Trace | 范涵宇 | SSE 与页面联调 |
| Note、Block、Search、Citation、RAG | 杨星萱 | 编辑器定位和搜索展示联调 |
跨模块字段变化时,需要同时更新:
```text
Pydantic Contract
OpenAPI
TypeScript Contract
Service
Store
本需求文档中的接口或状态说明
```
第一阶段优先保证完整工作流和可恢复性。页面视觉细节由 Design Token 与后续视觉稿继续收敛,但不能改变本文档中的数据边界、安全约束和关键交互状态。
@@ -0,0 +1,192 @@
# 后端接口契约(开发版)
> 更新日期:2026-09-01。本文档记录当前前后端联调使用的已实现接口;机器可读字段、校验规则和响应模型以 FastAPI 运行时生成的 OpenAPI 为准。第二阶段尚未实现的规划接口见 `第二阶段接口契约-开发版.md`,不要将规划路径视为当前服务能力。
## 契约入口
- Swagger UI`GET /docs`
- OpenAPI`GET /openapi.json`
- 所有普通接口使用 JSON。
- Chat 和 Agent 实时事件使用 `text/event-stream`SSE)。
- 内部时间统一使用 UTC ISO 8601。
- ID 使用稳定业务 ID,不使用文件路径代替 ID。
## 接口清单
### System
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/health` | Sidecar 健康检查 |
| GET | `/api/status` | 获取服务名称、版本和环境 |
### Notes 与 Search
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/notes` | 获取笔记列表 |
| POST | `/api/notes` | 创建笔记 |
| GET | `/api/notes/{note_id}` | 获取笔记及 Block |
| PATCH | `/api/notes/{note_id}` | 更新笔记 |
| DELETE | `/api/notes/{note_id}` | 删除笔记 |
| POST | `/api/notes/{note_id}/move` | 移动笔记 |
| POST | `/api/notes/{note_id}/rename` | 重命名笔记文件并保留 Note/Block 身份 |
| POST | `/api/search` | FTS、Vector 或 Hybrid 检索 |
### Workspace
Web 联调阶段只暴露后端通过 `APP_VAULT_PATH` 配置的单一 Vault,不接受浏览器传入任意本地目录。桌面多 Vault 与目录选择仍由后续 Tauri Host 提供。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/workspace` | 获取当前 Vault、文件数和索引同步状态 |
| POST | `/api/workspace/open` | 打开配置的 Vault;磁盘路径集变化时重建索引 |
| GET | `/api/workspace/tree` | 获取真实 Markdown 文件和目录树 |
| POST | `/api/workspace/folders` | 新建目录 |
| POST | `/api/workspace/folders/rename` | 重命名目录并同步 Note 路径 |
| POST | `/api/workspace/folders/delete` | 删除目录及其 Note、Block、FTS 和向量记录 |
### Chat、Agent 与 Tool
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | `/api/chat` | 发起聊天并返回 ModelEvent SSE |
| GET | `/api/agent/runs` | 获取 Agent Run 列表 |
| POST | `/api/agent/runs` | 创建 Agent Run |
| GET | `/api/agent/runs/{run_id}` | 获取 Agent Run 状态与 Trace 摘要 |
| POST | `/api/agent/runs/{run_id}/cancel` | 取消 Agent Run |
| GET | `/api/agent/runs/{run_id}/events` | 订阅 AgentEvent SSE,支持 `Last-Event-ID` / `after_sequence` 恢复 |
| GET | `/api/agent/runs/{run_id}/trace` | 分页读取持久化 Trace、摘要和运行配置快照 |
| POST | `/api/agent/runs/{run_id}/permissions/{request_id}` | 响应 Tool 权限确认 |
| GET | `/api/tools` | 获取已注册 Tool Definition |
### Skill 与 Plugin
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/skills` | 获取 Skill 列表及状态 |
| POST | `/api/skills/install` | 安装 Skill |
| GET | `/api/skills/{skill_id}` | 获取 Skill Manifest 与状态 |
| POST | `/api/skills/{skill_id}/enable` | 启用 Skill |
| POST | `/api/skills/{skill_id}/disable` | 停用 Skill |
| DELETE | `/api/skills/{skill_id}` | 卸载 Skill |
| GET | `/api/plugins` | 获取 Plugin 列表及状态 |
| POST | `/api/plugins/install` | 安装 Plugin |
| GET | `/api/plugins/{plugin_id}` | 获取 Plugin Manifest 与状态 |
| POST | `/api/plugins/{plugin_id}/enable` | 启用 Plugin |
| POST | `/api/plugins/{plugin_id}/disable` | 停用 Plugin |
| PUT | `/api/plugins/{plugin_id}/permissions` | 设置 Plugin 已授权权限 |
| GET | `/api/plugins/{plugin_id}/host` | 获取隔离 MCP Host 状态、工具数和协商信息 |
| POST | `/api/plugins/{plugin_id}/host/restart` | 重启 MCP Host 并重新发现、校验和注册 Tool |
| DELETE | `/api/plugins/{plugin_id}` | 卸载 Plugin |
### Provider
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/providers` | 获取 Provider 配置列表 |
| GET | `/api/providers/presets` | 获取 OpenAI、DeepSeek 与 Ollama 配置预设 |
| POST | `/api/providers` | 新建 Provider 配置 |
| GET | `/api/providers/{provider_id}` | 获取 Provider 配置 |
| PATCH | `/api/providers/{provider_id}` | 更新 Provider 配置 |
| DELETE | `/api/providers/{provider_id}` | 删除 Provider 配置 |
| GET | `/api/providers/{provider_id}/models` | 获取模型及 Capability 列表 |
| POST | `/api/providers/test` | 测试 Provider 连接 |
| GET | `/api/credentials/{credential_id}` | 查询凭据是否已配置,不返回明文 |
| PUT | `/api/credentials/{credential_id}` | 加密保存开发阶段 API Key |
| DELETE | `/api/credentials/{credential_id}` | 删除已保存凭据 |
Provider Contract 只传递 `credential_id` 或临时 `credential_context_id`。当前前后端开发阶段通过独立的 `PUT /api/credentials/{credential_id}` 接收 API Key,并立即加密落盘;该接口只返回配置状态,不返回密钥。Provider CRUD、模型列表和测试接口均不携带明文 API Key。Tauri 集成后由 Stronghold 接管存储实现。
### Tasks、Media 与 Index
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/tasks` | 获取任务列表 |
| POST | `/api/tasks` | 创建任务 |
| GET | `/api/tasks/{task_id}` | 获取任务 |
| PATCH | `/api/tasks/{task_id}` | 更新任务 |
| DELETE | `/api/tasks/{task_id}` | 删除任务 |
| POST | `/api/media/transcriptions` | 创建音频转写任务 |
| GET | `/api/media/transcriptions/{job_id}` | 获取转写任务状态 |
| GET | `/api/index/status` | 获取索引服务状态 |
| POST | `/api/index/rebuild` | 创建索引重建任务 |
| GET | `/api/index/jobs/{job_id}` | 获取索引任务状态 |
## 统一错误
所有普通 HTTP 错误统一返回:
```json
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Resource was not found.",
"details": {}
}
}
```
当前接口主要返回:
- `422 VALIDATION_ERROR`:请求字段不符合 Pydantic Contract
- `404 RESOURCE_NOT_FOUND`:路由或资源不存在。
- `409`:资源冲突、依赖缺失或扩展尚未获得权限;
- `429 AGENT_CAPACITY_EXCEEDED`:活动 Agent Run 达到上限。
前端只根据 `error.code` 判断业务错误,不解析第三方 SDK 的原始异常文本。
## SSE 约定
每条 SSE 包含事件名和对应 JSON Contract
```text
event: TextDelta
data: {"event":"TextDelta","sequence":1,"data":{"text":"..."},"timestamp":"..."}
```
ModelEvent 类型:
```text
TextDelta
ThinkingDelta
ToolCallStart
ToolCallDelta
ToolCallEnd
Usage
Error
Done
```
AgentEvent 类型:
```text
RunStarted
TextDelta
ThinkingDelta
ToolCall
ToolResult
PermissionRequired
Usage
Citation
RunCompleted
RunFailed
RunCancelled
```
## 当前实现状态
更新至 2026-09-01:后端 92 项回归测试通过。
- Chat、Agent Run、Agent Events、Tool 列表、Provider 配置生命周期、模型列表和连接测试已经接入 AI Core。
- Agent Run/Event 已持久化到 SQLiteSSE 帧携带 sequence `id`,断线后可以回放缺失事件。Trace API 与 Benchmark 共用同一事件事实,并在入库前执行 Secret 脱敏和结果限长。
- Provider Adapter 当前包含 Mock、真正增量 SSE 的 OpenAI-Compatible Chat Completions,以及 Ollama JSONL Streaming。
- Notes、Search、Index、Skills、Plugins、Tasks 和 Provider 生命周期均已接入业务服务。
- Workspace 已接入后端配置的真实 Vault;文件树、笔记读写、文件/目录新建、重命名和删除不再使用前端 Mock Fallback。
- Note Move 保留 `note_id`;Citation 的字符偏移统一使用 UTF-16 code unit,供浏览器编辑器直接定位。
- Plugin 启用前必须通过权限接口记录授权,未知权限默认拒绝。
- 本地 stdio MCP Server 已通过独立子进程接入 Plugin RuntimeAgent 只消费内部 Tool Contract。Host 支持 initialize、分页发现、调用、超时取消、状态查询、重启和异常退出后的 Tool 注销。
- Attachment Tool 读取 Host 管理的 `attachments` 目录;音频接口读取 Host 生成的转写文本,真实本地语音模型在第二阶段接入。
- 接入业务模块时保持当前路径和 Contract,不在 Router 中直接实现数据库、Provider 或 Agent 逻辑。
第二阶段开发保持本文件中已有路径兼容,并按 `第二阶段接口契约-开发版.md` 增加子资源、可选字段和事件。接口完成后先更新 OpenAPI 与本文件,再将第二阶段文档中的状态改为已实现。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,352 @@
# AI Core 与 Agent Core 开发说明
> 本文档用于团队开发和模块联调,记录当前已经落地的核心边界与使用方式。
> 更新日期:2026-09-01。第一阶段 AI Core、Agent Core、Extension Core 和 Model Core 主链路已经完成;第二阶段 Agent Trace 持久化、可恢复 SSE、stdio MCP Bridge 与隔离 Plugin Host 已落地,后端当前回归基线为 92 项测试通过。
## 当前实现
当前已经建立第一条可运行链路:
```text
FastAPI
→ Provider Registry
→ Agent Runtime
→ Permission Manager
→ Tool Registry
→ Skill Runtime / Plugin Runtime
→ Agent Trace / SSE
```
对应代码:
```text
backend/app/
├── providers/
│ ├── base.py Provider Protocol 与统一 Turn
│ ├── registry.py Provider 注册、发现、模型列表和连接测试
│ └── mock.py 离线开发 Provider
├── agent/
│ ├── runtime.py Agent Loop、限制、取消、Trace 和 SSE
│ ├── trace_repository.py Run/Event SQLite 持久化、分页、摘要与脱敏
│ ├── tools.py Tool 注册、参数校验、隔离执行和结果转换
│ ├── permissions.py 权限策略、确认请求和会话授权
│ └── builtin_tools.py 无副作用的内置开发 Tool
├── extensions/
│ ├── runtime.py Skill/Plugin Manifest、生命周期、依赖与 Tool Contribution
│ └── mcp.py stdio JSON-RPC、MCP 生命周期、发现、调用与 Host 隔离
└── container.py AI Core 依赖组装
backend/extensions/
├── skills/knowledge-assistant/ 内置知识库 Skill
├── plugins/text-tools/ 内置声明式 Plugin
└── fixtures/mcp-echo/ 离线 MCP Server 联调 Fixture
```
Router 只负责 HTTP/SSE 与错误转换,不实现 Agent、Tool 或 Provider 业务逻辑。
## 模块边界
当前实现属于范涵宇负责的 AI Core / Agent Core
- Provider 抽象与注册;
- Agent Run 生命周期;
- Tool Registry
- Tool 参数校验与执行隔离;
- Permission
- Step、Timeout、Token Budget、取消;
- Tool 并发上限与 run 级网络权限;
- SQLite Trace、分页快照与可恢复 SSE;
- Skill Manifest、Prompt、Tool/Permission/模型能力解析;
- Plugin Manifest、生命周期和 Tool Contribution
- stdio MCP Bridge、隔离进程生命周期、Tool 映射与 Host 健康状态;
- Skill 调用内置 Tool 与 Plugin Tool
- 公共 Contract 和 API 接入。
以下内容保持接口,不在本模块实现:
- Note、NoteBlock、Markdown Parser:由 Knowledge Core 提供;
- FTS5、Vector、RRF、Reranker、Citation:由 Retrieval Core 提供;
- 文件系统和 API Key 明文读取:由 Rust Host 提供;
- Frontend Extension Slot 与 Plugin Command/Settings:按第二阶段后续阶段实现。
## Provider
默认注册离线 Provider
```text
provider_id = mock
model = mock-1
```
它支持:
```text
chat
tool_calling
streaming
```
普通 Chat 请求:
```json
{
"provider_id": "mock",
"model": "mock-1",
"messages": [
{"role": "user", "content": "hello"}
]
}
```
`POST /api/chat` 返回 ModelEvent SSE。OpenAI-Compatible Adapter 直接消费上游 SSEOllama Adapter 直接消费 JSONL`TextDelta` 是真实增量内容,不再等待整段回答完成。
另外已经实现以下可配置 Adapter:
```text
openai_chat / openai_compatible
ollama
```
创建 Ollama Provider
```json
{
"provider_type": "ollama",
"name": "Local Ollama",
"base_url": "http://127.0.0.1:11434",
"default_model": "qwen3:latest"
}
```
创建 OpenAI-Compatible Provider
```json
{
"provider_type": "openai_compatible",
"name": "OpenAI Compatible",
"base_url": "https://api.openai.com/v1",
"default_model": "<model>",
"credential_id": "openai-main"
}
```
凭证 ID `openai-main` 可以对应开发环境变量 `AINOTE_CREDENTIAL_OPENAI_MAIN`。当前 Web 联调版也允许设置页通过 Credential API 提交 API Key,由 `EncryptedCredentialStore` 使用 Fernet 加密保存;前端 Store、Provider Config、日志和读取响应都不保存或返回明文。未来接入 Tauri 后,由 Rust Host 从 Stronghold 注入或替换存储实现。
Provider 配置生命周期接口已经可用:
```text
GET /api/providers
GET /api/providers/presets
POST /api/providers
GET /api/providers/{provider_id}
PATCH /api/providers/{provider_id}
DELETE /api/providers/{provider_id}
GET /api/providers/{provider_id}/models
POST /api/providers/test
GET /api/credentials/{credential_id}
PUT /api/credentials/{credential_id}
DELETE /api/credentials/{credential_id}
```
设置页现已提供 OpenAI、DeepSeek 和 Ollama 预设,并在保存后自动获取、排序和去重模型列表。模型发现会区分凭据缺失、鉴权失败、限流、超时和上游不可用等错误。
## Agent Run
创建普通 Agent Run
```json
{
"input": "hello",
"provider_id": "mock",
"model": "mock-1",
"max_steps": 10
}
```
请求:
```text
POST /api/agent/runs
```
创建后通过以下接口读取状态和事件:
```text
GET /api/agent/runs/{run_id}
GET /api/agent/runs/{run_id}/events
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
POST /api/agent/runs/{run_id}/cancel
```
Run 与 AgentEvent 已写入 SQLite`run_id + sequence` 是幂等键。SSE 每帧包含 `id: sequence`;客户端可以通过 `Last-Event-ID` 请求头或 `after_sequence` 查询参数恢复缺失事件。Trace API 返回平铺事件、下一游标、分页状态、模型/工具调用统计、耗时、Token Usage 和创建 Run 时的配置快照,不负责生成前端树形布局。
运行时内存仍只保留最近 2000 个事件用于实时订阅,完整 Trace 以 SQLite 为准。AI Core 重启后,已经终止的 Run 可以继续查询和回放;重启前未终止的 Run 会收束为 `AGENT_PROCESS_RESTARTED`,避免永久停在 `running`。API Key、Authorization、Password、Secret、常见 `sk-`/Bearer 值在入库前脱敏。Event、Request 和配置快照中的超长字符串与集合会截断;作为查询事实来源的 `AgentRun` 只脱敏、不限长,保证重启前后 input/output 内容一致。
## Tool Calling
当前注册以下内置 Tool
```text
system.echo
math.add
notes.search
rag.search
notes.read
notes.create
notes.update
notes.list
notes.move
tasks.create
tasks.update
tasks.list
attachments.read
audio.transcribe
```
Mock Provider 使用下面的开发语法产生 Tool Call:
```text
/tool system.echo {"text":"hello tool"}
/tool math.add {"left":1,"right":2}
```
Agent Run 需要显式声明 `allowed_tools`
```json
{
"input": "/tool math.add {\"left\":1,\"right\":2}",
"provider_id": "mock",
"model": "mock-1",
"allowed_tools": ["math.add"]
}
```
Tool 参数由独立 Pydantic Model 再次校验。Tool 的异常、非法参数、超时和权限拒绝统一转换为 `ToolResult`,不会直接打断 API 进程。
`notes.search` / `rag.search` 返回的 Citation 会由 Agent Runtime 收集到 `AgentRun.citations`,并产生 `Citation` Trace Event。Note 写操作调用 `note_service`,检索调用 Retrieval Engine,不直接访问 SQLite。
## Permission
Permission Policy 当前支持:
```text
allow
confirm
deny
```
需要确认时,Agent 状态进入 `waiting_permission`,并发出 `PermissionRequired` 事件。前端使用:
```text
POST /api/agent/runs/{run_id}/permissions/{request_id}
```
提交以下决策之一:
```json
{"decision":"allow_once"}
{"decision":"allow_session"}
{"decision":"deny"}
```
默认需要确认的高影响权限包括 `notes.write``notes.delete``tasks.write``network.request``secrets.use`。权限命名空间采用白名单,未知权限默认拒绝。
## Knowledge / Retrieval 接入
Knowledge Core 与 Retrieval Core 已通过 Tool Registry 接入 Agent。Agent Runtime 仍只依赖 Tool Contract,不直接依赖具体服务:
```python
tool_registry.register(
definition=tool_definition,
arguments_model=arguments_model,
executor=executor,
)
```
第一批已经接入:
```text
notes.search
notes.read
notes.create
notes.update
notes.list
rag.search
```
写操作 Executor 调用 Knowledge Core Service,不直接访问 SQLite;检索 Executor 调用 Retrieval Core Service,不直接拼接 FTS5 或 sqlite-vec SQL。
## Extension Core
### Skill Runtime
Skill Package 由 `skill.yaml` 和可选 `prompt.md` 组成。安装时使用 Pydantic 校验 Manifest,并解析:
```text
permissions
tools
retrieval
model.required_capabilities
```
Skill 启用前检查 Tool 是否已注册、Tool 所需权限是否已在 Manifest 声明。创建 Agent Run 时,Skill Runtime 生成 Agent Configuration,注入 System Prompt、允许的 Tool、权限和 Retrieval Config。模型缺少 `chat``tool_calling` 等必要 Capability 时拒绝启动。
生命周期接口:
```text
GET /api/skills
POST /api/skills/install
GET /api/skills/{skill_id}
POST /api/skills/{skill_id}/enable
POST /api/skills/{skill_id}/disable
DELETE /api/skills/{skill_id}
```
### Plugin Runtime
第一阶段 Plugin Runtime 完成 Manifest 校验、安装、启用、停用、卸载和声明式 Tool Contribution。阶段 C 增加 stdio MCP Bridge:第三方代码不会直接 import 到 AI Core,而由独立子进程运行,通过换行分隔 JSON-RPC 完成 initialize、Tool 发现和调用。
启用 Plugin 时将 Tool 注册到统一 Tool Registry,并标记 `source=plugin`;停用或异常时注销 Tool。启用中的 Skill 依赖某 Plugin Tool 时,Plugin 不能直接卸载。
生命周期接口:
```text
GET /api/plugins
POST /api/plugins/install
GET /api/plugins/{plugin_id}
POST /api/plugins/{plugin_id}/enable
POST /api/plugins/{plugin_id}/disable
PUT /api/plugins/{plugin_id}/permissions
GET /api/plugins/{plugin_id}/host
POST /api/plugins/{plugin_id}/host/restart
DELETE /api/plugins/{plugin_id}
```
Plugin Manifest 中的权限只是声明,不代表已经授权。带权限的 Plugin 安装后进入 `permission_required`,Host 必须通过权限接口记录用户授权,之后才能启用。JSON Schema 在安装阶段校验,Tool 调用时再次校验实际参数。
MCP Tool 进入 Registry 前统一增加 `<plugin_id>.<remote_name>` 命名空间。Server 声明的 `notesagent/permission` 必须属于已知权限并出现在 Plugin Manifest;发现集合还必须与 Manifest Contribution 完全一致。启用失败会回滚全部 Tool 并关闭子进程,异常退出会把 Plugin 标记为 `error` 并立即注销对应 Tool。详细实现和 Fixture 操作见 [MCP Bridge 与 Plugin Host 开发说明](MCP-Bridge与Plugin-Host开发说明.md)。
内置示例 `text-tools` 注册 `text.uppercase`。内置 `knowledge-assistant` Skill 同时声明 `notes.search``text.uppercase`,用于验证完整链路:
```text
Skill Manifest
→ Agent Configuration
→ Tool Registry
→ Plugin Tool
→ Tool Result
→ Agent Loop
```
## 当前限制与下一步
前端智能体页面已经完成中文联调:运行状态、Agent Event、内置 Tool、Permission 和常用事件详情字段均通过集中标签映射展示中文;`notes.search` 等技术 ID 继续保留,便于与后端 Trace、日志和接口契约对应。
- 已实现 Mock、OpenAI-Compatible Chat Completions 与 Ollama AdapterOpenAI Responses 和 Anthropic Messages 尚未实现。
- Provider 配置暂存内存,后续通过 Repository 接入 SQLitePATCH 已支持用显式 `null` 清空 base URL、默认模型和凭据引用。
- Run/Trace 已通过 Repository 接入 SQLite;后续增加按保留策略归档和 Benchmark 引用保护。
- Permission 已有核心等待/恢复机制,前端确认 UI 已完成联调和中文展示。
- Task 已持久化到 SQLiteAttachment Tool 读取 Host 管理目录中的 UTF-8 文件。
- `audio.transcribe` 当前消费 Host 预生成的 transcriptfaster-whisper 与说话人分离仍按技术基线在第二阶段接入。
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
- 当前 Plugin Host 支持内置声明式 handler 和本地 stdio MCP ServerStreamable HTTP、OS 级沙箱、Plugin Command/Settings 与 UI Contribution 留在后续阶段。
+76
View File
@@ -0,0 +1,76 @@
# Benchmark 开发说明
> 所属模块:Knowledge / Retrieval Core(后端,负责人 yxx)。RAG Benchmark 已交付;Agent Benchmark 暂缓,待 Agent Runtime 完成后在同一契约下补齐。
## 定位
Benchmark Service 用受控 Dataset 对检索引擎做可复现评测:创建即返回 queued、后台 asyncio.Task 执行、SSE 实时推送进度、结束后产出结构化报告。CLI、测试与前端报告页复用同一 Service,不各自实现指标。
## 接口
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/benchmarks/datasets?kind=rag` | 枚举受控目录下的 Dataset 元信息 |
| POST | `/api/benchmarks/rag/runs` | 创建 RAG Benchmark202 |
| GET | `/api/benchmarks/runs?kind=&status=&limit=&offset=` | 分页获取运行记录 |
| GET | `/api/benchmarks/runs/{run_id}` | 状态与指标摘要 |
| GET | `/api/benchmarks/runs/{run_id}/events` | SSE 进度与 Case 结果 |
| POST | `/api/benchmarks/runs/{run_id}/cancel` | 取消运行 |
| GET | `/api/benchmarks/runs/{run_id}/report` | 结构化完整报告 |
Agent Benchmark 的 `/api/benchmarks/agent/runs` 未暴露(暂缓),不在 OpenAPI 注册占位接口。
## Dataset
Dataset 来自 `settings.benchmark_datasets_path`(默认 `backend/data/benchmarks`),API 不接受调用方提交任意路径。按文件名 stem 精确匹配 `{dataset_id}.json`,与请求无关文件的损坏(JSON 语法错误、UTF-8 解码错误、顶层非对象)不会阻断加载;只有目标文件本身损坏才返回 `BENCHMARK_DATASET_INVALID`
RAG Case 结构:`case_id``query``expected_note_ids``expected_block_ids``citation_required``tags``citation_required=true` 时必须声明 `expected_block_ids`,否则无法计算 Citation Hit Rate。
## 运行生命周期
`queued → running → completed | failed | cancelled`
- 创建时校验索引兼容性:索引非空、Embedding model/dim 与当前引擎一致、vector/hybrid 时向量索引非空;不满足返回 `BENCHMARK_INDEX_INCOMPATIBLE`(409),避免把环境/索引错误误判为检索质量差。
- 内存注册表上限 `MAX_RUNS=100`,超限只淘汰终态 run;满容量且全为活动 run 时返回 `BENCHMARK_CAPACITY_EXCEEDED`429)。
- 失败/取消只向公开响应暴露项目错误码与安全消息,详细异常进入日志,不通过 HTTP/SSE 返回。
## 指标
RAG 按 (mode, case, repeat) 逐样本计算,再按 mode 聚合:
- 质量:`hit_at_1``hit_at_5``recall_at_k``mrr``citation_hit_rate`
- 延迟:`p50_latency_ms``p95_latency_ms`(仅统计成功样本);
- 样本构成:`total_cases``successful_cases``failed_cases``failure_rate`
失败样本按零分计入质量指标分母,报告据此可知实际分母,避免把执行失败误判为检索质量差。
## 事件与 SSE
事件流:`RunStarted → CaseCompleted* → RunCompleted | RunFailed | RunCancelled`
`GET /api/benchmarks/runs/{run_id}/events` 支持 `Last-Event-ID``?after_sequence=` 游标恢复(复用 Agent SSE 的解析逻辑),`RunCompleted` / `RunFailed` / `RunCancelled` 为终止事件,收到后断流。
## 错误码
```text
BENCHMARK_DATASET_NOT_FOUND
BENCHMARK_DATASET_INVALID
BENCHMARK_INDEX_INCOMPATIBLE
BENCHMARK_CAPACITY_EXCEEDED
BENCHMARK_RUN_NOT_FOUND
BENCHMARK_RUN_FAILED
BENCHMARK_CASE_EVALUATION_FAILED
```
## 配置快照
报告与运行记录保存 `config_snapshot`dataset hash/version、modes、retrieval 参数、Embedding model/version/dim、Reranker、索引元数据、App 版本与环境、Python 版本,保证不同实验结果可复现。
## 测试
```powershell
cd backend
uv run pytest -q
```
`tests/test_benchmark.py` 覆盖数据集注册与校验、指标纯函数、端到端运行、取消、索引兼容、容量与失败样本聚合;`tests/test_retrieval.py` 覆盖 FTS 阈值与分页 total 一致性。
@@ -0,0 +1,237 @@
# Knowledge Core 与 Retrieval Core 开发说明
> 本文档用于团队开发和模块联调,记录 Knowledge Core / Retrieval Core 已经落地的
> 模块边界、数据模型、接口与使用方式,对应分工表中的杨星萱。
> 更新日期:2026-09-01。第一阶段 Knowledge/Retrieval 主链路已经完成,并已接入 Agent Tool Registry;完整后端回归基线为 92 项测试通过。
## 当前实现
当前已经建立第一条可运行的检索链路:
```text
Markdown Vault
→ Markdown ParserBlock 切分 / heading_path / offset
→ SQLitenotes / blocks / FTS5+ sqlite-vecvec_blocks
→ FTS5(BM25) + Vector(cosine) 双路召回
→ RRF 融合 → Reranker 精排 → Metadata Filter → 分页
→ Citation + Snippet
```
对应代码:
```text
backend/app/
├── constants.py EMBEDDING_DIM = 128
├── config.py Settingsdata_dir / db_path / vault_path
├── textutils.py 分词、FTS 查询串、摘要片段
├── repository.py notes / blocks / blocks_fts 读写(领域记录层)
├── database/
│ ├── db.py SQLite 连接 + 事务 + 加载 sqlite-vec
│ └── migrations.py 轻量迁移(建 notes / blocks / blocks_fts / vec_blocks
├── knowledge/
│ └── parser.py Markdown → ParsedNote / NoteBlock
├── retrieval/
│ ├── embedding.py EmbeddingProvider 接口 + HashEmbeddingProvider
│ ├── reranker.py RerankerProvider 接口 + LexicalReranker
│ ├── vectorstore.py VectorStore 接口 + SqliteVecStore
│ ├── hybrid.py RRF 融合、分数归一化
│ └── engine.py RetrievalEngine(编排检索全流程)
└── services/
├── note_service.py Note CRUD + 索引编排
└── index_service.py 全量重建、索引状态、任务查询
```
Router`backend/app/routes.py`)只负责 HTTP 与错误转换;`/api/notes``/api/search`
`/api/index/*` 已接入上述服务,其余端点仍由对应模块负责。
## 模块边界
本模块负责(杨星萱):
- Markdown Vault、Note / NoteBlock 数据模型与解析;
- SQLite 元数据、FTS5 全文检索、sqlite-vec 向量检索;
- Embedding、Hybrid RAG、RRF、Reranker、Metadata Filter
- Citation 与笔记定位;
- 检索测试数据。
以下内容保持接口,不在本模块实现:
- Agent Runtime、Tool Registry、Permission:由 Agent Core 提供;
- Provider Adapter、多模型协议:由 Model Core 提供;
- Skill / Plugin 生命周期:由 Extension Core 提供;
- 文件系统与 API Key 明文读取:由 Rust Host 提供。
## 数据模型与稳定 ID
- 笔记元数据存 `notes` 表;正文切成 Block 存 `blocks` 表;`blocks_fts` 是 FTS5 虚拟表;
`vec_blocks` 是 sqlite-vec 的 `vec0` 虚拟表。
- 稳定 ID(内容/路径不变则 ID 不变):
```text
note_id = "note_" + sha256(rel_path)[:16]
block_id = "blk_" + sha256(note_id | heading_path | content)[:16]
citation_id = "cit_" + block_id
```
> 注意:`note_id` 是稳定业务 ID;移动接口会保留原 ID,文件路径不能代替业务实体 ID。
每个 Block 记录 `heading_path`(章节路径)、`start_offset` / `end_offset`(相对原文的
字符偏移,用于前端跳转高亮)、`content_hash``token_count`
## 分层依赖
按团队约定,依赖方向为:
```text
RouterHTTP/错误转换)
→ Servicenote_service / index_service 编排)
→ Repositorynotes/blocks/FTS5 访问) ← 只在此层访问 SQLite
→ Retrieval Infraembedding/reranker/vectorstore ← vec0 只在 vectorstore 层访问
```
- 检索 Executor 调用 `RetrievalEngine`,不直接拼接 FTS5 或 sqlite-vec SQL
- 向量实现只在 `retrieval/vectorstore.py`DB 访问只在 `repository.py`
## 分词与中文检索
FTS5 默认 `unicode61` 不切分中文,因此统一预分词:ASCII 单词 + CJK 单字 + CJK 相邻双字。
写入与查询走同一套拆分(`textutils.segment` / `textutils.match_query`),实现中文子串/词级召回。
## Embedding / Reranker(轻量实现,接口可替换)
当前是「统一接口 + 轻量实现」,后续接入真实模型时替换实例即可,不改变上层调用:
- `EmbeddingProvider``embed_documents` / `embed_query`)→ `HashEmbeddingProvider`
确定性特征哈希 + L2 归一化,`dim = 128``model_id = "hash-v1"`
- `RerankerProvider``rerank`)→ `LexicalReranker`:分数归一化 + 词重叠加权,
`model_id = "lexical-v1"`
- `VectorStore``upsert` / `delete` / `search` / `clear`)→ `SqliteVecStore`
sqlite-vec `vec0`,相似度取余弦 `score = 1 - distance² / 2`
## 检索流程
`RetrievalEngine.search(request)`
1. 按 `mode` 收集候选:`fts` / `vector` 各取 Top `CANDIDATE_POOL = 50`
2. `hybrid` 用 RRF`k = 60`)融合两路排序;
3. Metadata Filter`folders` / `note_ids` / `tags` / 时间范围;
4. `hybrid` 再经 Reranker 精排,其余模式按分数排序;
5. 分数归一化 → 分页 → 组装 `Citation``Snippet`
模块级单例 `engine = RetrievalEngine(HashEmbeddingProvider(), LexicalReranker(), SqliteVecStore())`
检索入口统一为 `engine.search(request)`
## 接口清单
### Note
```text
GET /api/notes?limit=&offset=&folder=&tag=
POST /api/notes
GET /api/notes/{note_id}
PATCH /api/notes/{note_id}
DELETE /api/notes/{note_id}
POST /api/notes/{note_id}/move (已实现,保留 note_id
```
创建笔记:
```json
POST /api/notes
{"title": "Python 基础", "markdown": "# 变量\n\nPython 是动态类型语言。", "folder": "编程", "tags": ["python"]}
```
### Search
```text
POST /api/search
```
```json
{"query": "向量数据库", "mode": "hybrid", "limit": 10}
```
`mode``fts` / `vector` / `hybrid`;可选 `folders` / `note_ids` / `tags` / 时间范围 /
`include_snippet`。结果项含 `score``snippet``citation``citation_id``file_path`
`heading_path``start_offset``end_offset`)。
### Index
```text
GET /api/index/status
POST /api/index/rebuild
GET /api/index/jobs/{job_id}
```
重建(MVP 同步执行,直接返回 `completed`):
```json
POST /api/index/rebuild
{"scope": "all"}
```
## 检索测试数据
样例 Vault 位于 `backend/data/vault/`,覆盖中英文、多级标题、frontmatter、子目录与不同 tags
```text
项目说明.md
编程/Python 基础语法.md
编程/向量数据库与相似度检索.md
产品/RAG 检索增强与引用定位.md
日记/2026-08-27 周会.md
```
示例查询:
```text
POST /api/search {"query": "向量数据库", "mode": "hybrid"} → 命中《向量数据库与相似度检索》
POST /api/search {"query": "检索", "mode": "fts", "folders": ["产品"]} → 只返回 产品/ 下笔记
POST /api/search {"query": "向量", "mode": "hybrid", "tags": ["向量"]} → 按 tag 过滤
```
## 测试
```powershell
cd backend
uv run pytest -q
```
当前后端完整测试共 120 个用例通过(单元 + 端到端)。测试通过 `tests/conftest.py` 的 autouse fixture 把
数据目录/DB/Vault 重定向到临时目录,不读写真实 `backend/data`,任何本机状态下结果确定。
## 配置
```text
APP_DATA_DIR 默认 backend/data
APP_DB_PATH 默认 backend/data/app.db
APP_VAULT_PATH 默认 backend/data/vault
APP_ATTACHMENTS_PATH 默认 backend/data/attachments
```
运行期生成的 `backend/data/*.db*` 已被 `.gitignore` 忽略,vault 下的 Markdown 测试数据会提交。
## 接入约定(Agent / 其他模块)
Agent 通过注册 Tool 接入本模块,不让 Agent Runtime 直接依赖具体实现:
```text
notes.search / notes.read / notes.create / notes.update / notes.list / notes.move
rag.search
```
- 写操作 Executor 调用 `note_service`,不直接访问 SQLite
- 检索 Executor 调用 `engine.search(request)`,不直接拼接 FTS5 或 sqlite-vec SQL。
## 当前限制与下一步
- `move` 接口已实现,移动文件后保持原 `note_id`,同时原子更新 Block、FTS 和向量索引。
- Citation 的 `start_offset` / `end_offset` 使用 UTF-16 code unit,直接兼容浏览器编辑器。
- Markdown 分块会识别 fenced code block,不会把代码中的 `#` 注释误判为标题。
- Embedding / Reranker 为轻量实现,后续替换为真实模型(接口不变)。
- 小语料下 hybrid 检索召回偏宽(向量 Top-K 覆盖全部 block),可加相关性阈值收紧。
- 重建为同步 + 全量,后续接入增量索引与异步任务队列。
- RAG Benchmark 已建立:`POST /api/benchmarks/rag/runs` 创建即返回 queued、后台 Task 执行,
通过 SSE 实时推送进度,报告含逐 Case 结果与 `total_cases` / `successful_cases` / `failed_cases` / `failure_rate`
- Agent Benchmark 暂缓,待 Agent Runtime 完成后交付。
@@ -0,0 +1,282 @@
# MCP Bridge 与 Plugin Host 开发说明
> 更新日期:2026-09-01。本文记录第二阶段阶段 C 已实现的本地 stdio MCP Bridge、隔离 Plugin Host、Tool Contract 转换和离线测试方式。Plugin Command 与 Settings 属于阶段 D,不在本文实现范围内。
## 1. 目标与实现状态
阶段 C 的目标是让外部 MCP Server 进入既有 Plugin、Tool、Permission、Agent 和 Trace 链路,同时避免 Agent Runtime、前端或 Benchmark 直接依赖 MCP 原始消息。
当前链路:
```text
Plugin Manifest
→ Plugin Runtime
→ 独立 stdio MCP Server 进程
→ initialize / capability negotiation
→ tools/list 分页发现与校验
→ NotesAgent ToolDefinition
→ Tool Registry / Permission Manager
→ Agent Runtime / Agent Trace
```
已经实现:
- 本地 stdio 子进程启动、关闭和异常退出检测;
- UTF-8、换行分隔的 JSON-RPC 2.0 消息;
- initialize、协议版本与 tools capability 协商;
- `notifications/initialized`
- 分页 `tools/list`
- `tools/call`、业务错误与 JSON-RPC 错误转换;
- 超时和 `notifications/cancelled`
- Tool 命名空间、JSON Schema、权限和 Manifest 集合校验;
- Host 状态查询、重启和异常后的 Tool 自动注销;
- stderr 隔离、环境变量裁剪、消息及结果大小限制;
- 无网络、无密钥的确定性 MCP Fixture。
实现依据为 MCP 官方 [Lifecycle 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)、[Transports 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 和 [Tools 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。
## 2. 代码位置
```text
backend/app/extensions/mcp.py
stdio 进程、JSON-RPC、MCP 生命周期、发现、调用和 Host 状态
backend/app/extensions/runtime.py
Plugin Manifest、权限、MCP Tool 批量注册/回滚和生命周期集成
backend/app/agent/tools.py
内部 Tool 参数校验、结构化执行错误和线程安全 Registry
backend/extensions/fixtures/mcp-echo/
确定性 stdio MCP Server 与 Plugin Manifest
```
## 3. Plugin Manifest
MCP Plugin 的后端配置示例:
```yaml
id: example-mcp
name: Example MCP
version: 1.0.0
permissions:
- notes.read
contributes:
tools:
- example-mcp.search
backend:
type: mcp
transport: stdio
command: uvx
args: [--isolated, --from, example-mcp==1.2.3, example-mcp]
startup_timeout_seconds: 60
tool_timeout_seconds: 30
```
约束:
- 阶段 C 只接受 `type: mcp``transport: stdio`
- 命令和参数通过数组直接传给 `subprocess.Popen`,不经过 Shell
- Python 包形式的 MCP Server 推荐使用 `uvx --isolated --from <package>==<version> <command>`,固定版本并与 NotesAgent 项目环境隔离;
- Plugin 包内自带且不需要第三方依赖的 Python 脚本可以使用 `python server.py`Node、Rust 等 Server 继续使用各自受控启动器,因此 Host 不强制所有 MCP 都经过 `uvx`
- PATH 中的 executable 使用名称,例如 `uvx``python``node`
- manifest 中带目录的 executable 必须解析到 Plugin 包内部;
- `contributes.tools` 使用 `<plugin_id>.<remote_name>`
- 安装阶段只读 Manifest,不启动第三方进程;
- 完成用户授权后,`enable` 才启动 Host。
## 4. 生命周期
### 4.1 启动
启用 MCP Plugin 时依次执行:
1. 检查 Plugin 声明权限是否全部获得授权;
2. 检查 Manifest 声明的 Tool ID 是否与现有 Registry 冲突;
3. 启动独立 stdio Server
4. 发送 `initialize`
5. 校验协商版本和 `tools` capability
6. 发送 `notifications/initialized`
7. 分页读取 `tools/list`
8. 校验全部 Tool
9. 确认发现集合与 Manifest 完全一致;
10. 将完整集合注册到 Tool Registry
11. Plugin 和 Host 进入 `ready`
任何步骤失败都会注销本轮已注册 Tool、关闭子进程并把 Plugin 标记为 `error`,不会留下半启用状态。
### 4.2 停止与异常退出
停用、卸载或应用关闭时,先注销 Tool,再关闭 stdin,等待 Server 正常退出。超时后依次 terminate 和 kill。
Server 异常退出、stdout 出现非 JSON-RPC 内容或发送超大协议消息时:
- 未完成请求返回 `PLUGIN_HOST_UNAVAILABLE`
- Host 进入 `unhealthy`
- Plugin 进入 `error`
- 对应 Tool 从 Registry 中立即注销;
- 用户可以调用 Host Restart 接口重新协商和发现。
Server 发送 `notifications/tools/list_changed` 时不会直接信任新集合。当前实现先把 Host 标记为不健康并注销旧 Tool,要求通过 Restart 重新执行完整发现与校验。
## 5. Tool Contract 转换
MCP Tool
```json
{
"name": "search",
"description": "Search notes",
"inputSchema": { "type": "object", "properties": {} },
"_meta": { "notesagent/permission": "notes.read" }
}
```
进入系统后转换为:
```json
{
"name": "example-mcp.search",
"description": "Search notes",
"parameters": { "type": "object", "properties": {} },
"permission": "notes.read",
"source": "plugin"
}
```
转换规则:
- 远端名称必须能转换为合法且稳定的项目 Tool ID;
- `inputSchema` 必须是有效的 object JSON Schema
- `additionalProperties``patternProperties` 等动态字段先由完整 JSON Schema 校验,Pydantic 参数载体不会再次误拒绝合法字段;
- `_meta.notesagent/permission` 必须属于项目已知权限;
- Tool 权限必须同时出现在 Plugin Manifest 中;
- Agent 仍通过 Tool Registry 执行参数校验、Permission、超时和 Trace
- MCP `structuredContent` 存在时映射为内部 output;否则保留为受控 `content` 数组;
- MCP `isError: true` 映射为 `MCP_TOOL_CALL_FAILED`
- 结果超过 256 KiB 映射为 `MCP_TOOL_RESULT_TOO_LARGE`
## 6. 隔离与安全边界
当前隔离是“独立进程 + 协议边界”,不是完整的操作系统沙箱。
`uvx` 解决的是 Python 工具依赖隔离:它等价于 `uv tool run`,在 uv 缓存中使用可丢弃的独立虚拟环境。它不会限制 Server 读取用户文件、访问网络、创建子进程或调用系统 API,因此不能代替安全沙箱。当前开发模式下,首次 `enable` 尚未缓存的包可能访问包索引,因此示例使用 60 秒启动上限;生产实现不得依赖该行为,必须在用户确认后的安装/更新阶段预取和验证固定版本,运行阶段只启动已准备好的环境。
已经执行的保护:
- 第三方模块不 import 到 AI Core
- 子进程 `cwd` 固定为 Plugin 包目录;
- 不使用 Shell 拼接命令;
- 不把 Provider API Key、`APP_DB_PATH`、Vault 路径和其他宿主环境变量传入子进程;
- stderr 与 JSON-RPC stdout 分离,stderr 不进入 API 和 Agent Trace
- stdout 只能发送合法 MCP JSON-RPC
- stdout 在读取完整行前即应用有界读取,单条协议消息上限 2 MiB;stderr 也按固定大小分块读取;
- 单次 Tool Result 上限 256 KiB
- MCP Tool 不绕过 Permission Manager 和 Agent Tool Timeout。
- 调用被 Agent 取消时,同时通知 Server 并唤醒本地 pending Queue,阻塞线程不会继续占用线程池直至远端超时。
当前尚未提供容器、受限系统账户、seccomp、Windows AppContainer 或 macOS Sandbox,因此 Plugin 进程仍具有当前操作系统用户授予的一般文件访问能力。正式社区插件分发或“一键安装”前必须完成以下安全门槛:
- 由 Tauri/Rust Host 统一启动进程并提供平台级文件、网络、子进程和资源配额限制;
- 安装/更新时完整展示 executable 与全部参数,明确警告并要求用户主动确认;
- 固定包来源和版本,增加包哈希/签名与可信发布者校验;
- 默认禁止访问 Vault、凭据和宿主环境,只通过声明 Permission 与受控 Host API 授权;
- 关闭 Host 时终止完整进程树,不只结束直接子进程。
在这些门槛完成前,当前 MCP Host 只适用于内置 Fixture、团队可信插件和开发联调;不得把它描述为可以安全执行任意社区代码。上述安装确认要求遵循 MCP [SEP-1024](https://modelcontextprotocol.io/seps/1024-mcp-client-security-requirements-for-local-server-)`uvx` 行为依据 uv 官方 [Using tools](https://docs.astral.sh/uv/guides/tools/) 文档。
后端通过 `APP_ENVIRONMENT` 强制该边界:只有 `development` 可以启动当前未沙箱化的 MCP Host;其他环境返回 `403 MCP_TRUST_APPROVAL_REQUIRED`,且不会创建进程或注册 Tool。后续 Tauri/Rust Host 提供沙箱与绑定完整命令摘要的可信许可后,再替换此临时门禁。
## 7. Host API
```http
GET /api/plugins/{plugin_id}/host
POST /api/plugins/{plugin_id}/host/restart
```
状态响应包含:
```text
plugin_id
backend_type / transport
status
tools_count
started_at / last_seen_at
protocol_version
server_name / server_version
error
```
状态值:
```text
stopped
starting
ready
unhealthy
error
```
Restart 返回 `202 OperationResponse`。接口返回前已完成本地 Host 重启和 Tool 重新发现;`message` 中给出最终 Host 状态。Restart 只用于运行中或异常 Host;用户主动停用、尚未启用或等待授权的 Plugin 返回 `409 PLUGIN_HOST_UNAVAILABLE`,必须通过 Enable 明确启动。
## 8. 离线 Fixture
Fixture 位于:
```text
backend/extensions/fixtures/mcp-echo
```
它提供:
- `mcp-fixture.echo`:返回 structuredContent
- `mcp-fixture.fail`:返回 `isError: true`
- `mcp-fixture.sleep`:验证超时和取消;
- `mcp-fixture.large`:验证结果大小上限;
- `mcp-fixture.environment`:验证宿主 Secret/路径没有进入子进程;
- `mcp-fixture.exit`:验证异常退出、Tool 注销和 Restart。
Fixture 的 `tools/list` 使用两页响应,用于覆盖分页发现。测试还会启动缺少 tools capability、返回无效 Schema/initialize result,以及输出超长无换行 stdout 的变体。
## 9. 验证
```powershell
cd backend
uv run python -m compileall -q app
uv run pytest
cd ../frontend
pnpm test
pnpm type-check
pnpm build
```
阶段 C 新增测试覆盖:
- initialize、版本和 capability negotiation
- 分页 `tools/list` 与命名空间映射;
- Permission、JSON Schema 与 Contribution 集合;
- Tool 成功、业务错误、结果过大和超时;
- Agent 取消后 pending 等待线程及时释放;
- `additionalProperties` 动态参数保持 JSON Schema 语义;
- Agent Runtime 调用 MCP Tool 并写入正式 Trace
- Secret/Vault 环境隔离;
- Server 异常退出、Tool 注销和 Host Restart
- 缺少 capability、无效 initialize result、无效 MCP Schema 和超长无换行 stdout
- disabled Plugin 不会被 Host Restart 隐式重新启用;
- OpenAPI 发布 Host 状态和重启路径。
## 10. 当前边界与后续阶段
阶段 C 不包含:
- Streamable HTTP MCP transport
- Resources、Prompts、Sampling、Elicitation 和 MCP Tasks
- Plugin Command 与 Settings Contribution
- Secret Reference 注入;
- Plugin Registry 持久化、签名与社区来源校验;
- 操作系统级沙箱;
- 一键安装前的完整命令展示与确认 UI;
- Tool 列表热更新的无中断替换。
阶段 D 将在当前 Plugin Runtime 上继续增加 Command、Settings、Secret Contract 和命名空间 Storage,不修改 Agent 使用内部 Tool Contract 的原则。
@@ -0,0 +1,124 @@
# 前端写作体验优化开发说明
> 更新日期:2026-08-30。本文所述优化均已进入当前分支;前端完整回归基线为 14 项测试通过,TypeScript 检查和 Vite 生产构建通过。
## 1. 本次目标
本次优化聚焦笔记写作主流程,不调整后端接口:
- 将界面中的装饰性 Emoji 统一替换为 Element Plus 图标;
- 将“写作”模式由 Markdown 源码与预览双栏改为单一可视化编辑区;
- 为写作区增加标题、加粗、斜体、有序列表、无序列表工具栏;
- 写作页代码块默认展开为可编辑状态;只读 Markdown 区域使用 Shiki 提供亮暗主题高亮。
## 2. 实现说明
### 2.1 图标体系
新增 `AppIcon.vue` 作为轻量图标出口,页面直接传入 `@element-plus/icons-vue` 组件。侧边栏、文件树、Vault 入口、主题按钮、空状态及扩展列表不再使用 Emoji 表达操作含义。
这样处理后,图标尺寸、颜色和主题状态都由 CSS 统一控制,也避免不同系统 Emoji 字体造成的显示差异。
### 2.2 可视化 Markdown 编辑器
写作模式使用 Milkdown Crepe 渲染 Markdown 文档,磁盘中仍保存标准 Markdown 文本。编辑器监听 Markdown 更新并写回 Pinia 状态,继续复用原有自动保存逻辑。
“源码”模式保留为独立模式,便于需要精确编辑 Markdown 的用户使用;写作模式中不再同时展示 Markdown 源码。
编辑器按当前文件路径重新挂载,保证切换文件、切换源码模式后,展示内容与 Store 中的最新 Markdown 一致。
### 2.3 Markdown 工具栏
写作区顶部提供以下基础格式操作:
- H1 至 H6 标题下拉选择,标题默认使用粗体显示;
- 加粗;
- 斜体;
- 有序列表;
- 无序列表;
- 12 px 至 32 px 字号选择;
- 行内代码与代码块;
- 行内公式与公式块;
- 链接插入。
标题、加粗、斜体和列表工具调用 Milkdown Command 修改当前选区或块级结构,因此能正确处理光标、选区和嵌套列表。工具栏使用常见的 `H``B``I``1.``•` 排版符号,减少图标语义歧义。
标准 Markdown 没有字号语法。字号功能仅在用户已选择文本时生效,并将内容写为兼容 Markdown 的内联 HTML
```markdown
<span style="font-size: 18px">选中的文本</span>
```
Milkdown 自定义插件在写作模式中隐藏 HTML 标记,并通过 ProseMirror Decoration 显示实际字号;切换到源码模式时可以直接看到并修改上述 Markdown 内容。
字号栏同时提供预设下拉框和 `896 px` 数值输入框。输入数值后按 Enter 或点击“应用”即可写入当前选区。标题下拉框提供“正文”选项,用于将标题恢复为普通段落;正文显式使用正常字重,只有 H1 至 H6 默认加粗。
选中文本后出现的 Crepe 浮动格式栏使用应用正文前景色、实色描边和悬浮强调色,避免亮暗主题下图标对比度不足。
浮动栏由 Crepe Tooltip Provider 挂载,不保证位于 Vue scoped 样式容器内部,因此对比度规则使用全局 `.milkdown-toolbar` 选择器,并通过主题变量适配亮暗模式。顶部格式按钮统一在 `pointerdown` 阶段阻止默认焦点迁移并执行命令,确保点击工具栏时不会丢失编辑器选区。
有序列表与无序列表使用相同尺寸、相同线条结构的经典列表符号,仅通过左侧的数字或圆点区分类型。亮色主题下,表格边框使用更高对比度的文本辅助色,列表序号、圆点及任务图标也改用辅助文本色并增加字重。
编辑器左侧加号打开的块菜单已完成中文本地化:
- “文本”分组包含正文、H1 至 H6、引用和分割线;
- “列表”分组包含无序列表、有序列表和任务列表;
- “插入”分组包含图片、代码块、表格和公式块。
代码语言搜索、复制操作、链接编辑及公式确认浮层也统一使用中文文案。
### 2.4 代码块编辑与 Shiki 高亮
代码高亮使用 Shiki 的 JavaScript 正则引擎,并只注册第一阶段常用语言:Markdown、HTML、CSS、JavaScript、TypeScript、JSON、Python、Shell 和 SQL。未知语言回退为 Markdown 语法展示,不阻塞整篇内容渲染。
Shiki 同时生成 `github-light``github-dark` 两套 CSS 变量。主题页提供“跟随主题 / GitHub Light / GitHub Dark”选项,通过根节点 `data-code-theme` 切换对应变量,无需重新执行高亮。偏好写入 `editor-appearance`,内置主题和后续主题包也可通过 `ThemeConfig.code_theme` 指定默认代码主题。
代码主题选择器下方使用真实的 `MarkdownContent` 和 Shiki 渲染 TypeScript 示例,选项变化后立即展示对应 GitHub 高亮效果。该预览只存在于主题设置页,不会恢复写作页代码块的额外预览面板。
代码块容器使用 GitHub 风格的背景、边框、6px 圆角、16px 内边距和等宽字体;相关颜色由 `--color-code-*` Token 控制,方便主题商店覆盖。
高亮结果同时生成 `github-light``github-dark` 颜色变量。根节点的 `data-theme` 变化后由 CSS 选择对应颜色,因此切换主题无需重新解析整篇 Markdown。
写作编辑器中的普通代码块进入文档后直接展开 CodeMirror 编辑区,不再先显示 Shiki 预览,也不再提供“编辑代码/查看高亮”切换,减少一次多余操作。公式块仍由 Milkdown 的 LaTeX 功能负责编辑和渲染。
Shiki 仅应用于:
- AI 对话中的 Markdown 代码块。
- Search、智能体等复用 `MarkdownContent` 的只读 Markdown 代码块。
Markdown HTML 仍在写入 DOM 前经过 DOMPurify 清理。
## 3. 新增依赖
- `@element-plus/icons-vue`:统一界面图标;
- `@milkdown/crepe``@milkdown/kit`:可视化 Markdown 编辑器及命令;
- `shiki``@shikijs/langs``@shikijs/themes``@shikijs/engine-javascript`:代码高亮和按需语言注册。
## 4. 验证记录
`frontend` 目录执行:
```bash
pnpm build
pnpm test
```
验证结果:TypeScript 类型检查与 Vite 生产构建均通过。当前前端完整回归测试共 14 项;其中写作与文件切换相关回归覆盖:
- 顶部工具栏对选区应用加粗;
- 浮动工具栏对选区应用斜体;
- 自定义字号输入写入 Markdown;
- 标题恢复为普通正文;
- 连续切换文件后渲染新文件内容;
- 从文件树连续点击时,活动路径与编辑器内容同步切换;
- 欢迎笔记的异步初始化不会覆盖用户刚点击的文件。
文件切换失效包含两层原因。第一层是旧实现先更新 `currentFilePath`、后等待文件内容,导致编辑器使用新路径和旧内容提前重建;现在改为文件读取成功后一次性提交路径和内容。第二层是工作区欢迎笔记的异步初始化结束后会无条件设为活动文件,可能覆盖用户在此期间的真实点击;现在点击文件时立即同步工作区活动路径,默认初始化仅在用户尚未选择文件且欢迎笔记确实加载成功时提交。文件读取失败时则恢复点击前的活动文件。
本地内置浏览器测试运行时因环境资源路径缺失未能启动,因此本次没有把自动化交互测试列为已通过项。合并前建议人工检查一次工具栏选区操作、文件切换同步,以及跟随主题、GitHub Light、GitHub Dark 三种代码块设置下的显示效果。
## 5. 后续建议
- 根据真实文档规模评估 Milkdown 与只读 Markdown 高亮模块的懒加载拆包;
- 为工具栏补充撤销、重做、引用、行内代码和链接;
- 增加编辑器选区命令与文件切换的组件测试。
@@ -0,0 +1,209 @@
# 前端壳子与接口层开发说明
> 更新日期:2026-08-30
> 适用范围:Vue 3 + TypeScript 页面、Workspace、公共 Service、FastAPI 接口适配和 SSE。
> 文档用途:帮助团队理解当前前端可用能力、模块边界、启动方式和后续页面开发入口。
## 1. 当前实现状态
当前前端已经形成一条可安装、可类型检查、可生产构建和可联调的基础链路:
```text
Vue Router
→ App Shell
→ Pinia Store
→ Service / FastAPI DTO Adapter
→ HTTP 或 SSE
→ FastAPI
```
当前已经落地的页面和公共界面包括:
- Vault 入口页;
- 应用标题栏、主侧边栏、辅助侧边栏和状态栏;
- Workspace 文件树;
- Markdown 写作/源码模式、手动保存和自动保存状态;
- 文件打开、新建、删除和重命名交互壳子;
- Search 查询、筛选、结果列表和 Citation 定位;
- Chat 会话、Provider/Model/Skill 选择和 SSE 输出;
- Agent Run 创建、Trace、取消和权限确认;
- Task 筛选、创建、编辑、状态切换和删除;
- Skill、Plugin 生命周期管理;
- Theme 预览、切换和编辑器 Token 覆盖;
- Settings 的通用、编辑器、Provider、索引、权限和 AI Core 诊断分区;
- 可收起主导航、功能型二级侧栏、状态栏和 `Ctrl+P` 命令面板。
- Milkdown 可视化写作、CodeMirror 源码/代码块编辑、Markdown 格式栏和 Shiki 只读代码高亮;
- OpenAI、DeepSeek、Ollama 预设、自动模型发现和开发阶段加密 API Key 输入;
- 智能体页面、运行状态、事件、工具和权限详情的中文展示。
原统一占位页已经删除,所有已注册业务路由均指向真实页面。Web Workspace 已通过 FastAPI 连接后端配置的单一真实 Vault,不再回退 Mock 数据;Tauri 多 Vault、原生目录选择、Stronghold 和桌面窗口能力仍在桌面容器阶段接入,不影响页面与 Store 的调用边界。
## 2. 目录与职责
```text
frontend/src/
├── components/common/ App Shell、导航、命令面板与扩展公共组件
├── contracts/index.ts UI View Model 与 FastAPI Wire DTO
├── features/ 按页面领域拆分的业务组件
├── features/editor/ 编辑器头部与写作/源码编辑区
├── features/vault/ Vault 入口
├── features/workspace/ Workspace 与递归文件树
├── router/index.ts 页面路由和 Vault Guard
├── services/ HTTP、SSE、DTO 映射和模块 API
├── stores/ Pinia 状态
└── styles/tokens.css Design Token
```
职责约定:
- Component 不直接拼接后端 URL
- Store 负责页面状态和业务操作编排;
- Service 负责 HTTP/SSE 调用以及 Wire DTO 到 View Model 的转换;
- `contracts/index.ts` 同时保留界面模型和以 `Api` 开头的 FastAPI DTO,两者不能混用;
- OpenAPI `/openapi.json` 是后端 Wire Contract 的最终依据。
## 3. 路由与页面壳子
已注册路由:
```text
/
/workspace
/search
/chat
/agent/runs/:runId?
/tasks
/extensions/skills
/extensions/plugins
/themes
/settings
```
除 Vault 入口外,其余路由需要先打开 Vault。全部路由均使用懒加载真实页面组件,既保持首屏包体可控,也避免占位页面掩盖缺失实现。
## 4. 页面实现边界
| 页面 | 当前可用能力 | 主要 Store / Service |
| --- | --- | --- |
| Workspace | 文件树、新建、重命名、删除、打开、编辑、保存、模式切换 | `workspaceStore``editorStore``workspaceService` |
| Search | FTS/Vector/Hybrid、文件夹与标签筛选、结果定位 | `searchStore``searchService` |
| Chat | 会话选择、模型配置、RAG、Skill、SSE、Citation | `chatStore``providerStore``chatService` |
| Agent | Run 配置、Tool 选择、Trace SSE、权限确认、取消 | `agentStore``agentService` |
| Tasks | 状态筛选、CRUD、完成与恢复 | `taskStore``taskService` |
| Skills | 列表、详情、安装、启停、卸载 | `skillStore``skillService` |
| Plugins | 列表、权限确认、安装、启停、卸载 | `pluginStore``pluginService` |
| Themes | 主题预览、应用、字体与行高覆盖、恢复默认 | `themeStore` |
| Settings | 通用、编辑器、Provider、索引、权限、诊断 | `settingsStore``providerStore`、相关 Service |
## 5. Workspace 与编辑器
Workspace 当前由以下组件构成:
```text
WorkspaceView
├── EditorHeader
└── EditorPane
SecondarySidebar
└── FileTreePanel
└── FileTreeNode(递归)
```
文件树把右键目标保存在 `contextTarget`,重命名和删除始终作用于实际被右键的节点,不再依赖当前编辑文件。根目录使用 `/` 表示,新增根级文件时直接写入 Store 顶层数组。
当前 `workspaceService` 是 FastAPI Workspace Adapter。打开 Vault 时只允许后端 `APP_VAULT_PATH` 配置的目录,随后通过 Workspace/Note API 读取真实文件树和 Markdown,并完成文件、目录的新建、重命名、移动、保存和删除。接口错误直接进入统一错误链路,不再用 Mock Fallback 掩盖连接或契约失败。
浏览器不能获得任意本地文件系统权限,因此 Web 模式不提供目录选择和多 Vault 管理。进入桌面端阶段后,由 Tauri Host 实现同一 Service 边界下的原生适配器,组件和 Store 无需感知底层传输变化。
## 6. HTTP 接口层
公共请求由 `apiClient.ts` 处理:
- 支持 GET、POST、PUT、PATCH 和 DELETE
- 使用 `VITE_API_BASE_URL`,并兼容旧的 `VITE_API_BASE`
- 自动附加 `X-Request-Id`
- 将后端统一错误体转换为 `ApiErrorClass`
- 204 响应返回 `undefined`
Service 已适配当前 FastAPI Contract
| 模块 | 主要适配内容 |
| --- | --- |
| Notes | `folder``markdown`、直接 Note 响应和 `{items, page}` |
| Search | 数组筛选字段、`items/page` 响应和 Search View Model 映射 |
| Chat | `provider_id``model``messages` 和 ModelEvent SSE |
| Agent | `input`、秒级 Timeout 字段、Run DTO 和 Permission Decision |
| Skill / Plugin | 嵌套 `manifest`、安装 `package_path` 和 Plugin Permission PUT |
| Provider | Provider Type、Capability 数组、模型列表包装和 Test 响应 |
| Task | `due_at`、分页响应和当前后端支持字段 |
| Index | `all/notes/vectors` Scope、Job 与状态 DTO |
| System | `/health``/api/status` 的真实响应字段 |
界面模型中存在的展示字段不能直接发送给后端。例如 Task View Model 的 `priority``source` 当前只是界面层字段,Service 创建与更新请求不会把它们发送给不支持这些字段的 FastAPI Contract。
## 7. SSE
`SseClient` 同时服务于 Chat 和 Agent Event
- 使用与普通 HTTP 相同的 API Base URL
- 支持 POST Chat Stream 和 GET Agent Event Stream
- 使用 `TextDecoder` 处理 UTF-8 增量字节;
- 在网络分片之间保留 `event` 和多行 `data` 状态;
- 以空行作为单个 SSE Event 的结束标志;
- 识别 `Done``RunCompleted``RunFailed``RunCancelled`
- 支持 AbortController 主动取消。
Chat Store 已从定时器模拟输出切换为真实 `/api/chat` SSE。默认离线联调配置为:
```text
provider_id = mock
model = mock-1
```
## 8. 环境和启动
```powershell
cd frontend
pnpm install --frozen-lockfile
pnpm dev
```
联调前在另一个终端启动后端:
```powershell
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
生产构建:
```powershell
cd frontend
pnpm build
```
## 9. 当前验证基线
```text
pnpm build passed
pnpm test 27 passed
uv run pytest 92 passed
preview smoke HTTP 200
git diff --check passed
```
当前前端使用 Vitest 执行 Store、Workspace API Adapter、SSE 恢复游标、文件树、编辑器组件、智能体标签、轻量动效约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题测试;`pnpm build` 同时执行 `vue-tsc -b` 与 Vite 生产构建。后端测试出现过 `.pytest_cache` 无法写入的 Windows 权限警告,不影响 92 项测试结果,也不涉及产品代码。
Vite 当前会提示 Chat 与 Workspace 的部分异步 Chunk 超过 500 kB,这是 Milkdown、CodeMirror、KaTeX 和 Shiki 等编辑/渲染依赖带来的性能优化项,不影响构建成功或功能正确性;进入桌面打包前应通过手动分包或更细粒度动态加载继续优化。
浏览器可视化冒烟在本次执行环境中因浏览器运行资源缺失未能启动;HTTP 冒烟已确认前端入口、后端健康检查与 OpenAPI 均能访问。进入合并验收前,仍建议团队在本机打开各路由完成一次人工视觉检查。
## 10. 后续开发要求
- 新页面文件与路由修改必须在同一提交中出现;
- 新增或修改接口时同步更新 FastAPI DTO、Service 映射和接口文档;
- 不允许用 `as any` 或错误返回类型掩盖 Contract 差异;
- SSE 相关变更需要覆盖跨 Chunk、CRLF、多行 data、终态事件和取消;
- Workspace 接入 Tauri 后,需要增加路径规范化、写入失败恢复和外部修改冲突测试;
- 页面新增交互必须经过键盘、空状态、加载状态、错误状态和窄窗口检查;
- Workspace 的 Milkdown 写作模式与 CodeMirror 源码模式共享同一 Markdown 数据源;后续修改编辑器时不得改变 Store/Service 边界,并必须保留文件切换、自动保存和选区格式化回归测试。
@@ -0,0 +1,113 @@
# 前端视觉与轻量动效优化开发说明
> 更新日期:2026-08-30
> 适用范围:全局 Design Token、App Shell、功能页、卡片、表单、弹窗和轻量交互动效
## 1. 目标
本轮优化不改变页面功能和前后端契约,主要解决原界面层级偏平、组件间距不统一、交互反馈不足的问题,同时为后续主题商店 CSS 注入保留稳定边界。
设计原则:
- 颜色、圆角、阴影、间距和速度继续使用 CSS 变量;
- 页面与弹窗动画只改变 `opacity``transform`
- 不使用背景模糊、连续粒子、视差、复杂 SVG 或大范围布局动画;
- 不使用 `transition: all`,只声明需要变化的属性;
- 尊重系统 `prefers-reduced-motion` 设置;
- 主题只需覆盖现有 Token,不需要了解组件内部动画实现。
## 2. 全局视觉基线
更新 `tokens.css`
- 调整四级圆角和阴影,使卡片、弹窗与导航层次更清楚;
- 调整标题栏、状态栏和双侧栏尺寸;
- 统一更短的运动时间与缓动曲线;
- 增加键盘 `focus-visible` 焦点环;
- 为 checkbox、radio 和 range 使用主题强调色;
- 窄窗口下收缩辅助侧栏与展开导航宽度;
- 在减少动态效果模式下,把动画和过渡缩短到近似即时完成。
更新 `features.css`
- 功能页增加受控内容宽度、响应式留白和低强度主题渐变;
- 卡片增加边框、阴影与最多 2px 的悬浮位移;
- 按钮、输入框、Badge、空状态和通知统一交互反馈;
- 设置页导航改为分段式卡片导航;
- 弹窗与页面增加一次性淡入和轻微位移动画。
## 3. 页面与布局优化
### 3.1 App Shell
- 主侧栏增加明确的 Active 标记、悬浮反馈和宽度过渡;
- 辅助侧栏统一为次级 Surface,并改善标题与标签层级;
- 标题栏应用名改为轻量胶囊标识;
- 状态栏强化状态点和窄窗口降级;
- 命令面板增加轻量入场、圆角、阴影和列表反馈。
### 3.2 业务页面
- Search 结果卡片增加左侧强调线和统一内容宽度;
- Chat 增加消息容器、头像层级、Citation 悬浮反馈和 Composer 顶部阴影;
- Agent Tool 选择卡增加选中状态,Trace 使用轻量时间线;
- Task 完成按钮增加主题化状态反馈;
- Settings 行在悬浮时提供背景提示;
- Workspace 空状态与 Vault 入口增加清晰的层级和一次性入场动画。
## 4. 动效性能边界
允许的常规动效:
```text
opacity
transform: translate / scale / rotate
background-color
border-color
color
box-shadow
```
默认禁止:
```text
transition: all
backdrop-filter / filter 模糊
持续改变 width / height / margin / padding 的动画
无限循环的装饰动画
全屏高频渐变或粒子动画
```
状态栏 Spinner 和 AI Core 检查状态点属于有明确状态含义的循环动画,并会被 `prefers-reduced-motion` 全局规则降级。
## 5. 主题商店接入约定
自定义主题优先覆盖以下 Token
- `--color-background-*`
- `--color-surface-*`
- `--color-text-*`
- `--color-accent-*`
- `--color-border-*`
- `--color-code-*`
- `--shadow-*`
- `--radius-*`
- `--motion-*`
主题 CSS 不应给通配选择器增加动画,不应重新启用高成本滤镜,也不应覆盖 `prefers-reduced-motion` 的降级规则。若主题需要完全静态的界面,可把三个 `--motion-*` Token 设置为接近 0ms。
## 6. 验证
新增 `styles/motion.spec.ts`,防止全局样式重新引入 `transition: all`、高成本模糊滤镜或布局型页面入场动画,并约束 Markdown 表格与列表使用独立的高对比度主题变量。
当前验证结果:
```text
pnpm test 9 files / 23 tests passed
pnpm build passed
git diff --check passed
```
生产构建仍有已有的大 Chunk 警告,主要来自 Milkdown、CodeMirror、KaTeX 和 Shiki;本轮样式及动效未增加 JavaScript 动画库或运行时依赖。
本轮已完成样式静态检查、自动化测试和生产构建。由于本机内置浏览器运行资源路径缺失,亮色/暗色主题的人工页面巡检需在 PR 验收环境补做。
@@ -0,0 +1,107 @@
# 模型提供商与模型发现开发说明
> 更新日期:2026-08-30。OpenAI、DeepSeek、Ollama 预设、模型自动发现、默认模型选择和开发阶段加密凭据存储均已实现并接入设置页。
## 1. 本次目标
本次完善设置页的模型提供商配置,不改变 Agent、Chat 和 Skill 对统一 Model Core 接口的依赖:
- 提供 OpenAI、DeepSeek 和 Ollama 配置预设;
- 保存 Provider 后自动获取该账号或服务当前可用的模型列表;
- 支持手动刷新模型列表和选择默认模型;
- 保留自定义 OpenAI-Compatible 服务入口;
- 不在 Vue、FastAPI 配置或仓库文件中保存、回显 API Key 明文。
## 2. 接口与实现
### 2.1 Provider 预设
新增接口:
```http
GET /api/providers/presets
```
预设由后端 `ProviderFactory` 提供,前端只消费名称、协议类型、Base URL 和是否需要凭据等配置元数据,不直接实现厂商协议。
当前预设:
| 提供商 | Provider Type | Base URL | 默认 Credential ID |
| --- | --- | --- | --- |
| OpenAI | `openai_chat` | `https://api.openai.com/v1` | `openai` |
| DeepSeek | `openai_compatible` | `https://api.deepseek.com` | `deepseek` |
| Ollama | `ollama` | `http://127.0.0.1:11434` | 无 |
OpenAI 和 DeepSeek 都通过项目已有的 `OpenAICompatibleProvider` 访问。模型发现分别请求 Base URL 下的 `/models`,不引入厂商 SDK。
### 2.2 自动获取模型
模型列表继续使用既有接口:
```http
GET /api/providers/{provider_id}/models
```
设置页在以下时机调用该接口:
- Provider 列表加载完成后,为所有已启用 Provider 自动刷新;
- 新增或编辑 Provider 保存成功后自动刷新;
- 用户点击“刷新模型”时手动刷新;
- 打开已有 Provider 的编辑窗口时刷新可选模型。
前端按模型名称排序并按 `model_id` 去重。获取结果保存在 `providerStore.modelsByProvider`,加载状态和错误按 Provider 隔离,单个外部服务失败不会阻止其他服务展示。
获取成功后,Provider 卡片展示模型数量和默认模型下拉框。更换默认模型会调用 Provider PATCH 接口写回配置;编辑窗口仍允许手动输入模型 ID,以兼容未出现在列表中的代理模型或部署别名。
### 2.3 错误处理
Provider Adapter 的错误在 FastAPI 路由转换为统一 API Error
| Provider Error | HTTP 状态 |
| --- | --- |
| `PROVIDER_AUTH_FAILED` | 401 |
| `MODEL_NOT_FOUND` | 404 |
| `PROVIDER_RATE_LIMITED` | 429 |
| `PROVIDER_TIMEOUT` | 504 |
| 其他 Provider 可用性错误 | 502 |
前端在对应 Provider 卡片内展示失败原因,并允许用户修正 Credential ID、Base URL 后重新获取。
## 3. 凭据边界
设置页选择 OpenAI 或 DeepSeek 预设后展示密码类型的 API Key 输入框,不再要求用户理解 Credential ID。输入值只存在于表单的临时 `ref`,不会写入 Pinia 或 localStorage;请求完成、取消表单或失败后都会清空。
API Key 通过独立接口写入:
```http
GET /api/credentials/{credential_id}
PUT /api/credentials/{credential_id}
DELETE /api/credentials/{credential_id}
```
PUT 请求使用 Pydantic `SecretStr` 接收密钥,响应仅包含 Credential ID 和 `configured` 状态。后端使用 Fernet 认证加密,将密文保存到 `data/credentials/credentials.json`,主密钥保存到 `data/credentials/master.key`;目录和文件尽可能设置为仅当前用户可访问并整体排除版本控制。写入采用临时文件替换,避免进程中断留下半写文件。Provider 发起请求时按 Credential ID 解密,解密失败转换为统一 Provider Error,任何读取接口均不返回明文。
本地开发存储的主密钥与密文仍位于同一用户数据目录,因此它解决的是仓库泄漏、普通配置误提交和静态明文暴露,不等同于操作系统安全硬件或 Stronghold。Tauri 集成后应以 Stronghold 实现替换 `EncryptedCredentialStore`。无界面环境仍兼容 `OPENAI_API_KEY``DEEPSEEK_API_KEY` 和 Host 注入的 `AINOTE_CREDENTIAL_<ID>`;设置页保存的本地密钥优先,环境变量仅作为回退。
自动化测试仅使用虚构测试值,验证磁盘文件不包含明文、加解密往返、API 响应不泄密,以及 Provider 能用解密后的值构造 Authorization Header。本次没有使用真实 OpenAI 或 DeepSeek Key,也没有向厂商发起真实请求。
## 4. 验证
后端:
```bash
cd backend
uv run pytest -q -p no:cacheprovider
```
前端:
```bash
cd frontend
pnpm test
pnpm build
```
自动化验证覆盖 Provider 预设、OpenAI-Compatible `/models` 请求与鉴权头、模型映射、前端自动刷新、排序去重及按 Provider 隔离错误。生产构建同时执行 Vue 和 TypeScript 类型检查。
当前完整回归基线:后端 92 项测试、前端 27 项测试通过,前端类型检查和生产构建通过。Provider 配置目前仍保存在内存 Registry,AI Core 重启后需要重新创建;凭据密文会保留。OpenAI Responses 与 Anthropic Messages Adapter 尚未实现,设置页正式预设不会使用这两种协议。
+166
View File
@@ -0,0 +1,166 @@
# CI/CD 细则(团队开发版)
> 本文档规定 NotesAgent 在 Gitea 上的持续集成、构建产物、发布和回滚要求。当前仓库尚未提交 Gitea Actions 工作流,因此本文首先作为落地流水线时的统一规范;流水线启用前,Pull Request 仍须人工执行同等检查。
> 更新日期:2026-09-01。当前阶段的 CD 指“生成可验证的候选构建与发布产物”,不包含把后端自动部署到公网环境。
## 1. 目标与原则
CI/CD 用于尽早发现依赖锁文件失效、类型错误、测试回归、前后端契约不一致和生产构建失败。流水线应遵守以下原则:
- 以 Gitea 为唯一远程和流水线入口;
- `main` 始终保持可安装、可测试、可构建;
- 安装依赖时使用锁文件,避免流水线与开发机解析出不同版本;
- 未通过必需检查的提交不得合入 `main`
- 外部模型、真实 API Key 和用户本地数据不得成为基础 CI 的前置条件;
- 缓存只用于加速,不得影响构建结果;删除缓存后流水线仍应成功;
- 测试、构建和发布步骤使用最小权限,敏感信息不得写入日志或产物。
## 2. 运行环境基线
| 组件 | CI 要求 | 说明 |
| --- | --- | --- |
| Python | 3.12 | 项目最低支持 3.11,CI 使用团队推荐版本 |
| uv | 当前稳定版,并在日志中输出版本 | 按 `backend/uv.lock` 安装后端依赖 |
| Node.js | 22 LTS | 满足前端环境要求并保持 Runner 兼容性 |
| pnpm | 10 | 按 `frontend/pnpm-lock.yaml` 安装前端依赖 |
| 操作系统 | Linux Runner 为基础门禁 | 桌面端启用后再增加 Windows、macOS 构建矩阵 |
Runner 镜像或 Action 的大版本必须固定。升级 Python、Node.js、uv、pnpm 或基础 Action 时,应使用独立的 `chore/` 分支,并完整运行前后端检查。
## 3. 触发规则
| 事件 | 必须执行 | 用途 |
| --- | --- | --- |
| Pull Request 指向 `main` | 文档检查、后端测试、前端测试、类型检查、生产构建 | 合并门禁 |
| 推送到 `main` | 全量检查、集成冒烟、保存候选构建 | 验证合并结果 |
| 推送功能分支 | 至少执行受影响模块的检查 | 尽早反馈;不得替代 PR 全量门禁 |
| 推送 `v*` 标签 | 全量检查、构建、校验和、发布候选产物 | 正式发布入口 |
| 手动触发 | 可选择全量回归或重新生成候选产物 | 发布前复核和故障恢复 |
纯文档变更可以跳过前后端耗时任务,但必须执行文档链接检查和 `git diff --check`。只有可靠的路径检测结果才能判定为纯文档变更;锁文件、工作流、构建配置和接口契约变更一律按代码变更处理。
## 4. Pull Request 必需检查
建议将以下 Job 名称固定为 Gitea 分支保护所要求的状态检查:
| Job | 必需命令或行为 | 通过标准 |
| --- | --- | --- |
| `docs-check` | `git diff --check`,检查仓库内 Markdown 相对链接 | 无空白错误、无失效本地链接 |
| `backend-test` | `uv sync --frozen`、编译检查、`uv run pytest` | 依赖锁有效且测试全部通过 |
| `frontend-test` | `pnpm install --frozen-lockfile``pnpm test` | 依赖锁有效且测试全部通过 |
| `frontend-typecheck` | `pnpm type-check` | 无 TypeScript/Vue 类型错误 |
| `frontend-build` | `pnpm build` | Vite 生产构建成功 |
| `integration-smoke` | 启动 FastAPI,验证健康检查和关键本地链路 | 服务可启动,响应与契约符合预期 |
后端 Job 的基准命令:
```bash
cd backend
uv sync --frozen
uv run python -m compileall -q app
uv run pytest
```
前端 Job 的基准命令:
```bash
cd frontend
pnpm install --frozen-lockfile
pnpm test
pnpm type-check
pnpm build
```
`integration-smoke` 应使用 Mock Provider、临时数据库和临时附件目录,不访问 OpenAI、DeepSeek 或其他外部服务。测试结束后必须关闭服务并清理临时数据。
## 5. 路径与模块检查规则
- 修改 `backend/**``backend/uv.lock` 或后端配置时,必须运行 `backend-test``integration-smoke`
- 修改 `frontend/**``frontend/pnpm-lock.yaml` 或前端配置时,必须运行全部前端 Job。
- 修改 `docs/contracts/**`、FastAPI 路由、DTO、SSE 事件或前端 Service 类型时,必须同时运行前后端全量检查。
- 修改 `.gitea/**`、根目录工程配置或依赖版本时,必须运行所有 Job。
- 修改 `docs/**` 以外且无法明确归类的文件时,默认运行所有 Job。
路径过滤只用于减少无关重复任务,不得造成关键检查缺失。若无法可靠判断影响范围,应执行全量流水线。
## 6. 凭据与敏感信息
- 基础 CI 不配置真实模型 API Key,Provider 相关测试统一使用 Mock 或请求桩。
- 确需发布签名或访问受保护服务时,凭据只保存在 Gitea Actions Secrets 中,不写入仓库、工作流参数、缓存或构建产物。
- 来自外部分支或不受信任 Pull Request 的任务不得读取发布凭据。
- Secret 名称表达用途和环境,例如 `RELEASE_SIGNING_KEY`;禁止使用含义模糊的 `KEY1``TOKEN2`
- 日志中禁止输出请求头、完整 Token、API Key、用户笔记内容和本地凭据存储内容。
- 生产凭据与测试凭据分离,并遵循最小权限、定期轮换和可撤销原则。
前端构建时注入的变量会进入静态资源,不能用于保存秘密。只有明确可公开的配置才允许使用 Vite 客户端环境变量。
## 7. 缓存与产物
可以缓存 uv 下载缓存和 pnpm Store,缓存键至少包含操作系统、运行时版本和对应锁文件哈希。不得缓存:
- `backend/.venv/`
- `frontend/node_modules/`
- `backend/data/`、测试数据库和用户附件;
- `.env`、API Key、本地凭据库或签名材料。
普通 PR 不上传可执行发布包,只保留必要的测试报告和前端构建日志。`main` 或版本标签的候选产物应记录提交 SHA,生成 SHA-256 校验和,并设置明确的保留期限;非正式候选产物建议保留 14 天。
## 8. 分支保护与合并门禁
Gitea 中的 `main` 应启用以下保护:
- 禁止普通成员直接推送和强制推送;
- 要求 Pull Request 审阅通过;
- 要求第 4 节列出的适用状态检查成功;
- Head 更新后使旧审阅和旧检查失效,必须针对最新提交重新检查;
- 对话和审阅意见处理完成后才允许合并;
- 优先使用 squash 或 rebase 保持主线清晰,具体方式遵循 [Git 使用细则](Git使用细则-团队开发版.md)。
临时绕过门禁只允许用于明确的仓库级故障。绕过者需要记录原因、影响、补验计划,并在恢复后立即补跑全部检查。
## 9. 发布流程
当前阶段按以下顺序生成发布候选:
1. 从已通过全部检查的 `main` 提交确定发布 SHA
2. 更新版本号、变更说明和必要文档;
3. 创建形如 `v0.2.0` 的语义化版本标签;
4. 标签流水线重新执行全部测试和生产构建;
5. 对产物执行本地启动或安装冒烟测试;
6. 生成校验和,并把版本、提交 SHA、构建环境和已知限制写入发布说明;
7. 人工确认后在 Gitea 发布页面公开产物。
Tauri 桌面端接入后,发布流水线再增加 Windows、macOS 和 Linux 构建矩阵、平台签名及安装包验证。在签名、更新通道和回滚方案准备完成前,不启用面向用户的自动更新。
## 10. 回滚与热修复
- 尚未公开的候选产物直接标记为失败,不覆盖同一版本的已有产物;修复后递增预发布编号或版本号。
- 已发布版本出现问题时,优先停止分发并回退到最近一个已验证版本。
- 代码修复从 `main` 创建 `hotfix/<模块>-<问题>` 分支,通过完整门禁后合并并发布补丁版本。
- 禁止重写已公开版本标签或用新文件替换旧版本同名产物。
- 回滚或热修复完成后,在 `docs/retrospectives/` 记录原因、影响、处置过程和防复发措施。
## 11. 流水线失败处理
1. 先确认失败是否可在本地使用相同锁文件和命令复现;
2. 判断是代码、测试、依赖、Runner 还是外部基础设施问题;
3. 代码或测试问题由当前 PR 修复,不通过重跑掩盖不稳定测试;
4. Runner 或 Gitea 故障应记录日志和时间,恢复后针对同一 Head 重新执行;
5. 连续出现的偶发失败必须作为缺陷处理,明确负责人并增加稳定性修复;
6. 修复流水线本身时,不得顺便降低测试范围或绕过既有门禁。
## 12. 落地清单
首次创建 `.gitea/workflows/` 时,应逐项确认:
- [ ] 工作流只使用 Gitea Runner 支持且来源可信的 Action
- [ ] Python、Node.js、uv 和 pnpm 版本符合本规范;
- [ ] 后端和前端依赖均以 frozen 模式安装;
- [ ] 必需 Job 名称与 `main` 分支保护一致;
- [ ] Mock 测试不依赖外部模型服务和真实凭据;
- [ ] 缓存键包含锁文件哈希,缓存内容不含用户数据或秘密;
- [ ] PR、`main`、版本标签和手动触发行为分别验证;
- [ ] 失败任务能返回非零退出码,后续发布步骤不会继续;
- [ ] 候选产物包含提交 SHA、校验和和保留期限;
- [ ] 团队成员能够按本文档在本地复现全部门禁。
@@ -0,0 +1,625 @@
# Git 使用细则(团队开发版)
> 本文档用于 Notes Agent 团队日常开发。目标是让三名成员可以并行开发、稳定联调,并确保 `main` 始终处于可运行状态。
> 更新日期:2026-09-01。当前远程只使用 `gitea`,功能分支不添加个人或工具名称前缀;合并门槛为相关测试、前端生产构建、文档同步和 `git diff --check` 全部通过。自动化门禁、产物和发布规则见 [CI/CD 细则](CI-CD细则-团队开发版.md)。
## 1. 仓库与远程
团队代码统一使用 Gitea
```text
remote: gitea
url: https://gitea.kronecker.cc/Kronecker/NotesAgentic.git
```
检查远程:
```powershell
git remote -v
```
正常情况下只应存在 `gitea`。不要自行增加名称相同、地址不同的远程,也不要将团队代码推送到个人公开仓库。
## 2. 分支约定
### 2.1 长期分支
| 分支 | 用途 | 规则 |
| --- | --- | --- |
| `main` | 团队集成与演示版本 | 必须可安装、可构建、可运行;禁止直接开发 |
当前阶段不额外维护 `develop`。三人团队通过短生命周期功能分支和 Gitea Pull Request 合入 `main`,减少长期分支之间的同步成本。
### 2.2 功能分支
命名格式:
```text
<类型>/<模块>-<简短描述>
```
类型:
```text
feat 新功能
fix Bug 修复
refactor 不改变功能的重构
docs 文档
test 测试
chore 构建、依赖、工程配置
hotfix main 上紧急修复
```
示例:
```text
feat/frontend-workspace
feat/agent-tool-registry
feat/retrieval-hybrid-search
fix/frontend-sse-reconnect
fix/agent-permission-timeout
docs/api-contract
chore/backend-dependencies
```
要求:
- 使用英文小写、数字和连字符;
- 分支名应表达模块和目标;
- 一个分支只处理一个主要问题;
- 不使用 `test1``new``final``xxx-dev` 等无法识别用途的名称;
- 功能合入后删除远程分支,避免长期堆积。
- 不在分支名前添加 `codex/`、成员姓名或设备名;归属由提交作者、Issue 和 PR Reviewer 表达。
## 3. 模块分工与改动边界
| 成员 | 主要目录或模块 | Review 要求 |
| --- | --- | --- |
| 吉海燕 | `frontend`、UI、Store、Service、Design Token | 跨 API Contract 时邀请范涵宇 |
| 范涵宇 | `backend` 中的 Agent、Provider、Skill、Plugin、公共 API 和工程集成 | 核心接口与跨模块变更负责 Review |
| 杨星萱 | Knowledge、Retrieval、SQLite/FTS5、Vector、RAG、Citation | 涉及前端定位时邀请吉海燕,涉及 Agent Tool 时邀请范涵宇 |
边界不是文件所有权锁。确实需要修改其他成员负责的模块时:
1. 先在群里或 Issue 中说明原因;
2. 将跨模块修改拆成容易 Review 的提交;
3. Pull Request 必须邀请对应负责人;
4. 同步更新 Contract、测试和文档。
禁止为了临时联调直接绕过其他模块的接口访问数据库、文件或 Store 内部状态。
## 4. 开始开发前
每项工作从最新的 `main` 创建分支:
```powershell
git switch main
git fetch gitea
git pull --ff-only gitea main
git switch -c feat/agent-example
```
说明:
- `git fetch` 获取远程状态,但不修改工作区;
- `git pull --ff-only` 只允许快进,避免在 `main` 上产生意外 Merge Commit
- 创建分支前先确认 `git status` 干净;
- 不要在已有未提交修改时随意切换分支。
检查:
```powershell
git status
git branch --show-current
```
## 5. 开发中的提交
### 5.1 提交原则
- 小步提交,每个提交只表达一个完整意图;
- 功能代码、测试和必要文档一起提交;
- 不提交无法运行的临时状态到共享分支;
- 不用一次提交混合前端格式化、后端功能和无关文档修改;
- 提交前检查实际 diff,不使用不加检查的 `git add .` 作为固定习惯。
推荐流程:
```powershell
git status --short
git diff
git add backend/app/agent backend/tests/test_agent_core.py
git diff --cached
git commit -m "feat(agent): 增加 Tool Registry"
```
需要提交所有确认过的修改时可以使用:
```powershell
git add -A
git diff --cached
```
`git diff --cached` 无异常后再提交。
### 5.2 Commit Message
格式:
```text
<类型>(<模块>): <简短说明>
```
示例:
```text
feat(agent): 增加 Tool 参数校验
feat(provider): 接入 Ollama Adapter
feat(frontend): 完成 Citation 定位交互
fix(retrieval): 修复 RRF 排名重复项
test(agent): 补充 Step Limit 测试
docs(api): 更新 Provider 接口契约
chore(frontend): 更新 Vite 依赖
```
常用模块:
```text
frontend
workspace
editor
search
chat
agent
provider
skill
plugin
retrieval
knowledge
api
docs
build
```
要求:
- 第一行建议不超过 72 个字符;
- 使用动词说明这次提交完成了什么;
- 不使用“修改一下”“update”“最终版”“fix bug”等模糊描述;
- 一个提交包含多个需要解释的变化时,在空行后补充正文;
- 关联 Issue 时可在正文写 `Refs #编号`,确认修复后写 `Closes #编号`
示例:
```text
feat(agent): 增加高风险 Tool 权限确认
支持 allow_once、allow_session 和 deny,并在等待期间
将 Agent Run 状态更新为 waiting_permission。
Closes #18
```
## 6. 提交前检查
### 6.1 通用检查
```powershell
git status --short
git diff --check
```
确认:
- 没有 API Key、Token、密码、真实用户笔记或个人路径;
- 没有调试输出、临时代码和无关格式化;
- 没有误删其他成员的改动;
- 新增接口同时更新了 Contract 和文档;
- 错误信息中不包含 Secret 或完整笔记正文。
### 6.2 后端检查
```powershell
cd backend
uv sync
uv run pytest
```
后端依赖变化时必须同时提交:
```text
backend/pyproject.toml
backend/uv.lock
```
### 6.3 前端检查
```powershell
cd frontend
pnpm install
pnpm build
```
前端依赖变化时必须同时提交:
```text
frontend/package.json
frontend/pnpm-lock.yaml
```
禁止同时生成或提交 npm、yarn 的锁文件。
## 7. 禁止提交的内容
以下内容不得进入仓库:
```text
backend/.venv/
backend/.uv-cache/
backend/.pytest_cache/
backend/**/__pycache__/
backend/*.egg-info/
backend/.env
frontend/node_modules/
frontend/dist/
.pnpm-store/
*.tsbuildinfo
.idea/
.vscode/
```
另外禁止提交:
- API Key、同步 Token、Cookie、私钥和 Stronghold 导出数据;
- 个人 Vault、真实笔记、音频、课堂资料和聊天记录;
- `.ainote/app.db`、本地索引、Embedding 和模型文件;
- 未经团队确认的大文件、二进制安装包和模型权重;
- 只在个人电脑有效的绝对路径;
- 临时日志和包含正文的调试数据。
发现 Secret 已经提交时,不要只删除文件后再次提交。立即通知团队,撤销或轮换凭证,并根据是否已推送决定是否清理历史。
## 8. 推送功能分支
第一次推送:
```powershell
git push -u gitea feat/agent-example
```
之后:
```powershell
git push
```
推送前确认:
```powershell
git status
git log --oneline -5
```
不要将个人功能分支强制推送到 `main`
## 9. Gitea Pull Request
功能通过 Pull Request 合入 `main`。Pull Request 标题沿用 Commit Message 风格:
```text
feat(agent): 完成基础 Agent Loop
```
### 9.1 Pull Request 描述
建议使用以下结构:
```markdown
## 改动内容
-
## 影响模块
-
## 验证方式
- [ ] 后端 pytest 通过
- [ ] 前端 build 通过
- [ ] 手动联调通过
## 接口或数据变更
- 无 / 具体说明
## 风险与恢复
-
## 关联 Issue
- Closes #
```
### 9.2 Pull Request 大小
- 优先保持在 Reviewer 可以一次理解的范围;
- 大功能按 Contract、核心实现、页面接入等阶段拆分;
- 纯机械格式化与功能修改分开;
- 如果必须提交较大 PR,在描述中提供阅读顺序。
### 9.3 Review 要求
- 至少一名其他成员 Review 后再合入;
- 公共 API、Agent、Provider、Skill、Plugin 或工程结构变更由范涵宇 Review;
- 前端交互和 Design Token 变更由吉海燕 Review
- Knowledge、Retrieval、Citation 和索引变更由杨星萱 Review;
- 作者自己不能作为唯一批准人;
- Review 意见解决后,由提出者确认或作者说明处理方式。
Reviewer 检查:
```text
功能是否符合分工与需求
依赖方向是否正确
Contract 是否同步
是否包含测试
错误与恢复是否明确
是否泄露敏感信息
是否误改其他模块
是否影响现有 Demo 流程
```
## 10. 合并策略
功能 PR 默认使用 `Squash Merge` 合入 `main`
- 一个 PR 在 `main` 中保留一个清晰提交;
- Squash Commit 标题使用规范的提交格式;
- 合并前确认 CI 或本地验证通过;
- 合并后删除功能分支。
不要在 Gitea 上选择会产生大量无意义 Merge Commit 的方式。确实需要保留多提交历史的较大集成工作,由团队讨论后使用普通 Merge。
`main` 建议在 Gitea 开启:
```text
禁止 Force Push
禁止删除分支
要求 Pull Request
要求至少 1 人批准
要求检查通过后合并
```
## 11. 同步 main
功能分支开发期间应定期同步:
```powershell
git fetch gitea
git rebase gitea/main
```
只在自己的功能分支上执行 rebase。Rebase 后如果该分支已经推送,需要:
```powershell
git push --force-with-lease
```
只能使用 `--force-with-lease`,禁止使用普通 `--force`。禁止重写其他成员正在使用的分支或 `main` 历史。
如果团队成员对 rebase 不熟悉,可以使用合并方式同步:
```powershell
git fetch gitea
git merge gitea/main
```
同一个 Pull Request 中不要反复混用 rebase 和 merge。
## 12. 冲突处理
### 12.1 Rebase 冲突
```powershell
git fetch gitea
git rebase gitea/main
git status
```
逐个打开冲突文件,理解双方修改后手工合并。处理完成:
```powershell
git add <冲突文件>
git rebase --continue
```
无法确认时终止操作:
```powershell
git rebase --abort
```
### 12.2 冲突原则
- 不使用“全部接受当前”或“全部接受传入”处理不理解的冲突;
- 涉及他人模块时联系对应负责人;
- `pyproject.toml``package.json` 冲突解决后重新生成并验证锁文件;
- API Contract 冲突需要同时检查前端 TypeScript 类型和接口文档;
- 文档冲突不能简单保留较新的文件,需要合并双方有效内容;
- 冲突解决后重新运行相关测试。
## 13. 修正提交
### 13.1 尚未推送
只修正最近一次提交:
```powershell
git add <文件>
git commit --amend
```
### 13.2 已经推送或进入 Review
优先新增修正提交:
```powershell
git commit -m "fix(agent): 修正权限超时状态"
git push
```
Reviewer 确认后由 Squash Merge 整理历史。不要为了“历史好看”随意重写其他成员已经拉取的提交。
## 14. 撤销与恢复
### 14.1 撤销已合入 main 的提交
使用 `git revert` 创建反向提交:
```powershell
git switch main
git pull --ff-only gitea main
git switch -c fix/revert-problem
git revert <commit-id>
git push -u gitea fix/revert-problem
```
随后创建 Pull Request。不要对共享 `main` 使用 `git reset --hard` 或重写历史。
### 14.2 放弃尚未提交的修改
先检查:
```powershell
git status
git diff
```
放弃修改会丢失本地内容,必须确认目标文件和影响范围。拿不准时先创建临时分支或使用 stash:
```powershell
git stash push -u -m "wip: 临时保存说明"
```
恢复:
```powershell
git stash list
git stash pop
```
Stash 只用于短期切换,不作为长期备份。
## 15. Hotfix
影响 `main` 启动、数据安全或 Demo 的紧急问题使用:
```powershell
git switch main
git pull --ff-only gitea main
git switch -c hotfix/<模块>-<问题>
```
Hotfix 仍需:
- 最小化修改范围;
- 添加回归测试;
- 至少一人快速 Review
- 合入后通知全员同步 `main`
- 后续补充问题原因和预防措施。
## 16. Issue 与里程碑
建议每项跨一天或跨模块的任务先建立 Gitea Issue,至少包含:
```text
目标
负责人
涉及模块
验收条件
依赖项
优先级
```
标签建议:
```text
frontend
backend
agent
provider
knowledge
retrieval
extension
bug
documentation
P0 / P1 / P2
```
Pull Request 关联 Issue,避免功能完成后无法对应需求和验收条件。
## 17. Release 与 Tag
比赛开发阶段使用语义化版本:
```text
v0.1.0 第一个可运行壳子
v0.2.0 第一条完整知识问答链路
v0.3.0 第一阶段 Demo 候选版本
v1.0.0 稳定交付版本
```
Tag 只从验证通过的 `main` 创建:
```powershell
git switch main
git pull --ff-only gitea main
git tag -a v0.2.0 -m "Notes Agent v0.2.0"
git push gitea v0.2.0
```
创建 Tag 前记录:
- 功能范围;
- 已知问题;
- 数据库或配置迁移;
- 测试结果;
- Demo 操作步骤。
## 18. 每日推荐流程
```text
同步 main
→ 创建或切换自己的功能分支
→ 开发并小步提交
→ 同步 main 并解决冲突
→ 运行相关测试
→ 检查 staged diff
→ 推送功能分支
→ 创建 Pull Request
→ Review 与修正
→ Squash Merge
→ 删除功能分支
→ 全员同步 main
```
常用命令汇总:
```powershell
git status --short
git fetch gitea
git switch main
git pull --ff-only gitea main
git switch -c feat/<模块>-<功能>
git diff
git add <文件>
git diff --cached
git commit -m "feat(<模块>): <说明>"
git push -u gitea <分支名>
git log --oneline --decorate -10
```
本细则的核心要求是:`main` 可运行、改动可 Review、问题可追踪、敏感信息不入库、跨模块变化同步 Contract 与文档。流水线启用后,合并和发布还必须满足 [CI/CD 细则](CI-CD细则-团队开发版.md) 中的状态检查与产物要求。
+43
View File
@@ -0,0 +1,43 @@
# 代码注释与 TODO 约定
本文用于统一团队在前后端代码中编写注释和待办项的方式。注释应解释设计意图、边界条件和不明显的取舍,不重复代码本身已经清楚表达的内容。
## 注释原则
- 模块或核心类说明其职责和边界,例如 Agent 编排器、工具执行边界、凭据存储边界。
- 异步流程说明顺序、快照、去重、回滚和竞态处理原因。
- 安全相关流程说明默认拒绝、权限收敛、输入净化和凭据优先级。
- 简单赋值、显然的条件判断、类型定义和展示模板不添加翻译式注释。
- 注释随实现一并维护;实现变化后已经失真的注释应在同一提交中修改或删除。
## TODO 格式
前端使用:
```ts
// TODO(editor): 描述尚未完成的能力、完成条件或替换目标。
```
后端使用:
```python
# TODO(agent): 描述尚未完成的能力、完成条件或替换目标。
```
领域标签使用小写英文,当前约定包括 `agent``ai-core``chat``desktop``editor``extension``performance``security``streaming`。一个 TODO 应对应真实存在的工程缺口;小型清理工作直接完成,不长期保留无负责人、无目标的占位待办。
## 当前待办索引
以下内容可通过 `rg "TODO\\(" backend/app frontend/src` 定位,代码中的注释是最新状态:
| 领域 | 当前边界 |
| --- | --- |
| Agent / Streaming | 运行事件仍在进程内保存,后续需要持久化、`Last-Event-ID` 和断线重连 |
| Security | 本地主密钥目前保存在数据目录,桌面端接入后迁移到系统凭据库 |
| Extension | 扩展安装状态尚未持久化;MCP Host、进程隔离、签名与来源校验属于第二阶段 |
| AI Core | 音频转写当前只读取文本或 Host 预生成旁路文本,后续接入本地 ASR 队列 |
| Desktop | Web Workspace 已连接 FastAPI 单 Vault;后续由 Tauri IPC 增加原生目录选择、多 Vault 和文件监听 |
| Editor / Chat | 待补文件冲突合并、受控链接对话框及会话持久化 |
| Performance | Shiki 已复用单例,后续按首屏指标评估延迟加载或 Web Worker |
TODO 完成后应删除对应代码注释并同步更新本索引;若工作超过一个提交,应建立 Issue,并在 Issue 中引用代码位置,而不是在源码中记录长篇设计讨论。
@@ -0,0 +1,378 @@
# 第一阶段测试验证操作手册
> 适用基线:2026-08-30 `main`
> 适用对象:开发、自测、代码审阅、合并验收和 Demo 前检查
> 验证范围:Vue Web 前端、FastAPI、Knowledge/Retrieval Core、AI/Agent Core、Extension Core、Provider 与开发阶段凭据链路
## 1. 验证目标
本手册用于确认第一阶段已经形成可运行的本地知识工作流:
```text
启动前后端
→ 编辑 Markdown
→ 建立或更新索引
→ Search / RAG 返回 Citation
→ Chat 或 Agent 调用统一 Provider
→ Agent 展示 Trace、Tool 和 Permission
→ Skill / Plugin 完成生命周期与 Tool 注册
```
当前不作为第一阶段通过条件的内容:Tauri/Rust Host、Stronghold、真实桌面文件系统、独立 MCP Plugin Host、真实音频模型和 Sync Server。
## 2. 环境准备
最低环境:
| 工具 | 要求 |
| --- | --- |
| Git | 较新稳定版 |
| Node.js | 22 或更高版本 |
| pnpm | 10 或更高版本 |
| Python | 3.11 或更高版本 |
| uv | 较新稳定版 |
在仓库根目录检查版本:
```powershell
git --version
node --version
pnpm --version
python --version
uv --version
```
同步依赖:
```powershell
cd backend
uv sync --frozen
cd ../frontend
pnpm install --frozen-lockfile
cd ..
```
`uv sync` 会自动创建和管理 `backend/.venv`,不需要手动创建或激活虚拟环境。
## 3. 自动化验收
### 3.1 后端测试
```powershell
cd backend
uv run pytest -q -p no:cacheprovider
```
当前基线:
```text
81 passed
```
通过标准:退出码为 0、失败数为 0。用例数可以随功能增加,但不得低于当前基线。
### 3.2 前端测试
```powershell
cd frontend
pnpm test
```
当前基线:
```text
11 test files passed
27 tests passed
```
通过标准:退出码为 0、失败数为 0。测试覆盖 Provider Store、主题偏好、Workspace、文件树、文件切换、可视化编辑器、智能体中文标签、轻量动效性能约束、Markdown 对比度 Token、scoped CSS 选择器约束和 Shiki GitHub 双主题输出。
### 3.3 类型检查与生产构建
```powershell
cd frontend
pnpm build
```
通过标准:`vue-tsc -b``vite build` 均成功,生成 `frontend/dist`。当前较大的编辑器与 Markdown Chunk 会产生体积警告,该警告不等于构建失败,但应记录在验收结果中。
### 3.4 Git 与文档检查
```powershell
cd ..
git diff --check
git status --short
```
通过标准:`git diff --check` 没有错误。测试产生的 `.venv``node_modules``dist`、凭据和运行数据不得进入提交。
## 4. 启动联调环境
打开两个 PowerShell 终端。
终端 A
```powershell
cd backend
uv run uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
终端 B
```powershell
cd frontend
pnpm dev
```
访问:
- 前端:<http://127.0.0.1:5173>
- 健康检查:<http://127.0.0.1:8000/health>
- 服务状态:<http://127.0.0.1:8000/api/status>
- Swagger UI<http://127.0.0.1:8000/docs>
- OpenAPI<http://127.0.0.1:8000/openapi.json>
快速检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8000/health
Invoke-RestMethod http://127.0.0.1:8000/api/status
```
如果 `/docs` 返回 `RESOURCE_NOT_FOUND`,检查启动命令是否在 `backend` 目录执行、端口 8000 是否被其他程序占用,以及浏览器地址是否确实为 `http://127.0.0.1:8000/docs`
## 5. 后端主链路验证
以下命令在第三个 PowerShell 终端执行,保持后端运行。
### 5.1 Note 创建、读取与检索
```powershell
$apiBase = 'http://127.0.0.1:8000/api'
$noteBody = @{
title = '第一阶段验收笔记'
markdown = "# 第一阶段验收`n`nNotes Agent 支持混合检索和可定位引用。"
folder = '验收'
tags = @('phase-1', 'verification')
} | ConvertTo-Json
$note = Invoke-RestMethod -Method Post -Uri "$apiBase/notes" -ContentType 'application/json' -Body $noteBody
$noteId = $note.note_id
Invoke-RestMethod -Uri "$apiBase/notes/$noteId"
```
执行搜索:
```powershell
$searchBody = @{
query = '混合检索'
mode = 'hybrid'
note_ids = @($noteId)
limit = 10
offset = 0
include_snippet = $true
} | ConvertTo-Json
$search = Invoke-RestMethod -Method Post -Uri "$apiBase/search" -ContentType 'application/json' -Body $searchBody
$search.items | Format-Table title, file_path, snippet
```
通过标准:创建响应包含稳定 `note_id`;读取内容一致;搜索至少返回一项,并包含 `note_id``block_id`、文件路径、Snippet 和 Citation 定位信息。
### 5.2 Index 状态与重建
```powershell
Invoke-RestMethod -Uri "$apiBase/index/status"
$indexJob = Invoke-RestMethod -Method Post -Uri "$apiBase/index/rebuild" -ContentType 'application/json' -Body '{"scope":"all","force":false}'
$indexJob
Invoke-RestMethod -Uri "$apiBase/index/jobs/$($indexJob.job_id)"
```
通过标准:状态接口可访问;重建任务最终为 `completed`。重建失败时应返回统一错误并保留可恢复状态,不应留下半成品索引。
### 5.3 Chat SSE
使用内置 Mock Provider,不需要外部 API Key
```powershell
curl.exe --no-buffer -X POST "http://127.0.0.1:8000/api/chat" -H "Content-Type: application/json" --data-raw '{"provider_id":"mock","model":"mock-1","messages":[{"role":"user","content":"请回复第一阶段 Chat 验收成功"}],"use_rag":false}'
```
通过标准:响应类型为 `text/event-stream`,能看到递增 `sequence``TextDelta`,并以 `Done` 终止;不得一次性伪装为流式结果。
### 5.4 Agent Run 与 Trace
```powershell
$runBody = @{
input = '执行第一阶段 Agent 基础验证'
provider_id = 'mock'
model = 'mock-1'
allowed_tools = @('system.echo', 'math.add')
max_steps = 10
tool_timeout_seconds = 30
run_timeout_seconds = 300
max_concurrent_tools = 1
allow_network = $false
} | ConvertTo-Json
$run = Invoke-RestMethod -Method Post -Uri "$apiBase/agent/runs" -ContentType 'application/json' -Body $runBody
Start-Sleep -Milliseconds 300
$runResult = Invoke-RestMethod -Uri "$apiBase/agent/runs/$($run.run_id)"
$runResult
```
订阅事件也可以使用:
```powershell
curl.exe --no-buffer "http://127.0.0.1:8000/api/agent/runs/$($run.run_id)/events"
```
通过标准:Run 最终为 `completed`,Trace 至少包含开始、文本或工具事件和完成事件;事件序号递增。需要权限的 Tool 应进入 `waiting_permission`,用户允许、会话允许或拒绝后能正确恢复或终止。
### 5.5 Tool、Skill 与 Plugin
```powershell
Invoke-RestMethod -Uri "$apiBase/tools"
Invoke-RestMethod -Uri "$apiBase/skills"
Invoke-RestMethod -Uri "$apiBase/plugins"
```
通过标准:内置 Tool Definition 能被列出;内置知识助手 Skill 和示例 Plugin 状态可读取;未知权限、缺失依赖和畸形 Manifest 必须被拒绝,不能静默启用。
### 5.6 Provider 与模型发现
```powershell
Invoke-RestMethod -Uri "$apiBase/providers"
Invoke-RestMethod -Uri "$apiBase/providers/presets"
Invoke-RestMethod -Uri "$apiBase/providers/mock/models"
```
通过标准:预设至少包含 OpenAI、DeepSeek 和 OllamaMock Provider 能返回模型列表。OpenAI/DeepSeek 属于选测项,需要测试人员自己的有效 API Key,真实密钥不得写入命令历史、文档、Issue、截图或提交。
如需验证外部模型,优先在“设置 → 模型提供商”中选择预设并填写 API Key。页面不得回显明文;后端 `GET /api/credentials/{credential_id}` 只返回 `configured` 状态。测试完成后可在 Swagger 中调用对应 DELETE 接口删除测试凭据。
## 6. 前端人工验收
### 6.1 App Shell 与主题
- 主导航、辅助侧栏、标题栏和状态栏正常显示;
- `Ctrl+P` 能打开命令面板并跳转页面;
- 亮色、暗色和护眼主题切换后文字、表格线、列表序号和浮动工具栏均清晰;
- 导航使用统一图标,不出现无意义 Emoji;
- 窄窗口下主要操作仍可访问。
### 6.2 Workspace 与 Markdown
- 启动 FastAPI 并配置 `APP_VAULT_PATH` 后,能打开后端真实 Vault
- 能在磁盘和 SQLite/FTS/向量索引之间一致地新建、读取、保存、重命名和删除文件及目录;
- 后端不可用时明确报告连接错误,不展示或写入 Mock 文件;
- 连续快速点击不同文件时,路径和正文始终一致;
- 文件切换前的未保存内容不会被错误写入新文件;
- 写作模式不展示 Markdown 源码,源码模式可以精确编辑;
- H1–H6、正文、粗体、斜体、有序/无序列表、行内代码、代码块、行内/块公式、链接和字号输入均能修改 Markdown;
- 选择文本后,顶部工具栏和浮动工具栏都对当前选区生效;
- 正文不默认加粗,标题默认加粗;
- 代码块默认展开编辑,不显示额外 Shiki 预览;
- 主题页可在跟随主题、GitHub Light 和 GitHub Dark 间切换代码块样式,刷新后偏好仍保留;
- Chat 等只读 Markdown 区域的代码高亮能跟随亮暗主题;
- 表格、公式和 Markdown HTML 渲染正常,危险 HTML 被 DOMPurify 清理。
### 6.3 Search、Chat 与 Citation
- FTS、Vector 和 Hybrid 查询可切换;
- 向量服务不可用时能降级到 FTS 并显示说明;
- Chat 能展示 Streaming、Thinking、Tool Call、Usage、错误与 Citation
- 点击 Citation 后能打开对应笔记并定位内容;
- 取消生成后页面状态恢复,不继续追加旧请求内容。
### 6.4 智能体页面
- 能新建、查看、切换和取消智能体运行;
- Provider、模型、Skill、Tool 和运行限制可配置;
- 运行状态、事件类型、工具说明、权限弹窗和常用详情字段显示中文;
- `notes.search` 等技术 ID 保留显示,便于与日志对应;
- Permission Request 不会跨 Run 残留;
- Completed、Failed、Cancelled 和连接中断状态均有明确反馈。
### 6.5 设置与扩展管理
- Provider 预设可选择,保存后自动获取模型,也能手动刷新和选择默认模型;
- 凭据缺失和 HTTP 401 会显示可理解的错误,不只显示笼统网络失败;
- Skill、Plugin 的安装、启用、停用、权限和删除操作状态一致;
- AI Core 诊断页能显示健康状态和开发 API 地址;
- 前端不会在 Store、Local Storage 或页面中保存、回显 API Key 明文。
## 7. 清理测试数据
删除本手册创建的验收笔记:
```powershell
Invoke-RestMethod -Method Delete -Uri "$apiBase/notes/$noteId"
```
如果测试了外部 Provider,还应删除临时 Provider 和不再使用的测试凭据。不要直接递归删除整个 `backend/data`,其中可能包含其他成员的本地 Vault、索引和任务数据。
## 8. 通过判定
第一阶段可以标记为“验证通过”需要同时满足:
- 后端测试零失败;
- 前端测试零失败;
- TypeScript 检查和生产构建成功;
- 健康检查、OpenAPI 和主要接口可访问;
- Note → Index/Search → Citation 主链路通过;
- Mock Chat 和 Agent Trace 主链路通过;
- Tool、Skill、Plugin 和 Provider 基础接口通过;
- 前端人工验收没有 P0/P1 缺陷;
- 没有真实密钥、生成目录或运行数据进入 Git;
- 已记录测试环境、提交、结果、警告和遗留问题。
外部 OpenAI/DeepSeek、Tauri、Stronghold、真实文件系统、真实音频模型与 Sync Server 失败或未测,不阻止当前第一阶段 Web 联调基线通过,但必须在验收记录中注明“未纳入本阶段”或“选测未执行”。
## 9. 验收记录模板
```markdown
# 第一阶段验收记录
- 日期:
- 验收人:
- 分支:main
- 提交:
- 操作系统:
- Node / pnpm
- Python / uv
## 自动化结果
- 后端 pytest:通过 / 失败,数量:
- 前端 Vitest:通过 / 失败,数量:
- 前端 build:通过 / 失败:
- git diff --check:通过 / 失败:
## 主链路
- Health / OpenAPI
- Note / Search / Citation
- Chat SSE
- Agent Run / Trace / Permission
- Tool / Skill / Plugin
- Provider / Models
- 前端页面人工验收:
## 选测项
- OpenAI:未测 / 通过 / 失败
- DeepSeek:未测 / 通过 / 失败
- Ollama:未测 / 通过 / 失败
## 警告与遗留问题
-
## 最终结论
- 通过 / 有条件通过 / 不通过
```
@@ -0,0 +1,491 @@
# Agent Core 第二阶段:Trace 持久化与 SSE 恢复问题复盘
> 审阅与修复日期:2026-09-01
> 涉及分支:`feat/agent-trace-persistence`
> 功能提交:`3cb197a feat(agent): 持久化Trace并支持SSE恢复`
> 文档用途:记录 Agent Run、Trace、SSE 恢复和审计数据安全问题的形成原因、实际后果、解决思路与落地方案,供后续开发文档、比赛材料和技术博客写作使用。
## 1. 背景与结论
第一阶段 Agent Runtime 已经能够完成模型调用、Tool Calling、权限确认、取消、Usage 和 Citation,但 Run 与 Event 仍以进程内字典和列表为事实来源。第一阶段审阅加入的 Run/Event 数量上限解决了内存无界增长,却没有解决重启丢失、断线续传、Benchmark 复用和敏感数据审计等第二阶段问题。
本轮处理了 9 类问题:
| 编号 | 问题 | 级别 | 处理结果 |
| --- | --- | --- | --- |
| A-01 | Agent Run 与 Event 只存在于进程内存 | P0 | 增加 SQLite v3 Schema 和 Trace Repository |
| A-02 | Event 裁剪后 sequence 可能重复 | P0 | 改为独立单调序号并建立数据库幂等键 |
| A-03 | SSE 断线后无法从指定事件恢复 | P1 | 支持 `Last-Event-ID``after_sequence` 和 SSE `id` |
| A-04 | 缺少可分页 Trace 与 Benchmark 配置快照 | P1 | 增加 Trace API、统计摘要与配置快照 |
| A-05 | Trace 缺少模型调用、父子关系和权限结果 | P1 | 增加第二阶段事件及耗时/parent 字段 |
| A-06 | Trace 可能保存 Secret 和超大 Tool Result | P0 | 全部持久化副本统一脱敏,审计摘要限长 |
| A-07 | 进程重启后未终止 Run 永久显示运行中 | P1 | 自动收束为 `AGENT_PROCESS_RESTARTED` |
| A-08 | 前端 DTO 与 SSE Client 无法消费恢复协议 | P1 | 同步 TypeScript Contract、Service、标签和 SSE id |
| A-09 | AgentRun 与审计摘要共用限长规则 | P1 | Run 只脱敏不限长,保证重启前后内容一致 |
修复后的验证基线为:后端 81 项测试、前端 27 项测试、TypeScript 类型检查和生产构建通过。
## 2. A-01Agent Run 与 Event 只存在于进程内存
### 原因
`AgentRuntime` 使用 `_records: dict[str, RunRecord]` 保存全部运行状态。每个 `RunRecord` 内部再保存 `events`、实时订阅队列和异步任务。创建、查询、列表和 SSE 回放都只读取这个字典,没有 Repository 边界。
第一阶段为控制内存加入了最多 200 个 Run、每个 Run 2000 个 Event 的限制。这是必要的资源保护,但只是减少进程内数据量,不能替代持久化。
### 后果
- AI Core 重启后,历史 Run、Tool Result、Citation 和错误全部丢失;
- 前端刷新或重新连接后,只能读取当前进程尚未淘汰的数据;
- Agent Benchmark 无法使用稳定的历史事实计算指标;
- Run 列表随着进程重启清空,界面记录和实际操作脱节;
- 内存裁剪后的旧事件无法再恢复。
### 解决思路
将 SQLite 中的 Run/Event 作为唯一持久化事实,把内存降级为执行上下文和实时订阅窗口。Runtime 继续负责状态机,Repository 负责落库、分页、恢复和统计,Router 不直接访问数据库。
### 解决方案
新增 SQLite v3 migration
```text
agent_runs
├── run_id
├── status
├── run_json
├── request_json
├── config_snapshot_json
├── created_at
└── updated_at
agent_events
├── run_id
├── sequence
├── event
├── data_json
└── timestamp
```
新增 `AgentTraceRepository`,负责:
- 创建 Run 及请求、配置快照;
- 在同一事务中更新 Run 并追加 Event;
- 按创建时间分页列出 Run
- 按 sequence 读取 Event
- 生成 Trace 分页响应和统计摘要;
- 恢复进程中断的非终态 Run。
内存中的 2000 条 Event 上限继续保留,但只用于实时订阅窗口;完整记录由 SQLite 管理。
## 3. A-02Event 裁剪后 sequence 可能重复
### 原因
`_publish()` 使用下面的方式生成序号:
```python
sequence = len(record.events)
```
当事件数量超过 2000 后,Runtime 会删除列表头部。列表长度重新回到 2000,后续事件仍会得到 2000,形成重复 sequence。
### 后果
- `run_id + sequence` 无法作为幂等键;
- 前端按 sequence 去重时会错误丢弃新事件;
- 时间线排序出现同序号节点;
- 持久化后会触发主键冲突,或者在错误的覆盖策略下破坏旧事件;
- Benchmark 无法可靠还原 Tool Call 顺序。
### 解决思路
sequence 应属于 Run 的逻辑时钟,不能从当前缓存长度推导。内存是否裁剪不得影响序号。
### 解决方案
- `RunRecord` 增加独立的 `next_sequence`
- 每次发布读取当前值,再原子递增;
- `agent_events` 使用 `(run_id, sequence)` 复合主键;
- Repository 对重复写入使用幂等插入,不覆盖已经存在的事件事实;
- Trace 和 SSE 均严格按 sequence 升序返回。
## 4. A-03SSE 断线后无法恢复
### 原因
旧 SSE 接口只能从内存列表头部重新回放全部历史,再切换到实时队列。协议帧只有 `event:``data:`,没有 SSE 标准的 `id:`。接口也不读取 `Last-Event-ID` 或查询游标。
前端即使知道自己最后处理到哪个 sequence,也无法把该位置传回服务端。
### 后果
- 短暂断网或页面切换后只能从头回放;
- 长 Run 重连会重复传输大量事件;
- 前端需要依赖本地去重掩盖服务端缺少恢复能力;
- AI Core 重启后无法续传,因为历史事件本身也不存在;
- 实时与历史交界处容易漏事件或重复事件。
### 解决思路
恢复协议以 sequence 为游标。服务端先注册实时订阅,再读取 `sequence > cursor` 的持久化历史,随后消费实时队列;交界处允许重复,但 Runtime 和前端都按 sequence 去重。
### 解决方案
接口支持两种游标输入:
```http
GET /api/agent/runs/{run_id}/events?after_sequence=42
Last-Event-ID: 42
```
返回帧包含:
```text
id: 43
event: ToolResult
data: {"run_id":"...","sequence":43,"data":{...}}
```
具体处理:
- `after_sequence` 优先于 `Last-Event-ID`
- 默认游标为 `-1`,表示从 sequence 0 开始;
- 非整数或小于 `-1` 的 Header 返回 `TRACE_CURSOR_INVALID`
- 历史回放读取 SQLite,不依赖内存窗口;
- 历史与实时交界处按最后已发送 sequence 跳过重复项;
- 终态 Run 回放完终止事件后关闭连接。
## 5. A-04:缺少可分页 Trace 与 Benchmark 配置快照
### 原因
旧接口只有 Run 状态和 SSE。SSE 适合实时消费,不适合报告页随机访问、大 Trace 分页或 Benchmark 批量计算。Run 也没有保存创建时的 Provider、Model、Skill、允许工具和限制参数快照。
### 后果
- Trace 页面只能依赖一次长连接重建全部状态;
- Benchmark 需要绕过正式接口读取 Runtime 内部对象;
- Provider 或 Skill 配置变化后,旧结果失去可解释性;
- 大 Trace 无法受控分页,接口响应体会持续增大;
- 前端和 Benchmark 容易各自实现一套不一致的统计逻辑。
### 解决思路
提供面向读取的 Trace Snapshot API,但只返回平铺事实,不在后端生成前端树形布局。前端按 parent ID 和 sequence 构造时间线,Benchmark 从同一事件计算指标。
### 解决方案
新增接口:
```http
GET /api/agent/runs/{run_id}/trace?after_sequence=-1&limit=200
```
响应包含:
```json
{
"run_id": "run_123",
"status": "completed",
"items": [],
"next_sequence": 199,
"has_more": true,
"summary": {
"model_calls": 2,
"tool_calls": 3,
"duration_ms": 1530,
"token_usage": 2048,
"errors": 0
},
"config_snapshot": {}
}
```
配置快照保存:
- Provider ID 和类型;
- Model
- Capability
- Skill ID
- 允许的 Tool
- `max_steps`、Token Budget 和网络权限;
- 经过脱敏的 Metadata。
## 6. A-05Trace 缺少关键执行事实
### 原因
第一阶段事件能够表达 Run、Tool、Permission Request、Usage 和 Citation,但没有明确表示一次模型调用的开始、完成或失败。Tool Call 与模型轮次之间也没有 parent ID,Tool Result 缺少统一耗时。权限接口只唤醒 Future,不记录最终决定。
### 后果
- 前端无法展示“模型调用 → 多个 Tool → 下一次模型调用”的完整树;
- Agent Benchmark 无法计算模型调用次数和平均步骤耗时;
- 并发 Tool Call 时难以判断属于哪个模型轮次;
- 权限卡片消失后,Trace 中只保留“请求过权限”,不知道用户允许还是拒绝;
- Provider 失败只能看到最终 RunFailed,缺少模型调用级上下文。
### 解决思路
保持现有 AgentEvent envelope 不变,只增加事件类型和可选数据字段。调用关系使用稳定 ID 表达,不让前端根据相邻位置猜测父子关系。
### 解决方案
新增事件:
```text
ModelCallStarted
ModelCallCompleted
ModelCallFailed
PermissionResolved
```
补充字段:
- Model Call`model_call_id`、Provider、Model、Step、Finish Reason、Token、Duration
- Tool Call/Result`parent_model_call_id``duration_ms`
- Permission Resolved`request_id`、Permission、Decision
- Model Call Failed`error_code` 和耗时,不写入第三方原始敏感异常。
模型与 Tool 耗时使用单调时钟计算,避免系统时间调整影响 Duration。
## 7. A-06Trace 可能保存 Secret 和超大结果
### 原因
Tool 参数和输出来自模型、插件或外部服务,属于不可信数据。旧事件直接保存 `ToolCall.model_dump()``ToolResult.model_dump()``notes.read`、附件或第三方 Tool 可以返回大段正文,参数也可能包含 `api_key`、Authorization 或 Password。
初版持久化修复只净化了 `agent_events.data_json`。提交前审阅发现,`agent_runs.run_json` 中的 `tool_results``output``input` 仍可能保存同一份敏感值,说明“只在事件层脱敏”并不完整。
### 后果
- API Key 或 Bearer Token 可能进入 SQLite、备份和测试产物;
- Trace API 不返回 Secret,但数据库中的 Run Snapshot 仍可能泄露;
- 单个 Tool Result 可以让 Event 和 Run JSON 快速膨胀;
- 前端展开节点时可能因为超大 JSON 卡顿;
- Benchmark Dataset 或报告导出可能间接携带密钥。
### 解决思路
所有进入持久化边界的数据统一经过同一个净化函数,不能分别在 Router、Runtime 和 Repository 中维护不同脱敏规则。Secret 脱敏适用于全部副本;体积限制只适用于 Trace Event、Request 和 Config 等审计数据,不能改变对外查询所依赖的 AgentRun 事实。
### 解决方案
统一处理以下对象:
```text
AgentRun Snapshot
AgentRunCreateRequest Snapshot
Config Snapshot
AgentEvent Data
```
通用脱敏规则:
- `api_key`、Authorization、Access/Refresh Token、Password、Secret 等键替换为 `[REDACTED]`
- 常见 `sk-...``Bearer ...` 字符串模式直接替换;
- `credential_id` 等非明文引用保留,不误判为 Secret。
Event、Request 和 Config 审计副本额外执行限长:
- 单字符串最多保留 4096 个字符;
- 单集合最多保留 100 项;
- 递归深度最多 8 层;
- 超限位置使用明确的 `[TRUNCATED]``[MAX_DEPTH]` 标记。
`AgentRun` Snapshot 仍经过同一套 Secret 脱敏,但不执行长度、集合和深度截断。回归测试直接读取 `agent_runs` 原始 SQLite 字段,确认测试密钥没有落盘,避免只验证 API 响应造成假安全。
## 8. A-07:重启后未终止 Run 永久显示运行中
### 原因
Agent 的异步 Task 和 Permission Future 不能跨进程恢复。持久化 Run 后,如果直接返回数据库状态,重启前处于 `queued``running``waiting_permission` 的记录会一直保持非终态,但新进程中没有对应 Task 可以继续执行。
### 后果
- 前端长期显示“运行中”或“等待授权”;
- SSE 订阅等待一个永远不会到来的终止事件;
- Benchmark Runner 无法判断 Case 已中断;
- 用户取消该 Run 时,新进程找不到实际 Task;
- 统计中的成功率和耗时被悬挂 Run 污染。
### 解决思路
本阶段提供“状态恢复”,不伪装成“执行恢复”。没有可重放状态机、Provider 幂等令牌和 Tool 副作用日志之前,自动继续执行会造成重复写入或重复网络请求。
### 解决方案
新 Runtime 首次读取不属于当前进程的非终态 Run 时:
- 状态改为 `failed`
- 错误码设为 `AGENT_PROCESS_RESTARTED`
- 错误信息说明进程在完成前重启;
- 使用数据库最大 sequence 加一,追加唯一 `RunFailed` 事件;
- 后续 Run 查询、Trace 和 SSE 都返回同一终态事实。
已经完成、失败或取消的 Run 不修改,可以在重启后继续查询和回放。
## 9. A-08:前端无法消费第二阶段恢复协议
### 原因
后端增加新事件和 SSE `id` 后,前端 `AgentEventType` 仍只包含第一阶段事件。`SseClient` 只解析 `event``data`,忽略 `id`,也没有发送 `Last-Event-ID`。因此仅完成后端并不能形成可联调的 Contract。
### 后果
- `Record<AgentEventType, string>` 中文标签无法通过类型检查;
- 前端不知道 Model Call 和 Permission Resolved 的类型;
- 断线后无法把最后事件 ID 传回后端;
- Trace 可视化负责人需要自行猜测 Wire DTO;
- 后端恢复能力只能通过 Curl 使用,页面调用链没有闭环。
### 解决思路
范侧只提供稳定的前端接口适配,不越过分工实现 Trace Visualization。Pydantic Contract、TypeScript Wire DTO 和 Service 必须在同一功能提交中同步。
### 解决方案
- TypeScript 增加四类第二阶段 Agent Event
- 增加 `AgentTraceSummary``AgentTraceResponse`
- `agentService.getAgentTrace()` 封装分页查询;
- `streamAgentEvents()` 接受 `afterSequence`
- `SseClient` 发送 `Last-Event-ID` 并解析返回帧的 `id`
- 新事件增加中文标签和详情字段名;
- 增加 SSE 请求头和 Event ID 单元测试。
Trace 时间线、树形布局、筛选、节点展开和 Citation 跳转仍由前端负责人实现。
## 10. A-09AgentRun 与审计摘要共用限长规则
### 原因
初版使用 `sanitize_trace_value()` 同时处理 `run_json`、Request、Config 和 Event。该函数不仅脱敏,还会截断超过 4096 字符的字符串、超过 100 项的集合和超过 8 层的结构。`run_json` 随后又被 `GET /agent/runs/{run_id}` 和 Run 列表当作重启后的事实来源,因此审计数据的防膨胀规则意外改变了业务响应。
### 后果
- 常见的长模型回答在 AI Core 重启后只剩前 4096 个字符和截断标记;
- 长输入、Tool Result 和嵌套结果也可能丢失;
- 同一个 Run 在进程内与重启后的接口响应不一致;
- 前端刷新、Benchmark 复核和问题追踪无法取得原始运行结果;
- 原有测试只验证了 Trace 参数截断,没有比较重启前后的长正文。
审阅时使用 5000 字符 output 复现:写入前长度为 5000,重新读取后长度变为 4110,并以 `...[TRUNCATED]` 结尾。
### 解决思路
持久化边界包含两类数据:AgentRun 是业务事实,Trace Event、Request 和 Config 是可视化与审计摘要。两类数据必须共享 Secret 脱敏规则,但不能共享有损的体积限制。
### 解决方案
- `sanitize_trace_value()` 增加明确的 `apply_limits` 策略参数;
- 默认继续限长,保持 Event、Request 和 Config 的安全边界;
- `_serialize_run()` 使用 `apply_limits=False`,完整保留 input、output、Tool Result 和 Citation
- Secret 键名及 `sk-`、Bearer 模式在两种策略下始终脱敏;
- 增加超过 4096 字符的 input/output 持久化回归测试,直接从新 Repository 读取并逐字比较。
## 11. 事务、顺序与恢复不变量
本轮修复明确了以下不变量:
1. `run_id + sequence` 唯一标识一条 Agent Event。
2. sequence 在一个 Run 内只增不减,不受内存裁剪影响。
3. 发布事件时,在同一 SQLite 事务中更新 Run Snapshot 并追加 Event。
4. SSE、Trace 页面和 Agent Benchmark 读取同一份 `agent_events`,不建立旁路。
5. 终止事件为 `RunCompleted``RunFailed``RunCancelled`;终态 Run 不再产生业务事件。
6. 重启后不能安全继续执行的 Run 必须明确失败,不能永久悬挂。
7. 所有持久化 Trace 数据先脱敏、再写入。
8. 前端按 `run_id + sequence` 去重,不能依赖一次网络读取对应一条 SSE Event。
9. AgentRun 的持久化副本不执行审计摘要限长,重启前后业务字段必须一致。
## 12. 验证方法
后端:
```powershell
cd backend
uv run python -m compileall -q app
uv run pytest
```
前端:
```powershell
cd frontend
pnpm test
pnpm type-check
pnpm build
```
仓库检查:
```powershell
git diff --check
```
验证结果:
```text
backend pytest 81 passed
backend compileall passed
frontend vitest 11 files / 27 tests passed
frontend type-check passed
frontend build passed
git diff --check passed
```
本轮新增回归覆盖:
- 完成 Run 在新 Runtime 中恢复查询;
- Trace 多页读取和游标无重复;
- SSE `Last-Event-ID``after_sequence``id:` 帧;
- Model Call、Permission Resolved 和 Tool parent ID
- 进程中断 Run 自动生成唯一终止事件;
- API Key、Authorization 和超长 Tool 参数净化;
- 超长 AgentRun input/output 在持久化和重启读取后保持完整;
- 直接检查 SQLite,确认 Secret 未进入 Run/Request/Config Snapshot
- OpenAPI 发布 Trace 路径;
- 前端 SSE Client 发送和解析恢复游标。
测试仍会出现本机 `.pytest_cache` 无写入权限警告,不影响 81 项用例结果,也不涉及产品代码。
## 13. 当前边界与后续工作
### 13.1 本阶段明确不做
- 不在后端生成前端 Trace 树形布局;
- 不在进程重启后自动重放未完成 Tool 副作用;
- 不把 Secret 明文放入 Trace、日志或 Benchmark
- 不为 Benchmark 建立绕过 Agent Runtime 的专用执行协议。
### 13.2 后续需要继续处理
- 增加 Trace 保留、归档和被 Benchmark 引用时的保护策略;
- 引入保留窗口后实现 `TRACE_CURSOR_EXPIRED`
- 根据桌面网络策略增加有上限的指数退避自动重连;
- 评估高频 Token Event 的批量写入,减少 SQLite 连接与事务开销;
- Agent Benchmark 接入正式 Trace 并验证指标字段是否充足;
- 前端完成 Trace Timeline/Tree、筛选、节点详情和 Citation 跳转;
- 多进程或远程执行出现需求后,再设计带租约和幂等副作用的执行恢复。
## 14. 可复用经验
### 14.1 资源上限不等于持久化
限制内存 Run 和 Event 数量只能防止进程膨胀,不能解决重启、审计和报告复现。临时保护措施应在文档中明确标注,不能被误认为最终架构已经完成。
### 14.2 游标必须独立于缓存结构
只要 sequence 来源于 `len(list)`、数组下标或当前页位置,裁剪和分页就可能破坏唯一性。可恢复事件流必须使用独立、单调且可持久化的逻辑序号。
### 14.3 恢复读取不等于恢复执行
恢复 Run/Trace 查询相对安全;恢复一个包含 Tool 副作用的执行任务需要额外的幂等、租约和补偿机制。在没有这些机制时,明确失败比重复执行更可靠。
### 14.4 脱敏要覆盖全部持久化副本
同一敏感值可能同时出现在 Event、Run Snapshot、Request、Config、日志和报告中。只检查最终 API 响应无法证明数据没有落盘,安全测试应直接验证持久化介质。
### 14.5 生产者和消费者 Contract 必须同时更新
后端新增事件类型、字段或 SSE 规则时,至少同步 Pydantic、OpenAPI、TypeScript DTO、Service 和协议测试。可视化页面可以由另一成员开发,但不能让对方从后端实现反推 Contract。
@@ -0,0 +1,503 @@
# Knowledge Core 与 Retrieval CorePR 审阅问题与修复复盘
> 本文记录 `feat/knowledge-retrieval-core` 合并前后的两轮代码审阅、问题复现、修复过程与工程经验。
> 它既是团队内部的问题档案,也可作为后续技术文档、课程报告和博客文章的素材底稿。
> 2026-08-30 状态补充:本文中的 43 项测试是当时该模块的历史基线,不应替换为当前全仓测试数。相关修复仍有效,当前完整后端回归为 71 项通过,Knowledge/Retrieval 已通过 `notes.*``rag.search` Tool 接入 Agent Runtime。
## 1. 背景
Knowledge Core 与 Retrieval Core 建立了项目第一条完整的本地知识检索链路:
```text
Markdown Vault
→ Markdown Block 解析
→ SQLite 元数据 / FTS5 / sqlite-vec
→ FTS 与向量双路召回
→ RRF 融合与轻量 Reranker
→ Metadata Filter
→ Citation 与 Snippet
```
功能分支完成后进行了两轮审阅。第一轮主要检查路径安全、索引一致性、PATCH 语义、检索分页和重建行为;第二轮重点验证第一轮修复是否真正覆盖事务失败和大数据边界。
审阅不是只看现有测试是否通过,而是主动构造失败条件,例如:
- 让 embedding、向量删除或索引元信息写入主动抛出异常;
- 创建同目录、同标题的重复笔记;
- 使用 `../`、Windows 盘符等恶意 folder
- 制造超过候选池以及超过 1000 条的 FTS 命中;
- 比较 `blocks``vec_blocks` 的 ID 集合;
- 在接口报错后重新读取 Markdown、SQLite 和向量表,检查是否发生部分提交。
## 2. 审阅与修复时间线
| 阶段 | 提交 | 内容 |
| --- | --- | --- |
| 功能分支首轮修复 | `8771745` | 路径逃逸、更新部分提交、失效向量和基础分页 |
| 功能分支二次修复 | `6cf531f` | 跨表事务、Metadata Filter、PATCH tags、rebuild 回滚 |
| 合并提交 | `9348225` | 将 Knowledge/Retrieval Core 合并到 `main` |
| 合并后第一批修复 | `af8ccb0` | `index_meta` 事务一致性、重复创建冲突 |
| 合并后第二批修复 | `32f249c` | 删除一致性、FTS 精确过滤与分页 |
最终回归测试结果:
```text
43 passed
```
## 3. 问题总览
| 编号 | 问题 | 主要风险 | 最终处理 |
| --- | --- | --- | --- |
| P-01 | folder 可逃逸 Vault | 任意位置写入或删除 Markdown | 路径标准化,并校验 resolve 后仍位于 Vault |
| P-02 | VectorStore 的 upsert 实际为 INSERT | 正常 PATCH 主键冲突 | delete-then-insert,实现幂等 upsert |
| P-03 | 元数据与向量分开提交 | 接口失败但数据库已部分改变 | 共用连接和 SQLite 事务 |
| P-04 | 更新正文后遗留旧向量 | Top-K 被无效向量占据 | 比较新旧 Block ID,事务内清理失效向量 |
| P-05 | PATCH tags 语义错误 | 省略字段却清空标签,空数组无法清空 | 严格区分 `None``[]` |
| P-06 | 固定候选池破坏过滤与分页 | 合法结果漏召回,total 不准确 | 先扩充候选,最终改为 FTS SQL 侧过滤和分页 |
| P-07 | rebuild 忽略 scope/note_ids 且先清空 | 请求语义错误,失败留下半成品 | 拒绝未支持范围,扫描在前,备份与失败恢复 |
| P-08 | `index_meta` 在主事务外写入 | 文件回滚但索引已更新 | 将 `index_meta` 纳入同一事务 |
| P-09 | POST 同路径静默覆盖 | 原笔记无提示丢失 | 数据库预检 + 文件排他创建 + 409 |
| P-10 | 删除由三个独立操作组成 | notes、向量和文件互相不一致 | tombstone + SQLite 同事务 + 失败恢复 |
## 4. P-01folder 路径逃逸
### 4.1 问题原因
最初的笔记路径由下面的逻辑生成:
```python
folder_part = folder.strip().strip("/")
rel_path = f"{folder_part}/{safe_title}.md"
absolute_path = vault / rel_path
```
代码只去除了 folder 两端的斜杠,没有处理:
- `..``.` 路径段;
- Windows 反斜杠;
- `C:\...` 等盘符路径;
- NUL 字节;
- 符号链接或规范化后越过 Vault 的路径。
因此 `folder="../outside"` 会使最终路径落到 Vault 之外。
### 4.2 后果
攻击者或错误的前端输入可以在 Vault 外创建 Markdown。数据库会保存这个越界路径,后续读取和删除接口还会继续访问该文件,风险从“越界写入”扩大为“越界读取和删除”。
### 4.3 解决思路
路径安全不能只依赖字符串替换,需要同时完成两层校验:
1. 输入层拒绝明显非法的路径片段;
2. 文件系统层对最终路径执行 `resolve()`,确认它仍属于 Vault。
### 4.4 最终方案
`note_service.py` 新增 `_normalize_folder()` 和安全的 `_abs_path()`
- 同时按 `/``\` 拆分目录;
- 拒绝 `.``..`、盘符和 NUL
- 对 Vault 与目标执行 `resolve()`
- 使用 `Path.is_relative_to()` 验证目标未逃逸;
- 所有读、写、删操作统一经过 `_abs_path()`
回归测试覆盖 `../../outside``..\..\etc``C:\Windows``a/../b` 等输入。
## 5. P-02 与 P-03:伪 upsert 和索引部分提交
### 5.1 问题原因
Block ID 根据 `note_id + heading_path + content` 稳定生成。只修改标题或标签时,正文 Block ID 不会改变。
最初 `SqliteVecStore.upsert()` 名为 upsert,实际执行的却是普通 INSERT:
```sql
INSERT INTO vec_blocks (block_id, embedding) VALUES (?, ?)
```
同一个 Block 再次写入时会触发 sqlite-vec 主键冲突。同时,元数据和 FTS 已经在另一个事务中提交,向量失败无法回滚前面的修改。
### 5.2 后果
一次仅修改标题的 PATCH 就可能出现:
```text
HTTP500
Markdown:已写入
notes / blocks / FTS:已更新
vec_blocks:写入失败
```
调用方看到的是“更新失败”,系统内部却已经改变。这类部分提交比直接失败更危险,因为客户端重试可能继续扩大不一致。
### 5.3 解决思路
- upsert 必须具备幂等性;
- notes、blocks、FTS 与 vec_blocks 都在同一个 SQLite 文件内,应共用连接和事务;
- Repository 和 VectorStore 既要支持独立调用,也要支持加入调用方事务。
### 5.4 最终方案
VectorStore 的 upsert 改为:
```text
DELETE existing block_id
→ INSERT new embedding
```
`repository.replace_note_metadata()``vector_store.upsert()``vector_store.delete()` 都可接收外部连接。`index_note()` 打开一个连接和事务,把元数据、FTS、失效向量删除、新向量插入放在同一提交边界中。
## 6. P-04:失效向量残留
### 6.1 问题原因
更新正文后,Repository 会删除旧 Block 并插入新 Block,但早期实现只写入新向量,没有删除内容已经不存在的旧向量。vec0 没有到 blocks 表的外键约束,因此这些向量不会自动级联删除。
### 6.2 后果
实测一次全文更新后出现:
```text
blocks = 1
vec_blocks = 2
```
旧向量仍会参加 KNN 排名,但随后无法取得 Block 上下文。反复编辑后,失效向量可能占满 Top-K,使真正有效的结果无法进入候选集。
### 6.3 解决思路与方案
更新前取得旧 `block_id`,解析后得到新 `block_id`,计算集合差:
```text
stale = old_ids - new_ids
missing = new_ids - old_ids
```
- 删除 `stale` 对应向量;
- 只为 `missing` 写入新向量;
- 未变化 Block 沿用原向量;
- 整个过程与 Block 替换使用同一事务。
测试以集合不变量验证修复:
```text
set(vec_blocks.block_id) == set(blocks.block_id)
```
## 7. P-05PATCH tags 语义错误
### 7.1 问题原因
解析器曾使用真值判断:
```python
resolved_tags = list(tags) if tags else parse_frontmatter_tags()
```
但 PATCH 中的 `None``[]` 含义不同:
- `None`:客户端没有提交 tags,应保留原值;
- `[]`:客户端明确要求清空标签。
真值判断把两种状态混为一谈。另外,更新服务直接把 `None` 传给解析器,导致原有 API 标签被 frontmatter 或空数组替换。
### 7.2 后果
- 仅修改标题也会意外清空标签;
- 显式提交空数组时,反而可能重新读取 frontmatter 标签,无法清空。
### 7.3 最终方案
解析器改为判断 `tags is not None`。更新服务根据 PATCH 语义计算有效标签:
```python
effective_tags = record.tags if tags is None else tags
```
回归测试分别覆盖省略、替换和清空三种情况。
## 8. P-06:候选池、Metadata Filter 与分页
### 8.1 第一阶段问题
初版 FTS 和向量召回都固定只取 50 条候选,然后才执行 Metadata Filter 和分页:
```text
Top 50 → Metadata Filter → offset/limit
```
这会产生两个错误:
1. 第 51 条以后的页面永远无法访问;
2. 满足 folder/tag 条件的结果如果排在第 51 条以后,会被错误地过滤掉。
实测 60 个匹配 Block 请求 `offset=50` 时,响应为 `total=50, items=[]`
### 8.2 中间修复为什么仍不完整
第一轮修复根据 `offset + limit` 扩大候选池,并在有过滤条件时 overscan;FTS 模式则把上限扩大到 1000。
这解决了常见的小数据场景,但本质只是把硬边界从 50 移到了 1000。第二轮实测 1010 个匹配 Block,请求 `offset=1000` 时仍得到:
```json
{"total": 1000, "items": []}
```
### 8.3 最终解决思路
全文检索分页不应先把所有结果加载到 Python 再切片,应由数据库完成:
```text
FTS MATCH
+ Metadata WHERE
→ COUNT(*) 得到精确 total
→ ORDER BY bm25
→ LIMIT / OFFSET
```
### 8.4 最终方案
Repository 新增 FTS 精确分页查询:
- JOIN `blocks``notes`
- folder 和 note_id 使用 SQL `IN`
- tags 使用 `json_each()`
- 时间范围使用 `julianday()` 比较;
- 相同过滤条件分别用于 COUNT 和数据页查询;
- `RetrievalEngine` 为纯 FTS 模式使用专用查询路径。
最终测试确认 1010 个匹配 Block 在 `offset=1000, limit=10` 时返回:
```text
total = 1010
items = 10
```
向量与 Hybrid 模式仍属于 Top-K 近邻召回,其 `total` 表示候选集数量,不等价于全文数据库的全局命中数。这是检索模型本身的语义差异,需要在接口文档和前端展示中保持明确。
## 9. P-07rebuild 请求语义与失败恢复
### 9.1 问题原因
接口模型公开了 `scope=all/notes/vectors``note_ids`,但最初所有请求都会无条件清空全部索引并全量扫描 Vault。旧索引在扫描和 embedding 之前就被删除,中途失败会留下空库或半成品。
### 9.2 后果
- 调用方请求只重建向量,实际却清空 notes 和 FTS;
- 指定 note_ids 仍触发全量重建;
- 单个损坏文件或 embedding 异常即可破坏原本可用的索引。
### 9.3 最终方案
当前 MVP 明确只支持全量重建:
- 非 `scope="all"` 或非空 `note_ids` 返回 `400 UNSUPPORTED_SCOPE`
- 清理旧索引前先把 Vault 文件全部读取到内存,读取失败不影响旧索引;
- 重建前备份 SQLite 文件;
- 中途失败时恢复备份,并记录 failed Job;
- 成功或失败后清理临时备份。
`force` 当前仍是预留字段,没有行为差异。后续实现增量重建时,应一起明确它的契约,避免再次出现“参数被接受但没有效果”。
## 10. P-08`index_meta` 位于主事务之外
### 10.1 问题如何被复审发现
第一轮修复已经把 notes、blocks、FTS 和向量放进同一事务,看起来解决了原子性问题。但 `index_meta` 仍在该事务提交后,通过新连接单独写入。
通过故障注入让 `set_index_meta()` 抛出异常后发现:
```text
主索引事务:已经提交新内容
index_meta:失败
update_note:捕获异常,恢复旧 Markdown
最终结果:Markdown 是旧内容,索引是新内容
```
这说明“把主要写操作放进事务”还不够,只要事务外仍有能够决定接口成功或失败的步骤,部分提交依然存在。
### 10.2 最终方案
`repository.set_index_meta()` 增加可选连接参数:
- 独立调用时自行开连接、开事务并提交;
- 收到外部连接时加入已有事务,不自行提交和关闭。
`index_note()` 在主事务退出前写入 `index_meta`。现在任何一步失败都会回滚全部 SQLite 修改,外层再恢复 Markdown,从而保持文件与索引一致。
## 11. P-09POST 同路径静默覆盖
### 11.1 问题原因
笔记的相对路径和 note_id 都由 `folder + title` 确定。创建接口写文件前没有检查同路径资源是否存在,Repository 又使用 `ON CONFLICT DO UPDATE`,因此第二次 POST 会被当作更新执行。
### 11.2 后果
连续创建同目录、同标题笔记时:
```text
第一次:original
第二次:replacement
note_id:相同
最终正文:replacement
```
原笔记没有任何冲突提示就被覆盖。更严重的是,早期失败回滚逻辑会在第二次索引失败时删除目标文件,连第一篇原文也一并丢失。
### 11.3 解决思路与方案
创建和更新必须具有不同语义:
- POST 只创建新资源,冲突时返回 409;
- PATCH 才允许修改已有资源。
最终实现包含双重保护:
1. 根据稳定 note_id 查询 SQLite,存在则返回 `409 RESOURCE_CONFLICT`
2. 使用 `Path.open("x")` 排他创建文件,处理数据库检查与文件写入之间的并发竞争。
只有本次请求确实创建了新文件,后续索引失败时才会删除它,因此不会误删旧文件。
## 12. P-10:删除操作部分提交
### 12.1 问题原因
早期删除流程是三个独立步骤:
```text
提交 notes / blocks / FTS 删除
→ 单独提交向量删除
→ 删除 Markdown
```
如果第二步向量删除失败,第一步无法回滚,第三步不会执行。
### 12.2 后果
故障注入后的状态为:
```text
notes:已删除
blocks / FTS:已删除
vec_blocks:仍存在
Markdown:仍存在
HTTP:失败
```
系统无法通过普通读取接口找到笔记,但文件和孤立向量仍在,调用方也无法判断是否应该重试。
### 12.3 解决思路
SQLite 内的四类数据可以使用同一事务;Markdown 文件不能加入 SQLite 事务,因此需要补偿事务。直接先删文件不利于恢复,先提交数据库又会在文件删除失败时不一致,所以采用可恢复的 tombstone:
```text
Markdown 原子 rename 为非 .md tombstone
→ 同一 SQLite 事务删除 metadata / blocks / FTS / vectors
→ 成功:删除 tombstone
→ 失败:rollback SQLite,并把 tombstone rename 回原路径
```
### 12.4 最终方案
- `repository.delete_note()``vector_store.delete()` 都支持外部连接;
- `note_service.delete_note()` 负责建立统一事务;
- 原文件先移动为同目录下带随机 UUID 的 `.deleting` 文件;
- SQLite 失败时恢复原文件;
- SQLite 成功后清理 tombstone
- tombstone 不以 `.md` 结尾,因此即使最终清理因系统原因失败,也不会被 Vault 扫描器重新索引。
回归测试通过让向量删除主动失败,确认 notes、Block、向量和 Markdown 全部恢复到删除前状态。
## 13. 测试策略
这次审阅新增的测试不只验证成功路径,还验证跨层不变量和失败恢复。
### 13.1 路径安全
- `test_create_note_rejects_path_traversal`
### 13.2 写入与回滚
- `test_update_note_rolls_back_file_on_index_error`
- `test_update_note_rolls_back_index_when_index_meta_fails`
- `test_create_note_rejects_existing_path_without_overwrite`
- `test_delete_note_rolls_back_when_vector_delete_fails`
### 13.3 向量一致性
- `test_update_removes_stale_vectors`
- `test_patch_partial_content_no_orphan_vectors`
### 13.4 PATCH 契约
- `test_patch_tags_semantics`
### 13.5 检索过滤与分页
- `test_search_pagination_total_reflects_all_matches`
- `test_fts_pagination_is_not_truncated_at_one_thousand`
- `test_fts_metadata_filter_recalls_beyond_candidate_pool`
### 13.6 rebuild
- `test_rebuild_rejects_unsupported_scope_and_note_ids`
- `test_rebuild_failure_restores_old_index`
测试运行方式:
```powershell
cd backend
uv run pytest -q
```
## 14. 可复用的工程经验
### 14.1 函数名不能替代语义验证
一个函数叫 `upsert`,不代表它真的具备幂等更新能力。审阅时应继续检查底层 SQL、唯一键行为和重复调用结果。
### 14.2 原子性要覆盖完整的成功判定路径
只把主要数据表放进事务还不够。日志、索引版本、元信息等后续步骤如果仍会让接口失败,也必须加入事务,或者被设计为不影响主操作结果。
### 14.3 文件系统与数据库需要补偿事务
SQLite 可以回滚,文件系统通常不能参与数据库事务。跨两种存储介质时,可采用临时文件、原子 rename、tombstone 和失败恢复构造可补偿流程。
### 14.4 PATCH 必须区分“未提供”和“显式为空”
`None`、空列表、空字符串和缺失字段经常具有不同业务含义。使用简单真值判断容易破坏部分更新语义。
### 14.5 固定扩大候选池不是分页方案
把上限从 50 调到 1000 只能推迟问题。只要 API 允许更大的 offset,硬截断就会再次暴露。能由数据库完成的过滤、计数和分页,应尽量下推到数据库。
### 14.6 回归测试应验证失败后的状态
只断言“抛出了异常”无法证明回滚正确。异常后还应重新读取每个存储层,检查文件、元数据、FTS 和向量是否满足不变量。
## 15. 后续可以继续改进的方向
- 为 rebuild 使用临时数据库或 SQLite Backup API,成功后原子切换,替代直接复制数据库文件;
- 明确并实现 `force``scope``note_ids` 的完整语义;
- 为 tombstone 增加启动时清理或恢复机制;
- 对并发创建、更新、删除增加集成测试;
- 明确 FTS 的精确 total 与 Vector/Hybrid 候选 total 在 API 层的差异;
- 建立更大规模的检索 Benchmark,持续观察召回率、延迟和索引体积;
- 在服务层引入结构化日志,记录回滚、tombstone 清理失败和 rebuild 恢复结果。
## 16. 相关文件
```text
backend/app/services/note_service.py Note CRUD、文件补偿与索引编排
backend/app/repository.py SQLite 元数据、FTS 查询与事务接入
backend/app/retrieval/vectorstore.py sqlite-vec 幂等写入和删除
backend/app/retrieval/engine.py FTS/Vector/Hybrid 检索编排
backend/app/services/index_service.py 全量重建与失败恢复
backend/app/knowledge/parser.py Block ID 与 tags 解析语义
backend/tests/test_retrieval.py 审阅回归测试
docs/development/Knowledge与Retrieval-Core开发说明.md 模块开发说明
```
@@ -0,0 +1,284 @@
# 前端合并审阅问题与修复复盘
> 审阅与修复日期:2026-08-29
> 涉及提交:`f9efc4f`,合并提交 `c6c28e4`
> 文档用途:记录前端分支合并后暴露的问题域、形成原因、实际后果、修复思路和落地方案,供后续技术文档、比赛材料与博客写作使用。
> 2026-08-30 状态补充:在本文两轮修复之后,项目又完成 Milkdown 写作工具栏、文件切换二次竞态修复、Shiki 只读高亮、Provider 预设/模型发现/加密 API Key 输入,以及智能体页面汉化。当前前端回归基线为 14 项测试和生产构建通过。
## 1. 结论
原前端提交一次增加了 42 个文件和约 8000 行内容,但没有在提交前执行成功的生产构建。合并后同时存在工程配置、组件完整性、接口契约、流式协议和文件树状态五个问题域。
本轮处理结果:
| 编号 | 问题域 | 原级别 | 处理结果 |
| --- | --- | --- | --- |
| F-01 | TypeScript 与生产构建不可用 | P0 | 已修复,`pnpm build` 通过 |
| F-02 | 路由引用未提交页面 | P0 | 已改为统一占位页,并补充基础编辑器组件 |
| F-03 | 前后端 Contract 系统性漂移 | P1 | 已增加 Wire DTO 和显式 Service 映射 |
| F-04 | SSE 跨网络分片丢失事件 | P1 | 已重写增量解析状态机 |
| F-05 | 文件树右键操作目标错误 | P1 | 已改为保存实际右键节点 |
| F-06 | 根目录新增文件不可见且路径异常 | P2 | 已处理顶层插入与路径拼接 |
| F-07 | Chat 仍使用模拟流式输出 | P2 | 已接入真实 `/api/chat` SSE |
## 2. F-01TypeScript 与生产构建不可用
### 原因
Vite 配置了 `@` 指向 `src`,但 `tsconfig.app.json` 没有配置 `baseUrl``paths`。Vite 和 TypeScript 使用不同的模块解析配置,只配置其中一侧后,开发服务器可能暂时工作,`vue-tsc` 仍无法解析全部别名。
提交中还存在多个独立错误:
- `ComputedRef` 与字符串直接比较,缺少 `.value`
- `FileTreePanel.vue` 在两个 Script 中重复导入 `FileNode`
- 浏览器 ESM 代码调用 CommonJS `require()`
- Chat Store 使用不存在的 `conversation_id` 变量;
- Service 聚合文件导出不存在的 `ApiError`
- StatusBar 使用没有表达式的 `@click`
### 后果
- `pnpm build` 无法生成生产包;
- CI 无法验证前端;
- 别名错误产生的大量隐式 `any` 干扰真正错误定位;
- `main` 不再满足“可构建”要求。
### 解决思路
先恢复唯一可信的构建基线,再处理运行时问题。路径别名同时配置给 Vite 和 TypeScript,其余错误按 Vue 3 Composition API 和浏览器 ESM 规则逐项修复。
### 解决方案
- 在 `tsconfig.app.json` 增加 `baseUrl``@/*` 映射;
- 在 Script 中通过 `.value` 读取 ComputedRef
- 拆分递归文件树组件,删除第二个 Script 和 `require()`
- 修正 Chat 变量名和类型导出;
- 删除无意义的空事件绑定;
- 将 `pnpm build` 作为提交前强制检查。
## 3. F-02:路由和公共组件引用未提交文件
### 原因
路由表和 Secondary Sidebar 按最终页面结构一次性写完,但对应页面没有随提交进入仓库。Workspace 也引用了不存在的 `EditorHeader.vue``EditorPane.vue`。缺失项覆盖 Search、Chat、Agent、Task、Skill、Plugin、Theme、Settings 和多个 Sidebar Panel,共 21 个 Vue 文件。
### 后果
- 修复路径别名后,TypeScript 和 Vite 仍因模块不存在而失败;
- 开发者无法判断页面是遗漏提交,还是尚未实现;
- 后续成员可能分别创建同名但职责不同的组件。
### 解决思路
路由只能引用当前提交真实存在的组件。为保留产品信息架构,使用一个明确标注“功能开发中”的公共占位页,避免建立一批内容为空的伪页面。
### 解决方案
- 新增统一 `PlaceholderView.vue`
- 未实现功能路由暂时指向占位页;
- Secondary Sidebar 对未实现 Panel 显示说明文本;
- 增加可运行的基础 Editor Header 和 Textarea Pane
- 文档明确占位路由不代表业务页面完成。
## 4. F-03:前后端 Contract 系统性漂移
### 原因
前端先按页面需要定义了扁平 View Model,并直接把它们作为 HTTP 请求和响应类型。后端已经形成明确的 Pydantic Contract,包括分页包装、嵌套 Manifest、枚举和值对象,两边没有通过 OpenAPI 或人工核对完成同步。
典型差异:
| 模块 | 原前端假设 | FastAPI 实际 Contract |
| --- | --- | --- |
| Notes | `folder_path/content` | `folder/markdown` |
| Search | 单值筛选、`results/total` | 数组筛选、`items/page` |
| Agent | `task`、可选 Provider | `input`、Provider 与 Model 必填 |
| Permission | `allow + scope` | `allow_once/allow_session/deny` |
| Skill / Plugin | 扁平对象 | `manifest + runtime status` |
| Provider | Capability 对象 | Capability 数组 |
| Task | `due_date`、priority、source | `due_at`,后两项尚未进入后端 |
| Index | `full/fts/vector` | `all/notes/vectors` |
### 后果
- Agent 创建、Permission 响应等请求稳定返回 422;
- 列表接口拿到对象后被当成数组使用;
- Skill、Plugin 和 Provider 页面读取不到标识和能力;
- TypeScript 声称调用安全,但运行时结构完全不同;
- 捕获异常后回退 Mock 会掩盖真实联调失败。
### 解决思路
区分 Wire DTO 和 View Model。HTTP 边界严格使用与 FastAPI 一致的 `Api*` 类型,Service 显式完成转换,页面展示字段不反向污染后端请求。
### 解决方案
- 增加 `ApiNote``ApiAgentRun``ApiSkill``ApiPlugin``ApiProviderConfig``ApiTask``ApiIndexStatus` 等 Wire DTO
- Notes Service 改用 `folder``markdown` 和真实分页结构;
- Search Service 将单值 UI Filter 转换为后端数组,并映射 `items/page`
- Agent Service 使用 `input``tool_timeout_seconds``run_timeout_seconds`
- Permission Store 将 `allow + once/session` 转换为后端枚举;
- Skill 和 Plugin Service 展开嵌套 Manifest
- 增加 Plugin Permission PUT
- Provider Capability 数组转换为界面布尔 Map;
- Task Service 只发送后端支持字段,并转换 `due_date/due_at`
- Index Service 显式转换 Scope
- 默认离线 Provider ID 统一为后端的 `mock`
## 5. F-04SSE 跨网络分片丢失事件
### 原因
旧解析器把 `eventName``dataStr` 声明在每次 `reader.read()` 的循环内部。网络 Chunk 与 SSE Event 没有一一对应关系,一个事件的 `event:``data:` 和结尾空行可以分别落在多个 Chunk 中。
```text
Chunk 1: event: TextDelta\n
Chunk 2: data: {"event":"TextDelta", ...}\n\n
```
读取 Chunk 2 时事件名已被重置成 `message`。如果 data 与空行分开,data 内容也会丢失。
### 后果
- Chat 增量文本偶发不显示;
- Agent 终态事件无法触发完成回调;
- 问题受网络分片影响,开发机难以稳定复现;
- 长回答和远程 Provider 更容易出现错误。
### 解决思路
SSE 解析状态必须跨 Chunk 保存,以完整行和空行结束事件为边界,不能以单次网络读取为边界。
### 解决方案
- 将 Event Name 和 Data Lines 移到读取循环外;
- Buffer 只移除已经形成完整行的内容;
- 支持 LF、CRLF、多行 data 和注释行;
- 流结束时 flush TextDecoder 和剩余 Event
- 终态回调增加去重;
- SSE URL 复用普通 HTTP 的 Base URL 解析。
## 6. F-05:文件树右键操作目标错误
### 原因
右键菜单打开时保存了 `contextMenuPath`,但执行删除和重命名时读取的是 `workspaceStore.activeFile`。右键节点与当前编辑节点是两个独立状态。
### 后果
用户右键未激活文件并点击删除时,可能关闭或修改正在编辑的另一个文件。这属于潜在数据破坏问题。
### 解决思路
菜单操作必须绑定菜单打开时的目标对象,不能在点击命令时从无关的 Active State 推断。
### 解决方案
- 使用 `contextTarget: Ref<FileNode | null>` 保存右键节点;
- Rename 和 Delete 只消费 `contextTarget`
- 菜单关闭后清空目标;
- 将递归 Node 独立为 `FileTreeNode.vue`,通过类型化 Emit 向上传递节点。
## 7. F-06:根目录新增文件不可见且路径异常
### 原因
Store 把 `/` 当作普通父节点查找,但文件树没有代表根目录的虚拟节点。Service 直接使用 `folderPath + '/' + name` 拼接路径,根目录会得到 `//name.md`
### 后果
- Service 返回成功,但新建项目没有加入界面文件树;
- 打开的文件路径带双斜杠;
- 接入真实文件系统后可能产生平台间路径差异。
### 解决思路与方案
顶层数组本身就是根节点的 children。当 `parentPath``/` 或空字符串时直接写入 `fileTree.value`Mock Service 拼接根目录路径时只保留一个 `/`
## 8. F-07Chat 使用模拟流式输出
### 原因
Chat Store 已经存在 SSE Service,但发送消息后仍通过 `setInterval` 拼接固定文本,没有调用后端。
### 后果
- 后端 Provider、RAG、错误事件和取消无法通过前端验证;
- 页面看似工作,实际没有形成前后端链路;
- SSE 解析缺陷长期被 Mock 掩盖。
### 解决思路与方案
保留初始展示数据,但用户主动发送消息时调用真实 `/api/chat`。请求使用当前 Provider、Model、RAG 开关和消息历史;TextDelta 追加到 Assistant MessageError、网络失败、Done 和主动取消同步更新 Streaming State。默认使用离线 `mock / mock-1`,无需外部 API Key。
## 9. 验证
```powershell
cd frontend
pnpm install --frozen-lockfile
pnpm build
cd ../backend
uv run pytest
cd ..
git diff --check
```
结果:
```text
frontend production build passed
73 frontend modules transformed
71 backend tests passed
preview returned HTTP 200
git diff --check passed
```
## 10. 预防措施
- PR 创建前必须执行与 CI 相同的 `pnpm build`
- 路由只引用当前提交存在的文件;
- FastAPI `/openapi.json` 是 Wire Contract 的唯一事实来源;
- View Model 与 API DTO 分层,Service 必须显式转换;
- 不用 Mock Fallback 掩盖 4xx、5xx 和契约错误;
- SSE 测试按任意 Chunk 边界构造数据,不能假定一次 read 等于一次 Event;
- 删除、移动和覆盖等高影响操作必须携带明确目标 ID 或对象;
- 合并后如果发现 P0,先恢复主分支构建,再继续业务页面开发。
## 11. 当前边界与后续事项
首次修复解决了前端壳子的工程正确性和接口边界。之后已继续补齐全部业务路由页面;Tauri Host、Stronghold 和真实文件系统仍属于桌面集成阶段。后续仍需要:
- 为 Service DTO 映射增加自动化契约测试;
- 为 SSE Parser 增加跨 Chunk 单元测试;
- 用 Tauri Command 替换 Mock Workspace Service
- 在 CI 中加入前端构建和后端测试两个必需检查。
## 12. 全页面完成后的第二轮审阅与修复
全部页面接通后再次审阅,发现编译通过并不等于交互状态正确。本轮问题与处理如下:
| 编号 | 问题 | 原因与后果 | 解决方案 |
| --- | --- | --- | --- |
| F-08 | Markdown 没有真实渲染 | 写作与源码模式共用同一个 `textarea`Chat 也把 Markdown 当纯文本显示 | 使用 `marked` 解析 GFM,使用 DOMPurify 清洗 HTML;编辑页提供源码输入和实时预览,Chat 回答复用安全渲染器 |
| F-09 | 重命名后路径仍是旧值 | 只修改节点名称,没有同步节点、子节点、打开文件和编辑器路径,后续保存或删除会作用于旧路径 | 新增递归路径迁移,同时更新 `openFiles`、活动路径和 Editor 当前路径;Mock 内容缓存也随路径迁移 |
| F-10 | 删除活动文件后正文错位 | Workspace 切换了活动文件,但 Editor 仍保留已删除文件正文 | 删除文件或文件夹时统一清理其所有打开路径;若存在下一个文件则加载,否则关闭编辑器 |
| F-11 | 异步读取和保存存在竞态 | 快速切换文件可能让较早请求覆盖较新文件;保存过程中继续编辑会被错误标记为已保存 | 使用读取版本号丢弃过期结果;保存使用路径和正文快照,只有快照仍是最新内容时才标记 `saved` |
| F-12 | Agent 权限弹窗跨 Run 残留 | 切换 Run、ToolResult 和终态事件没有释放 PermissionRequest | 加载 Run 前清空请求,并在 ToolResult、Completed、Failed、Cancelled 时同步清理;同时更新本地 Run 状态 |
| F-13 | Chat 丢弃非文本 SSE 事件 | Store 只处理 TextDelta 和 ErrorTool Call 请求会留下空消息 | 增加 Thinking、ToolCall Start/Delta/End、Usage 和 Citation 状态处理及页面卡片展示 |
| F-14 | Task 字段表现为保存但实际丢失 | 前端展示后端不支持的 Priority/Source,更新请求又漏掉后端已支持的 `note_id` | 暂时移除不可持久化字段的编辑与筛选;补齐 `note_id` 更新与解除关联的 `null` 语义 |
| F-15 | 编辑器设置不生效 | Settings Store 与 Editor/Theme 没有联动,自动保存固定为 1500ms | 自动保存、默认模式、拼写检查、字号、行高和行宽改为实际驱动编辑器,并保存到 Local Storage |
| F-16 | Vector 降级提示永远不可达 | `vectorUnavailable` 只声明不赋值,向量错误直接清空结果 | 对明确的向量、Embedding、模型和 Provider 不可用错误自动重试 FTS,并显示降级状态 |
| F-17 | 护眼主题与 Plugin 导航状态异常 | Sepia 只有预览卡没有 TokenPlugin 路由被错误映射为 Skill 激活状态 | 增加 Sepia Design Token,并按真实路由名计算主导航选中项 |
| F-18 | 文件切换仍可能丢失未保存内容 | `loadFile` 直接替换路径和正文,且旧的自动保存定时器会在切换后保存错误文件 | 切换前取消定时器,等待正在执行的保存并保存最新快照;保存失败或存在冲突时阻止切换;各入口仅在加载成功后更新 Workspace 活动路径 |
| F-19 | 编辑器外观启动时被默认值覆盖 | Theme Store 的立即监听早于 `initTheme` 执行,先把默认值写进 Local Storage | 增加 Hydration 状态,初始化前监听只更新 CSS,不持久化;读取本地配置完成后再允许写入 |
安全边界:Markdown 解析结果不得直接使用未经清洗的 `v-html`。DOMPurify 是渲染链路的必需依赖,后续升级 `marked` 或允许扩展 Markdown 时也必须保留清洗步骤。
## 13. 写作、Provider 与智能体页面的后续修复
第三轮交互完善继续处理了文件切换、Markdown 选区格式、代码块默认状态、亮暗主题对比度和浮动工具栏失效问题。Provider 设置页增加 OpenAI、DeepSeek、Ollama 预设与模型自动发现,API Key 改为提交给后端加密保存,不进入 Pinia 或 Local Storage。智能体页面的运行状态、事件、工具、权限及导航文案已完成中文化,同时保留技术 ID 便于排障。
该轮新增 Store、Workspace、文件树、编辑器和中文标签回归测试;当前结果为前端 14 项、后端 71 项测试通过,生产构建通过。
@@ -0,0 +1,338 @@
# 后端全面审阅问题与修复复盘
> 审阅日期:2026-08-28
> 审阅范围:FastAPI、Knowledge / Retrieval Core、Agent Core、Extension Core、Provider Adapter、公共接口和后端开发文档。
> 文档用途:记录问题形成原因、实际影响、修复判断和落地方案,供后续开发文档、比赛材料与技术博客使用。
> 2026-09-01 状态补充:本文记录的缺陷均保持修复。此后又加入 Provider 预设、模型发现、DeepSeek/OpenAI 凭据解析、Fernet 加密存储、Agent Trace 持久化和 stdio MCP Plugin Host,当前完整后端回归基线为 92 项测试通过。
## 1. 审阅结论
审阅前的主链路已经能够运行,原有 50 项测试全部通过,但测试没有覆盖首次失败、畸形扩展包、代码型 Markdown、浏览器字符偏移和真实 Provider Streaming 等边界。
本轮共处理 10 类问题:
| 编号 | 问题 | 原级别 | 处理结果 |
| --- | --- | --- | --- |
| R-01 | 首次索引重建失败留下半成品 | P1 | 已修复并补充失败注入测试 |
| R-02 | 代码围栏中的 `#` 被误判为标题 | P1 | 已增加 fenced code block 状态解析 |
| R-03 | Citation 偏移与浏览器 UTF-16 不一致 | P1 | 已统一为 UTF-16 code unit |
| R-04 | 未知权限默认放行,Plugin 缺少授权记录 | P1 | 已改为白名单和默认拒绝,并增加授权接口 |
| R-05 | 第一阶段 Tool 与接口不完整 | P1 | 已接入 Note Move、Task、Attachment 和转写适配链路 |
| R-06 | Provider SSE 不是真实增量流 | P1 | 已接入 OpenAI SSE 与 Ollama JSONL |
| R-07 | 畸形 Plugin Schema 导致 500 | P2 | 已在安装和调用阶段执行 JSON Schema 校验 |
| R-08 | Provider PATCH 无法清空可空字段 | P2 | 已按 `model_fields_set` 实现正确 PATCH 语义 |
| R-09 | Vault 扫描可能跟随链接读取外部文件 | P2 | 已增加真实路径范围校验 |
| R-10 | Agent Run 与 Trace 无界保留 | P2 | 已增加 Run、事件和单轮 Tool Call 上限 |
修复后后端共有 62 项自动化测试通过。
## 2. R-01:首次索引重建失败留下半成品
### 原因
旧实现只在正式数据库已经存在时创建 `.bak`。首次启动时没有旧库,重建过程会直接创建正式数据库并逐篇写入;如果第二篇或后续笔记解析、Embedding 或向量写入失败,异常分支没有可恢复的备份,也没有删除新建数据库。
### 后果
- 接口报告重建失败,但搜索仍能看到部分笔记;
- Metadata、FTS 和向量索引可能只覆盖 Vault 的一部分;
- 用户无法区分旧索引、半成品索引和完整索引;
- 再次重建前,Agent 可能基于不完整知识回答。
审阅时通过失败注入实际复现:初始数据库不存在,第二篇笔记抛错后,数据库残留 1 篇 Note 和 2 个 Block。
### 解决思路
失败后的状态必须与重建前一致:有旧库时恢复旧库,没有旧库时删除本次创建的数据库。同时限制并发重建,避免多个任务覆盖同一备份。
### 解决方案
- 使用共享 Vault Mutation Lock 串行化重建与 Note 创建、更新、移动、删除,避免文件和索引写操作交错;
- 备份文件名带 `job_id`,不再共用固定 `.db.bak`
- 首次重建失败时删除本次创建的数据库;
- 记录 running、failed、last completed 和 error 状态;
- Index Job 最多保留 100 条;
- 新增“首次重建第二篇失败”的回归测试。
## 3. R-02:代码围栏中的 `#` 被误判为标题
### 原因
旧解析器逐行使用标题正则,没有维护 Markdown fenced code block 状态。Python、Shell、YAML 等代码中的注释行符合 Markdown 标题正则。
### 后果
- 编程笔记产生不存在的标题层级;
- 后续正文被挂到错误的 `heading_path`
- Block 切分、RAG 上下文和 Citation 章节定位错误;
- 相同正文在重新编辑后可能生成不同 Block ID。
### 解决思路
标题和空行分块只应在普通 Markdown 上下文执行。进入反引号或波浪线围栏后,整段代码应作为普通正文收集,直到合法闭合围栏出现。
### 解决方案
- 识别 ````` 和 `~~~` 围栏;
- 围栏内部不执行标题识别和空行分块;
- 代码块作为独立 Block 保留原始内容;
- 增加 Python `# code comment` 回归测试。
## 4. R-03Citation 偏移单位不一致
### 原因
Python `len()` 返回 Unicode code point 数量,而 JavaScript 编辑器通常按 UTF-16 code unit 定位。emoji 和部分扩展汉字占一个 Python 字符,但占两个 UTF-16 单元。
### 后果
只要 Citation 前出现非 BMP 字符,前端按 `start_offset` / `end_offset` 跳转时就会错位,字符越多偏移越大。
### 解决思路
偏移是跨语言 Contract,必须明确单位。项目主要消费者是 Vue 和浏览器编辑器,因此后端直接输出 UTF-16 code unit,避免每个前端调用点重复转换。
### 解决方案
- `_split_lines()` 按 UTF-16 长度累加;
- Block 结束偏移和 frontmatter 正文起点使用相同单位;
- 接口文档明确 Citation 偏移为 UTF-16 code unit
- 增加 emoji 位于正文之前的回归测试。
## 5. R-04Permission 与 Plugin 授权 fail-open
### 原因
`PermissionPolicy` 对未知权限返回 `allow`。Plugin Runtime 只验证 Tool 使用的权限是否写进 Manifest,没有判断该权限是否属于项目命名空间,也没有区分“声明权限”和“用户已经授权”。
### 后果
- `notes.wirte` 一类拼写错误会静默放行;
- 新增高风险权限但忘记更新 Policy 时默认无确认执行;
- Plugin Enable 无法向用户展示和记录权限决策;
- Manifest 声明被错误地当成用户授权。
### 解决思路
权限边界应 fail-closed。声明、授权和单次运行确认是三个不同状态,不能共用一个布尔判断。
### 解决方案
- 建立 `KNOWN_PERMISSIONS` 白名单;
- 未知权限默认 `deny`
- Skill 和 Plugin 安装时拒绝未知权限;
- `Plugin` Contract 增加 `granted_permissions`
- 新增 `PUT /api/plugins/{plugin_id}/permissions`
- 带权限 Plugin 在授权完成前保持 `permission_required`
- 撤销必要权限时自动停用 Plugin;
- 增加未知权限、授权前启用和授权后启用测试。
## 6. R-05:第一阶段 Tool 与业务接口缺失
### 原因
早期先建立了 API 壳子,Note Move、Task 和 Media 路由保留为 501Agent Tool Registry 也只接入了 Note 基础读写与搜索。这与技术栈文档列出的第一阶段 Tool 集合不一致。
### 后果
- 前端能够看到接口,但一调用就得到 501;
- Agent 无法完成移动笔记和任务管理;
- Attachment 与音频链路没有统一 Tool Contract
- “第一阶段完成”的说法无法按技术基线验收。
### 解决思路
补齐能够在当前架构安全落地的能力。真实语音模型仍属于第二阶段,因此第一阶段提供 Host transcript 适配,不伪装成已经集成 faster-whisper。
### 解决方案
- 实现 `notes.move`,移动后保持原 `note_id`,失败时恢复文件;
- 新增 SQLite v2 migration 和 Task CRUD Service
- 接入 `tasks.create``tasks.update``tasks.list`
- 新增 Host 管理的 `attachments` 目录和 `attachments.read`
- 接入 `audio.transcribe`,读取 Host 预生成 transcript
- Transcript Job 最多保留 100 条;
- 实现原 Task、Media 和 Move HTTP 路由;
- 增加 Note Move、Task 生命周期和 Attachment/Transcript Tool 测试。
### 当前边界
`audio.transcribe` 当前只负责统一调用链和读取 Host 生成的文本。faster-whisper、pyannote.audio 与真实音频推理仍按照技术栈说明在第二阶段实现。
## 7. R-06Provider Streaming 只是 SSE 外壳
### 原因
`TurnStreamingMixin` 先调用非流式 `complete()`,等待完整回答后只发送一个 `TextDelta`。OpenAI-Compatible 和 Ollama 请求都明确设置 `stream=false`
### 后果
- 首字等待时间等于完整回答生成时间;
- 长回答无法边生成边展示;
- SSE 连接存在,但不具备真实 Streaming 的用户体验;
- 上游生成期间无法及时反馈 Tool Call 或 Usage。
### 解决思路
Adapter 应直接消费各 Provider 的原生流协议,再映射成统一 `ModelEvent`
### 解决方案
- OpenAI-Compatible 使用 `httpx.AsyncClient.stream()` 消费 SSE
- 解析 `TextDelta``ThinkingDelta`、Tool Call 分片、Usage 和 Done
- Ollama 使用相同连接消费 JSONL
- 统一递增 Event sequence
- Provider HTTP 错误继续映射为统一错误码;
- 增加两个 Adapter 的增量分片测试。
## 8. R-07:畸形 Plugin JSON Schema 返回 500
### 原因
旧实现假定 `parameters.properties` 一定是对象,启用阶段直接调用 `.items()`。扩展包可以提交 `properties: []`,安装成功后在启用时触发未捕获 `AttributeError`
### 后果
- 客户端输入错误被当成服务端故障;
- 统一错误 Contract 被破坏;
- 已安装记录进入 error,但用户拿不到可处理的 Schema 错误;
- JSON Schema 中的 enum、长度和嵌套约束没有在 Tool 调用时落实。
### 解决思路
Schema 是不可信扩展输入,应在安装阶段检查结构,并在每次 Tool 调用时校验实际参数。
### 解决方案
- 引入 `jsonschema`
- 安装时执行 Draft 2020-12 Schema Check
- 要求 Tool 参数根节点和 `properties` 为对象;
- Tool Registry 在 Pydantic 校验前执行 JSON Schema 校验;
- 所有格式错误转换为 `PLUGIN_TOOL_SCHEMA_INVALID`
- 增加畸形 `properties` 回归测试。
## 9. R-08Provider PATCH 无法清空字段
### 原因
旧路由使用 `model_dump(exclude_none=True)`。该调用无法区分“字段没有发送”和“客户端明确发送 null”。
### 后果
用户无法清空 `credential_id``base_url``default_model`,只能删除 Provider 后重建;同时直接 `model_copy(update=...)` 不会重新验证完整模型。
### 解决思路
PATCH 必须根据 Pydantic 的 `model_fields_set` 判断客户端实际发送了哪些字段,并在合并后重新验证 ProviderConfig。
### 解决方案
- 使用 `model_fields_set` 构建更新字典;
- 允许可空字段显式设置为 null
- 禁止 `name``enabled` 显式设置为 null
- 合并后通过 `ProviderConfig.model_validate()` 重新验证;
- 增加同时清空三个可空字段的测试。
## 10. R-09Vault 扫描越过根目录
### 原因
旧重建逻辑直接读取 `rglob("*.md")` 的结果,只使用词法相对路径,没有验证符号链接解析后的目标是否仍位于 Vault。
### 后果
在支持符号链接的平台上,Vault 内链接可以指向外部 Markdown。外部内容随后进入 FTS、向量索引和 RAG 上下文,并可能发送给外部模型 Provider。
### 解决思路
扫描和 Note API 应使用同一条路径安全原则:先 `resolve()`,再验证真实目标仍位于解析后的 Vault 根目录。
### 解决方案
- 扫描开始时解析 Vault 根目录;
- 每个 Markdown 路径执行 `resolve()`
- 不在 Vault 内的真实路径直接跳过;
- 文件状态和正文均从验证后的真实路径读取。
## 11. R-10Agent Run 与 Trace 无界增长
### 原因
旧 Runtime 将所有 Run、Agent Event、Tool Result 和 Citation 永久保存在进程字典中,没有 TTL、数量限制或持久化后的裁剪。
### 后果
桌面应用运行时间越长,内存占用越高。`notes.read` 等 Tool Result 还可能携带整篇 Markdown,使单个 Run 的体积明显增加。
### 解决思路
在 SQLite Trace Repository 接入前,先给内存实现设置明确上限,并在容量不足时返回可处理错误,不能让进程无限增长。
### 解决方案
- 最多保留 200 个 Run
- 创建新 Run 时优先淘汰最旧的终态 Run;
- 全部是活动 Run 且达到上限时返回 `429 AGENT_CAPACITY_EXCEEDED`
- 每个 Run 最多保留 2000 个 Event
- 单轮最多接受 50 个 Tool Call
- Index Job 和 Transcript Job 同样设置 100 条保留上限。
## 12. 文档同步
本轮同时修正以下文档漂移:
- 后端接口契约不再把 Notes、Search、Skills、Plugins、Tasks 和 Index 标为未实现;
- 补充 Plugin 权限设置接口;
- 补充真实 Provider Streaming 说明;
- 明确 Citation 偏移单位;
- 更新 Tool 列表和 Attachment / Transcript 边界;
- 删除 Knowledge 文档中的 Note Move 未实现说明;
- 测试数量从旧的 26 更新为当前完整数量。
## 13. 验证方法
执行:
```powershell
cd backend
uv lock --check
uv run python -m compileall -q app
uv run pytest -q -p no:cacheprovider
git diff --check
```
验证结果:
```text
71 passed
compileall passed
uv lock --check passed
git diff --check passed
```
测试覆盖新增了:
- 首次重建失败回滚;
- fenced code block 标题隔离;
- UTF-16 Citation 偏移;
- Note Move ID 稳定;
- Plugin 权限授权和未知权限拒绝;
- 畸形 JSON Schema 拒绝;
- Task CRUD
- Attachment 与 transcript Tool
- Provider PATCH 显式 null
- OpenAI SSE 与 Ollama JSONL 增量事件。
- Provider 预设、模型发现、凭据缺失/鉴权错误映射;
- 凭据密文落盘、API 不回显明文及 Provider 解密读取。
## 14. 后续工作
本轮解决的是第一阶段后端正确性和契约问题。以下内容仍按技术基线留在后续阶段:
- Run、Trace、Provider 和 Extension Registry 的完整 SQLite 持久化;
- faster-whisper、pyannote.audio 和真实音频任务队列;
- MCP Plugin Host 与独立进程健康检查;
- OpenAI Responses 与 Anthropic Messages Adapter
- 真实 Embedding / Reranker 模型;
- 增量索引、文件监听和 RAG / Agent Benchmark。
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+24 -1
View File
@@ -255,6 +255,22 @@ export type PluginStatus =
| 'dependency_missing' | 'dependency_missing'
| 'permission_required' | 'permission_required'
export type PluginHostState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error'
export interface PluginHostStatus {
plugin_id: string
backend_type: 'mcp' | 'internal_rpc' | 'none'
transport: 'stdio' | 'http' | 'none'
status: PluginHostState
tools_count: number
started_at?: string | null
last_seen_at?: string | null
protocol_version?: string | null
server_name?: string | null
server_version?: string | null
error?: string | null
}
export interface PluginContribution { export interface PluginContribution {
type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section' type: 'tool' | 'command' | 'importer' | 'exporter' | 'sidebar_panel' | 'settings_section'
id: string id: string
@@ -539,7 +555,14 @@ export interface ApiPlugin {
panels: string[] panels: string[]
settings_sections: string[] settings_sections: string[]
} }
backend: { type: 'mcp' | 'internal_rpc' | 'none'; transport: 'stdio' | 'http' | 'none' } backend: {
type: 'mcp' | 'internal_rpc' | 'none'
transport: 'stdio' | 'http' | 'none'
command?: string | null
args?: string[]
startup_timeout_seconds?: number
tool_timeout_seconds?: number
}
} }
status: PluginStatus status: PluginStatus
enabled: boolean enabled: boolean
+9 -1
View File
@@ -1,5 +1,5 @@
import apiClient from './apiClient' import apiClient from './apiClient'
import type { ApiPlugin, OperationResponse, Plugin, PluginContribution } from '@/contracts' import type { ApiPlugin, OperationResponse, Plugin, PluginContribution, PluginHostStatus } from '@/contracts'
function toPlugin(plugin: ApiPlugin): Plugin { function toPlugin(plugin: ApiPlugin): Plugin {
const { manifest } = plugin const { manifest } = plugin
@@ -54,6 +54,14 @@ export async function grantPluginPermissions(pluginId: string, permissions: stri
return toPlugin(await apiClient.put<ApiPlugin>(`/api/plugins/${pluginId}/permissions`, { permissions })) return toPlugin(await apiClient.put<ApiPlugin>(`/api/plugins/${pluginId}/permissions`, { permissions }))
} }
export async function getPluginHostStatus(pluginId: string): Promise<PluginHostStatus> {
return apiClient.get(`/api/plugins/${pluginId}/host`)
}
export async function restartPluginHost(pluginId: string): Promise<OperationResponse> {
return apiClient.post(`/api/plugins/${pluginId}/host/restart`)
}
export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> { export async function uninstallPlugin(pluginId: string): Promise<OperationResponse> {
return apiClient.delete(`/api/plugins/${pluginId}`) return apiClient.delete(`/api/plugins/${pluginId}`)
} }