feat: land page aggregate and phase7 document ai mainline
- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
@@ -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
@@ -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` 主链。**
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+7
-7
@@ -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;
|
||||
},
|
||||
|
||||
BIN
Binary file not shown.
@@ -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"'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import chat, health, luckysheet_ws, tasks
|
||||
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"])
|
||||
|
||||
@@ -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,7 +5,8 @@ redis==5.1.1
|
||||
httpx==0.27.2
|
||||
python-dotenv==1.0.1
|
||||
pgvector==0.3.5
|
||||
openai==1.59.5
|
||||
openai==2.11.0
|
||||
openai-agents==0.7.0
|
||||
python-multipart==0.0.17
|
||||
pydantic-settings==2.6.1
|
||||
typing_extensions==4.12.2
|
||||
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()
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 固定走 OmniRoute。Base 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 }> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user