diff --git a/AGENTS.md b/AGENTS.md index 15905390..abf91a85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 状态迁移。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d072518b..6ca709e5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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//` -- 否则回退到 `/mnt/Data1T/mnote/wolai-frontend/public/documents//` -- 兼容旧路径时,还会尝试 `getLegacyMindmapsBaseDir()`,即 `MNOTE_DATA_DIR/mindmaps//` 或 `/mnt/Data1T/mnote/wolai-frontend/public/mindmaps//` - -保存格式: - -- 保存为 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 三条链如何从兼容态进入正式主链。** diff --git a/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md new file mode 100644 index 00000000..e2817dc5 --- /dev/null +++ b/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md @@ -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 的模型 / 设定 / 工具状态已进入正式可见配置面 diff --git a/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md b/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md new file mode 100644 index 00000000..8c596473 --- /dev/null +++ b/design/07-ai/process/7-2-phase7-structured-artifact-write-chain-v1.md @@ -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,并且能在不污染页面树真相的前提下进入文件树管理视图。** diff --git a/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md b/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md new file mode 100644 index 00000000..23bc9676 --- /dev/null +++ b/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md @@ -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` 这条当前主线闭环。** diff --git a/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md new file mode 100644 index 00000000..251c6f40 --- /dev/null +++ b/design/old/07-ai/process/7-phase7-ai-kernel-projection-plan-v1.md @@ -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` 主链。** diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index d5fd4a87..88c41967 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -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 { if let Some(editor_document) = payload.editor_document.clone() { - let mut parsed = - serde_json::from_value::(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::(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::(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 { diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index 571d7ffb..d1fb1874 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -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, @@ -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") diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs index 870cf8ee..d90378a7 100644 --- a/rust/crates/mnote-web/src/routes/bridge.rs +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index e05a16d6..07807846 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 793a4df6..67d8d943 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/editor.rs b/rust/crates/mnote-web/src/routes/editor.rs index a1d2b5dd..8af849ec 100644 --- a/rust/crates/mnote-web/src/routes/editor.rs +++ b/rust/crates/mnote-web/src/routes/editor.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index 3da51cd0..f6ff916b 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 7ffa63c4..661cdfff 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -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) } diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index 18c6b066..5e5aea2e 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -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, diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index 9e0ab2c0..1ce631cc 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -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, diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index 0b1382f4..5909f96b 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -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()), diff --git a/rust/crates/storage-convex-bridge/src/lib.rs b/rust/crates/storage-convex-bridge/src/lib.rs index b85818fd..df7c51f7 100644 --- a/rust/crates/storage-convex-bridge/src/lib.rs +++ b/rust/crates/storage-convex-bridge/src/lib.rs @@ -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 { diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index d79f4e95..c77f5b86 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -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", diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js index 22336f28..76a62252 100644 --- a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js +++ b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island.js @@ -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; }, diff --git a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm index 2909d732..a446fc47 100644 Binary files a/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm and b/rust/spikes/leptos-tiptap-spike/generated/island/mnote-leptos-tiptap-spike-island_bg.wasm differ diff --git a/scripts/task091-tree-shell-smoke.js b/scripts/task091-tree-shell-smoke.js index 43e07393..369096a2 100644 --- a/scripts/task091-tree-shell-smoke.js +++ b/scripts/task091-tree-shell-smoke.js @@ -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"'), diff --git a/scripts/task097-homepage-entry-smoke.js b/scripts/task097-homepage-entry-smoke.js index d6f1ee64..ecc9a459 100644 --- a/scripts/task097-homepage-entry-smoke.js +++ b/scripts/task097-homepage-entry-smoke.js @@ -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, }, diff --git a/scripts/task103-document-shell-cutover-smoke.js b/scripts/task103-document-shell-cutover-smoke.js index 712d4f46..d408d01f 100644 --- a/scripts/task103-document-shell-cutover-smoke.js +++ b/scripts/task103-document-shell-cutover-smoke.js @@ -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, }, diff --git a/scripts/task104-document-runtime-input-smoke.js b/scripts/task104-document-runtime-input-smoke.js index 6acf086c..edd97d85 100644 --- a/scripts/task104-document-runtime-input-smoke.js +++ b/scripts/task104-document-runtime-input-smoke.js @@ -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 }); diff --git a/scripts/task105-document-runtime-transactions-smoke.js b/scripts/task105-document-runtime-transactions-smoke.js index aecc4b92..ca389b9e 100644 --- a/scripts/task105-document-runtime-transactions-smoke.js +++ b/scripts/task105-document-runtime-transactions-smoke.js @@ -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 }); diff --git a/scripts/task106-document-runtime-slash-reference-smoke.js b/scripts/task106-document-runtime-slash-reference-smoke.js index 0f367255..7d28535c 100644 --- a/scripts/task106-document-runtime-slash-reference-smoke.js +++ b/scripts/task106-document-runtime-slash-reference-smoke.js @@ -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 }); diff --git a/scripts/task107-document-runtime-save-smoke.js b/scripts/task107-document-runtime-save-smoke.js index efdb0b79..9c18b4e4 100644 --- a/scripts/task107-document-runtime-save-smoke.js +++ b/scripts/task107-document-runtime-save-smoke.js @@ -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 }); diff --git a/scripts/task111-phase7-document-ai-online-smoke.js b/scripts/task111-phase7-document-ai-online-smoke.js new file mode 100644 index 00000000..62687a57 --- /dev/null +++ b/scripts/task111-phase7-document-ai-online-smoke.js @@ -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); +}); diff --git a/scripts/tree-shell-smoke-helpers.js b/scripts/tree-shell-smoke-helpers.js index 83a0a2df..17b633de 100644 --- a/scripts/tree-shell-smoke-helpers.js +++ b/scripts/tree-shell-smoke-helpers.js @@ -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, diff --git a/wolai-backend/app/config.py b/wolai-backend/app/config.py index d4d3e071..ceceb97d 100644 --- a/wolai-backend/app/config.py +++ b/wolai-backend/app/config.py @@ -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" diff --git a/wolai-backend/app/routers/__init__.py b/wolai-backend/app/routers/__init__.py index 39016a74..883e310f 100644 --- a/wolai-backend/app/routers/__init__.py +++ b/wolai-backend/app/routers/__init__.py @@ -1,10 +1,11 @@ from fastapi import APIRouter -from . import chat, health, luckysheet_ws, tasks - -api_router = APIRouter(prefix="/api/v1") -api_router.include_router(tasks.router, tags=["tasks"]) -api_router.include_router(chat.router, tags=["chat"]) +from . import ai_agent, chat, health, luckysheet_ws, tasks + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(tasks.router, tags=["tasks"]) +api_router.include_router(chat.router, tags=["chat"]) +api_router.include_router(ai_agent.router, tags=["ai-agent"]) root_router = APIRouter() root_router.include_router(health.router, tags=["health"]) diff --git a/wolai-backend/app/routers/ai_agent.py b/wolai-backend/app/routers/ai_agent.py new file mode 100644 index 00000000..3e971452 --- /dev/null +++ b/wolai-backend/app/routers/ai_agent.py @@ -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()) diff --git a/wolai-backend/app/services/ai_document_agent.py b/wolai-backend/app/services/ai_document_agent.py new file mode 100644 index 00000000..40abc6a7 --- /dev/null +++ b/wolai-backend/app/services/ai_document_agent.py @@ -0,0 +1,1619 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import time +from collections import deque +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit +from typing import Any, AsyncGenerator, Callable, Literal, Mapping +from uuid import uuid4 + +import httpx +from pydantic import BaseModel, Field + +from app.config import settings + +try: + from agents import ( + Agent, + ItemHelpers, + ModelResponse, + MultiProvider, + OpenAIProvider, + OpenAIResponsesModel, + RunConfig, + RunContextWrapper, + Runner, + SQLiteSession, + Usage, + function_tool, + trace, + ) + from agents.models.interface import ModelProvider + from agents.models.multi_provider import MultiProviderMap + from agents.models.openai_responses import Converter as ResponsesConverter + from agents.util._json import _to_dump_compatible +except ImportError: # pragma: no cover - 依赖缺失时走降级分支 + Agent = None + ItemHelpers = None + ModelResponse = None + MultiProvider = None + OpenAIProvider = None + OpenAIResponsesModel = None + RunConfig = None + RunContextWrapper = Any + Runner = None + SQLiteSession = None + Usage = None + function_tool = None + trace = None + MultiProviderMap = None + ModelProvider = object + ResponsesConverter = None + _to_dump_compatible = None + + +def sdk_available() -> bool: + return all( + dependency is not None + for dependency in ( + Agent, + ModelResponse, + OpenAIProvider, + OpenAIResponsesModel, + ResponsesConverter, + RunConfig, + Runner, + SQLiteSession, + Usage, + function_tool, + trace, + ) + ) + + +def has_configured_openai_api_key() -> bool: + value = get_effective_openai_api_key() + if not value: + return False + return "please-set-your-key" not in value + + +def _is_placeholder_openai_key(value: str | None) -> bool: + return "please-set-your-key" in str(value or "").strip() + + +@lru_cache(maxsize=1) +def load_codex_auth_openai_key() -> str: + auth_path = Path.home() / ".codex" / "auth.json" + try: + payload = json.loads(auth_path.read_text("utf-8")) + except Exception: + return "" + value = str(payload.get("OPENAI_API_KEY", "")).strip() if isinstance(payload, dict) else "" + if not value or _is_placeholder_openai_key(value): + return "" + return value + + +def get_effective_openai_api_key(env: Mapping[str, str] | None = None) -> str: + env_map = env if env is not None else os.environ + env_value = str(env_map.get("OPENAI_API_KEY", "")).strip() + settings_value = settings.openai_api_key.strip() + auth_value = load_codex_auth_openai_key() + + if env_value and not _is_placeholder_openai_key(env_value): + return env_value + if settings_value and not _is_placeholder_openai_key(settings_value): + return settings_value + if auth_value and not _is_placeholder_openai_key(auth_value): + return auth_value + return env_value or settings_value or auth_value + + +@lru_cache(maxsize=8) +def load_available_openai_models( + base_url: str, + api_key: str, +) -> tuple[str, ...]: + normalized_base_url = base_url.rstrip("/") + if not normalized_base_url or not api_key.strip(): + return () + + try: + with httpx.Client(timeout=10.0) as client: + response = client.get( + f"{normalized_base_url}/models", + headers={"Authorization": f"Bearer {api_key.strip()}"}, + ) + response.raise_for_status() + payload = response.json() + except Exception: + return () + + data = payload.get("data") if isinstance(payload, dict) else None + if not isinstance(data, list): + return () + + ids: list[str] = [] + for item in data: + if not isinstance(item, dict): + continue + model_id = item.get("id") + if isinstance(model_id, str) and model_id.strip(): + ids.append(model_id.strip()) + return tuple(ids) + + +@dataclass(frozen=True) +class DocumentAiModelSpec: + key: str + title: str + description: str + gateway_model: str + resolved_combo: str | None = None + resolved_runtime_model: str | None = None + status: str = "active" + is_default: bool = False + + +@dataclass(frozen=True) +class DocumentAiProfileSpec: + id: str + title: str + description: str + instructions: str + session_mode: Literal["off", "page", "workspace"] = "page" + tool_names: tuple[str, ...] = () + is_default: bool = False + status: str = "active" + + +@dataclass(frozen=True) +class DocumentAiToolSpec: + name: str + title: str + description: str + scope: Literal["document", "tree", "workspace"] = "document" + mode: Literal["read", "write"] = "read" + status: str = "active" + version: str = "v1" + + +DEFAULT_OMNIROUTE_BASE_URL = "http://localhost:20128/v1" + + +DOCUMENT_AI_MODEL_SPECS: tuple[DocumentAiModelSpec, ...] = ( + DocumentAiModelSpec( + key="gpt-5.4", + title="GPT-5.4", + description="文档页默认主写模型键,经由 OmniRoute 二次路由。", + gateway_model="gpt-5.4", + resolved_combo="slow", + resolved_runtime_model="gpt-5.4", + is_default=True, + ), + DocumentAiModelSpec( + key="gpt-5.4-mini", + title="GPT-5.4 Mini", + description="更偏快响应的轻量模型键,经由 OmniRoute 二次路由。", + gateway_model="gpt-5.4-mini", + resolved_runtime_model="gpt-5.4-mini", + ), + DocumentAiModelSpec( + key="gpt-5.3-codex", + title="GPT-5.3 Codex", + description="偏代码与结构化执行的模型键,经由 OmniRoute 二次路由。", + gateway_model="gpt-5.3-codex", + resolved_combo="codex", + resolved_runtime_model="gpt-5.3-codex", + ), + DocumentAiModelSpec( + key="gemini-3.1-pro-preview", + title="Gemini 3.1 Pro Preview", + description="偏长上下文与深推理的模型键,经由 OmniRoute 二次路由。", + gateway_model="gemini-3.1-pro-preview", + resolved_runtime_model="gemini-3.1-pro-preview", + ), + DocumentAiModelSpec( + key="third", + title="Third", + description="第三路由保底模型键,经由 OmniRoute combo 直通。", + gateway_model="third", + resolved_combo="third", + resolved_runtime_model="third", + ), +) + + +DOCUMENT_AI_TOOL_SPECS: tuple[DocumentAiToolSpec, ...] = ( + DocumentAiToolSpec( + name="doc_get", + title="读取当前页摘要", + description="读取当前页块摘要,适合先理解页面结构。", + mode="read", + ), + DocumentAiToolSpec( + name="doc_find", + title="按块查找", + description="按文本查找当前页块,适合定位待改写内容。", + mode="read", + ), + DocumentAiToolSpec( + name="doc_insert_blocks", + title="插入块", + description="向当前页插入新块,适合新增标题和段落。", + mode="write", + ), + DocumentAiToolSpec( + name="doc_replace_range", + title="改写块文本", + description="改写当前页指定块文本。", + mode="write", + ), + DocumentAiToolSpec( + name="slash_run", + title="斜杠命令", + description="执行当前页允许的斜杠命令;当前仅限重命名当前页。", + mode="write", + ), +) + + +DOCUMENT_AI_PROFILE_SPECS: tuple[DocumentAiProfileSpec, ...] = ( + DocumentAiProfileSpec( + id="page_writer_ai_first", + title="AI 主写", + description="AI 优先直接进入主编辑区起草,人负责审核和继续编辑。", + instructions=( + "当前 profile:AI 优先直接进入主编辑区起草,人负责审核和继续编辑。" + " 如果用户目标明确,优先输出可直接落入当前页的修改结果,而不是长篇空谈。" + ), + tool_names=tuple(spec.name for spec in DOCUMENT_AI_TOOL_SPECS), + is_default=True, + ), + DocumentAiProfileSpec( + id="page_writer_strict", + title="严格审慎", + description="更保守地读取、定位、改写,优先减少误写。", + instructions=( + "当前 profile:严格审慎。写入前优先读取和定位目标块;若信息不足,先解释缺口,不要冒进写入。" + ), + tool_names=tuple(spec.name for spec in DOCUMENT_AI_TOOL_SPECS), + ), + DocumentAiProfileSpec( + id="page_writer_polish", + title="润色整理", + description="更偏改写、收敛、结构整理,不主动扩写超出当前页语境的内容。", + instructions=( + "当前 profile:润色整理。优先压缩、润色、重组当前页已有内容,不主动扩写超出当前页证据范围的新事实。" + ), + tool_names=tuple(spec.name for spec in DOCUMENT_AI_TOOL_SPECS), + ), +) + + +def _document_ai_model_spec_by_key(model_key: str | None) -> DocumentAiModelSpec | None: + value = str(model_key or "").strip() + if not value: + return None + return next((spec for spec in DOCUMENT_AI_MODEL_SPECS if spec.key == value), None) + + +def _document_ai_model_spec_by_gateway_model(gateway_model: str | None) -> DocumentAiModelSpec | None: + value = str(gateway_model or "").strip() + if not value: + return None + return next((spec for spec in DOCUMENT_AI_MODEL_SPECS if spec.gateway_model == value), None) + + +def _resolve_default_document_ai_model_key( + env: Mapping[str, str] | None = None, +) -> str: + env_map = env if env is not None else os.environ + for value in _iter_candidate_model_names( + request_model=None, + configured_default_model=settings.mnote_ai_default_model, + env=env_map, + ): + spec = _document_ai_model_spec_by_key(value) or _document_ai_model_spec_by_gateway_model(value) + if spec is not None: + return spec.key + default_spec = next((spec for spec in DOCUMENT_AI_MODEL_SPECS if spec.is_default), DOCUMENT_AI_MODEL_SPECS[0]) + return default_spec.key + + +def _resolve_default_document_ai_profile_id() -> str: + default_spec = next((spec for spec in DOCUMENT_AI_PROFILE_SPECS if spec.is_default), DOCUMENT_AI_PROFILE_SPECS[0]) + return default_spec.id + + +def resolve_document_ai_profile(profile_id: str | None) -> DocumentAiProfileSpec: + value = str(profile_id or "").strip() + if value: + picked = next((spec for spec in DOCUMENT_AI_PROFILE_SPECS if spec.id == value), None) + if picked is not None: + return picked + default_id = _resolve_default_document_ai_profile_id() + return next(spec for spec in DOCUMENT_AI_PROFILE_SPECS if spec.id == default_id) + + +def build_document_ai_config_payload( + env: Mapping[str, str] | None = None, +) -> dict[str, Any]: + env_map = env if env is not None else os.environ + default_model_key = _resolve_default_document_ai_model_key(env_map) + default_profile_id = _resolve_default_document_ai_profile_id() + raw_base_url = str(env_map.get("OPENAI_BASE_URL", "")).strip() or DEFAULT_OMNIROUTE_BASE_URL + return { + "provider": "online", + "transport": "openai_responses", + "baseUrl": normalize_loopback_base_url(raw_base_url), + "sessionEnabled": True, + "defaultModelKey": default_model_key, + "defaultProfileId": default_profile_id, + "models": [ + { + "key": spec.key, + "title": spec.title, + "description": spec.description, + "gatewayModel": spec.gateway_model, + "resolvedCombo": spec.resolved_combo, + "resolvedRuntimeModel": spec.resolved_runtime_model, + "status": spec.status, + "default": spec.key == default_model_key, + } + for spec in DOCUMENT_AI_MODEL_SPECS + ], + "profiles": [ + { + "id": spec.id, + "title": spec.title, + "description": spec.description, + "sessionMode": spec.session_mode, + "toolNames": list(spec.tool_names), + "status": spec.status, + "default": spec.id == default_profile_id, + } + for spec in DOCUMENT_AI_PROFILE_SPECS + ], + "tools": [ + { + "name": spec.name, + "title": spec.title, + "description": spec.description, + "scope": spec.scope, + "mode": spec.mode, + "status": spec.status, + "version": spec.version, + } + for spec in DOCUMENT_AI_TOOL_SPECS + ], + } + + +def normalize_loopback_base_url(base_url: str) -> str: + value = str(base_url or "").strip() + if not value: + return DEFAULT_OMNIROUTE_BASE_URL + parsed = urlsplit(value) + if parsed.hostname != "127.0.0.1": + return value + netloc = parsed.netloc.replace("127.0.0.1", "localhost", 1) + return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)) + + +def _iter_candidate_model_names( + *, + request_model: str | None, + configured_default_model: str | None, + env: Mapping[str, str], +) -> tuple[str, ...]: + values = ( + (request_model or "").strip(), + (configured_default_model or "").strip(), + str(env.get("ONLINE_AI_MODEL", "")).strip(), + str(env.get("OPENAI_MODEL", "")).strip(), + str(env.get("OPENAI_DEFAULT_MODEL", "")).strip(), + str(env.get("CLAW_DEFAULT_MODEL", "")).strip(), + ) + return tuple(value for value in values if value) + + +def _extract_model_prefix(model_name: str) -> str | None: + value = str(model_name or "").strip() + if "/" not in value: + return None + prefix, _ = value.split("/", 1) + return prefix.strip() or None + + +class GatewayPrefixedOpenAIProvider(ModelProvider): + def __init__( + self, + prefix: str, + *, + api_key: str | None, + base_url: str | None, + use_responses: bool = True, + ) -> None: + self._prefix = prefix + self._api_key = api_key or "" + self._base_url = base_url or "" + self._use_responses = use_responses + + def get_model(self, model_name: str | None): + full_model_name = f"{self._prefix}/{model_name}" if model_name else self._prefix + return CompatOpenAIResponsesModel( + model=full_model_name, + openai_client=None, + model_is_explicit=True, + base_url=self._base_url, + api_key=self._api_key, + use_responses=self._use_responses, + ) + + +class DocumentAiModelProvider(ModelProvider): + def __init__( + self, + *, + provider_map: Mapping[str, ModelProvider], + api_key: str | None, + base_url: str | None, + use_responses: bool = True, + ) -> None: + self._provider_map = dict(provider_map) + self._api_key = api_key or "" + self._base_url = base_url or "" + self._use_responses = use_responses + + def get_model(self, model_name: str | None): + value = str(model_name or "").strip() + if "/" in value: + prefix, suffix = value.split("/", 1) + provider = self._provider_map.get(prefix) + if provider is None: + provider = GatewayPrefixedOpenAIProvider( + prefix, + api_key=self._api_key, + base_url=self._base_url, + use_responses=self._use_responses, + ) + return provider.get_model(suffix) + + return CompatOpenAIResponsesModel( + model=value or None, + openai_client=None, + model_is_explicit=bool(value), + base_url=self._base_url, + api_key=self._api_key, + use_responses=self._use_responses, + ) + + +def build_agent_usage_from_response_usage(response_usage: Any): + if response_usage is None: + return Usage() + + def pick(name: str, default: Any = None) -> Any: + if isinstance(response_usage, Mapping): + return response_usage.get(name, default) + return getattr(response_usage, name, default) + + input_tokens = int(pick("input_tokens", 0) or 0) + output_tokens = int(pick("output_tokens", 0) or 0) + total_tokens_raw = pick("total_tokens", None) + total_tokens = input_tokens + output_tokens + if total_tokens_raw not in (None, ""): + try: + total_tokens = int(total_tokens_raw) + except (TypeError, ValueError): + total_tokens = input_tokens + output_tokens + return Usage( + requests=1, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + input_tokens_details=pick("input_tokens_details", None), + output_tokens_details=pick("output_tokens_details", None), + ) + + +def _is_openai_omit(value: Any) -> bool: + return value is None or value.__class__.__name__ == "Omit" + + +def parse_gateway_raw_response_text(raw_text: str) -> dict[str, Any]: + text = raw_text.strip() + if not text: + raise RuntimeError("网关未返回有效响应体") + + if text.startswith("{"): + payload = json.loads(text) + if not isinstance(payload, dict): + raise RuntimeError("网关响应不是对象") + return payload + + parsed_payload: dict[str, Any] | None = None + for block in text.split("\n\n"): + lines = [ + line[len("data:") :].strip() + for line in block.splitlines() + if line.startswith("data:") + ] + if not lines: + continue + try: + data = json.loads("\n".join(lines)) + except json.JSONDecodeError: + continue + if isinstance(data, dict) and isinstance(data.get("response"), dict): + parsed_payload = data["response"] + continue + if isinstance(data, dict): + parsed_payload = data + + if parsed_payload is None: + raise RuntimeError(f"无法解析网关 Responses 返回:{text[:200]}") + return parsed_payload + + +class CompatOpenAIResponsesModel(OpenAIResponsesModel): + def __init__( + self, + model, + openai_client, + *, + model_is_explicit: bool = True, + base_url: str, + api_key: str, + use_responses: bool = True, + ) -> None: + super().__init__(model=model, openai_client=openai_client, model_is_explicit=model_is_explicit) + self._gateway_base_url = base_url.rstrip("/") + self._gateway_api_key = api_key + self._gateway_use_responses = use_responses + + async def get_response( + self, + system_instructions, + input, + model_settings, + tools, + output_schema, + handoffs, + tracing, + previous_response_id: str | None = None, + conversation_id: str | None = None, + prompt=None, + ): + list_input = ItemHelpers.input_to_new_input_list(input) + list_input = _to_dump_compatible(list_input) + list_input = self._remove_openai_responses_api_incompatible_fields(list_input) + + if model_settings.parallel_tool_calls and tools: + parallel_tool_calls = True + elif model_settings.parallel_tool_calls is False: + parallel_tool_calls = False + else: + parallel_tool_calls = None + + tool_choice = ResponsesConverter.convert_tool_choice(model_settings.tool_choice) + converted_tools = ResponsesConverter.convert_tools(tools, handoffs) + converted_tools_payload = _to_dump_compatible(converted_tools.tools) + response_format = ResponsesConverter.get_response_format(output_schema) + should_omit_model = prompt is not None and not self._model_is_explicit + should_omit_tools = prompt is not None and len(converted_tools_payload) == 0 + + include_set: set[str] = set(converted_tools.includes) + if model_settings.response_include is not None: + include_set.update(model_settings.response_include) + if model_settings.top_logprobs is not None: + include_set.add("message.output_text.logprobs") + include = list(include_set) + + payload: dict[str, Any] = { + "previous_response_id": previous_response_id, + "conversation": conversation_id, + "instructions": system_instructions, + "model": None if should_omit_model else self.model, + "input": list_input, + "include": include or None, + "tools": None if should_omit_tools else converted_tools_payload, + "prompt": prompt, + "temperature": model_settings.temperature, + "top_p": model_settings.top_p, + "truncation": model_settings.truncation, + "max_output_tokens": model_settings.max_tokens, + "tool_choice": tool_choice, + "parallel_tool_calls": parallel_tool_calls, + "text": response_format, + "store": model_settings.store, + "prompt_cache_retention": model_settings.prompt_cache_retention, + "reasoning": model_settings.reasoning, + "metadata": model_settings.metadata, + } + if model_settings.top_logprobs is not None: + payload["top_logprobs"] = model_settings.top_logprobs + if model_settings.verbosity is not None: + if isinstance(payload.get("text"), dict): + payload["text"]["verbosity"] = model_settings.verbosity + else: + payload["text"] = {"verbosity": model_settings.verbosity} + if model_settings.extra_body: + payload.update(dict(model_settings.extra_body)) + + payload = { + key: value + for key, value in payload.items() + if not _is_openai_omit(value) + } + headers = { + "Authorization": f"Bearer {self._gateway_api_key}", + "Content-Type": "application/json", + } + if isinstance(model_settings.extra_headers, Mapping): + headers.update({str(k): str(v) for k, v in model_settings.extra_headers.items()}) + + async with httpx.AsyncClient(timeout=60.0) as client: + raw_response = await client.post( + f"{self._gateway_base_url}/responses", + headers=headers, + json=payload, + params=model_settings.extra_query, + ) + raw_response.raise_for_status() + parsed_payload = parse_gateway_raw_response_text(raw_response.text) + + if isinstance(parsed_payload.get("error"), dict): + raise RuntimeError( + str(parsed_payload["error"].get("message") or "Responses API 返回错误") + ) + + return ModelResponse( + output=parsed_payload.get("output") or [], + usage=build_agent_usage_from_response_usage(parsed_payload.get("usage")), + response_id=str(parsed_payload.get("id") or "") or None, + ) + + +def wrap_openai_model_for_gateway_compat(model: Any): + if OpenAIResponsesModel is not None and isinstance(model, OpenAIResponsesModel): + return CompatOpenAIResponsesModel( + model=model.model, + openai_client=model._client, + model_is_explicit=getattr(model, "_model_is_explicit", True), + base_url=getattr(getattr(model, "_client", None), "base_url", ""), + api_key="", + ) + return model + + +def build_document_model_provider( + *, + request_model: str | None, + configured_default_model: str | None, + env: Mapping[str, str] | None = None, + available_models_loader: Callable[[str, str], list[str] | tuple[str, ...]] | None = None, +): + if not sdk_available(): + raise RuntimeError("openai-agents 未安装,无法构建文档页 model provider") + + env_map = env if env is not None else os.environ + base_url = str(env_map.get("OPENAI_BASE_URL", "")).strip() + api_key = get_effective_openai_api_key(env_map) + loader = available_models_loader or load_available_openai_models + available_models = list(loader(base_url, api_key)) if base_url and api_key else [] + + prefixes: set[str] = set() + for model_name in _iter_candidate_model_names( + request_model=request_model, + configured_default_model=configured_default_model, + env=env_map, + ): + prefix = _extract_model_prefix(model_name) + if prefix: + prefixes.add(prefix) + for model_name in available_models: + prefix = _extract_model_prefix(str(model_name)) + if prefix: + prefixes.add(prefix) + provider_map: dict[str, ModelProvider] = {} + for prefix in sorted(prefixes): + if prefix in {"openai", "litellm"}: + continue + provider_map[prefix] = GatewayPrefixedOpenAIProvider( + prefix, + api_key=api_key, + base_url=base_url, + use_responses=True, + ) + + return DocumentAiModelProvider( + provider_map=provider_map, + api_key=api_key, + base_url=base_url, + use_responses=True, + ) + + +def resolve_document_agent_model( + *, + request_model: str | None, + configured_default_model: str | None, + env: Mapping[str, str] | None = None, + available_models_loader: Callable[[str, str], list[str] | tuple[str, ...]] | None = None, +) -> str: + env_map = env if env is not None else os.environ + candidates = _iter_candidate_model_names( + request_model=request_model, + configured_default_model=configured_default_model, + env=env_map, + ) + raw_model = candidates[0] if candidates else "" + + if not raw_model: + raise RuntimeError( + "未配置文档页 AI model;请设置 MNOTE_AI_DEFAULT_MODEL 或在请求中显式传入 provider/model" + ) + + if "/" in raw_model: + return raw_model + + base_url = str(env_map.get("OPENAI_BASE_URL", "")).strip() + api_key = get_effective_openai_api_key(env_map) + if not base_url or not api_key: + raise RuntimeError( + f"文档页 AI model '{raw_model}' 缺少 provider 前缀,且当前无法查询可用模型;请显式设置 provider/model" + ) + + loader = available_models_loader or load_available_openai_models + available_models = [model.strip() for model in loader(base_url, api_key) if str(model).strip()] + if not available_models: + raise RuntimeError( + f"文档页 AI model '{raw_model}' 缺少 provider 前缀,且当前无法查询可用模型;请显式设置 provider/model" + ) + + # 当前 sidecar 走 Responses API,优先选择网关上可用的 responses provider 前缀。 + responses_model = f"ju/{raw_model}" + if responses_model in available_models: + return responses_model + + suffix_matches = sorted( + { + model + for model in available_models + if model.endswith(f"/{raw_model}") + } + ) + if len(suffix_matches) == 1: + return suffix_matches[0] + if len(suffix_matches) > 1: + raise RuntimeError( + f"文档页 AI model '{raw_model}' 存在多个候选,请使用 provider/model 显式指定" + ) + + raise RuntimeError( + f"未找到可用的文档页 AI model '{raw_model}';请设置 MNOTE_AI_DEFAULT_MODEL 或在请求中显式传入 provider/model" + ) + + +def resolve_document_agent_request_model( + *, + request_model: str | None, + request_model_key: str | None, + configured_default_model: str | None, + env: Mapping[str, str] | None = None, + available_models_loader: Callable[[str, str], list[str] | tuple[str, ...]] | None = None, +) -> str: + direct_model_key = str(request_model_key or "").strip() + if direct_model_key: + spec = _document_ai_model_spec_by_key(direct_model_key) + if spec is not None: + return spec.gateway_model + return direct_model_key + + direct_request_model = str(request_model or "").strip() + if direct_request_model: + spec = _document_ai_model_spec_by_key(direct_request_model) + if spec is not None: + return spec.gateway_model + + return resolve_document_agent_model( + request_model=request_model, + configured_default_model=configured_default_model, + env=env, + available_models_loader=available_models_loader, + ) + + +def make_id(prefix: str) -> str: + return f"{prefix}_{uuid4().hex}" + + +def to_sse_frame(event: str, data: Any) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +class DocumentAiMessage(BaseModel): + role: Literal["user", "assistant"] + content: str + + +class DocumentAiContextPayload(BaseModel): + document_id: str | None = Field(default=None, alias="documentId") + document_blocks: Any = Field(default=None, alias="documentBlocks") + node: Any = None + subtree: Any = None + outline: Any = None + evidence: Any = None + page_options: Any = Field(default=None, alias="pageOptions") + editor_runtime_page_options: Any = Field( + default=None, + alias="editorRuntimePageOptions", + ) + + model_config = { + "populate_by_name": True, + } + + +class DocumentAiRunRequest(BaseModel): + user_id: str = Field(alias="userId") + session_id: str | None = Field(default=None, alias="sessionId") + model: str | None = None + model_key: str | None = Field(default=None, alias="modelKey") + profile_id: str | None = Field(default=None, alias="profileId") + max_steps: int = Field(default=10, alias="maxSteps") + messages: list[DocumentAiMessage] + context: DocumentAiContextPayload + + model_config = { + "populate_by_name": True, + } + + +class InsertBlockSpec(BaseModel): + block_type: Literal["paragraph", "heading"] = Field( + default="paragraph", + alias="type", + ) + text: str = "" + level: int = 2 + + model_config = { + "populate_by_name": True, + } + + +@dataclass +class DocumentAiEmitter: + queue: asyncio.Queue[tuple[str, Any] | None] = field( + default_factory=asyncio.Queue, + ) + tool_count: int = 0 + _tool_sequence: int = 0 + + async def emit(self, event: str, data: Any) -> None: + await self.queue.put((event, data)) + + async def close(self) -> None: + await self.queue.put(None) + + def next_tool_id(self, tool: str) -> str: + self._tool_sequence += 1 + return f"agents_{tool}_{self._tool_sequence}" + + +@dataclass +class DocumentAiRunContext: + user_id: str + request_id: str + trace_id: str + session_id: str | None + workspace_id: str | None + document_id: str | None + document_blocks: Any + node: Any + subtree: Any + outline: Any + evidence: Any + page_options: Any + editor_runtime_page_options: Any + forward_headers: dict[str, str] + emitter: DocumentAiEmitter + bridge: "LocalBridgeRuntime" + + +@dataclass(frozen=True) +class BridgeRuntimeInvocation: + command: str + args: tuple[str, ...] + cwd: str + + +@lru_cache(maxsize=1) +def resolve_bridge_repo_root() -> Path: + repo_root = Path(__file__).resolve().parents[3] + manifest = repo_root / "rust" / "Cargo.toml" + if not manifest.exists(): + raise RuntimeError(f"未找到 Rust bridge manifest: {manifest}") + return repo_root + + +@lru_cache(maxsize=1) +def resolve_bridge_runtime_invocation() -> BridgeRuntimeInvocation: + repo_root = resolve_bridge_repo_root() + explicit_bin = str(os.environ.get("MNOTE_RUST_BRIDGE_BIN", "")).strip() + if explicit_bin: + return BridgeRuntimeInvocation( + command=explicit_bin, + args=(), + cwd=str(repo_root), + ) + + for candidate in ( + repo_root / "rust" / "target" / "debug" / "bridge-runtime", + repo_root / "rust" / "target" / "release" / "bridge-runtime", + ): + if candidate.exists(): + return BridgeRuntimeInvocation( + command=str(candidate), + args=(), + cwd=str(repo_root), + ) + + return BridgeRuntimeInvocation( + command="cargo", + args=( + "run", + "--quiet", + "--manifest-path", + str(repo_root / "rust" / "Cargo.toml"), + "-p", + "bridge-runtime", + "--", + ), + cwd=str(repo_root), + ) + + +async def run_bridge_runtime(input_payload: dict[str, Any]) -> dict[str, Any]: + invocation = resolve_bridge_runtime_invocation() + payload = json.dumps(input_payload, ensure_ascii=False).encode("utf-8") + + try: + process = await asyncio.create_subprocess_exec( + invocation.command, + *invocation.args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=invocation.cwd, + env={ + **os.environ, + "CARGO_TERM_COLOR": "never", + }, + ) + except Exception as error: + raise RuntimeError(f"bridge-runtime 启动失败: {error}") from error + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(payload), + timeout=30.0, + ) + except asyncio.TimeoutError as error: + process.kill() + with contextlib.suppress(ProcessLookupError): + await process.wait() + raise RuntimeError("bridge-runtime 执行超时") from error + + stdout_text = stdout.decode("utf-8", errors="replace").strip() + stderr_text = stderr.decode("utf-8", errors="replace").strip() + if not stdout_text: + raise RuntimeError(stderr_text or "bridge-runtime 未返回任何结果") + + try: + response = json.loads(stdout_text) + except json.JSONDecodeError as error: + raise RuntimeError(f"bridge-runtime 返回非法 JSON: {error}") from error + + if not isinstance(response, dict): + raise RuntimeError("bridge-runtime 返回格式非法") + + if process.returncode != 0 or not bool(response.get("ok")): + error_payload = response.get("error") + if isinstance(error_payload, dict): + message = str(error_payload.get("message", "")).strip() + if message: + raise RuntimeError(message) + raise RuntimeError( + stderr_text or f"bridge-runtime 执行失败(code={process.returncode})" + ) + + return response + + +def infer_tool_invocation_kind(tool: str) -> Literal["command", "query", "job"]: + if tool in {"doc_get", "doc_find"}: + return "query" + return "command" + + +class LocalBridgeRuntime: + async def execute_tool( + self, + *, + tool: str, + context: DocumentAiRunContext, + invocation_kind: Literal["command", "query", "job"] | None = None, + args_json: dict[str, Any], + data: Any = None, + target: dict[str, Any] | None = None, + reason: str | None = None, + refs: list[str] | None = None, + ) -> Any: + payload = { + "kind": "tool", + "context": { + "deploymentId": None, + "projectId": None, + "workspaceId": context.workspace_id, + "requestId": context.request_id, + "traceId": context.trace_id, + "actor": { + "actorType": "user", + "actorId": context.user_id, + "sessionId": context.session_id, + }, + "source": { + "channel": "ai_orchestrator", + "client": "wolai-backend", + }, + "tenantId": None, + "authToken": None, + "idempotencyKey": None, + "validateOnly": False, + "dryRun": False, + }, + "tool": { + "tool": tool, + "kind": invocation_kind or infer_tool_invocation_kind(tool), + "mode": "result", + "argsJson": args_json, + "target": { + "workspaceId": context.workspace_id, + "pageId": target.get("pageId") if target else None, + "blockId": target.get("blockId") if target else None, + } + if target + else None, + "reason": reason, + "refs": refs or [], + }, + } + if data is not None: + payload["data"] = data + + response = await run_bridge_runtime(payload) + if "result" not in response: + raise RuntimeError("bridge-runtime 未返回 result") + return response["result"] + + +def _safe_json_preview(label: str, value: Any, limit: int) -> str | None: + if value is None: + return None + try: + text = json.dumps(value, ensure_ascii=False) + except TypeError: + return f"{label}=provided" + return f"{label}={text[:limit]}" + + +def _extract_inline_text(block: Any) -> str: + if not isinstance(block, dict): + return "" + content = block.get("content") + if not isinstance(content, list): + return "" + parts: list[str] = [] + for node in content: + if isinstance(node, dict) and isinstance(node.get("text"), str): + parts.append(node["text"]) + return "".join(parts).strip() + + +def _normalize_blocks(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, dict) and isinstance(value.get("blocks"), list): + return value["blocks"] + return [] + + +def _summarize_blocks(value: Any, limit: int = 24) -> list[dict[str, Any]]: + root_blocks = _normalize_blocks(value) + queue: deque[tuple[Any, int]] = deque((block, 0) for block in root_blocks) + summaries: list[dict[str, Any]] = [] + while queue and len(summaries) < limit: + block, depth = queue.popleft() + if not isinstance(block, dict): + continue + block_id = str(block.get("id") or "").strip() + block_type = str(block.get("type") or "unknown").strip() + children = block.get("children") + child_list = children if isinstance(children, list) else [] + if block_id: + summaries.append( + { + "id": block_id, + "type": block_type, + "depth": depth, + "text": _extract_inline_text(block)[:120], + "childCount": len(child_list), + } + ) + for child in child_list: + queue.append((child, depth + 1)) + return summaries + + +def build_document_agent_instructions( + request: DocumentAiRunRequest, +) -> str: + profile = resolve_document_ai_profile(request.profile_id) + lines = [ + "你是 mnote 的文档页 AI 编写代理。请始终使用简体中文。", + profile.instructions, + "你的工作对象只限当前文档页,不要假设你能跨页批量操作,也不要创建新的产品契约。", + "需要理解当前页内容时,先用 doc_get 或 doc_find;需要写入时,只用 doc_insert_blocks、doc_replace_range、slash_run。", + "只有在用户明确要求改标题时,才允许用 slash_run,并且只允许对当前 documentId 生成 /rename 命令。", + "如果工具没有返回成功结果,不要声称页面已经写入完成。", + ] + + latest_user_message = next( + ( + message.content.strip() + for message in reversed(request.messages) + if message.role == "user" and message.content.strip() + ), + "", + ) + normalized_latest_user_message = latest_user_message.lower() + hard_rules: list[str] = [] + if request.context.document_id and ( + "标题" in latest_user_message or "rename" in normalized_latest_user_message + ): + hard_rules.extend( + [ + "当前请求包含标题修改意图;你必须调用一次 slash_run,不允许只返回文字说明。", + f"slash_run 的 text 必须严格使用这个格式:/rename {request.context.document_id} <新标题>。", + "只有在 slash_run 返回成功后,才能用一句简短中文确认标题已修改。", + ] + ) + if "blockid" in normalized_latest_user_message and ( + "改写" in latest_user_message or "替换" in latest_user_message + ): + hard_rules.extend( + [ + "当前请求要求按 blockId 改写正文;你必须直接调用 doc_replace_range,不要只返回文字说明。", + "如果用户消息里已经给了 blockId 和目标文本,就不要先调用 doc_find。", + "只有在 doc_replace_range 返回成功后,才能用一句简短中文确认正文已修改。", + ] + ) + if ("插入" in latest_user_message or "新增" in latest_user_message) and ( + "正文" in latest_user_message or "段" in latest_user_message + ): + hard_rules.extend( + [ + "当前请求要求新增正文;你必须调用 doc_insert_blocks,不允许只返回文字说明。", + "doc_insert_blocks 至少要插入一个 paragraph 或 heading block;除非用户明确要求,不要顺手改标题。", + "只有在 doc_insert_blocks 返回成功后,才能用一句简短中文确认正文已写入。", + ] + ) + if hard_rules: + lines.append("当前请求的强制执行规则:\n- " + "\n- ".join(hard_rules)) + + context = request.context + lines.append(f"profileId={profile.id}") + if context.document_id: + lines.append(f"documentId={context.document_id}") + lines.append( + "documentBlockSummaries=" + + json.dumps( + _summarize_blocks(context.document_blocks), + ensure_ascii=False, + ) + ) + for label, value, limit in ( + ("pageOptions", context.page_options, 1600), + ("editorRuntimePageOptions", context.editor_runtime_page_options, 1200), + ("node", context.node, 1600), + ("subtree", context.subtree, 2400), + ("outline", context.outline, 2400), + ("evidence", context.evidence, 2400), + ): + preview = _safe_json_preview(label, value, limit) + if preview: + lines.append(preview) + return "\n\n".join(lines).strip() + + +def _trim_messages(messages: list[DocumentAiMessage]) -> list[dict[str, str]]: + return [ + {"role": message.role, "content": message.content} + for message in messages[-24:] + if message.content.strip() + ] + + +async def _emit_tool_event( + ctx: DocumentAiRunContext, + tool: str, + args_json: dict[str, Any], + execute: Callable[[], Any], +) -> Any: + tool_id = ctx.emitter.next_tool_id(tool) + await ctx.emitter.emit( + "tool_call", + { + "id": tool_id, + "tool": tool, + "args": args_json, + }, + ) + + started = time.perf_counter() + try: + result = await execute() + except Exception as error: + elapsed_ms = max(0, round((time.perf_counter() - started) * 1000)) + await ctx.emitter.emit( + "tool_result", + { + "id": tool_id, + "tool": tool, + "ok": False, + "ms": elapsed_ms, + "result": {"error": str(error)}, + }, + ) + raise + + ctx.emitter.tool_count += 1 + elapsed_ms = max(0, round((time.perf_counter() - started) * 1000)) + await ctx.emitter.emit( + "tool_result", + { + "id": tool_id, + "tool": tool, + "ok": True, + "ms": elapsed_ms, + "result": result, + }, + ) + return result + + +def build_document_tools(): + if not sdk_available(): + raise RuntimeError("openai-agents 未安装,无法构建文档页 tools") + + @function_tool + async def doc_get( + wrapper: RunContextWrapper[DocumentAiRunContext], + maxBlocks: int = 80, + ) -> Any: + """读取当前页的块摘要。需要先理解页面结构时调用。""" + + ctx = wrapper.context + args_json = {"maxBlocks": max(10, min(int(maxBlocks), 240))} + return await _emit_tool_event( + ctx, + "doc_get", + args_json, + lambda: ctx.bridge.execute_tool( + tool="doc_get", + context=ctx, + invocation_kind="query", + args_json=args_json, + data=ctx.document_blocks, + target={"pageId": ctx.document_id}, + ), + ) + + @function_tool + async def doc_find( + wrapper: RunContextWrapper[DocumentAiRunContext], + query: str, + maxResults: int = 8, + ) -> Any: + """按文本查找当前页的块。准备改写某段内容前先调用。""" + + ctx = wrapper.context + query_text = str(query).strip() + if not query_text: + raise RuntimeError("doc_find 缺少 query") + args_json = { + "query": query_text, + "maxResults": max(1, min(int(maxResults), 30)), + } + return await _emit_tool_event( + ctx, + "doc_find", + args_json, + lambda: ctx.bridge.execute_tool( + tool="doc_find", + context=ctx, + invocation_kind="query", + args_json=args_json, + data=ctx.document_blocks, + target={"pageId": ctx.document_id}, + ), + ) + + @function_tool + async def doc_insert_blocks( + wrapper: RunContextWrapper[DocumentAiRunContext], + blocks: list[InsertBlockSpec], + afterBlockId: str | None = None, + beforeBlockId: str | None = None, + ) -> Any: + """在当前页插入新块。新增标题或段落时调用。""" + + ctx = wrapper.context + if not blocks: + raise RuntimeError("doc_insert_blocks 缺少 blocks") + args_json = { + "blocks": [ + { + "type": block.block_type, + "text": block.text, + "level": block.level, + } + for block in blocks[:20] + ], + } + if afterBlockId: + args_json["afterBlockId"] = afterBlockId + if beforeBlockId: + args_json["beforeBlockId"] = beforeBlockId + return await _emit_tool_event( + ctx, + "doc_insert_blocks", + args_json, + lambda: ctx.bridge.execute_tool( + tool="doc_insert_blocks", + context=ctx, + invocation_kind="command", + args_json=args_json, + data=ctx.document_blocks, + target={"pageId": ctx.document_id}, + ), + ) + + @function_tool + async def doc_replace_range( + wrapper: RunContextWrapper[DocumentAiRunContext], + blockId: str, + text: str, + mode: Literal["replace", "append", "prepend"] = "replace", + ) -> Any: + """改写当前页某个块的文本。""" + + ctx = wrapper.context + block_id = str(blockId).strip() + content = str(text).strip() + if not block_id or not content: + raise RuntimeError("doc_replace_range 缺少 blockId 或 text") + safe_mode = mode if mode in {"replace", "append", "prepend"} else "replace" + args_json = { + "blockId": block_id, + "text": content, + "mode": safe_mode, + } + return await _emit_tool_event( + ctx, + "doc_replace_range", + args_json, + lambda: ctx.bridge.execute_tool( + tool="doc_replace_range", + context=ctx, + invocation_kind="command", + args_json=args_json, + data=ctx.document_blocks, + target={"pageId": ctx.document_id, "blockId": block_id}, + ), + ) + + @function_tool + async def slash_run( + wrapper: RunContextWrapper[DocumentAiRunContext], + text: str, + ) -> Any: + """执行当前页允许的斜杠命令。当前只允许改当前页标题。""" + + ctx = wrapper.context + command_text = str(text).strip() + if not command_text.startswith("/rename "): + raise RuntimeError("当前只允许 /rename 当前页标题") + if ctx.document_id and f"/rename {ctx.document_id} " not in command_text: + raise RuntimeError("slash_run 只允许改当前文档页标题") + args_json = {"text": command_text} + return await _emit_tool_event( + ctx, + "slash_run", + args_json, + lambda: ctx.bridge.execute_tool( + tool="slash_run", + context=ctx, + invocation_kind="command", + args_json=args_json, + data={"source": "ai-orchestrator"}, + target={"pageId": ctx.document_id}, + ), + ) + + return [ + doc_get, + doc_find, + doc_insert_blocks, + doc_replace_range, + slash_run, + ] + + +def build_document_agent( + request: DocumentAiRunRequest, +): + if not sdk_available(): + raise RuntimeError("openai-agents 未安装,无法构建文档页 agent") + + kwargs: dict[str, Any] = { + "name": "mnote-document-page-writer", + "instructions": build_document_agent_instructions(request), + "tools": build_document_tools(), + } + kwargs["model"] = resolve_document_agent_request_model( + request_model=request.model, + request_model_key=request.model_key, + configured_default_model=settings.mnote_ai_default_model, + ) + return Agent[DocumentAiRunContext](**kwargs) + + +def build_forward_headers(source_headers: dict[str, str]) -> dict[str, str]: + allowed = { + "authorization", + "cookie", + "x-request-id", + "x-trace-id", + "x-session-id", + "x-mnote-workspace-id", + "x-mnote-source-channel", + "x-mnote-source-client", + "x-mnote-actor-id", + "x-mnote-actor-type", + "user-agent", + } + return { + key: value + for key, value in source_headers.items() + if key.lower() in allowed and value + } + + +def build_session(session_id: str | None): + if not sdk_available() or not session_id: + return None + + raw_path = settings.openai_agents_session_db_path.strip() + db_path = ( + Path(raw_path) + if raw_path + else Path(__file__).resolve().parents[2] / ".data" / "openai-agents-sessions.sqlite3" + ) + db_path.parent.mkdir(parents=True, exist_ok=True) + return SQLiteSession(session_id, str(db_path)) + + +async def run_document_agent_stream( + request: DocumentAiRunRequest, + *, + source_headers: dict[str, str], +) -> AsyncGenerator[str, None]: + if not sdk_available(): + raise RuntimeError("openai-agents 未安装") + if not has_configured_openai_api_key(): + raise RuntimeError("后端未配置 OPENAI_API_KEY") + effective_api_key = get_effective_openai_api_key() + if effective_api_key: + os.environ.setdefault("OPENAI_API_KEY", effective_api_key) + + emitter = DocumentAiEmitter() + run_context = DocumentAiRunContext( + user_id=request.user_id, + request_id=make_id("req"), + trace_id=make_id("trace"), + session_id=request.session_id, + workspace_id=source_headers.get("x-mnote-workspace-id") or None, + document_id=request.context.document_id, + document_blocks=request.context.document_blocks, + node=request.context.node, + subtree=request.context.subtree, + outline=request.context.outline, + evidence=request.context.evidence, + page_options=request.context.page_options, + editor_runtime_page_options=request.context.editor_runtime_page_options, + forward_headers=build_forward_headers(source_headers), + emitter=emitter, + bridge=LocalBridgeRuntime(), + ) + + async def worker() -> None: + try: + agent = build_document_agent(request) + run_config = RunConfig( + model_provider=build_document_model_provider( + request_model=request.model, + configured_default_model=settings.mnote_ai_default_model, + ) + ) + session = build_session(request.session_id) + run_input = _trim_messages(request.messages) + if not run_input: + raise RuntimeError("缺少 messages") + + max_turns = max(1, min(int(request.max_steps), 12)) + with trace("mnote-document-ai-run", group_id=run_context.trace_id): + result = await Runner.run( + agent, + input=run_input, + context=run_context, + session=session, + max_turns=max_turns, + run_config=run_config, + ) + + text = str(result.final_output or "").strip() or "(无输出)" + await emitter.emit("assistant_message", {"text": text}) + await emitter.emit( + "completion", + { + "ok": True, + "text": text, + "steps": max(1, emitter.tool_count or 1), + }, + ) + except Exception as error: + await emitter.emit( + "error", + { + "ok": False, + "message": str(error), + }, + ) + finally: + await emitter.close() + + task = asyncio.create_task(worker()) + try: + while True: + item = await emitter.queue.get() + if item is None: + break + event, data = item + yield to_sse_frame(event, data) + finally: + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/wolai-backend/requirements.txt b/wolai-backend/requirements.txt index 606394e0..57626157 100644 --- a/wolai-backend/requirements.txt +++ b/wolai-backend/requirements.txt @@ -5,7 +5,8 @@ redis==5.1.1 httpx==0.27.2 python-dotenv==1.0.1 pgvector==0.3.5 -openai==1.59.5 -python-multipart==0.0.17 -pydantic-settings==2.6.1 -typing_extensions==4.12.2 +openai==2.11.0 +openai-agents==0.7.0 +python-multipart==0.0.17 +pydantic-settings==2.6.1 +typing_extensions==4.15.0 diff --git a/wolai-backend/tests/test_ai_document_agent.py b/wolai-backend/tests/test_ai_document_agent.py new file mode 100644 index 00000000..a6166630 --- /dev/null +++ b/wolai-backend/tests/test_ai_document_agent.py @@ -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() diff --git a/wolai-frontend/public/mnote-env.json b/wolai-frontend/public/mnote-env.json index 222f47be..323263e2 100644 --- a/wolai-frontend/public/mnote-env.json +++ b/wolai-frontend/public/mnote-env.json @@ -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", diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index 3248a831..4268130b 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -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>; } -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({ - 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({ + 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 (
diff --git a/wolai-frontend/src/app/api/ai-agent/document/config/route.test.ts b/wolai-frontend/src/app/api/ai-agent/document/config/route.test.ts new file mode 100644 index 00000000..7d0e1ff9 --- /dev/null +++ b/wolai-frontend/src/app/api/ai-agent/document/config/route.test.ts @@ -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"); + }); +}); diff --git a/wolai-frontend/src/app/api/ai-agent/document/config/route.ts b/wolai-frontend/src/app/api/ai-agent/document/config/route.ts new file mode 100644 index 00000000..5f027352 --- /dev/null +++ b/wolai-frontend/src/app/api/ai-agent/document/config/route.ts @@ -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 }); + } +} diff --git a/wolai-frontend/src/app/api/ai-agent/run/route.test.ts b/wolai-frontend/src/app/api/ai-agent/run/route.test.ts index 30563407..81ae4144 100644 --- a/wolai-frontend/src/app/api/ai-agent/run/route.test.ts +++ b/wolai-frontend/src/app/api/ai-agent/run/route.test.ts @@ -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("@/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"); + }); }); diff --git a/wolai-frontend/src/app/api/ai-agent/run/route.ts b/wolai-frontend/src/app/api/ai-agent/run/route.ts index 36f285e0..9f88330a 100644 --- a/wolai-frontend/src/app/api/ai-agent/run/route.ts +++ b/wolai-frontend/src/app/api/ai-agent/run/route.ts @@ -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({ + 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 extends Promise + ? T["client"] + : never; tool: string; argsJson: Record | 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; diff --git a/wolai-frontend/src/app/api/documents/options/route.ts b/wolai-frontend/src/app/api/documents/options/route.ts index 3addf0ca..4905080c 100644 --- a/wolai-frontend/src/app/api/documents/options/route.ts +++ b/wolai-frontend/src/app/api/documents/options/route.ts @@ -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, }); diff --git a/wolai-frontend/src/app/api/documents/page/route.ts b/wolai-frontend/src/app/api/documents/page/route.ts new file mode 100644 index 00000000..603aa6e1 --- /dev/null +++ b/wolai-frontend/src/app/api/documents/page/route.ts @@ -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 }); +} diff --git a/wolai-frontend/src/app/api/documents/route-adapters.test.ts b/wolai-frontend/src/app/api/documents/route-adapters.test.ts index b061af9e..a311e728 100644 --- a/wolai-frontend/src/app/api/documents/route-adapters.test.ts +++ b/wolai-frontend/src/app/api/documents/route-adapters.test.ts @@ -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(); + }); }); diff --git a/wolai-frontend/src/app/api/documents/save/route.ts b/wolai-frontend/src/app/api/documents/save/route.ts index de60e66b..ba2ce5ef 100644 --- a/wolai-frontend/src/app/api/documents/save/route.ts +++ b/wolai-frontend/src/app/api/documents/save/route.ts @@ -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, }); diff --git a/wolai-frontend/src/app/api/documents/title/route.ts b/wolai-frontend/src/app/api/documents/title/route.ts index 3f6b6de5..89365423 100644 --- a/wolai-frontend/src/app/api/documents/title/route.ts +++ b/wolai-frontend/src/app/api/documents/title/route.ts @@ -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, }); diff --git a/wolai-frontend/src/app/api/mnote-web/stream/route.test.ts b/wolai-frontend/src/app/api/mnote-web/stream/route.test.ts index 7cca1de7..44b80c95 100644 --- a/wolai-frontend/src/app/api/mnote-web/stream/route.test.ts +++ b/wolai-frontend/src/app/api/mnote-web/stream/route.test.ts @@ -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); }); }); diff --git a/wolai-frontend/src/app/api/mnote-web/stream/route.ts b/wolai-frontend/src/app/api/mnote-web/stream/route.ts index 788d0bde..9f3c92e0 100644 --- a/wolai-frontend/src/app/api/mnote-web/stream/route.ts +++ b/wolai-frontend/src/app/api/mnote-web/stream/route.ts @@ -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); diff --git a/wolai-frontend/src/app/layout.tsx b/wolai-frontend/src/app/layout.tsx index 6106542b..b625eb2d 100644 --- a/wolai-frontend/src/app/layout.tsx +++ b/wolai-frontend/src/app/layout.tsx @@ -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(/ { + 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"); + }); +}); diff --git a/wolai-frontend/src/components/ai-agent/panelShared.ts b/wolai-frontend/src/components/ai-agent/panelShared.ts index d3589c73..134687eb 100644 --- a/wolai-frontend/src/components/ai-agent/panelShared.ts +++ b/wolai-frontend/src/components/ai-agent/panelShared.ts @@ -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 diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx index 6a4768c0..b05219bd 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx @@ -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(), diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx index c4493109..af6e355c 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx @@ -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; + applyPageBodyCommandImpl?: (input: ExecutePageBodyCommandInput) => Promise; }): Promise { 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) : 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(10); const [aiProvider, setAiProvider] = useState("online"); const [aiModel, setAiModel] = useState(""); + const [aiModelKey, setAiModelKey] = useState(""); + const [aiProfileId, setAiProfileId] = useState(""); const [page, setPage] = useState("chat"); + const [onlineConfig, setOnlineConfig] = useState(null); + const [onlineConfigLoading, setOnlineConfigLoading] = useState(false); + const [onlineConfigError, setOnlineConfigError] = useState(""); const [toolLogs, setToolLogs] = useState([]); const [sessions, setSessions] = useState([]); @@ -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 | 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" ? (
-
- -
- - {!toolAuto ? ( -
-
允许使用的工具(手动模式)
-
- {(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => { - const on = selectedTools.includes(t); - return ( - - ); - })} + {aiProvider === "online" ? ( + <> +
+ 当前显示的是 online 主编排层真实注册的工具;后续新增 mindmap 等能力时,应同步出现在这里。
-
+
+ {(onlineConfig?.tools || []).map((tool) => ( +
+
+
{tool.title}
+ {tool.name} + {tool.mode} + {tool.scope} + {tool.status} +
+
{tool.description}
+
+ ))} + {onlineConfigLoading ?
正在读取工具注册表…
: null} + {!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? ( +
当前未返回工具注册表。
+ ) : null} + {onlineConfigError ?
读取失败:{onlineConfigError}
: null} +
+ ) : ( -
- 自动模式下:AI 会在允许的 ToolSet 范围内自行选择工具。 -
+ <> +
+ +
+ + {!toolAuto ? ( +
+
允许使用的工具(手动模式)
+
+ {(Object.keys(TOOL_LABEL) as ToolName[]).map((t) => { + const on = selectedTools.includes(t); + return ( + + ); + })} +
+
+ ) : ( +
+ 自动模式下:AI 会在允许的 ToolSet 范围内自行选择工具。 +
+ )} + )}
@@ -1254,6 +1363,22 @@ export function DocumentAiAgentPanelRuntime({ 提示:你可以在 VSCode 里继续用 Codex CLI 编辑,同时在这里用 #dev 进行编辑;若要共享上下文,请绑定同一个 SessionId。避免两边同时改同一文件以免冲突。
+ ) : aiProvider === "online" ? ( + <> + + + ) : ( <> @@ -1277,12 +1402,35 @@ export function DocumentAiAgentPanelRuntime({ disabled={loading} /> )} - - )}
+ {aiProvider === "online" ? ( +
+
+ 在线 provider 固定走 OmniRoute。Base URL: + {onlineConfig?.baseUrl || "http://localhost:20128/v1"} +
+
+ 当前模型键: + {selectedOnlineModel?.key || "未选择"} + {" · "} + Combo: + {selectedOnlineModel?.resolvedCombo || "由网关运行时解析"} +
+
+ Runtime Model: + + {selectedOnlineModel?.resolvedRuntimeModel || "由网关运行时解析"} + +
+
+ 说明:这里的 Runtime Model 表示当前页面 AI 实际发送到 OmniRoute 的 runtime model 字符串;更底层 provider 映射仍由网关内部维护。 +
+ {onlineConfigLoading ?
正在读取页面 AI 配置…
: null} + {onlineConfigError ?
读取失败:{onlineConfigError}
: null} +
+ ) : null}
说明:在线/本地的 BaseURL 与 Key 仍由配置文件/环境变量控制;这里仅做 provider 与 model 覆盖。Ollama 默认使用 http://127.0.0.1:11434/v1
@@ -1323,6 +1471,62 @@ export function DocumentAiAgentPanelRuntime({ /> + {aiProvider === "online" ? ( +
+
+ + +
+
+ {selectedOnlineProfile?.description || "当前未读取到 profile 描述。"} +
+
+ 说明:Profile 由后端 registry 持有真源,前端只做选择和展示。 +
+
+ 会话记忆: + + {onlineConfig?.sessionEnabled ? `已启用 · ${onlineSessionId || "等待会话初始化"}` : "未启用"} + +
+
+
当前 scope 已注册工具
+
+ 这里直接显示 online 主编排层返回的真实 tool registry;后续如果新增 mindmap 或页面工具,应在这里同步可见。 +
+ {onlineConfigLoading ?
正在读取工具注册表…
: null} + {!onlineConfigLoading && !onlineConfigError && (onlineConfig?.tools.length ?? 0) === 0 ? ( +
当前未返回工具注册表。
+ ) : null} + {onlineConfigError ?
读取失败:{onlineConfigError}
: null} +
+ {(onlineConfig?.tools || []).map((tool) => ( +
+
{tool.title}
+
+ {tool.name} + {" · "} + {tool.mode} + {" / "} + {tool.scope} +
+
+ ))} +
+
+
+ ) : null}
说明:步数越大越“能做事”,但会更慢且更消耗推理额度。
diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx index 2a5f82c3..b6a7f802 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx @@ -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; }; diff --git a/wolai-frontend/src/components/editor/blocknote-editor.tsx b/wolai-frontend/src/components/editor/blocknote-editor.tsx index fd9d0f51..7fc32d07 100644 --- a/wolai-frontend/src/components/editor/blocknote-editor.tsx +++ b/wolai-frontend/src/components/editor/blocknote-editor.tsx @@ -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, diff --git a/wolai-frontend/src/components/editor/document-content.tsx b/wolai-frontend/src/components/editor/document-content.tsx index c30f2f8c..9ca99745 100644 --- a/wolai-frontend/src/components/editor/document-content.tsx +++ b/wolai-frontend/src/components/editor/document-content.tsx @@ -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(initialOptions ?? defaultOptions); + const [pageClientState, dispatchPageClientState] = useReducer( + pageAggregateClientStateReducer, + page, + createPageAggregateClientState, + ); + const options = pageClientState.options; const [stats, setStats] = useState(initialStats ?? defaultStats); const [history, setHistory] = useState([]); const [historyOpen, setHistoryOpen] = useState(false); @@ -171,14 +175,9 @@ export function DocumentContent({ documentId, fallbackTitle: initialTitle, }); - const [content, setContent] = useState(initialContent); - const [serverContentSnapshot, setServerContentSnapshot] = useState(initialContent); - const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState( - initialPageSubtree, - ); - const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState(initialTitle); - const [contentRevision, setContentRevision] = useState(initialContentRevision); - const [conflictDetectionKey, setConflictDetectionKey] = useState(initialConflictDetectionKey); + const content = pageClientState.content; + const contentRevision = pageClientState.contentRevision; + const conflictDetectionKey = pageClientState.conflictDetectionKey; const [contentLoading, setContentLoading] = useState(() => initialContent == null); const [contentError, setContentError] = useState(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) => { 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); - return next; + const nextValue = !options[key]; + dispatchPageClientState({ + type: "patch_page_options", + patch: { [key]: nextValue } as Partial, }); + void persistOptions({ [key]: nextValue } as Partial); }, - [persistOptions, readOnly], + [options, persistOptions, readOnly], ); const setOptionPatch = useCallback( (patch: Partial) => { 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({ { - setContentRevision(meta.revision); - setConflictDetectionKey(meta.conflictDetectionKey); - }} + getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot} + onPersistedMetaChange={handlePersistedMetaChange} onPageHeadTitleChange={handleAiPageHeadTitleChange} /> diff --git a/wolai-frontend/src/components/editor/editor-host-types.ts b/wolai-frontend/src/components/editor/editor-host-types.ts index e66c10ae..2b1dcc77 100644 --- a/wolai-frontend/src/components/editor/editor-host-types.ts +++ b/wolai-frontend/src/components/editor/editor-host-types.ts @@ -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 }; diff --git a/wolai-frontend/src/components/editor/leptos-tiptap-editor-host.tsx b/wolai-frontend/src/components/editor/leptos-tiptap-editor-host.tsx index 216d3f64..4745567a 100644 --- a/wolai-frontend/src/components/editor/leptos-tiptap-editor-host.tsx +++ b/wolai-frontend/src/components/editor/leptos-tiptap-editor-host.tsx @@ -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?.({ diff --git a/wolai-frontend/src/components/editor/leptos-tiptap-inline-editor-host.tsx b/wolai-frontend/src/components/editor/leptos-tiptap-inline-editor-host.tsx index 8a87cede..35bee562 100644 --- a/wolai-frontend/src/components/editor/leptos-tiptap-inline-editor-host.tsx +++ b/wolai-frontend/src/components/editor/leptos-tiptap-inline-editor-host.tsx @@ -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 & { @@ -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, diff --git a/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx b/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx index f4035b34..1d9766cf 100644 --- a/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx +++ b/wolai-frontend/src/components/editor/leptos-tiptap-island-editor-host.tsx @@ -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; 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({ @@ -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]); diff --git a/wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts b/wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts new file mode 100644 index 00000000..d95c6768 --- /dev/null +++ b/wolai-frontend/src/components/editor/page-aggregate-client-state.test.ts @@ -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; + 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, + }, + }); + }); +}); diff --git a/wolai-frontend/src/components/editor/page-aggregate-client-state.ts b/wolai-frontend/src/components/editor/page-aggregate-client-state.ts new file mode 100644 index 00000000..57125561 --- /dev/null +++ b/wolai-frontend/src/components/editor/page-aggregate-client-state.ts @@ -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; + } + | { + 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, + }; +} diff --git a/wolai-frontend/src/components/editor/page-options-sidebar.tsx b/wolai-frontend/src/components/editor/page-options-sidebar.tsx index 8083a62f..7e60cb29 100644 --- a/wolai-frontend/src/components/editor/page-options-sidebar.tsx +++ b/wolai-frontend/src/components/editor/page-options-sidebar.tsx @@ -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({ )} - +
页面操作 @@ -282,7 +277,12 @@ export function PageOptionsSidebar({
- +
嵌入默认位置
diff --git a/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx b/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx deleted file mode 100644 index dd28969e..00000000 --- a/wolai-frontend/src/components/sidebar/MnoteWebTreeShell.tsx +++ /dev/null @@ -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; - 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) { - 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) { - 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, - 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, - 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(null); - const readyTimerRef = useRef(null); - const [refreshKey, setRefreshKey] = useState(0); - const [failedShellUrl, setFailedShellUrl] = useState(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) => { - 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 ( -
-