feat: land page aggregate and phase7 document ai mainline

- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
lix-2026
2026-04-23 07:38:34 +08:00
parent 8353aea2f9
commit 41e958769e
93 changed files with 8778 additions and 2222 deletions
+13 -3
View File
@@ -5,12 +5,15 @@
- 当前长期方向以 `tree-first graph kernel` 为准,不以 `BlockNote-first``Mindmap-first` 为准。
- `Convex` 继续保留为本地自托管的存储、实时、文件协作底座,不因为推进 Rust 主线而先拆掉。
- `Rust kernel` 持有树、子树、边、projection、query、command 的语义主导权;新增树规则不要继续散落到前端、Next route 或临时 compat 层。
- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat``fixture` 只用于过渡和测试,不应继续承载长期业务语义。
- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat``fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree``/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。
- 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。
- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island`BlockNote` 当前是 fallback / 对照链,不再代表默认主编辑器方向。
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate``tree command cutover``tree realtime event stream` 三条主线。
## 组件定位
- `BlockNote` 是当前文档页编辑器内核,但不是系统事实源。
- `leptos-tiptap` island 是当前文档页默认主编辑区 runtime,但不是系统事实源。
- `BlockNote` 是迁移期 fallback / 对照编辑器,不是系统事实源。
- `Mindmap``tree-first graph` 的一种视图和编辑挂件,不是对象真相层。
- `OnlyOffice` 是独立页面型编辑器,不直接嵌入 `BlockNote` 画布;正文中通常通过附件块跳转进入。
@@ -40,17 +43,24 @@
- 涉及树、页面结构、文件树、Sidebar、阅读投影、搜索投影、引用边语义时,优先判断是否应落到 Rust kernel,而不是直接改前端拼装逻辑。
- 前端可以做展示、交互和局部适配,但不要新增第二套树真相、排序真相或 projection 契约。
- `mnote-web``compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。
- 涉及文档页标题、页面设置、正文保存、页头与树一致性时,优先判断是否应收口到 `Page Aggregate`,不要继续在页面壳或 island 外侧拼第二份页面真相。
- 涉及页面新建、重命名、移动、归档、恢复、嵌入时,优先沿 `tree.*` 正式命名推进;`documents.*` 只视为兼容层,不应继续扩写为长期命令面。
- 需要架构判断时,优先参考:
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
- `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/04-tree-domain/process/4-6-tree-command-protocol-cutover-stage2-v1.md`
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
## 设计稿目录规则
- `design/01-*``design/07-*` 主线大类统一按 `process/``done/` 分层。
- 主线设计稿在推进中时放到对应大类的 `process/`
- 主线设计稿在当前真实代码中已完成后,必须移动到对应大类的 `done/`
- 已被后续实现或更新稿明确覆盖、但又不属于 `done` 的旧主线稿,必须移动到 `design/old/` 并标记为 `[recycle]`,不要继续占用活跃 `process/`
- `design/old/` 下的历史废弃稿也统一按 `process/``done/` 分层,但标题继续标记 `[recycle]`
- `design/90-reference/` 只放参考资料,不参与 `process/done` 状态迁移。
+179 -654
View File
@@ -1,34 +1,47 @@
# MNOTE 当前架构梳理
本文只描述当前仓库中保留下来的主线结构,不再依赖已移动到 `/mnt/Data1T/mnote/recycle/` 的旧设计稿。
> 更新时间:2026-04-23
## 1. 当前保留的主线目录
本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。
## 1. 当前主线结论
当前仓库的主线不是:
- `BlockNote-first`
- `Mindmap-first`
- “先拆掉 Convex 再谈 Rust”
当前主线固定为:
1. `tree-first graph kernel` 是长期对象真相层
2. `Convex` 继续保留为当前自托管存储 / 实时 / 文件协作底座
3. `mnote-web` 是当前 Rust Web 承载层,负责 query / command / projection / transport,但 `3000` 已是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界,`/tree``/document-debug` 等 debug 壳默认关闭,只在显式 debug 场景启用
4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island
5. `BlockNote` 当前保留为 fallback / 对照链,不再代表默认主编辑器方向
一句话收口:
> **Rust 持有语义主导权,Convex 保留底座,前端逐步从重壳转向消费稳定 projection 与少量交互 island。**
## 2. 当前主线目录
- `/mnt/Data1T/mnote/wolai-frontend/`
当前主前端,Next.js App Router页面、编辑器、思维导图、OnlyOffice 页面都在这里。
- `/mnt/Data1T/mnote/wolai-backend/`
当前辅助后端,FastAPI + Celery,主要提供补充服务能力
当前主前端,Next.js App Router文档页、Sidebar、搜索、OnlyOffice 页面都在这里。
- `/mnt/Data1T/mnote/rust/crates/core-protocol/`
Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。
- `/mnt/Data1T/mnote/rust/crates/mnote-web/`
Rust Web route、kernel projection、documents route、stream transport、compat,以及显式开启时的 debug shell。
- `/mnt/Data1T/mnote/wolai-frontend/convex/`
仍在使用的 Convex functions 与数据侧逻辑。
- `/mnt/Data1T/mnote/infra/convex/`
Convex 自托管相关配置
- `/mnt/Data1T/mnote/src/components/onlyoffice/`
根目录历史遗留,但当前仍保留,因为里面有 OnlyOffice 静态资源、插件和密钥数据
- `/mnt/Data1T/mnote/scripts/`
启动与环境脚本。
Convex 自托管部署与基础设施
- `/mnt/Data1T/mnote/wolai-backend/`
辅助后端与异步处理,不是当前页面主链
## 2. 总体运行结构
当前主链路是:
1. 前端页面由 `/mnt/Data1T/mnote/wolai-frontend/src/app/**` 提供。
2. 文档主页面进入 `DocumentShell -> DocumentContent -> BlockNoteEditor`,对应源码分别位于 `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx``/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx``/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
3. `BlockNoteEditor` 负责承载普通文档块、自定义块、思维导图块、在线表格块等。
4. 思维导图有两种形态:
- 作为 BlockNote 内嵌块存在于文档页面
- 作为独立全屏页面存在于路由 `/mindmap/[docId]/[mindmapId]`,对应源码 `/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
5. OnlyOffice 不嵌在 BlockNote 内部编辑,而是通过独立页面路由 `/onlyoffice` 打开,对应源码 `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
6. 文档中的附件块如果识别为 Office 文档,会跳转到路由 `/onlyoffice` 页面进行编辑,对应页面实现 `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
## 3. 前端主入口
## 3. 当前页面运行结构
### 3.1 根布局
@@ -36,725 +49,237 @@
职责:
- 注入运行时配置`window.__MNOTE_RUNTIME_CONFIG__`
- 挂载 Convex Provider、Query Provider
- 作为整个前端 App Router 根布局
- 注入运行时配置
- 挂载 Convex / Query Provider
- 作为前端 App Router 根布局
### 3.2 文档页面入口
### 3.2 工作区壳与 Sidebar
- `/mnt/Data1T/mnote/wolai-frontend/src/components/app-layout-shell.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-sidebar-data.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/use-preferred-sidebar-snapshot.ts`
当前 Sidebar 不是单一路径,而是三层组合:
1. 初始 SSR / route snapshot
2. query / refetch snapshot
3. tree stream live snapshot
`PreferredSidebarSnapshotProvider` 负责在 query 与 tree stream 之间做 freshness 选择,再把同一份 preferred snapshot 同时给:
- `Sidebar`
- `Breadcrumb`
- 文档页头标题消费链
这说明当前工作区树链已经开始收口,但还不是最终的单一正式 realtime 主链。
## 4. 文档页主链
### 4.1 入口
- `/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
职责
当前文档页入口已经不再直接把若干独立字段散传给页面壳,而是
- 根据文档 id 读取元数据
- 组织只读、禁下载、禁复制等页面权限
- 把文档上下文传给 `DocumentShell`,对应源码 `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx`
1. 拉取 `documents.meta.get`
2. 拉取 `documents.content.get`
3. 在前端入口组装 `PageAggregateProjection`
4. 传给 `DocumentShell -> DocumentContent`
文档页真正的编辑器链路是
对应聚合类型定义
`/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx` -> `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx` -> `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx` -> `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/page-aggregate.ts`
## 4. BlockNote 位置与嵌入形式
### 4.1 BlockNote 主组件
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
这是当前文档编辑核心。
它负责:
- 创建 BlockNote 实例
- 加载自定义 schema
- 挂接 Slash Menu、Side Menu、TOC、评论、搜索引用、全屏表格桥接等
- 承载普通文本块和自定义块
### 4.2 文档壳与内容层
### 4.2 文档壳
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx`
嵌入方式:
`DocumentShell` 现在只是薄封装,文档页核心状态和交互壳集中在 `DocumentContent`
- `DocumentShell` 用动态导入方式挂载 `DocumentContent`
- `DocumentContent` 再动态导入 `BlockNoteEditor`
- 所以 BlockNote 是“文档页面中的主编辑器内核”,不是一个独立页面
### 4.3 默认编辑器 host
### 4.3 BlockNote schema
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host-config.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/editor-host.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/schema.ts`
当前默认 host 为:
当前自定义 block 已明确注册在这里,包括:
- `leptos_tiptap_island`
- `mindmap`
- `onlineTable`
- `media`
- `pageReference`
- `blockReference`
- `advancedTodo`
- `progressMeter`
调试桥仍保留:
也就是说,思维导图和在线表格本质上都是 BlockNote 的自定义 block。
- `leptos_tiptap_iframe_debug`
## 5. Mindmap 位置与嵌入形式
兼容回退仍保留:
### 5.1 主组件
- `blocknote`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
### 4.4 正式主编辑器
这是思维导图前端主实现。
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx`
它同时承担:
当前默认主编辑器已经是页面内 Leptos island,不再是 iframe bridge。
- 内嵌块视图
- 本地全屏视图
- 独立页面全屏视图复用
- 侧栏、工具栏、导航器、缩略图、计数器、右键菜单等整套 UI
它负责:
### 5.2 作为 BlockNote 内嵌块
- 加载 Leptos/WASM 编辑器本体
- 接收初始化内容与 page options
- 处理同页事件、命令、保存与状态同步
注册位置:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/schema.ts`
插入位置:
### 4.5 BlockNote 的当前位置
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
嵌入方式
`BlockNote` 当前仍保留,但其定位已变成
- 在文档页面中,思维导图作为 `type: "mindmap"` 的 block 插入到 BlockNote 内容流里
- 渲染时由 `mindmapBlock()` 对应到 `MindmapBlockView`
- 这个块是“嵌在页面正文里的一大块交互式画布”
- fallback / 对照链
- 迁移期保险丝
视觉与交互形态:
而不是默认主编辑器方向。
- 默认是页面中的内嵌卡片画布
- 双击后可进入局部全屏模式
## 5. 当前编辑器与页面聚合关系
### 5.3 作为独立全屏页面
当前文档页已经开始消费统一的前端侧 `PageAggregateProjection`,但仍不能说“页面域已完全统一成 Rust 单一真源”。
- `/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
当前真实状态是:
嵌入方式:
- 标题链路已开始与工作区树 canonical snapshot 对齐
- 正文默认由 `leptos-tiptap` island 编辑与保存
- 页面设置已有一部分进入 island 运行时语义
- `page_tree` / `pageSubtree` 已进入统一聚合入口
- 这个页面不是重新实现一个思维导图编辑器
- 而是直接复用 `MindmapBlockView`
- 只用一个假的 `editorStub` 代替 BlockNote 编辑器上下文
- 因此“文档内嵌思维导图”和“独立思维导图页”本质是同一套前端组件
但仍未完成的关键点是:
### 5.4 Mindmap 相关子组件
1. Rust 侧还没有原生 `Page Aggregate` 契约
2. 标题 / 正文 / 页面设置还不是同一组 page aggregate command family
3. 页面聚合当前仍主要由前端入口装配,而不是 Rust 直接输出
主要都在
因此当前正确表述应是
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/`
> **文档页已经进入 page aggregate 过渡态,但页面域单一真源仍在推进中。**
关键文件包括:
## 6. 当前树域结构
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapMiniMap.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapCount.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapContextMenu.tsx`
### 6.1 Kernel projection 与 route
### 5.5 当前结论
- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs`
Mindmap 的前端定位不是独立编辑器系统,而是
当前 Rust 已提供
- 以 BlockNote 自定义块为主形态
- 以独立全屏页为辅助形态
- 两者共用同一核心组件 `MindmapBlockView`
- `sidebar_tree`
- `page_tree`
- `file_tree`
- `subtree`
- `edges`
- `graph`
## 6. OnlyOffice 位置与嵌入形式
这些 route 说明树域 projection family 已经在 Rust 侧具备清晰边界。
### 6.1 OnlyOffice 页面入口
### 6.2 树命令现状
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tree-command-client.ts`
- `/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/tree.rs`
- `/mnt/Data1T/mnote/rust/crates/storage-convex-bridge/src/mapping.rs`
当前 OnlyOffice 不是 BlockNote 内嵌编辑器,而是单独页面。
当前树命令已经开始以 `tree.*` 为 preferred command name,例如:
嵌入方式:
- `tree.node.create`
- `tree.node.rename`
- `tree.subtree.move`
- 打开路由 `/onlyoffice`,对应入口文件 `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
- 页面内部加载 OnlyOffice 文档编辑器脚本
- 通过 URL 参数和运行时配置决定打开哪个文档、哪种模式
但兼容层仍广泛保留:
### 6.2 页面职责
- `documents.create`
- `documents.title.update`
- `documents.move`
`page.tsx` 负责:
这说明 tree command cutover 已开始,但还没有完成。
- 在最早阶段注入 DOM 补丁
- 防止 OnlyOffice 某些 `NotFoundError` 导致整页白屏
### 6.3 树 realtime 现状
`OnlyOfficeClientPage.tsx` 负责
当前 Sidebar 已有
- 真正加载 OnlyOffice 编辑器
- 改写内部请求
- 处理代理地址
- 配置 callback、签名、强制保存
- 挂载 `OnlyOfficeAiAgentPanel`
- query snapshot
- tree stream
- preferred snapshot 选择
### 6.3 与正文的关系
但正式的 snapshot + delta 主链还没有完全成为唯一事实来源。
OnlyOffice 当前不是“BlockNote 中的一块可直接编辑的 Office 文档”。
因此当前正确表述应是:
当前模式是:
> **树域已经开始消费 Rust projection 与 tree stream,但实时主链仍处于从补偿式 freshness 选择向正式 realtime contract 过渡的阶段。**
1. 文档正文里放的是附件块 `MediaBlock`
2. 如果附件扩展名属于 Office 文档类型
3. 点击后新开路由 `/onlyoffice` 页面,对应实现 `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
4. 在独立页面内进行 OnlyOffice 编辑
## 7. Mindmap 与 OnlyOffice
对应主文件:
### 7.1 Mindmap
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx`
当前 Mindmap 不再是系统中心,而是:
这意味着:
- `tree-first graph kernel` 的一种视图 / 编辑挂件
- BlockNote 中只嵌入“文件入口”
- OnlyOffice 编辑器本身在独立页面运行
它仍有:
### 6.4 OnlyOffice 资源位置
- 文档内嵌块形态
- 独立页面形态
当前仍保留的历史资源目录:
但不应再被理解为对象真相层。
- `/mnt/Data1T/mnote/src/components/onlyoffice/onlyoffice-web-apps`
- `/mnt/Data1T/mnote/src/components/onlyoffice/onlyoffice-plugins`
- `/mnt/Data1T/mnote/src/components/onlyoffice/onlyoffice-data`
### 7.2 OnlyOffice
这里不是主 React 页面代码,但对 OnlyOffice 运行仍有意义,因为里面保留了
- web apps 静态资源
- 插件
- key / data
### 6.5 OnlyOffice 代理与 API
关键接口:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/callback/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/sign/route.ts`
当前角色分工:
- `proxy`
负责把浏览器不可直达的 OnlyOffice / 文件回源 URL 包成同源代理,避免跨域、内网地址、token 参数冲突
- `callback`
接收 OnlyOffice 保存回调,再把内容写回存储
- `forcesave`
主动触发保存
- `sign`
提供签名能力
### 6.6 当前结论
OnlyOffice 的前端定位是:
OnlyOffice 仍然是
- 独立页面型编辑器
- 不直接嵌入 BlockNote 编辑画布
- 通过附件块从文档页跳转进入
## 7. Online Table 的位置与嵌入形式
它不直接嵌入正文主编辑画布;正文中通常通过附件块跳转进入。
虽然你这次重点是 BlockNote / Mindmap / OnlyOffice,但当前结构里在线表格和它们关系紧密,也需要一起记住。
## 8. 当前最优先的架构收口
主文件
当前最优先的三条主线,不是继续大规模 UI 重写,而是
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/OnlineTableBlock.tsx`
### 8.1 Page Aggregate
嵌入方式
目标
- 在线表格是 BlockNote 的自定义 block
- 在正文中显示的是紧凑预览块
- 双击或操作按钮后,通过 editor bridge 打开全屏表格编辑器
- 让标题 / 页面设置 / 正文 / page tree 统一成同一组 page aggregate projection 与 command family
所以它和 Mindmap 类似,都是
对应设计稿
- 正文里先以内嵌 block 形式出现
- 再切换到更大编辑视图
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md`
但它和 OnlyOffice 不同:
### 8.2 Tree Command Cutover
- Online Table 仍然属于 BlockNote 自定义 block 体系
- OnlyOffice 是独立页面体系
目标:
## 8. 四层结构图
-`tree.*` 成为正式命令面
- `documents.*` 降为兼容层
下面用“组件层 / 页面层 / API 层 / 存储层”重新梳理一次当前主线。
对应设计稿:
### 8.1 组件层
- `/mnt/Data1T/mnote/design/04-tree-domain/process/4-6-tree-command-protocol-cutover-stage2-v1.md`
组件层主要位于:
### 8.3 Tree Realtime Event Stream
- `/mnt/Data1T/mnote/wolai-frontend/src/components/`
目标:
其中本次最关键的组件分组是:
- 让 snapshot + delta 正式成为树域实时主链
- 减少当前 query/refetch/freshness 补偿链的长期存在
- 文档编辑核心
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
- BlockNote 自定义块
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/OnlineTableBlock.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx`
- OnlyOffice 页面侧组件
- `/mnt/Data1T/mnote/wolai-frontend/src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx`
对应设计稿:
这一层的职责是:
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- 渲染交互界面
- 承载编辑器实例
- 把页面数据转成可操作 UI
- 管理局部交互状态
## 9. 当前不该再用的旧口径
### 8.2 页面层
下面这些说法现在都不准确:
页面层主要位于:
- “默认文档页仍然是 `BlockNoteEditor`
- “主编辑器还没切到页面内 Leptos island”
- “当前最优先是继续证明 `leptos-tiptap` 能不能跑”
- “当前最优先是先做树域 UI 重构”
- `/mnt/Data1T/mnote/wolai-frontend/src/app/`
当前真正的卡点已经变成:
关键页面关系是:
1. 文档页
- 路由:`/documents/[id]`
- 源码:`/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
- 负责把文档元数据交给文档编辑器宿主
2. 思维导图全屏页
- 路由:`/mindmap/[docId]/[mindmapId]`
- 源码:`/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
- 负责把 MindmapBlock 复用为独立编辑页面
3. OnlyOffice 页
- 路由:`/onlyoffice`
- 源码:`/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
- 客户端实现:`/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
- 负责承载独立的文档编辑器
这一层的职责是:
- 定义访问路由
- 组织页面级权限和参数
- 决定使用哪套组件组合
- 把独立编辑器和正文编辑器区分开
### 8.3 API 层
API 层主要位于:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/`
和本次主题最相关的 API 组是:
- 文档内容与页面数据
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/documents/`
- 思维导图
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap/`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap-ai/`
- 媒体与附件
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/media/`
- OnlyOffice
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/callback/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/sign/route.ts`
这一层的职责是:
- 做 BFF 转换
- 统一权限校验
- 代理前端和 Convex / 外部编辑器之间的请求
- 处理 OnlyOffice 的保存回写与代理回源
### 8.4 存储层
存储层当前不是单一系统,而是三部分组合:
1. Convex 主数据层
- 位置:`/mnt/Data1T/mnote/wolai-frontend/convex/`
- 负责文档元数据、业务记录、任务编排等主链路数据
2. 本地 / 历史文件资源层
- 位置:`/mnt/Data1T/mnote/src/components/onlyoffice/`
- 负责保留 OnlyOffice 相关静态资源、插件和密钥数据
3. 辅助后端层
- 位置:`/mnt/Data1T/mnote/wolai-backend/app/`
- 负责补充服务能力与异步处理配合
这一层的职责是:
- 保存业务主数据
- 保存编辑器所需静态资源
- 承载需要服务端参与的补充逻辑
### 8.5 三个核心能力在四层中的落点
#### A. BlockNote
- 组件层:`/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
- 页面层:`/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
- API 层:`/mnt/Data1T/mnote/wolai-frontend/src/app/api/documents/``/mnt/Data1T/mnote/wolai-frontend/src/app/api/blocks/`
- 存储层:`/mnt/Data1T/mnote/wolai-frontend/convex/`
#### B. Mindmap
- 组件层:`/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- 页面层:
- 文档内嵌页面宿主:`/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
- 独立页:`/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
- API 层:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap/`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/mindmap-ai/`
- 存储层:
- Convex 主数据:`/mnt/Data1T/mnote/wolai-frontend/convex/`
- 本地 mindmap 辅助逻辑:`/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/`
#### C. OnlyOffice
- 组件层:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx`
- 页面层:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
- API 层:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/`
- 存储层:
- OnlyOffice 静态资源:`/mnt/Data1T/mnote/src/components/onlyoffice/`
- 业务回写与数据协作:`/mnt/Data1T/mnote/wolai-frontend/convex/``/mnt/Data1T/mnote/wolai-backend/app/`
## 9. 页面树与文件树
这一部分专门说明当前左侧结构区里的两套树:页面树和文件树。
### 9.1 位置
它们当前都属于侧边栏系统,主要位于:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx`
其中:
- 页面树组件:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/private-tree.tsx`
- 文件树组件:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/file-tree.tsx`
也就是说,两者都在“组件层”的侧边栏模块里,但不是同一个组件。
### 9.2 来源
当前两套树的数据源都先汇总到侧边栏数据模型,再做不同投影。
主要数据入口是:
- `/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-convex-sidebar-data.ts`
这个 hook 会从 Convex 拉取:
- 文档列表
- 回收站文档
- 思维导图记录
- 媒体资产
- 回收站媒体资产
- 在线表格记录
- 工作空间摘要
然后把这些数据组合成统一的 `SidebarInitialData` 形状,交给侧边栏 UI。
### 9.3 页面树
页面树的核心特点是:
- 只关心“文档节点”本身
- 表示页面之间的父子结构
- 不直接把附件、思维导图文件夹、在线表格文件作为树节点展开
主要实现链路:
1. `/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-convex-sidebar-data.ts`
提供文档数据源
2. `/mnt/Data1T/mnote/wolai-frontend/src/lib/documents.ts`
负责把文档记录构造成 `DocumentNode`
3. `/mnt/Data1T/mnote/wolai-frontend/src/lib/sidebar-tree.ts`
负责 `flattenDocumentTree` 和 section 投影
4. `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/private-tree.tsx`
负责真正渲染页面树
页面树所在层次:
- 组件层:`/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/private-tree.tsx`
- 页面层宿主:`/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx`
- 数据来源:`/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-convex-sidebar-data.ts`
- 存储来源:`/mnt/Data1T/mnote/wolai-frontend/convex/`
页面树更像“文档导航树”。
### 9.4 文件树
文件树的核心特点是:
- 以文档为根
- 但会把文档下的索引页、附件、思维导图资产、在线表格资产一起展开
- 更像“资源管理器视图”
主要实现链路:
1. `/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-convex-sidebar-data.ts`
提供文档、媒体、思维导图、表格等混合数据
2. `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx`
把多类资源整理到 file-tree 视图所需的输入结构
3. `/mnt/Data1T/mnote/wolai-frontend/src/lib/file-tree/rows.ts`
把文档节点 + 资产组合成 `FileTreeRow[]`
4. `/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/file-tree.tsx`
负责真正渲染文件树
文件树所在层次:
- 组件层:`/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/file-tree.tsx`
- 页面层宿主:`/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx`
- 数据来源:`/mnt/Data1T/mnote/wolai-frontend/src/hooks/use-convex-sidebar-data.ts`
- 存储来源:`/mnt/Data1T/mnote/wolai-frontend/convex/`
文件树更像“页面 + 资源”的混合管理视图。
### 9.5 二者的关系
页面树和文件树不是两套独立后端数据模型,而是:
- 共用同一套侧边栏主数据源
- 在前端侧做两种不同投影
区别可以简单记成:
- 页面树:只看页面层级
- 文件树:看页面层级 + 页面下资源
### 9.6 当前结论
如果后续继续把这套能力收口到主仓 Rust 协议层,这两套树不应该混为一个组件重写,而应该拆成:
1. 统一侧边栏数据聚合层
2. 页面树投影层
3. 文件树投影层
4. 各自独立渲染组件
这样最符合当前实现,也最容易保持行为一致。
## 10. 数据保存与读写入口
### 10.1 BlockNote
核心文件:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/documents/save/route.ts`
保存位置:
- 主保存目标是 Convex 文档内容字段,对应后端 mutation 为 `api.documents.updateContent`
- 实现入口位于 `/mnt/Data1T/mnote/wolai-frontend/src/app/api/documents/save/route.ts`
保存格式:
- 前端提交字段为 `{ documentId, content }`
- 其中 `content` 是 BlockNote 的 JSON 块数组
- 如果初始值不是纯数组,也会在 `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx` 中先提取 `blocks`
读写入口:
- 读入口:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
- 写入口:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx`
- 其中 `saveContent()``POST /api/documents/save`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/documents/save/route.ts` 再调用 `api.documents.updateContent`
补充说明:
- BlockNote 主内容走 Convex 持久化
- 同文件里还保留了 `HocuspocusProvider + Y.Doc` 协作接入,说明实时协作层与最终持久化层是分开的
- 思维导图自动保存缓存也会临时写到浏览器 `localStorage`,但那不是文档主存储
### 10.2 Mindmap
核心文件:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmapLocalStore.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/server/local-paths.ts`
保存位置:
- 首选目录由 `/mnt/Data1T/mnote/wolai-frontend/src/lib/server/local-paths.ts``getDocumentsBaseDir()` 决定
- 如果设置了 `MNOTE_DATA_DIR`,目录是 `MNOTE_DATA_DIR/documents/<docId>/`
- 否则回退到 `/mnt/Data1T/mnote/wolai-frontend/public/documents/<docId>/`
- 兼容旧路径时,还会尝试 `getLegacyMindmapsBaseDir()`,即 `MNOTE_DATA_DIR/mindmaps/<docId>/``/mnt/Data1T/mnote/wolai-frontend/public/mindmaps/<docId>/`
保存格式:
- 保存为 JSON 文件
- 文件名规则在 `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmapLocalStore.ts`
- `mindmap.json`
- `mindmap-${mindmapId}.json`
- 实际写入使用 `JSON.stringify(data, null, 2)`,即格式化 JSON 文本
读写入口:
- 读入口:
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmapLocalStore.ts``readMindmapLocal(docId, mindmapId)`
- 写入口:
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/mindmap/mindmapLocalStore.ts``writeMindmapLocal(docId, mindmapId, data, docTitle)`
- 相关服务侧复用:
- `/mnt/Data1T/mnote/wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts`
补充说明:
- Mindmap 当前不是像 BlockNote 正文那样直接持久化到 Convex 内容字段
- 它更像“文档目录下的配套 JSON 资产”
- 页面中的思维导图块与独立全屏思维导图页,共用同一份数据结构
### 10.3 OnlyOffice
核心文件:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/callback/route.ts`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/sign/route.ts`
保存位置:
- 当前保存目标不是 BlockNote 内容,而是附件资产本身
- Convex 模式下,OnlyOffice 回调会把编辑后的二进制文件重新上传到 Convex Files
- 然后更新 `media_assets.storage_id / file_url` 之类的附件存储指针
保存格式:
- 保存内容是 Office 原始文件二进制,不是 BlockNote JSON
- 文档格式取决于当前附件类型,例如 `docx / xlsx / pptx / pdf`
- 前端传给 OnlyOffice 的 `document.url` 是可下载原文件的地址,OnlyOffice 保存后再通过回调传回新的下载地址
读写入口:
- 读入口:
- `/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MediaBlock.tsx` 负责从正文附件入口跳转
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
- 写入口:
- `OnlyOfficeClientPage.tsx` 里组装 `callbackUrl`
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/callback/route.ts` 接收 status `2/6`
- 回调路由下载 OnlyOffice 生成的新文件,再上传到 Convex Files,并调用 `api.mediaAssets.replaceStorageFromUpload`
补充说明:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts` 用于主动触发保存
- `/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/proxy/route.ts` 用于同源代理与回源下载
- 根目录 `/mnt/Data1T/mnote/src/components/onlyoffice/` 保留的是 OnlyOffice 静态资源、插件与历史数据,不是正文主数据存储
### 10.4 OnlyOffice key 生成规则
这里要区分两种“key / 密钥”。
第一种是前端传给 OnlyOffice 的 `document.key`
- 定义位置:
- `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx`
- 生成规则:
-`assetId` 时,优先生成 `assetId + "_" + hashKey(assetStorageId)`
- 如果还没拿到 `assetStorageId`,先退化为 `assetId`
- 没有 `assetId` 时,回退为 `hashKey(\`${effectiveFileUrl}-${fileName}\`)`
- 设计目的:
- 让同一附件多次打开时 `document.key` 尽量稳定,提升 OnlyOffice 内部缓存命中
- 当附件回写后 `storage_id` 变化,key 会自然变化,避免旧缓存污染新文件
- 字符集限制:
- 代码注释明确说明 OnlyOffice `document.key` 允许字符集接近 `[0-9a-zA-Z_.=-]`
- 所以这里对 `storage_id` 做 hash,而不是直接原样拼接
第二种是服务端签名与回调校验所需的密钥:
- `ONLYOFFICE_JWT_SECRET`
- 读取位置:`/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/sign/route.ts`
- 用途:给整体 config、`document``editorConfig` 生成 HS256 JWT
- 获得方式:这是你自己部署 OnlyOffice 时,在服务端环境变量里配置的一段共享密钥,不是前端算出来的值
- 如果未配置,当前实现会返回 `token: null`,兼容“服务端未开启 JWT 校验”的场景
- `ONLYOFFICE_CALLBACK_SECRET`
- 读取位置:`/mnt/Data1T/mnote/wolai-frontend/src/app/api/onlyoffice/callback/route.ts`
- 用途:校验 OnlyOffice 文档服务器回调到 `/api/onlyoffice/callback` 时附带的共享密钥
- 获得方式:同样是你在本项目环境变量和 OnlyOffice 侧约定的一段共享字符串,不是系统自动下发
- 如果未配置,当前实现保持兼容,不强制校验
当前结论:
- 如果你问“OnlyOffice 的 key 如何获得”,前端运行期真正用来区分文档实例的 key,是代码按 `assetId / storage_id / fileUrl / fileName` 现场生成的
- 如果你问“OnlyOffice 的密钥如何获得”,那指的是 `ONLYOFFICE_JWT_SECRET``ONLYOFFICE_CALLBACK_SECRET`,它们都应由部署者在环境变量中自行生成并在双方配置保持一致
## 11. 三者关系总结
### 11.1 BlockNote
定位:
- 主文档编辑器内核
- 页面正文的承载容器
表现形式:
- 文档主页面核心区域
- 挂载各种自定义 block
### 11.2 Mindmap
定位:
- BlockNote 内部的高级自定义块
表现形式:
- 文档页中可内嵌
- 也可进入全屏
- 独立路由 `/mindmap/[docId]/[mindmapId]` 页面与内嵌块共用同一核心组件,对应页面文件 `/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx`
### 11.3 OnlyOffice
定位:
- 独立页面型文档编辑器
表现形式:
- 从附件块跳转进入
- 运行在路由 `/onlyoffice`,对应页面文件 `/mnt/Data1T/mnote/wolai-frontend/src/app/onlyoffice/page.tsx`
- 不直接嵌入 BlockNote 正文编辑画布
## 12. 一句话记忆版
可以把当前架构记成下面这三句:
- `BlockNote` 是主页面正文编辑器容器
- `Mindmap` 是挂在 BlockNote 里的重交互 block,并可切到全屏
- `OnlyOffice` 是通过附件入口跳转出去的独立编辑页面
## 13. 后续重构建议
如果后面你要把这套能力继续收口到 `/mnt/Data1T/mnote/rust/`,最值得优先抽象的是三层:
1. 文档宿主层
- 文档页面壳
- 文档权限
- 编辑器挂载流程
2. Block 能力层
- BlockNote schema
- `mindmap` / `onlineTable` / `media` 的块协议
3. 外部编辑器层
- OnlyOffice 独立页面
- 代理 / callback / sign / forcesave
这样拆之后会非常清楚:
- 哪些能力应继续做成“内嵌 block”
- 哪些能力应继续保留为“独立页面编辑器”
> **单一真源如何收口,以及 projection / command / realtime 三条链如何从兼容态进入正式主链。**
@@ -0,0 +1,108 @@
# 7-1 [done] Phase 7 文档页 AI 最小闭环执行清单 v1
> 更新时间:2026-04-22
>
> 上位依据:
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md`
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
>
> 状态说明:
> - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/`
> - 本稿完成不等于整个 `Phase 7 v2` 已完成;结构化知识写链仍以后续阶段继续推进
---
## 1. 本轮范围
本轮只执行 `Phase 7 v2` 的第一轮最小闭环:
- 文档页 AI 读取当前页 page aggregate 上下文
- 编排层切入 `openai-agents-python`
- 正文插入 / 改写继续沿 `page.body.save` 回写
- 标题改名继续沿 `page.head.updateTitle` 回写
- 前端默认在线主路径先走新 sidecar,失败时 fallback 到 Hermes
- 页面 AI 配置开始收口到 `model_key / profile / tool registry`
本轮明确不做:
- `summary node`
- `ai_note node`
- `reference edge`
- `PDF / Book -> kernel index`
- mindmap / OnlyOffice AI 主链
---
## 2. 当前基线
- [x] 文档页主编辑区已经是页面内 `leptos-tiptap` island
- [x] 文档页 AI 已开始消费 page aggregate snapshot
- [x] 页面写命令面已开始收口到 `page.head.updateTitle` / `page.body.save`
- [x] 前端 `/api/ai-agent/run` 已承载 `online -> sidecar / fallback -> Hermes` 分流
- [x] `openai-agents-python` sidecar 已接入
- [x] 前端在线主路径已切到新 sidecar
---
## 3. 执行项
### A. 语义面冻结
- [x] 第一批正式写动作只覆盖:改标题 / 插入正文 / 改写正文
- [x] 正式命令语义固定为:`page.head.updateTitle` / `page.body.save`
- [x] 过渡工具面固定为:`doc_get` / `doc_find` / `doc_insert_blocks` / `doc_replace_range` / `slash_run`
- [x] 不新增第三套 AI 专用产品命名面
### B. 上下文装配冻结
- [x] sidecar 请求体固定包含:`documentId` / `documentBlocks` / `pageOptions` / `subtree` / `outline` / `evidence`
- [x] 不再新增新的 AI 私有页面 getter
- [x] 会话上下文裁剪规则先冻结到文档页最小字段集
### C. `openai-agents-python` sidecar
- [x]`wolai-backend` 新增文档页 AI sidecar route
- [x] 接入 `openai-agents-python`
- [x] 接入 OpenAI `Responses API`
- [x] 最小 tools 只注册:`doc_get` / `doc_find` / `doc_insert_blocks` / `doc_replace_range` / `slash_run`
- [x] tool 执行统一桥接到 `mnote-web /api/hermes/bridge`
- [x] 建立最小 session / tracing
- [x] 建立最小 guardrail / fallback 边界
### D. 前端主路径接线
- [x] `/api/ai-agent/run` 新增 sidecar adapter
- [x] `online` provider 默认先走 `openai-agents-python`
- [x] 新 sidecar 失败时自动 fallback Hermes
- [x] 继续输出兼容当前 panel 的 SSE 事件:`assistant_message` / `tool_call` / `tool_result` / `completion` / `error`
- [x] 文档页 AI 回写链继续复用 `page.head.updateTitle` / `page.body.save`
### E. 配置真源与可见性
- [x] 页面 AI 模型配置真源切到 `model_key`
- [x] `model_key -> resolved_combo -> resolved_runtime_model` 分层可见
- [x] 页面 AI 不再把 combo 名或 provider model 作为产品主键
- [x] `profile` 前端可设,但由后端 registry 持有真源
- [x] 页面 AI 设置页能显示当前 `profile`
- [x] 后端提供当前 scope 的真实 tool registry
- [x] 页面 AI 设置页能显示当前 scope 的已注册工具
### F. 验证
- [x] route 测试覆盖 sidecar 主路径与 Hermes fallback
- [x] 后端最小 smoke / 单测覆盖文档页 run route
- [x] 验证在线主路径下标题改名与正文改写都能完成结构化回写
说明:`task111-phase7-document-ai-online-smoke.js` 已验证 `doc_insert_blocks -> slash_run -> doc_replace_range` 在线主路径闭环
---
## 4. 本轮完成判定
当以下条件同时成立,才可以宣称本轮完成:
- [x] 文档页 AI 在线主路径不再默认依赖 Hermes 主编排
- [x] 文档页 AI 仍只围绕当前页主编辑区最小闭环工作
- [x] 标题改名与正文改写仍沿正式页面命令语义回写
- [x] Hermes 已退回 fallback / 对照链
- [x] 页面 AI 的模型 / 设定 / 工具状态已进入正式可见配置面
@@ -0,0 +1,528 @@
# 7-2 [process] Phase 7 结构化 Artifact 写链最小落地方案 v1
> 更新时间:2026-04-23
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md`
---
## 1. 文档目的
这份稿只回答一个问题:
> **在文档页 AI 最小闭环已经完成之后,`summary node / ai_note node / reference edge` 这三条结构化知识写链,第一版到底如何落地,才能既进入 kernel,又不重新污染页面树、文件树和页面主链。**
当前不写这份稿,后续最容易出现两类跑偏:
-`summary / ai_note` 继续做成 AI 面板里的临时聊天产物,而不是 kernel node
- 为了让文件树看见它们,额外发明一个 `AI Artifacts` 真容器节点,重新污染树真相
因此本稿的目标不是“做完整 AI 平台”,而是:
> **只把 `summary node / ai_note node / reference edge` 这三条结构化写链,收口成第一版可执行、可验证、可继续扩展的最小实现。**
---
## 2. 当前结论
第一版固定采用下面这组边界,不再开放:
1. 只做三类结构对象:
- `summary node`
- `ai_note node`
- `reference edge`
2. 只允许围绕**当前文档页**创建,不做跨页、跨工作区写链
3. 触发方式不是自然语言,不做自动推断,只允许页面 AI 面板里的固定按钮触发
4. 点击按钮后直接创建,不走“先预览再确认”的两阶段流
5. `summary node` 默认单例覆盖更新;`ai_note node` 每次新建
6. 两者都落成**可编辑的页面型节点**
7. 文件树可见,页面树默认不可见
8. 文件树中的 `AI Artifacts` 只是 projection-only 的虚拟分组,不是新的真实 kernel node
9. 每次创建 `summary node``ai_note node` 时,自动连一条指向当前页的 `reference edge`
一句话收口:
> **第一版不是让 AI 自由生长“知识图谱”,而是让当前页能够稳定产出两类正式 artifact node,并带上一条最小来源边。**
---
## 3. 为什么第一版只做这三条
### 3.1 这是从“正文写链”走向“结构写链”的最小跨越
当前 `7-1` 已经完成的是:
- AI 读取当前页 page aggregate 上下文
- AI 通过 `page.head.updateTitle / page.body.save` 回写标题和正文
但这仍然只是在“改当前页”。
如果要让 AI 真正进入 `tree-first graph kernel`,第一步不应该直接跳到:
- 跨页知识网
- PDF / Book 结构化摄取
- 完整 index node / retrieval 主线
而应先从三条最小结构写链开始:
- 为当前页生成一个 `summary node`
- 为当前页生成一个 `ai_note node`
- 把它们用 `reference edge` 连回当前页
### 3.2 这三条最适合当前主线边界
这三者同时满足:
- 与当前页 AI 主链直接相邻
- 不要求跨页权限扩展
- 不要求额外引入新的树真相层
- 能直接复用当前的 page aggregate 上下文
- 能验证 `typed node / typed edge` 进入产品主线,而不是继续只停在文档里
### 3.3 `PDF / Book -> kernel index` 当前必须继续后置
`kernel index` 第一版不并入本稿,原因很明确:
- 它依赖更稳定的摄取边界
- 它会引入更重的异步任务与检索协议
- 它不是当前页 AI 面板的最小邻接能力
所以本稿故意不把阶段 5 全写成一个“大包”,而是只切出:
> **当前页 -> artifact node / reference edge**
---
## 4. 对象模型
第一版真正进入 kernel 的对象只有三类。
### 4.1 `summary node`
`summary node` 是一个真实的 `KernelNode``node_type = summary`
它的产品语义固定为:
- 它代表“当前页的最新摘要页”
- 它不是聊天消息
- 它不是页面内一段临时 block
- 它是可编辑页面型节点,但语义上属于 artifact
它的最小数据要求:
- `node_type = summary`
- `workspace_id = 当前页 workspace`
- `metadata.title = 当前页标题 + Summary`
- `content = 可被页面主编辑区消费的正文载荷`
- `refs.reference_node_ids` 至少包含当前页 `pageId`
- `refs.source_node_ids` 至少包含当前页 `pageId`
- `audit` 带最小 request / trace / actor 信息
### 4.2 `ai_note node`
`ai_note node` 也是一个真实的 `KernelNode``node_type = ai_note`
它的产品语义固定为:
- 它代表“围绕当前页生成的一份 AI 工作笔记页”
- 它不是摘要快照
- 它允许后续继续人工编辑
- 它是页面型节点,但语义上属于 artifact
它的最小数据要求:
- `node_type = ai_note`
- `workspace_id = 当前页 workspace`
- `metadata.title = 当前页标题 + AI Note + 时间戳/序号`
- `content = 可被页面主编辑区消费的正文载荷`
- `refs.reference_node_ids` 至少包含当前页 `pageId`
- `refs.source_node_ids` 至少包含当前页 `pageId`
- `audit` 带最小 request / trace / actor 信息
### 4.3 `reference edge`
`reference edge` 在第一版里不新增新的 edge type 名字,先收口到现有 `KernelEdgeType::References`
它的产品语义固定为:
- 表示 artifact node 引用了当前页
- 它不是父子边
- 它不进入页面树 / 文件树
- 它只进入关系层 / 引用层 /后续 graph traversal
第一版边方向固定为:
- `from_node_id = artifact node`
- `to_node_id = 当前页 node`
- `edge_type = references`
这样可以稳定表达:
> **这个 summary / ai_note 是围绕当前页生成的。**
### 4.4 `AI Artifacts` 不是对象,只是投影分组
第一版明确禁止把 `AI Artifacts` 做成真实 node。
它只允许存在于:
- 文件树 projection
- 可能的 Inspector / artifact 列表 projection
它不允许存在于:
- kernel node truth
- tree command truth
- page aggregate truth
理由很简单:
- 它只是展示分组
- 它不是用户真正的知识对象
- 它不应反向塑造树真相
---
## 5. 写入语义
### 5.1 统一动作面
第一版只开放两个显式动作:
- `create_summary_artifact_for_current_page`
- `create_ai_note_artifact_for_current_page`
这里先用产品动作名描述,不在本稿中强行冻结底层最终 route/path 名。
长期原则固定为:
- 它们必须进入 Rust runtime / bridge-runtime 主写链
- 不允许绕过 kernel 直接只写前端本地状态
- 不允许只停留在 AI 面板会话日志里
### 5.2 `summary node` 的写入策略
`summary node` 第一版采用**单例覆盖更新**。
固定规则:
1. 先按“当前页 -> artifact node_type = summary”查询是否已有 summary node
2. 若已存在:
- 复用该节点 `node_id`
- 覆盖其 `content`
- 更新其 `metadata.updated_at`
- 保留其页面型身份
3. 若不存在:
- 新建一个 `summary node`
- 自动附加到当前页的 artifact projection 分组下
- 自动创建一条 `reference edge`
为什么 `summary` 采用单例:
- 它更像“当前页最新摘要”
- 否则文件树下会快速积累多份摘要页
- 第一版要先控制 artifact 数量,而不是先保留完整历史
### 5.3 `ai_note node` 的写入策略
`ai_note node` 第一版采用**每次新建**。
固定规则:
1. 每次点击 `创建 AI Note` 都新建一个 `ai_note node`
2. 标题默认带时间戳或递增序号
3. 创建后自动创建一条 `reference edge`
4. 不尝试合并、覆盖旧 `ai_note`
为什么 `ai_note` 采用多实例:
- 它更像工作产物
- 一次次 AI 分析、本轮整理、本轮方案,本来就可能并存
- 如果也做成单例,会过早丢失工作过程
### 5.4 来源边写入策略
创建 `summary node``ai_note node` 时,自动写一条 `reference edge`
第一版不要求用户再单独确认,也不要求额外按钮。
这条边的作用不是“展示炫酷图谱”,而是保证两个基本能力:
1. 后续能追溯这个 artifact 来源于哪一页
2. 后续文件树/关系面板/graph traversal 不需要重新猜测来源
### 5.5 第一版不做的写入动作
第一版明确不做:
- artifact -> 多来源页面的复合引用
- artifact -> 块级 evidence 的精细挂接
- 跨页创建 summary / ai_note
- 人工拖拽 artifact 重新挂到别的页面
- artifact 自动进页面树
这些都属于第二版以后再做的能力。
---
## 6. 触发方式与产品入口
### 6.1 只允许固定按钮触发
第一版不走自然语言触发。
不支持:
- “帮我顺手创建一个摘要节点”
- “看起来像摘要就自动生成”
- “只要语义明确就直接落库”
只支持页面 AI 面板里的两个固定按钮:
- `创建 Summary`
- `创建 AI Note`
### 6.2 点击后直接创建
第一版点击按钮后,直接创建,不走预览确认。
这是一个非常明确的产品取舍:
- 优点:动作和结果一一对应,最容易验证主写链
- 缺点:产物质量控制不如“先预览再落库”
当前优先级里,第一版更重视:
- 正式写链成立
- kernel node / edge 成立
- 文件树 projection 成立
而不是先做复杂的人机审核流。
### 6.3 内容来源固定为当前页全文
第一版两个按钮的内容来源都固定为:
- 当前页全文
不做:
- 仅选区生成
- 仅当前可见块生成
- 混入会话消息做额外上下文真相
这能确保第一版边界足够清楚:
> **artifact 是围绕当前页全文生成,而不是围绕聊天面板局部状态生成。**
---
## 7. 文件树 projection 与页面树隐藏规则
### 7.1 页面树默认不可见
`summary node``ai_note node` 第一版默认不进入页面树 projection。
原因:
- 页面树是日常导航主链
- artifact 进入页面树会明显污染日常使用
- 它们虽然是页面型节点,但不是“用户正常页面层级”的一部分
### 7.2 文件树可见
`summary node``ai_note node` 第一版可以在文件树 projection 中可见。
这样做的目的不是把文件树变成第二个知识图谱,而是保留一个“必要时可管理”的管理入口。
### 7.3 `AI Artifacts` 虚拟分组规则
在文件树 projection 中,为每个当前页生成一个 projection-only 分组:
- `AI Artifacts`
固定规则如下:
1. 它不是真实 node
2. 它不持久化到 kernel
3. 它只存在于 `file_tree` projection 结果中
4. 它的 children 由“当前页关联的 artifact nodes”动态投影得出
### 7.4 文件树中的相对位置
第一版推荐把 `AI Artifacts` 放在当前页下面,但与普通子页面分区显示。
不是:
- artifact 混在普通子页面列表里
而是:
- 当前页
- 普通文件/子页
- `AI Artifacts`
- `Summary`
- `AI Note 2026-04-23 10:31`
- `AI Note 2026-04-23 10:46`
这样既满足“文件树可管理”,又最大限度避免污染普通导航。
### 7.5 `reference edge` 不进树
`reference edge` 永远不是树项。
它应只进入:
- 关系面板
- 引用面板
- graph traversal
- 后续结构阅读态
它不允许伪装成页面树 / 文件树节点。
---
## 8. 与当前主链的关系
### 8.1 不改变当前页主编辑区真相
第一版 artifact 写链不会改变:
- 当前页正文依然走 `page.body.save`
- 当前页标题依然走 `page.head.updateTitle`
- 当前页页面设置依然走 `page.layout.updateOptions`
artifact 写链是新增的结构写链,不是对当前页主链的替代。
### 8.2 与 page aggregate 的关系
第一版 artifact 生成必须依赖当前 page aggregate 上下文,但不把 artifact 自身并入当前页的 page aggregate truth。
也就是说:
- page aggregate 提供生成材料
- artifact 是新的 kernel node
- artifact 不是当前页 `page_body` 的附属字段
这是必须守住的边界,否则又会回到“把结构对象塞进页面壳状态”的旧路。
### 8.3 与 tree command 的关系
artifact node 虽然默认不进页面树,但它们仍然是 tree-first graph kernel 的真实 node。
因此长期上仍应可复用:
- `kernel.node.get`
- `kernel.subtree.get`
- `kernel.edges.list`
- `kernel.edge.attach`
但第一版不要求把 artifact 的全部树命令交互一次做完。
---
## 9. 最小实现分层
### 9.1 Kernel / Rust runtime 层
需要补的最小能力:
1. 创建 `summary node`
2. 更新现有 `summary node`
3. 创建 `ai_note node`
4. 创建 `references` edge
5. 按当前页查找其关联 artifact nodes
第一版不要求完整 artifact 生命周期,只要求:
- create
- update summary
- list by current page relation
### 9.2 Projection 层
需要补的最小能力:
1. `file_tree` projection 能识别“当前页关联的 artifact nodes”
2. 在 projection 里注入一个虚拟 `AI Artifacts` 分组
3. 页面树 projection 不注入该分组
### 9.3 前端文档页层
需要补的最小能力:
1. 页面 AI 面板显示两个固定按钮
2. 点击按钮后触发相应写入动作
3. 创建完成后,文件树对应 projection 刷新
4. 若新建了 `ai_note node`,允许点击进入该页面
5. 若更新了 `summary node`,允许点击进入摘要页
---
## 10. 验证口径
第一版完成,至少要满足以下验证。
### 10.1 Summary 链
- 当前页点击 `创建 Summary`
- 若不存在 summary node,则新建
- 若已存在,则覆盖更新
- 文件树中当前页下出现或保留 `AI Artifacts / Summary`
- 页面树中不出现该节点
- 可打开该 `summary node`
- kernel 中存在从 `summary node -> 当前页``references` edge
### 10.2 AI Note 链
- 当前页点击 `创建 AI Note`
- 每次都新建一个新的 `ai_note node`
- 文件树中当前页下 `AI Artifacts` 分组内累积出现新节点
- 页面树中不出现这些节点
- 可打开新建的 `ai_note node`
- kernel 中存在从 `ai_note node -> 当前页``references` edge
### 10.3 真相边界
- `AI Artifacts` 不出现在真实 kernel node 列表中
- `AI Artifacts` 只存在于文件树 projection 结果中
- 当前页 page aggregate 不被额外塞入 artifact truth
- artifact 不混入普通页面树导航主链
---
## 11. 明确不做什么
第一版明确不做:
- 自然语言隐式触发 artifact 创建
- 跨页 summary / ai_note 写链
- artifact 多来源引用
- block 级 evidence 精细挂接
- `AI Artifacts` 真实容器节点化
- artifact 进入页面树默认主链
- artifact 复杂权限模型
- artifact 预览确认流
- artifact 版本历史产品壳
---
## 12. 最终冻结口径
当前冻结如下:
> **阶段 5 的第一版,只把 `summary node / ai_note node / reference edge` 作为最小结构化知识写链落到 kernel。**
> **`summary node / ai_note node` 都是真实页面型 kernel node`reference edge` 使用现有 `references` typed edge`AI Artifacts` 只是文件树 projection 下的虚拟分组,不是真实对象。**
> **第一版只允许围绕当前页,通过页面 AI 面板固定按钮直接触发;`summary node` 单例覆盖更新,`ai_note node` 每次新建;创建节点时自动连一条指向当前页的 `reference edge`。**
一句话收口:
> **第一版的目标不是做“大而全 AI 知识层”,而是让当前页第一次稳定地产出正式 artifact node,并且能在不污染页面树真相的前提下进入文件树管理视图。**
@@ -0,0 +1,779 @@
# 7 [process] mnote Kernel Phase 7 AI 编排与 Kernel Projection 实施计划 v2
> 更新时间:2026-04-22
>
> 当前主线依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
>
> 本稿与 `v1` 的关系:
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md` 已降级为上一轮判断稿
> - 当前如要真正执行 `Phase 7`,应以本 `v2` 为执行口径
>
> 当前状态补充:
> - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md` 对应的“文档页 AI 最小闭环”已完成
> - 但本 `v2` 的后续阶段,尤其 `summary node / ai_note node / reference edge / kernel index` 仍未完成,因此本稿继续保留在 `process/`
> - 2026-04-23 验证补充:
> - `cd /mnt/Data1T/mnote/wolai-backend && ./.venv/bin/python -m pytest -q tests/test_ai_document_agent.py` -> `15 passed`
> - `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- --runInBand src/app/api/ai-agent/run/route.test.ts src/app/api/ai-agent/document/config/route.test.ts src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/components/ai-agent/panelShared.test.ts` -> `202 passed`
---
## 1. 文档目的
这份 `v2` 只做一件事:
> **把 `Phase 7` 从“抽象地谈 AI 最终形态”改写成“能接在当前 page aggregate / tree command / realtime 主线之后执行的现实方案”。**
当前不再适合继续直接沿用 `v1` 的原因,不是方向完全错了,而是:
- `v1` 更像长期判断稿
- 它对当前真实工具面、命令面、依赖顺序仍然写得过于理想化
- 它没有充分吸收 `Page Aggregate` 这几天已经形成的收口事实
因此本稿要解决的不是“要不要做 Phase 7”,而是:
1. 如果现在开始做 `Phase 7`,第一交付物到底是什么
2. `Phase 7` 应该接在哪条现有主线之后,而不是另起一套理想结构
3. `openai-agents-python` 到底如何接入当前文档页 AI 主链
4. 哪些 `v1` 里的目标继续保留,哪些必须降级到后续阶段
一句话收口:
> **`Phase 7` 当前第一目标不是“做完整 AI 平台”,而是让文档页 AI 先沿着 `page aggregate command family` 进入主编辑区正式主链。**
---
## 2. 当前结论
当前推荐口径固定如下:
> **`mnote` 的长期 AI 主线仍然采用“Rust kernel / Rust runtime 做唯一事实源与真执行面,`openai-agents-python` 作为 `mnote` 专用编排层,`Hermes` 退回过渡与实验平台”的结构。**
但如果按真实执行顺序来排,当前 `Phase 7` 必须服从下面这个前置条件:
1. `Page Aggregate` 继续作为文档页真相收口主线
2. `tree command cutover` 继续作为页面生命周期正式命名面主线
3. `tree realtime event stream` 继续作为工作区树实时主线
4. `Phase 7` 只在这三条主线已形成可接入的最小面之后,切入文档页 AI 最小闭环
因此当前不能把 `Phase 7` 表述成:
- 先做一套完整 AI 产品平台
- 先把所有 AI 工具都切到全新命名
- 先把 `summary node / ai_note node / reference edge / kernel index` 全部一口气打通
当前正确表述应是:
> **`Phase 7` 的第一闭环,是“文档页 AI 读取 page aggregate 上下文,并通过 `page.head.updateTitle / page.body.save / tree.*` 正式命令面回写主编辑区”。**
---
## 3. 当前真实基线
`v2` 必须建立在当前已经真实成立的代码事实之上。
### 3.1 文档页主编辑器已经切到正式 island
当前默认主编辑区已经是页面内 `leptos-tiptap` island,不再是 `iframe bridge``BlockNote` 只保留为 fallback / 对照链。
这意味着 `Phase 7` 的文档页 AI 主写链,默认目标已经不是旧编辑器壳,而是当前正式主编辑区。
### 3.2 文档页已经开始消费统一 page aggregate
当前文档页首屏与客户端补拉已经围绕 `PageAggregateProjection` 收口,页面本地状态也开始收口到统一 client aggregate state。
这意味着:
- AI 上下文已经不必继续只围绕零散 `title / content / options / pageSubtree` getter 组织
- 文档页 AI 可以以 page aggregate snapshot 作为第一上下文来源
### 3.3 页面命令面已经开始收口到 `page.*`
当前前端与 route 执行面已经开始统一到下面三组页面命令:
- `page.head.updateTitle`
- `page.layout.updateOptions`
- `page.body.save`
这组命令面已经成为文档页 AI 后续接入的第一正式写入口。
这也是本稿与 `v1` 的关键差异之一:
> **`Phase 7` 第一阶段不再直接凭空引入一套全新的 `editor.*` 产品命名面,而是优先挂接已经开始收口的 `page aggregate command family`。**
### 3.4 树域命令面正在切到 `tree.*`
当前页面新建、重命名、移动、归档、恢复、嵌入等生命周期命令,长期正式命名已固定为:
- `tree.node.create`
- `tree.node.rename`
- `tree.subtree.move`
- `tree.node.archive`
- `tree.node.restore`
- `tree.node.purge`
- `tree.node.embed`
- `tree.subtree.copy`
这意味着:
- AI 若需要跨页面生命周期操作,不应继续以 `documents.*` 作为长期契约
-`Phase 7` 第一闭环仍以当前页正文编写为先,不先扩大全树操作面
### 3.5 当前 AI runtime 仍以 Hermes 兼容桥为主
当前文档页 AI 面板仍主要通过 `Hermes bridge` 工作,前端默认工具面仍以这些运行时工具为主:
- `slash_run`
- `doc_get`
- `doc_find`
- `doc_insert_blocks`
- `doc_replace_range`
- `docs_search`
- `docs_read`
- `image_read`
并且当前结构化结果恢复,明确只对以下文档页写工具做了正式回接:
- `slash_run`
- `doc_insert_blocks`
- `doc_replace_range`
这意味着当前现实不是:
- 已有一套完整 `mnote-native` agent 编排层
而是:
- 已有一个可工作的文档页 AI 过渡链
- 这条链已经开始向 `page aggregate command family` 回接
- `Phase 7` 的第一任务,应是把这条链从 Hermes 兼容桥迁到正式 `openai-agents-python` 编排层
---
## 4. 为什么 `v1` 不能直接当执行稿
`v1` 里最需要修正的不是方向,而是以下四个执行偏差。
### 4.1 工具命名过于理想化
`v1` 直接把第一批工具写成:
- `editor.insert_block_after`
- `editor.replace_block`
- `editor.delete_block`
- `kernel.summary.create`
- `kernel.ai_note.create`
这在长期上未必错,但它跳过了当前已经开始成形的两层真实命名:
- 页面命令面:`page.head.* / page.layout.* / page.body.*`
- 树域命令面:`tree.*`
如果现在按 `v1` 直接执行,容易在现有 `page.*` / `tree.*` 之外,再长出第三套 AI 专用命名面。
### 4.2 没有明确把 Phase 7 锚定到 Page Aggregate
`v1` 虽然提到了 `Page Aggregate`,但没有把它明确设为 `Phase 7` 的第一依赖。
而当前真实主线已经很明确:
> **文档页 AI 若不先对齐 `Page Aggregate`,后面很容易再次绕回“前端局部快照 + 兼容副作用链”的旧路。**
### 4.3 第一阶段战线铺得过宽
`v1` 把下面这些目标放得过于靠前:
- `summary node`
- `ai_note node`
- `reference edge`
- `PDF / Book -> kernel index`
这些目标仍然重要,但当前如果要先做 `Phase 7`,第一交付物不应是它们。
当前第一交付物应是:
> **文档页 AI 直接编写主编辑区,人审核后应用,且整个过程沿 `page aggregate` 正式命令面回写。**
### 4.4 没有充分吸收当前 Hermes 兼容桥已形成的过渡事实
当前已经存在一条可运行的 Hermes 文档页 AI 过渡链,且它已经开始把结果恢复成:
- `page.body.save` 语义
- `page.head.updateTitle` 语义
所以 `Phase 7` 的正确做法不是假装这条链不存在,而是:
1. 明确它是过渡链
2. 冻结它的保留边界
3.`openai-agents-python` 替换它的编排层,而不是推翻整条文档页 AI 回写链
---
## 5. 架构边界冻结
### 5.1 长期推荐结构
```text
Document Page / Read View / AI Panel
|
v
AI Gateway / SSE Event Adapter
|
+---- current fallback: Hermes bridge
|
+---- target mainline: openai-agents-python
| |
| +---- provider: OpenAI Responses API
| +---- mnote page tools
| +---- mnote tree tools
|
v
Rust runtime / mnote-web / bridge-runtime
|
+---- page aggregate command family
| - page.head.updateTitle
| - page.layout.updateOptions
| - page.body.save
|
+---- tree command family
| - tree.node.*
| - tree.subtree.*
|
v
Rust kernel truth
```
### 5.2 各层职责
#### Rust kernel / Rust runtime
负责:
- 对象真相
- projection / query / command
- page aggregate 命令面
- tree command 命令面
- 审计、trace、失败回放与回滚基线
不负责:
- 通用 agent 编排框架
- 通用聊天产品壳
#### `openai-agents-python`
负责:
- 文档页 AI 会话编排
- tools / handoffs / tracing / guardrails / HITL
- 调用 OpenAI `Responses API`
- 组织 `mnote` 的文档页 AI 工具合同
不负责:
- 保存产品真相
- 绕过 Rust runtime 直接写产品数据
- 自己持有第二套页面真相
#### Hermes
负责:
- 过渡期 fallback
- 回归对照
- 试验非主链场景
不负责:
- 文档页 AI 长期主编排
- 产品正式工具契约
#### 前端 AI Host / Panel
负责:
- 会话 UI
- 流式事件渲染
- 审核与应用入口
- 挂接当前页面的 page aggregate snapshot
- 展示当前页面 AI 的正式配置真源与运行时能力面
不负责:
- 私下拼出第二份页面真相
- 私下定义长期 AI 工具协议
- 私下持有 `profile` 真源
- 私下硬编码一份长期工具注册表
### 5.3 页面 AI 配置真源冻结
当前页面 AI 的配置真源,必须额外冻结为下面三层。
#### 模型层:前端保存 `model_key`,不保存底层 provider model
页面 AI 的模型配置真源,不应是:
- `slow / fast / codex` 这类 combo 内部路由名
- `ju/gpt-5.4` 这类底层 provider model id
页面 AI 应保存的是**产品层可识别的 `model_key`**,例如:
- `gpt-5.4`
- `gpt-5.4-mini`
- `gpt-5.3-codex`
- `gemini-3.1-pro-preview`
- `third`
其长期原则固定如下:
- `model_key` 是前端设置与产品语义真源
- `resolved_combo` 是运行时解析结果,例如 `slow / fast / codex`
- `resolved_runtime_model` 是最终底层节点 / provider model,仅用于调试、trace 与排障
也就是说:
> **页面 AI 配置真源固定为 `model_key -> resolved_combo -> resolved_runtime_model`,而不是直接把 combo 名或底层 provider model 暴露成产品配置键。**
#### 设定层:前端可设 `profile`,后端持有真源
当前 `Soul / Profile` 的正确落点不是前端自由文本壳,也不是 `openai-agents-python` 自己的长期真相层,而是:
- 前端可以选择与查看 `profile`
- 后端持有正式 `profile registry`
- 编排层把 `profile` 编译成最终 instructions / policy / defaults
固定原则如下:
- 前端只提交 `profileId`
- `profile` 内容、版本、默认值、升级策略由后端持有
- 前端可显示 profile 说明、用途、默认工具集、session 策略
- 不允许前端静态硬编码出第二份长期 `profile` 真相
#### 工具层:工具注册表必须前端可见
当前页面 AI 的正式工具面,不允许只在后端注册、前端不可见。
必须固定为:
- 后端持有正式 tool registry
- 前端设置页可以看见当前 scope 已注册工具
- 工具显示至少包含:
- `name`
- `description`
- `scope`
- `read/write`
- `status`
- `version`(如有)
这样后续若新增 `mindmap`、阅读态、搜索态等工具,前端设置页会直接反映真实能力面,而不是继续依赖人工记忆。
---
## 6. `Phase 7` 的当前目标重写
本稿把 `Phase 7` 当前目标重写为两层。
### 6.1 第一层目标:文档页 AI 最小闭环
这是当前必须优先完成的唯一第一闭环。
完成口径如下:
- AI 读取当前文档页 page aggregate 上下文
- AI 能对当前页正文执行插入、改写、重命名
- AI 回写沿正式 `page aggregate command family`
- 主编辑区 island 能正式回显结果
- 人可以作为审核者决定是否应用或继续编辑
- 人可以在页面 AI 设置中看见当前 `model_key / profile / tool registry` 的正式状态
一句话说:
> **先让 AI 真正进入当前页主编辑区主链,而不是先做大而全的 AI 能力矩阵。**
### 6.2 第二层目标:结构化知识写链
下面这些目标保留,但降到文档页最小闭环之后:
- `summary node`
- `ai_note node`
- `reference edge`
- `PDF / Book -> kernel index`
它们属于 `Phase 7` 的后续扩展,不再作为第一交付物。
---
## 7. 第一闭环的冻结验收口径
当前如要宣称 `Phase 7` 进入执行,应只以以下验收口径作为第一阶段成功标准。
### 7.1 读侧
AI 至少能稳定拿到:
- `documentId`
- 当前页 `blocks`
- 当前页 `pageSubtree`
- `pageOptions`
- `editorRuntimePageOptions`
- 必要时的 `outline / evidence`
说明:
- 当前不要求所有上下文都来自 Rust 原生 `Page Aggregate route`
- 允许过渡期继续使用当前文档页已经收口出的 page aggregate loader 与 client aggregate snapshot
- 但不再允许继续围绕零散页面壳对象拼装新的 AI 私有上下文
### 7.2 写侧
AI 第一批正式写能力只冻结为:
- 当前页标题改名
- 当前页正文插入
- 当前页正文改写
其长期命令语义分别锚定为:
- 标题改名 -> `page.head.updateTitle`
- 正文插入 / 改写 -> `page.body.save`
说明:
- 过渡期运行时仍可继续通过 `slash_run / doc_insert_blocks / doc_replace_range` 承接
- 但这些运行时工具必须被视为过渡工具,不得继续上升为长期产品契约
### 7.3 审核侧
当前文档页 AI 的产品定位固定为:
> **AI 直接接入主编辑区编写,人负责审核、继续编辑、确认结果。**
因此第一闭环里必须明确:
- AI 输出不是单纯聊天文本
- AI 输出必须能映射到页面正式写链
- 人能在主编辑区中继续接手与修订
### 7.4 不在第一闭环内的项
当前不纳入第一闭环:
- 页面设置类 AI 写工具
- 跨页大规模树操作
- mindmap AI 主链
- PDF / Book 结构化摄取
- `summary node / ai_note node / reference edge`
### 7.5 配置侧
第一闭环内,页面 AI 的配置面至少要有可冻结的正式口径:
- 模型真源为 `model_key`
- `resolved_combo / resolved_runtime_model` 只作为运行时调试信息
- `profile` 前端可设,但由后端 registry 持有真源
- 当前 scope 的 tool registry 必须前端可见
说明:
- 第一闭环不要求一开始就做完整 AI 设置产品壳
- 但必须把“谁是配置真源、哪些信息必须可见”冻结下来
- 否则后续模型、profile、工具会继续散落在前端本地状态、后端环境变量和隐式注册代码里
---
## 8. 正式工具面与过渡工具面的双层口径
为避免混乱,本稿把工具合同拆成两层。
### 8.1 长期正式命令面
这是产品长期应围绕的正式语义面:
#### 页面命令面
- `page.head.updateTitle`
- `page.layout.updateOptions`
- `page.body.save`
#### 树域命令面
- `tree.node.create`
- `tree.node.rename`
- `tree.node.archive`
- `tree.node.restore`
- `tree.node.purge`
- `tree.node.embed`
- `tree.subtree.move`
- `tree.subtree.copy`
### 8.2 过渡运行时工具面
这是当前 Hermes 兼容链与 bridge-runtime 已经真实承接的工具面:
- `slash_run`
- `doc_get`
- `doc_find`
- `doc_insert_blocks`
- `doc_replace_range`
- `docs_search`
- `docs_read`
- `image_read`
固定原则如下:
> **`Phase 7` 不删除过渡工具面,但也不把过渡工具名继续写成长期产品契约。**
### 8.3 `openai-agents-python` 的接入原则
`openai-agents-python` 接入时,优先接的不是抽象理想工具名,而是:
1. 当前页最小闭环所需的正式命令语义
2. 当前已真实存在的过渡运行时工具能力
3. 两者之间的明确映射关系
也就是说:
- 对外长期文档写正式命令语义
- 对内迁移期允许 adapter 调用当前 bridge-runtime 能跑通的过渡工具
### 8.4 工具注册表可见原则
文档页 AI 长期不能只做到“后端注册工具”,还必须做到“前端能看见当前真正注册了哪些工具”。
因此冻结如下:
1. 后端需要提供正式能力面 / 注册表接口
2. 前端页面 AI 设置页默认展示当前 scope 的真实工具注册表
3. 前端手动工具选择只允许在真实注册工具集合内进行
4. 新增 `mindmap`、阅读态、搜索态工具时,必须同步进入注册表可见面
一句话收口:
> **工具不是隐藏在编排层里的实现细节,而是页面 AI 产品能力面的一部分。**
---
## 9. 分阶段实施计划
## 阶段 0:冻结 `Phase 7` 依赖关系
### 目标
明确 `Phase 7` 是文档页主线的下一层,而不是脱离 `Page Aggregate` 单独启动的平行工程。
### 需要完成
- [x] 明确 `Phase 7` 第一依赖是 `5-6 Phase J`
- [x] 明确当前第一闭环只覆盖文档页 AI 主写链
- [x] 明确 `summary / ai_note / reference / index` 降为后续扩展
- [x] 明确 `Hermes` 只保留为 fallback / 对照链
- [x] 明确 `openai-agents-python` 为长期推荐编排层
- [x] 明确页面 AI 模型配置真源是 `model_key`,不是 combo 名或 provider model
- [x] 明确 `profile` 前端可设、后端持有真源
- [x] 明确 tool registry 必须前端可见
### 完成判定
- [ ] 团队口径统一为“先文档页最小闭环,再扩结构化知识写链”
- [ ] 团队口径统一为“`model_key / profile / tool registry` 都有正式真源,不再散落在隐式实现里”
---
## 阶段 1:冻结文档页 AI 正式语义面
### 目标
先把文档页 AI 到底面向哪一组正式语义面写清楚。
### 需要完成
- [x] 冻结当前页 AI 写入第一批正式动作:
- 改标题
- 插入正文
- 改写正文
- [x] 冻结这些动作对应的长期正式命令语义:
- `page.head.updateTitle`
- `page.body.save`
- [x] 冻结树域相关动作的长期正式命令语义为 `tree.*`
- [x] 写清 `过渡工具面 -> 正式命令面` 的映射关系
- [x] 禁止新增第三套 AI 专用产品命名面
- [x] 冻结页面 AI 模型配置真源为 `model_key`
- [x] 冻结 `model_key -> resolved_combo -> resolved_runtime_model` 的分层关系
- [x] 冻结 `profile` 前端可设、后端 registry 持有真源
- [x] 冻结当前 scope tool registry 必须前端可见
### 完成判定
- [x] 文档页 AI 已有一套与 `page aggregate command family` 对齐的稳定语义面
- [x] 文档页 AI 已有一套与 `model_key / profile / tool registry` 对齐的稳定配置语义面
---
## 阶段 2:冻结文档页 AI 上下文装配
### 目标
让 AI 输入上下文显式锚定到 page aggregate,而不是继续围绕零散页面壳字段。
### 需要完成
- [x] 固定当前页 AI 最小上下文字段:
- `documentId`
- `blocks`
- `pageSubtree`
- `pageOptions`
- `editorRuntimePageOptions`
- `outline`
- `evidence`
- [x] 明确上下文优先级:
- page aggregate snapshot
- page subtree / outline / evidence
- 必要的兼容页面壳快照
- [x] 不再继续扩散新的 AI 私有页面 getter
- [x] 为 AI 会话定义上下文裁剪规则
### 完成判定
- [x] 文档页 AI 读侧可以被明确描述为“消费 page aggregate 上下文”
---
## 阶段 3:引入 `openai-agents-python` 文档页编排层
### 目标
在不推翻现有文档页 AI 回写链的前提下,用 `openai-agents-python` 替换 Hermes 作为长期文档页编排层。
### 需要完成
- [x] 建立独立的 `mnote-ai-orchestrator` Python 服务或 sidecar
- [x] 接入 OpenAI `Responses API`
- [x] 先只注册文档页第一闭环所需工具
- [ ] 打通 session / tracing / guardrails / HITL 最小能力
- [x] 对齐当前前端 SSE 协议,或提供新的稳定 event adapter
- [x] 前端 provider 切换具备 `Hermes fallback`
- [x] 建立页面 AI capability/config route,至少返回:
- `model_key` 列表
- 默认 `profile`
- 当前 scope tool registry
- `resolved_combo / resolved_runtime_model` 调试字段
- [x] 让 online provider 也能稳定进入正式 session 模式,而不只是在 codex provider 下启用
### 完成判定
- [x] 在不依赖 Hermes 主编排的情况下,文档页 AI 已能跑通第一闭环
- [x] 新编排层已具备“模型 / profile / 工具”统一能力面出口
---
## 阶段 4:切文档页 AI 默认主路径
### 目标
让文档页 AI 在产品可见行为上真正从 Hermes 过渡到新编排层。
### 需要完成
- [x] 文档页 AI panel 默认走 `openai-agents-python`
- [x] 标题改名沿 `page.head.updateTitle` 回写
- [x] 正文改写沿 `page.body.save` 回写
- [x] 主编辑区 island 稳定回显 AI 结果
- [x] 审核与继续编辑行为维持在主编辑区内完成
- [x] Hermes 仅保留 fallback 开关
- [x] 页面 AI 设置页显示正式 `model_key` 下拉,而不是自由文本底层 model
- [x] 页面 AI 设置页显示当前 `profile`
- [x] 页面 AI 设置页显示当前 scope 已注册工具列表
- [x] 页面 AI 设置页显示当前 `resolved_combo / resolved_runtime_model` 作为调试信息,而不是产品主键
### 完成判定
- [x] 可以明确说“AI 已正式进入当前页主编辑区主链”
- [x] 可以明确说“页面 AI 的模型 / 设定 / 工具状态对用户是可见且可解释的”
---
## 阶段 5:扩展结构化知识写链
### 目标
在文档页最小闭环稳定后,再向更完整的 `Phase 7` 目标扩展。
### 需要完成
- [ ] `summary node`
- [ ] `ai_note node`
- [ ] `reference edge`
- [ ] `PDF / Book -> kernel index`
- [ ] 对这些写链补齐 trace / audit / rollback / evidence
### 完成判定
- [ ] `Phase 7` 才能开始被描述为“进入结构化知识写链阶段”
---
## 10. Hermes 的过渡策略重写
当前不建议立刻删除 Hermes,但必须缩边界。
### 10.1 继续保留的价值
- 承接现有文档页 AI 运行链
- 作为 `openai-agents-python` 的回归对照
- 作为 fallback 开关
- 承接非主链实验场景
### 10.2 明确禁止的事情
- 不让 Hermes 成为文档页长期主编排
- 不让 Hermes memory 取代 page aggregate / kernel-aware retrieval
- 不让 Hermes 工具名继续成为长期产品契约
- 不让 Hermes 默认 shell/fs 能力绕过 Rust runtime 写业务数据
### 10.3 最终可接受状态
最终可接受状态有两种:
1. Hermes 退化为非主链实验平台
2. Hermes 完全下线,只保留 `openai-agents-python + Rust runtime`
---
## 11. 非目标
本稿当前不覆盖:
- 完整 AI 平台产品化
- 完整 suggestion review 产品壳
- 多平台消息网关
- 通用桌面 agent / shell agent
- mindmap AI 主线
- OnlyOffice AI 主线
- 完整长期记忆产品壳
这些都不是当前 `Phase 7` 的第一任务。
---
## 12. 当前冻结口径
当前冻结如下:
> **`Phase 7` 仍然以 `openai-agents-python` 作为长期推荐编排层,以 Rust runtime 作为唯一事实源与真执行面。**
> **但当前 `Phase 7` 的第一交付物不再定义为“完整 AI 平台”或“完整结构化知识写链”,而是“文档页 AI 直接进入主编辑区,并沿 `page aggregate command family` 正式回写”。**
> **`Hermes` 可以继续作为过渡平台与实验平台存在,但不再作为文档页 AI 的长期语义中心。**
> **页面 AI 的模型真源固定为 `model_key``resolved_combo / resolved_runtime_model` 只作为运行时调试信息;`profile` 前端可设但由后端持有真源;tool registry 必须前端可见。**
一句话收口:
> **当前如要先做 `Phase 7`,正确起点不是继续抽象谈 kernel-aware AI,而是先让 AI 真正沿 `page aggregate -> 主编辑区 island` 这条当前主线闭环。**
@@ -0,0 +1,549 @@
# 7 [process][recycle] mnote Kernel Phase 7 AI 编排与 Kernel Projection 实施计划 v1
> 更新时间:2026-04-22
>
> 状态说明:
> - 本稿已被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md` 覆盖
> - 保留在 `design/old/` 仅作为上一轮判断稿与历史参考,不再作为当前执行口径
>
> 当前主线依据:
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/process/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
>
> 历史参考:
> - [tree-first-graph-kernel-checklist-v2.md](/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md)
> - [rust-web-long-term-checklist-v2.md](/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md)
> - [ai-first-rust-block-editor-baseline-v1.md](/mnt/Data1T/mnote/design/old/05-editor-mainline/process/ai-first-rust-block-editor-baseline-v1.md)
> - [rust-block-editor-ai-tool-contract-v0.md](/mnt/Data1T/mnote/design/old/05-editor-mainline/done/rust-block-editor-ai-tool-contract-v0.md)
---
## 1. 文档目的
本文用于冻结 `mnote``Kernel Phase 7` 中与 AI 相关的实施口径。
这里仍沿用 `Phase 7` 命名,是为了保留与旧阶段拆分的一致性;当前执行依据应以 `design/01-05-current-priority-overview.md``3-rust-web-long-term-architecture-v1.md``5-5 / 5-6``4-6``3-3` 这些仍在推进的主线文档为准,不再把 `1-1``3-1` 视为唯一推进入口。
这份计划只回答下面四件事:
1. `Phase 7` 中 AI 子系统到底要落什么
2. 为什么当前更适合采用 `openai-agents-python``mnote` 专用编排层,而不是继续把 `Hermes` 当长期主线,也不是直接从零手写完整 agent 平台
3. Rust kernel / Rust runtime / OpenAI Agents / Hermes 各自的边界是什么
4. 如何分阶段把当前 AI 面板切到真正的 `kernel projection + kernel command` 主链
一句话说:
> **在 `Phase 7` 中,AI 的核心任务不是“换一个聊天框架”,而是让 AI 真正直接面向 `node / subtree / edge / editor command / kernel command` 工作。**
---
## 2. 对齐当前 AI 主线验收口径
当前 AI 主线的验收口径,应综合以下现行主线来理解:
- `tree-first graph kernel` 的对象真相边界
- `Page Aggregate` 对标题 / 正文 / 页面设置的单一真源收口
- `tree command cutover` 对正式命令面的统一
- `tree realtime event stream` 对 projection + delta 主链的统一
结合历史 `Kernel Phase 7` 清单中已经写明的目标,当前 AI 仍需满足下面这些验收要求:
- AI tool 直接面向 `node / subtree / edge`
- AI 不再默认面向“页面前端壳”
- AI 能创建 `summary node / ai_note node / reference edge`
- AI 能把 PDF / Book 解析结果落进 `kernel index`
因此本计划不把目标定义为:
- 再做一套前端 AI runtime
- 继续围绕页面对象和编辑器壳拼 prompt
- 继续把 `Hermes` 或某个 SDK 本身当事实中心
本计划把目标定义为:
> **让 AI 变成 `kernel-aware` 的业务执行者,而不是围绕旧页面壳工作的聊天插件。**
---
## 3. 当前结论
当前推荐口径固定如下:
> **`mnote` 的长期主线应采用“Rust kernel / Rust runtime 做唯一事实源与真执行面,`openai-agents-python` 作为 `mnote` 专用 AI 编排层”的结构。`Hermes` 继续保留为过渡期外置 agent 平台或实验平台,但不再作为长期主链的语义中心。**
再展开就是:
- **事实源**:只有 Rust kernel 的 `node / edge / subtree / projection / command`
- **业务真执行面**:只有 Rust runtime / Rust Web / Rust editor command
- **AI 编排层**:优先采用 `openai-agents-python`
- **模型底座**:优先采用 OpenAI `Responses API`
- **过渡平台**:当前 `Hermes` 可继续承接已有会话、流式事件和部分工具桥接
- **浏览器前端**:只保留 host / island / stream 渲染,不再承担主 AI 编排
一句话总结:
> **不是把 OpenAI Agents SDK 当事实源,而是把它当“比纯自建更省力、比 Hermes 更可塑”的编排层。**
---
## 4. 为什么这次主线更适合 `openai-agents-python`
### 4.1 不是从零手写整套 agent 平台
如果纯自建,就必须自己补:
- agent run loop
- session/history
- tool orchestration
- tracing
- guardrails
- handoff
- human in the loop
这条路线长期最干净,但在 `Phase 7` 并不是最优先的工作。
`Phase 7` 更应该把时间投到:
- `kernel-aware context assembly`
- Rust tool contract
- summary / ai_note / reference edge 写链
- PDF / Book -> kernel index 写链
- 阅读页与 AI 共用 `pageSubtree / outline / evidence`
因此当前不值得先把 agent 平台从头再造一遍。
### 4.2 也不继续把 Hermes 当长期主线
`Hermes` 的优势很明显:
- skills
- memory
- cron
- gateway
- 多后端模型
- 现成 agent 平台能力
但对 `mnote` 来说,它的问题也很明确:
- 它是通用 agent 平台,不是 `mnote-native` AI 架构
- 它自己的 memory / search / skill 体系,容易和 `kernel-aware retrieval` 冲突
- 它自己的工具心智模型,不适合作为 `node / subtree / edge / command` 的长期事实入口
- 它更适合做外置大脑壳,不适合做产品内核
所以当前口径不是“彻底弃用 Hermes”,而是:
> **让 Hermes 退回到过渡平台与实验平台的位置。**
### 4.3 `openai-agents-python` 正好卡在中间层
`openai-agents-python` 当前已经提供:
- agent primitives
- tools
- handoffs
- guardrails
- sessions
- tracing
- human in the loop
- sandbox agents
这意味着:
- 不必从零重写 agent orchestration
- 也不必接受 Hermes 整个平台的产品壳
- 可以把 `mnote` 自己的 Rust tools / kernel retrieval / audit 规则深度塞进去
这条路线更符合当前项目的长期方向:
- AI-first
- CLI-first
- Rust-native
- 工具直达 Rust command
- UI 只剩薄壳
---
## 5. 架构边界冻结
### 5.1 长期推荐结构
```text
UI Host / AI Panel / Document Read View
|
v
AI Gateway / Stream Adapter
|
v
openai-agents-python Orchestrator
|
+---- provider: OpenAI Responses API
|
+---- mnote tools:
| - kernel.node.*
| - kernel.subtree.*
| - kernel.edge.*
| - editor.command.*
| - search.kernel_aware.*
| - import_export.*
|
v
Rust runtime / mnote-web / bridge-runtime
|
v
Rust kernel truth
```
### 5.2 每层职责
#### Rust kernel / Rust runtime
负责:
- 统一对象真相
- projection/query/command
- editor command
- audit / rollback / policy
- summary node / ai_note node / reference edge / kernel index 的真写链
不负责:
- 多 agent handoff 框架
- 通用聊天 session UI
- 通用 agent 记忆产品壳
#### `openai-agents-python` 编排层
负责:
- prompt / instructions / tool routing
- session orchestration
- tracing / guardrails
- handoff / background agent / HITL
- 与 OpenAI `Responses API` 的 agent 级编排
不负责:
- 定义产品事实源
- 保存 `mnote` 的结构真相
- 直接写产品数据库
- 绕过 Rust runtime 调产品数据
#### Hermes
负责:
- 过渡期运行现有链路
- 保留实验平台和已有运营场景
- 提供短期 fallback
不负责:
- 长期 AI 主线语义中心
- `Phase 7` 的最终实现基线
---
## 6. Phase 7 中 AI 的实施目标
本计划只覆盖 `Phase 7` 所需 AI 主链,不扩展到完整长期 AI 平台。
### 6.1 目标一:AI 输入上下文改成 `kernel-aware`
AI 读取上下文不再默认基于:
- 页面前端对象
- BlockNote 临时结构
- 页面壳里拼出来的二次真相
AI 输入上下文统一改成:
- `node`
- `subtree`
- `outline`
- `evidence`
- `edges`
- 可选的 `pageSubtree projection`
### 6.2 目标二:AI 工具改成 Rust tool contract
第一批 AI tools 固定围绕下面几类:
- `kernel.node.get`
- `kernel.subtree.get`
- `kernel.edges.list`
- `kernel.project_view`
- `editor.insert_block_after`
- `editor.replace_block`
- `editor.delete_block`
- `editor.move_block`
- `editor.indent_block`
- `editor.outdent_block`
- `editor.toggle_heading_collapse`
- `kernel.summary.create`
- `kernel.ai_note.create`
- `kernel.reference.attach`
- `kernel.index.ingest_pdf`
- `kernel.index.ingest_book`
原则固定为:
> **AI 不操作 UI,不操作 DOM,不操作页面壳;AI 只操作 Rust 暴露出来的真工具面。**
### 6.3 目标三:先打通三条主写链
`Phase 7` 中优先打通下面三条链:
1. 文档页 AI -> summary node / ai_note node / reference edge
2. 文档页 AI -> block editor Rust command
3. PDF / Book AI 解析 -> kernel index
### 6.4 目标四:阅读页与 AI 共用一套 projection 口径
阅读页与 AI 共用:
- `pageSubtree`
- `outline`
- `evidence`
- `edges`
这样阅读页与 AI 才能真正围绕同一套真相工作。
---
## 7. 分阶段实施计划
## 阶段 0:边界冻结与过渡口径确认
### 目标
把 AI 主线从“继续围绕 Hermes 与前端 runtime 打补丁”改成“为 Rust kernel 准备长期编排层”。
### 需要完成
- [ ] 冻结 `Phase 7` 的 AI 主线目标,不再把“继续增强前端 AI runtime”当主任务
- [ ] 明确 `openai-agents-python` 为当前推荐编排层
- [ ] 明确 Hermes 只保留为过渡 fallback / 实验平台
- [ ] 明确 Rust runtime 才是唯一真执行面
- [ ] 明确前端 AI host 只负责会话壳与流式渲染
### 完成判定
- [ ] 团队口径统一为“AI 编排层可替换,但 Rust truth layer 不可替换”
---
## 阶段 1:定义 `mnote` 专用 AI tool surface
### 目标
先把 AI 能做什么冻结成稳定工具合同。
### 需要完成
- [ ] 基于 `node / subtree / edge / editor command` 定义第一批 tool schema
- [ ] 为所有写工具补齐审计字段:
- actor
- target node ids / block ids
- command list
- trace id
- rollback token
- [ ] 把现有前端临时工具名收口到统一命名
- [ ] 区分:
- read tools
- write tools
- index tools
- retrieval tools
- [ ]`summary node / ai_note node / reference edge` 定义稳定写协议
### 完成判定
- [ ] `mnote` 已有一套不依赖前端 UI 的 AI 工具合同
---
## 阶段 2:做 `kernel-aware context assembly`
### 目标
让 AI 的输入上下文改成以 kernel projection 为主。
### 需要完成
- [ ] 把文档页 AI 上下文切到:
- `node`
- `subtree`
- `outline`
- `evidence`
- `edges`
- [ ] 明确上下文优先级:
- page subtree > outline > evidence > page shell snapshots
- [ ] 移除或降级旧页面对象快照在主链中的权重
- [ ] 为 AI 会话定义可重复的上下文裁剪规则
- [ ] 让阅读页与 AI 共用同一套 projection 获取接口
### 完成判定
- [ ] AI 已经不再默认围绕旧页面壳工作
---
## 阶段 3:引入 `openai-agents-python` 编排层
### 目标
用官方 agent primitives 取代当前以 Hermes 为中心的长期编排口径,但不触碰 Rust truth layer。
### 需要完成
- [ ] 建立独立的 `mnote-ai-orchestrator` Python 服务或 sidecar
- [ ] 接入 OpenAI `Responses API`
- [ ]`mnote` Rust tools 注册到 `openai-agents-python`
- [ ] 打通 session / tracing / guardrails / handoff 基础设施
- [ ] 对齐当前前端 SSE 协议或定义新的稳定 event adapter
- [ ] 明确前端如何在 Hermes / Agents Python 之间切换
- [ ] 保留 Hermes fallback 开关,便于过渡期回退
### 完成判定
- [ ] 在不依赖 Hermes 的情况下,AI 已能跑通至少一条 `mnote-native` 主链
---
## 阶段 4:打通三条主写链
### 目标
`Phase 7` 的 AI 写能力真正落到 kernel。
### 需要完成
- [ ] AI -> `summary node` 写链
- [ ] AI -> `ai_note node` 写链
- [ ] AI -> `reference edge` 写链
- [ ] AI -> Rust block editor command 写链
- [ ] PDF / Book -> AI 结构化解析 -> kernel index 写链
- [ ] 对所有写操作补齐:
- trace
- audit
- target evidence
- rollback
### 完成判定
- [ ] AI 能稳定创建 `summary node / ai_note node / reference edge`
- [ ] AI 能把 PDF / Book 解析结果落进 `kernel index`
---
## 阶段 5:切文档页与阅读页主路径
### 目标
`Phase 7` 在产品可见行为上成立。
### 需要完成
- [ ] 文档页 AI 面板默认走 `kernel-aware` 上下文
- [ ] 阅读页大纲来自 kernel subtree
- [ ] 回链 / 引用 / 结构信息来自 kernel edge
- [ ] AI 输出的结构节点能在阅读页中被稳定消费
- [ ] 至少保留一条兼容 fallback,但不再是默认主链
### 完成判定
- [ ] 阅读页与 AI 至少各有一条主路径已经直接消费 kernel projection
---
## 8. Hermes 的过渡策略
当前不建议立刻删除 Hermes。
### 8.1 Hermes 在过渡期继续保留的价值
- 承接现有运行链
- 作为 agent 行为回归对照
- 作为短期 fallback
- 承接不属于 `mnote-native` 主链的实验场景
### 8.2 需要限制的边界
- 不让 Hermes 直接成为产品事实源
- 不让 Hermes memory 取代 `kernel-aware retrieval`
- 不让 Hermes 默认 shell/fs 能力直接改业务数据
- 不让 Hermes 工具名继续成为长期产品契约
### 8.3 最终状态
最终可接受的状态有两种:
1. Hermes 退化为非主链实验平台
2. Hermes 完全下线,只保留 `openai-agents-python + Rust runtime`
---
## 9. 非目标
这份计划当前不覆盖:
- 完整 AI suggestion review UI
- 完整协作评论体系
- 多平台消息网关
- 完整长期记忆产品壳
- 通用桌面 agent / shell agent 平台
- 把前端页面壳继续增强成 AI 主执行面
这些都不是 `Phase 7` 的核心目标。
---
## 10. 风险与取舍
### 风险一:`openai-agents-python` 仍然不是 Rust
这是现实限制,但当前可接受,因为它只做编排层,不做事实层。
### 风险二:Hermes 与新编排层会短期并存
这是过渡期复杂度,但能换来更平滑的切换与回退能力。
### 风险三:如果先把“agent 平台能力”做太重,会挤压 `Phase 7` 主任务
因此必须坚持:
> **`Phase 7` 先做 kernel-aware tools 与写链,不先做通用 AI 平台产品化。**
---
## 11. 最终冻结口径
当前冻结如下:
> **`Kernel Phase 7` 的 AI 主任务,是把 AI 从“页面壳上的聊天插件”升级为“直接围绕 Rust kernel / Rust editor command 工作的业务执行者”。**
> **当前推荐采用 `openai-agents-python` 作为 `mnote` 专用编排层,以减少从零自建 agent 平台的成本;同时继续让 Rust runtime 保持唯一事实源和真执行面。**
> **Hermes 可以继续作为过渡平台与实验平台存在,但不再应作为长期主线的语义中心。**
---
## 12. 对应当前 AI 主线的落地映射
本计划与历史 `Kernel Phase 7` 检查项以及当前主线收口目标的一一对应如下:
| `Phase 7` 检查项 | 本计划对应 |
| --- | --- |
| AI tool 直接面向 `node / subtree / edge` | 阶段 1、阶段 2 |
| AI 不再默认面向“页面前端壳” | 阶段 2、阶段 5 |
| AI 能创建 `summary node / ai_note node / reference edge` | 阶段 1、阶段 4 |
| AI 能把 PDF / Book 解析结果落进 `kernel index` | 阶段 4 |
| 阅读页与 AI 至少有一条主路径直接消费 kernel projection | 阶段 5 |
一句话收口:
> **如果这份计划执行完成,那么 `Phase 7` 中 AI 这一半就不再只是“接了个 agent”,而是第一次真正进入 `tree-first graph kernel` 主链。**
+276 -22
View File
@@ -5468,12 +5468,12 @@ fn execute_command(
}),
}))
}
"documents.title.update" | "tree.node.rename" => {
"documents.title.update" | "tree.node.rename" | "page.head.updateTitle" => {
let payload: DocumentTitleCommandPayload = parse_payload(command_wire.payload.clone())?;
let command_name = if command_wire.name == "tree.node.rename" {
"tree.node.rename"
} else {
"documents.title.update"
let command_name = match command_wire.name.as_str() {
"tree.node.rename" => "tree.node.rename",
"page.head.updateTitle" => "page.head.updateTitle",
_ => "documents.title.update",
};
let command = CommandEnvelope {
name: command_name.into(),
@@ -5508,12 +5508,12 @@ fn execute_command(
}),
}))
}
"documents.save" => {
"documents.save" | "page.body.save" => {
let payload: DocumentSaveCommandPayload = parse_payload(command_wire.payload.clone())?;
let editor_document = normalize_save_editor_document(&payload)?;
let canonical_content = legacy_content_from_editor_document(&editor_document);
let command = CommandEnvelope {
name: "documents.save".into(),
name: command_wire.name.clone(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
@@ -5525,7 +5525,8 @@ fn execute_command(
revision: payload.revision,
content_json: serde_json::to_string(&canonical_content).map_err(|error| {
BridgeError::validation(format!(
"documents.save content 序列化失败: {error}"
"{} content 序列化失败: {error}",
command_wire.name
))
})?,
conflict_detection_key: payload.conflict_detection_key.clone(),
@@ -6186,21 +6187,19 @@ fn normalize_save_editor_document(
payload: &DocumentSaveCommandPayload,
) -> Result<EditorBlockDocument, BridgeError> {
if let Some(editor_document) = payload.editor_document.clone() {
let mut parsed =
serde_json::from_value::<EditorBlockDocument>(editor_document).map_err(|error| {
BridgeError::validation(format!("documents.save editorDocument 非法: {error}"))
})?;
if parsed.document_id.trim().is_empty() {
parsed.document_id = payload.document_id.clone();
if let Ok(mut parsed) = serde_json::from_value::<EditorBlockDocument>(editor_document) {
if parsed.document_id.trim().is_empty() {
parsed.document_id = payload.document_id.clone();
}
if parsed.root_block_ids.is_empty() {
parsed.root_block_ids = parsed
.blocks
.iter()
.map(|block| block.block_id.clone())
.collect();
}
return Ok(parsed);
}
if parsed.root_block_ids.is_empty() {
parsed.root_block_ids = parsed
.blocks
.iter()
.map(|block| block.block_id.clone())
.collect();
}
return Ok(parsed);
}
if let Some(tiptap_document) = payload.tiptap_document.clone() {
let parsed = serde_json::from_value::<TiptapNode>(tiptap_document).map_err(|error| {
@@ -7259,6 +7258,261 @@ mod tests {
);
}
#[test]
fn documents_save_command_plan_falls_back_from_invalid_editor_document_to_tiptap() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_2".into(),
idempotency_key: Some("idem_save_2".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 6,
"editorDocument": {
"documentId": "doc_1",
"rootBlockIds": ["legacy_block_1"],
"blocks": [
{
"blockId": "legacy_block_1",
"blockType": "paragraph",
"contentNodes": [
{
"attrs": {}
}
],
"childBlockIds": []
}
]
},
"tiptapDocument": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {
"blockId": "p_1"
},
"content": [
{
"type": "text",
"text": "来自 tiptap 回退"
}
]
}
]
},
"content": [
{
"id": "content_block_1",
"type": "paragraph",
"content": "来自 content 回退"
}
],
"conflictDetectionKey": "doc_1:6"
}),
reason: Some("保存正文".into()),
refs: vec!["task-save-fallback".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("p_1")));
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("来自 tiptap 回退"))
);
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds/0"),
Some(&json!("p_1"))
);
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockId"),
Some(&json!("p_1"))
);
}
#[test]
fn documents_save_command_plan_prefers_valid_editor_document_over_other_sources() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_3".into(),
idempotency_key: Some("idem_save_3".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 7,
"editorDocument": {
"documentId": "doc_1",
"rootBlockIds": ["editor_block_1"],
"blocks": [
{
"blockId": "editor_block_1",
"blockType": "paragraph",
"contentNodes": [
{
"payload": {
"type": "text",
"text": "来自 editorDocument",
"marks": []
},
"attrs": {}
}
],
"childBlockIds": []
}
]
},
"tiptapDocument": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {
"blockId": "p_1"
},
"content": [
{
"type": "text",
"text": "来自 tiptap"
}
]
}
]
},
"content": [
{
"id": "content_block_1",
"type": "paragraph",
"content": "来自 content"
}
],
"conflictDetectionKey": "doc_1:7"
}),
reason: Some("保存正文".into()),
refs: vec!["task-save-prefer-editor".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("来自 editorDocument"))
);
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/contentNodes/0/payload/text"),
Some(&json!("来自 editorDocument"))
);
assert_eq!(
plan.args_json.pointer("/content/0/id"),
Some(&json!("editor_block_1"))
);
}
#[test]
fn documents_save_command_plan_supports_legacy_content_only_payloads() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "page.body.save".into(),
command_id: "cmd_save_4".into(),
idempotency_key: Some("idem_save_4".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 8,
"content": [
{
"id": "legacy_content_1",
"type": "paragraph",
"content": "来自旧式 content-only 保存"
}
],
"conflictDetectionKey": "doc_1:8"
}),
reason: Some("保存正文".into()),
refs: vec!["task-save-content-only".into()],
dry_run: false,
validate_only: false,
},
})
.expect("page.body.save plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("legacy_content_1")));
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("来自旧式 content-only 保存"))
);
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds/0"),
Some(&json!("legacy_content_1"))
);
}
#[test]
fn blocks_move_command_plan_maps_to_blocks_move() {
let plan = execute_runtime_input(RuntimeInput::Command {
+6 -1
View File
@@ -11,6 +11,7 @@ pub struct AppConfig {
pub service_name: String,
pub service_version: String,
pub bind_addr: String,
pub enable_debug_shell_routes: bool,
pub hermes_base_path: String,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
@@ -29,7 +30,11 @@ impl AppConfig {
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
bind_addr: env::var("MNOTE_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:3104".into()),
bind_addr: env::var("MNOTE_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:0".into()),
enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
.ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
@@ -162,6 +162,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -102,6 +102,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -510,6 +510,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -1772,6 +1772,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -195,6 +195,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+12 -6
View File
@@ -20,12 +20,10 @@ use axum::Router;
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
let compat_next_base_path = state.config().compat_next_base_path.clone();
let enable_debug_shell_routes = state.config().enable_debug_shell_routes;
Router::new()
let mut router = Router::new()
.route("/health", get(health::health))
.route("/tree", get(tree::tree_shell))
.route("/document-debug", get(editor::document_editor_shell))
.route("/document", get(editor::document_editor_shell))
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/save", post(documents::save))
@@ -63,6 +61,14 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/ai-agent/run", post(compat::next_ai_agent_run))
.route("/sidebar", get(compat::next_sidebar)),
)
.with_state(state)
);
if enable_debug_shell_routes {
router = router
.route("/tree", get(tree::tree_shell))
.route("/document-debug", get(editor::document_editor_shell))
.route("/document", get(editor::document_editor_shell));
}
router.with_state(state)
}
+1
View File
@@ -43,6 +43,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
+1
View File
@@ -2213,6 +2213,7 @@ mod tests {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
@@ -445,7 +445,8 @@ mod tests {
AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:3104".into(),
bind_addr: "127.0.0.1:0".into(),
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: Some("http://127.0.0.1:3210".into()),
@@ -197,6 +197,45 @@ mod tests {
.contains("\"name\":\"documents.title.update\""));
}
#[test]
fn page_head_update_title_command_maps_to_documents_update_title() {
let command = CommandEnvelope {
name: "page.head.updateTitle".into(),
command_id: "cmd_page_title_1".into(),
idempotency_key: Some("idem_page_title".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_1".into()),
block_id: None,
}),
payload: UpdatePageTitle {
page_id: "page_1".into(),
title: "新标题".into(),
},
reason: None,
refs: vec![],
dry_run: false,
validate_only: false,
};
let request =
build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateTitle");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request
.payload_json
.contains("\"name\":\"page.head.updateTitle\""));
}
#[test]
fn document_save_command_maps_to_documents_update_content() {
let command = CommandEnvelope {
@@ -237,6 +276,46 @@ mod tests {
assert!(request.payload_json.contains("\"name\":\"documents.save\""));
}
#[test]
fn page_body_save_command_maps_to_documents_update_content() {
let command = CommandEnvelope {
name: "page.body.save".into(),
command_id: "cmd_page_save_1".into(),
idempotency_key: Some("idem_page_save".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_1".into()),
block_id: None,
}),
payload: SavePageContent {
page_id: "page_1".into(),
workspace_id: Some("ws_1".into()),
revision: Some(42),
content_json: "{\"type\":\"doc\"}".into(),
conflict_detection_key: Some("rev:42".into()),
},
reason: Some("保存正文".into()),
refs: vec!["checklist:c3".into()],
dry_run: false,
validate_only: false,
};
let request =
build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateContent");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"page.body.save\""));
}
#[test]
fn document_embed_command_maps_to_documents_update_content() {
let command = CommandEnvelope {
@@ -608,6 +687,57 @@ mod tests {
.contains("\"name\":\"documents.options.update\""));
}
#[test]
fn page_layout_update_options_command_maps_to_documents_update_options() {
let command = CommandEnvelope {
name: "page.layout.updateOptions".into(),
command_id: "cmd_page_options_1".into(),
idempotency_key: Some("idem_page_options".into()),
actor: ActorPayload {
actor_type: "human".into(),
actor_id: "user_1".into(),
session_id: Some("session_1".into()),
},
source: SourcePayload {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(TargetRef {
workspace_id: Some("ws_1".into()),
page_id: Some("page_1".into()),
block_id: None,
}),
payload: UpdatePageOptions {
page_id: "page_1".into(),
wide_layout: None,
small_text: None,
show_heading_numbers: None,
show_toc: Some(true),
show_structure: None,
protect_editing: None,
show_word_count: None,
collapse_backlinks: None,
page_font: None,
layout_density: Some("compact".into()),
hide_child_pages: None,
show_block_ref_count: None,
embed_default_block_id: None,
},
reason: None,
refs: vec![],
dry_run: false,
validate_only: false,
};
let request =
build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateOptions");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request
.payload_json
.contains("\"name\":\"page.layout.updateOptions\""));
}
#[test]
fn media_asset_writeback_command_maps_to_replace_storage_from_upload() {
let command = CommandEnvelope {
@@ -54,9 +54,12 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
"documents.embed" => "documents:updateContent",
"blocks.patch" => "documents:updateContent",
"documents.save" => "documents:updateContent",
"page.body.save" => "documents:updateContent",
"documents.title.update" => "documents:updateTitle",
"page.head.updateTitle" => "documents:updateTitle",
"documents.stats.update" => "documents:updateStats",
"documents.options.update" => "documents:updateOptions",
"page.layout.updateOptions" => "documents:updateOptions",
"mindmaps.put" => "mindmaps:put",
"mindmaps.delete" => "mindmaps:softDelete",
"mindmaps.restore" => "mindmaps:restore",
@@ -1038,37 +1038,37 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1204, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1226, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1295, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1317, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 949, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 971, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h1ea2b89742286ce6);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1130, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1152, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1206, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1228, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1143, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1165, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1205, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
return ret;
},
+14 -5
View File
@@ -8,7 +8,7 @@
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104";
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
@@ -19,6 +19,14 @@ function assert(condition, message) {
}
}
function requireMnoteWebSmokeBaseUrl() {
assert(
Boolean(MNOTE_WEB_BASE_URL),
"当前 smoke 仅用于 legacy mnote-web tree shell,对应端口已默认退役;如需执行,请显式设置 MNOTE_WEB_SMOKE_BASE_URL。",
);
return MNOTE_WEB_BASE_URL;
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
@@ -110,6 +118,7 @@ async function ensureAuthenticated(page, requestContext) {
}
async function runTreeShellRegression(page, requestContext, viewer, target) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
const uniqueSuffix = Date.now().toString();
const rootTitle = `task091-root-${uniqueSuffix}`;
const childTitle = `task091-child-${uniqueSuffix}`;
@@ -132,7 +141,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
const iframe = page.locator('iframe[title="mnote-web tree shell"]');
await iframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframeSrc = await iframe.getAttribute("src");
assert(iframeSrc && iframeSrc.includes(`${MNOTE_WEB_BASE_URL}/tree`), `Sidebar 未挂载 mnote-web tree shell${iframeSrc}`);
assert(iframeSrc && iframeSrc.includes(`${runtimeBaseUrl}/tree`), `Sidebar 未挂载 mnote-web tree shell${iframeSrc}`);
assert(iframeSrc.includes(`actorId=${encodeURIComponent(viewer.userId)}`), `tree shell 未透传当前用户 actorId${iframeSrc}`);
const getTreeFrame = () => page.frameLocator('iframe[title="mnote-web tree shell"]');
@@ -177,7 +186,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
const createResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"create"'),
@@ -200,7 +209,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
page.once("dialog", (dialog) => dialog.accept(renamedTitle));
const renameResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"rename"'),
@@ -220,7 +229,7 @@ async function runTreeShellRegression(page, requestContext, viewer, target) {
const moveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${MNOTE_WEB_BASE_URL}/api/tree/commands`) &&
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"move"'),
-21
View File
@@ -6,7 +6,6 @@
// - 未登录场景允许 "/" 返回 30x 到 "/auth",也允许直接返回 HTML。
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104").replace(/\/+$/, "");
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 8000);
function assert(condition, message) {
@@ -35,23 +34,6 @@ async function readSnippet(response) {
return text.slice(0, 200);
}
async function probeMnoteWeb() {
try {
const response = await fetchWithTimeout(`${MNOTE_WEB_BASE_URL}/health`, {
method: "GET",
});
return {
reachable: true,
status: response.status,
};
} catch (error) {
return {
reachable: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function validateAuthEntry() {
const response = await fetchWithTimeout(`${BASE_URL}/auth`, { method: "GET" });
const contentType = response.headers.get("content-type") || "";
@@ -98,7 +80,6 @@ async function validateRootEntry() {
}
async function main() {
const mnoteWeb = await probeMnoteWeb();
const authEntry = await validateAuthEntry();
const rootEntry = await validateRootEntry();
@@ -107,8 +88,6 @@ async function main() {
{
ok: true,
baseUrl: BASE_URL,
mnoteWebBaseUrl: MNOTE_WEB_BASE_URL,
mnoteWeb,
authEntry,
rootEntry,
},
@@ -10,6 +10,7 @@ const {
ensureAuthenticated,
openDocument,
prepareTempTreeFixture,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function runDefaultShellPath(page, fixture) {
@@ -30,9 +31,10 @@ async function runDefaultShellPath(page, fixture) {
}
async function runRuntimeDebugPath(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -70,6 +72,7 @@ async function main() {
workspaceId: fixture.workspaceId,
parentId: fixture.parentId,
childId: fixture.childId,
debugRuntimeBaseUrl: MNOTE_WEB_BASE_URL || null,
primary,
debug,
},
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -3,19 +3,20 @@
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -0,0 +1,601 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
openDocument,
purgeDocument,
renameDocument,
requestJson,
} = require("./tree-shell-smoke-helpers");
const AI_TIMEOUT_MS = Number(process.env.MNOTE_AI_SMOKE_TIMEOUT_MS || 180_000);
function parseJsonSafely(text) {
try {
return text ? JSON.parse(text) : null;
} catch {
return text;
}
}
function parseSseText(rawText) {
return String(rawText || "")
.split(/\n\n+/)
.map((chunk) => chunk.trim())
.filter(Boolean)
.filter((chunk) => !chunk.startsWith(":"))
.map((chunk) => {
const lines = chunk.split(/\r?\n/);
let type = "message";
const dataLines = [];
for (const line of lines) {
if (line.startsWith("event:")) {
type = line.slice("event:".length).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trimStart());
}
}
const dataText = dataLines.join("\n");
return {
type,
dataText,
data: parseJsonSafely(dataText),
};
});
}
function serializeForError(value) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
async function requestSseEvents(requestContext, path, body) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
},
data: body,
timeout: AI_TIMEOUT_MS,
});
const text = await response.text();
if (!response.ok()) {
throw new Error(`${path} 请求失败: ${response.status()} ${response.statusText()} ${text.slice(0, 600)}`);
}
const contentType = response.headers()["content-type"] || "";
assert(contentType.includes("text/event-stream"), `${path} 未返回 SSE${contentType}`);
return parseSseText(text);
}
async function fetchPageAggregate(requestContext, documentId, workspaceId) {
return await requestJson(
requestContext,
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
}
async function fetchDocumentMeta(requestContext, documentId, workspaceId) {
return await requestJson(
requestContext,
`/api/documents/meta?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
}
async function fetchDocumentContent(requestContext, documentId, workspaceId) {
return await requestJson(
requestContext,
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
}
function buildAiContextFromPageAggregate(pagePayload) {
const page = pagePayload?.page ?? null;
const pageSubtree = page?.tree?.pageSubtree ?? null;
const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes)
? pageSubtree.subtree.nodes.slice(0, 160)
: [];
return {
documentId: page?.identity?.documentId ?? null,
documentBlocks: page?.body?.content ?? null,
node: pageSubtree?.rootNode ?? null,
subtree: pageSubtree
? {
projectionId: pageSubtree.projectionId ?? null,
rootNodeId: pageSubtree?.subtree?.rootNodeId ?? null,
stats: pageSubtree?.stats ?? null,
nodes: subtreeNodes,
}
: null,
outline: Array.isArray(pageSubtree?.outline) ? pageSubtree.outline.slice(0, 40) : null,
evidence: Array.isArray(pageSubtree?.evidence) ? pageSubtree.evidence.slice(0, 32) : null,
pageOptions: page?.layout?.pageOptions ?? {},
};
}
function findSuccessfulToolResult(events, toolName) {
return [...events]
.reverse()
.find(
(event) =>
event?.type === "tool_result" &&
event?.data &&
event.data.tool === toolName &&
event.data.ok === true,
) ?? null;
}
function findErrorEvent(events) {
return events.find((event) => event?.type === "error") ?? null;
}
function extractRenamedTitleFromSlashToolResult(toolEvent, documentId) {
if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "slash_run") {
return null;
}
const resultRecord =
toolEvent.data.result && typeof toolEvent.data.result === "object"
? toolEvent.data.result
: null;
const parsed =
resultRecord?.parsed && typeof resultRecord.parsed === "object"
? resultRecord.parsed
: null;
const params =
parsed?.params && typeof parsed.params === "object"
? parsed.params
: null;
if (!parsed || parsed.command !== "rename_doc" || !params) {
return null;
}
if (String(params.documentId ?? "") !== documentId) {
return null;
}
const title = String(params.title ?? "").trim();
return title || null;
}
function extractBodySnapshotFromToolResult(toolEvent) {
if (!toolEvent?.data?.result || !["doc_insert_blocks", "doc_replace_range"].includes(toolEvent?.data?.tool)) {
return null;
}
const resultRecord =
toolEvent.data.result && typeof toolEvent.data.result === "object"
? toolEvent.data.result
: null;
return resultRecord && Array.isArray(resultRecord.data) ? resultRecord.data : null;
}
function extractInsertedBlockIdFromToolResult(toolEvent) {
if (!toolEvent?.data?.result || toolEvent?.data?.tool !== "doc_insert_blocks") {
return null;
}
const resultRecord =
toolEvent.data.result && typeof toolEvent.data.result === "object"
? toolEvent.data.result
: null;
const inserted = resultRecord && Array.isArray(resultRecord.inserted) ? resultRecord.inserted : null;
const blockId = typeof inserted?.[0] === "string" ? inserted[0].trim() : "";
return blockId || null;
}
async function persistAiTitleResult(requestContext, fixture, toolEvent) {
const renamedTitle = extractRenamedTitleFromSlashToolResult(toolEvent, fixture.documentId);
assert(renamedTitle, `slash_run 结果未产出当前页标题:${serializeForError(toolEvent)}`);
await renameDocument(requestContext, fixture.workspaceId, fixture.documentId, renamedTitle);
return renamedTitle;
}
async function persistAiBodyResult(requestContext, fixture, toolEvent) {
const nextBlocks = extractBodySnapshotFromToolResult(toolEvent);
assert(Array.isArray(nextBlocks), `文档写工具未返回 legacy blocks 快照:${serializeForError(toolEvent)}`);
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
await requestJson(requestContext, "/api/documents/save", {
method: "POST",
data: {
documentId: fixture.documentId,
workspaceId: fixture.workspaceId,
// task111 对齐正式 page body save:正文写工具结果只接受 legacy blocks 快照。
content: nextBlocks,
revision: aggregate?.page?.body?.revision ?? null,
conflictDetectionKey: aggregate?.page?.body?.conflictDetectionKey ?? null,
snapshotCapturedAt: new Date().toISOString(),
blockCount: nextBlocks.length,
},
});
return nextBlocks;
}
async function waitFor(check, description, timeoutMs = UI_TIMEOUT_MS, intervalMs = 500) {
const deadline = Date.now() + timeoutMs;
let lastValue = null;
while (Date.now() < deadline) {
lastValue = await check();
if (lastValue) {
return lastValue;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`${description} 超时,最后结果:${serializeForError(lastValue)}`);
}
async function waitForPageAggregateBodyText(requestContext, fixture, expectedText) {
await waitFor(
async () => {
const pagePayload = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const raw = JSON.stringify(pagePayload?.page?.body?.content ?? null);
return raw.includes(expectedText) ? pagePayload : null;
},
`等待 page aggregate 正文同步到 ${expectedText}`,
AI_TIMEOUT_MS,
);
}
async function waitForRuntimeIsland(page) {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = host?.querySelector(".editor-surface .ProseMirror[contenteditable='true']");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function waitForTitleInput(page) {
const input = page.getByLabel("页面标题");
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input;
}
async function waitForEditorText(page, expectedText) {
await page.waitForFunction(
(text) => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").includes(text);
},
expectedText,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readVisibleTitle(page) {
const input = page.getByLabel("页面标题");
if (await input.isVisible().catch(() => false)) {
return ((await input.inputValue().catch(() => "")) || "").trim();
}
const heading = page.locator("h1").first();
return ((await heading.textContent().catch(() => "")) || "").trim();
}
async function readEditorText(page) {
return await page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return (editor?.textContent ?? "").trim();
});
}
async function typeIntoEditor(page, text) {
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Control+a");
await page.keyboard.press("Backspace");
await page.keyboard.type(text, { delay: 20 });
}
async function waitForPersistedSave(page, saveRequests, documentId, expectedText) {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const editorText = await readEditorText(page);
const hasSaveRequest = saveRequests.some((item) => item.documentId === documentId);
const runtimeStatus = await page.evaluate(() => {
return (
document
.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
?.getAttribute("data-runtime-editor-status") ?? null
);
});
if (editorText.includes(expectedText) && (runtimeStatus === "saved" || hasSaveRequest)) {
return;
}
await page.waitForTimeout(250);
}
throw new Error(`等待编辑器保存超时:${documentId}`);
}
async function runTitleRename(requestContext, fixture, renamedTitle) {
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
stream: true,
maxSteps: 8,
scope: "document",
messages: [
{
role: "user",
content: `请把当前页面标题改成“${renamedTitle}”。只改标题,不要改正文;改完后用一句话确认。`,
},
],
context: buildAiContextFromPageAggregate(aggregate),
options: {
ai: {
provider: "online",
},
},
});
const errorEvent = findErrorEvent(events);
assert(!errorEvent, `标题改名流返回 error${serializeForError(errorEvent)}`);
const slashResult = findSuccessfulToolResult(events, "slash_run");
assert(slashResult, `标题改名未得到成功的 slash_run:${serializeForError(events)}`);
const persistedTitle = await persistAiTitleResult(requestContext, fixture, slashResult);
assert(
persistedTitle === renamedTitle,
`AI 标题结果与目标不一致,期望 ${renamedTitle},实际 ${persistedTitle}`,
);
await waitFor(
async () => {
const meta = await fetchDocumentMeta(requestContext, fixture.documentId, fixture.workspaceId);
return String(meta?.doc?.title ?? "").trim() === renamedTitle ? meta : null;
},
"等待标题改名落盘",
AI_TIMEOUT_MS,
);
return events;
}
async function runBodyInsert(requestContext, fixture, initialBody) {
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
stream: true,
maxSteps: 8,
scope: "document",
messages: [
{
role: "user",
content: `请在当前页面插入一段正文:“${initialBody}”。只插入这一段,不要改标题;完成后简短确认。`,
},
],
context: buildAiContextFromPageAggregate(aggregate),
options: {
ai: {
provider: "online",
},
},
});
const errorEvent = findErrorEvent(events);
assert(!errorEvent, `正文插入流返回 error${serializeForError(errorEvent)}`);
const insertResult = findSuccessfulToolResult(events, "doc_insert_blocks");
assert(insertResult, `正文插入未得到成功的 doc_insert_blocks${serializeForError(events)}`);
await persistAiBodyResult(requestContext, fixture, insertResult);
const insertedBlockId = extractInsertedBlockIdFromToolResult(insertResult);
assert(insertedBlockId, `正文插入结果缺少 inserted blockId${serializeForError(insertResult)}`);
await waitFor(
async () => {
const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId);
const raw = JSON.stringify(content?.content ?? null);
return raw.includes(initialBody) ? content : null;
},
"等待正文插入落盘",
AI_TIMEOUT_MS,
);
await waitForPageAggregateBodyText(requestContext, fixture, initialBody);
return {
events,
insertedBlockId,
};
}
async function runBodyRewrite(requestContext, fixture, targetBlockId, rewrittenBody) {
const aggregate = await fetchPageAggregate(requestContext, fixture.documentId, fixture.workspaceId);
const events = await requestSseEvents(requestContext, "/api/ai-agent/run", {
stream: true,
maxSteps: 8,
scope: "document",
messages: [
{
role: "user",
content: `请把当前页面中 blockId 为“${targetBlockId}”的那一段改写为“${rewrittenBody}”。只改这个 blockId 对应的段落,不要改标题;直接用 doc_replace_range 完成,不需要先搜索。改完后简短确认。`,
},
],
context: buildAiContextFromPageAggregate(aggregate),
options: {
ai: {
provider: "online",
},
},
});
const errorEvent = findErrorEvent(events);
assert(!errorEvent, `正文改写流返回 error${serializeForError(errorEvent)}`);
const replaceResult = findSuccessfulToolResult(events, "doc_replace_range");
assert(replaceResult, `正文改写未得到成功的 doc_replace_range${serializeForError(events)}`);
await persistAiBodyResult(requestContext, fixture, replaceResult);
await waitFor(
async () => {
const content = await fetchDocumentContent(requestContext, fixture.documentId, fixture.workspaceId);
const raw = JSON.stringify(content?.content ?? null);
return raw.includes(rewrittenBody) ? content : null;
},
"等待正文改写落盘",
AI_TIMEOUT_MS,
);
return events;
}
async function verifyDocumentUi(page, fixture, expectedTitle, expectedBody) {
await openDocument(page, fixture.workspaceId, fixture.documentId);
await waitForRuntimeIsland(page);
await waitForTitleInput(page);
await waitForEditorText(page, expectedBody);
const visibleTitle = await readVisibleTitle(page);
const editorText = await readEditorText(page);
assert(
visibleTitle.includes(expectedTitle),
`页面标题未同步到 UI,期望包含 ${expectedTitle},实际为 ${visibleTitle}`,
);
assert(
editorText.includes(expectedBody),
`编辑区正文未同步到 UI,期望包含 ${expectedBody},实际为 ${editorText}`,
);
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
fixture = await createTempDocument(context.request, null);
const uniqueSuffix = Date.now().toString().slice(-6);
const initialTitle = `phase7-ai-initial-${uniqueSuffix}`;
const renamedTitle = `phase7-ai-renamed-${uniqueSuffix}`;
const initialBody = `phase7 AI 原始正文 ${uniqueSuffix}`;
const rewrittenBody = `phase7 AI 改写正文 ${uniqueSuffix}`;
await renameDocument(context.request, fixture.workspaceId, fixture.documentId, initialTitle);
const insertRun = await runBodyInsert(context.request, fixture, initialBody);
const renameEvents = await runTitleRename(context.request, fixture, renamedTitle);
const rewriteEvents = await runBodyRewrite(
context.request,
fixture,
insertRun.insertedBlockId,
rewrittenBody,
);
const meta = await fetchDocumentMeta(context.request, fixture.documentId, fixture.workspaceId);
const content = await fetchDocumentContent(context.request, fixture.documentId, fixture.workspaceId);
assert(
String(meta?.doc?.title ?? "").trim() === renamedTitle,
`标题未完成持久化,期望 ${renamedTitle},实际 ${String(meta?.doc?.title ?? "").trim()}`,
);
const serializedContent = JSON.stringify(content?.content ?? null);
assert(
serializedContent.includes(rewrittenBody),
`正文未完成持久化,未命中 ${rewrittenBody}`,
);
assert(
!serializedContent.includes(initialBody),
`正文仍保留旧文本 ${initialBody}`,
);
const pageAggregate = await fetchPageAggregate(context.request, fixture.documentId, fixture.workspaceId);
assert(
String(pageAggregate?.page?.head?.title ?? "").trim() === renamedTitle,
`page aggregate 标题未更新为 ${renamedTitle}`,
);
assert(
JSON.stringify(pageAggregate?.page?.body?.content ?? null).includes(rewrittenBody),
`page aggregate 正文未更新为 ${rewrittenBody}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
documentId: fixture.documentId,
renamedTitle,
rewrittenBody,
insertToolSequence: insertRun.events
.filter((event) => event.type === "tool_result")
.map((event) => event.data?.tool ?? null)
.filter(Boolean),
renameToolSequence: renameEvents
.filter((event) => event.type === "tool_result")
.map((event) => event.data?.tool ?? null)
.filter(Boolean),
rewriteToolSequence: rewriteEvents
.filter((event) => event.type === "tool_result")
.map((event) => event.data?.tool ?? null)
.filter(Boolean),
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture?.documentId) {
try {
await purgeDocument(context.request, fixture.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${
cleanupError instanceof Error
? cleanupError.stack || cleanupError.message
: String(cleanupError)
}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+10 -1
View File
@@ -1,7 +1,7 @@
"use strict";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3104").replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
@@ -13,6 +13,14 @@ function assert(condition, message) {
}
}
function requireMnoteWebSmokeBaseUrl() {
assert(
Boolean(MNOTE_WEB_BASE_URL),
"当前 smoke 仅用于 legacy mnote-web debug/runtime,对应端口已默认退役;如需执行,请显式设置 MNOTE_WEB_SMOKE_BASE_URL。",
);
return MNOTE_WEB_BASE_URL;
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
@@ -251,6 +259,7 @@ module.exports = {
REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS,
assert,
requireMnoteWebSmokeBaseUrl,
cleanupDocuments,
createTempDocument,
ensureAuthenticated,
+3
View File
@@ -22,6 +22,9 @@ class Settings(BaseSettings):
redis_url: str = "redis://localhost:6379/0"
frontend_url: str = "http://localhost:3000"
openai_api_key: str = ""
mnote_ai_default_model: str = ""
mnote_ai_orchestrator_api_key: str = ""
openai_agents_session_db_path: str = ""
lightrag_db_url: str = ""
lightrag_collection: str = "wolai-docs"
+6 -5
View File
@@ -1,10 +1,11 @@
from fastapi import APIRouter
from . import chat, health, luckysheet_ws, tasks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(tasks.router, tags=["tasks"])
api_router.include_router(chat.router, tags=["chat"])
from . import ai_agent, chat, health, luckysheet_ws, tasks
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(tasks.router, tags=["tasks"])
api_router.include_router(chat.router, tags=["chat"])
api_router.include_router(ai_agent.router, tags=["ai-agent"])
root_router = APIRouter()
root_router.include_router(health.router, tags=["health"])
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from app.config import settings
from app.services.ai_document_agent import (
DocumentAiRunRequest,
build_document_ai_config_payload,
has_configured_openai_api_key,
run_document_agent_stream,
sdk_available,
)
router = APIRouter(prefix="/ai-agent")
def verify_internal_key(
x_mnote_ai_key: Annotated[str | None, Header()] = None,
) -> None:
expected = settings.mnote_ai_orchestrator_api_key.strip()
if not expected:
return
if x_mnote_ai_key == expected:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid orchestrator key",
)
@router.get("/health")
async def health() -> JSONResponse:
return JSONResponse(
{
"ok": True,
"bridge": "openai_agents_python",
"sdkAvailable": sdk_available(),
"openaiConfigured": has_configured_openai_api_key(),
"bridgeRuntimeMode": "local_runtime",
}
)
@router.post("/document/run")
async def run_document(
request: Request,
payload: DocumentAiRunRequest,
_: None = Depends(verify_internal_key),
) -> StreamingResponse:
stream = run_document_agent_stream(
payload,
source_headers={key.lower(): value for key, value in request.headers.items()},
)
return StreamingResponse(stream, media_type="text/event-stream")
@router.get("/document/config")
async def get_document_config(
_: None = Depends(verify_internal_key),
) -> JSONResponse:
return JSONResponse(build_document_ai_config_payload())
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -5,7 +5,8 @@ redis==5.1.1
httpx==0.27.2
python-dotenv==1.0.1
pgvector==0.3.5
openai==1.59.5
python-multipart==0.0.17
pydantic-settings==2.6.1
typing_extensions==4.12.2
openai==2.11.0
openai-agents==0.7.0
python-multipart==0.0.17
pydantic-settings==2.6.1
typing_extensions==4.15.0
@@ -0,0 +1,362 @@
import contextlib
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from app.services.ai_document_agent import (
DocumentAiEmitter,
DocumentAiContextPayload,
DocumentAiRunContext,
DocumentAiMessage,
DocumentAiRunRequest,
LocalBridgeRuntime,
build_agent_usage_from_response_usage,
build_document_ai_config_payload,
build_document_model_provider,
build_document_agent_instructions,
build_forward_headers,
run_document_agent_stream,
resolve_document_agent_request_model,
resolve_document_agent_model,
)
class AiDocumentAgentTests(unittest.TestCase):
def test_build_document_agent_instructions_includes_core_context(self):
request = DocumentAiRunRequest(
userId="user-1",
messages=[DocumentAiMessage(role="user", content="帮我改写第一段")],
context=DocumentAiContextPayload(
documentId="doc-1",
documentBlocks=[
{
"id": "block-1",
"type": "paragraph",
"content": [{"text": "第一段正文"}],
"children": [],
}
],
pageOptions={"wideLayout": True},
outline={"items": ["第一段"]},
),
)
instructions = build_document_agent_instructions(request)
self.assertIn("documentId=doc-1", instructions)
self.assertIn("documentBlockSummaries=", instructions)
self.assertIn("第一段正文", instructions)
self.assertIn("pageOptions=", instructions)
self.assertIn("outline=", instructions)
def test_build_document_agent_instructions_includes_profile_policy(self):
request = DocumentAiRunRequest(
userId="user-1",
profileId="page_writer_ai_first",
messages=[DocumentAiMessage(role="user", content="帮我补一段总结")],
context=DocumentAiContextPayload(
documentId="doc-1",
documentBlocks=[],
),
)
instructions = build_document_agent_instructions(request)
self.assertIn("AI 优先直接进入主编辑区起草", instructions)
self.assertIn("documentId=doc-1", instructions)
def test_build_document_agent_instructions_includes_title_write_hard_rules(self):
request = DocumentAiRunRequest(
userId="user-1",
messages=[DocumentAiMessage(role="user", content="请把当前页面标题改成“新标题”。")],
context=DocumentAiContextPayload(
documentId="doc-1",
documentBlocks=[],
),
)
instructions = build_document_agent_instructions(request)
self.assertIn("你必须调用一次 slash_run", instructions)
self.assertIn("/rename doc-1 <新标题>", instructions)
def test_build_forward_headers_only_keeps_allowed_headers(self):
forwarded = build_forward_headers(
{
"authorization": "Bearer token",
"cookie": "a=1",
"x-request-id": "req-1",
"x-trace-id": "trace-1",
"x-mnote-source-channel": "next_route",
"user-agent": "Vitest",
"x-ignore-me": "nope",
}
)
self.assertEqual(forwarded["authorization"], "Bearer token")
self.assertEqual(forwarded["cookie"], "a=1")
self.assertEqual(forwarded["x-request-id"], "req-1")
self.assertNotIn("x-ignore-me", forwarded)
def test_resolve_document_agent_model_uses_prefixed_request_model_first(self):
resolved = resolve_document_agent_model(
request_model="gh/gpt-4.1",
configured_default_model="ju/gpt-5.4",
env={},
available_models_loader=lambda _base_url, _api_key: [],
)
self.assertEqual(resolved, "gh/gpt-4.1")
def test_resolve_document_agent_request_model_prefers_model_key_semantic_name(self):
resolved = resolve_document_agent_request_model(
request_model=None,
request_model_key="gpt-5.3-codex",
configured_default_model="ju/gpt-5.4",
env={
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
"OPENAI_API_KEY": "sk-test",
},
available_models_loader=lambda _base_url, _api_key: [
"slow",
"fast",
"codex",
"ju/gpt-5.4",
],
)
self.assertEqual(resolved, "gpt-5.3-codex")
def test_resolve_document_agent_model_prefers_responses_provider_for_bare_default(self):
resolved = resolve_document_agent_model(
request_model=None,
configured_default_model="",
env={
"CLAW_DEFAULT_MODEL": "gpt-5.4",
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
"OPENAI_API_KEY": "sk-test",
},
available_models_loader=lambda _base_url, _api_key: [
"gh/gpt-5.4",
"ju/gpt-5.4",
"gmn/gpt-5.4",
],
)
self.assertEqual(resolved, "ju/gpt-5.4")
def test_resolve_document_agent_model_requires_explicit_prefix_when_bare_model_still_ambiguous(self):
with self.assertRaisesRegex(RuntimeError, "provider/model"):
resolve_document_agent_model(
request_model=None,
configured_default_model="",
env={
"CLAW_DEFAULT_MODEL": "gpt-5.4",
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
"OPENAI_API_KEY": "sk-test",
},
available_models_loader=lambda _base_url, _api_key: [
"gh/gpt-5.4",
"gmn/gpt-5.4",
],
)
def test_resolve_document_agent_model_fails_when_no_model_is_configured(self):
with self.assertRaisesRegex(RuntimeError, "未配置文档页 AI model"):
resolve_document_agent_model(
request_model=None,
configured_default_model="",
env={},
available_models_loader=lambda _base_url, _api_key: [],
)
def test_build_document_model_provider_supports_gateway_prefixed_models(self):
provider = build_document_model_provider(
request_model="ju/gpt-5.4",
configured_default_model="",
env={
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
"OPENAI_API_KEY": "sk-test",
},
available_models_loader=lambda _base_url, _api_key: [
"ju/gpt-5.4",
"gh/gpt-4.1",
],
)
ju_model = provider.get_model("ju/gpt-5.4")
gh_model = provider.get_model("gh/gpt-4.1")
self.assertEqual(getattr(ju_model, "model", None), "ju/gpt-5.4")
self.assertEqual(getattr(gh_model, "model", None), "gh/gpt-4.1")
def test_build_agent_usage_from_response_usage_falls_back_to_input_plus_output(self):
class FakeUsage:
input_tokens = 12
output_tokens = 5
total_tokens = None
input_tokens_details = None
output_tokens_details = None
usage = build_agent_usage_from_response_usage(FakeUsage())
self.assertEqual(usage.input_tokens, 12)
self.assertEqual(usage.output_tokens, 5)
self.assertEqual(usage.total_tokens, 17)
def test_build_agent_usage_from_response_usage_ignores_invalid_total_tokens(self):
class FakeUsage:
input_tokens = 8
output_tokens = 3
total_tokens = "not-a-number"
input_tokens_details = None
output_tokens_details = None
usage = build_agent_usage_from_response_usage(FakeUsage())
self.assertEqual(usage.input_tokens, 8)
self.assertEqual(usage.output_tokens, 3)
self.assertEqual(usage.total_tokens, 11)
def test_build_document_ai_config_payload_exposes_model_keys_profiles_and_tools(self):
payload = build_document_ai_config_payload(
env={
"OPENAI_BASE_URL": "http://127.0.0.1:20128/v1",
"CLAW_DEFAULT_MODEL": "gpt-5.4",
}
)
self.assertEqual(payload["provider"], "online")
self.assertEqual(payload["baseUrl"], "http://localhost:20128/v1")
self.assertEqual(payload["defaultModelKey"], "gpt-5.4")
self.assertEqual(payload["defaultProfileId"], "page_writer_ai_first")
self.assertTrue(payload["sessionEnabled"])
self.assertTrue(any(item["key"] == "gpt-5.4" for item in payload["models"]))
self.assertTrue(
any(
item["key"] == "gpt-5.4"
and item["resolvedCombo"] == "slow"
and item["resolvedRuntimeModel"] == "gpt-5.4"
for item in payload["models"]
)
)
self.assertTrue(any(item["id"] == "page_writer_ai_first" for item in payload["profiles"]))
self.assertTrue(any(item["name"] == "doc_get" for item in payload["tools"]))
class AiDocumentAgentBridgeRuntimeTests(unittest.IsolatedAsyncioTestCase):
async def test_local_bridge_runtime_execute_tool_uses_local_runtime_result_mode(self):
bridge = LocalBridgeRuntime()
ctx = DocumentAiRunContext(
user_id="user-1",
request_id="req-1",
trace_id="trace-1",
session_id=None,
workspace_id="ws-1",
document_id="doc-1",
document_blocks=[],
node=None,
subtree=None,
outline=None,
evidence=None,
page_options=None,
editor_runtime_page_options=None,
forward_headers={},
emitter=DocumentAiEmitter(),
bridge=bridge,
)
mock_run_bridge_runtime = AsyncMock(
return_value={
"ok": True,
"result": {
"ok": True,
"parsed": {
"command": "rename_doc",
},
},
}
)
with patch(
"app.services.ai_document_agent.run_bridge_runtime",
new=mock_run_bridge_runtime,
):
result = await bridge.execute_tool(
tool="slash_run",
invocation_kind="command",
context=ctx,
args_json={"text": "/rename doc-1 新标题"},
data={"source": "ai-orchestrator"},
target={"pageId": "doc-1"},
)
self.assertTrue(result["ok"])
self.assertEqual(result["parsed"]["command"], "rename_doc")
payload = mock_run_bridge_runtime.await_args.args[0]
self.assertEqual(payload["kind"], "tool")
self.assertEqual(payload["tool"]["tool"], "slash_run")
self.assertEqual(payload["tool"]["kind"], "command")
self.assertEqual(payload["tool"]["mode"], "result")
self.assertEqual(payload["tool"]["target"]["pageId"], "doc-1")
self.assertEqual(payload["context"]["source"]["channel"], "ai_orchestrator")
self.assertEqual(payload["context"]["source"]["client"], "wolai-backend")
async def test_run_document_agent_stream_does_not_require_legacy_mnote_web_config(self):
request = DocumentAiRunRequest(
userId="user-1",
maxSteps=1,
messages=[DocumentAiMessage(role="user", content="请总结当前页面")],
context=DocumentAiContextPayload(
documentId="doc-1",
documentBlocks=[],
),
)
class FakeRunner:
@staticmethod
async def run(*_args, **_kwargs):
return SimpleNamespace(final_output="已完成")
with (
patch("app.services.ai_document_agent.sdk_available", return_value=True),
patch(
"app.services.ai_document_agent.has_configured_openai_api_key",
return_value=True,
),
patch(
"app.services.ai_document_agent.build_document_agent",
return_value=object(),
),
patch(
"app.services.ai_document_agent.build_document_model_provider",
return_value=object(),
),
patch(
"app.services.ai_document_agent.build_session",
return_value=None,
),
patch(
"app.services.ai_document_agent.RunConfig",
side_effect=lambda **kwargs: kwargs,
),
patch(
"app.services.ai_document_agent.Runner",
new=FakeRunner,
),
patch(
"app.services.ai_document_agent.trace",
new=lambda *_args, **_kwargs: contextlib.nullcontext(),
),
):
chunks: list[str] = []
async for chunk in run_document_agent_stream(request, source_headers={}):
chunks.append(chunk)
text = "".join(chunks)
self.assertIn("event: assistant_message", text)
self.assertIn("event: completion", text)
self.assertIn("已完成", text)
if __name__ == "__main__":
unittest.main()
-2
View File
@@ -4,8 +4,6 @@
"supabaseInternalUrl": "http://127.0.0.1:18000",
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
"backendUrl": "https://frp-dry.com:44399",
"mnoteWebBaseUrl": "http://127.0.0.1:3104",
"mnoteWebTreeShellEnabled": false,
"onlyofficeBaseUrlWeb": "/onlyoffice-server",
"onlyofficeBaseUrlDesktop": "http://localhost:8082",
"onlyofficeProxyOrigin": "http://host.docker.internal:3000",
@@ -1,14 +1,6 @@
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { DocumentShell } from "@/components/editor/document-shell";
import type { PageFont, PageLayoutDensity, PageOptionsState, DocumentStats } from "@/types/page-options";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
import {
DEFAULT_EDITOR_HOST_KIND,
normalizeEditorHostKind,
@@ -16,120 +8,13 @@ import {
type EditorHostKind,
} from "@/components/editor/editor-host-config";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import { loadPageAggregateFromNextHeaders } from "@/lib/documents/page-aggregate-loader";
interface DocumentPageProps {
params: Promise<{ id: string }>;
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}
type DocumentMetaPayload = {
id: string;
workspace_id: string;
title: string | null;
updated_at: string | null;
can_edit?: boolean | null;
disable_download?: boolean | null;
disable_copy?: boolean | null;
wide_layout?: boolean | null;
use_small_text?: boolean | null;
show_heading_numbers?: boolean | null;
show_toc?: boolean | null;
show_structure?: boolean | null;
protect_editing?: boolean | null;
show_word_count?: boolean | null;
collapse_backlinks?: boolean | null;
page_font?: PageFont | null;
layout_density?: PageLayoutDensity | null;
hide_child_pages?: boolean | null;
show_block_ref_count?: boolean | null;
embed_default_block_id?: string | null;
word_count?: number | null;
character_count?: number | null;
block_count?: number | null;
todo_total?: number | null;
todo_total_count?: number | null;
todo_done?: number | null;
todo_done_count?: number | null;
};
type DocumentContentPayload = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
page_subtree?: PageSubtreeProjection | null;
};
async function fetchDocumentContentOnServer(input: {
documentId: string;
workspaceId: string;
title?: string | null;
}): Promise<{
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
pageSubtree: PageSubtreeProjection | null;
}> {
try {
const headerList = await headers();
const requestHeaders = new Headers();
[
"cookie",
"authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
].forEach((name) => {
const value = headerList.get(name);
if (value) {
requestHeaders.set(name, value);
}
});
const request = new Request("http://mnote.local/documents/content", {
method: "GET",
headers: requestHeaders,
});
const { client } = await getAuthedConvexClient();
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: input.workspaceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: bridgeContext,
envelope,
});
const result = await executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
const normalized = normalizeDocumentContentResponse({
documentId: input.documentId,
title: input.title ?? null,
payload: result,
});
return normalized;
} catch {
return {
content: null,
revision: null,
conflictDetectionKey: null,
pageSubtree: null,
};
}
}
export default async function DocumentPage({ params, searchParams }: DocumentPageProps) {
const { id } = await params;
const resolvedSearch = (await searchParams) ?? {};
@@ -169,68 +54,19 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
if (isConvexEnabled()) {
const workspaceIdRaw = resolvedSearch?.workspaceId;
const workspaceId = typeof workspaceIdRaw === "string" ? workspaceIdRaw : null;
const result = await fetchDocumentMetaViaBridge<DocumentMetaPayload>({
const loaded = await loadPageAggregateFromNextHeaders({
documentId: id,
workspaceId,
});
const doc = result?.doc;
if (!doc) {
if (!loaded) {
notFound();
}
const readOnly = doc.can_edit === false;
const disableDownload = Boolean(doc.disable_download);
const disableCopy = Boolean(doc.disable_copy);
const initialOptions: PageOptionsState = {
wideLayout: doc.wide_layout ?? false,
smallText: doc.use_small_text ?? false,
showHeadingNumbers: doc.show_heading_numbers ?? true,
showToc: doc.show_toc ?? false,
showStructure: doc.show_structure ?? false,
protectEditing: doc.protect_editing ?? false,
showWordCount: doc.show_word_count ?? true,
collapseBacklinks: doc.collapse_backlinks ?? false,
pageFont: doc.page_font ?? "default",
layoutDensity: doc.layout_density ?? "normal",
hideChildPages: doc.hide_child_pages ?? false,
showBlockRefCount: doc.show_block_ref_count ?? false,
embedDefaultBlockId: doc.embed_default_block_id ?? null,
};
const initialStats: DocumentStats = {
wordCount: doc.word_count ?? 0,
characterCount: doc.character_count ?? 0,
blockCount: doc.block_count ?? 0,
todoTotal: doc.todo_total ?? doc.todo_total_count ?? 0,
todoDone: doc.todo_done ?? doc.todo_done_count ?? 0,
};
const initialDocumentContent = await fetchDocumentContentOnServer({
documentId: doc.id,
workspaceId: doc.workspace_id,
title: doc.title ?? "无标题",
});
const page = buildPageAggregate({
documentId: doc.id,
workspaceId: doc.workspace_id,
title: doc.title ?? "无标题",
updatedAt: doc.updated_at,
readOnly,
disableDownload,
disableCopy,
pageOptions: initialOptions,
content: initialDocumentContent.content,
revision: initialDocumentContent.revision,
conflictDetectionKey: initialDocumentContent.conflictDetectionKey,
pageSubtree: initialDocumentContent.pageSubtree,
stats: initialStats,
});
return (
<div className="flex h-screen flex-col">
<div className="min-h-0 flex-1">
<DocumentShell
page={page}
page={loaded.page}
openTableId={openTableId}
editorHostKind={editorHostKind}
/>
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockFetchDocumentAiOrchestratorConfig = vi.fn();
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: mockIsConvexEnabled,
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
fetchDocumentAiOrchestratorConfig: mockFetchDocumentAiOrchestratorConfig,
}));
describe("/api/ai-agent/document/config route", () => {
beforeEach(() => {
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockFetchDocumentAiOrchestratorConfig.mockReset();
});
it("应返回 orchestrator config 能力面", async () => {
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockFetchDocumentAiOrchestratorConfig.mockResolvedValue({
provider: "online",
defaultModelKey: "gpt-5.4",
defaultProfileId: "page_writer_ai_first",
sessionEnabled: true,
models: [{ key: "gpt-5.4", title: "GPT-5.4" }],
profiles: [{ id: "page_writer_ai_first", title: "AI 主写" }],
tools: [{ name: "doc_get", description: "读取当前页摘要" }],
});
const { GET } = await import("./route");
const response = await GET(new Request("http://127.0.0.1:3000/api/ai-agent/document/config"));
const payload = await response.json();
expect(response.status).toBe(200);
expect(mockFetchDocumentAiOrchestratorConfig).toHaveBeenCalledTimes(1);
expect(payload.defaultModelKey).toBe("gpt-5.4");
expect(payload.defaultProfileId).toBe("page_writer_ai_first");
expect(payload.tools[0]?.name).toBe("doc_get");
});
});
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { fetchDocumentAiOrchestratorConfig } from "@/lib/server/document-ai-orchestrator";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
let userId = "";
if (isConvexEnabled()) {
const { auth } = await getAuthedConvexClient();
userId = auth.userId ?? "";
}
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const payload = await fetchDocumentAiOrchestratorConfig({ request });
return NextResponse.json(payload, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -6,7 +6,9 @@ const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockFetchHermesStructuredToolResultFromMnoteWeb = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockExecuteRustBridgeTool = vi.fn();
const mockStartDocumentAiOrchestratorRun = vi.fn();
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
@@ -30,8 +32,16 @@ vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
streamHermesRunEvents: mockStreamHermesRunEvents,
}));
vi.mock("@/lib/server/mnote-web-hermes", () => ({
fetchHermesStructuredToolResultFromMnoteWeb: mockFetchHermesStructuredToolResultFromMnoteWeb,
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeTool: mockExecuteRustBridgeTool,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
}));
describe("/api/ai-agent/run route", () => {
@@ -42,7 +52,9 @@ describe("/api/ai-agent/run route", () => {
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockFetchHermesStructuredToolResultFromMnoteWeb.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockExecuteRustBridgeTool.mockReset();
mockStartDocumentAiOrchestratorRun.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
@@ -55,7 +67,7 @@ describe("/api/ai-agent/run route", () => {
},
options: {
ai: {
provider: "online",
provider: "hermes",
},
},
});
@@ -65,6 +77,28 @@ describe("/api/ai-agent/run route", () => {
auth: {
userId: "user-1",
},
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
@@ -86,13 +120,19 @@ describe("/api/ai-agent/run route", () => {
output: "已完成",
});
});
mockFetchHermesStructuredToolResultFromMnoteWeb.mockResolvedValue({
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
mockExecuteRustBridgeTool.mockResolvedValue({
plan: {
kind: "tool",
toolName: "slash_run",
},
result: {
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
},
});
@@ -106,13 +146,15 @@ describe("/api/ai-agent/run route", () => {
const text = await response.text();
expect(response.status).toBe(200);
expect(mockFetchHermesStructuredToolResultFromMnoteWeb).toHaveBeenCalledWith(
expect(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
tool: "slash_run",
argsJson: {
toolName: "slash_run",
args: {
text: "/rename doc-1 AI 标题",
},
data: {
source: "ai-agent-route",
},
}),
);
expect(text).toContain("event: tool_result");
@@ -120,4 +162,223 @@ describe("/api/ai-agent/run route", () => {
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
it("文档页 AI 请求应把 pageOptions 带入 Hermes instructions", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "根据当前页面设置调整内容" }],
context: {
documentId: "doc-1",
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-2",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "已完成",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("pageOptions="),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('"wideLayout":true'),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("editorRuntimePageOptions="),
}),
);
});
it("文档页在线流式请求应优先走 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
}),
);
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain("event: ready");
expect(text).toContain("来自 orchestrator");
});
it("文档页在线请求应把 modelKey、profileId 和 sessionId 透传到 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
});
it("orchestrator 失败时应自动回退 Hermes", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成回退标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockRejectedValue(new Error("sidecar down"));
mockStartHermesRun.mockResolvedValue({
runId: "run-fallback",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "Hermes fallback",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
expect(text).toContain("Hermes fallback");
});
});
+116 -22
View File
@@ -17,14 +17,18 @@ import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { fetchHermesStructuredToolResultFromMnoteWeb } from "@/lib/server/mnote-web-hermes";
import { startDocumentAiOrchestratorRun } from "@/lib/server/document-ai-orchestrator";
import { buildDocumentBridgeContext } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes";
type RuntimeProvider = "hermes" | "codex";
type RuntimeProvider = "agents" | "hermes" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
@@ -48,6 +52,7 @@ type RequestPayload = {
mindmapId?: string;
selectedUids?: string[];
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
@@ -59,6 +64,8 @@ type RequestPayload = {
provider?: RawAiProvider;
model?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
};
};
};
@@ -112,11 +119,46 @@ const clampSteps = (raw: unknown) => {
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
};
const normalizeProvider = (raw: unknown): RuntimeProvider => {
const normalizeProvider = (
raw: unknown,
scope: AgentScope,
stream: boolean,
): RuntimeProvider => {
const value = String(raw ?? "").trim().toLowerCase();
if (value === "online" && scope === "document" && stream) {
return "agents";
}
return value === "codex" ? "codex" : "hermes";
};
const proxySseResponse = (upstream: Response) => {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, requestId: makeRunId() })));
const reader = upstream.body?.getReader();
if (!reader) {
controller.close();
return;
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
controller.enqueue(value);
}
}
} finally {
controller.close();
}
},
});
return new Response(body, { headers: sseHeaders });
};
const normalizeScope = (payload: RequestPayload): AgentScope => {
const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") {
@@ -192,6 +234,22 @@ const buildHermesInstructions = (
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
}
if (payload.context?.pageOptions !== undefined) {
lines.push(
serializeContextSnapshot(
"pageOptions",
payload.context.pageOptions,
2000,
) ?? "pageOptions=provided",
);
lines.push(
serializeContextSnapshot(
"editorRuntimePageOptions",
pickLeptosTiptapRuntimePageOptions(payload.context.pageOptions),
1200,
) ?? "editorRuntimePageOptions=provided",
);
}
if (attachments.length > 0) {
lines.push(
[
@@ -237,6 +295,9 @@ const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
? T["client"]
: never;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
@@ -263,24 +324,41 @@ const recoverStructuredHermesToolResult = async (input: {
return null;
}
return fetchHermesStructuredToolResultFromMnoteWeb({
return buildDocumentBridgeContext({
request: input.request,
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data,
requestId: input.fallbackRequestId,
traceId: input.fallbackTraceId,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}).catch(() => null);
workspaceId: null,
})
.then((context) =>
executeRustBridgeTool({
context: {
...context,
requestId: context.requestId || input.fallbackRequestId,
traceId: context.traceId || input.fallbackTraceId,
actor: {
...context.actor,
actorId: input.userId,
},
},
toolName: input.tool,
invocationKind: "command",
args: input.argsJson,
data: (data && typeof data === "object" && !Array.isArray(data) ? data : { data }) as Record<
string,
unknown
>,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}),
)
.then((result) => result.result)
.catch(() => null);
};
const streamHermesLegacyEvents = async ({
@@ -619,13 +697,29 @@ export async function POST(request: Request) {
return errorResponses.unauthorized();
}
const provider = normalizeProvider(payload.options?.ai?.provider);
const stream = payload.stream !== false;
const scope = normalizeScope(payload);
const provider = normalizeProvider(payload.options?.ai?.provider, scope, stream);
if (provider === "codex") {
return await runCodexBridge({ payload, request, stream });
}
const scope = normalizeScope(payload);
if (provider === "agents") {
try {
const upstream = await startDocumentAiOrchestratorRun({
request,
userId,
payload,
});
return proxySseResponse(upstream);
} catch (error) {
console.warn(
"document ai orchestrator 不可用,回退 Hermes",
error instanceof Error ? error.message : String(error),
);
}
}
const maxSteps = clampSteps(payload.maxSteps);
const instructions = buildHermesInstructions(payload, userId, scope, maxSteps);
const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null;
@@ -9,9 +9,10 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeMetadataBridgeCommand,
type DocumentOptionsUpdatePayload,
} from "@/lib/documents/metadata-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
type OptionsPayload = {
documentId: string;
@@ -31,7 +32,7 @@ export async function POST(request: Request) {
workspaceId: normalizedWorkspaceId,
});
const envelope = buildDocumentCommandEnvelope({
name: "documents.options.update",
name: PAGE_COMMAND_NAMES.updateLayout,
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
@@ -43,7 +44,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeMetadataBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(request: Request) {
if (isConvexEnabled()) {
try {
const url = new URL(request.url);
const documentId = assertDocumentId(url.searchParams.get("documentId"));
const workspaceId = url.searchParams.get("workspaceId")?.trim() || null;
const loaded = await loadPageAggregate({
request,
documentId,
workspaceId,
});
if (!loaded) {
return NextResponse.json(
{
error: "页面不存在",
meta: {
requestId: "unknown",
traceId: "unknown",
queryName: "documents.page.get",
},
},
{ status: 404 },
);
}
return NextResponse.json({
page: loaded.page,
meta: loaded.bridge,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -4,6 +4,28 @@ vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
@@ -12,11 +34,52 @@ vi.mock("@/lib/documents/page-command-adapter", () => ({
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
}));
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
executePageWriteBridgeCommand: vi.fn(async () => ({
requestId: "req_1",
traceId: "trace_1",
commandId: "cmd_1",
commandName: "page.command",
revision: 1,
conflictDetectionKey: "doc_1:1",
})),
}));
vi.mock("@/lib/documents/page-aggregate-loader", () => ({
loadPageAggregate: vi.fn(async () => ({
page: {
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "页面标题",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: null, revision: 0, conflictDetectionKey: "doc_1:0" },
tree: { pageSubtree: null },
stats: null,
},
bridge: {
requestId: "req_1",
traceId: "trace_1",
queryName: "documents.page.get",
},
})),
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postEmbed } from "@/app/api/documents/embed/route";
import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postPurge } from "@/app/api/documents/purge/route";
import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import {
executeDocumentCreateChildBridgeCommand,
executeDocumentEmbedBridgeCommand,
@@ -24,6 +87,8 @@ import {
executeDocumentEmptyTrashBridgeCommand,
executeDocumentPurgeBridgeCommand,
} from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
describe("documents route adapters", () => {
it("creates child route delegates to unified adapter", async () => {
@@ -65,4 +130,46 @@ describe("documents route adapters", () => {
}));
expect(executeDocumentPurgeBridgeCommand).toHaveBeenCalled();
});
it("page route delegates to unified aggregate loader", async () => {
const response = await getPage(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { identity: { documentId: string } };
meta: { queryName: string };
};
expect(loadPageAggregate).toHaveBeenCalled();
expect(response.status).toBe(200);
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route delegates to unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
it("options route delegates to unified page write adapter", async () => {
await postOptions(new Request("http://localhost/api/documents/options", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", options: { wideLayout: true } }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
it("save route delegates to unified page write adapter", async () => {
await postSave(new Request("http://localhost/api/documents/save", {
method: "POST",
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", content: [] }),
}));
expect(executePageWriteBridgeCommand).toHaveBeenCalled();
});
});
@@ -6,11 +6,12 @@ import {
buildDocumentCommandEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { executeSaveBridgeCommand } from "@/lib/documents/save-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import {
buildDocumentSavePayload,
type DocumentSavePayload,
} from "@/lib/documents/save-contract";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
export async function POST(request: Request) {
if (isConvexEnabled()) {
@@ -31,7 +32,7 @@ export async function POST(request: Request) {
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.save",
name: PAGE_COMMAND_NAMES.saveBody,
payload: payload satisfies DocumentSavePayload,
context: bridgeContext,
target: {
@@ -39,7 +40,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeSaveBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -8,9 +8,10 @@ import {
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeMetadataBridgeCommand,
type DocumentTitleUpdatePayload,
} from "@/lib/documents/metadata-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
interface RenamePayload {
documentId: string;
@@ -27,7 +28,7 @@ export async function POST(request: Request) {
const normalizedWorkspaceId = workspaceId?.trim() || null;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({
name: "documents.title.update",
name: PAGE_COMMAND_NAMES.updateTitle,
payload: {
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
@@ -39,7 +40,7 @@ export async function POST(request: Request) {
pageId: normalizedDocumentId,
},
});
const result = await executeMetadataBridgeCommand({
const result = await executePageWriteBridgeCommand({
context: bridgeContext,
envelope,
});
@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockBuildMnoteWebForwardHeaders = vi.fn();
const mockBuildMnoteWebStreamUrl = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockBuildDocumentQueryEnvelope = vi.fn();
const mockExecuteRustBridgeQueryTransport = vi.fn();
const mockResolveRustBridgeQueryPlan = vi.fn();
const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
Response.json(
{
@@ -11,41 +14,99 @@ const mockDocumentBridgeErrorResponse = vi.fn((error: unknown) =>
),
);
vi.mock("@/lib/server/mnote-web", () => ({
buildMnoteWebForwardHeaders: mockBuildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl: mockBuildMnoteWebStreamUrl,
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
buildDocumentQueryEnvelope: mockBuildDocumentQueryEnvelope,
documentBridgeErrorResponse: mockDocumentBridgeErrorResponse,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeQueryTransport: mockExecuteRustBridgeQueryTransport,
resolveRustBridgeQueryPlan: mockResolveRustBridgeQueryPlan,
}));
describe("/api/mnote-web/stream route", () => {
beforeEach(() => {
mockBuildMnoteWebForwardHeaders.mockReset();
mockBuildMnoteWebStreamUrl.mockReset();
mockGetAuthedConvexClient.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockBuildDocumentQueryEnvelope.mockReset();
mockExecuteRustBridgeQueryTransport.mockReset();
mockResolveRustBridgeQueryPlan.mockReset();
mockDocumentBridgeErrorResponse.mockClear();
vi.unstubAllGlobals();
});
it("透传上游 SSE 并去掉 set-cookie", async () => {
const upstreamHeaders = new Headers({
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
"set-cookie": "secret=1",
it("直接在 3000 内生成 snapshot SSE,不再回源 mnote-web", async () => {
mockGetAuthedConvexClient.mockResolvedValue({
client: { query: vi.fn() },
});
const upstreamResponse = new Response("event: snapshot\ndata: {\"ok\":true}\n\n", {
status: 200,
headers: upstreamHeaders,
mockBuildDocumentBridgeContext.mockResolvedValue({
requestId: "req_stream_1",
traceId: "trace_stream_1",
workspaceId: "ws_1",
});
mockBuildMnoteWebForwardHeaders.mockResolvedValue(new Headers({ cookie: "a=1" }));
mockBuildMnoteWebStreamUrl.mockReturnValue(
new URL("http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1"),
);
const fetchMock = vi.fn(async () => upstreamResponse);
vi.stubGlobal("fetch", fetchMock);
mockBuildDocumentQueryEnvelope
.mockReturnValueOnce({
name: "sidebar.dataset.list",
payload: { workspaceId: "ws_1" },
})
.mockReturnValueOnce({
name: "bridge.workspace.overview",
payload: {
workspaceId: "ws_1",
limit: 20,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
mockResolveRustBridgeQueryPlan
.mockResolvedValueOnce({ kind: "query", functionName: "sidebar:datasetList", argsJson: { workspaceId: "ws_1" } })
.mockResolvedValueOnce({ kind: "query", functionName: "bridgeLogs:listWorkspaceOverview", argsJson: { workspaceId: "ws_1" } });
mockExecuteRustBridgeQueryTransport
.mockResolvedValueOnce({
active_workspace_id: "ws_1",
workspaces: [],
documents: [
{
id: "page_root",
workspace_id: "ws_1",
title: "工作区首页",
parent_id: null,
sort_order: 0,
is_starred: true,
is_template: false,
created_at: "2026-04-22T00:00:00Z",
updated_at: "2026-04-22T00:00:00Z",
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
})
.mockResolvedValueOnce({
workspace_id: "ws_1",
command_logs: [],
domain_events: [],
counts: { command_logs: 0, domain_events: 0 },
filters: null,
next_cursor: null,
has_more: false,
generated_at: "2026-04-22T00:00:00Z",
});
const { GET } = await import("./route");
const response = await GET(
@@ -55,19 +116,15 @@ describe("/api/mnote-web/stream route", () => {
}),
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3104/api/stream/events?stream=workspace&projection=sidebar_tree&workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
redirect: "follow",
headers: expect.any(Headers),
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("set-cookie")).toBeNull();
await expect(response.text()).resolves.toContain("event: snapshot");
const text = await response.text();
expect(text).toContain("event: snapshot");
expect(text).toContain('"kind":"snapshot"');
expect(text).toContain('"projection":"sidebar_tree"');
expect(text).toContain('"workspaceId":"ws_1"');
expect(text).toContain('"activeWorkspaceId":"ws_1"');
expect(mockResolveRustBridgeQueryPlan).toHaveBeenCalledTimes(2);
expect(mockExecuteRustBridgeQueryTransport).toHaveBeenCalledTimes(2);
});
});
@@ -1,11 +1,21 @@
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildMnoteWebForwardHeaders,
buildMnoteWebStreamUrl,
} from "@/lib/server/mnote-web";
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { mapSidebarDatasetListQueryResultToInitialData } from "@/lib/sidebar-data";
export const dynamic = "force-dynamic";
function toSseFrame(event: string, data: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(data ?? null)}\n\n`;
}
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
@@ -16,26 +26,73 @@ export async function GET(request: Request) {
return Response.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const targetUrl = buildMnoteWebStreamUrl({ workspaceId, cursor });
const headers = await buildMnoteWebForwardHeaders(request);
headers.set("accept", "text/event-stream");
headers.set("x-mnote-workspace-id", workspaceId);
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "follow",
const { client } = await getAuthedConvexClient();
const context = await buildDocumentBridgeContext({
request,
workspaceId,
});
const responseHeaders = new Headers(upstream.headers);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
const sidebarEnvelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const sidebarPlan = await resolveRustBridgeQueryPlan({
context,
envelope: sidebarEnvelope,
});
const sidebarDataset = await executeRustBridgeQueryTransport({
client,
plan: sidebarPlan,
});
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
const overviewEnvelope = buildDocumentQueryEnvelope({
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 20,
cursor,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
},
});
const overviewPlan = await resolveRustBridgeQueryPlan({
context,
envelope: overviewEnvelope,
});
const overview = await executeRustBridgeQueryTransport({
client,
plan: overviewPlan,
});
const payload = {
kind: "snapshot",
stream: "workspace",
projection: "sidebar_tree",
workspaceId,
rootNodeId: null,
cursor,
requestId: context.requestId,
traceId: context.traceId,
data: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
snapshot: {
dataset: sidebarDataset,
tree: sidebarDataset.kernel_sidebar_projection ?? sidebarDataset.kernelSidebarProjection ?? null,
},
overview,
};
return new Response(toSseFrame("snapshot", payload), {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
});
} catch (error) {
return documentBridgeErrorResponse(error);
+2 -2
View File
@@ -3,7 +3,7 @@ import "./globals.css";
import { QueryProvider } from "@/components/providers/query-provider";
import { ConvexClientProvider } from "@/components/providers/convex-provider";
import { AppPreferencesHydrator } from "@/components/providers/app-preferences-hydrator";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { getMnotePublicRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
@@ -20,7 +20,7 @@ export default async function RootLayout({
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const useConvex = isConvexEnabled();
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
const runtimeConfig = { ...getMnotePublicRuntimeConfig(), isDesktop, useConvex };
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
return (
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from "vitest";
import { readAiPanelPrefs } from "./panelShared";
describe("panelShared 在线模型真源", () => {
beforeEach(() => {
window.localStorage.clear();
});
it("读取在线偏好时不应再把旧 model 字段回退成 modelKey", () => {
window.localStorage.setItem("doc_ai_provider", "online");
window.localStorage.setItem("doc_ai_model", "gpt-5.4");
const prefs = readAiPanelPrefs("doc_ai", {
provider: "online",
model: "",
modelKey: "",
profileId: "",
maxSteps: 10,
});
expect(prefs.model).toBe("gpt-5.4");
expect(prefs.modelKey).toBe("");
});
it("读取在线偏好时应优先使用显式存储的 modelKey", () => {
window.localStorage.setItem("doc_ai_provider", "online");
window.localStorage.setItem("doc_ai_model", "legacy-model");
window.localStorage.setItem("doc_ai_model_key", "gpt-5.3-codex");
const prefs = readAiPanelPrefs("doc_ai", {
provider: "online",
model: "",
modelKey: "",
profileId: "",
maxSteps: 10,
});
expect(prefs.model).toBe("legacy-model");
expect(prefs.modelKey).toBe("gpt-5.3-codex");
});
});
@@ -5,6 +5,8 @@ export type AiProvider = "online" | "local" | "ollama" | "codex";
export type AiPanelPrefs = {
provider: AiProvider;
model: string;
modelKey?: string;
profileId?: string;
maxSteps: number;
};
@@ -51,6 +53,8 @@ export const readAiPanelPrefs = (
try {
const providerRaw = (window.localStorage.getItem(`${storageKeyPrefix}_provider`) || "").trim();
const model = window.localStorage.getItem(`${storageKeyPrefix}_model`) || defaults.model;
const modelKey = window.localStorage.getItem(`${storageKeyPrefix}_model_key`) || defaults.modelKey || "";
const profileId = window.localStorage.getItem(`${storageKeyPrefix}_profile_id`) || defaults.profileId || "";
const stepsRaw = window.localStorage.getItem(`${storageKeyPrefix}_max_steps`) || "";
const parsedSteps = Number(stepsRaw);
@@ -62,6 +66,8 @@ export const readAiPanelPrefs = (
return {
provider,
model,
modelKey,
profileId,
maxSteps: Number.isFinite(parsedSteps) ? Math.floor(parsedSteps) : defaults.maxSteps,
};
} catch {
@@ -75,6 +81,8 @@ export const writeAiPanelPrefs = (storageKeyPrefix: string, prefs: AiPanelPrefs)
try {
window.localStorage.setItem(`${storageKeyPrefix}_provider`, prefs.provider);
window.localStorage.setItem(`${storageKeyPrefix}_model`, prefs.model);
window.localStorage.setItem(`${storageKeyPrefix}_model_key`, prefs.modelKey || "");
window.localStorage.setItem(`${storageKeyPrefix}_profile_id`, prefs.profileId || "");
window.localStorage.setItem(`${storageKeyPrefix}_max_steps`, String(prefs.maxSteps));
} catch {
// ignore
@@ -87,10 +87,29 @@ describe("applyDocWriteToolResultToPageBody", () => {
data: [{ id: "block_1", type: "paragraph", content: "AI 正文" }],
},
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
getLatestPageAggregateSnapshot: () => ({
blocks: [{ id: "block_0", type: "paragraph", content: "旧正文" }] as Json,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
}),
applyEditorSnapshot,
onPersistedMetaChange,
@@ -121,10 +140,76 @@ describe("applyDocWriteToolResultToPageBody", () => {
ok: true,
result: { data: [] },
documentId: "doc-1",
getLatestPersistedMeta: () => ({
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
getLatestPageAggregateSnapshot: () => ({
blocks: null,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
applyPageBodyCommandImpl,
}),
).resolves.toBe(false);
expect(applyPageBodyCommandImpl).not.toHaveBeenCalled();
});
it("doc 写工具返回非 legacy blocks 数组时应直接忽略", async () => {
const applyPageBodyCommandImpl = vi.fn();
await expect(
applyDocWriteToolResultToPageBody({
tool: "doc_replace_range",
ok: true,
result: {
data: {
documentId: "doc-1",
blocks: [],
},
},
documentId: "doc-1",
getLatestPageAggregateSnapshot: () => ({
blocks: null,
pageSubtree: null,
persistedMeta: {
workspaceId: "ws-1",
revision: 5,
conflictDetectionKey: "doc-1:5",
},
pageOptions: {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
},
}),
applyEditorSnapshot: vi.fn(),
onPersistedMetaChange: vi.fn(),
@@ -14,13 +14,13 @@ import { Textarea } from "@/components/ui/textarea";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { clamp, MIN_AGENT_STEPS, MAX_AGENT_STEPS } from "@/lib/constants";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
applyPageBodyCommand,
type ApplyPageBodyCommandInput,
executePageBodyCommand,
type ExecutePageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-body-command";
} from "@/lib/documents/page-command-client";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageAggregateAiSnapshot } from "@/components/editor/DocumentAiAgentPanel";
type AgentMessage = { role: "user" | "assistant"; content: string };
type CodexMode = "chat" | "test" | "dev";
@@ -164,10 +164,10 @@ export async function applyDocWriteToolResultToPageBody(input: {
ok: boolean;
result: unknown;
documentId: string;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
applyPageBodyCommandImpl?: (input: ApplyPageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
applyPageBodyCommandImpl?: (input: ExecutePageBodyCommandInput) => Promise<PageBodyPersistedMeta>;
}): Promise<boolean> {
if (!input.ok || (input.tool !== "doc_insert_blocks" && input.tool !== "doc_replace_range")) {
return false;
@@ -175,12 +175,12 @@ export async function applyDocWriteToolResultToPageBody(input: {
const resultRecord =
input.result && typeof input.result === "object" ? (input.result as Record<string, unknown>) : null;
const dataNode = resultRecord && "data" in resultRecord ? resultRecord.data : null;
if (dataNode == null) {
if (!Array.isArray(dataNode)) {
return false;
}
const latestPersistedMeta = input.getLatestPersistedMeta();
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? applyPageBodyCommand;
const latestPersistedMeta = input.getLatestPageAggregateSnapshot().persistedMeta;
const applyPageBodyCommandImpl = input.applyPageBodyCommandImpl ?? executePageBodyCommand;
const persistedMeta = await applyPageBodyCommandImpl({
documentId: input.documentId,
workspaceId: latestPersistedMeta.workspaceId,
@@ -233,16 +233,12 @@ const safeJsonStringify = (value: unknown) => {
export function DocumentAiAgentPanelRuntime({
documentId,
getLatestBlocks,
getLatestPageSubtree,
getLatestPersistedMeta,
getLatestPageAggregateSnapshot,
onPersistedMetaChange,
onPageHeadTitleChange,
}: {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
}) {
@@ -260,7 +256,12 @@ export function DocumentAiAgentPanelRuntime({
const [maxSteps, setMaxSteps] = useState<number>(10);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [aiModelKey, setAiModelKey] = useState<string>("");
const [aiProfileId, setAiProfileId] = useState<string>("");
const [page, setPage] = useState<PanelPage>("chat");
const [onlineConfig, setOnlineConfig] = useState<DocumentAiCapabilityConfig | null>(null);
const [onlineConfigLoading, setOnlineConfigLoading] = useState(false);
const [onlineConfigError, setOnlineConfigError] = useState<string>("");
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [sessions, setSessions] = useState<ChatSession[]>([]);
@@ -283,25 +284,77 @@ export function DocumentAiAgentPanelRuntime({
}, [flightMode]);
useEffect(() => {
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", maxSteps: 10 });
const prefs = readAiPanelPrefs("doc_ai", { provider: "online", model: "", modelKey: "", profileId: "", maxSteps: 10 });
setMaxSteps(clamp(prefs.maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS));
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setAiModelKey(prefs.modelKey || "");
setAiProfileId(prefs.profileId || "");
}, []);
useEffect(() => {
writeAiPanelPrefs("doc_ai", {
provider: aiProvider,
model: aiModel,
modelKey: aiModelKey,
profileId: aiProfileId,
maxSteps: clamp(maxSteps, MIN_AGENT_STEPS, MAX_AGENT_STEPS),
});
}, [aiModel, aiProvider, maxSteps]);
}, [aiModel, aiModelKey, aiProfileId, aiProvider, maxSteps]);
useEffect(() => {
// 关闭面板时,回到对话页,避免下次打开还停留在设置/历史等子页
if (!open) setPage("chat");
}, [open]);
useEffect(() => {
if (aiProvider !== "online") return;
let cancelled = false;
setOnlineConfigLoading(true);
setOnlineConfigError("");
void fetch("/api/ai-agent/document/config", {
method: "GET",
cache: "no-store",
})
.then(async (response) => {
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
throw new Error(String(payload?.error ?? `读取页面 AI 配置失败:${response.status}`));
}
return (await response.json()) as DocumentAiCapabilityConfig;
})
.then((payload) => {
if (cancelled) return;
setOnlineConfig(payload);
setAiModelKey((prev) => {
const picked = prev.trim();
if (picked && payload.models.some((item) => item.key === picked)) {
return picked;
}
return payload.defaultModelKey || picked;
});
setAiProfileId((prev) => {
const picked = prev.trim();
if (picked && payload.profiles.some((item) => item.id === picked)) {
return picked;
}
return payload.defaultProfileId || picked;
});
})
.catch((error) => {
if (cancelled) return;
setOnlineConfigError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
if (!cancelled) {
setOnlineConfigLoading(false);
}
});
return () => {
cancelled = true;
};
}, [aiProvider]);
// 会话/历史:按 documentId 隔离持久化
useEffect(() => {
try {
@@ -414,6 +467,18 @@ export function DocumentAiAgentPanelRuntime({
const currentSession = useMemo(() => sessions.find((s) => s.id === activeSessionId) ?? null, [activeSessionId, sessions]);
const currentSessionTitle = currentSession?.title || "新会话";
const selectedOnlineModel = useMemo(
() => onlineConfig?.models.find((item) => item.key === aiModelKey) ?? null,
[aiModelKey, onlineConfig],
);
const selectedOnlineProfile = useMemo(
() => onlineConfig?.profiles.find((item) => item.id === aiProfileId) ?? null,
[aiProfileId, onlineConfig],
);
const onlineSessionId = useMemo(() => {
if (aiProvider !== "online" || !activeSessionId || !documentId) return null;
return `doc_ai:${documentId}:${activeSessionId}`;
}, [activeSessionId, aiProvider, documentId]);
const [codexSessionDraft, setCodexSessionDraft] = useState("");
useEffect(() => {
@@ -578,10 +643,11 @@ export function DocumentAiAgentPanelRuntime({
const payloadMessagesForRequest = payloadMessages;
const blocks = getLatestBlocks();
const pageAggregateSnapshot = getLatestPageAggregateSnapshot();
const blocks = pageAggregateSnapshot.blocks;
const blocksJson = blocks ? safeJsonStringify(blocks) : "";
const shouldSendBlocks = blocksJson && blocksJson.length <= 500_000;
const pageSubtree = getLatestPageSubtree();
const pageSubtree = pageAggregateSnapshot.pageSubtree;
const contextNode = pageSubtree?.rootNode ?? null;
const contextSubtree = pageSubtree
? {
@@ -596,6 +662,16 @@ export function DocumentAiAgentPanelRuntime({
const codexSessionIdForRequest =
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
const onlineModelKeyForRequest =
aiProvider === "online"
? String(aiModelKey || onlineConfig?.defaultModelKey || "")
.trim() || null
: null;
const onlineProfileIdForRequest =
aiProvider === "online"
? String(aiProfileId || onlineConfig?.defaultProfileId || "")
.trim() || null
: null;
if (aiProvider === "codex" && activeSessionId) {
setSessions((prev) =>
@@ -638,13 +714,17 @@ export function DocumentAiAgentPanelRuntime({
subtree: contextSubtree,
outline: contextOutline,
evidence: contextEvidence,
pageOptions: pageAggregateSnapshot.pageOptions,
},
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
...(aiProvider === "online" && onlineSessionId ? { sessionId: onlineSessionId } : {}),
...(aiProvider === "online" && onlineModelKeyForRequest ? { modelKey: onlineModelKeyForRequest } : {}),
...(aiProvider === "online" && onlineProfileIdForRequest ? { profileId: onlineProfileIdForRequest } : {}),
...(aiProvider !== "codex" && aiProvider !== "online" && aiModel.trim() ? { model: aiModel.trim() } : {}),
},
},
}),
@@ -723,7 +803,7 @@ export function DocumentAiAgentPanelRuntime({
ok: Boolean(obj.ok),
result,
documentId,
getLatestPersistedMeta,
getLatestPageAggregateSnapshot,
applyEditorSnapshot: editorBridge?.replaceWithSnapshot
? (blocks) => {
editorBridge.replaceWithSnapshot(blocks);
@@ -1054,47 +1134,76 @@ export function DocumentAiAgentPanelRuntime({
{page === "tools" ? (
<ScrollArea className="h-full">
<div className="space-y-3 p-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
{aiProvider === "online" ? (
<>
<div className="text-xs text-muted-foreground">
online mindmap
</div>
</div>
<div className="space-y-2">
{(onlineConfig?.tools || []).map((tool) => (
<div key={tool.name} className="rounded border bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{tool.title}</div>
<code className="rounded bg-muted px-1 py-0.5 text-xs">{tool.name}</code>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.mode}</span>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.scope}</span>
<span className="rounded border px-1.5 py-0.5 text-xs">{tool.status}</span>
</div>
<div className="mt-2 text-xs text-muted-foreground">{tool.description}</div>
</div>
))}
{onlineConfigLoading ? <div className="text-xs text-muted-foreground"></div> : null}
{!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : null}
{onlineConfigError ? <div className="text-xs text-red-600">{onlineConfigError}</div> : null}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">
AI ToolSet
</div>
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={`inline-flex items-center gap-1 rounded border px-2 py-1 ${toolAuto ? "bg-white" : "bg-muted"}`}
onClick={() => setToolAuto((v) => !v)}
disabled={loading}
title="工具自动/手动"
>
<Settings2 className="h-3.5 w-3.5" />
{toolAuto ? "自动工具" : "手动工具"}
</button>
</div>
{!toolAuto ? (
<div>
<div className="mb-2 text-xs text-muted-foreground">使</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => {
const on = selectedTools.includes(t);
return (
<button
key={t}
type="button"
className={`rounded border px-2 py-1 text-xs ${on ? "bg-[#111827] text-white" : "bg-white"}`}
onClick={() =>
setSelectedTools((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
)
}
disabled={loading}
>
{TOOL_LABEL[t]}
</button>
);
})}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground">
AI ToolSet
</div>
)}
</>
)}
</div>
</ScrollArea>
@@ -1254,6 +1363,22 @@ export function DocumentAiAgentPanelRuntime({
VSCode Codex CLI <code className="rounded bg-muted px-1 py-0.5">#dev</code> SessionId
</div>
</div>
) : aiProvider === "online" ? (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
<select
className="h-9 min-w-[260px] rounded border bg-white px-2 text-sm"
value={aiModelKey}
onChange={(e) => setAiModelKey(String(e.target.value || "").trim())}
disabled={loading || onlineConfigLoading || !onlineConfig}
>
{onlineConfig?.models.map((item) => (
<option key={item.key} value={item.key}>
{item.title} · {item.key}
</option>
))}
</select>
</>
) : (
<>
<label className="ml-2 text-xs text-muted-foreground"></label>
@@ -1277,12 +1402,35 @@ export function DocumentAiAgentPanelRuntime({
disabled={loading}
/>
)}
<datalist id="doc-ai-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</>
)}
</div>
{aiProvider === "online" ? (
<div className="space-y-2 rounded border bg-muted/30 p-3 text-xs text-muted-foreground">
<div>
线 provider OmniRouteBase URL
<code className="ml-1 rounded bg-muted px-1 py-0.5">{onlineConfig?.baseUrl || "http://localhost:20128/v1"}</code>
</div>
<div>
<code className="ml-1 rounded bg-muted px-1 py-0.5">{selectedOnlineModel?.key || "未选择"}</code>
{" · "}
Combo
<code className="ml-1 rounded bg-muted px-1 py-0.5">{selectedOnlineModel?.resolvedCombo || "由网关运行时解析"}</code>
</div>
<div>
Runtime Model
<code className="ml-1 rounded bg-muted px-1 py-0.5">
{selectedOnlineModel?.resolvedRuntimeModel || "由网关运行时解析"}
</code>
</div>
<div>
Runtime Model AI OmniRoute runtime model provider
</div>
{onlineConfigLoading ? <div> AI </div> : null}
{onlineConfigError ? <div className="text-red-600">{onlineConfigError}</div> : null}
</div>
) : null}
<div className="text-xs text-muted-foreground">
线/ BaseURL Key / provider model Ollama 使 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>。
</div>
@@ -1323,6 +1471,62 @@ export function DocumentAiAgentPanelRuntime({
/>
</label>
</div>
{aiProvider === "online" ? (
<div className="space-y-3 rounded border bg-white p-3">
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-muted-foreground">Soul / Profile</label>
<select
className="h-9 min-w-[240px] rounded border bg-white px-2 text-sm"
value={aiProfileId}
onChange={(e) => setAiProfileId(String(e.target.value || "").trim())}
disabled={loading || onlineConfigLoading || !onlineConfig}
>
{onlineConfig?.profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.title}
</option>
))}
</select>
</div>
<div className="text-xs text-muted-foreground">
{selectedOnlineProfile?.description || "当前未读取到 profile 描述。"}
</div>
<div className="text-xs text-muted-foreground">
Profile registry
</div>
<div className="text-xs text-muted-foreground">
<code className="ml-1 rounded bg-muted px-1 py-0.5">
{onlineConfig?.sessionEnabled ? `已启用 · ${onlineSessionId || "等待会话初始化"}` : "未启用"}
</code>
</div>
<div className="space-y-2 rounded border bg-muted/20 p-3">
<div className="text-xs font-medium text-foreground"> scope </div>
<div className="text-xs text-muted-foreground">
online tool registry mindmap
</div>
{onlineConfigLoading ? <div className="text-xs text-muted-foreground"></div> : null}
{!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? (
<div className="text-xs text-muted-foreground"></div>
) : null}
{onlineConfigError ? <div className="text-xs text-red-600">{onlineConfigError}</div> : null}
<div className="flex flex-wrap gap-2">
{(onlineConfig?.tools || []).map((tool) => (
<div key={tool.name} className="rounded border bg-white px-2 py-1 text-xs">
<div className="font-medium">{tool.title}</div>
<div className="text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">{tool.name}</code>
{" · "}
{tool.mode}
{" / "}
{tool.scope}
</div>
</div>
))}
</div>
</div>
</div>
) : null}
<div className="text-xs text-muted-foreground"></div>
</div>
</ScrollArea>
@@ -2,16 +2,26 @@
import dynamic from "next/dynamic";
import { useEffect } from "react";
import type { PageBodyPersistedMeta } from "@/lib/documents/page-body-command";
import type { PageOptionsState } from "@/types/page-options";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageBodyPersistedMeta, PageBodyPersistedState } from "@/lib/documents/page-body-command";
import type { Json } from "@/types/supabase";
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
export type PageAggregateAiSnapshot = {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
};
pageOptions: PageOptionsState;
};
type DocumentAiAgentPanelProps = {
documentId: string;
getLatestBlocks: () => Json | null;
getLatestPageSubtree: () => PageSubtreeProjection | null;
getLatestPersistedMeta: () => PageBodyPersistedState;
getLatestPageAggregateSnapshot: () => PageAggregateAiSnapshot;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
onPageHeadTitleChange?: (title: string) => void;
};
@@ -35,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { BlockNoteEditorProps } from "@/components/editor/editor-host-types";
@@ -306,48 +307,19 @@ export function BlockNoteEditor({
try {
setSaveError(null);
const blockCount = Array.isArray(content) ? content.length : null;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
),
});
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && typeof payload.error === "string") {
message = payload.error;
}
} catch {
// ignore
}
setSaveError(message);
throw new Error(message);
}
const payload = await response.json() as {
revision?: number | null;
conflictDetectionKey?: string | null;
};
const nextRevision =
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const nextConflictDetectionKey =
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
);
revisionRef.current = persistedMeta.revision ?? null;
conflictDetectionKeyRef.current = persistedMeta.conflictDetectionKey ?? null;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -1,7 +1,16 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
@@ -21,14 +30,17 @@ import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view";
import { emitDocumentsChanged } from "@/lib/events";
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { extractPageBlocks } from "@/lib/documents/page-subtree";
import {
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
import {
executePageHeadCommand,
executePageLayoutCommand,
type PageBodyPersistedMeta,
} from "@/lib/documents/page-command-client";
import { EditorHost } from "@/components/editor/editor-host";
import {
DEFAULT_EDITOR_HOST_KIND,
@@ -41,6 +53,12 @@ import type {
} from "@/components/editor/editor-host-types";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { usePageHeadTitle } from "@/components/editor/use-page-head-title";
import {
createPageAggregateClientState,
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
} from "@/components/editor/page-aggregate-client-state";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -95,21 +113,6 @@ export interface DocumentContentProps {
editorHostKind?: EditorHostKind;
}
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
const EDITOR_UNMOUNT_GRACE_MS = 1000;
const FALLBACK_TRIGGER_HISTORY_LIMIT = 20;
@@ -143,16 +146,17 @@ export function DocumentContent({
const readOnly = page.head.permissions.readOnly;
const disableDownload = page.head.permissions.disableDownload;
const disableCopy = page.head.permissions.disableCopy;
const initialOptions = page.layout.pageOptions;
const initialContent = page.body.content;
const initialContentRevision = page.body.revision;
const initialConflictDetectionKey = page.body.conflictDetectionKey;
const initialPageSubtree = page.tree.pageSubtree;
const initialStats = page.stats;
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly;
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [pageClientState, dispatchPageClientState] = useReducer(
pageAggregateClientStateReducer,
page,
createPageAggregateClientState,
);
const options = pageClientState.options;
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
@@ -171,14 +175,9 @@ export function DocumentContent({
documentId,
fallbackTitle: initialTitle,
});
const [content, setContent] = useState<unknown>(initialContent);
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
initialPageSubtree,
);
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(initialTitle);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const content = pageClientState.content;
const contentRevision = pageClientState.contentRevision;
const conflictDetectionKey = pageClientState.conflictDetectionKey;
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
@@ -353,33 +352,23 @@ export function DocumentContent({
}, [documentId, editorBridge, openTableId, router]);
useEffect(() => {
setServerPageSubtreeTitle(committedPageTitle);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: committedPageTitle,
});
}, [committedPageTitle]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
}, [initialPageSubtree]);
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
dispatchPageClientState({
type: "hydrate_from_page",
page,
});
}, [page]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
useEffect(() => {
setContentRevision(initialContentRevision);
}, [initialContentRevision]);
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => {
setServerContentSnapshot(initialContent);
}, [initialContent]);
useEffect(() => {
const nextBlocks = extractPageBlocks(content);
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
@@ -424,7 +413,10 @@ export function DocumentContent({
const load = async () => {
setContentError(null);
setContentLoading(initialContent == null);
setContent(initialContent);
dispatchPageClientState({
type: "hydrate_from_page",
page,
});
setShowContentLoadingIndicator(false);
if (initialContent != null) {
@@ -443,7 +435,7 @@ export function DocumentContent({
try {
const response = await fetch(
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{
method: "GET",
credentials: "include",
@@ -455,31 +447,31 @@ export function DocumentContent({
throw new Error(payload?.error ?? "加载页面内容失败");
}
const payload = (await response.json()) as {
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
page?: PageAggregateProjection;
};
const reloadedPage = payload.page ?? null;
const reloadedBody = reloadedPage?.body ?? null;
if (canceled) return;
setContent(payload.content ?? null);
setServerContentSnapshot(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0,
);
setConflictDetectionKey(
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: `${documentId}:0`,
);
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
setServerPageSubtreeTitle(
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: committedPageTitle,
);
if (reloadedPage) {
dispatchPageClientState({
type: "hydrate_from_page",
page: {
...reloadedPage,
body: {
...reloadedBody,
content: reloadedBody?.content ?? null,
revision:
typeof reloadedBody?.revision === "number" && Number.isInteger(reloadedBody.revision)
? reloadedBody.revision
: 0,
conflictDetectionKey:
typeof reloadedBody?.conflictDetectionKey === "string" && reloadedBody.conflictDetectionKey.trim()
? reloadedBody.conflictDetectionKey
: `${documentId}:0`,
},
},
});
}
} catch (error) {
if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return;
@@ -506,7 +498,7 @@ export function DocumentContent({
contentLoadingTimerRef.current = null;
}
};
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
}, [contentReloadKey, documentId, initialContent, page, workspaceId]);
const persistTitle = useCallback(
async (nextTitle: string) => {
@@ -516,11 +508,14 @@ export function DocumentContent({
documentId,
workspaceId,
title: nextTitle,
persistTitleCommand: updatePageTitleCommand,
persistTitleCommand: executePageHeadCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
commitPersistedTitle(payload);
setServerPageSubtreeTitle(payload);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: payload,
});
} catch (error) {
console.error("更新页面标题失败", error);
}
@@ -532,10 +527,14 @@ export function DocumentContent({
(nextTitle: string) => {
setPageTitleDraft(nextTitle);
commitPersistedTitle(nextTitle);
setServerPageSubtreeTitle(nextTitle);
dispatchPageClientState({
type: "update_server_page_subtree_title",
title: nextTitle,
});
emitDocumentsChanged(documentId);
void persistTitle(nextTitle);
},
[commitPersistedTitle, documentId, setPageTitleDraft],
[commitPersistedTitle, documentId, persistTitle, setPageTitleDraft],
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
@@ -565,7 +564,7 @@ export function DocumentContent({
async (patch: Partial<PageOptionsState>) => {
if (readOnly) return;
try {
await updatePageOptionsCommand({
await executePageLayoutCommand({
documentId,
workspaceId,
pageOptions: patch,
@@ -580,37 +579,37 @@ export function DocumentContent({
const toggleOption = useCallback(
(key: BooleanPageOptionKey) => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
const nextValue = !options[key];
dispatchPageClientState({
type: "patch_page_options",
patch: { [key]: nextValue } as Partial<PageOptionsState>,
});
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
},
[persistOptions, readOnly],
[options, persistOptions, readOnly],
);
const setOptionPatch = useCallback(
(patch: Partial<PageOptionsState>) => {
if (readOnly) return;
setOptions((prev) => {
const next = { ...prev, ...patch };
void persistOptions(patch);
return next;
dispatchPageClientState({
type: "patch_page_options",
patch,
});
void persistOptions(patch);
},
[persistOptions, readOnly],
);
const closeToc = useCallback(() => {
if (readOnly) return;
setOptions((prev) => {
if (!prev.showToc) return prev;
const next = { ...prev, showToc: false };
void persistOptions({ showToc: false });
return next;
if (!options.showToc) return;
dispatchPageClientState({
type: "patch_page_options",
patch: { showToc: false },
});
}, [persistOptions, readOnly]);
void persistOptions({ showToc: false });
}, [options.showToc, persistOptions, readOnly]);
const handleSetPageFont = useCallback(
(font: PageFont) => {
@@ -854,22 +853,10 @@ export function DocumentContent({
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
const pageSubtree = useMemo(() => {
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
const contentUnchanged = content === serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return serverPageSubtreeSnapshot;
}
return null;
}, [
content,
pageTitle,
serverContentSnapshot,
serverPageSubtreeSnapshot,
serverPageSubtreeTitle,
]);
const pageSubtree = useMemo(
() => selectPageAggregateClientPageSubtree(pageClientState, pageTitle),
[pageClientState, pageTitle],
);
const readViewTocEntries = useMemo(
() =>
(pageSubtree?.outline ?? [])
@@ -882,16 +869,20 @@ export function DocumentContent({
})),
[pageSubtree],
);
const getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
const getLatestPersistedMeta = useCallback(
() => ({
workspaceId,
revision: contentRevision,
conflictDetectionKey,
}),
[conflictDetectionKey, contentRevision, workspaceId],
const getLatestPageAggregateSnapshot = useCallback(
() =>
selectPageAggregateClientAiSnapshot(pageClientState, {
workspaceId,
pageTitle,
}),
[pageClientState, pageTitle, workspaceId],
);
const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => {
dispatchPageClientState({
type: "apply_persisted_body_meta",
meta,
});
}, []);
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => {
@@ -903,7 +894,10 @@ export function DocumentContent({
}, [isEditing]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setContent(payload.blocks);
dispatchPageClientState({
type: "apply_local_content_snapshot",
content: payload.blocks,
});
latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
const now = Date.now();
@@ -1162,8 +1156,7 @@ export function DocumentContent({
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
handlePersistedMetaChange(meta);
}}
onHostEvent={handleHostEvent}
onRequestFallback={(payload) => {
@@ -1184,8 +1177,7 @@ export function DocumentContent({
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
handlePersistedMetaChange(meta);
}}
/>
)}
@@ -1266,13 +1258,8 @@ export function DocumentContent({
<DocumentCommentsDrawer />
<DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={getLatestBlocks}
getLatestPageSubtree={getLatestPageSubtree}
getLatestPersistedMeta={getLatestPersistedMeta}
onPersistedMetaChange={(meta) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
onPersistedMetaChange={handlePersistedMetaChange}
onPageHeadTitleChange={handleAiPageHeadTitleChange}
/>
</ImagePickerProvider>
@@ -4,6 +4,7 @@ import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import type { ReferenceTarget } from "@/types/search";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
import type { LeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
export interface DocumentEditorHostProps {
documentId: string;
@@ -87,7 +88,4 @@ export type LeptosTiptapHostBridgeEventDetail = {
at?: string;
};
export type LeptosTiptapRuntimePageOptions = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
export type { LeptosTiptapRuntimePageOptions };
@@ -11,11 +11,11 @@ import {
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
const RUNTIME_PORT = 8123;
const RUNTIME_NAME = "8123-leptos-tiptap-runtime";
@@ -72,19 +72,6 @@ function resolveRuntimeUrl() {
if (typeof window === "undefined") {
return `http://localhost:${RUNTIME_PORT}/`;
}
const cfg = getMnoteRuntimeConfig();
if (cfg.mnoteWebBaseUrl) {
try {
const url = new URL(cfg.mnoteWebBaseUrl);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
// ignore
}
}
try {
const url = new URL(window.location.href);
url.port = String(RUNTIME_PORT);
@@ -313,37 +300,26 @@ export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const saveSnapshot = async (payload: HostDocumentPayload) => {
const normalizedRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
const { blocks } = publishSnapshot(payload);
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
),
});
const nextMeta = await response.json().catch(() => null);
if (!response.ok) {
const errorMessage = typeof nextMeta?.error === "string" ? nextMeta.error : `保存失败(${response.status}`;
throw new Error(errorMessage);
}
const nextRevision = typeof nextMeta?.revision === "number" ? nextMeta.revision : payload.meta?.revision ?? null;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
);
const nextRevision = persistedMeta.revision ?? payload.meta?.revision ?? null;
const nextConflictDetectionKey =
typeof nextMeta?.conflictDetectionKey === "string"
? nextMeta.conflictDetectionKey
: payload.meta?.conflict_detection_key ?? null;
persistedMeta.conflictDetectionKey ?? payload.meta?.conflict_detection_key ?? null;
revisionRef.current = nextRevision;
conflictDetectionKeyRef.current = nextConflictDetectionKey;
onPersistedMetaChangeRef.current?.({
@@ -17,6 +17,7 @@ import {
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import { loadLeptosTiptapRuntime } from "@/components/editor/leptos-tiptap-runtime-loader";
type RuntimeSelectionState = Record<string, boolean | number | null | undefined> & {
@@ -601,45 +602,25 @@ export function LeptosTiptapRuntimeEditorHost(props: DocumentEditorHostProps) {
const persistSnapshot = async () => {
emitStatus(INLINE_RUNTIME_STATUS.saving);
const snapshot = latestSnapshotRef.current ?? (await readCurrentSnapshot());
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: nowIso(),
blockCount: snapshot.stats.blockCount,
}),
),
});
const payload = (await response.json().catch(() => null)) as
| {
error?: string;
revision?: number | null;
conflictDetectionKey?: string | null;
}
| null;
if (!response.ok) {
throw new Error(payload?.error ?? `保存失败(${response.status}`);
}
revisionRef.current =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: nowIso(),
blockCount: snapshot.stats.blockCount,
}),
);
revisionRef.current = persistedMeta.revision ?? revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string" &&
payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
persistedMeta.conflictDetectionKey ?? conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -9,13 +9,15 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { DocumentStats } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageBodySavePayload } from "@/lib/documents/page-command-client";
import {
loadLeptosTiptapIslandAssets,
} from "@/components/editor/leptos-tiptap-island-loader";
@@ -116,21 +118,7 @@ type IslandBootstrapPayload = {
readOnly: boolean;
pageOptions: RuntimePageOptionsPayload;
};
type RuntimePageOptionsPayload = Pick<
PageOptionsState,
"wideLayout" | "smallText" | "layoutDensity" | "showHeadingNumbers" | "embedDefaultBlockId"
>;
function buildRuntimePageOptions(pageOptions: PageOptionsState): RuntimePageOptionsPayload {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
type RuntimePageOptionsPayload = ReturnType<typeof pickLeptosTiptapRuntimePageOptions>;
function toIsoNow(): string {
return new Date().toISOString();
@@ -316,7 +304,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
});
const [editorHeight, setEditorHeight] = useState(FALLBACK_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<RuntimeBridgeState>({
@@ -375,7 +363,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
};
}, [
mountIdentity,
@@ -418,35 +406,22 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
const persistDocument = useCallback(async () => {
const normalizedDoc = latestDocRef.current;
const normalizedBlocks = blocksFromTiptapDoc(normalizedDoc) as Json;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
content: normalizedBlocks,
tiptapDocument: normalizedDoc,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
}),
),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(
typeof payload?.error === "string" ? payload.error : `保存失败(${response.status}`,
);
}
revisionRef.current =
typeof payload?.revision === "number" ? payload.revision : revisionRef.current;
const persistedMeta = await executePageBodySavePayload(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(normalizedDoc, props.documentId),
content: normalizedBlocks,
tiptapDocument: normalizedDoc,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(normalizedBlocks) ? normalizedBlocks.length : 0,
}),
);
revisionRef.current = persistedMeta.revision ?? revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string"
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
persistedMeta.conflictDetectionKey ?? conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
@@ -717,7 +692,7 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
}
dispatchRuntimeCommand(target, {
command: "setPageOptions",
pageOptions: buildRuntimePageOptions(props.pageOptions),
pageOptions: pickLeptosTiptapRuntimePageOptions(props.pageOptions),
});
}, [props.pageOptions]);
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import type { Json } from "@/types/supabase";
import { buildPageAggregate } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
createPageAggregateClientState,
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
} from "@/components/editor/page-aggregate-client-state";
import type { PageOptionsState } from "@/types/page-options";
const pageOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
function createPageSubtree(title: string): PageSubtreeProjection {
return {
projectionId: "projection:page_tree:doc_1",
projection: "page_tree",
rootNodeId: "doc_1",
rootNode: {
id: "doc_1",
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title,
textSnippet: null,
blockType: null,
headingLevel: null,
numbering: null,
childCount: 0,
order: 0,
path: ["doc_1"],
},
},
subtree: {
rootNodeId: "doc_1",
nodes: [],
},
outline: [],
evidence: [],
stats: {
blockCount: 0,
headingCount: 0,
evidenceCount: 0,
maxDepth: 0,
},
};
}
function createPageAggregate(input?: {
title?: string;
content?: unknown;
pageSubtree?: PageSubtreeProjection | null;
pageOptionsPatch?: Partial<PageOptionsState>;
revision?: number | null;
conflictDetectionKey?: string | null;
}) {
const content =
input?.content ??
[
{
id: "block_1",
type: "paragraph",
content: [{ type: "text", text: "正文" }],
},
];
return buildPageAggregate({
documentId: "doc_1",
workspaceId: "ws_1",
title: input?.title ?? "页面标题",
pageOptions: {
...pageOptions,
...input?.pageOptionsPatch,
},
content,
revision: input?.revision ?? 3,
conflictDetectionKey: input?.conflictDetectionKey ?? "doc_1:3",
pageSubtree: input?.pageSubtree ?? createPageSubtree("页面标题"),
});
}
describe("page-aggregate-client-state", () => {
it("应从 page aggregate 初始化 body/layout/tree 本地真相", () => {
const page = createPageAggregate();
const state = createPageAggregateClientState(page);
expect(state.options).toEqual(page.layout.pageOptions);
expect(state.content).toBe(page.body.content);
expect(state.serverContentSnapshot).toBe(page.body.content);
expect(state.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
expect(state.serverPageSubtreeTitle).toBe("页面标题");
expect(state.contentRevision).toBe(3);
expect(state.conflictDetectionKey).toBe("doc_1:3");
});
it("路由 reload 后应整体替换 body/layout/tree 的服务端快照", () => {
const initialPage = createPageAggregate();
const reloadedContent = [
{
id: "block_2",
type: "paragraph",
content: [{ type: "text", text: "刷新后的正文" }],
},
];
const reloadedPage = createPageAggregate({
title: "刷新后的标题",
content: reloadedContent,
pageSubtree: createPageSubtree("刷新后的标题"),
pageOptionsPatch: { wideLayout: true, showToc: true },
revision: 9,
conflictDetectionKey: "doc_1:9",
});
const next = pageAggregateClientStateReducer(createPageAggregateClientState(initialPage), {
type: "hydrate_from_page",
page: reloadedPage,
});
expect(next.options.wideLayout).toBe(true);
expect(next.options.showToc).toBe(true);
expect(next.content).toBe(reloadedContent);
expect(next.serverContentSnapshot).toBe(reloadedContent);
expect(next.serverPageSubtreeSnapshot).toBe(reloadedPage.tree.pageSubtree);
expect(next.serverPageSubtreeTitle).toBe("刷新后的标题");
expect(next.contentRevision).toBe(9);
expect(next.conflictDetectionKey).toBe("doc_1:9");
});
it("正文保存元信息回写时只更新 persisted meta,不覆盖本地内容快照", () => {
const page = createPageAggregate();
const localContent = [
{
id: "block_local",
type: "paragraph",
content: [{ type: "text", text: "本地正文" }],
},
];
const withLocalSnapshot = pageAggregateClientStateReducer(createPageAggregateClientState(page), {
type: "apply_local_content_snapshot",
content: localContent,
});
const next = pageAggregateClientStateReducer(withLocalSnapshot, {
type: "apply_persisted_body_meta",
meta: {
revision: 10,
conflictDetectionKey: "doc_1:10",
},
});
expect(next.content).toBe(localContent);
expect(next.serverContentSnapshot).toBe(page.body.content);
expect(next.contentRevision).toBe(10);
expect(next.conflictDetectionKey).toBe("doc_1:10");
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(selectPageAggregateClientPageSubtree(initialState, "页面标题")).toBe(page.tree.pageSubtree);
const localContentState = pageAggregateClientStateReducer(initialState, {
type: "apply_local_content_snapshot",
content: [{ id: "block_local", type: "paragraph", content: [] }],
});
expect(selectPageAggregateClientPageSubtree(localContentState, "页面标题")).toBeNull();
const retitledState = pageAggregateClientStateReducer(initialState, {
type: "update_server_page_subtree_title",
title: "持久化后的标题",
});
expect(selectPageAggregateClientPageSubtree(retitledState, "页面标题")).toBeNull();
expect(selectPageAggregateClientPageSubtree(retitledState, "持久化后的标题")).toBe(page.tree.pageSubtree);
});
it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => {
const page = createPageAggregate();
const next = pageAggregateClientStateReducer(createPageAggregateClientState(page), {
type: "patch_page_options",
patch: {
showToc: true,
layoutDensity: "compact",
},
});
expect(next.options).toEqual({
...page.layout.pageOptions,
showToc: true,
layoutDensity: "compact",
});
expect(next.content).toBe(page.body.content);
expect(next.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
});
it("应能从统一 client state 导出 AI 所需的页面聚合快照", () => {
const page = createPageAggregate({
pageOptionsPatch: {
wideLayout: true,
smallText: true,
},
});
const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), {
workspaceId: "ws_1",
pageTitle: "页面标题",
});
expect(snapshot).toEqual({
blocks: page.body.content as Json,
pageSubtree: page.tree.pageSubtree,
persistedMeta: {
workspaceId: "ws_1",
revision: 3,
conflictDetectionKey: "doc_1:3",
},
pageOptions: {
...pageOptions,
wideLayout: true,
smallText: true,
},
});
});
});
@@ -0,0 +1,144 @@
import type { PageBodyPersistedMeta } from "@/lib/documents/page-command-client";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientState = {
options: PageOptionsState;
content: unknown;
serverContentSnapshot: unknown;
serverPageSubtreeSnapshot: PageSubtreeProjection | null;
serverPageSubtreeTitle: string;
contentRevision: number | null;
conflictDetectionKey: string | null;
};
export type PageAggregateClientStateAction =
| {
type: "hydrate_from_page";
page: PageAggregateProjection;
}
| {
type: "patch_page_options";
patch: Partial<PageOptionsState>;
}
| {
type: "apply_local_content_snapshot";
content: unknown;
}
| {
type: "apply_persisted_body_meta";
meta: PageBodyPersistedMeta;
}
| {
type: "update_server_page_subtree_title";
title: string;
};
function normalizePageTitle(title: string | null | undefined): string {
const normalized = String(title ?? "").trim();
return normalized || "无标题";
}
function resolveServerPageSubtreeTitle(page: PageAggregateProjection): string {
const subtreeTitle = page.tree.pageSubtree?.rootNode.metadata.title;
if (typeof subtreeTitle === "string" && subtreeTitle.trim()) {
return subtreeTitle.trim();
}
return normalizePageTitle(page.head.title);
}
export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
options: page.layout.pageOptions,
content: page.body.content,
serverContentSnapshot: page.body.content,
serverPageSubtreeSnapshot: page.tree.pageSubtree,
serverPageSubtreeTitle: resolveServerPageSubtreeTitle(page),
contentRevision: page.body.revision,
conflictDetectionKey: page.body.conflictDetectionKey,
};
}
export function pageAggregateClientStateReducer(
state: PageAggregateClientState,
action: PageAggregateClientStateAction,
): PageAggregateClientState {
switch (action.type) {
case "hydrate_from_page":
return createPageAggregateClientState(action.page);
case "patch_page_options":
return {
...state,
options: {
...state.options,
...action.patch,
},
};
case "apply_local_content_snapshot":
return {
...state,
content: action.content,
};
case "apply_persisted_body_meta":
return {
...state,
contentRevision: action.meta.revision,
conflictDetectionKey: action.meta.conflictDetectionKey,
};
case "update_server_page_subtree_title":
return {
...state,
serverPageSubtreeTitle: normalizePageTitle(action.title),
};
default:
return state;
}
}
export function selectPageAggregateClientPageSubtree(
state: PageAggregateClientState,
pageTitle: string,
): PageSubtreeProjection | null {
const hasServerPageSubtree = Boolean(state.serverPageSubtreeSnapshot);
const titleUnchanged = normalizePageTitle(pageTitle) === state.serverPageSubtreeTitle;
const contentUnchanged = state.content === state.serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return state.serverPageSubtreeSnapshot;
}
return null;
}
export function selectPageAggregateClientAiSnapshot(
state: PageAggregateClientState,
input: {
workspaceId: string | null;
pageTitle: string;
},
): {
blocks: Json | null;
pageSubtree: PageSubtreeProjection | null;
persistedMeta: {
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
};
pageOptions: PageOptionsState;
} {
const blocks = state.content as Json | null;
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, input.pageTitle),
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
conflictDetectionKey: state.conflictDetectionKey,
},
pageOptions: state.options,
};
}
@@ -7,6 +7,7 @@ import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity,
import { DocumentTaskPanel } from "@/components/document-task-panel";
import { Button } from "@/components/ui/button";
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
import { PAGE_OPTION_PANEL_GROUPS } from "@/lib/documents/page-option-semantics";
type TabId = "page" | "custom" | "global";
@@ -67,17 +68,6 @@ const OPTION_META: Record<
},
};
const PAGE_OPTIONS: BooleanPageOptionKey[] = [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
];
const CUSTOM_PAGE_OPTIONS: BooleanPageOptionKey[] = ["collapseBacklinks", "hideChildPages", "showBlockRefCount"];
interface PageOptionsSidebarProps {
documentId: string;
options: PageOptionsState;
@@ -163,7 +153,12 @@ export function PageOptionsSidebar({
</div>
</section>
)}
<OptionToggleGroup title="页面选项" optionKeys={PAGE_OPTIONS} options={options} onToggle={onToggle} />
<OptionToggleGroup
title="页面选项"
optionKeys={PAGE_OPTION_PANEL_GROUPS.page}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
<div className="flex items-center justify-between">
<span className="font-semibold text-gray-800"></span>
@@ -282,7 +277,12 @@ export function PageOptionsSidebar({
</div>
</section>
<OptionToggleGroup title="反向链接" optionKeys={CUSTOM_PAGE_OPTIONS} options={options} onToggle={onToggle} />
<OptionToggleGroup
title="反向链接"
optionKeys={PAGE_OPTION_PANEL_GROUPS.custom}
options={options}
onToggle={onToggle}
/>
<section className="rounded-2xl border border-[#eef1f6] p-4">
<div className="text-sm font-semibold text-gray-800"></div>
@@ -1,380 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
const TREE_SHELL_PATH = "/tree";
export type MnoteTreeShellMode = "page" | "picker" | "filetree";
type TreeShellMessage =
| {
channel?: string;
type?: string;
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
target?: { documentId?: string };
payload?: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x?: number;
y?: number;
};
}
| null
| undefined;
type MnoteWebTreeShellProps = {
workspaceId: string | null;
activeDocumentId?: string;
actorId?: string | null;
mode?: MnoteTreeShellMode;
allowRootPick?: boolean;
excludeIds?: string[];
reloadToken?: number;
onNavigate: (documentId: string) => void;
onPick?: (documentId: string | null) => void;
onOpenAsset?: (assetId: string) => void;
onOpenContextMenu?: (args: { documentId: string; x: number; y: number }) => void;
onOpenFileTreeContextMenu?: (args: {
documentId?: string;
assetId?: string;
rowId?: string;
rowKind?: string;
x: number;
y: number;
}) => void;
onRefresh: () => Promise<void>;
fallback: React.ReactNode;
};
function buildShellUrl(
baseUrl: string,
workspaceId: string | null,
activeDocumentId: string | undefined,
actorId: string | null | undefined,
mode: MnoteTreeShellMode,
allowRootPick: boolean,
excludeIds: string[],
refreshKey: number,
reloadToken: number,
) {
const url = new URL(TREE_SHELL_PATH, `${baseUrl}/`);
if (workspaceId) {
url.searchParams.set("workspaceId", workspaceId);
}
if (activeDocumentId) {
url.searchParams.set("activeDocumentId", activeDocumentId);
}
if (actorId && actorId.trim()) {
url.searchParams.set("actorId", actorId.trim());
}
url.searchParams.set("mode", mode);
if (mode === "picker") {
url.searchParams.set("allowRootPick", allowRootPick ? "1" : "0");
if (excludeIds.length > 0) {
url.searchParams.set("excludeIds", excludeIds.join(","));
}
}
url.searchParams.set("host", "wolai-frontend");
url.searchParams.set("channel", TREE_SHELL_CHANNEL);
url.searchParams.set("v", `${refreshKey}-${reloadToken}`);
return url.toString();
}
function extractDocumentId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.documentId === "string" ? message.documentId.trim() : "";
if (direct) return direct;
const fromTarget =
message.target && typeof message.target.documentId === "string"
? message.target.documentId.trim()
: "";
if (fromTarget) return fromTarget;
const fromPayload =
message.payload && typeof message.payload.documentId === "string"
? message.payload.documentId.trim()
: "";
return fromPayload;
}
function extractAssetId(message: Exclude<TreeShellMessage, null | undefined>) {
const direct = typeof message.assetId === "string" ? message.assetId.trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload.assetId === "string"
? message.payload.assetId.trim()
: "";
return fromPayload;
}
function extractCoordinate(
message: Exclude<TreeShellMessage, null | undefined>,
axis: "x" | "y",
) {
const direct = typeof message[axis] === "number" ? message[axis] : null;
if (typeof direct === "number" && Number.isFinite(direct)) return direct;
const fromPayload =
message.payload && typeof message.payload[axis] === "number"
? message.payload[axis]
: null;
if (typeof fromPayload === "number" && Number.isFinite(fromPayload)) return fromPayload;
return null;
}
function extractTextField(
message: Exclude<TreeShellMessage, null | undefined>,
field: "rowId" | "rowKind",
) {
const direct = typeof message[field] === "string" ? message[field].trim() : "";
if (direct) return direct;
const fromPayload =
message.payload && typeof message.payload[field] === "string"
? message.payload[field].trim()
: "";
return fromPayload;
}
export function MnoteWebTreeShell({
workspaceId,
activeDocumentId,
actorId,
mode = "page",
allowRootPick = false,
excludeIds = [],
reloadToken = 0,
onNavigate,
onPick,
onOpenAsset,
onOpenContextMenu,
onOpenFileTreeContextMenu,
onRefresh,
fallback,
}: MnoteWebTreeShellProps) {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const treeShellEnabled = runtime.mnoteWebTreeShellEnabled === true;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const readyTimerRef = useRef<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
const [authCookieReady, setAuthCookieReady] = useState(false);
const shellUrl = useMemo(() => {
if (!baseUrl || !treeShellEnabled) return null;
return buildShellUrl(
baseUrl,
workspaceId,
activeDocumentId,
actorId,
mode,
allowRootPick,
excludeIds,
refreshKey,
reloadToken,
);
}, [
activeDocumentId,
actorId,
allowRootPick,
baseUrl,
excludeIds,
mode,
reloadToken,
refreshKey,
treeShellEnabled,
workspaceId,
]);
useEffect(() => {
if (!shellUrl) {
setAuthCookieReady(false);
return;
}
let cancelled = false;
setAuthCookieReady(false);
setFailedShellUrl(null);
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (!cancelled) {
setAuthCookieReady(true);
}
} catch {
if (!cancelled) {
setFailedShellUrl(shellUrl);
}
}
})();
return () => {
cancelled = true;
};
}, [shellUrl]);
useEffect(() => {
if (!shellUrl || !authCookieReady) return;
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
}
// 说明:当前 shell 仍处于渐进接入期,若路由不存在或页面未按协议 ready,
// 前端应自动回退到旧 React 树,而不是让用户看到空白 iframe。
readyTimerRef.current = window.setTimeout(() => {
setFailedShellUrl(shellUrl);
}, 2500);
return () => {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
};
}, [authCookieReady, shellUrl]);
useEffect(() => {
if (!baseUrl || !treeShellEnabled) return;
const expectedOrigin = (() => {
try {
return new URL(baseUrl).origin;
} catch {
return "";
}
})();
const onMessage = (event: MessageEvent<TreeShellMessage>) => {
if (!expectedOrigin || event.origin !== expectedOrigin) return;
if (event.source !== iframeRef.current?.contentWindow) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.channel !== TREE_SHELL_CHANNEL) return;
const type = typeof message.type === "string" ? message.type.trim() : "";
if (!type) return;
if (type === "tree.ready" || type === "ready") {
if (readyTimerRef.current) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
return;
}
if (type === "tree.navigate" || type === "navigate") {
const documentId = extractDocumentId(message);
if (documentId) {
onNavigate(documentId);
}
return;
}
if (type === "tree.pick" || type === "picker.pick") {
onPick?.(extractDocumentId(message) || null);
return;
}
if (type === "tree.pick.root" || type === "picker.pick.root") {
onPick?.(null);
return;
}
if (type === "tree.asset.open" || type === "filetree.asset.open") {
const assetId = extractAssetId(message);
if (assetId) {
onOpenAsset?.(assetId);
}
return;
}
if (type === "tree.context-menu" || type === "tree.page.context-menu") {
const documentId = extractDocumentId(message);
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (!documentId || x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenContextMenu?.({
documentId,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (type === "tree.filetree.context-menu") {
const documentId = extractDocumentId(message) || undefined;
const assetId = extractAssetId(message) || undefined;
const rowId = extractTextField(message, "rowId") || undefined;
const rowKind = extractTextField(message, "rowKind") || undefined;
const x = extractCoordinate(message, "x");
const y = extractCoordinate(message, "y");
if (x === null || y === null) {
return;
}
const iframeRect = iframeRef.current?.getBoundingClientRect();
if (!iframeRect) {
return;
}
onOpenFileTreeContextMenu?.({
documentId,
assetId,
rowId,
rowKind,
x: iframeRect.left + x,
y: iframeRect.top + y,
});
return;
}
if (
type === "tree.node.created" ||
type === "tree.node.renamed" ||
type === "tree.subtree.moved" ||
type === "tree.refresh" ||
type === "refresh"
) {
void onRefresh().finally(() => {
setRefreshKey((value) => value + 1);
});
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
if (!shellUrl || !authCookieReady || failedShellUrl === shellUrl) {
return <>{fallback}</>;
}
return (
<div className="h-full w-full min-w-0 overflow-hidden bg-white">
<iframe
ref={iframeRef}
title="mnote-web tree shell"
src={shellUrl}
className="h-full w-full border-0 bg-white"
loading="lazy"
onError={() => {
setFailedShellUrl(shellUrl);
}}
/>
</div>
);
}
@@ -0,0 +1,42 @@
export type DocumentAiToolRegistryItem = {
name: string;
title: string;
description: string;
scope: "document" | "tree" | "workspace";
mode: "read" | "write";
status: string;
version?: string;
};
export type DocumentAiProfileConfigItem = {
id: string;
title: string;
description: string;
sessionMode: "off" | "page" | "workspace";
toolNames: string[];
status: string;
default?: boolean;
};
export type DocumentAiModelConfigItem = {
key: string;
title: string;
description: string;
gatewayModel: string;
resolvedCombo?: string | null;
resolvedRuntimeModel?: string | null;
status: string;
default?: boolean;
};
export type DocumentAiCapabilityConfig = {
provider: "online";
transport: string;
baseUrl: string;
sessionEnabled: boolean;
defaultModelKey: string;
defaultProfileId: string;
models: DocumentAiModelConfigItem[];
profiles: DocumentAiProfileConfigItem[];
tools: DocumentAiToolRegistryItem[];
};
@@ -134,6 +134,9 @@ const DOCUMENT_BRIDGE_MUTATION_FUNCTIONS = {
"documents.stats.update": "documents:updateStats",
"documents.options.update": "documents:updateOptions",
"documents.save": "documents:updateContent",
"page.head.updateTitle": "documents:updateTitle",
"page.layout.updateOptions": "documents:updateOptions",
"page.body.save": "documents:updateContent",
"mindmaps.delete": "mindmaps:softDelete",
"mindmaps.restore": "mindmaps:restore",
"mindmaps.purge": "mindmaps:purge",
@@ -84,6 +84,13 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
title: payload.title,
}),
},
"page.head.updateTitle": {
convexMutation: api.documents.updateTitle,
mapConvexArgs: (payload: DocumentTitleUpdatePayload) => ({
id: payload.documentId,
title: payload.title,
}),
},
"documents.stats.update": {
convexMutation: api.documents.updateStats,
mapConvexArgs: (payload: DocumentStatsUpdatePayload) => ({
@@ -99,6 +106,10 @@ const metadataWriteAdapters: Record<string, MetadataWriteAdapter<unknown>> = {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.layout.updateOptions": {
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
};
function getMetadataWriteAdapter<TPayload>(commandName: string): MetadataWriteAdapter<TPayload> {
@@ -115,7 +126,10 @@ export async function executeMetadataBridgeCommand<TPayload>(input: {
}): Promise<MetadataCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
try {
if (input.envelope.name === "documents.title.update") {
if (
input.envelope.name === "documents.title.update" ||
input.envelope.name === "page.head.updateTitle"
) {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { buildPageAggregateFromDocumentPayloads } from "@/lib/documents/page-aggregate-builder";
describe("page-aggregate-builder", () => {
it("会把 documents meta 与 content 收口为统一 page aggregate", () => {
const aggregate = buildPageAggregateFromDocumentPayloads({
meta: {
id: "doc_1",
workspace_id: "ws_1",
title: " 页面标题 ",
updated_at: "2026-04-22T12:00:00.000Z",
can_edit: false,
disable_download: true,
disable_copy: false,
wide_layout: true,
use_small_text: true,
show_heading_numbers: false,
show_toc: true,
show_structure: false,
protect_editing: false,
show_word_count: true,
collapse_backlinks: true,
page_font: "default",
layout_density: "compact",
hide_child_pages: true,
show_block_ref_count: true,
embed_default_block_id: "block_1",
word_count: 20,
character_count: 40,
block_count: 2,
todo_total: 3,
todo_done: 1,
},
contentPayload: {
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 8,
conflict_detection_key: "doc_1:8",
page_subtree: null,
},
});
expect(aggregate.identity).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
});
expect(aggregate.head).toEqual({
title: "页面标题",
updatedAt: "2026-04-22T12:00:00.000Z",
permissions: {
readOnly: true,
disableDownload: true,
disableCopy: false,
},
});
expect(aggregate.layout.pageOptions).toMatchObject({
wideLayout: true,
smallText: true,
showHeadingNumbers: false,
showToc: true,
collapseBacklinks: true,
layoutDensity: "compact",
hideChildPages: true,
showBlockRefCount: true,
embedDefaultBlockId: "block_1",
});
expect(aggregate.body).toEqual({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 8,
conflictDetectionKey: "doc_1:8",
});
expect(aggregate.stats).toEqual({
wordCount: 20,
characterCount: 40,
blockCount: 2,
todoTotal: 3,
todoDone: 1,
});
});
});
@@ -0,0 +1,100 @@
import { buildPageAggregate, type PageAggregateProjection } from "@/lib/documents/page-aggregate";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import type {
DocumentStats,
PageFont,
PageLayoutDensity,
PageOptionsState,
} from "@/types/page-options";
export type DocumentMetaPayload = {
id: string;
workspace_id: string;
title: string | null;
updated_at: string | null;
can_edit?: boolean | null;
disable_download?: boolean | null;
disable_copy?: boolean | null;
wide_layout?: boolean | null;
use_small_text?: boolean | null;
show_heading_numbers?: boolean | null;
show_toc?: boolean | null;
show_structure?: boolean | null;
protect_editing?: boolean | null;
show_word_count?: boolean | null;
collapse_backlinks?: boolean | null;
page_font?: PageFont | null;
layout_density?: PageLayoutDensity | null;
hide_child_pages?: boolean | null;
show_block_ref_count?: boolean | null;
embed_default_block_id?: string | null;
word_count?: number | null;
character_count?: number | null;
block_count?: number | null;
todo_total?: number | null;
todo_total_count?: number | null;
todo_done?: number | null;
todo_done_count?: number | null;
};
export type DocumentContentPayload = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
conflictDetectionKey?: string | null;
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
};
export function buildPageAggregateFromDocumentPayloads(input: {
meta: DocumentMetaPayload;
contentPayload?: DocumentContentPayload | null;
}): PageAggregateProjection {
const normalizedContent = normalizeDocumentContentResponse({
documentId: input.meta.id,
title: input.meta.title ?? "无标题",
payload: input.contentPayload,
});
const pageOptions: PageOptionsState = {
wideLayout: input.meta.wide_layout ?? false,
smallText: input.meta.use_small_text ?? false,
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
showToc: input.meta.show_toc ?? false,
showStructure: input.meta.show_structure ?? false,
protectEditing: input.meta.protect_editing ?? false,
showWordCount: input.meta.show_word_count ?? true,
collapseBacklinks: input.meta.collapse_backlinks ?? false,
pageFont: input.meta.page_font ?? "default",
layoutDensity: input.meta.layout_density ?? "normal",
hideChildPages: input.meta.hide_child_pages ?? false,
showBlockRefCount: input.meta.show_block_ref_count ?? false,
embedDefaultBlockId: input.meta.embed_default_block_id ?? null,
};
const stats: DocumentStats = {
wordCount: input.meta.word_count ?? 0,
characterCount: input.meta.character_count ?? 0,
blockCount: input.meta.block_count ?? 0,
todoTotal: input.meta.todo_total ?? input.meta.todo_total_count ?? 0,
todoDone: input.meta.todo_done ?? input.meta.todo_done_count ?? 0,
};
return buildPageAggregate({
documentId: input.meta.id,
workspaceId: input.meta.workspace_id,
title: input.meta.title ?? "无标题",
updatedAt: input.meta.updated_at,
readOnly: input.meta.can_edit === false,
disableDownload: Boolean(input.meta.disable_download),
disableCopy: Boolean(input.meta.disable_copy),
pageOptions,
content: normalizedContent.content,
revision: normalizedContent.revision,
conflictDetectionKey: normalizedContent.conflictDetectionKey,
pageSubtree: normalizedContent.pageSubtree,
stats,
});
}
@@ -0,0 +1,132 @@
import { headers } from "next/headers";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import {
buildPageAggregateFromDocumentPayloads,
type DocumentContentPayload,
type DocumentMetaPayload,
} from "@/lib/documents/page-aggregate-builder";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const FORWARDED_REQUEST_HEADERS = [
"cookie",
"authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
] as const;
export type LoadedPageAggregate = {
page: PageAggregateProjection;
bridge: {
requestId: string;
traceId: string;
queryName: "documents.page.get";
};
};
function copyForwardHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
async function buildServerBridgeRequest(pathname: string): Promise<Request> {
const headerList = await headers();
const requestHeaders = new Headers();
FORWARDED_REQUEST_HEADERS.forEach((name) => {
copyForwardHeaderIfPresent(requestHeaders, headerList, name);
});
return new Request(`http://mnote.local${pathname}`, {
method: "GET",
headers: requestHeaders,
});
}
async function fetchDocumentContentPayload(input: {
context: BridgeContext;
documentId: string;
workspaceId: string;
}): Promise<DocumentContentPayload | null> {
const { client } = await getAuthedConvexClient();
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: input.context,
envelope,
});
return executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
}
export async function loadPageAggregate(input: {
request: Request;
documentId: string;
workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> {
const { client } = await getAuthedConvexClient();
const meta = await client.query(api.documents.getMeta, {
id: input.documentId,
});
if (!meta) {
return null;
}
const workspaceId = input.workspaceId?.trim() || meta.workspace_id;
const context = await buildDocumentBridgeContext({
request: input.request,
workspaceId,
});
const contentPayload = await fetchDocumentContentPayload({
context,
documentId: meta.id,
workspaceId,
});
return {
page: buildPageAggregateFromDocumentPayloads({
meta,
contentPayload,
}),
bridge: {
requestId: context.requestId,
traceId: context.traceId,
queryName: "documents.page.get",
},
};
}
export async function loadPageAggregateFromNextHeaders(input: {
documentId: string;
workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> {
const request = await buildServerBridgeRequest("/documents/page");
return loadPageAggregate({
request,
documentId: input.documentId,
workspaceId: input.workspaceId,
});
}
@@ -1,76 +1,7 @@
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ApplyPageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function persistPageBodyViaRoute(
payload: DocumentSavePayload,
fetchImpl: typeof fetch,
): Promise<PageBodyPersistedMeta> {
const response = await fetchImpl("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as
| {
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string;
}
| null;
if (!response.ok) {
const message = body && typeof body.error === "string" && body.error.trim() ? body.error.trim() : "页面正文保存失败";
throw new Error(message);
}
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function applyPageBodyCommand(input: ApplyPageBodyCommandInput): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await persistPageBodyViaRoute(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
export {
executePageBodyCommand as applyPageBodyCommand,
executePageBodySavePayload,
type ExecutePageBodyCommandInput as ApplyPageBodyCommandInput,
type PageBodyPersistedMeta,
type PageBodyPersistedState,
} from "@/lib/documents/page-command-client";
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase";
import {
executePageBodyCommand,
executePageBodySavePayload,
executePageHeadCommand,
executePageLayoutCommand,
} from "./page-command-client";
import { buildDocumentSavePayload } from "./save-contract";
describe("page-command-client", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("标题命令应走统一 page head command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, meta: { commandName: "page.head.updateTitle" } }),
} as Response);
await executePageHeadCommand({
documentId: "doc-1",
workspaceId: "ws-1",
title: "页面标题",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/title",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, init] = fetchMock.mock.calls[0] ?? [];
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
documentId: "doc-1",
workspaceId: "ws-1",
title: "页面标题",
commandName: "page.head.updateTitle",
});
});
it("页面设置命令应走统一 page layout command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, meta: { commandName: "page.layout.updateOptions" } }),
} as Response);
await executePageLayoutCommand({
documentId: "doc-1",
workspaceId: "ws-1",
pageOptions: { wideLayout: true },
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/options",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
const [, init] = fetchMock.mock.calls[0] ?? [];
expect(JSON.parse(String(init?.body ?? "{}"))).toEqual({
documentId: "doc-1",
workspaceId: "ws-1",
options: { wideLayout: true },
commandName: "page.layout.updateOptions",
});
});
it("正文保存 payload 应走统一 page body command", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ ok: true, revision: 5, conflictDetectionKey: "doc-1:5" }),
} as Response);
const payload = buildDocumentSavePayload({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 4,
conflictDetectionKey: "doc-1:4",
content: [{ id: "block_1", type: "paragraph", content: "正文" }] as Json,
blockCount: 1,
});
await expect(executePageBodySavePayload(payload)).resolves.toEqual({
revision: 5,
conflictDetectionKey: "doc-1:5",
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/documents/save",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
);
});
it("高层正文命令应在保存成功后再回显快照并回传元信息", async () => {
const applyEditorSnapshot = vi.fn();
const onPersistedMetaChange = vi.fn();
const fetchImpl = vi.fn(async () =>
new Response(JSON.stringify({ ok: true, revision: 9, conflictDetectionKey: "doc-1:9" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const blocks = [{ id: "block_1", type: "paragraph", content: "统一正文" }] as Json;
await expect(
executePageBodyCommand({
documentId: "doc-1",
workspaceId: "ws-1",
revision: 8,
conflictDetectionKey: "doc-1:8",
blocks,
applyEditorSnapshot,
onPersistedMetaChange,
fetchImpl,
}),
).resolves.toEqual({
revision: 9,
conflictDetectionKey: "doc-1:9",
});
expect(applyEditorSnapshot).toHaveBeenCalledWith(blocks);
expect(onPersistedMetaChange).toHaveBeenCalledWith({
revision: 9,
conflictDetectionKey: "doc-1:9",
});
});
});
@@ -0,0 +1,152 @@
import type { PageLayoutCommandInput, PageTitleCommandInput } from "@/lib/documents/page-command-contract";
import { PAGE_COMMAND_NAMES } from "@/lib/documents/page-command-contract";
import { buildDocumentSavePayload, type DocumentSavePayload } from "@/lib/documents/save-contract";
import type { Json } from "@/types/supabase";
export type PageCommandMeta = {
requestId?: string;
traceId?: string;
commandId?: string;
commandName?: string;
};
type PageCommandErrorPayload = {
error?: string;
};
export type PageHeadCommandResult = {
ok: true;
meta?: PageCommandMeta;
};
export type PageLayoutCommandResult = {
ok: true;
meta?: PageCommandMeta;
};
export type PageBodyPersistedMeta = {
revision: number | null;
conflictDetectionKey: string | null;
};
export type PageBodyPersistedState = PageBodyPersistedMeta & {
workspaceId: string | null;
};
export type ExecutePageBodyCommandInput = {
documentId: string;
workspaceId: string | null;
revision: number | null;
conflictDetectionKey: string | null;
blocks: Json;
applyEditorSnapshot?: (blocks: Json) => void;
onPersistedMetaChange?: (meta: PageBodyPersistedMeta) => void;
fetchImpl?: typeof fetch;
persistPageBody?: (payload: DocumentSavePayload) => Promise<PageBodyPersistedMeta>;
};
async function postPageCommand<TResult>(
path: string,
payload: unknown,
fallbackMessage: string,
fetchImpl: typeof fetch = fetch,
): Promise<TResult> {
const response = await fetchImpl(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = (await response.json().catch(() => null)) as TResult | PageCommandErrorPayload | null;
if (!response.ok) {
const message =
body && typeof body === "object" && "error" in body && typeof body.error === "string"
? body.error
: fallbackMessage;
throw new Error(message);
}
return body as TResult;
}
export async function executePageHeadCommand(
input: PageTitleCommandInput,
fetchImpl?: typeof fetch,
): Promise<PageHeadCommandResult> {
return postPageCommand<PageHeadCommandResult>(
"/api/documents/title",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
title: input.title,
commandName: PAGE_COMMAND_NAMES.updateTitle,
},
"重命名失败,请稍后再试",
fetchImpl,
);
}
export async function executePageLayoutCommand(
input: PageLayoutCommandInput,
fetchImpl?: typeof fetch,
): Promise<PageLayoutCommandResult> {
return postPageCommand<PageLayoutCommandResult>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: input.pageOptions,
commandName: PAGE_COMMAND_NAMES.updateLayout,
},
"更新页面选项失败,请稍后再试",
fetchImpl,
);
}
export async function executePageBodySavePayload(
payload: DocumentSavePayload,
fetchImpl: typeof fetch = fetch,
): Promise<PageBodyPersistedMeta> {
const body = await postPageCommand<{
ok?: boolean;
revision?: number | null;
conflictDetectionKey?: string | null;
}>(
"/api/documents/save",
payload,
"页面正文保存失败",
fetchImpl,
);
return {
revision:
typeof body?.revision === "number" && Number.isInteger(body.revision) ? body.revision : payload.revision,
conflictDetectionKey:
typeof body?.conflictDetectionKey === "string" && body.conflictDetectionKey.trim()
? body.conflictDetectionKey.trim()
: payload.conflictDetectionKey,
};
}
export async function executePageBodyCommand(
input: ExecutePageBodyCommandInput,
): Promise<PageBodyPersistedMeta> {
const payload = buildDocumentSavePayload({
documentId: input.documentId,
workspaceId: input.workspaceId,
revision: input.revision,
conflictDetectionKey: input.conflictDetectionKey,
content: input.blocks,
editorDocument: undefined,
tiptapDocument: undefined,
blockCount: Array.isArray(input.blocks) ? input.blocks.length : null,
snapshotCapturedAt: new Date().toISOString(),
});
const persistedMeta = input.persistPageBody
? await input.persistPageBody(payload)
: await executePageBodySavePayload(payload, input.fetchImpl ?? fetch);
input.applyEditorSnapshot?.(input.blocks);
input.onPersistedMetaChange?.(persistedMeta);
return persistedMeta;
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { PageOptionsState } from "@/types/page-options";
import {
PAGE_OPTION_PANEL_GROUPS,
PAGE_OPTION_SEMANTICS,
pickLeptosTiptapRuntimePageOptions,
} from "@/lib/documents/page-option-semantics";
const options: PageOptionsState = {
wideLayout: true,
smallText: true,
showHeadingNumbers: false,
showToc: true,
showStructure: true,
protectEditing: true,
showWordCount: true,
collapseBacklinks: true,
pageFont: "song",
layoutDensity: "compact",
hideChildPages: true,
showBlockRefCount: true,
embedDefaultBlockId: "block-anchor-1",
};
describe("page-option-semantics", () => {
it("应固定页面设置在 inspector 中的分组,而不是让分组散落在 UI 文件里", () => {
expect(PAGE_OPTION_PANEL_GROUPS.page).toEqual([
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
]);
expect(PAGE_OPTION_PANEL_GROUPS.custom).toEqual([
"collapseBacklinks",
"hideChildPages",
"showBlockRefCount",
]);
});
it("应明确 page options 的运行时语义归属", () => {
expect(PAGE_OPTION_SEMANTICS.wideLayout.surfaces).toEqual([
"page_shell_layout",
"editor_runtime",
]);
expect(PAGE_OPTION_SEMANTICS.showToc.surfaces).toEqual(["read_view"]);
expect(PAGE_OPTION_SEMANTICS.protectEditing.runtimeSupport).toBe("planned");
expect(PAGE_OPTION_SEMANTICS.showBlockRefCount.runtimeSupport).toBe("planned");
expect(PAGE_OPTION_SEMANTICS.embedDefaultBlockId.runtimeSupport).toBe("wired");
});
it("应只把已经正式接通的 runtime 选项送入 leptos-tiptap island payload", () => {
expect(pickLeptosTiptapRuntimePageOptions(options)).toEqual({
wideLayout: true,
smallText: true,
layoutDensity: "compact",
showHeadingNumbers: false,
embedDefaultBlockId: "block-anchor-1",
});
});
});
@@ -0,0 +1,111 @@
import type {
BooleanPageOptionKey,
PageLayoutDensity,
PageOptionsState,
} from "@/types/page-options";
export type PageOptionSurface =
| "page_shell_layout"
| "read_view"
| "editor_runtime"
| "inspector_only";
export type PageOptionRuntimeSupport = "wired" | "planned" | "ui_only";
export type PageOptionSemanticDescriptor = {
surfaces: PageOptionSurface[];
runtimeSupport: PageOptionRuntimeSupport;
};
export const PAGE_OPTION_PANEL_GROUPS: {
page: BooleanPageOptionKey[];
custom: BooleanPageOptionKey[];
} = {
page: [
"wideLayout",
"smallText",
"showHeadingNumbers",
"showToc",
"protectEditing",
"showWordCount",
],
custom: ["collapseBacklinks", "hideChildPages", "showBlockRefCount"],
};
export const PAGE_OPTION_SEMANTICS: Record<
BooleanPageOptionKey | "pageFont" | "layoutDensity" | "showStructure" | "embedDefaultBlockId",
PageOptionSemanticDescriptor
> = {
wideLayout: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
smallText: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
showHeadingNumbers: {
surfaces: ["read_view", "editor_runtime"],
runtimeSupport: "wired",
},
showToc: {
surfaces: ["read_view"],
runtimeSupport: "wired",
},
showStructure: {
surfaces: ["read_view"],
runtimeSupport: "ui_only",
},
protectEditing: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "planned",
},
showWordCount: {
surfaces: ["inspector_only"],
runtimeSupport: "wired",
},
collapseBacklinks: {
surfaces: ["page_shell_layout"],
runtimeSupport: "wired",
},
pageFont: {
surfaces: ["page_shell_layout"],
runtimeSupport: "wired",
},
layoutDensity: {
surfaces: ["page_shell_layout", "editor_runtime"],
runtimeSupport: "wired",
},
hideChildPages: {
surfaces: ["page_shell_layout", "read_view"],
runtimeSupport: "wired",
},
showBlockRefCount: {
surfaces: ["editor_runtime", "inspector_only"],
runtimeSupport: "planned",
},
embedDefaultBlockId: {
surfaces: ["editor_runtime"],
runtimeSupport: "wired",
},
};
export type LeptosTiptapRuntimePageOptions = {
wideLayout: boolean;
smallText: boolean;
layoutDensity: PageLayoutDensity;
showHeadingNumbers: boolean;
embedDefaultBlockId: string | null;
};
export function pickLeptosTiptapRuntimePageOptions(
pageOptions: PageOptionsState,
): LeptosTiptapRuntimePageOptions {
return {
wideLayout: pageOptions.wideLayout,
smallText: pageOptions.smallText,
layoutDensity: pageOptions.layoutDensity,
showHeadingNumbers: pageOptions.showHeadingNumbers,
embedDefaultBlockId: pageOptions.embedDefaultBlockId,
};
}
@@ -0,0 +1,212 @@
import { describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/api-utils", () => ({
apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
message,
status,
details,
})),
}));
import {
buildDocumentCommandEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(),
}));
vi.mock("@/lib/documents/bridge-log", () => ({
recordBridgeCommandArtifacts: vi.fn(),
recordBridgeCommandFailureArtifacts: vi.fn(),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeCommandPlan: vi.fn(),
executeRustBridgeMutationTransport: vi.fn(),
}));
const mockContext: BridgeContext = {
deploymentId: null,
projectId: null,
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actor: {
actorType: "user",
actorId: "user_1",
sessionId: "sess_1",
},
source: {
channel: "next-route",
client: "vitest",
},
tenantId: null,
authToken: null,
idempotencyKey: "idem_1",
validateOnly: false,
dryRun: false,
};
describe("page-write-command-adapter", () => {
it("标题命令应走 rust bridge transport", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.head.updateTitle",
commandId: "cmd_title_1",
functionName: "documents:updateTitle",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
title: "新标题",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({ ok: true });
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.head.updateTitle",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(resolveRustBridgeCommandPlan).toHaveBeenCalledWith({
context: mockContext,
envelope: expect.objectContaining({
name: "page.head.updateTitle",
}),
});
expect(result.commandName).toBe("page.head.updateTitle");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
});
it("页面设置命令应走 bridge mutation request", async () => {
const mutation = vi.fn().mockResolvedValue({ ok: true });
const { getAuthedConvexClient } = await import("@/lib/convex/route");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation } as unknown as ConvexHttpClient,
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.layout.updateOptions",
payload: {
documentId: "doc_1",
workspaceId: "ws_1",
options: {
showToc: true,
layoutDensity: "compact",
embedDefaultBlockId: null,
},
},
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(mutation).toHaveBeenCalledTimes(1);
expect(result.commandName).toBe("page.layout.updateOptions");
expect(result.revision).toBeNull();
expect(result.conflictDetectionKey).toBeNull();
});
it("正文保存命令应返回 revision 与 conflictDetectionKey", async () => {
const { getAuthedConvexClient } = await import("@/lib/convex/route");
const {
resolveRustBridgeCommandPlan,
executeRustBridgeMutationTransport,
} = await import("@/lib/documents/rust-runtime");
vi.mocked(getAuthedConvexClient).mockResolvedValue({
auth: { userId: "user_1" },
client: { mutation: vi.fn() } as unknown as ConvexHttpClient,
});
vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
kind: "command",
commandName: "page.body.save",
commandId: "cmd_save_1",
functionName: "documents:updateContent",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{\"kind\":\"command\"}",
argsJson: {
id: "doc_1",
content: [{ id: "block_1" }],
expectedRevision: 7,
conflictDetectionKey: "conflict_1",
},
});
vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
revision: 8,
conflict_detection_key: "conflict_2",
});
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 7,
content: [{ id: "block_1" }],
conflictDetectionKey: "conflict_1",
});
const result = await executePageWriteBridgeCommand({
context: mockContext,
envelope: buildDocumentCommandEnvelope({
name: "page.body.save",
payload,
context: mockContext,
target: { workspaceId: "ws_1", pageId: "doc_1" },
}),
});
expect(result.commandName).toBe("page.body.save");
expect(result.revision).toBe(8);
expect(result.conflictDetectionKey).toBe("conflict_2");
});
});
@@ -0,0 +1,180 @@
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeMutationRequest,
executeDocumentBridgeMutationRequest,
type BridgeContext,
type CommandEnvelope,
DocumentBridgeError,
} from "@/lib/documents/bridge";
import {
recordBridgeCommandArtifacts,
recordBridgeCommandFailureArtifacts,
} from "@/lib/documents/bridge-log";
import type { DocumentOptionsUpdatePayload, DocumentTitleUpdatePayload } from "@/lib/documents/metadata-command-adapter";
import type { DocumentSavePayload } from "@/lib/documents/save-contract";
import {
executeRustBridgeMutationTransport,
resolveRustBridgeCommandPlan,
} from "@/lib/documents/rust-runtime";
type PageWritePayload =
| DocumentTitleUpdatePayload
| DocumentOptionsUpdatePayload
| DocumentSavePayload;
type MetadataMutationArgs = Record<string, unknown>;
type PageWriteAdapter<TPayload> = {
kind: "rust_transport" | "convex_mutation";
convexMutation?: unknown;
mapConvexArgs?: (payload: TPayload) => MetadataMutationArgs;
};
export type PageWriteCommandExecutionResult = {
requestId: string;
traceId: string;
commandId: string;
commandName: string;
revision: number | null;
conflictDetectionKey: string | null;
};
function mapDocumentOptionsToConvexArgs(payload: DocumentOptionsUpdatePayload) {
return {
id: payload.documentId,
options: {
wideLayout: payload.options.wideLayout,
smallText: payload.options.smallText,
showHeadingNumbers: payload.options.showHeadingNumbers,
showToc: payload.options.showToc,
showStructure: payload.options.showStructure,
protectEditing: payload.options.protectEditing,
showWordCount: payload.options.showWordCount,
collapseBacklinks: payload.options.collapseBacklinks,
pageFont: payload.options.pageFont,
layoutDensity: payload.options.layoutDensity,
hideChildPages: payload.options.hideChildPages,
showBlockRefCount: payload.options.showBlockRefCount,
embedDefaultBlockId:
typeof payload.options.embedDefaultBlockId === "string" ? payload.options.embedDefaultBlockId : null,
},
};
}
const pageWriteAdapters: Record<string, PageWriteAdapter<unknown>> = {
"page.head.updateTitle": {
kind: "rust_transport",
},
"page.layout.updateOptions": {
kind: "convex_mutation",
convexMutation: api.documents.updateOptions,
mapConvexArgs: mapDocumentOptionsToConvexArgs,
},
"page.body.save": {
kind: "rust_transport",
},
};
function getPageWriteAdapter<TPayload>(commandName: string): PageWriteAdapter<TPayload> {
const adapter = pageWriteAdapters[commandName];
if (!adapter) {
throw new Error(`未注册页面写命令适配器: ${commandName}`);
}
return adapter as PageWriteAdapter<TPayload>;
}
function normalizePersistedMeta(result: unknown): Pick<PageWriteCommandExecutionResult, "revision" | "conflictDetectionKey"> {
const record = result && typeof result === "object" ? (result as Record<string, unknown>) : null;
return {
revision:
typeof record?.revision === "number" && Number.isInteger(record.revision)
? record.revision
: null,
conflictDetectionKey:
typeof record?.conflict_detection_key === "string" && record.conflict_detection_key.trim()
? record.conflict_detection_key.trim()
: null,
};
}
export async function executePageWriteBridgeCommand<TPayload extends PageWritePayload>(input: {
context: BridgeContext;
envelope: CommandEnvelope<TPayload>;
}): Promise<PageWriteCommandExecutionResult> {
const { client } = await getAuthedConvexClient();
const adapter = getPageWriteAdapter<TPayload>(input.envelope.name);
try {
if (adapter.kind === "rust_transport") {
const plan = await resolveRustBridgeCommandPlan({
context: input.context,
envelope: input.envelope,
});
const transportResult = await executeRustBridgeMutationTransport({
client,
plan,
});
const persistedMeta = normalizePersistedMeta(transportResult);
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: persistedMeta.revision,
conflictDetectionKey: persistedMeta.conflictDetectionKey,
};
}
const mutationRequest = buildDocumentBridgeMutationRequest({
context: input.context,
envelope: input.envelope,
mapConvexArgs: adapter.mapConvexArgs!,
});
await executeDocumentBridgeMutationRequest({
client,
mutation: adapter.convexMutation!,
request: mutationRequest,
});
await recordBridgeCommandArtifacts({
context: input.context,
envelope: input.envelope,
});
return {
requestId: input.context.requestId,
traceId: input.context.traceId,
commandId: input.envelope.commandId,
commandName: input.envelope.name,
revision: null,
conflictDetectionKey: null,
};
} catch (error) {
await recordBridgeCommandFailureArtifacts({
context: input.context,
envelope: input.envelope,
client,
error,
});
if (
input.envelope.name === "page.body.save" &&
error instanceof Error &&
/正文(内容已变更|冲突检测失败)/.test(error.message)
) {
throw new DocumentBridgeError(error.message, 409, "REJECTED", {
reason: "content_conflict",
revision: (input.envelope.payload as DocumentSavePayload).revision,
conflictDetectionKey: (input.envelope.payload as DocumentSavePayload).conflictDetectionKey,
});
}
throw error;
}
}
@@ -0,0 +1,12 @@
export function shouldUseBuiltBridgeRuntimeBinary(input: {
builtBinaryMtimeMs: number | null;
latestSourceMtimeMs: number | null;
}): boolean {
if (typeof input.builtBinaryMtimeMs !== "number" || !Number.isFinite(input.builtBinaryMtimeMs)) {
return false;
}
if (typeof input.latestSourceMtimeMs !== "number" || !Number.isFinite(input.latestSourceMtimeMs)) {
return true;
}
return input.builtBinaryMtimeMs >= input.latestSourceMtimeMs;
}
@@ -0,0 +1,43 @@
import { beforeAll, describe, expect, it } from "vitest";
let runtimeSelection: Record<string, unknown> = {};
beforeAll(async () => {
try {
runtimeSelection = (await import("@/lib/documents/rust-runtime-selection")) as Record<string, unknown>;
} catch {
runtimeSelection = {};
}
});
describe("shouldUseBuiltBridgeRuntimeBinary", () => {
it("当源码比已编译二进制更新时应放弃旧二进制", () => {
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
expect(typeof decide).toBe("function");
if (typeof decide !== "function") {
return;
}
expect(
decide({
builtBinaryMtimeMs: 100,
latestSourceMtimeMs: 200,
}),
).toBe(false);
});
it("当二进制不旧于源码时仍可直接复用", () => {
const decide = runtimeSelection.shouldUseBuiltBridgeRuntimeBinary;
expect(typeof decide).toBe("function");
if (typeof decide !== "function") {
return;
}
expect(
decide({
builtBinaryMtimeMs: 300,
latestSourceMtimeMs: 200,
}),
).toBe(true);
});
});
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
import { access, readdir, stat } from "node:fs/promises";
import path from "node:path";
import type { ConvexHttpClient } from "convex/browser";
import { api } from "@/lib/convex/api";
@@ -11,6 +11,7 @@ import {
type CommandEnvelope,
type QueryEnvelope,
} from "@/lib/documents/bridge";
import { shouldUseBuiltBridgeRuntimeBinary } from "@/lib/documents/rust-runtime-selection";
export type RustRuntimeExecutedQuery<TResult = unknown> = {
ok: true;
@@ -140,6 +141,68 @@ async function resolveRepoRoot() {
throw new DocumentBridgeError("未找到 mnote 仓库根目录", 500, "TRANSPORT_ERROR");
}
async function readLatestMtimeMs(targetPath: string): Promise<number | null> {
try {
const stats = await stat(targetPath);
if (stats.isFile()) {
return stats.mtimeMs;
}
if (!stats.isDirectory()) {
return null;
}
const entries = await readdir(targetPath, { withFileTypes: true });
const nestedTimes = await Promise.all(
entries
.filter((entry) => !entry.name.startsWith("."))
.map((entry) => readLatestMtimeMs(path.join(targetPath, entry.name))),
);
const latestChild = nestedTimes.reduce<number | null>(
(current, next) => {
if (typeof next !== "number" || !Number.isFinite(next)) {
return current;
}
if (typeof current !== "number" || !Number.isFinite(current)) {
return next;
}
return Math.max(current, next);
},
null,
);
return latestChild == null ? stats.mtimeMs : Math.max(stats.mtimeMs, latestChild);
} catch {
return null;
}
}
async function readRuntimeSourceLatestMtimeMs(repoRoot: string) {
const sourceRoots = [
path.join(repoRoot, "rust", "Cargo.toml"),
path.join(repoRoot, "rust", "Cargo.lock"),
path.join(repoRoot, "rust", "crates", "bridge-runtime", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "bridge-runtime", "src"),
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "storage-convex-bridge", "src"),
path.join(repoRoot, "rust", "crates", "core-protocol", "Cargo.toml"),
path.join(repoRoot, "rust", "crates", "core-protocol", "src"),
];
const mtimes = await Promise.all(sourceRoots.map((targetPath) => readLatestMtimeMs(targetPath)));
return mtimes.reduce<number | null>(
(current, next) => {
if (typeof next !== "number" || !Number.isFinite(next)) {
return current;
}
if (typeof current !== "number" || !Number.isFinite(current)) {
return next;
}
return Math.max(current, next);
},
null,
);
}
async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const explicitBin = process.env.MNOTE_RUST_BRIDGE_BIN?.trim();
if (explicitBin) {
@@ -152,10 +215,22 @@ async function resolveRuntimeInvocation(): Promise<RuntimeInvocation> {
const repoRoot = await resolveRepoRoot();
const builtBinary = path.join(repoRoot, "rust", "target", "debug", "bridge-runtime");
if (await pathExists(builtBinary)) {
return {
command: builtBinary,
args: [],
};
const [builtBinaryMtimeMs, latestSourceMtimeMs] = await Promise.all([
readLatestMtimeMs(builtBinary),
readRuntimeSourceLatestMtimeMs(repoRoot),
]);
if (
shouldUseBuiltBridgeRuntimeBinary({
builtBinaryMtimeMs,
latestSourceMtimeMs,
})
) {
return {
command: builtBinary,
args: [],
};
}
}
return {
@@ -4,6 +4,12 @@ import type {
PageLayoutCommandInput,
PageTitleCommandInput,
} from "@/lib/documents/page-command-contract";
import {
executePageHeadCommand,
executePageLayoutCommand,
type PageHeadCommandResult,
type PageLayoutCommandResult,
} from "@/lib/documents/page-command-client";
import type { PageOptionsState } from "@/types/page-options";
type DocumentCommandMeta = {
@@ -170,23 +176,19 @@ export async function renameDocumentCommand(input: RenameDocumentInput): Promise
export async function updatePageTitleCommand(
input: PageTitleCommandInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
return renameDocumentCommand(input);
): Promise<PageHeadCommandResult> {
return executePageHeadCommand(input);
}
export async function updatePageOptionsCommand(
input: PageLayoutCommandInput | UpdatePageOptionsInput,
): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
): Promise<PageLayoutCommandResult> {
const pageOptions = "pageOptions" in input ? input.pageOptions : {};
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
"/api/documents/options",
{
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
options: pageOptions,
},
"更新页面选项失败,请稍后再试",
);
return executePageLayoutCommand({
documentId: input.documentId,
workspaceId: input.workspaceId ?? null,
pageOptions,
});
}
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
-12
View File
@@ -1,12 +0,0 @@
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
export async function ensureMnoteWebAuthCookie(): Promise<void> {
const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error("mnote-web 鉴权 cookie 准备失败");
}
}
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getMnotePublicRuntimeConfig,
getMnoteRuntimeConfig,
} from "@/lib/runtime-config";
describe("runtime-config public projection", () => {
it("不再暴露 legacy mnote-web runtime 字段", () => {
const runtime = getMnotePublicRuntimeConfig();
expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
delete process.env.MNOTE_WEB_BASE_URL;
delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
});
it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
process.env.MNOTE_WEB_BASE_URL = "http://127.0.0.1:3104";
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED = "1";
process.env.MNOTE_WEB_TREE_SHELL_ENABLED = "1";
const runtime = getMnoteRuntimeConfig();
expect("mnoteWebBaseUrl" in (runtime as Record<string, unknown>)).toBe(false);
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
});
});
+10 -38
View File
@@ -22,16 +22,6 @@ export type MnoteRuntimeConfig = {
onlyofficeProxyOriginWeb?: string;
onlyofficeCallbackOriginWeb?: string;
onlyofficeCallbackOriginDesktop?: string;
/**
* Rust Web tree shell
* SSR
*/
mnoteWebBaseUrl?: string;
/**
* Rust Web tree shell
* mnoteWebBaseUrl
*/
mnoteWebTreeShellEnabled?: boolean;
/**
* host
* leptos_tiptap_island
@@ -60,6 +50,8 @@ export type MnoteRuntimeConfig = {
onlyofficeCallbackOrigin?: string;
};
export type MnotePublicRuntimeConfig = MnoteRuntimeConfig;
declare global {
interface Window {
__MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
@@ -153,24 +145,6 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
...((process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? process.env.MNOTE_WEB_BASE_URL) !== undefined
? {
mnoteWebBaseUrl:
process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ??
process.env.MNOTE_WEB_BASE_URL,
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
) !== undefined
? {
mnoteWebTreeShellEnabled: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED ??
process.env.MNOTE_WEB_TREE_SHELL_ENABLED,
),
}
: {}),
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
@@ -278,9 +252,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
: (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
parseRuntimeBoolean(cfg.mnoteWebTreeShellEnabled) ?? false;
const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
@@ -289,8 +260,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
return {
...cfg,
isDesktop,
mnoteWebBaseUrl,
mnoteWebTreeShellEnabled,
documentEditorHost,
documentEditorBlocknoteKillSwitch,
onlyofficeBaseUrl,
@@ -302,7 +271,10 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
if (typeof window !== "undefined") {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
// 说明:浏览器侧禁止再兜底读取 NEXT_PUBLIC_MNOTE_WEB_BASE_URL / MNOTE_WEB_BASE_URL
// 否则会把 internal-only 的 mnote-web 边界重新泄漏回客户端。
// 客户端只信任服务端注入的 public runtime 配置。
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? {});
}
const isDesktop = process.env.MNOTE_DESKTOP === "1";
const publicRuntime = readFromPublicJson();
@@ -319,10 +291,10 @@ export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
: {
...publicRuntime,
...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
mnoteWebTreeShellEnabled:
publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled,
};
return normalizeRuntimeConfig(merged);
}
export function getMnotePublicRuntimeConfig(): MnotePublicRuntimeConfig {
return getMnoteRuntimeConfig();
}
@@ -0,0 +1,133 @@
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
import type { DocumentAiCapabilityConfig } from "@/lib/ai-agent/document-config";
import type { PageOptionsState } from "@/types/page-options";
type AgentMessage = { role: "user" | "assistant"; content: string };
type RequestPayload = {
maxSteps?: number;
messages: AgentMessage[];
context?: {
documentId?: string;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
};
options?: {
ai?: {
model?: string;
modelKey?: string;
profileId?: string;
sessionId?: string;
};
};
};
function resolveBackendUrl(): string | null {
const cfg = getMnoteRuntimeConfig();
const backendUrl =
process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || cfg.backendUrl;
return backendUrl?.trim().replace(/\/+$/, "") || null;
}
function buildErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "detail" in payload) {
return String((payload as Record<string, unknown>).detail ?? fallback);
}
if (payload && typeof payload === "object" && "message" in payload) {
return String((payload as Record<string, unknown>).message ?? fallback);
}
if (payload && typeof payload === "object" && "error" in payload) {
return String((payload as Record<string, unknown>).error ?? fallback);
}
return fallback;
}
export async function startDocumentAiOrchestratorRun(input: {
request?: Request;
userId: string;
payload: RequestPayload;
}): Promise<Response> {
const backendUrl = resolveBackendUrl();
if (!backendUrl) {
throw new Error("未配置 BACKEND_URL");
}
const headers = await buildForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
if (apiKey) {
headers.set("x-mnote-ai-key", apiKey);
}
const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/run`, {
method: "POST",
headers,
body: JSON.stringify({
userId: input.userId,
sessionId: String(input.payload.options?.ai?.sessionId ?? "").trim() || null,
model: String(input.payload.options?.ai?.model ?? "").trim() || null,
modelKey: String(input.payload.options?.ai?.modelKey ?? "").trim() || null,
profileId: String(input.payload.options?.ai?.profileId ?? "").trim() || null,
maxSteps: input.payload.maxSteps,
messages: input.payload.messages,
context: {
documentId: input.payload.context?.documentId ?? null,
documentBlocks: input.payload.context?.documentBlocks ?? null,
node: input.payload.context?.node ?? null,
subtree: input.payload.context?.subtree ?? null,
outline: input.payload.context?.outline ?? null,
evidence: input.payload.context?.evidence ?? null,
pageOptions: input.payload.context?.pageOptions ?? null,
editorRuntimePageOptions: input.payload.context?.pageOptions
? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
: null,
},
}),
cache: "no-store",
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(buildErrorMessage(payload, `AI orchestrator 请求失败:HTTP ${response.status}`));
}
if (!response.body) {
throw new Error("AI orchestrator 未返回事件流");
}
return response;
}
export async function fetchDocumentAiOrchestratorConfig(input?: {
request?: Request;
}): Promise<DocumentAiCapabilityConfig> {
const backendUrl = resolveBackendUrl();
if (!backendUrl) {
throw new Error("未配置 BACKEND_URL");
}
const headers = await buildForwardHeaders(input?.request);
const apiKey = (process.env.MNOTE_AI_ORCHESTRATOR_API_KEY || "").trim();
if (apiKey) {
headers.set("x-mnote-ai-key", apiKey);
}
const response = await fetch(`${backendUrl}/api/v1/ai-agent/document/config`, {
method: "GET",
headers,
cache: "no-store",
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(buildErrorMessage(payload, `AI orchestrator config 请求失败:HTTP ${response.status}`));
}
return (await response.json()) as DocumentAiCapabilityConfig;
}
@@ -0,0 +1,55 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
export async function buildForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
@@ -1,79 +0,0 @@
import { buildHermesRuntimeToolResultRequest } from "@/lib/ai-agent/hermes/tool-result-recovery";
import { buildMnoteWebForwardHeaders, getMnoteWebBaseUrl } from "@/lib/server/mnote-web";
type PlainObject = Record<string, unknown>;
function readErrorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string") {
return payload.error;
}
if (payload && typeof payload === "object" && "message" in payload && typeof payload.message === "string") {
return payload.message;
}
return fallback;
}
export async function fetchHermesStructuredToolResultFromMnoteWeb(input: {
request?: Request;
userId: string;
tool: string;
argsJson: PlainObject;
data?: unknown;
requestId: string;
traceId: string;
workspaceId?: string | null;
target?: {
workspaceId?: string | null;
pageId?: string | null;
blockId?: string | null;
} | null;
reason?: string | null;
refs?: string[];
}): Promise<unknown> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const headers = await buildMnoteWebForwardHeaders(input.request);
headers.set("Content-Type", "application/json");
const response = await fetch(new URL("/api/hermes/bridge", `${baseUrl}/`).toString(), {
method: "POST",
headers,
body: JSON.stringify(
buildHermesRuntimeToolResultRequest({
userId: input.userId,
tool: input.tool,
argsJson: input.argsJson,
data: input.data,
requestId: input.requestId,
traceId: input.traceId,
workspaceId: input.workspaceId,
target: input.target,
reason: input.reason,
refs: input.refs,
}),
),
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as
| {
ok?: boolean;
result?: unknown;
error?: string;
message?: string;
}
| null;
if (!response.ok) {
throw new Error(readErrorMessage(payload, "mnote-web Hermes runtime 请求失败"));
}
if (!payload || !("result" in payload)) {
throw new Error("mnote-web Hermes runtime 未返回 result");
}
return payload.result;
}
-143
View File
@@ -1,143 +0,0 @@
import { headers } from "next/headers";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
import { getAuthContext } from "@/lib/auth/authContext";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
type MnoteWebSidebarCompatResponse = {
ok?: boolean;
requestId?: string;
traceId?: string;
workspaceId?: string;
result?: SidebarDatasetListQueryResult;
};
function copyHeaderIfPresent(target: Headers, source: Headers, name: string) {
const value = source.get(name);
if (value) {
target.set(name, value);
}
}
export async function buildMnoteWebForwardHeaders(request?: Request): Promise<Headers> {
const source = request?.headers ?? new Headers(await headers());
const forwarded = new Headers();
copyHeaderIfPresent(forwarded, source, "cookie");
copyHeaderIfPresent(forwarded, source, "authorization");
copyHeaderIfPresent(forwarded, source, "x-request-id");
copyHeaderIfPresent(forwarded, source, "x-trace-id");
copyHeaderIfPresent(forwarded, source, "x-session-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-id");
copyHeaderIfPresent(forwarded, source, "x-mnote-actor-type");
copyHeaderIfPresent(forwarded, source, "user-agent");
if (!forwarded.has("authorization") && !isDevAuthEnabled()) {
const token = await convexAuthNextjsToken();
if (token?.trim()) {
forwarded.set("authorization", `Bearer ${token.trim()}`);
}
}
if (!forwarded.has("x-mnote-source-channel")) {
forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component");
}
if (!forwarded.has("x-mnote-source-client")) {
forwarded.set("x-mnote-source-client", "wolai-frontend");
}
if (!forwarded.has("x-mnote-actor-id")) {
try {
const auth = await getAuthContext();
if (auth.userId?.trim()) {
forwarded.set("x-mnote-actor-id", auth.userId.trim());
forwarded.set("x-mnote-actor-type", "user");
}
} catch {
// 说明:未登录或当前上下文无法解析用户时,继续走已有 header / admin fallback。
}
}
return forwarded;
}
export function getMnoteWebBaseUrl(): string | null {
const runtime = getMnoteRuntimeConfig();
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
return baseUrl || null;
}
export async function fetchSidebarDatasetFromMnoteWeb(input: {
workspaceId: string;
request?: Request;
}): Promise<{
dataset: SidebarDatasetListQueryResult;
meta: {
requestId: string | null;
traceId: string | null;
workspaceId: string;
};
}> {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/compat/next/sidebar", baseUrl);
url.searchParams.set("workspaceId", input.workspaceId);
const forwardedHeaders = await buildMnoteWebForwardHeaders(input.request);
forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId);
const response = await fetch(url, {
method: "GET",
headers: forwardedHeaders,
cache: "no-store",
});
const payload = (await response
.json()
.catch(() => null)) as MnoteWebSidebarCompatResponse | null;
if (!response.ok) {
const message =
payload && typeof (payload as Record<string, unknown>).message === "string"
? String((payload as Record<string, unknown>).message)
: "mnote-web 侧边栏兼容接口请求失败";
throw new Error(message);
}
if (!payload?.result) {
throw new Error("mnote-web /api/compat/next/sidebar 未返回 result");
}
return {
dataset: payload.result,
meta: {
requestId: payload.requestId ?? null,
traceId: payload.traceId ?? null,
workspaceId: payload.workspaceId?.trim() || input.workspaceId,
},
};
}
export function buildMnoteWebStreamUrl(input: {
workspaceId: string;
cursor?: string | null;
}): URL {
const baseUrl = getMnoteWebBaseUrl();
if (!baseUrl) {
throw new Error("未配置 MNOTE_WEB_BASE_URL");
}
const url = new URL("/api/stream/events", `${baseUrl}/`);
url.searchParams.set("stream", "workspace");
url.searchParams.set("projection", "sidebar_tree");
url.searchParams.set("workspaceId", input.workspaceId.trim());
if (typeof input.cursor === "string" && input.cursor.trim()) {
url.searchParams.set("cursor", input.cursor.trim());
}
return url;
}
+3 -16
View File
@@ -64,25 +64,12 @@ function looksLikeSidebarDatasetListQueryResult(
}
export function buildWorkspaceTreeStreamUrl(
baseUrl: string,
workspaceId: string,
cursor?: string | null,
): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const shouldUseSameOriginProxy =
normalizedBaseUrl.length > 0 &&
typeof window !== "undefined" &&
(() => {
try {
const runtimeUrl = new URL(`${normalizedBaseUrl}/`);
return runtimeUrl.origin !== window.location.origin;
} catch {
return false;
}
})();
const url = shouldUseSameOriginProxy
? new URL("/api/mnote-web/stream", window.location.origin)
: new URL("/api/mnote-web/stream", `${normalizedBaseUrl}/`);
const baseOrigin =
typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000";
const url = new URL("/api/mnote-web/stream", baseOrigin);
url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim());
@@ -19,13 +19,13 @@ describe("tree-stream/protocol", () => {
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3104/", " ws_1 ", "evt_9"),
buildWorkspaceTreeStreamUrl(" ws_1 ", "evt_9"),
).toBe(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9",
);
});
it("同源 runtime baseUrl 下仍走同源 stream route", () => {
it("固定走同源 stream route", () => {
vi.stubGlobal("window", {
...window,
location: {
@@ -34,7 +34,7 @@ describe("tree-stream/protocol", () => {
},
});
expect(
buildWorkspaceTreeStreamUrl("http://127.0.0.1:3000/", "ws_1", null),
buildWorkspaceTreeStreamUrl("ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1");
});
@@ -4,16 +4,6 @@ import { createRoot, type Root } from "react-dom/client";
import { useSidebarTreeStream } from "./use-sidebar-tree-stream";
import type { SidebarInitialData } from "@/components/sidebar/types";
const mockRuntimeConfig = vi.hoisted(() => ({
getMnoteRuntimeConfig: vi.fn(),
}));
vi.mock("@/lib/runtime-config", () => mockRuntimeConfig);
vi.mock("@/lib/mnote-web-auth", () => ({
ensureMnoteWebAuthCookie: vi.fn(async () => undefined),
}));
type MockEventListener = (event: MessageEvent<string>) => void;
class MockEventSource {
@@ -114,8 +104,12 @@ describe("useSidebarTreeStream", () => {
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
mockRuntimeConfig.getMnoteRuntimeConfig.mockReturnValue({
mnoteWebBaseUrl: "http://127.0.0.1:3104",
vi.stubGlobal("window", {
...window,
location: {
...window.location,
origin: "http://127.0.0.1:3000",
},
});
MockEventSource.instances = [];
globalThis.EventSource = MockEventSource as unknown as typeof EventSource;
@@ -2,14 +2,12 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildWorkspaceTreeStreamUrl,
normalizeTreeStreamSnapshot,
parseTreeStreamMessage,
} from "@/lib/tree-stream/protocol";
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
export interface SidebarTreeStreamState {
data: SidebarInitialData | null;
@@ -37,10 +35,8 @@ function normalizeDeltaEvent(input: unknown): TreeStreamDeltaEvent | null {
}
export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTreeStreamState {
const runtime = useMemo(() => getMnoteRuntimeConfig(), []);
const workspaceId = initialData.activeWorkspaceId;
const baseUrl = (runtime.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const streamEnabled = Boolean(baseUrl && workspaceId);
const streamEnabled = Boolean(workspaceId);
const cursorRef = useRef<string | null>(null);
const [state, setState] = useState<SidebarTreeStreamState>({
@@ -133,32 +129,18 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
}
};
void (async () => {
try {
await ensureMnoteWebAuthCookie();
if (cancelled) {
return;
}
if (cancelled) {
return undefined;
}
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
} catch {
if (cancelled) {
return;
}
setState((previous) => ({
...previous,
status: previous.data ? "live" : "fallback",
error: previous.error ?? new Error("tree stream 鉴权失败"),
}));
}
})();
const url = buildWorkspaceTreeStreamUrl(workspaceId, cursorRef.current);
eventSource = new EventSource(url, { withCredentials: true });
eventSourceRef.current = eventSource;
eventSource.addEventListener("snapshot", handleMessage as EventListener);
eventSource.addEventListener("delta", handleMessage as EventListener);
eventSource.addEventListener("resync", handleMessage as EventListener);
eventSource.onmessage = handleMessage;
eventSource.onerror = handleError;
return () => {
cancelled = true;
@@ -170,7 +152,7 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
eventSourceRef.current = null;
}
};
}, [baseUrl, streamEnabled, workspaceId]);
}, [streamEnabled, workspaceId]);
return state;
}