diff --git a/design/rust-web-long-term-checklist-v2.md b/design/rust-web-long-term-checklist-v2.md index 3b55f566..192ec729 100644 --- a/design/rust-web-long-term-checklist-v2.md +++ b/design/rust-web-long-term-checklist-v2.md @@ -35,6 +35,7 @@ - [x] `Phase 0` 的文档化边界梳理已经完成 - [x] `Phase 1` 已落了一个最小 `axum` Web 骨架:`rust/crates/mnote-web/` +- [x] `Phase 1` 已不再只是空骨架,现已包含 kernel / bridge / compat sidebar 的真实查询接缝 - [x] `Phase 2` 文档阅读态分离已经有实质代码 - [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝 - [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence` @@ -93,7 +94,7 @@ | 阶段 | v1 口径 | 当前真实状态 | 说明 | | --- | --- | --- | --- | | Phase 0 | 边界冻结 | `DONE` | 文档、候选模块、边界口径已经成形,但性能基线更多还是文档定义,不是完整观测系统 | -| Phase 1 | Rust Web 基础层 | `PARTIAL` | `mnote-web` 已创建,`axum` 骨架已存在,sidebar kernel route 也已接到真实 query plan,但远不是主流量入口 | +| Phase 1 | Rust Web 基础层 | `PARTIAL` | `mnote-web` 已创建,`axum` 骨架已存在,且已补 bridge route、通用 query transport 与 Sidebar compat 切流入口,但仍不是整个产品的主 Web 入口 | | Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` | | Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 | | Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 | @@ -147,7 +148,10 @@ - [x] 已有最小 Hermes bridge route - [hermes.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes.rs) - [x] 已有 SSE / WS / compat route 骨架 -- [x] kernel sidebar route 已通过 `sidebar.dataset.list` runtime plan + `execute_sidebar_dataset_query(...)` 读取数据集 +- [x] 已有真实 bridge / compat 接缝 + - [bridge.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/bridge.rs) + - [compat.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/compat.rs) +- [x] kernel sidebar route 已通过 `sidebar.dataset.list` runtime plan + 通用 query transport 读取数据集 - [kernel.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs) ### 6.2 明确还没完成 @@ -155,8 +159,8 @@ - [ ] `mnote-web` 还不是主 Web 服务入口 - [ ] 还没有主页面壳 SSR - [ ] 还没有主 API 大面积从 Next route 切到 Rust Web -- [ ] 当前 `compat_next_base_path` 仍说明它主要还是兼容层,不是主承载层 -- [ ] `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` fixture fallback 仍然存在,不能把当前接缝写成正式主链 +- [ ] 当前 `compat_next_base_path` 仍说明它还带有兼容层属性,不是唯一承载层 +- [ ] fixture 已收口到测试/开发边界,但这还不等于整个产品流量已经全面切到 Rust Web - [ ] 还不能说“后续页面迁移已不依赖 Next API route 作为唯一入口”,只能说“已经开始脱钩” ### 6.3 v2 后续任务 @@ -211,13 +215,15 @@ - [sidebar-data.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/sidebar-data.ts) - [x] 主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始树与同步来源 - [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx) +- [x] 已补统一 `page_tree` projection protocol,并让 `PrivateTree` / 文件树 / move-embed picker 共用 + - [tree-projection.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-projection.ts) ### 8.2 当前真实问题 - [ ] `Sidebar` 仍然是超大客户端组件 - [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx) -- [ ] 页面树 / 文件树仍主要在客户端组装与交互 -- [ ] `buildDocumentTree(...)` 等旧拼树 helper 仍残留在 `move-embed-picker-dialog.tsx`、`lib/documents.ts` 等兼容场景 +- [ ] 页面树 / 文件树虽然已统一协议,但整体执行面仍主要在客户端 +- [ ] `buildDocumentTree(...)` 仅剩兼容 helper 与旧单测,不再位于树域主路径 - [ ] 主布局仍然默认常驻挂载 Sidebar - [ ] 还不能叫“服务端输出 + 局部 island”,现在更像“服务端首包 + 超大客户端壳” @@ -225,7 +231,7 @@ - [ ] 先拆 Sidebar 自身为 host / runtime 或分片 island - [ ] 把树结构首包与交互态严格分层 -- [ ] 把主 Sidebar 之外的 page tree、file tree、embed/move picker 也统一切到 kernel projection +- [x] 把主 Sidebar 之外的 page tree、file tree、embed/move picker 也统一切到 kernel projection - [ ] 把局部刷新协议显式化 - [ ] 给 Sidebar 建立真正的切页重渲染基线 diff --git a/design/tree-first-graph-kernel-checklist-v2.md b/design/tree-first-graph-kernel-checklist-v2.md index 505f83cf..1bedac2a 100644 --- a/design/tree-first-graph-kernel-checklist-v2.md +++ b/design/tree-first-graph-kernel-checklist-v2.md @@ -52,8 +52,8 @@ - `Kernel Phase 0`:`DONE` - `Kernel Phase 1`:`DONE` - `Kernel Phase 2`:`DONE` -- `Kernel Phase 3`:`PARTIAL` -- `Kernel Phase 4`:`PARTIAL` +- `Kernel Phase 3`:`DONE` +- `Kernel Phase 4`:`DONE` - `Kernel Phase 5`:`PARTIAL` - `Kernel Phase 6`:`PARTIAL` - `Kernel Phase 7`:`PARTIAL` @@ -62,7 +62,7 @@ 一句话总结: -> **Kernel 基础层已经进入真实 Rust 主线,搜索/阅读页/AI 也开始带上 node、subtree、evidence 一类结构上下文,但 consumer 主路径仍未切完,旧前端壳依然是主要执行面。** +> **Kernel 基础层与 Rust Web 承载层已经进入真实主线,树域 consumer 已统一到稳定 projection family;但旧前端壳仍然是主要执行面,下一步才是独立 Rust Web tree shell 重构。** --- @@ -74,7 +74,7 @@ | --- | --- | --- | | Rust 中存在统一的 kernel node / edge / projection / subtree 真相层 | `DONE` | `core-protocol` 已落地 | | Rust 中存在统一的 kernel query / command 面 | `DONE` | `bridge-runtime` 已支持 kernel 查询与写协议 | -| Rust Web 能承接 kernel route | `PARTIAL` | `mnote-web` 已有 route,且已接到真实 sidebar dataset query plan,但仍是样板级接入 | +| Rust Web 能承接 kernel route | `DONE` | `mnote-web` 已具备通用 query transport、kernel/bridge/compat route、测试专用 fixture 边界,以及可切到真实 Sidebar 流量的兼容入口 | | Sidebar / 页面树 / 文件树直接消费 kernel projection | `PARTIAL` | 前端主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但仍是重客户端壳,其他树域仍残留旧拼树 helper | | 搜索直接消费 kernel-aware 检索结果 | `PARTIAL` | cron 已有 `kernel-aware refresh` 过渡链,搜索结果也已带 `nodeId` / `subtreeRootId` / `evidence`,但仍是 LightRAG 过渡口径,不是 kernel 真相层检索 | | 阅读页直接消费 page subtree projection | `PARTIAL` | `DocumentReadView` 已直接消费 `pageSubtree`,但 projection 仍在前端读链内生成,不是 Rust/kernel 真相层输出 | @@ -174,7 +174,7 @@ ## 8. Kernel Phase 3:Rust Web 接入 kernel,成为主承载层 -**当前状态:`PARTIAL`** +**当前状态:`DONE`** ### 已落地 @@ -185,57 +185,99 @@ - `/api/kernel/subtree` - `/api/kernel/edges` - `/api/kernel/graph` -- sidebar kernel route 已通过 `sidebar.dataset.list` runtime plan + `execute_sidebar_dataset_query(...)` 读取数据集 -- 已有路由测试,说明 route 不只是声明 +- sidebar kernel route 已通过 `sidebar.dataset.list` runtime plan + 通用 Convex query transport 读取真实数据集 +- 已新增真实 workspace / bridge 查询 route: + - `/api/bridge/workspace` + - `/api/bridge/request` + - `/api/bridge/trace` +- 已新增 Next 兼容切流入口: + - `/api/compat/next/sidebar` +- `MNOTE_WEB_QUERY_FIXTURES_JSON` + `allow_dev_fixtures` 已把 fixture 收紧到测试/开发边界 +- `/api/sidebar` 与服务端 Sidebar 首包在配置 `MNOTE_WEB_BASE_URL` 后,默认经 `mnote-web` 的 Sidebar compat route 取数 +- `mnote-web` transport 已优先转发真实 `Authorization`,否则才退回开发态 admin/dev identity +- 已有 kernel / bridge 路由测试,说明 route 不只是声明 -### 当前真实问题 +### 已完成判定 -- `mnote-web` 还不是主 Web 入口 -- kernel route 主链已经不再直接依赖 `demo_sidebar_dataset(...)` helper,但仍保留 `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` 环境变量 fallback -- 当前更真实的接缝主要集中在 sidebar dataset 这一条查询上,还没有形成更广的 workspace / storage / bridge 主链 -- 这只能证明“kernel route 已进入 Rust Web”,不能证明“真实主链已切过去” +- Sidebar 这条 kernel 查询主链已不依赖生产态 fixture fallback +- `mnote-web` 已不再只有 Sidebar 单点 transport,而是能承接更广的 query / bridge 主链 +- Next -> Rust Web 的第一条真实切流边界已经明确并可启用 -### 下一阶段必须完成 +### 当前保留边界 -- 把 fixture fallback 收紧到测试/开发边界,避免环境变量 fixture 成为隐性主链 -- 在 sidebar dataset 之外,再打通至少一条真实 workspace / storage / bridge 查询主链 -- 至少让一条真实页面树或工作区查询主链通过 `mnote-web` 提供 -- 明确 Next -> Rust Web 的真实流量边界 +- `kernel.subtree.get` / `kernel.project_view` 仍主要建立在 `sidebar.dataset.list` 这一份页面树数据集之上 +- 更广义的 node pool、reference edge、summary/index node 真相层,仍属于后续阶段 +- `mnote-web` 还不是整个产品的唯一 Web 入口,这属于更后的双栈收缩问题 ### 完成判定 -- 至少一条不依赖 fixture fallback 的 kernel 查询主链在 `mnote-web` 上稳定运行 +- 至少一条不依赖 fixture fallback 的 kernel 查询主链已在 `mnote-web` 上稳定运行 +- 至少一条真实 workspace / bridge 查询主链已通过 `mnote-web` 对外提供 +- 至少一条真实前端流量已具备默认切到 `mnote-web` 的兼容边界 --- ## 9. Kernel Phase 4:Sidebar / 页面树 / 文件树切到 kernel projection -**当前状态:`PARTIAL`** +**当前状态:`DONE`** + +### 本阶段边界 + +这个阶段只解决一件事: + +> **把树域 consumer 全部统一到稳定的 kernel projection / tree protocol。** + +这里刻意**不**包含: + +- 独立 Rust Web tree shell +- `Leptos` / `Dioxus` / `Yew` 树域 UI 重写 +- Sidebar 整体壳替换 + +这些属于 `Phase 4` 完成之后的独立大任务,即: + +- [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) ### 已落地 -- Sidebar 已有服务端首包 -- Rust runtime 已能把 `sidebar.dataset.list` 转为统一 kernel subtree / projection 结果 -- `sidebar-data.ts` 已生成 `kernel_sidebar_projection` 与 `kernelSidebarTree` -- 前端主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始化与同步的主树来源 +- [x] Sidebar 已有服务端首包 +- [x] Rust runtime 已能把 `sidebar.dataset.list` 转为统一 kernel subtree / projection 结果 +- [x] `sidebar-data.ts` 已生成 `kernel_sidebar_projection` 与 `kernelSidebarTree` +- [x] 前端主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始化与同步的主树来源 +- [x] 已新增统一 `page_tree` projection protocol: + - `rowId` + - `nodeId` + - `parentNodeId` + - `projectionKind` + - `depth` + - `position` + - `capabilities` + - `resourceMeta` +- [x] `PrivateTree` 已改为直接消费 `page_tree` projection 可见行,而不是自行 flatten 嵌套树 +- [x] 文件树已改为只消费 `page_tree projection + asset 映射`,`buildVisibleRows(...)` 收口为 projection -> visible rows +- [x] `move-embed picker` 空查询态已直接消费 `kernelSidebarTree -> page_tree projection`,不再调用 `buildDocumentTree(...)` +- [x] `SidebarInitialData` / `sidebar-data.ts` 已将 `kernelSidebarProjection` 与 `kernelSidebarTree` 收紧为主路径必备字段,不再在映射阶段对缺失 projection 做主路径 fallback -### 当前真实问题 +### 本阶段完成后仍保留的问题 - 前端主 Sidebar 仍是超大客户端组件 -- 主 Sidebar 已不再以 `DocumentRecord[] -> buildDocumentTree(...)` 作为主树入口,但 `move-embed picker`、`lib/documents.ts` 等兼容场景仍保留旧拼树 helper -- 页面树 / 文件树还没有完全统一到 kernel node / edge 真相层 +- 页面树 / 文件树 / picker 虽已统一协议,但 Rust Web tree shell 仍未开始 +- 文件树中的 `asset-folder` / `asset` / `index` 仍由前端 adapter 基于现有数据集补齐,不是 Rust 直接输出的 `file_tree projection` +- `buildDocumentTree(...)` 仍保留在兼容 helper 与旧单测中,但已退出树域主路径 +- 现在可以进入树域 Rust Web 壳重写,但不能把这一步与本阶段混写成同一任务 -### 下一阶段必须完成 +### 下一阶段任务 -- 把主 Sidebar 之外的 page tree、file tree、embed/move picker 也统一改读 kernel projection -- 把展开、折叠、拖拽、hover 之类状态收口为纯 UI 层 -- 收敛 `buildDocumentTree(...)` 一类旧树 helper 的残留 consumer -- 让布局层只保留轻 host,不再常驻重树组件 +- 独立推进 [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) +- 把当前 `page_tree` / `file_tree` protocol 下沉成 Rust Web 独立 route / shell +- 继续缩小 Sidebar 超大客户端壳,只保留局部交互岛 +- 让文件树中的更宽对象投影逐步由 Rust projection 直接输出 ### 完成判定 -- 主 Sidebar 以及相关树域已直接消费 kernel projection -- 页面树 / 文件树 / 嵌入移动器等不再通过旧对象数组拼树 +- [x] 主 Sidebar 以及相关树域已直接消费 kernel projection +- [x] 页面树 / 文件树 / 嵌入移动器等不再通过旧对象数组拼树 +- [x] tree row / projection protocol 已冻结,足以支撑下一步独立 Rust Web tree shell 重构 +- [x] 当前 React/Next 树域可以继续作为 consumer 壳存在,但不再定义树结构真相 --- @@ -449,8 +491,9 @@ 如果按当前真实代码继续推进,建议顺序是: -1. 完成 `Kernel Phase 3` 收尾 -2. 先打通 `Kernel Phase 4` +1. 完成 `Kernel Phase 4` +2. 基于稳定 tree protocol,启动树域独立重构: + - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) 3. 同步启动 `Kernel Phase 5` 4. 再推进 `Kernel Phase 6` 5. 再推进 `Kernel Phase 7` @@ -458,7 +501,7 @@ 原因很简单: -- 如果 `mnote-web` 仍是 demo route,后面的 projection consumer 都会继续挂在旧前端壳上 +- 如果 Sidebar / 页面树 / 文件树还没切到统一 kernel projection,树域 Rust Web 重写会在协议未冻结前重复返工 - 如果 Sidebar / 页面树还没切到 kernel,工作区主导航就还没换真相层 - 如果知识刷新与 kernel-aware 检索不成立,AI 和 BookMindmap 路线也无法形成长期闭环 @@ -472,4 +515,4 @@ 所以后续主线必须固定为: -> **先补齐 Rust Web 的真实 kernel 主链,再切 Sidebar / 页面树 / 文件树,再建立结构知识刷新与 kernel-aware 检索,之后才轮到 Mindmap、阅读页、AI、`BlockNote` 和旧壳退场。** +> **先完成树域的 kernel projection 统一,再基于稳定协议做 Sidebar / 页面树 / 文件树的独立 Rust Web 重构;之后再并行推进结构知识刷新、Mindmap、阅读页、AI、`BlockNote` 与旧壳退场。** diff --git a/design/tree-first-graph-kernel-phase3-task-breakdown-v1.md b/design/tree-first-graph-kernel-phase3-task-breakdown-v1.md new file mode 100644 index 00000000..e6660071 --- /dev/null +++ b/design/tree-first-graph-kernel-phase3-task-breakdown-v1.md @@ -0,0 +1,461 @@ +# Tree-First Graph Kernel Phase 3 专项任务拆分 v1 + +> 更新时间:2026-04-16 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v2.md` + +## 0. 本轮判断 + +这份拆解**整体是合理的**,但有两个口径需要收紧: + +- `P3-3` 不能把“仍然复用同一份 `sidebar.dataset.list` 数据集的另一个 projection/query 名字”直接算成第二条真实主链 +- `P3-5` 的第一条切流边界,最现实也最有价值的不是凭空新增页面,而是把 Sidebar 首包与 `/api/sidebar` 这条真实高频读链接到 `mnote-web` + +基于 2026-04-16 当天的代码推进,这份拆解对应的落地情况已经变成: + +- `P3-1`:已完成 +- `P3-2`:已完成 +- `P3-3`:已完成 + - 实际采用的是 `bridge.workspace.overview` / `bridge.request.get` / `bridge.trace.get` 这组真实 workspace / bridge 查询主链 +- `P3-4`:已完成 +- `P3-5`:已完成 + - Sidebar 服务端首包与 `/api/sidebar` 在配置 `MNOTE_WEB_BASE_URL` 后,会默认经 `/api/compat/next/sidebar` 走 `mnote-web` +- `P3-6`:已完成 + +## 1. 文档目的 + +这份文档只处理一件事: + +> **把 `Kernel Phase 3:Rust Web 接入 kernel,成为主承载层` 单独拆开,按当前真实代码重新分解成可执行任务。** + +原因很直接: + +- `Phase 1` 和 `Phase 2` 已经完成 +- `Phase 3` 现在不是“没开始”,也不是“接近完成” +- 它已经有真实代码,但工程量明显比最初预估更大 + +所以接下来的推进方式不应该继续写成一条大任务,而应该拆成若干可以逐个闭环的小阶段。 + +--- + +## 2. 当前真实完成情况 + +基于当前 git 中的实际代码,`Phase 3` 已经落下了下面这些东西。 + +### 2.1 已存在的 Rust Web 基础骨架 + +- `rust/crates/mnote-web/` 已进入 workspace +- 已有 `axum` app/router 骨架: + - [app.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/app.rs) + - [mod.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/mod.rs) +- 已有 request context / middleware / error 基础: + - [context.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/context.rs) + - [error.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/error.rs) + +### 2.2 已存在的 kernel route + +- 已有: + - `/api/kernel/projections/sidebar` + - `/api/kernel/subtree` + - `/api/kernel/edges` + - `/api/kernel/graph` +- route 文件: + - [kernel.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs) + +### 2.3 已接上的第一条真实查询接缝 + +当前最关键的一点是: + +- kernel route 已不再只是本地 demo helper +- 它已经能先生成 `sidebar.dataset.list` 的 runtime query plan +- 再通过通用 Convex query transport 去请求 Convex `/api/query` + +对应代码: + +- [kernel.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs) +- [convex.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/transport/convex.rs) + +这说明: + +> **Phase 3 的“Rust Web 开始承接真实 kernel 查询主链”已经成立,但只成立在很窄的一条 Sidebar dataset 链路上。** + +### 2.4 当前新增事实 + +- `transport/convex.rs` 已从 Sidebar 单点执行器收口为通用 query transport +- `mnote-web` 已新增: + - `/api/bridge/workspace` + - `/api/bridge/request` + - `/api/bridge/trace` + - `/api/compat/next/sidebar` +- fixture 已改成 `allow_dev_fixtures + MNOTE_WEB_QUERY_FIXTURES_JSON` + - 不再是生产路径默认 fallback +- Next 侧的 Sidebar 首包与 `/api/sidebar` 已具备可切到 `mnote-web` 的兼容边界 +- transport 已优先转发真实 `Authorization`,否则才回退到开发态 admin/dev identity + +--- + +## 3. 当前 Phase 3 的真实缺口 + +### 3.1 fixture 已不再是生产 fallback + +当前 fixture 已收口为: + +- `allow_dev_fixtures` +- `MNOTE_WEB_QUERY_FIXTURES_JSON` + +这意味着: + +- 测试仍方便 +- 但生产路径默认不会再悄悄吃 fixture +- query fixture 已能同时覆盖 kernel / bridge route 测试 + +### 3.2 真实数据链不再只覆盖 Sidebar dataset + +现在真正打通的已至少包括: + +- `sidebar.dataset.list` +- `bridge.workspace.overview` +- `bridge.request.get` +- `bridge.trace.get` + +这说明 `mnote-web` 已经不再只是“局部样板接入”。 + +### 3.3 transport 已不再是单点实现 + +当前 transport 已能执行通用 query plan,并已被 Sidebar 与 bridge route 复用。 + +### 3.4 切流边界已经明确 + +当前已经明确: + +- 第一条真实切流对象是 Sidebar 首包与 `/api/sidebar` +- 开关边界是 `MNOTE_WEB_BASE_URL` +- 回退方式是移除该 base url,恢复 Next 侧原桥接逻辑 + +### 3.5 验证重点已经转向“主链稳定” + +当前仍需持续补的不是“有没有 route”,而是: + +- 更广义 kernel 数据源是否继续扩展 +- 切流开启后的运行态回归 +- 旧 Next/React 壳还能再收掉多少 + +--- + +## 4. Phase 3 的重新定义 + +为了避免范围继续膨胀,建议把 `Phase 3` 重新定义为下面这个最小目标: + +> **让 `mnote-web` 至少承接一组不依赖 fixture 的真实 kernel 查询链,并具备可复用 transport、明确切流边界、可回归的集成验证。** + +这版定义刻意不包含: + +- 全部页面 SSR 重写 +- 全部前端 API 切换 +- 全部 Sidebar / 搜索 / AI / Mindmap consumer 切换 + +这些应该属于后续阶段。 + +`Phase 3` 只负责把 Rust Web 从“骨架 + 样板 route”推进成“可被后续阶段依赖的真实承载层”。 + +--- + +## 5. Phase 3 拆分原则 + +### 原则 1 + +先打通主承载链,再扩 consumer。 + +### 原则 2 + +先消除 fixture/样板依赖,再谈切流。 + +### 原则 3 + +先把 transport 层做成可复用,再扩第二条、第三条真实查询链。 + +### 原则 4 + +每一步都必须有明确验收,不允许再出现“骨架写了就算完成”。 + +--- + +## 6. Phase 3 任务拆分 + +建议把 `Phase 3` 拆成 6 个子任务。 + +--- + +## 7. P3-1:固化当前 Sidebar kernel 主链 + +**目标** + +把当前已有的 Sidebar dataset -> kernel projection 这条链,从“能跑”收紧到“边界清晰、失败语义清晰、测试与真实链分离”。 + +**当前依据** + +- [kernel.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs) +- [convex.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/transport/convex.rs) + +**要做的事** + +- 把 `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` 限定为测试专用或开发专用 +- 明确生产路径默认不允许 fixture fallback +- 为真实 Convex 查询失败补齐清晰错误语义 +- 明确 workspace 缺失、auth 缺失、query 失败时的返回口径 +- 给 Sidebar kernel route 补非 fixture 集成验证 + +**交付物** + +- `kernel.rs` 的数据加载边界收紧 +- 测试和生产路径分离 +- 文档化的错误语义 + +**完成判定** + +- 在非 fixture 模式下,Sidebar kernel route 可以稳定返回真实数据 +- fixture 不再是默认隐性主链 + +--- + +## 8. P3-2:抽象可复用的 Rust Web transport/query provider + +**目标** + +把当前只支持 `sidebar:datasetList` 的单点实现,提升成后续可复用的 transport 层。 + +**当前问题** + +- `execute_sidebar_dataset_query(...)` 是专用函数 +- transport 还不是通用 query transport + +**要做的事** + +- 抽出通用的 Convex query 执行层 +- 明确 query plan -> HTTP query -> JSON value 的公共路径 +- 将 `sidebar.dataset.list` 改成这个公共层的首个 consumer +- 为后续第二条真实 query 链预留统一入口 + +**交付物** + +- 可复用的 transport API +- Sidebar dataset 迁到通用 transport 之上 + +**完成判定** + +- `transport/convex.rs` 不再只为 Sidebar dataset 服务 +- 第二条 query 链可以直接复用同一 transport + +--- + +## 9. P3-3:打通第二条非 Sidebar 的真实 kernel 查询链 + +**目标** + +证明 `mnote-web` 不是只会做 Sidebar projection,而是开始具备更一般化的 kernel 主承载能力。 + +**优先建议** + +优先级建议如下: + +1. 工作区级 page tree / subtree 查询 +2. workspace overview 类查询 +3. 文档阅读页会直接需要的 subtree 查询主链 + +不建议一上来就选: + +- 搜索 +- AI +- Mindmap + +因为这些 consumer 更重,依赖更多,会把 `Phase 3` 范围重新拉爆。 + +**要做的事** + +- 基于已有 transport 层再接一条真实 query plan +- 让其经由 `mnote-web` route 对外提供 +- 明确其 request context、workspace、auth、trace 透传 +- 为它补路由级测试和真实集成验证 + +**交付物** + +- 第二条非 Sidebar 的真实 kernel 查询 route + +**完成判定** + +- `mnote-web` 至少存在两条不依赖 fixture 的真实 kernel 查询主链 + +--- + +## 10. P3-4:补齐 Phase 3 的上下文、鉴权和错误稳定性 + +**目标** + +让 `mnote-web` 具备作为主承载层的最低运行稳定性,而不是只有 happy path。 + +**当前依据** + +- [context.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/context.rs) +- [error.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/error.rs) + +**要做的事** + +- 明确 request id / trace id / workspace id 的透传规则 +- 明确 dev auth 与真实 auth 的边界 +- 给 transport 错误、上游错误、参数错误建立稳定 code +- 检查 response header 是否统一回传 request/trace 上下文 +- 给 4xx / 5xx 场景补测试 + +**交付物** + +- Phase 3 级别的错误模型与上下文模型 +- 对应测试 + +**完成判定** + +- 真实错误不再落成模糊的 internal error +- request/trace/workspace 上下文可以稳定追踪 + +--- + +## 11. P3-5:定义并落地第一条真实切流边界 + +**目标** + +明确 `Phase 3` 到底把哪一条真实前端流量切给 `mnote-web`。 + +**这是当前最容易被忽略的点** + +如果没有切流边界,`Phase 3` 会永远停在“后端已准备好,但没人用”的状态。 + +**建议切流对象** + +优先从这类低风险路径里选一条: + +1. Sidebar kernel projection 的服务端读取链 +2. 工作区树只读查询链 +3. 文档只读 subtree 查询链 + +不建议在 `Phase 3` 就切: + +- AI 运行主链 +- 搜索主链 +- 导图交互主链 + +**要做的事** + +- 明确旧链和新链的边界 +- 明确 feature flag 或环境开关 +- 明确回退策略 +- 明确前端 host 如何消费 `mnote-web` 结果 + +**交付物** + +- 一条真实切流方案 +- 对应开关和回退策略 + +**完成判定** + +- 至少一条真实请求默认走 `mnote-web` +- 出现问题时可控回退 + +--- + +## 12. P3-6:补 Phase 3 的验收矩阵与收尾文档 + +**目标** + +避免 `Phase 3` 再次在口径上被提前写成“已完成”。 + +**要做的事** + +- 为 `Phase 3` 建立验收矩阵: + - route 可用 + - 非 fixture 主链 + - 第二条真实查询链 + - 错误模型 + - 上下文透传 + - 切流边界 +- 回写长期 checklist 中的 `Phase 3` 状态 +- 回写 `harness` 任务状态 + +**交付物** + +- 验收矩阵文档 +- checklist 更新 +- harness 状态更新 + +**完成判定** + +- `Phase 3` 是否完成可以由矩阵直接判断,而不是靠描述性口径 + +--- + +## 13. 建议执行顺序 + +建议顺序如下: + +1. `P3-1` 固化 Sidebar 主链 +2. `P3-2` 抽通用 transport +3. `P3-3` 打通第二条真实查询链 +4. `P3-4` 补上下文/鉴权/错误稳定性 +5. `P3-5` 定义并落地第一条切流边界 +6. `P3-6` 做验收矩阵和文档回写 + +原因: + +- 如果不先收紧 Sidebar 主链,后面所有扩展都会继续复制不稳的模式 +- 如果不先抽 transport,第二条查询链会继续写成特例 +- 如果不先有第二条真实链,就无法证明 `mnote-web` 真能成为承载层 +- 如果不定义切流,`Phase 3` 即使后端做完也无法进入后续阶段 + +--- + +## 14. 暂不纳入 Phase 3 的内容 + +下面这些先不要塞进 `Phase 3`: + +- 搜索全面切到 Rust Web +- AI 全面切到 kernel-first tool bridge +- Mindmap 改成 kernel projection/editor +- `BlockNote` 完全退化为内容节点编辑器 +- 旧前端壳清理 + +原因不是它们不重要,而是它们分别属于: + +- `Phase 5` +- `Phase 6` +- `Phase 7` +- `Phase 8` +- `Phase 9` + +如果提前塞回 `Phase 3`,这个阶段会再次失控。 + +--- + +## 15. Phase 3 完成的最终标准 + +只有同时满足下面几条,才建议把 `Phase 3` 标成完成: + +- `mnote-web` 至少有一条不依赖 fixture 的真实 kernel 查询主链 +- 在 Sidebar 之外,至少还有一条真实 workspace / storage / bridge 查询主链 +- Sidebar 那条主链已经不再以 fixture fallback 作为默认路径 +- transport 层已可复用,不再是单函数特例 +- request/trace/workspace/error 语义已稳定 +- 至少一条真实前端流量已切到 `mnote-web` +- 有清晰的回退策略和验收矩阵 + +当前这几个条件已经成立,因此 `Phase 3` 可以收口为: + +> **DONE,但 Done 的含义是“Rust Web 已成为可被后续阶段依赖的真实承载层”,不是“所有 consumer 都已经切完”。** + +--- + +## 16. 最终结论 + +`Phase 3` 当前最准确的判断是: + +> **已经从“骨架 + 样板 route”跨到了“真实承载层完成”,后续应转入 `Phase 4+` 的 consumer 主路径切换。** diff --git a/design/tree-first-graph-kernel-phase4-task-breakdown-v1.md b/design/tree-first-graph-kernel-phase4-task-breakdown-v1.md new file mode 100644 index 00000000..7e3593e5 --- /dev/null +++ b/design/tree-first-graph-kernel-phase4-task-breakdown-v1.md @@ -0,0 +1,419 @@ +# Tree-First Graph Kernel Phase 4 专项任务拆分 v1 + +> 更新时间:2026-04-16 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-checklist-v2.md` +> - `/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md` + +## 1. 文档目的 + +这份文档只处理一件事: + +> **把 `Kernel Phase 4:Sidebar / 页面树 / 文件树切到 kernel projection` 单独拆开,按当前真实代码重写成可执行任务。** + +这个阶段的目标不是: + +- 直接重写整个 Sidebar +- 直接把树 UI 改成 `Leptos` +- 直接启动树域 Rust Web shell 替换 + +这个阶段只负责: + +> **把树域的 truth、projection、consumer 协议先统一。** + +也就是先把树“变对”,再谈“换壳”。 + +--- + +## 2. Phase 4 的准确定位 + +在 `tree-first graph kernel` 里: + +- 树是主骨架 +- 页面树 / 文件树都只是 projection +- Sidebar 是树域承载壳,不是事实源 + +所以 `Phase 4` 的本质不是普通前端整理,而是: + +> **让树域 consumer 不再自己拼树,而是统一消费稳定的 kernel projection。** + +如果这一步不先完成,后面的 Rust Web tree shell 重构就会在协议未冻结前重复返工。 + +--- + +## 3. 当前真实完成情况 + +基于当前 git,`Phase 4` 已经有一些关键前置。 + +### 3.1 已有 projection 起点 + +- `sidebar.dataset.list` 已能产出: + - `kernel_sidebar_projection` + - `kernelSidebarTree` +- 对应文件: + - [sidebar-data.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/sidebar-data.ts) + - [kernel-sidebar.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/kernel-sidebar.ts) + +### 3.2 Sidebar 主树已切到统一 tree consumer + +- 主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为主树来源 +- `PrivateTree` 已改为直接消费 `page_tree` projection 可见行 +- 对应文件: + - [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx) + - [private-tree.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/private-tree.tsx) + +### 3.3 文件树已收口为统一 projection consumer + +- 文件树当前不再直接走嵌套 `SidebarTreeNode[]` 递归拼树 +- 它已经建立在: + - `page_tree projection rows` + - `assetsByDoc` + - `buildVisibleRows(...)` + 之上 +- 对应文件: + - [rows.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/file-tree/rows.ts) + - [types.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/file-tree/types.ts) + - [file-tree.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/file-tree.tsx) + +### 3.4 picker 已退出旧拼树主路径 + +- `move-embed picker` 空查询态已不再读取 `documents[] -> buildDocumentTree(...)` +- 它现在直接消费 `kernelSidebarTree -> page_tree projection -> picker rows` +- 对应文件: + - [move-embed-picker-dialog.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx) + - [tree-projection.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-projection.ts) + +--- + +## 4. 本轮完成后的保留边界 + +### 4.1 旧拼树 helper 已退出主路径,但仍保留兼容边界 + +- [`buildDocumentTree(...)`](/mnt/Data1T/mnote/wolai-frontend/src/lib/documents.ts) 仍保留在兼容 helper 与旧单测里 +- 但运行时主路径已不再由它构造 Sidebar / 页面树 / 文件树 / picker + +### 4.2 文件树仍不是 Rust 直接输出的完整 file_tree projection + +- 当前文件树已经消费统一 `page_tree projection` +- 但 `asset-folder` / `asset` / `index` 仍由前端 adapter 基于现有数据集补齐 + +### 4.3 Sidebar 壳仍然过重 + +当前 Sidebar 仍是超大客户端组件。 + +这不是 `Phase 4` 的阻塞项,但它解释了为什么下一个任务必须单列为独立 Rust Web tree shell 重构。 + +--- + +## 5. Phase 4 的重新定义 + +建议把 `Phase 4` 重新定义成下面这个最小目标: + +> **统一树域 projection protocol,让 Sidebar 主树、页面树、文件树、move/embed picker 都只消费同一套 kernel projection family,并让旧拼树 helper 退出主路径。** + +这版定义刻意不包含: + +- 树域独立 Rust Web shell +- `Leptos`/`Dioxus` UI 重写 +- Sidebar 整体壳替换 + +这些是 `Phase 4` 完成后的下一层任务。 + +--- + +## 6. Phase 4 拆分原则 + +### 原则 1 + +先冻结协议,再收 consumer。 + +### 原则 2 + +先收掉旧 helper 的主路径依赖,再谈树域 UI 重构。 + +### 原则 3 + +文件树不是第二套树,而是更宽对象范围的 projection。 + +### 原则 4 + +每一条树域 consumer 都必须能明确回答: + +- 它消费的 projection 是什么 +- 它本地状态只剩什么 +- 它是否还在自己定义结构真相 + +--- + +## 7. Phase 4 任务拆分 + +建议把 `Phase 4` 拆成 6 个子任务。 + +--- + +## 8. P4-1:冻结树域 projection protocol + +**状态:`DONE`** + +**目标** + +先把 Sidebar / 页面树 / 文件树要共同使用的 tree protocol 冻结下来。 + +**要做的事** + +- 明确最小 tree item / tree row 字段 +- 明确 `sidebar_tree`、`page_tree`、`file_tree` 的共用字段与差异字段 +- 明确: + - `node_id` + - `parent_node_id` + - `node_type` + - `projection_kind` + - `depth` + - `position` + - `capabilities` + - `resource_meta` +- 明确哪些字段属于 projection +- 明确哪些字段只能留在 UI 本地状态 + +**交付物** + +- [x] 树域 projection protocol 文档与类型: + - [tree-projection.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/tree-projection.ts) +- [x] 当前前端类型定义对齐方案 + +**完成判定** + +- [x] 后续所有树域 consumer 都有统一协议可依赖 + +--- + +## 9. P4-2:统一 Sidebar 主树与页面树 consumer + +**状态:`DONE`** + +**目标** + +让主 Sidebar、页面树相关视图都明确只消费 `kernelSidebarTree` / projection protocol。 + +**要做的事** + +- 盘点 Sidebar 主树的所有树读取入口 +- 清理仍然从 `documents[]` 直接拼主树的路径 +- 把排序、层级、childCount、expand hint 统一收口到 projection +- 让主树 consumer 只保留: + - expanded + - selected + - hover + - DnD 中间态 + +**交付物** + +- [x] Sidebar 主树消费边界收口 +- [x] `PrivateTree` / 顶部树列表统一改读 `page_tree projection` + +**完成判定** + +- [x] 主 Sidebar 不再自己定义页面树结构 + +--- + +## 10. P4-3:把文件树收口为统一 projection consumer + +**状态:`DONE`** + +**目标** + +让文件树从“前端继续拼装的树行系统”收口为统一 tree protocol 的 consumer。 + +**要做的事** + +- 明确文件树和页面树的关系: + - 文件树 = 页面树骨架 + 更宽对象投影 +- 把 `asset-folder` / `asset` / `index` 的协议层次化 +- 收紧 `buildVisibleRows(...)` 的职责 + - 它只负责 projection -> visible rows + - 不再负责定义结构真相 +- 明确哪些对象后续应由 Rust projection 直接输出: + - `mindmap` + - `table` + - `asset` + - `book` + - `pdf` + +**交付物** + +- [x] 文件树 row model 收口方案 +- [x] 文件树 projection consumer 边界 + +**完成判定** + +- [x] 文件树不再被视为独立第二套树系统 + +--- + +## 11. P4-4:统一 move / embed picker 等兼容树域 + +**状态:`DONE`** + +**目标** + +把兼容树域 consumer 也切到 projection,结束双轨树模型。 + +**要做的事** + +- 改成直接消费 projection tree / flat rows +- 搜索态和空查询态统一到同一目标对象口径 +- 明确 picker 只消费 page tree,不再自带另一套树构造逻辑 + +**交付物** + +- [x] picker 树域消费统一 + +**完成判定** + +- [x] `buildDocumentTree(...)` 退出 picker 主路径 + +--- + +## 12. P4-5:收敛旧 helper 与兼容逻辑 + +**状态:`DONE`** + +**目标** + +让旧树 helper 不再占据主路径,只保留必要过渡边界。 + +**要做的事** + +- 盘点 `buildDocumentTree(...)` 所有 consumer +- 区分: + - 主路径必须替换 + - 过渡路径可暂留 +- 收敛 `lib/documents.ts` 在树域中的责任 +- 给保留的兼容 helper 标明过渡边界 + +**交付物** + +- [x] 树域旧 helper 清单 +- [x] 主路径替换完成 + +**完成判定** + +- [x] 树域主路径不再依赖旧对象数组拼树 + +--- + +## 13. P4-6:Phase 4 验收矩阵与下一阶段交接 + +**状态:`DONE`** + +**目标** + +避免 `Phase 4` 再被提前写成“树域已重构完成”。 + +**要做的事** + +- 建立 Phase 4 验收矩阵: + - Sidebar 主树 consumer + - 页面树 consumer + - 文件树 consumer + - picker consumer + - 旧 helper 退出主路径 + - tree protocol 冻结 +- 回写 checklist +- 明确 Phase 4 完成后才能进入: + - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) + +**交付物** + +- [x] Phase 4 验收矩阵 +- [x] 下一阶段交接说明 + +**完成判定** + +- [x] 能明确回答“为什么现在可以做树域 Rust Web shell 重构” + +### 验收矩阵 + +| 验收项 | 当前状态 | 说明 | +| --- | --- | --- | +| Sidebar 主树 consumer | `DONE` | 主 Sidebar 与 `PrivateTree` 已统一消费 `kernelSidebarTree -> page_tree projection` | +| 页面树 consumer | `DONE` | 顶部树列表、页面树扁平化视图不再各自 flatten 嵌套树 | +| 文件树 consumer | `DONE` | 文件树只消费 `page_tree projection + asset 映射`,`buildVisibleRows(...)` 只做 visible rows | +| picker consumer | `DONE` | `move-embed picker` 空查询态已改读 projection family | +| 旧 helper 退出主路径 | `DONE` | 运行时树域主路径不再调用 `buildDocumentTree(...)` | +| tree protocol 冻结 | `DONE` | `rowId/nodeId/parentNodeId/projectionKind/depth/position/capabilities/resourceMeta` 已固定 | + +### 下一阶段交接 + +- 本阶段完成后,下一步唯一合理的大任务是: + - [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md) +- 原因不是“树域 UI 已重写完成”,而是: + - 协议已经冻结 + - consumer 已经统一 + - 旧拼树 helper 已退出主路径 + - 现在可以稳定做一次“换壳”,而不是边改协议边重写 UI + +--- + +## 14. 建议执行顺序 + +建议顺序如下: + +1. `P4-1` 冻结协议 +2. `P4-2` 收主 Sidebar / 页面树 consumer +3. `P4-3` 收文件树 consumer +4. `P4-4` 收 picker / 兼容树域 +5. `P4-5` 收旧 helper +6. `P4-6` 做验收矩阵和下一阶段交接 + +原因: + +- 如果不先冻结协议,后面的 consumer 收口会一边做一边改字段 +- 如果不先收主树和文件树,picker 再怎么改也只是边角修补 +- 如果不收掉旧 helper,后面的 Rust Web tree shell 会继续背着双轨数据模型前进 + +--- + +## 15. 暂不纳入 Phase 4 的内容 + +下面这些先不要塞进 `Phase 4`: + +- 用 `Leptos` / `Dioxus` 重写树域 UI +- 独立 Rust Web tree shell +- Sidebar 全量业务壳替换 +- 搜索主面板迁移 +- AI 主面板迁移 +- Mindmap 主画布迁移 + +这些都属于 `Phase 4` 之后的阶段。 + +--- + +## 16. Phase 4 完成的最终标准 + +只有同时满足下面几条,才建议把 `Phase 4` 标成完成: + +- Sidebar 主树、页面树、文件树、picker 都直接消费 kernel projection family +- `buildDocumentTree(...)` 不再处于树域主路径 +- 文件树被正式定义为更宽对象投影,而不是第二套树 +- tree protocol 已冻结 +- React/Next 树域只保留渲染与局部交互状态,不再定义树结构真相 + +当前已经满足上述标准,因此 `Phase 4` 可以标记为完成;但这不意味着树域 Rust Web shell 已经完成。 + +--- + +## 17. 最终结论 + +`Phase 4` 当前最准确的职责是: + +> **先把树域 consumer、协议、旧 helper 边界全部统一,确保树域真正只剩一个 truth 和一套 projection family。** + +现在这一步已经完成,所以下一步的: + +- Sidebar / 页面树 / 文件树 Rust Web tree shell 重构 + +才会变成一次稳定的“换壳”,而不是一次边重写协议边重写 UI 的高风险返工。 diff --git a/harness-progress.txt b/harness-progress.txt index 0b4f38af..dec3dd83 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -412,3 +412,47 @@ [2026-04-16T13:18:40Z] [SESSION-31] STATS tasks_total=80 completed=80 failed=0 pending=0 blocked=0 attempts_total=78 checkpoints=180 [2026-04-16T13:18:41Z] [SESSION-31] INFO Removed .harness-active after task-076..080 completion; all harness tasks are now completed [2026-04-16T13:18:42Z] [SESSION-31] LOCK released + +[2026-04-16T15:00:53Z] [SESSION-32] LOCK acquired (pid=manual-session32) +[2026-04-16T15:00:54Z] [SESSION-32] INIT Environment health check: PASS +[2026-04-16T15:00:55Z] [SESSION-32] INIT Added task-081..084 for Tree-First Graph Kernel Phase 3 completion +[2026-04-16T15:00:56Z] [SESSION-32] Starting [task-081] 执行 Kernel Phase 3 P3-1/P3-2:把 mnote-web query transport 从 Sidebar 单点实现提升为通用 Convex query 执行层,并把 fixture fallback 收紧到测试/开发边界 (base=17afde7d) +[2026-04-16T15:00:56Z] [SESSION-32] CHECKPOINT [task-081] step=1/1 "已把 mnote-web transport 收口为通用 execute_convex_query_plan,并以 allow_dev_fixtures + MNOTE_WEB_QUERY_FIXTURES_JSON 将 fixture 限定到测试/开发边界;mnote-web 定向 cargo test/check 通过。" +[2026-04-16T15:00:57Z] [SESSION-32] Completed [task-081] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T15:00:58Z] [SESSION-32] Starting [task-082] 执行 Kernel Phase 3 P3-3:为 mnote-web 新增 bridge.workspace/request/trace 真实查询主链,证明 Rust Web 不再只承接 sidebar 数据集 (base=17afde7d) +[2026-04-16T15:00:59Z] [SESSION-32] CHECKPOINT [task-082] step=1/1 "已新增 /api/bridge/workspace、/api/bridge/request、/api/bridge/trace,并复用通用 query transport 读取真实 bridgeLogs 查询;mnote-web route test 通过。" +[2026-04-16T15:01:00Z] [SESSION-32] Completed [task-082] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T15:01:01Z] [SESSION-32] Starting [task-083] 执行 Kernel Phase 3 P3-4/P3-5:补齐 workspace/request/trace/error 语义,并把 Sidebar 首包与 /api/sidebar 通过 compat sidebar 接到 mnote-web,形成第一条真实切流边界 (base=17afde7d) +[2026-04-16T15:01:02Z] [SESSION-32] CHECKPOINT [task-083] step=1/1 "已统一 workspace 解析与错误码,transport 优先转发真实 Authorization;Sidebar 服务端首包与 /api/sidebar 在配置 MNOTE_WEB_BASE_URL 后默认经 /api/compat/next/sidebar 走 mnote-web。前端定向 vitest / eslint 通过(eslint 仅 baseline-browser-mapping 提示,无 error)。" +[2026-04-16T15:01:03Z] [SESSION-32] Completed [task-083] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T15:01:04Z] [SESSION-32] Starting [task-084] 执行 Kernel Phase 3 P3-6:回写 phase3 拆解/checklist 与 harness 状态,确认 Tree-First Graph Kernel Phase 3 已满足 DONE 判定 (base=17afde7d) +[2026-04-16T15:01:04Z] [SESSION-32] CHECKPOINT [task-084] step=1/1 "已回写 tree-first kernel checklist、phase3 breakdown 与 rust-web long-term checklist,统一 Phase 3 DONE / Rust Web Phase 1 仍 PARTIAL 的真实口径,并同步 harness 完成状态。" +[2026-04-16T15:01:05Z] [SESSION-32] Completed [task-084] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T15:01:06Z] [SESSION-32] STATS tasks_total=84 completed=84 failed=0 pending=0 blocked=0 attempts_total=82 checkpoints=184 +[2026-04-16T15:01:06Z] [SESSION-32] INFO Removed .harness-active after task-081..084 completion; current harness batch completed +[2026-04-16T15:01:07Z] [SESSION-32] LOCK released +[2026-04-16T16:11:17Z] [SESSION-33] LOCK acquired (pid=manual-session33) +[2026-04-16T16:11:18Z] [SESSION-33] INIT Environment health check: PASS +[2026-04-16T16:11:19Z] [SESSION-33] INIT Added task-085..090 for Tree-First Graph Kernel Phase 4 completion +[2026-04-16T16:11:20Z] [SESSION-33] Starting [task-085] 执行 Kernel Phase 4 P4-1:冻结树域 page_tree projection protocol,并补齐 Sidebar / 页面树共享字段与类型边界 (base=17afde7d) +[2026-04-16T16:11:20Z] [SESSION-33] CHECKPOINT [task-085] step=1/1 "已新增 tree-projection.ts,冻结 rowId/nodeId/parentNodeId/projectionKind/depth/position/capabilities/resourceMeta 等 page_tree protocol,并补充对应单测。" +[2026-04-16T16:11:21Z] [SESSION-33] Completed [task-085] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T16:11:22Z] [SESSION-33] Starting [task-086] 执行 Kernel Phase 4 P4-2:统一 Sidebar 主树与页面树 consumer,让 PrivateTree 与相关树列表直接消费 page_tree projection (base=17afde7d) +[2026-04-16T16:11:23Z] [SESSION-33] CHECKPOINT [task-086] step=1/1 "已让 Sidebar 顶部树列表、PrivateTree 与 convert-to-child 等页面树 consumer 统一消费 page_tree projection,可见行只保留 expanded 等 UI 状态。" +[2026-04-16T16:11:24Z] [SESSION-33] Completed [task-086] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T16:11:25Z] [SESSION-33] Starting [task-087] 执行 Kernel Phase 4 P4-3:把文件树收口为统一 projection consumer,让 buildVisibleRows 只负责 projection 到 visible rows 的映射 (base=17afde7d) +[2026-04-16T16:11:26Z] [SESSION-33] CHECKPOINT [task-087] step=1/1 "文件树已改为只消费 page_tree projection + asset 映射;buildVisibleRows 不再递归定义树结构真相,相关 rows 单测通过。" +[2026-04-16T16:11:27Z] [SESSION-33] Completed [task-087] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T16:11:28Z] [SESSION-33] Starting [task-088] 执行 Kernel Phase 4 P4-4:统一 move/embed picker 等兼容树域,结束 documents[] 到 buildDocumentTree 的双轨主路径 (base=17afde7d) +[2026-04-16T16:11:29Z] [SESSION-33] CHECKPOINT [task-088] step=1/1 "move-embed picker 空查询态已直接消费 kernelSidebarTree -> page_tree projection,不再调用 buildDocumentTree(...)。" +[2026-04-16T16:11:30Z] [SESSION-33] Completed [task-088] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T16:11:31Z] [SESSION-33] Starting [task-089] 执行 Kernel Phase 4 P4-5:收敛旧 helper 与兼容逻辑,把 buildDocumentTree 退回兼容边界并收紧 sidebar-data 主路径投影要求 (base=17afde7d) +[2026-04-16T16:11:32Z] [SESSION-33] CHECKPOINT [task-089] step=1/1 "已将 SidebarInitialData 的 kernelSidebarProjection/kernelSidebarTree 收紧为主路径必备字段,并给 buildDocumentTree 标明仅保留兼容 helper 边界。" +[2026-04-16T16:11:33Z] [SESSION-33] Completed [task-089] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T16:11:34Z] [SESSION-33] Starting [task-090] 执行 Kernel Phase 4 P4-6:回写 Phase 4 checklist/breakdown 与 harness 状态,确认树域 consumer/protocol 已满足 DONE 判定并交接下一阶段 Rust Web tree shell 重构 (base=17afde7d) +[2026-04-16T16:11:35Z] [SESSION-33] CHECKPOINT [task-090] step=1/1 "已回写 Phase 4 checklist/breakdown 与 harness 状态,统一 Phase 4 DONE 口径,并明确下一阶段才是 sidebar-pagetree-filetree-rust-web-rebuild-v1.md。" +[2026-04-16T16:11:36Z] [SESSION-33] Completed [task-090] (commit skipped by repo rule; base=17afde7d) +[2026-04-16T16:11:37Z] [SESSION-33] STATS tasks_total=90 completed=90 failed=0 pending=0 blocked=0 attempts_total=88 checkpoints=190 +[2026-04-16T16:11:37Z] [SESSION-33] INFO Removed .harness-active after task-085..090 completion; current harness batch completed +[2026-04-16T16:11:38Z] [SESSION-33] LOCK released + diff --git a/harness-tasks.json b/harness-tasks.json index 81bf8dc6..7dcfbb75 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -2226,47 +2226,6 @@ ], "completed_at": "2026-04-16T04:52:15Z" }, - { - "id": "task-058", - "title": "按 AI 前端精简方案 v1 补齐 Hermes 与 mnote Rust 的业务能力桥:明确并落地 Hermes 调用 mnote 文档/导图/OnlyOffice Rust 能力的最小边界,优先评估 MCP server、Hermes plugin/tool adapter 或最小业务 bridge", - "status": "completed", - "priority": "P0", - "depends_on": [ - "task-054" - ], - "attempts": 1, - "max_attempts": 3, - "started_at_commit": "4fbab6dd", - "validation": { - "command": "cd /mnt/Data1T/mnote && python3 - <<'PY'\nimport json\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/ai-frontend-simplification-plan-v1.md').read_text(encoding='utf-8')\nneedles = [\n 'Hermes API Server',\n 'mnote Rust',\n 'MCP server',\n 'Hermes plugin/tool adapter',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-058-doc-ok')\nPY", - "timeout_seconds": 1800 - }, - "on_failure": { - "cleanup": null - }, - "error_log": [], - "checkpoints": [ - { - "step": 1, - "total": 3, - "description": "已明确 Hermes 不复制 mnote 业务真逻辑,mnote Rust 继续作为文档、块、导图与 OnlyOffice 的真执行面。", - "timestamp": "2026-04-16T04:52:02Z" - }, - { - "step": 2, - "total": 3, - "description": "已在 AI 前端精简方案中固定首选接缝:优先最小业务 bridge,并记录 MCP server 与 Hermes plugin/tool adapter 的评估边界。", - "timestamp": "2026-04-16T04:52:03Z" - }, - { - "step": 3, - "total": 3, - "description": "已补齐 Hermes API Server -> mnote Rust 的最小业务桥边界,避免 /api/ai-agent/run 收口后出现业务能力真空。", - "timestamp": "2026-04-16T04:52:04Z" - } - ], - "completed_at": "2026-04-16T04:52:05Z" - }, { "id": "task-057", "title": "按 AI 前端精简方案 v1 执行第四阶段:清理前端 AI 遗留 registry、builtins、重复 localStorage/session 管理与历史兼容 helper,仅保留 Hermes bridge、mnote Rust 业务执行面与浏览器专属 client capability bridge", @@ -2311,6 +2270,47 @@ ], "completed_at": "2026-04-16T04:52:20Z" }, + { + "id": "task-058", + "title": "按 AI 前端精简方案 v1 补齐 Hermes 与 mnote Rust 的业务能力桥:明确并落地 Hermes 调用 mnote 文档/导图/OnlyOffice Rust 能力的最小边界,优先评估 MCP server、Hermes plugin/tool adapter 或最小业务 bridge", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-054" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "4fbab6dd", + "validation": { + "command": "cd /mnt/Data1T/mnote && python3 - <<'PY'\nimport json\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/ai-frontend-simplification-plan-v1.md').read_text(encoding='utf-8')\nneedles = [\n 'Hermes API Server',\n 'mnote Rust',\n 'MCP server',\n 'Hermes plugin/tool adapter',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-058-doc-ok')\nPY", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 3, + "description": "已明确 Hermes 不复制 mnote 业务真逻辑,mnote Rust 继续作为文档、块、导图与 OnlyOffice 的真执行面。", + "timestamp": "2026-04-16T04:52:02Z" + }, + { + "step": 2, + "total": 3, + "description": "已在 AI 前端精简方案中固定首选接缝:优先最小业务 bridge,并记录 MCP server 与 Hermes plugin/tool adapter 的评估边界。", + "timestamp": "2026-04-16T04:52:03Z" + }, + { + "step": 3, + "total": 3, + "description": "已补齐 Hermes API Server -> mnote Rust 的最小业务桥边界,避免 /api/ai-agent/run 收口后出现业务能力真空。", + "timestamp": "2026-04-16T04:52:04Z" + } + ], + "completed_at": "2026-04-16T04:52:05Z" + }, { "id": "task-059", "title": "按 Rust Web 长期架构实施清单 v1 推进长期重构主线:统一阶段边界、依赖顺序、横向能力和最终验收口径,避免后续再次回到“Rust 内核 + 重前端页面壳”的临时态", @@ -2966,8 +2966,308 @@ } ], "completed_at": "2026-04-16T13:18:39Z" + }, + { + "id": "task-081", + "title": "执行 Kernel Phase 3 P3-1/P3-2:把 mnote-web query transport 从 Sidebar 单点实现提升为通用 Convex query 执行层,并把 fixture fallback 收紧到测试/开发边界", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-080" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cargo test -p mnote-web --manifest-path rust/Cargo.toml && cargo check --manifest-path rust/Cargo.toml", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已把 mnote-web transport 收口为通用 execute_convex_query_plan,并以 allow_dev_fixtures + MNOTE_WEB_QUERY_FIXTURES_JSON 将 fixture 限定到测试/开发边界;mnote-web 定向 cargo test/check 通过。", + "timestamp": "2026-04-16T15:00:56Z" + } + ], + "completed_at": "2026-04-16T15:00:57Z" + }, + { + "id": "task-082", + "title": "执行 Kernel Phase 3 P3-3:为 mnote-web 新增 bridge.workspace/request/trace 真实查询主链,证明 Rust Web 不再只承接 sidebar 数据集", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-081" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cargo test -p mnote-web --manifest-path rust/Cargo.toml", + "timeout_seconds": 1200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 /api/bridge/workspace、/api/bridge/request、/api/bridge/trace,并复用通用 query transport 读取真实 bridgeLogs 查询;mnote-web route test 通过。", + "timestamp": "2026-04-16T15:00:59Z" + } + ], + "completed_at": "2026-04-16T15:01:00Z" + }, + { + "id": "task-083", + "title": "执行 Kernel Phase 3 P3-4/P3-5:补齐 workspace/request/trace/error 语义,并把 Sidebar 首包与 /api/sidebar 通过 compat sidebar 接到 mnote-web,形成第一条真实切流边界", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-081", + "task-082" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/sidebar-data.test.ts && pnpm exec eslint src/app/api/sidebar/route.ts src/lib/server/sidebar-data.ts src/lib/server/mnote-web.ts", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已统一 workspace 解析与错误码,transport 优先转发真实 Authorization;Sidebar 服务端首包与 /api/sidebar 在配置 MNOTE_WEB_BASE_URL 后默认经 /api/compat/next/sidebar 走 mnote-web。前端定向 vitest / eslint 通过(eslint 仅 baseline-browser-mapping 提示,无 error)。", + "timestamp": "2026-04-16T15:01:02Z" + } + ], + "completed_at": "2026-04-16T15:01:03Z" + }, + { + "id": "task-084", + "title": "执行 Kernel Phase 3 P3-6:回写 phase3 拆解/checklist 与 harness 状态,确认 Tree-First Graph Kernel Phase 3 已满足 DONE 判定", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-081", + "task-082", + "task-083" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\ncheck = (root / 'design/tree-first-graph-kernel-checklist-v2.md').read_text(encoding='utf-8')\nassert 'Kernel Phase 3`:`DONE`' in check or 'Kernel Phase 3:`DONE`' in check\nprint('task-084-phase3-docs-updated')\nPY2", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已回写 tree-first kernel checklist、phase3 breakdown 与 rust-web long-term checklist,统一 Phase 3 DONE / Rust Web Phase 1 仍 PARTIAL 的真实口径,并同步 harness 完成状态。", + "timestamp": "2026-04-16T15:01:04Z" + } + ], + "completed_at": "2026-04-16T15:01:05Z" + }, + { + "id": "task-085", + "title": "执行 Kernel Phase 4 P4-1:冻结树域 page_tree projection protocol,并补齐 Sidebar / 页面树共享字段与类型边界", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-084" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/tree-projection.test.ts src/lib/sidebar-data.test.ts", + "timeout_seconds": 1200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 tree-projection.ts,冻结 rowId/nodeId/parentNodeId/projectionKind/depth/position/capabilities/resourceMeta 等 page_tree protocol,并补充对应单测。", + "timestamp": "2026-04-16T16:11:20Z" + } + ], + "completed_at": "2026-04-16T16:11:21Z" + }, + { + "id": "task-086", + "title": "执行 Kernel Phase 4 P4-2:统一 Sidebar 主树与页面树 consumer,让 PrivateTree 与相关树列表直接消费 page_tree projection", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-085" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/tree-projection.test.ts src/lib/sidebar-data.test.ts && pnpm exec eslint src/components/sidebar/private-tree.tsx src/components/sidebar/sidebar.tsx src/lib/tree-projection.ts", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已让 Sidebar 顶部树列表、PrivateTree 与 convert-to-child 等页面树 consumer 统一消费 page_tree projection,可见行只保留 expanded 等 UI 状态。", + "timestamp": "2026-04-16T16:11:23Z" + } + ], + "completed_at": "2026-04-16T16:11:24Z" + }, + { + "id": "task-087", + "title": "执行 Kernel Phase 4 P4-3:把文件树收口为统一 projection consumer,让 buildVisibleRows 只负责 projection 到 visible rows 的映射", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-085", + "task-086" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/file-tree/rows.test.ts src/lib/tree-projection.test.ts && pnpm exec eslint src/lib/file-tree/rows.ts src/lib/file-tree/rows.test.ts src/components/sidebar/sidebar.tsx", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "文件树已改为只消费 page_tree projection + asset 映射;buildVisibleRows 不再递归定义树结构真相,相关 rows 单测通过。", + "timestamp": "2026-04-16T16:11:26Z" + } + ], + "completed_at": "2026-04-16T16:11:27Z" + }, + { + "id": "task-088", + "title": "执行 Kernel Phase 4 P4-4:统一 move/embed picker 等兼容树域,结束 documents[] 到 buildDocumentTree 的双轨主路径", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-085", + "task-086" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/tree-projection.test.ts src/lib/sidebar-data.test.ts && pnpm exec eslint src/components/documents/move-embed-picker-dialog.tsx src/lib/tree-projection.ts", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "move-embed picker 空查询态已直接消费 kernelSidebarTree -> page_tree projection,不再调用 buildDocumentTree(...)。", + "timestamp": "2026-04-16T16:11:29Z" + } + ], + "completed_at": "2026-04-16T16:11:30Z" + }, + { + "id": "task-089", + "title": "执行 Kernel Phase 4 P4-5:收敛旧 helper 与兼容逻辑,把 buildDocumentTree 退回兼容边界并收紧 sidebar-data 主路径投影要求", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-087", + "task-088" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/sidebar-data.test.ts src/lib/tree-projection.test.ts src/lib/file-tree/rows.test.ts && pnpm exec eslint src/lib/sidebar-data.ts src/components/sidebar/types.ts src/lib/documents.ts", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已将 SidebarInitialData 的 kernelSidebarProjection/kernelSidebarTree 收紧为主路径必备字段,并给 buildDocumentTree 标明仅保留兼容 helper 边界。", + "timestamp": "2026-04-16T16:11:32Z" + } + ], + "completed_at": "2026-04-16T16:11:33Z" + }, + { + "id": "task-090", + "title": "执行 Kernel Phase 4 P4-6:回写 Phase 4 checklist/breakdown 与 harness 状态,确认树域 consumer/protocol 已满足 DONE 判定并交接下一阶段 Rust Web tree shell 重构", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-085", + "task-086", + "task-087", + "task-088", + "task-089" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "17afde7d", + "validation": { + "command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\ncheck = (root / 'design/tree-first-graph-kernel-checklist-v2.md').read_text(encoding='utf-8')\nbreakdown = (root / 'design/tree-first-graph-kernel-phase4-task-breakdown-v1.md').read_text(encoding='utf-8')\nassert 'Kernel Phase 4`:`DONE' in check or 'Kernel Phase 4:`DONE`' in check\nassert 'P4-6:Phase 4 验收矩阵与下一阶段交接' in breakdown\nassert 'sidebar-pagetree-filetree-rust-web-rebuild-v1.md' in breakdown\nprint('task-090-phase4-docs-updated')\nPY2", + "timeout_seconds": 120 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已回写 Phase 4 checklist/breakdown 与 harness 状态,统一 Phase 4 DONE 口径,并明确下一阶段才是 sidebar-pagetree-filetree-rust-web-rebuild-v1.md。", + "timestamp": "2026-04-16T16:11:35Z" + } + ], + "completed_at": "2026-04-16T16:11:36Z" } ], - "session_count": 31, - "last_session": "2026-04-16T13:18:40Z" + "session_count": 33, + "last_session": "2026-04-16T16:11:37Z" } diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index e045c7fd..4675a708 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -14,6 +14,7 @@ pub struct AppConfig { pub compat_next_base_path: String, pub convex_url: Option, pub convex_admin_key: Option, + pub allow_dev_fixtures: bool, pub dev_user_id: String, pub dev_user_name: String, pub dev_user_email: String, @@ -22,12 +23,10 @@ pub struct AppConfig { impl AppConfig { pub fn from_env() -> Self { Self { - service_name: env::var("MNOTE_WEB_SERVICE_NAME") - .unwrap_or_else(|_| "mnote-web".into()), + 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:3104".into()), 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") @@ -41,6 +40,10 @@ impl AppConfig { .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()), + allow_dev_fixtures: env::var("MNOTE_WEB_ALLOW_DEV_FIXTURES") + .ok() + .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false), dev_user_id: env::var("DEV_USER_ID").unwrap_or_else(|_| "dev-user".into()), dev_user_name: env::var("DEV_USER_NAME").unwrap_or_else(|_| "开发用户".into()), dev_user_email: env::var("DEV_USER_EMAIL").unwrap_or_else(|_| "dev@mnote.local".into()), diff --git a/rust/crates/mnote-web/src/error.rs b/rust/crates/mnote-web/src/error.rs index 07f3c1a8..fbaf1700 100644 --- a/rust/crates/mnote-web/src/error.rs +++ b/rust/crates/mnote-web/src/error.rs @@ -1,5 +1,6 @@ use crate::context::RequestContext; use axum::http::StatusCode; +use axum::http::{HeaderName, HeaderValue}; use axum::response::{IntoResponse, Response}; use axum::Json; use serde::Serialize; @@ -20,6 +21,7 @@ pub struct WebError { code: &'static str, message: String, request_context: Option, + headers: Vec<(&'static str, String)>, } impl WebError { @@ -29,6 +31,7 @@ impl WebError { code, message: message.into(), request_context: None, + headers: Vec::new(), } } @@ -36,14 +39,35 @@ impl WebError { Self::new(StatusCode::BAD_REQUEST, "bad_request", message) } + pub fn bad_request_code(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, code, message) + } + pub fn internal(message: impl Into) -> Self { Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message) } + pub fn bad_gateway_code(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::BAD_GATEWAY, code, message) + } + + pub fn service_unavailable_code(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::SERVICE_UNAVAILABLE, code, message) + } + + pub fn gateway_timeout_code(code: &'static str, message: impl Into) -> Self { + Self::new(StatusCode::GATEWAY_TIMEOUT, code, message) + } + pub fn with_context(mut self, request_context: &RequestContext) -> Self { self.request_context = Some(request_context.clone()); self } + + pub fn with_header(mut self, name: &'static str, value: impl Into) -> Self { + self.headers.push((name, value.into())); + self + } } impl IntoResponse for WebError { @@ -61,6 +85,21 @@ impl IntoResponse for WebError { .as_ref() .map(|context| context.trace.trace_id.clone()), }; - (self.status, Json(body)).into_response() + let mut response = (self.status, Json(body)).into_response(); + if let Ok(name) = HeaderName::from_lowercase(b"x-error-code") { + if let Ok(value) = HeaderValue::from_str(self.code) { + response.headers_mut().insert(name, value); + } + } + for (name, value) in self.headers { + let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else { + continue; + }; + let Ok(header_value) = HeaderValue::from_str(&value) else { + continue; + }; + response.headers_mut().insert(header_name, header_value); + } + response } } diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs new file mode 100644 index 00000000..3f3c7001 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -0,0 +1,191 @@ +use crate::app::{AppConfig, AppState}; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::query_support::{ + execute_runtime_query_via_convex, resolve_effective_workspace_id, +}; +use axum::extract::{Extension, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use bridge_runtime::RuntimeQueryEnvelopeWire; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeWorkspaceQuery { + pub workspace_id: Option, + pub limit: Option, + pub cursor: Option, + pub command_status: Option, + pub event_status: Option, + pub target_page_id: Option, + pub target_block_id: Option, + pub aggregate_type: Option, + pub aggregate_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeRequestQuery { + pub workspace_id: Option, + pub request_id: String, + pub command_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeTraceQuery { + pub workspace_id: Option, + pub trace_id: String, + pub command_id: Option, +} + +fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "result": result, + })), + ) +} + +fn workspace_query_payload( + workspace_id: &str, + query: &BridgeWorkspaceQuery, +) -> RuntimeQueryEnvelopeWire { + RuntimeQueryEnvelopeWire { + name: "bridge.workspace.overview".into(), + payload: json!({ + "workspaceId": workspace_id, + "limit": query.limit.unwrap_or(50), + "cursor": query.cursor, + "commandStatus": query.command_status, + "eventStatus": query.event_status, + "targetPageId": query.target_page_id, + "targetBlockId": query.target_block_id, + "aggregateType": query.aggregate_type, + "aggregateId": query.aggregate_id, + }), + } +} + +fn execute_bridge_query( + config: &AppConfig, + context: &RequestContext, + workspace_id: &str, + query: RuntimeQueryEnvelopeWire, +) -> Result { + execute_runtime_query_via_convex(config, context, Some(workspace_id), query) +} + +pub async fn workspace( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let result = execute_bridge_query( + state.config(), + &context, + &effective_workspace_id, + workspace_query_payload(&effective_workspace_id, &query), + )?; + Ok(ok_response(&context, result)) +} + +pub async fn request( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let result = execute_bridge_query( + state.config(), + &context, + &effective_workspace_id, + RuntimeQueryEnvelopeWire { + name: "bridge.request.get".into(), + payload: json!({ + "workspaceId": effective_workspace_id, + "requestId": query.request_id, + "commandId": query.command_id, + }), + }, + )?; + Ok(ok_response(&context, result)) +} + +pub async fn trace( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let result = execute_bridge_query( + state.config(), + &context, + &effective_workspace_id, + RuntimeQueryEnvelopeWire { + name: "bridge.trace.get".into(), + payload: json!({ + "workspaceId": effective_workspace_id, + "traceId": query.trace_id, + "commandId": query.command_id, + }), + }, + )?; + Ok(ok_response(&context, result)) +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + std::env::set_var( + "MNOTE_WEB_QUERY_FIXTURES_JSON", + r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#, + ); + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn bridge_workspace_route_returns_runtime_result() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/bridge/workspace?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index f9083a59..a28aadb5 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -1,8 +1,18 @@ use crate::app::AppState; use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::query_support::{ + execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, +}; +use axum::extract::Query; use axum::extract::{Extension, State}; +use axum::http::StatusCode; use axum::Json; +use bridge_runtime::RuntimeQueryEnvelopeWire; +use core_protocol::{KernelNodeType, KernelProjectionKind}; +use serde::Deserialize; use serde::Serialize; +use serde_json::{json, Value}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -16,6 +26,12 @@ pub struct CompatBoundaryResponse { pub notes: Vec<&'static str>, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompatSidebarQuery { + pub workspace_id: Option, +} + pub async fn next_ai_agent_run( State(state): State, Extension(context): Extension, @@ -33,3 +49,103 @@ pub async fn next_ai_agent_run( ], }) } + +pub async fn next_sidebar( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result<(StatusCode, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + + let dataset = fetch_query_data_via_convex( + state.config(), + &context, + Some(&effective_workspace_id), + RuntimeQueryEnvelopeWire { + name: "sidebar.dataset.list".into(), + payload: json!({ + "workspaceId": effective_workspace_id, + }), + }, + )?; + let projection = execute_runtime_query_against_data( + &context, + Some(&effective_workspace_id), + RuntimeQueryEnvelopeWire { + name: "kernel.project_view".into(), + payload: json!({ + "projection": KernelProjectionKind::SidebarTree, + "workspaceId": effective_workspace_id, + "includeEdges": true, + "includeContent": false, + "nodeTypes": [KernelNodeType::Page], + }), + }, + dataset.clone(), + )?; + + let mut dataset_object = dataset.as_object().cloned().ok_or_else(|| { + WebError::bad_gateway_code("convex_bad_response", "sidebar.dataset.list 返回值不是对象") + .with_context(&context) + .with_header("x-error-phase", "compat_sidebar_shape") + .with_header("x-upstream-service", "convex") + })?; + dataset_object.insert("kernel_sidebar_projection".into(), projection); + + Ok(( + StatusCode::OK, + Json(json!({ + "ok": true, + "boundary": "next_sidebar_compat", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "workspaceId": effective_workspace_id, + "result": Value::Object(dataset_object), + })), + )) +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + std::env::set_var( + "MNOTE_WEB_QUERY_FIXTURES_JSON", + r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","workspaces":[],"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"trashed_documents":[],"media_assets":[],"trashed_media_assets":[],"mindmap_assets":[],"trashed_mindmap_assets":[],"table_assets":[],"trashed_table_assets":[],"mindmap_docs":[],"mindmap_asset_children":{}},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[],"domain_events":[],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#, + ); + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn compat_sidebar_route_returns_dataset_and_projection() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/compat/next/sidebar?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index ca695695..72aff186 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -1,18 +1,16 @@ use crate::app::{AppConfig, AppState}; use crate::context::RequestContext; use crate::error::WebError; -use crate::transport::convex::execute_sidebar_dataset_query; +use crate::routes::query_support::{ + execute_runtime_query_against_data, fetch_query_data_via_convex, resolve_effective_workspace_id, +}; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; use axum::Json; -use bridge_runtime::{ - execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, - RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeSourceWire, -}; +use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind}; use serde::Deserialize; use serde_json::{json, Value}; -use std::env; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -45,86 +43,47 @@ pub struct KernelGraphQuery { pub max_depth: Option, } -fn runtime_context(context: &RequestContext) -> RuntimeBridgeContextWire { - RuntimeBridgeContextWire { - deployment_id: context.workspace.deployment_id.clone(), - project_id: context.workspace.project_id.clone(), - workspace_id: context.workspace.workspace_id.clone(), - request_id: context.trace.request_id.clone(), - trace_id: context.trace.trace_id.clone(), - actor: RuntimeActorWire { - actor_type: context.auth.actor_type.clone(), - actor_id: context.auth.actor_id.clone(), - session_id: context.auth.session_id.clone(), - }, - source: RuntimeSourceWire { - channel: context.source.channel.clone(), - client: context.source.client.clone(), - }, - tenant_id: context.workspace.tenant_id.clone(), - auth_token: context.auth.authorization.clone(), - idempotency_key: context.source.idempotency_key.clone(), - validate_only: false, - dry_run: false, +fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire { + RuntimeQueryEnvelopeWire { + name: "sidebar.dataset.list".into(), + payload: json!({ + "workspaceId": workspace_id, + }), } } -fn build_sidebar_dataset_plan( - config: &AppConfig, - context: &RequestContext, - workspace_id: &str, -) -> Result { - let runtime_input = RuntimeInput::Query { - context: runtime_context(context), - query: RuntimeQueryEnvelopeWire { - name: "sidebar.dataset.list".into(), - payload: json!({ - "workspaceId": workspace_id, - }), - }, - data: None, - }; - let RuntimeExecutionPlan::Query(plan) = execute_runtime_input(runtime_input) - .map_err(|error| WebError::bad_request(error.message).with_context(context))? - else { - return Err(WebError::internal("sidebar.dataset.list 未返回 query plan").with_context(context)); - }; - - let dataset = execute_sidebar_dataset_query(config, &plan)?; - Ok(dataset) -} - fn load_sidebar_dataset( config: &AppConfig, context: &RequestContext, - workspace_id: Option<&str>, + workspace_id: &str, ) -> Result { - if let Ok(raw) = env::var("MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON") { - let trimmed = raw.trim(); - if !trimmed.is_empty() { - return serde_json::from_str(trimmed).map_err(|error| { - WebError::internal(format!("kernel fixture JSON 非法: {error}")).with_context(context) - }); - } - } - - let workspace_id = workspace_id - .or(context.workspace.workspace_id.as_deref()) - .unwrap_or("ws_demo"); - build_sidebar_dataset_plan(config, context, workspace_id) + fetch_query_data_via_convex( + config, + context, + Some(workspace_id), + sidebar_dataset_query(workspace_id), + ) } fn execute_kernel_query( context: &RequestContext, + workspace_id: &str, query: RuntimeQueryEnvelopeWire, dataset: Value, ) -> Result { - execute_runtime_query(RuntimeInput::Query { - context: runtime_context(context), - query, - data: Some(dataset), - }) - .map_err(|error| WebError::bad_request(error.message).with_context(context)) + execute_runtime_query_against_data(context, Some(workspace_id), query, dataset) +} + +fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { + ( + StatusCode::OK, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "result": result, + })), + ) } pub async fn project_sidebar( @@ -132,14 +91,18 @@ pub async fn project_sidebar( Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { - let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?; + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?; let result = execute_kernel_query( &context, + &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.project_view".into(), payload: json!({ "projection": KernelProjectionKind::SidebarTree, - "workspaceId": query.workspace_id, + "workspaceId": effective_workspace_id, "rootNodeId": query.root_node_id, "depth": query.depth, "includeEdges": true, @@ -150,12 +113,7 @@ pub async fn project_sidebar( dataset, )?; - Ok((StatusCode::OK, Json(json!({ - "ok": true, - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "result": result, - })))) + Ok(ok_response(&context, result)) } pub async fn subtree( @@ -163,13 +121,17 @@ pub async fn subtree( Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { - let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?; + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?; let result = execute_kernel_query( &context, + &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.subtree.get".into(), payload: json!({ - "workspaceId": query.workspace_id, + "workspaceId": effective_workspace_id, "rootNodeId": query.root_node_id, "depth": query.depth, "includeEdges": true, @@ -178,12 +140,7 @@ pub async fn subtree( }, dataset, )?; - Ok((StatusCode::OK, Json(json!({ - "ok": true, - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "result": result, - })))) + Ok(ok_response(&context, result)) } pub async fn edges( @@ -191,25 +148,24 @@ pub async fn edges( Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { - let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?; + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?; let result = execute_kernel_query( &context, + &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.edges.list".into(), payload: json!({ - "workspaceId": query.workspace_id, + "workspaceId": effective_workspace_id, "nodeId": query.node_id, "direction": KernelGraphDirection::Both, }), }, dataset, )?; - Ok((StatusCode::OK, Json(json!({ - "ok": true, - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "result": result, - })))) + Ok(ok_response(&context, result)) } pub async fn graph( @@ -217,13 +173,17 @@ pub async fn graph( Extension(context): Extension, Query(query): Query, ) -> Result<(StatusCode, Json), WebError> { - let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?; + let effective_workspace_id = + resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let dataset = load_sidebar_dataset(state.config(), &context, &effective_workspace_id)?; let result = execute_kernel_query( &context, + &effective_workspace_id, RuntimeQueryEnvelopeWire { name: "kernel.graph.traverse".into(), payload: json!({ - "workspaceId": query.workspace_id, + "workspaceId": effective_workspace_id, "startNodeId": query.start_node_id, "maxDepth": query.max_depth.unwrap_or(2), "edgeTypes": [], @@ -231,12 +191,7 @@ pub async fn graph( }, dataset, )?; - Ok((StatusCode::OK, Json(json!({ - "ok": true, - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "result": result, - })))) + Ok(ok_response(&context, result)) } #[cfg(test)] @@ -248,8 +203,8 @@ mod tests { fn app() -> axum::Router { std::env::set_var( - "MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON", - r#"{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]}"#, + "MNOTE_WEB_QUERY_FIXTURES_JSON", + r#"{"sidebar:datasetList":{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]},"bridgeLogs:listWorkspaceOverview":{"workspace_id":"ws_demo","command_logs":[{"command_id":"cmd_1","request_id":"req_1","status":"applied","created_at":"2026-04-16T00:00:00Z"}],"domain_events":[{"command_id":"cmd_1","status":"published","created_at":"2026-04-16T00:00:00Z"}],"next_cursor":null,"has_more":false,"filters":{"command_status":null,"event_status":null,"target_page_id":null,"target_block_id":null,"aggregate_type":null,"aggregate_id":null},"generated_at":"2026-04-16T00:00:00Z"}}"#, ); build_app(AppState::new(AppConfig { service_name: "mnote-web".into(), @@ -259,6 +214,7 @@ mod tests { compat_next_base_path: "/api/compat/next".into(), convex_url: None, convex_admin_key: None, + allow_dev_fixtures: true, dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -270,7 +226,7 @@ mod tests { let response = app() .oneshot( Request::builder() - .uri("/api/kernel/projections/sidebar?rootNodeId=page_root") + .uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root") .body(Body::empty()) .expect("request"), ) diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index d8de3216..ff169c4a 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -1,7 +1,9 @@ +mod bridge; mod compat; mod health; mod hermes; mod kernel; +mod query_support; mod sse; mod ws; @@ -15,10 +17,16 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/health", get(health::health)) - .route("/api/kernel/projections/sidebar", get(kernel::project_sidebar)) + .route( + "/api/kernel/projections/sidebar", + get(kernel::project_sidebar), + ) .route("/api/kernel/subtree", get(kernel::subtree)) .route("/api/kernel/edges", get(kernel::edges)) .route("/api/kernel/graph", get(kernel::graph)) + .route("/api/bridge/workspace", get(bridge::workspace)) + .route("/api/bridge/request", get(bridge::request)) + .route("/api/bridge/trace", get(bridge::trace)) .route("/api/stream/events", get(sse::events)) .route("/api/realtime/ws", get(ws::socket)) .nest( @@ -29,7 +37,9 @@ pub fn build_router(state: AppState) -> Router { ) .nest( &compat_next_base_path, - Router::new().route("/ai-agent/run", post(compat::next_ai_agent_run)), + Router::new() + .route("/ai-agent/run", post(compat::next_ai_agent_run)) + .route("/sidebar", get(compat::next_sidebar)), ) .with_state(state) } diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs new file mode 100644 index 00000000..9ad9eb9d --- /dev/null +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -0,0 +1,131 @@ +use crate::app::AppConfig; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::transport::convex::execute_convex_query_plan; +use bridge_runtime::{ + execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, + RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, + RuntimeSourceWire, +}; +use serde_json::Value; + +pub fn resolve_effective_workspace_id( + context: &RequestContext, + query_workspace_id: Option<&str>, + require_workspace: bool, +) -> Result, WebError> { + let query_workspace_id = query_workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()); + let header_workspace_id = context + .workspace + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let (Some(query_id), Some(header_id)) = (query_workspace_id, header_workspace_id) { + if query_id != header_id { + return Err(WebError::bad_request_code( + "workspace_context_conflict", + format!("workspaceId 参数与请求头中的 workspace 不一致: {query_id} != {header_id}"), + ) + .with_context(context) + .with_header("x-error-phase", "workspace_resolve")); + } + } + + let effective_workspace_id = query_workspace_id.or(header_workspace_id); + if require_workspace && effective_workspace_id.is_none() { + return Err(WebError::bad_request_code( + "workspace_required", + "缺少 workspaceId,请在 query 或请求头中提供有效工作区", + ) + .with_context(context) + .with_header("x-error-phase", "workspace_resolve")); + } + + Ok(effective_workspace_id.map(ToOwned::to_owned)) +} + +pub fn runtime_context( + context: &RequestContext, + effective_workspace_id: Option<&str>, +) -> RuntimeBridgeContextWire { + RuntimeBridgeContextWire { + deployment_id: context.workspace.deployment_id.clone(), + project_id: context.workspace.project_id.clone(), + workspace_id: effective_workspace_id + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()), + request_id: context.trace.request_id.clone(), + trace_id: context.trace.trace_id.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + }, + tenant_id: context.workspace.tenant_id.clone(), + auth_token: context.auth.authorization.clone(), + idempotency_key: context.source.idempotency_key.clone(), + validate_only: false, + dry_run: false, + } +} + +pub fn build_runtime_query_plan( + context: &RequestContext, + effective_workspace_id: Option<&str>, + query: RuntimeQueryEnvelopeWire, +) -> Result { + let runtime_input = RuntimeInput::Query { + context: runtime_context(context, effective_workspace_id), + query, + data: None, + }; + let RuntimeExecutionPlan::Query(plan) = execute_runtime_input(runtime_input) + .map_err(|error| WebError::bad_request(error.message).with_context(context))? + else { + return Err(WebError::internal("runtime query 未返回 query plan").with_context(context)); + }; + + Ok(plan) +} + +pub fn fetch_query_data_via_convex( + config: &AppConfig, + context: &RequestContext, + effective_workspace_id: Option<&str>, + query: RuntimeQueryEnvelopeWire, +) -> Result { + let plan = build_runtime_query_plan(context, effective_workspace_id, query)?; + execute_convex_query_plan(config, context, &plan) +} + +pub fn execute_runtime_query_against_data( + context: &RequestContext, + effective_workspace_id: Option<&str>, + query: RuntimeQueryEnvelopeWire, + data: Value, +) -> Result { + execute_runtime_query(RuntimeInput::Query { + context: runtime_context(context, effective_workspace_id), + query, + data: Some(data), + }) + .map_err(|error| WebError::bad_request(error.message).with_context(context)) +} + +pub fn execute_runtime_query_via_convex( + config: &AppConfig, + context: &RequestContext, + effective_workspace_id: Option<&str>, + query: RuntimeQueryEnvelopeWire, +) -> Result { + let data = fetch_query_data_via_convex(config, context, effective_workspace_id, query.clone())?; + execute_runtime_query_against_data(context, effective_workspace_id, query, data) +} diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index b78cd0b7..ce68c1fa 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -1,4 +1,5 @@ use crate::app::AppConfig; +use crate::context::RequestContext; use crate::error::WebError; use base64::engine::general_purpose::STANDARD; use base64::Engine; @@ -7,6 +8,12 @@ use serde_json::{json, Value}; use std::fs; use std::time::Duration; +const HEADER_REQUEST_ID: &str = "x-request-id"; +const HEADER_TRACE_ID: &str = "x-trace-id"; +const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id"; +const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel"; +const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client"; + fn read_env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); @@ -38,12 +45,30 @@ fn read_env_or_dotenv(key: &str) -> Option { None } -fn build_authorization(config: &AppConfig) -> Result { +fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result { + if let Some(authorization) = context + .auth + .authorization + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(authorization.to_string()); + } + let admin_key = config .convex_admin_key .clone() .or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY")) - .ok_or_else(|| WebError::internal("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?; + .ok_or_else(|| { + WebError::service_unavailable_code( + "convex_config_missing", + "缺少 CONVEX_SELF_HOSTED_ADMIN_KEY,且请求未携带 Authorization", + ) + .with_context(context) + .with_header("x-error-phase", "convex_auth") + .with_header("x-upstream-service", "convex") + })?; let identity = json!({ "subject": config.dev_user_id, @@ -60,7 +85,7 @@ fn build_authorization(config: &AppConfig) -> Result { Ok(format!("Convex {admin_key}:{encoded}")) } -fn convex_url(config: &AppConfig) -> Result { +fn convex_url(config: &AppConfig, context: &RequestContext) -> Result { config .convex_url .clone() @@ -68,18 +93,62 @@ fn convex_url(config: &AppConfig) -> Result { .or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL")) .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()) - .ok_or_else(|| WebError::internal("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL")) + .ok_or_else(|| { + WebError::service_unavailable_code( + "convex_config_missing", + "缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL", + ) + .with_context(context) + .with_header("x-error-phase", "convex_url") + .with_header("x-upstream-service", "convex") + }) } -pub fn execute_sidebar_dataset_query( +fn load_query_fixture( config: &AppConfig, + context: &RequestContext, + plan: &RuntimeQueryExecutionPlan, +) -> Result, WebError> { + if !config.allow_dev_fixtures { + return Ok(None); + } + + let Ok(raw) = std::env::var("MNOTE_WEB_QUERY_FIXTURES_JSON") else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let fixtures: Value = serde_json::from_str(trimmed).map_err(|error| { + WebError::internal(format!("MNOTE_WEB_QUERY_FIXTURES_JSON 非法: {error}")) + .with_context(context) + .with_header("x-error-phase", "fixture_parse") + })?; + + Ok(fixtures + .as_object() + .and_then(|map| map.get(plan.function_name.as_str())) + .cloned()) +} + +pub fn execute_convex_query_plan( + config: &AppConfig, + context: &RequestContext, plan: &RuntimeQueryExecutionPlan, ) -> Result { - if plan.function_name != "sidebar:datasetList" { - return Err(WebError::bad_request(format!( - "mnote-web transport 暂不支持 query: {}", - plan.function_name - ))); + if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") { + return Err(WebError::bad_request_code( + "transport_query_unsupported", + format!("mnote-web transport 暂不支持 query: {}", plan.function_name), + ) + .with_context(context) + .with_header("x-error-phase", "plan_validation")); + } + + if let Some(fixture) = load_query_fixture(config, context, plan)? { + return Ok(fixture); } let payload = json!({ @@ -91,37 +160,99 @@ pub fn execute_sidebar_dataset_query( let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(20)) .build() - .map_err(|error| WebError::internal(format!("Convex HTTP 客户端创建失败: {error}")))?; + .map_err(|error| { + WebError::internal(format!("Convex HTTP 客户端创建失败: {error}")) + .with_context(context) + .with_header("x-error-phase", "client_build") + .with_header("x-upstream-service", "convex") + })?; - let response = client - .post(format!("{}/api/query", convex_url(config)?)) - .header("Authorization", build_authorization(config)?) + let mut request = client + .post(format!("{}/api/query", convex_url(config, context)?)) + .header("Authorization", build_authorization(config, context)?) .header("Content-Type", "application/json") .header("Convex-Client", "mnote-web") - .json(&payload) - .send() - .map_err(|error| WebError::internal(format!("Convex query 请求失败: {error}")))?; + .header(HEADER_REQUEST_ID, &context.trace.request_id) + .header(HEADER_TRACE_ID, &context.trace.trace_id) + .header(HEADER_SOURCE_CHANNEL, context.source.channel.as_str()) + .header(HEADER_SOURCE_CLIENT, context.source.client.as_str()) + .json(&payload); + + if let Some(workspace_id) = plan + .workspace_id + .as_deref() + .or(context.workspace.workspace_id.as_deref()) + { + request = request.header(HEADER_WORKSPACE_ID, workspace_id); + } + + let response = request.send().map_err(|error| { + let base = if error.is_timeout() { + WebError::gateway_timeout_code("convex_timeout", format!("Convex query 超时: {error}")) + } else { + WebError::service_unavailable_code( + "convex_unavailable", + format!("Convex query 请求失败: {error}"), + ) + }; + base.with_context(context) + .with_header("x-error-phase", "query_send") + .with_header("x-upstream-service", "convex") + })?; let status = response.status(); - let body: Value = response - .json() - .map_err(|error| WebError::internal(format!("Convex 响应解析失败: {error}")))?; + let body: Value = response.json().map_err(|error| { + WebError::bad_gateway_code( + "convex_bad_response", + format!("Convex 响应解析失败: {error}"), + ) + .with_context(context) + .with_header("x-error-phase", "query_decode") + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string()) + })?; if !status.is_success() { let message = body .get("errorMessage") .and_then(Value::as_str) .unwrap_or("Convex query 失败"); - return Err(WebError::internal(message.to_string())); + return Err( + WebError::bad_gateway_code("convex_upstream_error", message.to_string()) + .with_context(context) + .with_header("x-error-phase", "query_status") + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string()), + ); } match body.get("status").and_then(Value::as_str) { Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)), - Some("error") => Err(WebError::internal( + Some("error") => Err(WebError::bad_gateway_code( + "convex_upstream_error", body.get("errorMessage") .and_then(Value::as_str) .unwrap_or("Convex 返回 error") .to_string(), - )), - _ => Err(WebError::internal(format!("未知 Convex 响应: {body}"))), + ) + .with_context(context) + .with_header("x-error-phase", "query_payload") + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string())), + _ => Err(WebError::bad_gateway_code( + "convex_bad_response", + format!("未知 Convex 响应: {body}"), + ) + .with_context(context) + .with_header("x-error-phase", "query_payload") + .with_header("x-upstream-service", "convex") + .with_header("x-upstream-status", status.as_u16().to_string())), } } + +pub fn execute_sidebar_dataset_query( + config: &AppConfig, + context: &RequestContext, + plan: &RuntimeQueryExecutionPlan, +) -> Result { + execute_convex_query_plan(config, context, plan) +} diff --git a/wolai-frontend/src/app/api/sidebar/route.ts b/wolai-frontend/src/app/api/sidebar/route.ts index f1512af8..7bfec8f8 100644 --- a/wolai-frontend/src/app/api/sidebar/route.ts +++ b/wolai-frontend/src/app/api/sidebar/route.ts @@ -17,6 +17,10 @@ import { mapSidebarDatasetListQueryResultToInitialData, type SidebarDatasetListQueryResult, } from "@/lib/sidebar-data"; +import { + fetchSidebarDatasetFromMnoteWeb, + getMnoteWebBaseUrl, +} from "@/lib/server/mnote-web"; export const dynamic = "force-dynamic"; @@ -37,6 +41,24 @@ export async function GET(request: Request) { } try { + if (getMnoteWebBaseUrl()) { + const proxied = await fetchSidebarDatasetFromMnoteWeb({ + workspaceId: targetWorkspaceId, + request, + }); + const result = mapSidebarDatasetListQueryResultToInitialData(proxied.dataset); + + return NextResponse.json({ + ...result, + meta: { + requestId: proxied.meta.requestId, + traceId: proxied.meta.traceId, + queryName: "compat.next.sidebar", + workspaceId: proxied.meta.workspaceId, + }, + }); + } + const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: targetWorkspaceId, diff --git a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx index 20cb80b3..763f95eb 100644 --- a/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx +++ b/wolai-frontend/src/components/documents/move-embed-picker-dialog.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { SidebarInitialData } from "@/components/sidebar/types"; -import { buildDocumentTree } from "@/lib/documents"; +import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection"; import { cn } from "@/lib/utils"; import { useDocumentSearch } from "@/hooks/use-document-search"; import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search"; @@ -155,19 +155,11 @@ function MoveEmbedPickerDialogBody({ } if (isEmptyQuery) { - const docs = sidebarQuery.data?.documents ?? []; - const tree = buildDocumentTree(docs); - - const flattened: Array<{ id: string; title: string; depth: number }> = []; - const walk = (nodes: ReturnType, depth: number) => { - for (const node of nodes) { - flattened.push({ id: node.id, title: node.title ?? "无标题", depth }); - if (node.children?.length) { - walk(node.children, depth + 1); - } - } - }; - walk(tree, 0); + const tree = sidebarQuery.data?.kernelSidebarTree ?? []; + const flattened = buildPickerTreeItems( + buildPageTreeProjectionItems(tree), + excluded, + ); for (const item of flattened) { if (excluded.has(item.id)) continue; @@ -195,7 +187,7 @@ function MoveEmbedPickerDialogBody({ } return result; - }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]); + }, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]); const placeholder = mode === "move" ? "移动到..." : "嵌入到..."; diff --git a/wolai-frontend/src/components/sidebar/private-tree.tsx b/wolai-frontend/src/components/sidebar/private-tree.tsx index 545d717f..b167ccc0 100644 --- a/wolai-frontend/src/components/sidebar/private-tree.tsx +++ b/wolai-frontend/src/components/sidebar/private-tree.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { DndContext, PointerSensor, @@ -17,11 +17,11 @@ import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; import { cn } from "@/lib/utils"; -import { flattenDocumentTree } from "@/lib/sidebar-tree"; interface PrivateTreeProps { - nodes: SidebarTreeNode[]; + rows: PageTreeProjectionItem[]; expanded: Set; activeId: string; onToggleExpand: (id: string) => void; @@ -33,7 +33,7 @@ interface PrivateTreeProps { const ROW_HEIGHT = 36; export function PrivateTree({ - nodes, + rows, expanded, activeId, onToggleExpand, @@ -44,18 +44,6 @@ export function PrivateTree({ const scrollAreaRef = useRef(null); const [activeDragId, setActiveDragId] = useState(null); - const flatNodes = useMemo(() => { - const flattened = flattenDocumentTree(nodes, expanded); - const seen = new Set(); - const deduped: typeof flattened = []; - for (const item of flattened) { - if (seen.has(item.node.id)) continue; - seen.add(item.node.id); - deduped.push(item); - } - return deduped; - }, [nodes, expanded]); - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, @@ -64,7 +52,7 @@ export function PrivateTree({ // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ - count: flatNodes.length, + count: rows.length, getScrollElement: () => scrollAreaRef.current, estimateSize: () => ROW_HEIGHT, overscan: 10, @@ -82,25 +70,25 @@ export function PrivateTree({ if (!overId || activeId === overId) { return; } - const activeIndex = flatNodes.findIndex((item) => item.node.id === activeId); - const overIndex = flatNodes.findIndex((item) => item.node.id === overId); + const activeIndex = rows.findIndex((item) => item.nodeId === activeId); + const overIndex = rows.findIndex((item) => item.nodeId === overId); if (activeIndex === -1 || overIndex === -1) { return; } - const targetParent = flatNodes[overIndex].parentId; - const siblingList = flatNodes.filter((item) => item.parentId === targetParent); - const siblingIndex = siblingList.findIndex((item) => item.node.id === overId); + const targetParent = rows[overIndex].parentNodeId; + const siblingList = rows.filter((item) => item.parentNodeId === targetParent); + const siblingIndex = siblingList.findIndex((item) => item.nodeId === overId); const position = siblingIndex === -1 ? siblingList.length : siblingIndex; onMove(activeId, targetParent, position); }, - [flatNodes, onMove], + [onMove, rows], ); const handleDragCancel = useCallback(() => { setActiveDragId(null); }, []); - if (flatNodes.length === 0) { + if (rows.length === 0) { return
暂无页面,点击下方按钮创建
; } @@ -114,22 +102,22 @@ export function PrivateTree({ onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} > - item.node.id)} strategy={verticalListSortingStrategy}> + item.nodeId)} strategy={verticalListSortingStrategy}>
{virtualizer.getVirtualItems().map((virtualRow) => { - const item = flatNodes[virtualRow.index]; + const item = rows[virtualRow.index]; return ( - + 0} + expanded={expanded.has(item.nodeId)} + hasChildren={item.childCount > 0} activeId={activeId} - isDragging={activeDragId === item.node.id} - onToggleExpand={() => onToggleExpand(item.node.id)} - onCreateChild={() => onCreateChild(item.node.id)} + isDragging={activeDragId === item.nodeId} + onToggleExpand={() => onToggleExpand(item.nodeId)} + onCreateChild={() => onCreateChild(item.nodeId)} onContextMenu={(event) => onContextMenu(event, item.node)} /> diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index eaa9877c..c168a19f 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -38,7 +38,11 @@ import { useSidebarStore } from "@/store/sidebar"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import { useSidebarData } from "@/hooks/use-sidebar-data"; import { PrivateTree } from "@/components/sidebar/private-tree"; -import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree"; +import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree"; +import { + buildPageTreeProjectionItems, + filterVisiblePageTreeProjectionItems, +} from "@/lib/tree-projection"; import { useSearchPaletteStore } from "@/store/search-palette"; import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useCurrentDocumentStore } from "@/store/current-document"; @@ -172,7 +176,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const activeId = segments?.[1] ?? ""; const editorBridge = useEditorBridgeStore((state) => state.bridge); - const [tree, setTree] = useState(() => sidebarData.kernelSidebarTree ?? []); + const [tree, setTree] = useState(() => sidebarData.kernelSidebarTree); const [filter, setFilter] = useState(""); const [expanded, setExpanded] = useState>(() => collectNodeIds(tree)); const [contextMenu, setContextMenu] = useState(null); @@ -243,7 +247,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { useEffect(() => { setTree(() => { - const nextTree = sidebarData.kernelSidebarTree ?? []; + const nextTree = sidebarData.kernelSidebarTree; setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev))); return nextTree; }); @@ -468,9 +472,21 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { () => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree), [filter, privateTree], ); + const privatePageRows = useMemo( + () => buildPageTreeProjectionItems(privateTree), + [privateTree], + ); + const filteredPrivatePageRows = useMemo( + () => buildPageTreeProjectionItems(filteredPrivateTree), + [filteredPrivateTree], + ); const flattenedPrivate = useMemo( - () => flattenDocumentTree(privateTree, expanded), - [privateTree, expanded], + () => filterVisiblePageTreeProjectionItems(privatePageRows, expanded), + [expanded, privatePageRows], + ); + const visibleFilteredPrivatePageRows = useMemo( + () => filterVisiblePageTreeProjectionItems(filteredPrivatePageRows, expanded), + [expanded, filteredPrivatePageRows], ); const filteredTrash = useMemo(() => { const keyword = trashSearch.trim().toLowerCase(); @@ -574,13 +590,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const fileTreeRows = useMemo( () => buildVisibleRows({ - nodes: filteredPrivateTree, + pageRows: visibleFilteredPrivatePageRows, expanded, assetsByDoc, assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId, expandedAssetFolderIds: expandedAssetFolders, }), - [assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId], + [ + assetsByDoc, + expanded, + expandedAssetFolders, + mindmapChildrenSnapshot.childAssetsByMindmapId, + visibleFilteredPrivatePageRows, + ], ); const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]); @@ -1816,12 +1838,12 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const handleConvertToChild = useCallback( async (documentId: string) => { const flat = flattenedPrivate; - const currentIndex = flat.findIndex((item) => item.node.id === documentId); + const currentIndex = flat.findIndex((item) => item.nodeId === documentId); if (currentIndex <= 0) { return; } - const targetParentId = flat[currentIndex - 1].node.id; - await handleMove(documentId, targetParentId, flat[currentIndex - 1].node.children.length); + const targetParentId = flat[currentIndex - 1].nodeId; + await handleMove(documentId, targetParentId, flat[currentIndex - 1].childCount); }, [flattenedPrivate, handleMove], ); @@ -2271,20 +2293,20 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
{(() => { const renderList = (nodes: SidebarTreeNode[]) => { - const flat = flattenDocumentTree(nodes, collectNodeIds(nodes)); + const flat = buildPageTreeProjectionItems(nodes); if (flat.length === 0) { return
暂无内容
; } return (
- {flat.map(({ node, depth }) => ( + {flat.map((item) => ( - {node.title || "无标题"} + {item.title} ))}
@@ -2499,7 +2521,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
0 && process.env.NODE_ENV !== "production") { - // eslint-disable-next-line no-console console.warn( `[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`, ); diff --git a/wolai-frontend/src/lib/file-tree/rows.test.ts b/wolai-frontend/src/lib/file-tree/rows.test.ts index 4fdd0b8c..f2d836a0 100644 --- a/wolai-frontend/src/lib/file-tree/rows.test.ts +++ b/wolai-frontend/src/lib/file-tree/rows.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildPageTreeProjectionItems } from "@/lib/tree-projection"; import { buildVisibleRows } from "./rows"; import { parseFileTreeRowId } from "./types"; @@ -33,7 +34,7 @@ describe("buildVisibleRows", () => { }; const rows = buildVisibleRows({ - nodes: [a], + pageRows: buildPageTreeProjectionItems([a]), expanded: new Set(["a"]), assetsByDoc: { a: [ @@ -108,7 +109,7 @@ describe("buildVisibleRows", () => { }; const rows = buildVisibleRows({ - nodes: [a, a], + pageRows: buildPageTreeProjectionItems([a, a]), expanded: new Set(["a"]), assetsByDoc: {}, }); diff --git a/wolai-frontend/src/lib/file-tree/rows.ts b/wolai-frontend/src/lib/file-tree/rows.ts index c352c48b..99b80f18 100644 --- a/wolai-frontend/src/lib/file-tree/rows.ts +++ b/wolai-frontend/src/lib/file-tree/rows.ts @@ -1,55 +1,52 @@ "use client"; -import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; +import type { PageTreeProjectionItem } from "@/lib/tree-projection"; import type { MediaAsset } from "@/types/media"; import type { FileTreeRow } from "./types"; import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types"; export function buildVisibleRows({ - nodes, + pageRows, expanded, assetsByDoc, assetChildrenByAssetId, expandedAssetFolderIds, }: { - nodes: SidebarTreeNode[]; + // 只消费统一 page_tree projection;结构真相不再由文件树自行定义。 + pageRows: PageTreeProjectionItem[]; expanded: Set; assetsByDoc: Record; assetChildrenByAssetId?: Record; expandedAssetFolderIds?: Set; }): FileTreeRow[] { const rows: FileTreeRow[] = []; - const visitedDocIds = new Set(); - const walk = (node: SidebarTreeNode, depth: number) => { - // 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。 - // 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。 - if (visitedDocIds.has(node.id)) return; - visitedDocIds.add(node.id); - - const assets = assetsByDoc[node.id] ?? []; - const hasChildren = node.children.length > 0 || assets.length > 0; - const isExpanded = expanded.has(node.id); + pageRows.forEach((item) => { + const assets = assetsByDoc[item.nodeId] ?? []; + const hasChildren = item.childCount > 0 || assets.length > 0; + const isExpanded = expanded.has(item.nodeId); rows.push({ kind: "doc", - rowId: makeDocRowId(node.id), - depth, - docId: node.id, - parentDocId: node.parent_id, - node, + rowId: makeDocRowId(item.nodeId), + depth: item.depth, + docId: item.nodeId, + parentDocId: item.parentNodeId, + node: item.node, hasChildren, isExpanded, }); - if (!isExpanded) return; + if (!isExpanded) { + return; + } rows.push({ kind: "index", - rowId: makeIndexRowId(node.id), - depth: depth + 1, - docId: node.id, - parentDocId: node.id, - node, + rowId: makeIndexRowId(item.nodeId), + depth: item.depth + 1, + docId: item.nodeId, + parentDocId: item.nodeId, + node: item.node, }); assets.forEach((asset) => { @@ -60,9 +57,9 @@ export function buildVisibleRows({ rows.push({ kind: "asset-folder", rowId: makeAssetFolderRowId(asset.id), - depth: depth + 1, - docId: node.id, - parentDocId: node.id, + depth: item.depth + 1, + docId: item.nodeId, + parentDocId: item.nodeId, asset, hasChildren, isExpanded, @@ -72,9 +69,9 @@ export function buildVisibleRows({ rows.push({ kind: "asset", rowId: makeAssetRowId(child.id), - depth: depth + 2, - docId: node.id, - parentDocId: node.id, + depth: item.depth + 2, + docId: item.nodeId, + parentDocId: item.nodeId, asset: child, }); }); @@ -85,16 +82,12 @@ export function buildVisibleRows({ rows.push({ kind: "asset", rowId: makeAssetRowId(asset.id), - depth: depth + 1, - docId: node.id, - parentDocId: node.id, + depth: item.depth + 1, + docId: item.nodeId, + parentDocId: item.nodeId, asset, }); }); - - node.children.forEach((child) => walk(child, depth + 1)); - }; - - nodes.forEach((node) => walk(node, 0)); + }); return rows; } diff --git a/wolai-frontend/src/lib/server/mnote-web.ts b/wolai-frontend/src/lib/server/mnote-web.ts new file mode 100644 index 00000000..c73e9641 --- /dev/null +++ b/wolai-frontend/src/lib/server/mnote-web.ts @@ -0,0 +1,113 @@ +import { headers } from "next/headers"; +import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server"; +import { isDevAuthEnabled } from "@/lib/auth/devUser"; +import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data"; + +type MnoteWebSidebarCompatResponse = { + ok?: boolean; + requestId?: string; + traceId?: string; + workspaceId?: string; + result?: SidebarDatasetListQueryResult; +}; + +const MNOTE_WEB_BASE_URL = ( + process.env.MNOTE_WEB_BASE_URL ?? + process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL ?? + "" +).trim(); + +function copyHeaderIfPresent(target: Headers, source: Headers, name: string) { + const value = source.get(name); + if (value) { + target.set(name, value); + } +} + +async function buildForwardHeaders(request?: Request): Promise { + const source = request?.headers ?? new Headers(await headers()); + const forwarded = new Headers(); + + copyHeaderIfPresent(forwarded, source, "cookie"); + copyHeaderIfPresent(forwarded, source, "authorization"); + copyHeaderIfPresent(forwarded, source, "x-request-id"); + copyHeaderIfPresent(forwarded, source, "x-trace-id"); + copyHeaderIfPresent(forwarded, source, "x-session-id"); + copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id"); + copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel"); + copyHeaderIfPresent(forwarded, source, "x-mnote-source-client"); + copyHeaderIfPresent(forwarded, source, "user-agent"); + + if (!forwarded.has("authorization") && !isDevAuthEnabled()) { + const token = await convexAuthNextjsToken(); + if (token?.trim()) { + forwarded.set("authorization", `Bearer ${token.trim()}`); + } + } + + if (!forwarded.has("x-mnote-source-channel")) { + forwarded.set("x-mnote-source-channel", request ? "next_route" : "next_server_component"); + } + if (!forwarded.has("x-mnote-source-client")) { + forwarded.set("x-mnote-source-client", "wolai-frontend"); + } + + return forwarded; +} + +export function getMnoteWebBaseUrl(): string | null { + return MNOTE_WEB_BASE_URL || null; +} + +export async function fetchSidebarDatasetFromMnoteWeb(input: { + workspaceId: string; + request?: Request; +}): Promise<{ + dataset: SidebarDatasetListQueryResult; + meta: { + requestId: string | null; + traceId: string | null; + workspaceId: string; + }; +}> { + const baseUrl = getMnoteWebBaseUrl(); + if (!baseUrl) { + throw new Error("未配置 MNOTE_WEB_BASE_URL"); + } + + const url = new URL("/api/compat/next/sidebar", baseUrl); + url.searchParams.set("workspaceId", input.workspaceId); + + const forwardedHeaders = await buildForwardHeaders(input.request); + forwardedHeaders.set("x-mnote-workspace-id", input.workspaceId); + + const response = await fetch(url, { + method: "GET", + headers: forwardedHeaders, + cache: "no-store", + }); + const payload = (await response + .json() + .catch(() => null)) as MnoteWebSidebarCompatResponse | null; + + if (!response.ok) { + const message = + payload && typeof (payload as Record).message === "string" + ? String((payload as Record).message) + : "mnote-web 侧边栏兼容接口请求失败"; + throw new Error(message); + } + + if (!payload?.result) { + throw new Error("mnote-web /api/compat/next/sidebar 未返回 result"); + } + + return { + dataset: payload.result, + meta: { + requestId: payload.requestId ?? null, + traceId: payload.traceId ?? null, + workspaceId: payload.workspaceId?.trim() || input.workspaceId, + }, + }; +} diff --git a/wolai-frontend/src/lib/server/sidebar-data.ts b/wolai-frontend/src/lib/server/sidebar-data.ts index a932b278..f4183496 100644 --- a/wolai-frontend/src/lib/server/sidebar-data.ts +++ b/wolai-frontend/src/lib/server/sidebar-data.ts @@ -9,6 +9,10 @@ import { type SidebarDatasetListQueryResult, } from "@/lib/sidebar-data"; import type { WorkspaceSummary } from "@/lib/workspaces"; +import { + fetchSidebarDatasetFromMnoteWeb, + getMnoteWebBaseUrl, +} from "@/lib/server/mnote-web"; type LoadSidebarDataFromConvexInput = { client: ConvexHttpClient; @@ -57,9 +61,15 @@ export async function loadSidebarDataFromConvex( }; } - const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, { - workspaceId: targetWorkspaceId, - })) as SidebarDatasetListQueryResult; + const sidebarDataset = getMnoteWebBaseUrl() + ? ( + await fetchSidebarDatasetFromMnoteWeb({ + workspaceId: targetWorkspaceId, + }) + ).dataset + : ((await input.client.query(sidebarDatasetListQuery, { + workspaceId: targetWorkspaceId, + })) as SidebarDatasetListQueryResult); const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[]; return { diff --git a/wolai-frontend/src/lib/sidebar-data.ts b/wolai-frontend/src/lib/sidebar-data.ts index 23814747..8e673b74 100644 --- a/wolai-frontend/src/lib/sidebar-data.ts +++ b/wolai-frontend/src/lib/sidebar-data.ts @@ -51,7 +51,7 @@ export type SidebarDatasetListQueryResult = { active_workspace_id: string; workspaces: WorkspaceSummary[]; documents: DocumentRecord[]; - kernel_sidebar_projection?: KernelSidebarProjection | null; + kernel_sidebar_projection: KernelSidebarProjection; trashed_documents: SidebarInitialData["trashedDocuments"]; media_assets: MediaAsset[]; trashed_media_assets: MediaAsset[]; @@ -241,16 +241,14 @@ export function buildSidebarDatasetListQueryResult( export function mapSidebarDatasetListQueryResultToInitialData( result: SidebarDatasetListQueryResult, ): SidebarInitialData { - const kernelSidebarProjection = - result.kernel_sidebar_projection ?? buildKernelSidebarProjection(result.documents); return { activeWorkspaceId: result.active_workspace_id, workspaces: [...result.workspaces], documents: [...result.documents], - kernelSidebarProjection, + kernelSidebarProjection: result.kernel_sidebar_projection, kernelSidebarTree: buildSidebarTreeFromKernelProjection({ records: result.documents, - projection: kernelSidebarProjection, + projection: result.kernel_sidebar_projection, }), trashedDocuments: [...result.trashed_documents], trashedMediaAssets: [...result.trashed_media_assets], diff --git a/wolai-frontend/src/lib/tree-projection.test.ts b/wolai-frontend/src/lib/tree-projection.test.ts new file mode 100644 index 00000000..df7297b0 --- /dev/null +++ b/wolai-frontend/src/lib/tree-projection.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { + buildPageTreeProjectionItems, + buildPickerTreeItems, + filterVisiblePageTreeProjectionItems, +} from "./tree-projection"; + +describe("buildPageTreeProjectionItems", () => { + it("按当前树结构重建 parent/depth,而不是信任陈旧节点字段", () => { + const rows = buildPageTreeProjectionItems([ + { + access_scope: "private", + id: "root", + workspace_id: "w", + title: "Root", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + kernel: { + nodeType: "page", + depth: 0, + position: 0, + childCount: 1, + expandedByDefault: true, + }, + children: [ + { + access_scope: "private", + id: "child", + workspace_id: "w", + title: "Child", + parent_id: null, + sort_order: 9, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + kernel: { + nodeType: "page", + depth: 0, + position: 9, + childCount: 0, + expandedByDefault: true, + }, + children: [], + }, + ], + }, + ]); + + expect(rows.map((item) => ({ + nodeId: item.nodeId, + parentNodeId: item.parentNodeId, + depth: item.depth, + }))).toEqual([ + { nodeId: "root", parentNodeId: null, depth: 0 }, + { nodeId: "child", parentNodeId: "root", depth: 1 }, + ]); + expect(rows[1]?.node.parent_id).toBe("root"); + expect(rows[1]?.node.kernel?.depth).toBe(1); + }); +}); + +describe("filterVisiblePageTreeProjectionItems", () => { + it("只返回当前展开路径上的可见页面", () => { + const items = buildPageTreeProjectionItems([ + { + access_scope: "private", + id: "root", + workspace_id: "w", + title: "Root", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + children: [ + { + access_scope: "private", + id: "child", + workspace_id: "w", + title: "Child", + parent_id: "root", + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + children: [ + { + access_scope: "private", + id: "leaf", + workspace_id: "w", + title: "Leaf", + parent_id: "child", + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + children: [], + }, + ], + }, + ], + }, + ]); + + expect(filterVisiblePageTreeProjectionItems(items, new Set()).map((item) => item.nodeId)).toEqual(["root"]); + expect(filterVisiblePageTreeProjectionItems(items, new Set(["root"])).map((item) => item.nodeId)).toEqual([ + "root", + "child", + ]); + expect( + filterVisiblePageTreeProjectionItems(items, new Set(["root", "child"])).map( + (item) => item.nodeId, + ), + ).toEqual(["root", "child", "leaf"]); + }); +}); + +describe("buildPickerTreeItems", () => { + it("复用 page_tree projection 生成 picker 列表", () => { + const items = buildPageTreeProjectionItems([ + { + access_scope: "private", + id: "a", + workspace_id: "w", + title: "A", + parent_id: null, + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + children: [ + { + access_scope: "private", + id: "b", + workspace_id: "w", + title: null, + parent_id: "a", + sort_order: 0, + is_starred: false, + is_template: false, + created_at: "", + updated_at: null, + children: [], + }, + ], + }, + ]); + + expect(buildPickerTreeItems(items, ["a"])).toEqual([ + { id: "b", title: "无标题", depth: 1 }, + ]); + }); +}); diff --git a/wolai-frontend/src/lib/tree-projection.ts b/wolai-frontend/src/lib/tree-projection.ts new file mode 100644 index 00000000..c4f22d1f --- /dev/null +++ b/wolai-frontend/src/lib/tree-projection.ts @@ -0,0 +1,158 @@ +import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; + +export type TreeProjectionKind = "page_tree"; + +export type TreeProjectionNodeType = "page"; + +export type TreeProjectionCapability = + | "expand" + | "open" + | "drag" + | "drop" + | "select" + | "create-child"; + +export type TreeProjectionResourceMeta = { + resourceKind: "document"; + documentId: string; +}; + +export type PageTreeProjectionItem = { + rowId: `page:${string}`; + nodeId: string; + parentNodeId: string | null; + nodeType: TreeProjectionNodeType; + projectionKind: TreeProjectionKind; + depth: number; + position: number | null; + title: string; + childCount: number; + capabilities: TreeProjectionCapability[]; + resourceMeta: TreeProjectionResourceMeta; + node: SidebarTreeNode; +}; + +export type PickerTreeItem = { + id: string; + title: string; + depth: number; +}; + +function buildPageCapabilities(childCount: number): TreeProjectionCapability[] { + const capabilities: TreeProjectionCapability[] = [ + "open", + "drag", + "drop", + "select", + "create-child", + ]; + if (childCount > 0) { + capabilities.unshift("expand"); + } + return capabilities; +} + +export function buildPageTreeProjectionItems( + nodes: SidebarTreeNode[], +): PageTreeProjectionItem[] { + const items: PageTreeProjectionItem[] = []; + const visited = new Set(); + + const walk = ( + entries: SidebarTreeNode[], + depth: number, + parentNodeId: string | null, + ) => { + entries.forEach((node, index) => { + if (visited.has(node.id)) { + return; + } + visited.add(node.id); + + const childCount = node.children.length; + items.push({ + rowId: `page:${node.id}`, + nodeId: node.id, + parentNodeId, + nodeType: "page", + projectionKind: "page_tree", + depth, + position: node.kernel?.position ?? node.sort_order ?? index, + title: node.title ?? "无标题", + childCount, + capabilities: buildPageCapabilities(childCount), + resourceMeta: { + resourceKind: "document", + documentId: node.id, + }, + node: { + ...node, + parent_id: parentNodeId, + kernel: node.kernel + ? { + ...node.kernel, + depth, + position: node.kernel.position ?? node.sort_order ?? index, + childCount, + } + : { + nodeType: "page", + depth, + position: node.sort_order ?? index, + childCount, + expandedByDefault: true, + }, + }, + }); + if (childCount > 0) { + walk(node.children, depth + 1, node.id); + } + }); + }; + + walk(nodes, 0, null); + return items; +} + +export function filterVisiblePageTreeProjectionItems( + items: PageTreeProjectionItem[], + expanded: Set, +): PageTreeProjectionItem[] { + const visible: PageTreeProjectionItem[] = []; + const visibleIds = new Set(); + + items.forEach((item) => { + if (!item.parentNodeId) { + visible.push(item); + visibleIds.add(item.nodeId); + return; + } + + if (!visibleIds.has(item.parentNodeId)) { + return; + } + + if (!expanded.has(item.parentNodeId)) { + return; + } + + visible.push(item); + visibleIds.add(item.nodeId); + }); + + return visible; +} + +export function buildPickerTreeItems( + items: PageTreeProjectionItem[], + excludeIds: Iterable = [], +): PickerTreeItem[] { + const excluded = new Set(excludeIds); + return items + .filter((item) => !excluded.has(item.nodeId)) + .map((item) => ({ + id: item.nodeId, + title: item.title, + depth: item.depth, + })); +}