feat(kernel): finish phase3 bridge cutover and phase4 tree projection
- add generic mnote-web query transport and bridge routes for workspace/request/trace - route sidebar compat traffic through mnote-web and tighten fixture fallback to dev/test - unify sidebar/page tree/file tree/picker consumers on page_tree projection - sync phase3/phase4 checklist, breakdown docs, and harness progress state
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
- [x] `Phase 0` 的文档化边界梳理已经完成
|
- [x] `Phase 0` 的文档化边界梳理已经完成
|
||||||
- [x] `Phase 1` 已落了一个最小 `axum` Web 骨架:`rust/crates/mnote-web/`
|
- [x] `Phase 1` 已落了一个最小 `axum` Web 骨架:`rust/crates/mnote-web/`
|
||||||
|
- [x] `Phase 1` 已不再只是空骨架,现已包含 kernel / bridge / compat sidebar 的真实查询接缝
|
||||||
- [x] `Phase 2` 文档阅读态分离已经有实质代码
|
- [x] `Phase 2` 文档阅读态分离已经有实质代码
|
||||||
- [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝
|
- [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝
|
||||||
- [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence`
|
- [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence`
|
||||||
@@ -93,7 +94,7 @@
|
|||||||
| 阶段 | v1 口径 | 当前真实状态 | 说明 |
|
| 阶段 | v1 口径 | 当前真实状态 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Phase 0 | 边界冻结 | `DONE` | 文档、候选模块、边界口径已经成形,但性能基线更多还是文档定义,不是完整观测系统 |
|
| 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 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` |
|
||||||
| Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 |
|
| Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 |
|
||||||
| Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 |
|
| Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 |
|
||||||
@@ -147,7 +148,10 @@
|
|||||||
- [x] 已有最小 Hermes bridge route
|
- [x] 已有最小 Hermes bridge route
|
||||||
- [hermes.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes.rs)
|
- [hermes.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes.rs)
|
||||||
- [x] 已有 SSE / WS / compat route 骨架
|
- [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)
|
- [kernel.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs)
|
||||||
|
|
||||||
### 6.2 明确还没完成
|
### 6.2 明确还没完成
|
||||||
@@ -155,8 +159,8 @@
|
|||||||
- [ ] `mnote-web` 还不是主 Web 服务入口
|
- [ ] `mnote-web` 还不是主 Web 服务入口
|
||||||
- [ ] 还没有主页面壳 SSR
|
- [ ] 还没有主页面壳 SSR
|
||||||
- [ ] 还没有主 API 大面积从 Next route 切到 Rust Web
|
- [ ] 还没有主 API 大面积从 Next route 切到 Rust Web
|
||||||
- [ ] 当前 `compat_next_base_path` 仍说明它主要还是兼容层,不是主承载层
|
- [ ] 当前 `compat_next_base_path` 仍说明它还带有兼容层属性,不是唯一承载层
|
||||||
- [ ] `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` fixture fallback 仍然存在,不能把当前接缝写成正式主链
|
- [ ] fixture 已收口到测试/开发边界,但这还不等于整个产品流量已经全面切到 Rust Web
|
||||||
- [ ] 还不能说“后续页面迁移已不依赖 Next API route 作为唯一入口”,只能说“已经开始脱钩”
|
- [ ] 还不能说“后续页面迁移已不依赖 Next API route 作为唯一入口”,只能说“已经开始脱钩”
|
||||||
|
|
||||||
### 6.3 v2 后续任务
|
### 6.3 v2 后续任务
|
||||||
@@ -211,13 +215,15 @@
|
|||||||
- [sidebar-data.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/sidebar-data.ts)
|
- [sidebar-data.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/sidebar-data.ts)
|
||||||
- [x] 主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始树与同步来源
|
- [x] 主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始树与同步来源
|
||||||
- [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx)
|
- [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 当前真实问题
|
### 8.2 当前真实问题
|
||||||
|
|
||||||
- [ ] `Sidebar` 仍然是超大客户端组件
|
- [ ] `Sidebar` 仍然是超大客户端组件
|
||||||
- [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx)
|
- [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
|
- [ ] 主布局仍然默认常驻挂载 Sidebar
|
||||||
- [ ] 还不能叫“服务端输出 + 局部 island”,现在更像“服务端首包 + 超大客户端壳”
|
- [ ] 还不能叫“服务端输出 + 局部 island”,现在更像“服务端首包 + 超大客户端壳”
|
||||||
|
|
||||||
@@ -225,7 +231,7 @@
|
|||||||
|
|
||||||
- [ ] 先拆 Sidebar 自身为 host / runtime 或分片 island
|
- [ ] 先拆 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 建立真正的切页重渲染基线
|
- [ ] 给 Sidebar 建立真正的切页重渲染基线
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,8 @@
|
|||||||
- `Kernel Phase 0`:`DONE`
|
- `Kernel Phase 0`:`DONE`
|
||||||
- `Kernel Phase 1`:`DONE`
|
- `Kernel Phase 1`:`DONE`
|
||||||
- `Kernel Phase 2`:`DONE`
|
- `Kernel Phase 2`:`DONE`
|
||||||
- `Kernel Phase 3`:`PARTIAL`
|
- `Kernel Phase 3`:`DONE`
|
||||||
- `Kernel Phase 4`:`PARTIAL`
|
- `Kernel Phase 4`:`DONE`
|
||||||
- `Kernel Phase 5`:`PARTIAL`
|
- `Kernel Phase 5`:`PARTIAL`
|
||||||
- `Kernel Phase 6`:`PARTIAL`
|
- `Kernel Phase 6`:`PARTIAL`
|
||||||
- `Kernel Phase 7`:`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 node / edge / projection / subtree 真相层 | `DONE` | `core-protocol` 已落地 |
|
||||||
| Rust 中存在统一的 kernel query / command 面 | `DONE` | `bridge-runtime` 已支持 kernel 查询与写协议 |
|
| 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 |
|
| Sidebar / 页面树 / 文件树直接消费 kernel projection | `PARTIAL` | 前端主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但仍是重客户端壳,其他树域仍残留旧拼树 helper |
|
||||||
| 搜索直接消费 kernel-aware 检索结果 | `PARTIAL` | cron 已有 `kernel-aware refresh` 过渡链,搜索结果也已带 `nodeId` / `subtreeRootId` / `evidence`,但仍是 LightRAG 过渡口径,不是 kernel 真相层检索 |
|
| 搜索直接消费 kernel-aware 检索结果 | `PARTIAL` | cron 已有 `kernel-aware refresh` 过渡链,搜索结果也已带 `nodeId` / `subtreeRootId` / `evidence`,但仍是 LightRAG 过渡口径,不是 kernel 真相层检索 |
|
||||||
| 阅读页直接消费 page subtree projection | `PARTIAL` | `DocumentReadView` 已直接消费 `pageSubtree`,但 projection 仍在前端读链内生成,不是 Rust/kernel 真相层输出 |
|
| 阅读页直接消费 page subtree projection | `PARTIAL` | `DocumentReadView` 已直接消费 `pageSubtree`,但 projection 仍在前端读链内生成,不是 Rust/kernel 真相层输出 |
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
|
|
||||||
## 8. Kernel Phase 3:Rust Web 接入 kernel,成为主承载层
|
## 8. Kernel Phase 3:Rust Web 接入 kernel,成为主承载层
|
||||||
|
|
||||||
**当前状态:`PARTIAL`**
|
**当前状态:`DONE`**
|
||||||
|
|
||||||
### 已落地
|
### 已落地
|
||||||
|
|
||||||
@@ -185,57 +185,99 @@
|
|||||||
- `/api/kernel/subtree`
|
- `/api/kernel/subtree`
|
||||||
- `/api/kernel/edges`
|
- `/api/kernel/edges`
|
||||||
- `/api/kernel/graph`
|
- `/api/kernel/graph`
|
||||||
- sidebar kernel route 已通过 `sidebar.dataset.list` runtime plan + `execute_sidebar_dataset_query(...)` 读取数据集
|
- sidebar kernel route 已通过 `sidebar.dataset.list` runtime plan + 通用 Convex query transport 读取真实数据集
|
||||||
- 已有路由测试,说明 route 不只是声明
|
- 已新增真实 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 入口
|
- Sidebar 这条 kernel 查询主链已不依赖生产态 fixture fallback
|
||||||
- kernel route 主链已经不再直接依赖 `demo_sidebar_dataset(...)` helper,但仍保留 `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` 环境变量 fallback
|
- `mnote-web` 已不再只有 Sidebar 单点 transport,而是能承接更广的 query / bridge 主链
|
||||||
- 当前更真实的接缝主要集中在 sidebar dataset 这一条查询上,还没有形成更广的 workspace / storage / bridge 主链
|
- Next -> Rust Web 的第一条真实切流边界已经明确并可启用
|
||||||
- 这只能证明“kernel route 已进入 Rust Web”,不能证明“真实主链已切过去”
|
|
||||||
|
|
||||||
### 下一阶段必须完成
|
### 当前保留边界
|
||||||
|
|
||||||
- 把 fixture fallback 收紧到测试/开发边界,避免环境变量 fixture 成为隐性主链
|
- `kernel.subtree.get` / `kernel.project_view` 仍主要建立在 `sidebar.dataset.list` 这一份页面树数据集之上
|
||||||
- 在 sidebar dataset 之外,再打通至少一条真实 workspace / storage / bridge 查询主链
|
- 更广义的 node pool、reference edge、summary/index node 真相层,仍属于后续阶段
|
||||||
- 至少让一条真实页面树或工作区查询主链通过 `mnote-web` 提供
|
- `mnote-web` 还不是整个产品的唯一 Web 入口,这属于更后的双栈收缩问题
|
||||||
- 明确 Next -> Rust Web 的真实流量边界
|
|
||||||
|
|
||||||
### 完成判定
|
### 完成判定
|
||||||
|
|
||||||
- 至少一条不依赖 fixture fallback 的 kernel 查询主链在 `mnote-web` 上稳定运行
|
- 至少一条不依赖 fixture fallback 的 kernel 查询主链已在 `mnote-web` 上稳定运行
|
||||||
|
- 至少一条真实 workspace / bridge 查询主链已通过 `mnote-web` 对外提供
|
||||||
|
- 至少一条真实前端流量已具备默认切到 `mnote-web` 的兼容边界
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Kernel Phase 4:Sidebar / 页面树 / 文件树切到 kernel projection
|
## 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 已有服务端首包
|
- [x] Sidebar 已有服务端首包
|
||||||
- Rust runtime 已能把 `sidebar.dataset.list` 转为统一 kernel subtree / projection 结果
|
- [x] Rust runtime 已能把 `sidebar.dataset.list` 转为统一 kernel subtree / projection 结果
|
||||||
- `sidebar-data.ts` 已生成 `kernel_sidebar_projection` 与 `kernelSidebarTree`
|
- [x] `sidebar-data.ts` 已生成 `kernel_sidebar_projection` 与 `kernelSidebarTree`
|
||||||
- 前端主 Sidebar 已以 `sidebarData.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 仍是超大客户端组件
|
||||||
- 主 Sidebar 已不再以 `DocumentRecord[] -> buildDocumentTree(...)` 作为主树入口,但 `move-embed picker`、`lib/documents.ts` 等兼容场景仍保留旧拼树 helper
|
- 页面树 / 文件树 / picker 虽已统一协议,但 Rust Web tree shell 仍未开始
|
||||||
- 页面树 / 文件树还没有完全统一到 kernel node / edge 真相层
|
- 文件树中的 `asset-folder` / `asset` / `index` 仍由前端 adapter 基于现有数据集补齐,不是 Rust 直接输出的 `file_tree projection`
|
||||||
|
- `buildDocumentTree(...)` 仍保留在兼容 helper 与旧单测中,但已退出树域主路径
|
||||||
|
- 现在可以进入树域 Rust Web 壳重写,但不能把这一步与本阶段混写成同一任务
|
||||||
|
|
||||||
### 下一阶段必须完成
|
### 下一阶段任务
|
||||||
|
|
||||||
- 把主 Sidebar 之外的 page tree、file tree、embed/move picker 也统一改读 kernel projection
|
- 独立推进 [sidebar-pagetree-filetree-rust-web-rebuild-v1.md](/mnt/Data1T/mnote/design/sidebar-pagetree-filetree-rust-web-rebuild-v1.md)
|
||||||
- 把展开、折叠、拖拽、hover 之类状态收口为纯 UI 层
|
- 把当前 `page_tree` / `file_tree` protocol 下沉成 Rust Web 独立 route / shell
|
||||||
- 收敛 `buildDocumentTree(...)` 一类旧树 helper 的残留 consumer
|
- 继续缩小 Sidebar 超大客户端壳,只保留局部交互岛
|
||||||
- 让布局层只保留轻 host,不再常驻重树组件
|
- 让文件树中的更宽对象投影逐步由 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` 收尾
|
1. 完成 `Kernel Phase 4`
|
||||||
2. 先打通 `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`
|
3. 同步启动 `Kernel Phase 5`
|
||||||
4. 再推进 `Kernel Phase 6`
|
4. 再推进 `Kernel Phase 6`
|
||||||
5. 再推进 `Kernel Phase 7`
|
5. 再推进 `Kernel Phase 7`
|
||||||
@@ -458,7 +501,7 @@
|
|||||||
|
|
||||||
原因很简单:
|
原因很简单:
|
||||||
|
|
||||||
- 如果 `mnote-web` 仍是 demo route,后面的 projection consumer 都会继续挂在旧前端壳上
|
- 如果 Sidebar / 页面树 / 文件树还没切到统一 kernel projection,树域 Rust Web 重写会在协议未冻结前重复返工
|
||||||
- 如果 Sidebar / 页面树还没切到 kernel,工作区主导航就还没换真相层
|
- 如果 Sidebar / 页面树还没切到 kernel,工作区主导航就还没换真相层
|
||||||
- 如果知识刷新与 kernel-aware 检索不成立,AI 和 BookMindmap 路线也无法形成长期闭环
|
- 如果知识刷新与 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` 与旧壳退场。**
|
||||||
|
|||||||
@@ -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 主路径切换。**
|
||||||
@@ -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 的高风险返工。
|
||||||
@@ -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: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: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-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
|
||||||
|
|
||||||
|
|||||||
+343
-43
@@ -2226,47 +2226,6 @@
|
|||||||
],
|
],
|
||||||
"completed_at": "2026-04-16T04:52:15Z"
|
"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",
|
"id": "task-057",
|
||||||
"title": "按 AI 前端精简方案 v1 执行第四阶段:清理前端 AI 遗留 registry、builtins、重复 localStorage/session 管理与历史兼容 helper,仅保留 Hermes bridge、mnote Rust 业务执行面与浏览器专属 client capability bridge",
|
"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"
|
"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",
|
"id": "task-059",
|
||||||
"title": "按 Rust Web 长期架构实施清单 v1 推进长期重构主线:统一阶段边界、依赖顺序、横向能力和最终验收口径,避免后续再次回到“Rust 内核 + 重前端页面壳”的临时态",
|
"title": "按 Rust Web 长期架构实施清单 v1 推进长期重构主线:统一阶段边界、依赖顺序、横向能力和最终验收口径,避免后续再次回到“Rust 内核 + 重前端页面壳”的临时态",
|
||||||
@@ -2966,8 +2966,308 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"completed_at": "2026-04-16T13:18:39Z"
|
"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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"session_count": 31,
|
"completed_at": "2026-04-16T15:00:57Z"
|
||||||
"last_session": "2026-04-16T13:18:40Z"
|
},
|
||||||
|
{
|
||||||
|
"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": 33,
|
||||||
|
"last_session": "2026-04-16T16:11:37Z"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct AppConfig {
|
|||||||
pub compat_next_base_path: String,
|
pub compat_next_base_path: String,
|
||||||
pub convex_url: Option<String>,
|
pub convex_url: Option<String>,
|
||||||
pub convex_admin_key: Option<String>,
|
pub convex_admin_key: Option<String>,
|
||||||
|
pub allow_dev_fixtures: bool,
|
||||||
pub dev_user_id: String,
|
pub dev_user_id: String,
|
||||||
pub dev_user_name: String,
|
pub dev_user_name: String,
|
||||||
pub dev_user_email: String,
|
pub dev_user_email: String,
|
||||||
@@ -22,12 +23,10 @@ pub struct AppConfig {
|
|||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
Self {
|
Self {
|
||||||
service_name: env::var("MNOTE_WEB_SERVICE_NAME")
|
service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
|
||||||
.unwrap_or_else(|_| "mnote-web".into()),
|
|
||||||
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
|
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
|
||||||
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
|
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
|
||||||
bind_addr: env::var("MNOTE_WEB_BIND")
|
bind_addr: env::var("MNOTE_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:3104".into()),
|
||||||
.unwrap_or_else(|_| "127.0.0.1:3104".into()),
|
|
||||||
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
|
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
|
||||||
.unwrap_or_else(|_| "/api/hermes".into()),
|
.unwrap_or_else(|_| "/api/hermes".into()),
|
||||||
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
|
||||||
@@ -41,6 +40,10 @@ impl AppConfig {
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty()),
|
.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_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_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()),
|
dev_user_email: env::var("DEV_USER_EMAIL").unwrap_or_else(|_| "dev@mnote.local".into()),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -20,6 +21,7 @@ pub struct WebError {
|
|||||||
code: &'static str,
|
code: &'static str,
|
||||||
message: String,
|
message: String,
|
||||||
request_context: Option<RequestContext>,
|
request_context: Option<RequestContext>,
|
||||||
|
headers: Vec<(&'static str, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebError {
|
impl WebError {
|
||||||
@@ -29,6 +31,7 @@ impl WebError {
|
|||||||
code,
|
code,
|
||||||
message: message.into(),
|
message: message.into(),
|
||||||
request_context: None,
|
request_context: None,
|
||||||
|
headers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +39,35 @@ impl WebError {
|
|||||||
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
|
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn bad_request_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_REQUEST, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn internal(message: impl Into<String>) -> Self {
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
|
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn bad_gateway_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_GATEWAY, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_unavailable_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::SERVICE_UNAVAILABLE, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gateway_timeout_code(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::GATEWAY_TIMEOUT, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_context(mut self, request_context: &RequestContext) -> Self {
|
pub fn with_context(mut self, request_context: &RequestContext) -> Self {
|
||||||
self.request_context = Some(request_context.clone());
|
self.request_context = Some(request_context.clone());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_header(mut self, name: &'static str, value: impl Into<String>) -> Self {
|
||||||
|
self.headers.push((name, value.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for WebError {
|
impl IntoResponse for WebError {
|
||||||
@@ -61,6 +85,21 @@ impl IntoResponse for WebError {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|context| context.trace.trace_id.clone()),
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
pub command_status: Option<String>,
|
||||||
|
pub event_status: Option<String>,
|
||||||
|
pub target_page_id: Option<String>,
|
||||||
|
pub target_block_id: Option<String>,
|
||||||
|
pub aggregate_type: Option<String>,
|
||||||
|
pub aggregate_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BridgeRequestQuery {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub request_id: String,
|
||||||
|
pub command_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BridgeTraceQuery {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub trace_id: String,
|
||||||
|
pub command_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||||
|
(
|
||||||
|
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<Value, WebError> {
|
||||||
|
execute_runtime_query_via_convex(config, context, Some(workspace_id), query)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn workspace(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<BridgeWorkspaceQuery>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<BridgeRequestQuery>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<BridgeTraceQuery>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
use crate::app::AppState;
|
use crate::app::AppState;
|
||||||
use crate::context::RequestContext;
|
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::extract::{Extension, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
|
use core_protocol::{KernelNodeType, KernelProjectionKind};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -16,6 +26,12 @@ pub struct CompatBoundaryResponse {
|
|||||||
pub notes: Vec<&'static str>,
|
pub notes: Vec<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CompatSidebarQuery {
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn next_ai_agent_run(
|
pub async fn next_ai_agent_run(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
@@ -33,3 +49,103 @@ pub async fn next_ai_agent_run(
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn next_sidebar(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<CompatSidebarQuery>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
use crate::app::{AppConfig, AppState};
|
use crate::app::{AppConfig, AppState};
|
||||||
use crate::context::RequestContext;
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
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::extract::{Extension, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use bridge_runtime::{
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
|
|
||||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
|
|
||||||
};
|
|
||||||
use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind};
|
use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -45,86 +43,47 @@ pub struct KernelGraphQuery {
|
|||||||
pub max_depth: Option<u32>,
|
pub max_depth: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn runtime_context(context: &RequestContext) -> RuntimeBridgeContextWire {
|
fn sidebar_dataset_query(workspace_id: &str) -> RuntimeQueryEnvelopeWire {
|
||||||
RuntimeBridgeContextWire {
|
RuntimeQueryEnvelopeWire {
|
||||||
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 build_sidebar_dataset_plan(
|
|
||||||
config: &AppConfig,
|
|
||||||
context: &RequestContext,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> Result<Value, WebError> {
|
|
||||||
let runtime_input = RuntimeInput::Query {
|
|
||||||
context: runtime_context(context),
|
|
||||||
query: RuntimeQueryEnvelopeWire {
|
|
||||||
name: "sidebar.dataset.list".into(),
|
name: "sidebar.dataset.list".into(),
|
||||||
payload: json!({
|
payload: json!({
|
||||||
"workspaceId": workspace_id,
|
"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(
|
fn load_sidebar_dataset(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
workspace_id: Option<&str>,
|
workspace_id: &str,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
if let Ok(raw) = env::var("MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON") {
|
fetch_query_data_via_convex(
|
||||||
let trimmed = raw.trim();
|
config,
|
||||||
if !trimmed.is_empty() {
|
context,
|
||||||
return serde_json::from_str(trimmed).map_err(|error| {
|
Some(workspace_id),
|
||||||
WebError::internal(format!("kernel fixture JSON 非法: {error}")).with_context(context)
|
sidebar_dataset_query(workspace_id),
|
||||||
});
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let workspace_id = workspace_id
|
|
||||||
.or(context.workspace.workspace_id.as_deref())
|
|
||||||
.unwrap_or("ws_demo");
|
|
||||||
build_sidebar_dataset_plan(config, context, workspace_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_kernel_query(
|
fn execute_kernel_query(
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
query: RuntimeQueryEnvelopeWire,
|
query: RuntimeQueryEnvelopeWire,
|
||||||
dataset: Value,
|
dataset: Value,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
execute_runtime_query(RuntimeInput::Query {
|
execute_runtime_query_against_data(context, Some(workspace_id), query, dataset)
|
||||||
context: runtime_context(context),
|
}
|
||||||
query,
|
|
||||||
data: Some(dataset),
|
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||||
})
|
(
|
||||||
.map_err(|error| WebError::bad_request(error.message).with_context(context))
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
"result": result,
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn project_sidebar(
|
pub async fn project_sidebar(
|
||||||
@@ -132,14 +91,18 @@ pub async fn project_sidebar(
|
|||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Query(query): Query<KernelProjectionQuery>,
|
Query(query): Query<KernelProjectionQuery>,
|
||||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
) -> Result<(StatusCode, Json<Value>), 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(
|
let result = execute_kernel_query(
|
||||||
&context,
|
&context,
|
||||||
|
&effective_workspace_id,
|
||||||
RuntimeQueryEnvelopeWire {
|
RuntimeQueryEnvelopeWire {
|
||||||
name: "kernel.project_view".into(),
|
name: "kernel.project_view".into(),
|
||||||
payload: json!({
|
payload: json!({
|
||||||
"projection": KernelProjectionKind::SidebarTree,
|
"projection": KernelProjectionKind::SidebarTree,
|
||||||
"workspaceId": query.workspace_id,
|
"workspaceId": effective_workspace_id,
|
||||||
"rootNodeId": query.root_node_id,
|
"rootNodeId": query.root_node_id,
|
||||||
"depth": query.depth,
|
"depth": query.depth,
|
||||||
"includeEdges": true,
|
"includeEdges": true,
|
||||||
@@ -150,12 +113,7 @@ pub async fn project_sidebar(
|
|||||||
dataset,
|
dataset,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok((StatusCode::OK, Json(json!({
|
Ok(ok_response(&context, result))
|
||||||
"ok": true,
|
|
||||||
"requestId": context.trace.request_id,
|
|
||||||
"traceId": context.trace.trace_id,
|
|
||||||
"result": result,
|
|
||||||
}))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn subtree(
|
pub async fn subtree(
|
||||||
@@ -163,13 +121,17 @@ pub async fn subtree(
|
|||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Query(query): Query<KernelSubtreeQuery>,
|
Query(query): Query<KernelSubtreeQuery>,
|
||||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
) -> Result<(StatusCode, Json<Value>), 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(
|
let result = execute_kernel_query(
|
||||||
&context,
|
&context,
|
||||||
|
&effective_workspace_id,
|
||||||
RuntimeQueryEnvelopeWire {
|
RuntimeQueryEnvelopeWire {
|
||||||
name: "kernel.subtree.get".into(),
|
name: "kernel.subtree.get".into(),
|
||||||
payload: json!({
|
payload: json!({
|
||||||
"workspaceId": query.workspace_id,
|
"workspaceId": effective_workspace_id,
|
||||||
"rootNodeId": query.root_node_id,
|
"rootNodeId": query.root_node_id,
|
||||||
"depth": query.depth,
|
"depth": query.depth,
|
||||||
"includeEdges": true,
|
"includeEdges": true,
|
||||||
@@ -178,12 +140,7 @@ pub async fn subtree(
|
|||||||
},
|
},
|
||||||
dataset,
|
dataset,
|
||||||
)?;
|
)?;
|
||||||
Ok((StatusCode::OK, Json(json!({
|
Ok(ok_response(&context, result))
|
||||||
"ok": true,
|
|
||||||
"requestId": context.trace.request_id,
|
|
||||||
"traceId": context.trace.trace_id,
|
|
||||||
"result": result,
|
|
||||||
}))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn edges(
|
pub async fn edges(
|
||||||
@@ -191,25 +148,24 @@ pub async fn edges(
|
|||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Query(query): Query<KernelEdgesQuery>,
|
Query(query): Query<KernelEdgesQuery>,
|
||||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
) -> Result<(StatusCode, Json<Value>), 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(
|
let result = execute_kernel_query(
|
||||||
&context,
|
&context,
|
||||||
|
&effective_workspace_id,
|
||||||
RuntimeQueryEnvelopeWire {
|
RuntimeQueryEnvelopeWire {
|
||||||
name: "kernel.edges.list".into(),
|
name: "kernel.edges.list".into(),
|
||||||
payload: json!({
|
payload: json!({
|
||||||
"workspaceId": query.workspace_id,
|
"workspaceId": effective_workspace_id,
|
||||||
"nodeId": query.node_id,
|
"nodeId": query.node_id,
|
||||||
"direction": KernelGraphDirection::Both,
|
"direction": KernelGraphDirection::Both,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
dataset,
|
dataset,
|
||||||
)?;
|
)?;
|
||||||
Ok((StatusCode::OK, Json(json!({
|
Ok(ok_response(&context, result))
|
||||||
"ok": true,
|
|
||||||
"requestId": context.trace.request_id,
|
|
||||||
"traceId": context.trace.trace_id,
|
|
||||||
"result": result,
|
|
||||||
}))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn graph(
|
pub async fn graph(
|
||||||
@@ -217,13 +173,17 @@ pub async fn graph(
|
|||||||
Extension(context): Extension<RequestContext>,
|
Extension(context): Extension<RequestContext>,
|
||||||
Query(query): Query<KernelGraphQuery>,
|
Query(query): Query<KernelGraphQuery>,
|
||||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
) -> Result<(StatusCode, Json<Value>), 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(
|
let result = execute_kernel_query(
|
||||||
&context,
|
&context,
|
||||||
|
&effective_workspace_id,
|
||||||
RuntimeQueryEnvelopeWire {
|
RuntimeQueryEnvelopeWire {
|
||||||
name: "kernel.graph.traverse".into(),
|
name: "kernel.graph.traverse".into(),
|
||||||
payload: json!({
|
payload: json!({
|
||||||
"workspaceId": query.workspace_id,
|
"workspaceId": effective_workspace_id,
|
||||||
"startNodeId": query.start_node_id,
|
"startNodeId": query.start_node_id,
|
||||||
"maxDepth": query.max_depth.unwrap_or(2),
|
"maxDepth": query.max_depth.unwrap_or(2),
|
||||||
"edgeTypes": [],
|
"edgeTypes": [],
|
||||||
@@ -231,12 +191,7 @@ pub async fn graph(
|
|||||||
},
|
},
|
||||||
dataset,
|
dataset,
|
||||||
)?;
|
)?;
|
||||||
Ok((StatusCode::OK, Json(json!({
|
Ok(ok_response(&context, result))
|
||||||
"ok": true,
|
|
||||||
"requestId": context.trace.request_id,
|
|
||||||
"traceId": context.trace.trace_id,
|
|
||||||
"result": result,
|
|
||||||
}))))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -248,8 +203,8 @@ mod tests {
|
|||||||
|
|
||||||
fn app() -> axum::Router {
|
fn app() -> axum::Router {
|
||||||
std::env::set_var(
|
std::env::set_var(
|
||||||
"MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON",
|
"MNOTE_WEB_QUERY_FIXTURES_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"}]}"#,
|
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 {
|
build_app(AppState::new(AppConfig {
|
||||||
service_name: "mnote-web".into(),
|
service_name: "mnote-web".into(),
|
||||||
@@ -259,6 +214,7 @@ mod tests {
|
|||||||
compat_next_base_path: "/api/compat/next".into(),
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
convex_url: None,
|
convex_url: None,
|
||||||
convex_admin_key: None,
|
convex_admin_key: None,
|
||||||
|
allow_dev_fixtures: true,
|
||||||
dev_user_id: "dev-user".into(),
|
dev_user_id: "dev-user".into(),
|
||||||
dev_user_name: "开发用户".into(),
|
dev_user_name: "开发用户".into(),
|
||||||
dev_user_email: "dev@mnote.local".into(),
|
dev_user_email: "dev@mnote.local".into(),
|
||||||
@@ -270,7 +226,7 @@ mod tests {
|
|||||||
let response = app()
|
let response = app()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.uri("/api/kernel/projections/sidebar?rootNodeId=page_root")
|
.uri("/api/kernel/projections/sidebar?workspaceId=ws_demo&rootNodeId=page_root")
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.expect("request"),
|
.expect("request"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
mod bridge;
|
||||||
mod compat;
|
mod compat;
|
||||||
mod health;
|
mod health;
|
||||||
mod hermes;
|
mod hermes;
|
||||||
mod kernel;
|
mod kernel;
|
||||||
|
mod query_support;
|
||||||
mod sse;
|
mod sse;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
@@ -15,10 +17,16 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health::health))
|
.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/subtree", get(kernel::subtree))
|
||||||
.route("/api/kernel/edges", get(kernel::edges))
|
.route("/api/kernel/edges", get(kernel::edges))
|
||||||
.route("/api/kernel/graph", get(kernel::graph))
|
.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/stream/events", get(sse::events))
|
||||||
.route("/api/realtime/ws", get(ws::socket))
|
.route("/api/realtime/ws", get(ws::socket))
|
||||||
.nest(
|
.nest(
|
||||||
@@ -29,7 +37,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.nest(
|
.nest(
|
||||||
&compat_next_base_path,
|
&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)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Option<String>, 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<RuntimeQueryExecutionPlan, WebError> {
|
||||||
|
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<Value, WebError> {
|
||||||
|
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<Value, WebError> {
|
||||||
|
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<Value, WebError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::app::AppConfig;
|
use crate::app::AppConfig;
|
||||||
|
use crate::context::RequestContext;
|
||||||
use crate::error::WebError;
|
use crate::error::WebError;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -7,6 +8,12 @@ use serde_json::{json, Value};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::time::Duration;
|
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<String> {
|
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||||
if let Ok(value) = std::env::var(key) {
|
if let Ok(value) = std::env::var(key) {
|
||||||
let trimmed = value.trim().trim_matches('"').to_string();
|
let trimmed = value.trim().trim_matches('"').to_string();
|
||||||
@@ -38,12 +45,30 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_authorization(config: &AppConfig) -> Result<String, WebError> {
|
fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> {
|
||||||
|
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
|
let admin_key = config
|
||||||
.convex_admin_key
|
.convex_admin_key
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
|
.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!({
|
let identity = json!({
|
||||||
"subject": config.dev_user_id,
|
"subject": config.dev_user_id,
|
||||||
@@ -60,7 +85,7 @@ fn build_authorization(config: &AppConfig) -> Result<String, WebError> {
|
|||||||
Ok(format!("Convex {admin_key}:{encoded}"))
|
Ok(format!("Convex {admin_key}:{encoded}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convex_url(config: &AppConfig) -> Result<String, WebError> {
|
fn convex_url(config: &AppConfig, context: &RequestContext) -> Result<String, WebError> {
|
||||||
config
|
config
|
||||||
.convex_url
|
.convex_url
|
||||||
.clone()
|
.clone()
|
||||||
@@ -68,18 +93,62 @@ fn convex_url(config: &AppConfig) -> Result<String, WebError> {
|
|||||||
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
|
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
|
||||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.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,
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
plan: &RuntimeQueryExecutionPlan,
|
||||||
|
) -> Result<Option<Value>, 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,
|
plan: &RuntimeQueryExecutionPlan,
|
||||||
) -> Result<Value, WebError> {
|
) -> Result<Value, WebError> {
|
||||||
if plan.function_name != "sidebar:datasetList" {
|
if plan.function_name.trim().is_empty() || plan.function_name.ends_with(":unknown") {
|
||||||
return Err(WebError::bad_request(format!(
|
return Err(WebError::bad_request_code(
|
||||||
"mnote-web transport 暂不支持 query: {}",
|
"transport_query_unsupported",
|
||||||
plan.function_name
|
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!({
|
let payload = json!({
|
||||||
@@ -91,37 +160,99 @@ pub fn execute_sidebar_dataset_query(
|
|||||||
let client = reqwest::blocking::Client::builder()
|
let client = reqwest::blocking::Client::builder()
|
||||||
.timeout(Duration::from_secs(20))
|
.timeout(Duration::from_secs(20))
|
||||||
.build()
|
.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
|
let mut request = client
|
||||||
.post(format!("{}/api/query", convex_url(config)?))
|
.post(format!("{}/api/query", convex_url(config, context)?))
|
||||||
.header("Authorization", build_authorization(config)?)
|
.header("Authorization", build_authorization(config, context)?)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Convex-Client", "mnote-web")
|
.header("Convex-Client", "mnote-web")
|
||||||
.json(&payload)
|
.header(HEADER_REQUEST_ID, &context.trace.request_id)
|
||||||
.send()
|
.header(HEADER_TRACE_ID, &context.trace.trace_id)
|
||||||
.map_err(|error| WebError::internal(format!("Convex query 请求失败: {error}")))?;
|
.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 status = response.status();
|
||||||
let body: Value = response
|
let body: Value = response.json().map_err(|error| {
|
||||||
.json()
|
WebError::bad_gateway_code(
|
||||||
.map_err(|error| WebError::internal(format!("Convex 响应解析失败: {error}")))?;
|
"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() {
|
if !status.is_success() {
|
||||||
let message = body
|
let message = body
|
||||||
.get("errorMessage")
|
.get("errorMessage")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Convex query 失败");
|
.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) {
|
match body.get("status").and_then(Value::as_str) {
|
||||||
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
|
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")
|
body.get("errorMessage")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Convex 返回 error")
|
.unwrap_or("Convex 返回 error")
|
||||||
.to_string(),
|
.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<Value, WebError> {
|
||||||
|
execute_convex_query_plan(config, context, plan)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
mapSidebarDatasetListQueryResultToInitialData,
|
mapSidebarDatasetListQueryResultToInitialData,
|
||||||
type SidebarDatasetListQueryResult,
|
type SidebarDatasetListQueryResult,
|
||||||
} from "@/lib/sidebar-data";
|
} from "@/lib/sidebar-data";
|
||||||
|
import {
|
||||||
|
fetchSidebarDatasetFromMnoteWeb,
|
||||||
|
getMnoteWebBaseUrl,
|
||||||
|
} from "@/lib/server/mnote-web";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -37,6 +41,24 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const bridgeContext = await buildDocumentBridgeContext({
|
||||||
request,
|
request,
|
||||||
workspaceId: targetWorkspaceId,
|
workspaceId: targetWorkspaceId,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
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 { cn } from "@/lib/utils";
|
||||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||||
@@ -155,19 +155,11 @@ function MoveEmbedPickerDialogBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isEmptyQuery) {
|
if (isEmptyQuery) {
|
||||||
const docs = sidebarQuery.data?.documents ?? [];
|
const tree = sidebarQuery.data?.kernelSidebarTree ?? [];
|
||||||
const tree = buildDocumentTree(docs);
|
const flattened = buildPickerTreeItems(
|
||||||
|
buildPageTreeProjectionItems(tree),
|
||||||
const flattened: Array<{ id: string; title: string; depth: number }> = [];
|
excluded,
|
||||||
const walk = (nodes: ReturnType<typeof buildDocumentTree>, 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);
|
|
||||||
|
|
||||||
for (const item of flattened) {
|
for (const item of flattened) {
|
||||||
if (excluded.has(item.id)) continue;
|
if (excluded.has(item.id)) continue;
|
||||||
@@ -195,7 +187,7 @@ function MoveEmbedPickerDialogBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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" ? "移动到..." : "嵌入到...";
|
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
@@ -17,11 +17,11 @@ import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
|||||||
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
|
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||||
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { flattenDocumentTree } from "@/lib/sidebar-tree";
|
|
||||||
|
|
||||||
interface PrivateTreeProps {
|
interface PrivateTreeProps {
|
||||||
nodes: SidebarTreeNode[];
|
rows: PageTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
onToggleExpand: (id: string) => void;
|
onToggleExpand: (id: string) => void;
|
||||||
@@ -33,7 +33,7 @@ interface PrivateTreeProps {
|
|||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
|
|
||||||
export function PrivateTree({
|
export function PrivateTree({
|
||||||
nodes,
|
rows,
|
||||||
expanded,
|
expanded,
|
||||||
activeId,
|
activeId,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
@@ -44,18 +44,6 @@ export function PrivateTree({
|
|||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||||
|
|
||||||
const flatNodes = useMemo(() => {
|
|
||||||
const flattened = flattenDocumentTree(nodes, expanded);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: { distance: 5 },
|
activationConstraint: { distance: 5 },
|
||||||
@@ -64,7 +52,7 @@ export function PrivateTree({
|
|||||||
|
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: flatNodes.length,
|
count: rows.length,
|
||||||
getScrollElement: () => scrollAreaRef.current,
|
getScrollElement: () => scrollAreaRef.current,
|
||||||
estimateSize: () => ROW_HEIGHT,
|
estimateSize: () => ROW_HEIGHT,
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
@@ -82,25 +70,25 @@ export function PrivateTree({
|
|||||||
if (!overId || activeId === overId) {
|
if (!overId || activeId === overId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const activeIndex = flatNodes.findIndex((item) => item.node.id === activeId);
|
const activeIndex = rows.findIndex((item) => item.nodeId === activeId);
|
||||||
const overIndex = flatNodes.findIndex((item) => item.node.id === overId);
|
const overIndex = rows.findIndex((item) => item.nodeId === overId);
|
||||||
if (activeIndex === -1 || overIndex === -1) {
|
if (activeIndex === -1 || overIndex === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetParent = flatNodes[overIndex].parentId;
|
const targetParent = rows[overIndex].parentNodeId;
|
||||||
const siblingList = flatNodes.filter((item) => item.parentId === targetParent);
|
const siblingList = rows.filter((item) => item.parentNodeId === targetParent);
|
||||||
const siblingIndex = siblingList.findIndex((item) => item.node.id === overId);
|
const siblingIndex = siblingList.findIndex((item) => item.nodeId === overId);
|
||||||
const position = siblingIndex === -1 ? siblingList.length : siblingIndex;
|
const position = siblingIndex === -1 ? siblingList.length : siblingIndex;
|
||||||
onMove(activeId, targetParent, position);
|
onMove(activeId, targetParent, position);
|
||||||
},
|
},
|
||||||
[flatNodes, onMove],
|
[onMove, rows],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragCancel = useCallback(() => {
|
const handleDragCancel = useCallback(() => {
|
||||||
setActiveDragId(null);
|
setActiveDragId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (flatNodes.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,22 +102,22 @@ export function PrivateTree({
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={handleDragCancel}
|
onDragCancel={handleDragCancel}
|
||||||
>
|
>
|
||||||
<SortableContext items={flatNodes.map((item) => item.node.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={rows.map((item) => item.nodeId)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const item = flatNodes[virtualRow.index];
|
const item = rows[virtualRow.index];
|
||||||
return (
|
return (
|
||||||
<VirtualRow key={item.node.id} virtualRow={virtualRow}>
|
<VirtualRow key={item.nodeId} virtualRow={virtualRow}>
|
||||||
<SortableTreeRow
|
<SortableTreeRow
|
||||||
node={item.node}
|
node={item.node}
|
||||||
depth={item.depth}
|
depth={item.depth}
|
||||||
expanded={expanded.has(item.node.id)}
|
expanded={expanded.has(item.nodeId)}
|
||||||
hasChildren={item.node.children.length > 0}
|
hasChildren={item.childCount > 0}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
isDragging={activeDragId === item.node.id}
|
isDragging={activeDragId === item.nodeId}
|
||||||
onToggleExpand={() => onToggleExpand(item.node.id)}
|
onToggleExpand={() => onToggleExpand(item.nodeId)}
|
||||||
onCreateChild={() => onCreateChild(item.node.id)}
|
onCreateChild={() => onCreateChild(item.nodeId)}
|
||||||
onContextMenu={(event) => onContextMenu(event, item.node)}
|
onContextMenu={(event) => onContextMenu(event, item.node)}
|
||||||
/>
|
/>
|
||||||
</VirtualRow>
|
</VirtualRow>
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ import { useSidebarStore } from "@/store/sidebar";
|
|||||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
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 { useSearchPaletteStore } from "@/store/search-palette";
|
||||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||||
@@ -172,7 +176,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
const activeId = segments?.[1] ?? "";
|
const activeId = segments?.[1] ?? "";
|
||||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||||
|
|
||||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree ?? []);
|
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
@@ -243,7 +247,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTree(() => {
|
setTree(() => {
|
||||||
const nextTree = sidebarData.kernelSidebarTree ?? [];
|
const nextTree = sidebarData.kernelSidebarTree;
|
||||||
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
|
||||||
return nextTree;
|
return nextTree;
|
||||||
});
|
});
|
||||||
@@ -468,9 +472,21 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
|
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
|
||||||
[filter, privateTree],
|
[filter, privateTree],
|
||||||
);
|
);
|
||||||
|
const privatePageRows = useMemo(
|
||||||
|
() => buildPageTreeProjectionItems(privateTree),
|
||||||
|
[privateTree],
|
||||||
|
);
|
||||||
|
const filteredPrivatePageRows = useMemo(
|
||||||
|
() => buildPageTreeProjectionItems(filteredPrivateTree),
|
||||||
|
[filteredPrivateTree],
|
||||||
|
);
|
||||||
const flattenedPrivate = useMemo(
|
const flattenedPrivate = useMemo(
|
||||||
() => flattenDocumentTree(privateTree, expanded),
|
() => filterVisiblePageTreeProjectionItems(privatePageRows, expanded),
|
||||||
[privateTree, expanded],
|
[expanded, privatePageRows],
|
||||||
|
);
|
||||||
|
const visibleFilteredPrivatePageRows = useMemo(
|
||||||
|
() => filterVisiblePageTreeProjectionItems(filteredPrivatePageRows, expanded),
|
||||||
|
[expanded, filteredPrivatePageRows],
|
||||||
);
|
);
|
||||||
const filteredTrash = useMemo(() => {
|
const filteredTrash = useMemo(() => {
|
||||||
const keyword = trashSearch.trim().toLowerCase();
|
const keyword = trashSearch.trim().toLowerCase();
|
||||||
@@ -574,13 +590,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
const fileTreeRows = useMemo(
|
const fileTreeRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildVisibleRows({
|
buildVisibleRows({
|
||||||
nodes: filteredPrivateTree,
|
pageRows: visibleFilteredPrivatePageRows,
|
||||||
expanded,
|
expanded,
|
||||||
assetsByDoc,
|
assetsByDoc,
|
||||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||||
expandedAssetFolderIds: expandedAssetFolders,
|
expandedAssetFolderIds: expandedAssetFolders,
|
||||||
}),
|
}),
|
||||||
[assetsByDoc, expanded, expandedAssetFolders, filteredPrivateTree, mindmapChildrenSnapshot.childAssetsByMindmapId],
|
[
|
||||||
|
assetsByDoc,
|
||||||
|
expanded,
|
||||||
|
expandedAssetFolders,
|
||||||
|
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||||
|
visibleFilteredPrivatePageRows,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
||||||
@@ -1816,12 +1838,12 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
const handleConvertToChild = useCallback(
|
const handleConvertToChild = useCallback(
|
||||||
async (documentId: string) => {
|
async (documentId: string) => {
|
||||||
const flat = flattenedPrivate;
|
const flat = flattenedPrivate;
|
||||||
const currentIndex = flat.findIndex((item) => item.node.id === documentId);
|
const currentIndex = flat.findIndex((item) => item.nodeId === documentId);
|
||||||
if (currentIndex <= 0) {
|
if (currentIndex <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const targetParentId = flat[currentIndex - 1].node.id;
|
const targetParentId = flat[currentIndex - 1].nodeId;
|
||||||
await handleMove(documentId, targetParentId, flat[currentIndex - 1].node.children.length);
|
await handleMove(documentId, targetParentId, flat[currentIndex - 1].childCount);
|
||||||
},
|
},
|
||||||
[flattenedPrivate, handleMove],
|
[flattenedPrivate, handleMove],
|
||||||
);
|
);
|
||||||
@@ -2271,20 +2293,20 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
const renderList = (nodes: SidebarTreeNode[]) => {
|
const renderList = (nodes: SidebarTreeNode[]) => {
|
||||||
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
|
const flat = buildPageTreeProjectionItems(nodes);
|
||||||
if (flat.length === 0) {
|
if (flat.length === 0) {
|
||||||
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{flat.map(({ node, depth }) => (
|
{flat.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={node.id}
|
key={item.nodeId}
|
||||||
href={`/documents/${node.id}`}
|
href={`/documents/${item.nodeId}`}
|
||||||
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
||||||
style={{ paddingLeft: 8 + depth * 12 }}
|
style={{ paddingLeft: 8 + item.depth * 12 }}
|
||||||
>
|
>
|
||||||
{node.title || "无标题"}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2499,7 +2521,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
<div className="flex-1 px-1 pb-2">
|
<div className="flex-1 px-1 pb-2">
|
||||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||||
<PrivateTree
|
<PrivateTree
|
||||||
nodes={filteredPrivateTree}
|
rows={visibleFilteredPrivatePageRows}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
onToggleExpand={toggleExpand}
|
onToggleExpand={toggleExpand}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export interface SidebarInitialData {
|
|||||||
activeWorkspaceId: string;
|
activeWorkspaceId: string;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
kernelSidebarProjection?: KernelSidebarProjection | null;
|
kernelSidebarProjection: KernelSidebarProjection;
|
||||||
kernelSidebarTree?: SidebarTreeNode[];
|
kernelSidebarTree: SidebarTreeNode[];
|
||||||
trashedDocuments: TrashRecord[];
|
trashedDocuments: TrashRecord[];
|
||||||
trashedMediaAssets?: MediaAsset[];
|
trashedMediaAssets?: MediaAsset[];
|
||||||
trashedMindmapAssets?: MediaAsset[];
|
trashedMindmapAssets?: MediaAsset[];
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface DocumentNode extends DocumentRecord {
|
|||||||
children: DocumentNode[];
|
children: DocumentNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 过渡兼容 helper:保留给旧单测和非树域场景使用。
|
||||||
|
// Sidebar / 页面树 / 文件树 / move-embed picker 主路径已统一改读 projection family。
|
||||||
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
||||||
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
|
// 防御性处理:当上游数据意外包含重复 id 时,避免生成重复节点导致渲染 key 冲突。
|
||||||
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
|
// 以“最后一次出现”为准(与原先 nodeMap.set 的覆盖行为保持一致)。
|
||||||
@@ -33,7 +35,6 @@ export function buildDocumentTree(records: DocumentRecord[]): DocumentNode[] {
|
|||||||
uniqueRecords.reverse();
|
uniqueRecords.reverse();
|
||||||
|
|
||||||
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
|
if (duplicatedIds.size > 0 && process.env.NODE_ENV !== "production") {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
|
`[buildDocumentTree] 检测到重复文档 id(已自动去重):${Array.from(duplicatedIds).slice(0, 10).join(", ")}${duplicatedIds.size > 10 ? "…" : ""}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||||
import { buildVisibleRows } from "./rows";
|
import { buildVisibleRows } from "./rows";
|
||||||
import { parseFileTreeRowId } from "./types";
|
import { parseFileTreeRowId } from "./types";
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ describe("buildVisibleRows", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rows = buildVisibleRows({
|
const rows = buildVisibleRows({
|
||||||
nodes: [a],
|
pageRows: buildPageTreeProjectionItems([a]),
|
||||||
expanded: new Set(["a"]),
|
expanded: new Set(["a"]),
|
||||||
assetsByDoc: {
|
assetsByDoc: {
|
||||||
a: [
|
a: [
|
||||||
@@ -108,7 +109,7 @@ describe("buildVisibleRows", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rows = buildVisibleRows({
|
const rows = buildVisibleRows({
|
||||||
nodes: [a, a],
|
pageRows: buildPageTreeProjectionItems([a, a]),
|
||||||
expanded: new Set(["a"]),
|
expanded: new Set(["a"]),
|
||||||
assetsByDoc: {},
|
assetsByDoc: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,55 +1,52 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||||
import type { MediaAsset } from "@/types/media";
|
import type { MediaAsset } from "@/types/media";
|
||||||
import type { FileTreeRow } from "./types";
|
import type { FileTreeRow } from "./types";
|
||||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||||
|
|
||||||
export function buildVisibleRows({
|
export function buildVisibleRows({
|
||||||
nodes,
|
pageRows,
|
||||||
expanded,
|
expanded,
|
||||||
assetsByDoc,
|
assetsByDoc,
|
||||||
assetChildrenByAssetId,
|
assetChildrenByAssetId,
|
||||||
expandedAssetFolderIds,
|
expandedAssetFolderIds,
|
||||||
}: {
|
}: {
|
||||||
nodes: SidebarTreeNode[];
|
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
||||||
|
pageRows: PageTreeProjectionItem[];
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
assetsByDoc: Record<string, MediaAsset[]>;
|
assetsByDoc: Record<string, MediaAsset[]>;
|
||||||
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
||||||
expandedAssetFolderIds?: Set<string>;
|
expandedAssetFolderIds?: Set<string>;
|
||||||
}): FileTreeRow[] {
|
}): FileTreeRow[] {
|
||||||
const rows: FileTreeRow[] = [];
|
const rows: FileTreeRow[] = [];
|
||||||
const visitedDocIds = new Set<string>();
|
|
||||||
|
|
||||||
const walk = (node: SidebarTreeNode, depth: number) => {
|
pageRows.forEach((item) => {
|
||||||
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
|
const assets = assetsByDoc[item.nodeId] ?? [];
|
||||||
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
|
const hasChildren = item.childCount > 0 || assets.length > 0;
|
||||||
if (visitedDocIds.has(node.id)) return;
|
const isExpanded = expanded.has(item.nodeId);
|
||||||
visitedDocIds.add(node.id);
|
|
||||||
|
|
||||||
const assets = assetsByDoc[node.id] ?? [];
|
|
||||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
|
||||||
const isExpanded = expanded.has(node.id);
|
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "doc",
|
kind: "doc",
|
||||||
rowId: makeDocRowId(node.id),
|
rowId: makeDocRowId(item.nodeId),
|
||||||
depth,
|
depth: item.depth,
|
||||||
docId: node.id,
|
docId: item.nodeId,
|
||||||
parentDocId: node.parent_id,
|
parentDocId: item.parentNodeId,
|
||||||
node,
|
node: item.node,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isExpanded) return;
|
if (!isExpanded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "index",
|
kind: "index",
|
||||||
rowId: makeIndexRowId(node.id),
|
rowId: makeIndexRowId(item.nodeId),
|
||||||
depth: depth + 1,
|
depth: item.depth + 1,
|
||||||
docId: node.id,
|
docId: item.nodeId,
|
||||||
parentDocId: node.id,
|
parentDocId: item.nodeId,
|
||||||
node,
|
node: item.node,
|
||||||
});
|
});
|
||||||
|
|
||||||
assets.forEach((asset) => {
|
assets.forEach((asset) => {
|
||||||
@@ -60,9 +57,9 @@ export function buildVisibleRows({
|
|||||||
rows.push({
|
rows.push({
|
||||||
kind: "asset-folder",
|
kind: "asset-folder",
|
||||||
rowId: makeAssetFolderRowId(asset.id),
|
rowId: makeAssetFolderRowId(asset.id),
|
||||||
depth: depth + 1,
|
depth: item.depth + 1,
|
||||||
docId: node.id,
|
docId: item.nodeId,
|
||||||
parentDocId: node.id,
|
parentDocId: item.nodeId,
|
||||||
asset,
|
asset,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -72,9 +69,9 @@ export function buildVisibleRows({
|
|||||||
rows.push({
|
rows.push({
|
||||||
kind: "asset",
|
kind: "asset",
|
||||||
rowId: makeAssetRowId(child.id),
|
rowId: makeAssetRowId(child.id),
|
||||||
depth: depth + 2,
|
depth: item.depth + 2,
|
||||||
docId: node.id,
|
docId: item.nodeId,
|
||||||
parentDocId: node.id,
|
parentDocId: item.nodeId,
|
||||||
asset: child,
|
asset: child,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,16 +82,12 @@ export function buildVisibleRows({
|
|||||||
rows.push({
|
rows.push({
|
||||||
kind: "asset",
|
kind: "asset",
|
||||||
rowId: makeAssetRowId(asset.id),
|
rowId: makeAssetRowId(asset.id),
|
||||||
depth: depth + 1,
|
depth: item.depth + 1,
|
||||||
docId: node.id,
|
docId: item.nodeId,
|
||||||
parentDocId: node.id,
|
parentDocId: item.nodeId,
|
||||||
asset,
|
asset,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
node.children.forEach((child) => walk(child, depth + 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes.forEach((node) => walk(node, 0));
|
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Headers> {
|
||||||
|
const source = request?.headers ?? new Headers(await headers());
|
||||||
|
const forwarded = new Headers();
|
||||||
|
|
||||||
|
copyHeaderIfPresent(forwarded, source, "cookie");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "authorization");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "x-request-id");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "x-trace-id");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "x-session-id");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "x-mnote-workspace-id");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "x-mnote-source-channel");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "x-mnote-source-client");
|
||||||
|
copyHeaderIfPresent(forwarded, source, "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<string, unknown>).message === "string"
|
||||||
|
? String((payload as Record<string, unknown>).message)
|
||||||
|
: "mnote-web 侧边栏兼容接口请求失败";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload?.result) {
|
||||||
|
throw new Error("mnote-web /api/compat/next/sidebar 未返回 result");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataset: payload.result,
|
||||||
|
meta: {
|
||||||
|
requestId: payload.requestId ?? null,
|
||||||
|
traceId: payload.traceId ?? null,
|
||||||
|
workspaceId: payload.workspaceId?.trim() || input.workspaceId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
type SidebarDatasetListQueryResult,
|
type SidebarDatasetListQueryResult,
|
||||||
} from "@/lib/sidebar-data";
|
} from "@/lib/sidebar-data";
|
||||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||||
|
import {
|
||||||
|
fetchSidebarDatasetFromMnoteWeb,
|
||||||
|
getMnoteWebBaseUrl,
|
||||||
|
} from "@/lib/server/mnote-web";
|
||||||
|
|
||||||
type LoadSidebarDataFromConvexInput = {
|
type LoadSidebarDataFromConvexInput = {
|
||||||
client: ConvexHttpClient;
|
client: ConvexHttpClient;
|
||||||
@@ -57,9 +61,15 @@ export async function loadSidebarDataFromConvex(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
const sidebarDataset = getMnoteWebBaseUrl()
|
||||||
|
? (
|
||||||
|
await fetchSidebarDatasetFromMnoteWeb({
|
||||||
workspaceId: targetWorkspaceId,
|
workspaceId: targetWorkspaceId,
|
||||||
})) as SidebarDatasetListQueryResult;
|
})
|
||||||
|
).dataset
|
||||||
|
: ((await input.client.query(sidebarDatasetListQuery, {
|
||||||
|
workspaceId: targetWorkspaceId,
|
||||||
|
})) as SidebarDatasetListQueryResult);
|
||||||
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export type SidebarDatasetListQueryResult = {
|
|||||||
active_workspace_id: string;
|
active_workspace_id: string;
|
||||||
workspaces: WorkspaceSummary[];
|
workspaces: WorkspaceSummary[];
|
||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
kernel_sidebar_projection?: KernelSidebarProjection | null;
|
kernel_sidebar_projection: KernelSidebarProjection;
|
||||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||||
media_assets: MediaAsset[];
|
media_assets: MediaAsset[];
|
||||||
trashed_media_assets: MediaAsset[];
|
trashed_media_assets: MediaAsset[];
|
||||||
@@ -241,16 +241,14 @@ export function buildSidebarDatasetListQueryResult(
|
|||||||
export function mapSidebarDatasetListQueryResultToInitialData(
|
export function mapSidebarDatasetListQueryResultToInitialData(
|
||||||
result: SidebarDatasetListQueryResult,
|
result: SidebarDatasetListQueryResult,
|
||||||
): SidebarInitialData {
|
): SidebarInitialData {
|
||||||
const kernelSidebarProjection =
|
|
||||||
result.kernel_sidebar_projection ?? buildKernelSidebarProjection(result.documents);
|
|
||||||
return {
|
return {
|
||||||
activeWorkspaceId: result.active_workspace_id,
|
activeWorkspaceId: result.active_workspace_id,
|
||||||
workspaces: [...result.workspaces],
|
workspaces: [...result.workspaces],
|
||||||
documents: [...result.documents],
|
documents: [...result.documents],
|
||||||
kernelSidebarProjection,
|
kernelSidebarProjection: result.kernel_sidebar_projection,
|
||||||
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
|
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
|
||||||
records: result.documents,
|
records: result.documents,
|
||||||
projection: kernelSidebarProjection,
|
projection: result.kernel_sidebar_projection,
|
||||||
}),
|
}),
|
||||||
trashedDocuments: [...result.trashed_documents],
|
trashedDocuments: [...result.trashed_documents],
|
||||||
trashedMediaAssets: [...result.trashed_media_assets],
|
trashedMediaAssets: [...result.trashed_media_assets],
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
|
||||||
|
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<string>,
|
||||||
|
): PageTreeProjectionItem[] {
|
||||||
|
const visible: PageTreeProjectionItem[] = [];
|
||||||
|
const visibleIds = new Set<string>();
|
||||||
|
|
||||||
|
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<string> = [],
|
||||||
|
): 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user