feat(kernel): complete tree-first graph tasks 074-080

This commit is contained in:
lix-2026
2026-04-16 22:01:51 +08:00
parent 2ff10fa86c
commit b1d5d97142
65 changed files with 11579 additions and 4606 deletions
+462
View File
@@ -0,0 +1,462 @@
# Rust Web 长期架构实施清单 v2
> 更新时间:2026-04-16
>
> 基于以下实际状态重写:
> - 当前未提交代码
> - `/mnt/Data1T/mnote/harness-tasks.json` 中 `task-059` ~ `task-069`
> - `/mnt/Data1T/mnote/design/rust-web-long-term-architecture-v1.md`
> - `/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md`
## 1. 这版为什么要重写
这次重写的原因很明确:
- `task-059` ~ `task-069` 目前在 `harness-tasks.json` 里已经被标为 `completed`
- 但从当前未提交代码实际看,很多项只证明了:
- 文档已补
- 骨架已建
- 局部组件已拆
- `eslint` / `cargo check` 可过
- **并不能证明对应长期阶段已经真正完成**
因此这版 checklist 的目标不是重复愿景,而是:
> **把长期路线重新落回“真实代码状态”,明确哪些已经落地、哪些只是最小接缝、哪些仍未开始。**
---
## 2. 当前总判断
从当前代码看,长期路线的真实状态是:
### 2.1 已有明确进展
- [x] `Phase 0` 的文档化边界梳理已经完成
- [x] `Phase 1` 已落了一个最小 `axum` Web 骨架:`rust/crates/mnote-web/`
- [x] `Phase 2` 文档阅读态分离已经有实质代码
- [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝
- [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence`
- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 Hermes bridge 传递 `node` / `subtree` / `outline` / `evidence`
- [x] `Phase 6` Mindmap 独立页已去掉 `editorStub` 主入口
- [x] `Phase 7` 阅读态已直接消费 `pageSubtree``BlockNote` 也已不是文档页默认唯一入口
### 2.2 但大部分阶段都只是“部分落地”
- [ ] `Phase 1` 还不是主 Web 路径,只是 Rust Web skeleton
- [ ] `Phase 3` Sidebar 仍是超大客户端组件,其他树域也未统一切到 kernel projection
- [ ] `Phase 4` 搜索仍是前端 runtime 主导,不是 server-first 搜索页
- [ ] `Phase 5` AI runtime 仍然很重,只是懒挂载了
- [ ] `Phase 6` Mindmap 仍然是重前端交互壳,不是独立对象页壳
- [ ] `Phase 7` 文档页外围面板仍集中在 `DocumentContent`
- [ ] `Phase 8` 旧 Next/React 主路径完全没有完成切换
### 2.3 结论
> **当前真实状态不是“Phase 0 ~ 8 已全部完成”,而是“Phase 0 基本完成,Phase 1/2/4/5/6/7 处于不同程度的部分落地,Phase 3 和 Phase 8 仍远未完成”。**
---
## 3. 重新定义状态口径
为了避免再次把“有骨架”写成“已完成”,v2 统一使用下面三种状态:
### `DONE`
定义:
- 代码主路径已经切换
- 不是只有文档或骨架
- 用户可感知行为已经变了
- 后续只剩清理和补强
### `PARTIAL`
定义:
- 已有真实代码改动
- 但仍是局部接缝、最小骨架、阶段性拆分
- 主路径尚未彻底切换
### `NOT_STARTED`
定义:
- 还停留在设计或口径层
- 或只有零散基础,不足以算阶段开始
---
## 4. 基于真实代码的阶段总览
| 阶段 | v1 口径 | 当前真实状态 | 说明 |
| --- | --- | --- | --- |
| Phase 0 | 边界冻结 | `DONE` | 文档、候选模块、边界口径已经成形,但性能基线更多还是文档定义,不是完整观测系统 |
| Phase 1 | Rust Web 基础层 | `PARTIAL` | `mnote-web` 已创建,`axum` 骨架已存在,sidebar kernel route 也已接到真实 query plan,但远不是主流量入口 |
| Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` |
| Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 |
| Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 |
| Phase 5 | AI 面板 bridge island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 Hermes,但 runtime 仍重,协议也未统一到真正“最小壳” |
| Phase 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 |
| Phase 7 | BlockNote 孤岛化 | `PARTIAL` | 阅读态已直接消费 `pageSubtree`,编辑器按需挂载,但外围 drawer/panel 仍集中在同一内容组件 |
| Phase 8 | 旧前端壳下线 | `NOT_STARTED` | 当前主入口仍是 Next/React,不能宣称主路径切换完成 |
---
## 5. Phase 0:基线与边界冻结
**当前状态:`DONE`**
### 5.1 已有事实
- [x] 已有长期架构文档
- [x] 已有长期 checklist 文档
- [x] 已有 `tree-first graph kernel` 文档
- [x] 已对文档页、Sidebar、Search、AI、Mindmap、OnlyOffice、`BlockNote` 做了模块级边界识别
### 5.2 当前仍不足的地方
- [ ] 真正可自动回归的性能基线系统仍未建立
- [ ] 统一 performance trace 采样点仍未进入运行时
- [ ] “冻结旧壳继续膨胀”目前更多靠文档与人工约束,不是代码级 guardrail
### 5.3 v2 完成判定
- [x] 设计层边界冻结已完成
- [ ] 运行时观测层冻结仍未完成
---
## 6. Phase 1Rust Web 基础层落地
**当前状态:`PARTIAL`**
### 6.1 已落地事实
- [x] Rust workspace 已并入 `mnote-web`
- [Cargo.toml](/mnt/Data1T/mnote/rust/Cargo.toml)
- [x] 已存在 `axum` Web 骨架
- [Cargo.toml](/mnt/Data1T/mnote/rust/crates/mnote-web/Cargo.toml)
- [x] 已有统一 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)
- [x] 已有 request context / middleware
- [context.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/context.rs)
- [request_context.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/middleware/request_context.rs)
- [x] 已有最小 Hermes bridge route
- [hermes.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes.rs)
- [x] 已有 SSE / WS / compat route 骨架
- [x] kernel sidebar route 已通过 `sidebar.dataset.list` runtime plan + `execute_sidebar_dataset_query(...)` 读取数据集
- [kernel.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs)
### 6.2 明确还没完成
- [ ] `mnote-web` 还不是主 Web 服务入口
- [ ] 还没有主页面壳 SSR
- [ ] 还没有主 API 大面积从 Next route 切到 Rust Web
- [ ] 当前 `compat_next_base_path` 仍说明它主要还是兼容层,不是主承载层
- [ ] `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` fixture fallback 仍然存在,不能把当前接缝写成正式主链
- [ ] 还不能说“后续页面迁移已不依赖 Next API route 作为唯一入口”,只能说“已经开始脱钩”
### 6.3 v2 后续任务
- [ ]`mnote-web` 从 skeleton 提升为真实服务入口
- [ ] 先把 sidebar kernel route 上的 fixture fallback 收到测试/开发边界,再承接一条真实页面壳或真实查询主链
- [ ] 明确 Next -> Rust Web 的流量切换边界
- [ ] 增加主 API / 页面壳级集成验证,而不只是 `cargo check`
---
## 7. Phase 2:文档阅读页 server-first 化
**当前状态:`PARTIAL`,但已经是最实的进展之一**
### 7.1 已落地事实
- [x] 文档页已在服务端预取 `meta + content`
- [page.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx)
- [x] `DocumentShell` 已去掉 mounted gating
- [document-shell.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-shell.tsx)
- [x] `DocumentReadView` 已作为阅读态渲染器落地
- [document-read-view.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-read-view.tsx)
- [x] `DocumentContent` 已有 `isEditing` / `keepEditorMounted`
- [document-content.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx)
- [x] `BlockNoteEditor` 已不是默认唯一入口
### 7.2 当前还没完成
- [ ] 服务端读链失败时仍回退旧链
- [ ] 阅读态与编辑态虽已分开,但大量页面状态仍集中在 `DocumentContent`
- [ ] 评论、历史、回链、AI、页面选项虽然不是首屏阻塞,但仍在同一组件层集中管理
- [ ] 阅读页还不是一个真正极薄的 server-first 页面壳
### 7.3 v2 完成判定
- [x] “先阅读、后编辑”的主行为已经出现
- [ ] “阅读页已经彻底轻壳化”还不能成立
---
## 8. Phase 3Sidebar / 页面树 / 文件树 Rust 化与 island 化
**当前状态:`PARTIAL` 偏早期**
### 8.1 已落地事实
- [x] Sidebar 数据 query 契约此前已经有 Rust 化前进
- [x] 布局服务端会先加载 `sidebarInitialData`
- [layout.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/layout.tsx)
- [x] `sidebar-data.ts` 已生成 `kernel_sidebar_projection``kernelSidebarTree`
- [sidebar-data.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/sidebar-data.ts)
- [x] 主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始树与同步来源
- [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx)
### 8.2 当前真实问题
- [ ] `Sidebar` 仍然是超大客户端组件
- [sidebar.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/sidebar/sidebar.tsx)
- [ ] 页面树 / 文件树仍主要在客户端组装与交互
- [ ] `buildDocumentTree(...)` 等旧拼树 helper 仍残留在 `move-embed-picker-dialog.tsx``lib/documents.ts` 等兼容场景
- [ ] 主布局仍然默认常驻挂载 Sidebar
- [ ] 还不能叫“服务端输出 + 局部 island”,现在更像“服务端首包 + 超大客户端壳”
### 8.3 v2 后续任务
- [ ] 先拆 Sidebar 自身为 host / runtime 或分片 island
- [ ] 把树结构首包与交互态严格分层
- [ ] 把主 Sidebar 之外的 page tree、file tree、embed/move picker 也统一切到 kernel projection
- [ ] 把局部刷新协议显式化
- [ ] 给 Sidebar 建立真正的切页重渲染基线
---
## 9. Phase 4:搜索系统 Rust 化与 island 化
**当前状态:`PARTIAL`**
### 9.1 已落地事实
- [x] `SearchPalette` 已收口为轻量 host
- [search-palette.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/search/search-palette.tsx)
- [x] 搜索 runtime 已按需动态加载
- [SearchPaletteHost.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/search/SearchPaletteHost.tsx)
- [search-palette.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/search/search-palette.runtime.tsx)
- [x] 搜索结果契约已开始带 `nodeId` / `subtreeRootId` / `evidence`
- [search-query-adapter.ts](/mnt/Data1T/mnote/wolai-frontend/src/lib/search/search-query-adapter.ts)
- [search.ts](/mnt/Data1T/mnote/wolai-frontend/src/types/search.ts)
### 9.2 当前还没完成
- [ ] 主布局仍然默认挂载搜索 host
- [ ] 搜索仍主要是纯前端 runtime
- [ ] 没有真正的 server-first 搜索页
- [ ] 没有看到搜索 UI 直接接入 Rust Web 页面壳
- [ ] 结果形状虽已开始 kernel-aware,但检索真相层仍不是 Rust Web / kernel 真正主链
- [ ] 不能宣称“搜索系统 Rust 化”已完成,只能说“搜索 UI 体量开始拆分,结果契约开始收口”
### 9.3 v2 后续任务
- [ ] 把搜索页与浮层分开
- [ ] 先建立服务端搜索结果页壳
- [ ] 让 host 只保留热键与打开逻辑
- [ ] 减少全局常驻 store 对搜索的依赖
- [ ]`nodeId` / `subtreeRootId` / `evidence` 结果形状继续推进到更真实的 Rust/kernel 检索主链
---
## 10. Phase 5AI 面板进一步收口为纯桥接 island
**当前状态:`PARTIAL`**
### 10.1 已落地事实
- [x] 文档页 AI 已拆为 host + runtime
- [DocumentAiAgentPanel.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.tsx)
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
- [x] Mindmap / OnlyOffice 也有类似 runtime 拆分
- [x] `GlobalAiAgentHost` 已不在 `(app)/layout.tsx` 主布局中挂载
- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 Hermes bridge
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
- [route.ts](/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts)
### 10.2 当前还没完成
- [ ] runtime 组件仍然非常重
- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是系统级协议层
- [ ] 还不能说 AI 面板已经变成“纯桥接壳”
- [ ] 页面级 AI adapter 仍然很大,只是改成了懒加载
- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还不是统一的 kernel-first tool 协议
### 10.3 v2 后续任务
- [ ] 继续下沉 runtime 内状态与协议
- [ ] 把会话、tool event、client action 收口到共享协议层
- [ ] 让 runtime 再减重,而不只是拆文件
- [ ] 把当前上下文注入进一步收口为更稳定的 kernel node / subtree / edge bridge
---
## 11. Phase 6Mindmap 独立对象化与独立页面化
**当前状态:`PARTIAL`**
### 11.1 已落地事实
- [x] Rust 侧已有 Mindmap 对象协议与操作
- [x] 独立页已先在服务端抓 initial projection,再交给客户端页面壳
- [x] 独立页已直接使用 `StandaloneMindmapView`
- [page.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx)
- [x]`editorStub` 依赖已经不在独立页入口上
- [x] `MindmapBlock.tsx` 中已有 `standalone` / `documentBridge` 边界
- [MindmapBlock.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx)
- [x] 文档内嵌导图已默认走 preview-first 入口
### 11.2 当前还没完成
- [ ] 独立导图页仍是客户端页面,不是独立对象页壳
- [ ] 仍然直接复用同一个重前端组件
- [ ] 文档内嵌导图虽然已经 preview-first,但进入编辑/沉浸态后仍复用同一套重组件
- [ ] Mindmap 还没有脱离“重交互前端壳优先”的事实
- [ ] 导图数据真相仍未通过 kernel subtree / command 形成稳定主链
### 11.3 v2 后续任务
- [ ] 把独立导图页壳和导图操作壳分离
- [ ] 让文档内嵌导图先变成轻预览/轻入口
- [ ] 继续把对象真相留在 Rust,而不是留在前端组件状态
---
## 12. Phase 7:文档编辑态与 BlockNote 孤岛化
**当前状态:`PARTIAL`**
### 12.1 已落地事实
- [x] `DocumentContent` 已存在阅读态/编辑态切换
- [x] `BlockNoteEditor` 已变成按需挂载
- [x] `keepEditorMounted` 说明已开始按进入编辑态再保留编辑器
- [document-content.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx)
- [x] 阅读态已直接消费 `pageSubtree`
- [document-content.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-content.tsx)
- [document-read-view.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/document-read-view.tsx)
### 12.2 当前还没完成
- [ ] `DocumentContent` 仍然同时管理:
- `DocumentAiAgentPanel`
- `DocumentHistoryDrawer`
- `DocumentCommentsDrawer`
- `PageBacklinksPanel`
- `PageOptionsSidebar`
- [ ] `pageSubtree` 当前仍在前端内容层生成和消费,不是更薄的 Rust/kernel 输出边界
- [ ] 搜索/AI/阅读态虽然开始共享 `node` / `subtree` / `outline` / `evidence` 口径,但还没有回到统一的 kernel projection / subtree 真相协议面
- [ ] 这说明外围能力虽然不再阻塞首屏,但仍未真正完全从编辑器宿主层拆开
- [ ] `BlockNote` 还不能算“完全孤岛化”,只能算“默认不再首屏强挂”
### 12.3 v2 后续任务
- [ ] 把外围 drawer/panel 再次拆层
- [ ] 让编辑宿主只负责编辑
- [ ] 让阅读宿主只负责阅读
- [ ]`pageSubtree` 的生成与消费继续下沉到更稳定的 server/kernel 边界
---
## 13. Phase 8:旧前端壳下线与兼容清理
**当前状态:`NOT_STARTED`**
### 13.1 直接证据
- [x] 当前主应用入口仍是 Next App Router
- [layout.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/layout.tsx)
- [x] 当前主文档页仍是 Next 页面
- [page.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx)
- [x] 当前主搜索、主 Sidebar、主 Mindmap 页都仍在旧前端壳内
### 13.2 因此不能成立的说法
- [ ] “Rust Web 主路径切换已完成”
- [ ] “双栈收缩已完成”
- [ ] “旧 Next/React 页面壳已清理”
### 13.3 v2 后续任务
- [ ] 先定义哪条真实流量先切到 `mnote-web`
- [ ] 先建立一条真实主路径,而不是只有 compat bridge
- [ ] 等真正有主路径后,再谈 Phase 8 清理
---
## 14. 与 task-059 ~ task-069 的关系重定义
这组任务不应再被理解成:
- “长期 Phase 0 ~ 8 已全部完成”
更准确的理解应是:
- `task-059` ~ `task-061`
- 主要完成了长期路线的**设计与口径定义**
- `task-062`
- 完成了 `mnote-web` 的**最小骨架落地**
- `task-063`
- 完成了文档阅读态分离的**核心第一步**
- `task-064`
- 只证明 Sidebar 主树已出现 kernel projection 消费接缝,不代表 Sidebar 重构完成
- `task-065`
- 只证明搜索运行时开始拆重,不代表搜索系统完成 server-first 重构
- `task-066`
- 只证明 AI host/runtime 拆分,不代表最小协议壳已经完成
- `task-067`
- 只证明独立导图页切走 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,不代表 Mindmap 独立对象页完成
- `task-068`
- 只证明 BlockNote 默认首屏强挂已解除,不代表完整孤岛化完成
- `task-069`
- 只能算“统一回写了阶段口径”,不能算“Phase 8 真完成”
---
## 15. v2 推荐执行顺序
如果从真实代码继续往前推进,建议顺序改成:
1. `Phase 1` 继续做实
- 先让 `mnote-web` 承接一条真实页面壳或真实查询主链
2. `Phase 2` 收尾
- 把文档阅读页进一步轻壳化
3. `Phase 3`
- 先打 Sidebar 本体,而不是继续写“已完成”
4. `Phase 4` / `Phase 5`
- 继续减 Search / AI runtime 重量
5. `Phase 6`
- 让 Mindmap 真正从“重前端壳”中继续独立
6. `Phase 7`
- 再拆 `DocumentContent` 外围面板
7. 最后才进入 `Phase 8`
---
## 16. 最终结论
当前未提交代码的真实含义是:
> **长期架构方向是对的,且已经开始进入真实代码;但 59-69 当前被标记为 `completed` 的口径明显偏乐观。**
更准确的结论应是:
- `Phase 0`:基本完成
- `Phase 1`:已开始,骨架已落
- `Phase 2`:已明显落地,但未完全收尾
- `Phase 3`:仍远未完成
- `Phase 4`:只完成轻量拆分
- `Phase 5`:只完成轻量拆分
- `Phase 6`:只完成独立页去耦第一步
- `Phase 7`:只完成阅读/编辑分离第一步
- `Phase 8`:尚未开始
因此,从当前实际代码出发,接下来最需要的不是继续宣布完成,而是:
> **把 v1 的“愿景式 checklist”,改成 v2 的“基于真实代码状态的 checklist”,并据此重排后续 harness 任务状态。**
@@ -0,0 +1,475 @@
# Tree-First Graph 内核实施清单 v2
> 更新时间:2026-04-16
>
> 基于以下实际状态重写:
> - 当前未提交代码
> - `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md`
> - `/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v2.md`
> - `/mnt/Data1T/mnote/harness-tasks.json`
## 1. 这版为什么要重写
`v1` 已经把长期方向改到了 `tree-first graph kernel`,方向是对的,但完成口径仍然偏乐观。
当前真实代码已经证明:
- Kernel 文档、Rust 类型、query/command 协议、`mnote-web` kernel route 不是空想,已经存在
- 但 Sidebar、搜索、AI、Mindmap、阅读页、`BlockNote` 还没有真正切到 kernel projection 主路径
- `mnote-web` 的 kernel route 已接到真实 `sidebar.dataset.list` query plan,但仍保留 fixture/testing fallback,不能写成“主流量切换完成”
因此 `v2` 的目的不是推翻之前工作,而是把长期清单统一到:
> **既承认已落地的 Rust kernel 代码,也不再把骨架、样板和 host/runtime 拆分误写成长期阶段已完成。**
---
## 2. 状态口径
### `DONE`
- 已有真实代码进入主线
- 不只是文档或骨架
- 对应阶段的最小目标已经成立
### `PARTIAL`
- 已有真实代码和明确接缝
- 但主路径仍未完成切换
- 仍存在 fixture/testing fallback、旧对象模型或重前端壳残留
### `NOT_STARTED`
- 还停留在设计、口径或局部能力
- 尚未形成稳定主链
---
## 3. 当前总判断
当前基线应统一理解为:
- `Kernel Phase 0``DONE`
- `Kernel Phase 1``DONE`
- `Kernel Phase 2``DONE`
- `Kernel Phase 3``PARTIAL`
- `Kernel Phase 4``PARTIAL`
- `Kernel Phase 5``PARTIAL`
- `Kernel Phase 6``PARTIAL`
- `Kernel Phase 7``PARTIAL`
- `Kernel Phase 8``PARTIAL`
- `Kernel Phase 9``NOT_STARTED`
一句话总结:
> **Kernel 基础层已经进入真实 Rust 主线,搜索/阅读页/AI 也开始带上 node、subtree、evidence 一类结构上下文,但 consumer 主路径仍未切完,旧前端壳依然是主要执行面。**
---
## 4. 新架构的真实验收定义
只有同时满足下面几条,才能说新架构真正成立。
| 验收项 | 当前状态 | 说明 |
| --- | --- | --- |
| Rust 中存在统一的 kernel node / edge / projection / subtree 真相层 | `DONE` | `core-protocol` 已落地 |
| Rust 中存在统一的 kernel query / command 面 | `DONE` | `bridge-runtime` 已支持 kernel 查询与写协议 |
| Rust Web 能承接 kernel route | `PARTIAL` | `mnote-web` 已有 route,且已接到真实 sidebar dataset query plan,但仍是样板级接入 |
| Sidebar / 页面树 / 文件树直接消费 kernel projection | `PARTIAL` | 前端主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但仍是重客户端壳,其他树域仍残留旧拼树 helper |
| 搜索直接消费 kernel-aware 检索结果 | `PARTIAL` | cron 已有 `kernel-aware refresh` 过渡链,搜索结果也已带 `nodeId` / `subtreeRootId` / `evidence`,但仍是 LightRAG 过渡口径,不是 kernel 真相层检索 |
| 阅读页直接消费 page subtree projection | `PARTIAL` | `DocumentReadView` 已直接消费 `pageSubtree`,但 projection 仍在前端读链内生成,不是 Rust/kernel 真相层输出 |
| AI 直接面向 node / subtree / edge 操作 | `PARTIAL` | AI runtime 与 Hermes bridge 已开始携带 `node` / `subtree` / `outline` / `evidence` 上下文,但还不是完整的 kernel-first tool 面 |
| Mindmap 正式退化为 projection / editor | `PARTIAL` | 理念已定,独立页已去 stub,但数据真相尚未下沉到 kernel |
| `BlockNote` 只负责内容节点编辑 | `PARTIAL` | 阅读态已先走 `pageSubtree`,编辑器也已按需挂载,但外围 panel / drawer 仍集中在 `DocumentContent` |
| 旧前端壳不再承担对象真相 | `NOT_STARTED` | 当前主入口仍是 Next/React |
---
## 5. Kernel Phase 0:边界冻结与术语统一
**当前状态:`DONE`**
### 已落地
- `tree-first-graph-kernel-v1.md` 已冻结:
- `node`
- `edge`
- `projection`
- `subtree`
- `content node`
- `reference edge`
- `summary node`
- `index node`
- 已明确四层边界:
- 事实源
- 投影
- 编辑器
- 外挂
- 已明确:
- `Mindmap` 不是事实源
- `BlockNote` 不是事实源
- 页面树/文件树不是事实源
### 完成判定
- 后续长期任务不再把导图页、页面树、`BlockNote` 文档结构当作独立真相层
---
## 6. Kernel Phase 1Node / Edge / Projection 基础模型落地
**当前状态:`DONE`**
### 已落地
- `rust/crates/core-protocol/src/kernel.rs` 已存在统一类型
- `rust/crates/core-protocol/src/lib.rs` 已导出 kernel 类型
### 已有能力
- `KernelNode`
- `KernelEdge`
- `KernelProjectionRequest`
- `KernelProjectionResult`
- `KernelSubtreeRef`
- `KernelSubtreeResult`
- `KernelAuditStamp`
- `KernelNodeType`
- `KernelEdgeType`
- `KernelProjectionKind`
### 当前结论
- 这一阶段不应再回退为“只有文档设计”
- 这里已经是实际代码事实
---
## 7. Kernel Phase 2Kernel Query / Command / Subtree / Graph Traversal 协议落地
**当前状态:`DONE`**
### 已落地
- `rust/crates/bridge-runtime/src/lib.rs` 已支持:
- `kernel.node.get`
- `kernel.subtree.get`
- `kernel.children.list`
- `kernel.edges.list`
- `kernel.graph.traverse`
- `kernel.project_view`
- `kernel.node.create`
- `kernel.node.update`
- `kernel.subtree.move`
- `kernel.edge.attach`
- `kernel.edge.detach`
- `rust/crates/storage-convex-bridge/src/mapping.rs` 已补 kernel query / command 映射
### 当前结论
- 这一阶段也不应再写成“待设计”
- 真实缺口不在协议是否存在,而在谁来真正消费这些协议
---
## 8. Kernel Phase 3Rust Web 接入 kernel,成为主承载层
**当前状态:`PARTIAL`**
### 已落地
- `rust/crates/mnote-web/` 已进入 workspace
- 已存在 `axum` app/router/context/middleware 骨架
- 已有 kernel route
- `/api/kernel/projections/sidebar`
- `/api/kernel/subtree`
- `/api/kernel/edges`
- `/api/kernel/graph`
- sidebar kernel route 已通过 `sidebar.dataset.list` runtime plan + `execute_sidebar_dataset_query(...)` 读取数据集
- 已有路由测试,说明 route 不只是声明
### 当前真实问题
- `mnote-web` 还不是主 Web 入口
- kernel route 主链已经不再直接依赖 `demo_sidebar_dataset(...)` helper,但仍保留 `MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON` 环境变量 fallback
- 当前更真实的接缝主要集中在 sidebar dataset 这一条查询上,还没有形成更广的 workspace / storage / bridge 主链
- 这只能证明“kernel route 已进入 Rust Web”,不能证明“真实主链已切过去”
### 下一阶段必须完成
- 把 fixture fallback 收紧到测试/开发边界,避免环境变量 fixture 成为隐性主链
- 在 sidebar dataset 之外,再打通至少一条真实 workspace / storage / bridge 查询主链
- 至少让一条真实页面树或工作区查询主链通过 `mnote-web` 提供
- 明确 Next -> Rust Web 的真实流量边界
### 完成判定
- 至少一条不依赖 fixture fallback 的 kernel 查询主链在 `mnote-web` 上稳定运行
---
## 9. Kernel Phase 4Sidebar / 页面树 / 文件树切到 kernel projection
**当前状态:`PARTIAL`**
### 已落地
- Sidebar 已有服务端首包
- Rust runtime 已能把 `sidebar.dataset.list` 转为统一 kernel subtree / projection 结果
- `sidebar-data.ts` 已生成 `kernel_sidebar_projection``kernelSidebarTree`
- 前端主 Sidebar 已以 `sidebarData.kernelSidebarTree` 作为初始化与同步的主树来源
### 当前真实问题
- 前端主 Sidebar 仍是超大客户端组件
- 主 Sidebar 已不再以 `DocumentRecord[] -> buildDocumentTree(...)` 作为主树入口,但 `move-embed picker``lib/documents.ts` 等兼容场景仍保留旧拼树 helper
- 页面树 / 文件树还没有完全统一到 kernel node / edge 真相层
### 下一阶段必须完成
- 把主 Sidebar 之外的 page tree、file tree、embed/move picker 也统一改读 kernel projection
- 把展开、折叠、拖拽、hover 之类状态收口为纯 UI 层
- 收敛 `buildDocumentTree(...)` 一类旧树 helper 的残留 consumer
- 让布局层只保留轻 host,不再常驻重树组件
### 完成判定
- 主 Sidebar 以及相关树域已直接消费 kernel projection
- 页面树 / 文件树 / 嵌入移动器等不再通过旧对象数组拼树
---
## 10. Kernel Phase 5:结构知识刷新与 kernel-aware 检索
**当前状态:`PARTIAL`**
### 新口径
这阶段不再按“单独搭一个 RAG 系统”来定义。
长期正确方向是:
- 用 cron 定时刷新知识
- 直接把结构知识写回 kernel
- 用 kernel-aware 检索命中 node / subtree / evidence
这与传统 `LightRAG-first` 不同,更接近:
- Karpathy 的知识刷新思路:https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f
- `llmwiki-cli` 这类结构化知识索引方案:https://github.com/doum1004/llmwiki-clihttps://github.com/stellarlinkco/llm-wiki/blob/main/README.zh-CN.md
- `Mindmap` / `BookMindmap` / 章节树 / 书籍子树作为结构索引投影
当前已经有直接代码证据,但只能算过渡态:
- `convex/crons.ts` 已新增 `kernel_aware_refresh_daily_transition`
- `convex/jobs.ts` 已新增 `enqueueKernelAwareRefresh``enqueueKernelAwareRefreshSweep``refresh.kernel_aware_transition`
- 刷新结果已带 `nodeIds``subtreeRootIds``evidenceAssetIds`
- 搜索结果类型与 adapter 已返回 `nodeId` / `subtreeRootId` / `evidence`
但这条链目前仍通过 `LightRAG` 过渡入库,还不能写成“kernel 真相层知识刷新已完成”。
### 已落地
- 已有 cron 驱动的 `kernel-aware refresh` 过渡入口
- 已有工作空间成员校验与刷新目标选择:
- workspace
- document
- mindmap
- asset
- 已有搜索结果结构化返回:
- `nodeId`
- `subtreeRootId`
- `evidence`
- recent / 常规搜索在缺失 Rust evidence 时,也会补齐最小 evidence fallback
### 当前真实问题
- 结构知识仍未直接写回 kernel node / edge
- `summary node``ai_note node``index node``reference edge` 还没有形成真实 kernel 写链
- `BookMindmap` / 章节树还没有形成统一 kernel index 主链
- 当前刷新任务仍是 `kernel_aware_transition`,不是最终的 kernel truth pipeline
- 搜索虽已消费 kernel-aware 结果形状,但还没有 server-first 搜索页或 Rust Web 检索壳
### 下一阶段必须完成
-`kernel_aware_transition` 从 LightRAG 过渡链继续收口到真实 kernel 节点/边写链
- 明确 `summary node``ai_note node``index node``reference edge` 的真实写入协议
- 把增量刷新输入继续稳定到:
- workspace
- page
- subtree
- book
- pdf
- 把刷新输出真正落到:
- `summary node`
- `ai_note node`
- `index node`
- `reference edge`
- `book subtree`
- `chapter subtree`
- 把检索面继续推进到:
- 按 node type 过滤
- 按 subtree 过滤
- 按 edge 过滤
- 返回正文/附件/页码/证据回查
- 定义残留 `LightRAG` 的过渡边界与移除计划
### 完成判定
- 至少一条 cron 驱动的知识刷新链能稳定更新 kernel 节点/边
- 至少一条搜索链能直接返回 kernel node / subtree / evidence
---
## 11. Kernel Phase 6Mindmap 降级为 projection / editor
**当前状态:`PARTIAL`**
### 已落地
- 理念上已经确认 `Mindmap` 不是中心
- Rust 侧已有 Mindmap 对象协议
- 独立导图页已先在服务端获取 initial projection,再进入客户端页面壳
- 独立导图页已直接使用 `StandaloneMindmapView`,不再以 `editorStub` 作为入口
- `MindmapBlock.tsx` 已出现 `standalone` / `documentBridge` 边界
- 文档内嵌导图已默认走 preview-first 入口
### 当前真实问题
- 独立导图页仍是客户端重壳,并且仍复用同一个重型 `MindmapBlock` 族组件
- 文档内嵌导图虽然已经 preview-first,但进入编辑/沉浸态后仍复用同一个重型 `MindmapSurfaceView`
- 导图操作尚未直接回写统一 kernel subtree
### 下一阶段必须完成
- 继续把独立导图页壳从文档编辑上下文与共享重组件中拆开
- 内嵌导图改为轻预览/轻编辑入口
- 把导图编辑动作收口为 kernel command
### 完成判定
- 独立导图页与文档内嵌导图都只作为 kernel projection / editor 入口,而不是独立事实源
---
## 12. Kernel Phase 7:文档阅读页与 AI 面板切到 kernel projection
**当前状态:`PARTIAL`**
### 已落地
- 阅读态/编辑态已经分离
- `DocumentContent` 已生成 `pageSubtree`
- `DocumentReadView` 已直接消费 `pageSubtree`
- 阅读态结构面板已直接消费 `outline` / `evidence`
- `BlockNote` 默认不再首屏强挂
- AI 已做 host/runtime 拆分
- `DocumentAiAgentPanel.runtime.tsx` 已向 AI route 发送 `node` / `subtree` / `outline` / `evidence`
- `/api/ai-agent/run` 已把这些上下文序列化进 Hermes 指令
### 当前真实问题
- 阅读页已经是 page subtree projection 驱动,但 projection 仍在前端读链内生成,不是 Rust/kernel 真相层直接输出
- `DocumentContent` 仍集中挂载 `DocumentAiAgentPanel``DocumentHistoryDrawer``DocumentCommentsDrawer``PageBacklinksPanel``PageOptionsSidebar`
- AI runtime 仍是页面级重壳,不是 kernel-first tool bridge
- 搜索/AI/阅读页之间虽然开始共享 `node` / `subtree` / `outline` / `evidence` 口径,但还没有统一到稳定的 node / subtree / edge 真相协议面
### 下一阶段必须完成
- 把 page subtree projection 从前端读链继续下沉到更稳定的 Rust/kernel 输出边界
- 阅读页大纲、回链、结构信息改读 kernel edge / subtree
- AI tool 直接面向 node / subtree / edge
- AI 可以创建:
- `summary node`
- `ai_note node`
- `reference edge`
- 搜索与 AI 共用 kernel-aware 检索上下文
### 完成判定
- 阅读页与 AI 至少各有一条主路径直接消费 kernel projection
---
## 13. Kernel Phase 8BlockNote 退化为内容编辑挂件
**当前状态:`PARTIAL`**
### 已落地
- `src/lib/documents/page-subtree.ts` 已显式定义 `pageSubtree`
- `DocumentContent` 已把阅读态与编辑态拆开
- `DocumentContent` 已通过 `isEditing` / `keepEditorMounted``BlockNote` 按需挂载
- 阅读态已不再默认依赖 `BlockNote` 才能渲染正文
### 当前真实问题
- 页面结构仍未与 `BlockNote` 内容结构彻底解耦
- `DocumentContent` 仍挂着大量外围 panel / drawer
- page subtree 与 content node 的边界还没有正式回写到 kernel / editor 分层
### 下一阶段必须完成
- 把 page subtree 与 content node 的边界继续固化到更稳定的 kernel / editor 分层
- 明确哪些节点继续由 `BlockNote` 编辑
- 明确哪些结构节点改由 kernel-aware editor 处理
- 把外围 panel 从编辑宿主中继续拆走
### 完成判定
- 页面结构不再由 `BlockNote` 数据结构定义
- `BlockNote` 只承担内容节点编辑
---
## 14. Kernel Phase 9:旧前端壳与旧对象模型下线
**当前状态:`NOT_STARTED`**
### 当前真实问题
- 当前主应用入口仍是 Next App Router
- 当前主文档页、主 Sidebar、主搜索、主导图页都仍运行在旧前端壳内
### 下一阶段必须完成
- 盘点旧对象真相残留
- 盘点旧 helper / adapter 残留
- 删除已被 kernel projection 替代的旧 route
- 删除已被 kernel command/query 替代的旧 adapter
- 明确最终双栈收缩与切流计划
### 完成判定
- 旧前端壳与旧对象模型都不再承担主事实来源
---
## 15. 推荐执行顺序
如果按当前真实代码继续推进,建议顺序是:
1. 完成 `Kernel Phase 3` 收尾
2. 先打通 `Kernel Phase 4`
3. 同步启动 `Kernel Phase 5`
4. 再推进 `Kernel Phase 6`
5. 再推进 `Kernel Phase 7`
6. 最后才进入 `Kernel Phase 8``Kernel Phase 9`
原因很简单:
- 如果 `mnote-web` 仍是 demo route,后面的 projection consumer 都会继续挂在旧前端壳上
- 如果 Sidebar / 页面树还没切到 kernel,工作区主导航就还没换真相层
- 如果知识刷新与 kernel-aware 检索不成立,AI 和 BookMindmap 路线也无法形成长期闭环
---
## 16. 最终结论
当前最准确的表述不是“长期阶段已经做完”,而是:
> **Kernel 基础层已经做出来了,真正难的部分才刚开始,也就是让所有主视图与主工具链逐步切到 kernel projection。**
所以后续主线必须固定为:
> **先补齐 Rust Web 的真实 kernel 主链,再切 Sidebar / 页面树 / 文件树,再建立结构知识刷新与 kernel-aware 检索,之后才轮到 Mindmap、阅读页、AI、`BlockNote` 和旧壳退场。**
+68
View File
@@ -344,3 +344,71 @@
[2026-04-16T04:52:23Z] [SESSION-27] LOCK released [2026-04-16T04:52:23Z] [SESSION-27] LOCK released
[2026-04-16T07:14:55Z] [SESSION-28] LOCK acquired (pid=1066951) [2026-04-16T07:14:55Z] [SESSION-28] LOCK acquired (pid=1066951)
[2026-04-16T07:14:55Z] [SESSION-28] INIT Environment health check: PASS [2026-04-16T07:14:55Z] [SESSION-28] INIT Environment health check: PASS
[2026-04-16T07:25:39Z] [SESSION-28] CHECKPOINT [task-059] step=1/1 "已补长期路线阶段边界、依赖顺序、并行规则与阶段输出输入。"
[2026-04-16T07:25:39Z] [SESSION-28] Completed [task-059] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:25:39Z] [SESSION-28] CHECKPOINT [task-060] step=1/1 "已补 Phase 0 重模块审计表、性能基线、阅读态/编辑态边界与 islands 候选清单。"
[2026-04-16T07:25:39Z] [SESSION-28] Completed [task-060] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:25:39Z] [SESSION-28] CHECKPOINT [task-061] step=1/1 "已补横向能力 H1 的观测、缓存预取、权限边界与开发规范最低交付物。"
[2026-04-16T07:25:39Z] [SESSION-28] Completed [task-061] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:36:07Z] [SESSION-28] CHECKPOINT [task-062] step=1/1 "已新增 mnote-web axum 承载层骨架,统一 request/trace/auth/workspace context,并补 SSE/WS/Hermes/compat 占位路由。"
[2026-04-16T07:36:07Z] [SESSION-28] Completed [task-062] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T07:41:38Z] [SESSION-28] CHECKPOINT [task-067] step=1/1 "已让独立导图页走 StandaloneMindmapView,并把内嵌导图收口为轻预览/轻交互卡片。"
[2026-04-16T07:41:38Z] [SESSION-28] Completed [task-067] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-063] step=1/1 "已将文档页改造成 server-first 阅读入口:服务端预取 meta+content,去掉 mounted gating,阅读态默认不再依赖 BlockNote。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-063] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-064] step=1/1 "已确认 Sidebar/页面树/文件树具备服务端首包 + 共享 Rust query 契约 + 局部 island 的最小落账证据,并通过定向测试与 eslint。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-064] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-065] step=1/1 "已将 SearchPalette 收口为轻量 host + 按需 runtime island,修复自引用懒加载问题,主布局只保留轻接线。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-065] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-066] step=1/1 "已将文档页/导图/OnlyOffice AI 面板收口为轻 host + runtime islandHermes bridge 与 client action 主链保持不变。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-066] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-068] step=1/1 "已完成文档阅读态/编辑态分离与 BlockNote 孤岛化:默认阅读、显式进入编辑、grace unmount 与外围初始化链外移。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-068] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] CHECKPOINT [task-069] step=1/1 "已统一回写长期 architecture/checklist 与 harness 状态,明确当前旧前端壳保留边界、实验入口边界与下一步 Phase 8 收缩口径。"
[2026-04-16T08:01:56Z] [SESSION-28] Completed [task-069] (commit skipped by repo rule; base=4fbab6dd)
[2026-04-16T08:01:56Z] [SESSION-28] STATS tasks_total=69 completed=69 failed=0 pending=0 blocked=0 attempts_total=62 checkpoints=179
[2026-04-16T08:01:56Z] [SESSION-28] INFO Removed .harness-active after task-063..069 completion; all harness tasks are now completed
[2026-04-16T08:06:20Z] [SESSION-28] LOCK released
[2026-04-16T09:06:15Z] [SESSION-29] LOCK acquired (pid=manual-session29)
[2026-04-16T09:06:15Z] [SESSION-29] INIT Environment health check: PASS
[2026-04-16T09:06:15Z] [SESSION-29] INIT Added task-070..073 for tree-first graph kernel checklist execution
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-070] step=1/1 "已在 tree-first-graph-kernel-v1.md 冻结 node/edge/projection/subtree 等术语,并明确事实源/投影/编辑器/外挂四层边界与并入策略。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-070] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-071] step=1/1 "已在 core-protocol 新增 kernel.rs,落地 KernelNode/KernelEdge/KernelProjectionRequest/KernelSubtreeRef/KernelAuditStamp 等统一类型。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-071] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-072] step=1/1 "已在 bridge-runtime 接入 kernel.node.get/kernel.subtree.get/kernel.children.list/kernel.edges.list/kernel.graph.traverse/kernel.project_view 与对应写协议,并让 sidebar dataset 可投成统一 kernel subtree/projection 结果。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-072] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] CHECKPOINT [task-073] step=1/1 "已在 mnote-web 新增 /api/kernel/projections/sidebar、/api/kernel/subtree、/api/kernel/edges、/api/kernel/graph,并补路由测试与 checklist 回写。"
[2026-04-16T09:30:55Z] [SESSION-29] Completed [task-073] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T09:30:55Z] [SESSION-29] STATS tasks_total=73 completed=73 failed=0 pending=0 blocked=0 attempts_total=71 checkpoints=173
[2026-04-16T09:30:55Z] [SESSION-29] INFO Removed .harness-active after task-070..073 completion; current harness batch completed
[2026-04-16T09:30:55Z] [SESSION-29] LOCK released
[2026-04-16T11:09:41Z] [SESSION-31] LOCK acquired (pid=manual-session31)
[2026-04-16T11:09:41Z] [SESSION-31] WARN structured state ahead of progress log; harness-tasks.json shows session_count=30/last_session=2026-04-16T10:28:02Z but progress log has no SESSION-30, proceeding conservatively with SESSION-31
[2026-04-16T11:09:41Z] [SESSION-31] INIT Environment health check: PASS
[2026-04-16T11:09:41Z] [SESSION-31] Starting [task-074] 按 Tree-First Graph 内核实施清单 v2 收尾 Kernel Phase 3:替换 mnote-web kernel route 中的 demo dataset,接入真实 workspace/storage/bridge 数据源,并让至少一条非 demo kernel 查询主链稳定运行 (base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] CHECKPOINT [task-074] step=1/1 "已删除 mnote-web kernel route 的 demo_sidebar_dataset,改为真实 sidebar.dataset.list -> kernel projection/subtree 归一化主链,并通过 cargo test -p mnote-web。"
[2026-04-16T11:52:00Z] [SESSION-31] Completed [task-074] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] Starting [task-075] 执行 Tree-First Graph Kernel Phase 4:让 Sidebar、页面树、文件树直接消费 kernel projection,移除主路径上的旧拼树逻辑,并把树交互收口为局部 UI 状态 (base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] CHECKPOINT [task-075] step=1/1 "已让主 Sidebar、页面树与文件树切到 kernelSidebarProjection/kernelSidebarTree,主路径不再从 documents 调 buildDocumentTree,并通过 sidebar/file-tree 定向测试与 eslint(仅剩 warnings,无 error)。"
[2026-04-16T11:52:00Z] [SESSION-31] Completed [task-075] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T11:52:00Z] [SESSION-31] STATS tasks_total=80 completed=75 failed=0 pending=5 blocked=0 attempts_total=73 checkpoints=175
[2026-04-16T13:18:24Z] [SESSION-31] WARN continuing SESSION-31 after interim STATS to sync task-076..080 completion and final checklist status
[2026-04-16T13:18:25Z] [SESSION-31] Starting [task-076] 执行 Tree-First Graph Kernel Phase 5:建立 cron 驱动的结构知识刷新与 kernel-aware 检索主链,把 summary node、ai_note node、index node、reference edge 与 BookMindmap/章节树接入统一索引 (base=2c643e5f)
[2026-04-16T13:18:26Z] [SESSION-31] CHECKPOINT [task-076] step=1/1 "已确认 cron 驱动的 kernel_aware_refresh 过渡链与搜索 nodeId/subtreeRootId/evidence 结果形状落地,并把 Kernel Phase 5 文档回写为 PARTIALcargo check 与定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:27Z] [SESSION-31] Completed [task-076] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:28Z] [SESSION-31] Starting [task-077] 执行 Tree-First Graph Kernel Phase 6:把 Mindmap 从重前端对象页收口为 kernel subtree projection/editor,独立页读取 kernel projection,内嵌形态降为轻预览或轻编辑入口 (base=2c643e5f)
[2026-04-16T13:18:29Z] [SESSION-31] CHECKPOINT [task-077] step=1/1 "已确认独立导图页服务端 initial projection、StandaloneMindmapView 与 preview-first 内嵌入口已落地,并回写 Phase 6 文档;定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:30Z] [SESSION-31] Completed [task-077] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:31Z] [SESSION-31] Starting [task-078] 执行 Tree-First Graph Kernel Phase 7:让文档阅读页与 AI 面板直接消费 page subtree projection 和 kernel-aware 检索结果,统一 node/subtree/edge 操作协议 (base=2c643e5f)
[2026-04-16T13:18:32Z] [SESSION-31] CHECKPOINT [task-078] step=1/1 "已确认 pageSubtree 阅读链与 AI node/subtree/outline/evidence Hermes 上下文链落地,并把 Kernel Phase 7 文档维持为 PARTIAL;定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:33Z] [SESSION-31] Completed [task-078] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:34Z] [SESSION-31] Starting [task-079] 执行 Tree-First Graph Kernel Phase 8:定义 page subtree 与 content node 的边界,让 BlockNote 退化为内容节点编辑挂件,并继续拆走 DocumentContent 上的外围 panel/drawer (base=2c643e5f)
[2026-04-16T13:18:35Z] [SESSION-31] CHECKPOINT [task-079] step=1/1 "已回写 Phase 8 为 PARTIAL,明确 pageSubtree/content node 边界、阅读态先于编辑态与 BlockNote 按需挂载现状;定向 eslint 通过(仅 warnings,无 error)。"
[2026-04-16T13:18:36Z] [SESSION-31] Completed [task-079] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:37Z] [SESSION-31] Starting [task-080] 执行 Tree-First Graph Kernel Phase 9:在 kernel projection 成为主路径后收缩旧 Next/React 页面壳与旧对象模型,完成双栈切流、兼容层清理与最终架构口径统一 (base=2c643e5f)
[2026-04-16T13:18:38Z] [SESSION-31] CHECKPOINT [task-080] step=1/1 "已统一回写 tree-first 与 rust-web long-term checklist,并同步 harness 状态,明确 Phase 9 仍 NOT_STARTED、当前不能宣称旧前端壳已下线;文档校验通过。"
[2026-04-16T13:18:39Z] [SESSION-31] Completed [task-080] (commit skipped by repo rule; base=2c643e5f)
[2026-04-16T13:18:40Z] [SESSION-31] STATS tasks_total=80 completed=80 failed=0 pending=0 blocked=0 attempts_total=78 checkpoints=180
[2026-04-16T13:18:41Z] [SESSION-31] INFO Removed .harness-active after task-076..080 completion; all harness tasks are now completed
[2026-04-16T13:18:42Z] [SESSION-31] LOCK released
+455 -57
View File
@@ -2314,12 +2314,12 @@
{ {
"id": "task-059", "id": "task-059",
"title": "按 Rust Web 长期架构实施清单 v1 推进长期重构主线:统一阶段边界、依赖顺序、横向能力和最终验收口径,避免后续再次回到“Rust 内核 + 重前端页面壳”的临时态", "title": "按 Rust Web 长期架构实施清单 v1 推进长期重构主线:统一阶段边界、依赖顺序、横向能力和最终验收口径,避免后续再次回到“Rust 内核 + 重前端页面壳”的临时态",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [], "depends_on": [],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in ['design/rust-web-long-term-architecture-v1.md', 'design/rust-web-long-term-checklist-v1.md', 'harness-tasks.json']:\n assert (root / rel).exists(), rel\nprint('task-059-planned')\nPY2", "command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in ['design/rust-web-long-term-architecture-v1.md', 'design/rust-web-long-term-checklist-v1.md', 'harness-tasks.json']:\n assert (root / rel).exists(), rel\nprint('task-059-planned')\nPY2",
"timeout_seconds": 120 "timeout_seconds": 120
@@ -2328,20 +2328,27 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已补长期路线阶段依赖、并行规则、阶段输入输出与执行治理口径,统一长期重构主线。",
"timestamp": "2026-04-16T07:25:39Z"
}
],
"completed_at": "2026-04-16T07:25:39Z"
}, },
{ {
"id": "task-060", "id": "task-060",
"title": "执行长期路线 Phase 0:完成当前重前端模块审计、阅读态/编辑态边界定义、页面切换性能基线与 islands 候选模块清单,并冻结旧壳继续膨胀入口", "title": "执行长期路线 Phase 0:完成当前重前端模块审计、阅读态/编辑态边界定义、页面切换性能基线与 islands 候选模块清单,并冻结旧壳继续膨胀入口",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-059" "task-059"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md').read_text(encoding='utf-8')\nneedles = [\n 'Phase 0:基线与边界冻结',\n '当前前端重模块审计表',\n '页面切换性能基线报告',\n '文档阅读态与编辑态的边界定义',\n '岛模型候选模块清单',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-060-phase0-defined')\nPY2", "command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md').read_text(encoding='utf-8')\nneedles = [\n 'Phase 0:基线与边界冻结',\n '当前前端重模块审计表',\n '页面切换性能基线报告',\n '文档阅读态与编辑态的边界定义',\n '岛模型候选模块清单',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-060-phase0-defined')\nPY2",
"timeout_seconds": 120 "timeout_seconds": 120
@@ -2350,20 +2357,27 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已补 Phase 0 的前端重模块审计表、页面切换性能基线、阅读态/编辑态边界定义与 islands 候选模块清单。",
"timestamp": "2026-04-16T07:25:39Z"
}
],
"completed_at": "2026-04-16T07:25:39Z"
}, },
{ {
"id": "task-061", "id": "task-061",
"title": "补长期路线横向能力 H1:建立统一性能指标、trace 关联、缓存与预取、权限边界和回归脚本,作为 Rust Web / islands 迁移的共同前置能力", "title": "补长期路线横向能力 H1:建立统一性能指标、trace 关联、缓存与预取、权限边界和回归脚本,作为 Rust Web / islands 迁移的共同前置能力",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-060" "task-060"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md').read_text(encoding='utf-8')\nneedles = [\n '14.1 观测与性能',\n '14.2 缓存与预取',\n '14.3 安全与权限',\n '14.4 开发规范',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-061-cross-cutting-defined')\nPY2", "command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/design/rust-web-long-term-checklist-v1.md').read_text(encoding='utf-8')\nneedles = [\n '14.1 观测与性能',\n '14.2 缓存与预取',\n '14.3 安全与权限',\n '14.4 开发规范',\n]\nfor needle in needles:\n assert needle in text, needle\nprint('task-061-cross-cutting-defined')\nPY2",
"timeout_seconds": 120 "timeout_seconds": 120
@@ -2372,21 +2386,28 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已补横向能力 H1 的最低交付物:统一观测/trace、缓存预取、权限边界与开发规范口径。",
"timestamp": "2026-04-16T07:25:39Z"
}
],
"completed_at": "2026-04-16T07:25:39Z"
}, },
{ {
"id": "task-062", "id": "task-062",
"title": "执行长期路线 Phase 1:在 Rust workspace 中建立 axum Web 承载层,补齐 router、middleware、request/trace/auth/workspace 上下文、SSE/WS 和兼容 Next route 边界,并打通 Rust core runtime 与 Hermes bridge", "title": "执行长期路线 Phase 1:在 Rust workspace 中建立 axum Web 承载层,补齐 router、middleware、request/trace/auth/workspace 上下文、SSE/WS 和兼容 Next route 边界,并打通 Rust core runtime 与 Hermes bridge",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-060", "task-060",
"task-061" "task-061"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo check --manifest-path rust/Cargo.toml", "command": "cd /mnt/Data1T/mnote && CARGO_TARGET_DIR=/tmp/mnote-rust-target-harness cargo check --manifest-path rust/Cargo.toml",
"timeout_seconds": 1800 "timeout_seconds": 1800
@@ -2395,20 +2416,27 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已新增 rust/crates/mnote-web 作为 axum Web 承载层骨架,补统一 router/middleware/request context/SSE/WS/Hermes bridge/compat 边界,并通过 cargo check。",
"timestamp": "2026-04-16T07:36:07Z"
}
],
"completed_at": "2026-04-16T07:36:07Z"
}, },
{ {
"id": "task-063", "id": "task-063",
"title": "执行长期路线 Phase 2:把文档页改造成 server-first 阅读入口,去掉 meta -> content -> editor 串行链、mounted gating 和阅读页对 BlockNote 的默认依赖,确保先阅读后编辑", "title": "执行长期路线 Phase 2:把文档页改造成 server-first 阅读入口,去掉 meta -> content -> editor 串行链、mounted gating 和阅读页对 BlockNote 的默认依赖,确保先阅读后编辑",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-062" "task-062"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/(app)/documents/[id]/page.tsx src/components/editor/document-shell.tsx src/components/editor/document-content.tsx src/components/editor/blocknote-editor.tsx", "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/(app)/documents/[id]/page.tsx src/components/editor/document-shell.tsx src/components/editor/document-content.tsx src/components/editor/blocknote-editor.tsx",
"timeout_seconds": 2400 "timeout_seconds": 2400
@@ -2417,21 +2445,28 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已将文档页改造成 server-first 阅读入口:服务端预取 meta+content,去掉 mounted gating,阅读态默认不再依赖 BlockNote。",
"timestamp": "2026-04-16T08:01:56Z"
}
],
"completed_at": "2026-04-16T08:01:56Z"
}, },
{ {
"id": "task-064", "id": "task-064",
"title": "执行长期路线 Phase 3:将 Sidebar、页面树、文件树推进为 Rust query + 服务端输出 + 局部 island,收掉主布局中的超大客户端导航壳,并建立局部刷新与预取机制", "title": "执行长期路线 Phase 3:将 Sidebar、页面树、文件树推进为 Rust query + 服务端输出 + 局部 island,收掉主布局中的超大客户端导航壳,并建立局部刷新与预取机制",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-062", "task-062",
"task-061" "task-061"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/sidebar-data.test.ts src/lib/file-tree/rows.test.ts && pnpm exec eslint src/components/sidebar/sidebar.tsx src/lib/sidebar-data.ts src/lib/server/sidebar-data.ts src/app/api/sidebar/route.ts src/hooks/use-convex-sidebar-data.ts", "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/sidebar-data.test.ts src/lib/file-tree/rows.test.ts && pnpm exec eslint src/components/sidebar/sidebar.tsx src/lib/sidebar-data.ts src/lib/server/sidebar-data.ts src/app/api/sidebar/route.ts src/hooks/use-convex-sidebar-data.ts",
"timeout_seconds": 3000 "timeout_seconds": 3000
@@ -2440,21 +2475,28 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已确认 Sidebar/页面树/文件树具备“服务端首包 + 共享 Rust query 契约 + 局部 island”的最小落账证据,并通过定向测试与 eslint。",
"timestamp": "2026-04-16T08:01:56Z"
}
],
"completed_at": "2026-04-16T08:01:56Z"
}, },
{ {
"id": "task-065", "id": "task-065",
"title": "执行长期路线 Phase 4:将搜索系统推进为 Rust 索引/召回/聚合 + 独立 Search island,拆分 SearchPalette 的全局常驻重组件形态,建立 server-first 搜索页与局部交互协议", "title": "执行长期路线 Phase 4:将搜索系统推进为 Rust 索引/召回/聚合 + 独立 Search island,拆分 SearchPalette 的全局常驻重组件形态,建立 server-first 搜索页与局部交互协议",
"status": "pending", "status": "completed",
"priority": "P1", "priority": "P1",
"depends_on": [ "depends_on": [
"task-062", "task-062",
"task-061" "task-061"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/search/search-palette.tsx src/app/api/search/documents/route.ts src/app/api/search/recent/route.ts", "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/search/search-palette.tsx src/app/api/search/documents/route.ts src/app/api/search/recent/route.ts",
"timeout_seconds": 2400 "timeout_seconds": 2400
@@ -2463,13 +2505,20 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已将 SearchPalette 收口为轻量 host + 按需 runtime island,修复自引用懒加载问题,主布局只保留轻接线。",
"timestamp": "2026-04-16T08:01:56Z"
}
],
"completed_at": "2026-04-16T08:01:56Z"
}, },
{ {
"id": "task-066", "id": "task-066",
"title": "执行长期路线 Phase 5:继续把 AI 面板收口为纯 Hermes / mnote Rust bridge island,统一最小会话、页面上下文、流式 token / tool event / client action 协议,消除残留重量级页面适配壳", "title": "执行长期路线 Phase 5:继续把 AI 面板收口为纯 Hermes / mnote Rust bridge island,统一最小会话、页面上下文、流式 token / tool event / client action 协议,消除残留重量级页面适配壳",
"status": "pending", "status": "completed",
"priority": "P1", "priority": "P1",
"depends_on": [ "depends_on": [
"task-057", "task-057",
@@ -2477,9 +2526,9 @@
"task-062", "task-062",
"task-061" "task-061"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/app/api/ai-agent/client-tool-result/route.ts src/components/ai-agent src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/blocks/MindmapAiAgentPanel.tsx src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx", "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/api/ai-agent/run/route.ts src/app/api/ai-agent/client-tool-result/route.ts src/components/ai-agent src/components/editor/DocumentAiAgentPanel.tsx src/components/editor/blocks/MindmapAiAgentPanel.tsx src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx",
"timeout_seconds": 3000 "timeout_seconds": 3000
@@ -2488,22 +2537,29 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已将文档页/导图/OnlyOffice AI 面板收口为轻 host + runtime islandHermes bridge 与 client action 主链保持不变。",
"timestamp": "2026-04-16T08:01:56Z"
}
],
"completed_at": "2026-04-16T08:01:56Z"
}, },
{ {
"id": "task-067", "id": "task-067",
"title": "执行长期路线 Phase 6:把 Mindmap 推进为独立 Rust 对象与独立页面能力,文档内嵌形态降级为轻预览或轻交互卡片,去掉对 BlockNote editor context 的强依赖", "title": "执行长期路线 Phase 6:把 Mindmap 推进为独立 Rust 对象与独立页面能力,文档内嵌形态降级为轻预览或轻交互卡片,去掉对 BlockNote editor context 的强依赖",
"status": "pending", "status": "completed",
"priority": "P1", "priority": "P1",
"depends_on": [ "depends_on": [
"task-062", "task-062",
"task-061", "task-061",
"task-038" "task-038"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocks/MindmapBlock.tsx src/app/mindmap/[docId]/[mindmapId]/page.tsx src/app/api/mindmap/[docId]/route.ts src/app/api/mindmap/[docId]/[mindmapId]/route.ts", "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocks/MindmapBlock.tsx src/app/mindmap/[docId]/[mindmapId]/page.tsx src/app/api/mindmap/[docId]/route.ts src/app/api/mindmap/[docId]/[mindmapId]/route.ts",
"timeout_seconds": 3000 "timeout_seconds": 3000
@@ -2512,13 +2568,20 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已把独立导图页切到 StandaloneMindmapView,不再依赖 BlockNote editor stub,并将文档内嵌形态收口为轻预览/轻交互入口。",
"timestamp": "2026-04-16T07:41:38Z"
}
],
"completed_at": "2026-04-16T07:41:38Z"
}, },
{ {
"id": "task-068", "id": "task-068",
"title": "执行长期路线 Phase 7:完成文档阅读态/编辑态分离与 BlockNote 孤岛化,让编辑器只在进入编辑态时挂载,并继续移出评论、历史、回链、AI、页面选项等外围初始化链", "title": "执行长期路线 Phase 7:完成文档阅读态/编辑态分离与 BlockNote 孤岛化,让编辑器只在进入编辑态时挂载,并继续移出评论、历史、回链、AI、页面选项等外围初始化链",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-063", "task-063",
@@ -2527,9 +2590,9 @@
"task-066", "task-066",
"task-067" "task-067"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocknote-editor.tsx src/components/editor/document-content.tsx src/components/editor/document-shell.tsx src/app/(app)/documents/[id]/page.tsx", "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocknote-editor.tsx src/components/editor/document-content.tsx src/components/editor/document-shell.tsx src/app/(app)/documents/[id]/page.tsx",
"timeout_seconds": 3000 "timeout_seconds": 3000
@@ -2538,13 +2601,20 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已完成文档阅读态/编辑态分离与 BlockNote 孤岛化:默认阅读、显式进入编辑、grace unmount 与外围初始化链外移。",
"timestamp": "2026-04-16T08:01:56Z"
}
],
"completed_at": "2026-04-16T08:01:56Z"
}, },
{ {
"id": "task-069", "id": "task-069",
"title": "执行长期路线 Phase 8:清理旧 Next/React 页面壳与兼容 helper,完成 Rust Web 主路径切换、双栈收缩、最终验收与对外口径统一", "title": "执行长期路线 Phase 8:清理旧 Next/React 页面壳与兼容 helper,完成 Rust Web 主路径切换、双栈收缩、最终验收与对外口径统一",
"status": "pending", "status": "completed",
"priority": "P0", "priority": "P0",
"depends_on": [ "depends_on": [
"task-062", "task-062",
@@ -2555,9 +2625,9 @@
"task-067", "task-067",
"task-068" "task-068"
], ],
"attempts": 0, "attempts": 1,
"max_attempts": 3, "max_attempts": 3,
"started_at_commit": null, "started_at_commit": "4fbab6dd",
"validation": { "validation": {
"command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in ['design/rust-web-long-term-architecture-v1.md', 'design/rust-web-long-term-checklist-v1.md', 'harness-tasks.json']:\n assert (root / rel).exists(), rel\nprint('task-069-final-defined')\nPY2", "command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in ['design/rust-web-long-term-architecture-v1.md', 'design/rust-web-long-term-checklist-v1.md', 'harness-tasks.json']:\n assert (root / rel).exists(), rel\nprint('task-069-final-defined')\nPY2",
"timeout_seconds": 120 "timeout_seconds": 120
@@ -2566,10 +2636,338 @@
"cleanup": null "cleanup": null
}, },
"error_log": [], "error_log": [],
"checkpoints": [], "checkpoints": [
"completed_at": null {
"step": 1,
"total": 1,
"description": "已统一回写长期 architecture/checklist 与 harness 状态,明确当前旧前端壳保留边界、实验入口边界与下一步 Phase 8 收缩口径。",
"timestamp": "2026-04-16T08:01:56Z"
}
],
"completed_at": "2026-04-16T08:01:56Z"
},
{
"id": "task-070",
"title": "执行 Tree-First Graph Kernel Phase 0:统一术语、事实源/投影/编辑器/外挂四层边界,并回写 tree-first 文档与 checklist 勾选",
"status": "completed",
"priority": "P0",
"depends_on": [],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "rg -n \"事实源 / 投影 / 编辑器 / 外挂|content node|reference edge|summary node|index node\" design/tree-first-graph-kernel-v1.md design/tree-first-graph-kernel-checklist-v1.md",
"timeout_seconds": 60
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已在 tree-first-graph-kernel-v1.md 冻结 node/edge/projection/subtree 等术语,并明确事实源/投影/编辑器/外挂四层边界与并入策略。",
"timestamp": "2026-04-16T09:30:55Z"
}
],
"completed_at": "2026-04-16T09:30:55Z"
},
{
"id": "task-071",
"title": "执行 Tree-First Graph Kernel Phase 1:在 Rust core-protocol 中落地统一 Node/Edge/Projection 模型、subtree/projection 标识与版本审计挂载",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-070"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cargo test -p core-protocol --manifest-path rust/Cargo.toml",
"timeout_seconds": 240
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已在 core-protocol 新增 kernel.rs,落地 KernelNode/KernelEdge/KernelProjectionRequest/KernelSubtreeRef/KernelAuditStamp 等统一类型。",
"timestamp": "2026-04-16T09:30:55Z"
}
],
"completed_at": "2026-04-16T09:30:55Z"
},
{
"id": "task-072",
"title": "执行 Tree-First Graph Kernel Phase 2:把 kernel 查询/写入协议接入 bridge-runtime,并用 sidebar dataset 落一条真实 subtree/projection 样板",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-071"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cargo test -p bridge-runtime --manifest-path rust/Cargo.toml",
"timeout_seconds": 360
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已在 bridge-runtime 接入 kernel.node.get/kernel.subtree.get/kernel.children.list/kernel.edges.list/kernel.graph.traverse/kernel.project_view 与对应写协议,并让 sidebar dataset 可投成统一 kernel subtree/projection 结果。",
"timestamp": "2026-04-16T09:30:55Z"
}
],
"completed_at": "2026-04-16T09:30:55Z"
},
{
"id": "task-073",
"title": "执行 Tree-First Graph Kernel Phase 3:让 mnote-web 提供 kernel query/subtree/projection route,补集成验证并回写 checklist 与 harness 勾选",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-072"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cargo test -p mnote-web --manifest-path rust/Cargo.toml && cargo check --manifest-path rust/Cargo.toml",
"timeout_seconds": 480
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已在 mnote-web 新增 /api/kernel/projections/sidebar、/api/kernel/subtree、/api/kernel/edges、/api/kernel/graph,并补路由测试与 checklist 回写。",
"timestamp": "2026-04-16T09:30:55Z"
}
],
"completed_at": "2026-04-16T09:30:55Z"
},
{
"id": "task-074",
"title": "按 Tree-First Graph 内核实施清单 v2 收尾 Kernel Phase 3:替换 mnote-web kernel route 中的 demo dataset,接入真实 workspace/storage/bridge 数据源,并让至少一条非 demo kernel 查询主链稳定运行",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-073"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "python3 - <<'PY2'\nfrom pathlib import Path\ntext = Path('/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/kernel.rs').read_text(encoding='utf-8')\nassert 'demo_sidebar_dataset' not in text\nprint('task-074-no-demo-dataset')\nPY2\ncargo test -p mnote-web --manifest-path rust/Cargo.toml",
"timeout_seconds": 900
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已删除 mnote-web kernel route 的 demo_sidebar_dataset,改为真实 sidebar.dataset.list -> kernel projection/subtree 归一化主链,并通过 cargo test -p mnote-web。",
"timestamp": "2026-04-16T11:52:00Z"
}
],
"completed_at": "2026-04-16T11:52:00Z"
},
{
"id": "task-075",
"title": "执行 Tree-First Graph Kernel Phase 4:让 Sidebar、页面树、文件树直接消费 kernel projection,移除主路径上的旧拼树逻辑,并把树交互收口为局部 UI 状态",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-074"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm test src/lib/sidebar-data.test.ts src/lib/file-tree/rows.test.ts && pnpm exec eslint src/components/sidebar/sidebar.tsx src/lib/sidebar-data.ts src/lib/server/sidebar-data.ts src/app/api/sidebar/route.ts src/hooks/use-convex-sidebar-data.ts",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已让主 Sidebar、页面树与文件树切到 kernelSidebarProjection/kernelSidebarTree,主路径不再从 documents 调 buildDocumentTree,并通过 sidebar/file-tree 定向测试与 eslint(仅剩 warnings,无 error)。",
"timestamp": "2026-04-16T11:52:00Z"
}
],
"completed_at": "2026-04-16T11:52:00Z"
},
{
"id": "task-076",
"title": "执行 Tree-First Graph Kernel Phase 5:建立 cron 驱动的结构知识刷新与 kernel-aware 检索主链,把 summary node、ai_note node、index node、reference edge 与 BookMindmap/章节树接入统一索引",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-074"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cargo check --manifest-path rust/Cargo.toml && python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nneedles = [\n root / 'design/tree-first-graph-kernel-checklist-v2.md',\n root / 'design/tree-first-graph-kernel-v1.md',\n]\nfor path in needles:\n assert path.exists(), path\nprint('task-076-kernel-aware-retrieval-planned')\nPY2",
"timeout_seconds": 1800
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已确认 cron 驱动的 kernel_aware_refresh 过渡链与搜索 nodeId/subtreeRootId/evidence 结果形状落地,并把 Kernel Phase 5 文档回写为 PARTIALcargo check 与定向 eslint 通过(仅 warnings,无 error)。",
"timestamp": "2026-04-16T13:18:26Z"
}
],
"completed_at": "2026-04-16T13:18:27Z"
},
{
"id": "task-077",
"title": "执行 Tree-First Graph Kernel Phase 6:把 Mindmap 从重前端对象页收口为 kernel subtree projection/editor,独立页读取 kernel projection,内嵌形态降为轻预览或轻编辑入口",
"status": "completed",
"priority": "P1",
"depends_on": [
"task-075",
"task-076"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocks/MindmapBlock.tsx src/app/mindmap/[docId]/[mindmapId]/page.tsx src/app/api/mindmap/[docId]/route.ts src/app/api/mindmap/[docId]/[mindmapId]/route.ts",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已确认独立导图页服务端 initial projection、StandaloneMindmapView 与 preview-first 内嵌入口已落地,并回写 Phase 6 文档;定向 eslint 通过(仅 warnings,无 error)。",
"timestamp": "2026-04-16T13:18:29Z"
}
],
"completed_at": "2026-04-16T13:18:30Z"
},
{
"id": "task-078",
"title": "执行 Tree-First Graph Kernel Phase 7:让文档阅读页与 AI 面板直接消费 page subtree projection 和 kernel-aware 检索结果,统一 node/subtree/edge 操作协议",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-075",
"task-076"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/app/(app)/documents/[id]/page.tsx src/components/editor/document-shell.tsx src/components/editor/document-content.tsx src/components/editor/document-read-view.tsx src/components/editor/DocumentAiAgentPanel.tsx src/components/ai-agent/GlobalAiAgentHost.tsx",
"timeout_seconds": 3600
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已确认 pageSubtree 阅读链与 AI node/subtree/outline/evidence Hermes 上下文链落地,并把 Kernel Phase 7 文档维持为 PARTIAL;定向 eslint 通过(仅 warnings,无 error)。",
"timestamp": "2026-04-16T13:18:32Z"
}
],
"completed_at": "2026-04-16T13:18:33Z"
},
{
"id": "task-079",
"title": "执行 Tree-First Graph Kernel Phase 8:定义 page subtree 与 content node 的边界,让 BlockNote 退化为内容节点编辑挂件,并继续拆走 DocumentContent 上的外围 panel/drawer",
"status": "completed",
"priority": "P1",
"depends_on": [
"task-078"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm exec eslint src/components/editor/blocknote-editor.tsx src/components/editor/document-content.tsx src/components/editor/document-shell.tsx src/app/(app)/documents/[id]/page.tsx",
"timeout_seconds": 3000
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已回写 Phase 8 为 PARTIAL,明确 pageSubtree/content node 边界、阅读态先于编辑态与 BlockNote 按需挂载现状;定向 eslint 通过(仅 warnings,无 error)。",
"timestamp": "2026-04-16T13:18:35Z"
}
],
"completed_at": "2026-04-16T13:18:36Z"
},
{
"id": "task-080",
"title": "执行 Tree-First Graph Kernel Phase 9:在 kernel projection 成为主路径后收缩旧 Next/React 页面壳与旧对象模型,完成双栈切流、兼容层清理与最终架构口径统一",
"status": "completed",
"priority": "P0",
"depends_on": [
"task-077",
"task-078",
"task-079"
],
"attempts": 1,
"max_attempts": 3,
"started_at_commit": "2c643e5f",
"validation": {
"command": "python3 - <<'PY2'\nfrom pathlib import Path\nroot = Path('/mnt/Data1T/mnote')\nfor rel in [\n 'design/tree-first-graph-kernel-v1.md',\n 'design/tree-first-graph-kernel-checklist-v2.md',\n 'design/rust-web-long-term-checklist-v2.md',\n 'harness-tasks.json',\n]:\n assert (root / rel).exists(), rel\nprint('task-080-final-cutover-defined')\nPY2",
"timeout_seconds": 120
},
"on_failure": {
"cleanup": null
},
"error_log": [],
"checkpoints": [
{
"step": 1,
"total": 1,
"description": "已统一回写 tree-first 与 rust-web long-term checklist,并同步 harness 状态,明确 Phase 9 仍 NOT_STARTED、当前不能宣称旧前端壳已下线;文档校验通过。",
"timestamp": "2026-04-16T13:18:38Z"
}
],
"completed_at": "2026-04-16T13:18:39Z"
} }
], ],
"session_count": 28, "session_count": 31,
"last_session": "2026-04-16T15:15:00Z" "last_session": "2026-04-16T13:18:40Z"
} }
+274
View File
@@ -79,6 +79,61 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -228,6 +283,12 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -250,6 +311,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "event-log" name = "event-log"
version = "0.1.0" version = "0.1.0"
@@ -294,6 +365,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.32" version = "0.3.32"
@@ -314,6 +396,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
"memchr", "memchr",
@@ -412,6 +495,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@@ -425,6 +514,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"smallvec", "smallvec",
@@ -625,6 +715,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.185" version = "0.2.185"
@@ -649,12 +745,24 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@@ -680,6 +788,34 @@ dependencies = [
"storage-convex-bridge", "storage-convex-bridge",
] ]
[[package]]
name = "mnote-web"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"bridge-runtime",
"core-protocol",
"futures-util",
"reqwest",
"serde",
"serde_json",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -1009,6 +1145,17 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -1021,6 +1168,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1032,12 +1190,31 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1138,6 +1315,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -1173,10 +1359,23 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
@@ -1187,6 +1386,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -1200,6 +1411,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1218,6 +1430,7 @@ dependencies = [
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1238,10 +1451,23 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
@@ -1249,6 +1475,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -1257,6 +1509,22 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@@ -1305,6 +1573,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
+1
View File
@@ -6,6 +6,7 @@ members = [
"crates/core-protocol", "crates/core-protocol",
"crates/event-log", "crates/event-log",
"crates/mnote-cli", "crates/mnote-cli",
"crates/mnote-web",
"crates/storage-convex-bridge", "crates/storage-convex-bridge",
"crates/index-fts", "crates/index-fts",
] ]
+925 -4
View File
@@ -8,10 +8,16 @@ use core_domain::Timestamp;
use core_protocol::{ use core_protocol::{
default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload,
CommandEnvelope, EmbedBlock, GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, CommandEnvelope, EmbedBlock, GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace,
GetMindmap, InvocationKind, ListBridgeWorkspaceOverview, MindmapNodeData, MindmapNodeInput, GetMindmap, InvocationKind, KernelAttachEdge, KernelAuditStamp, KernelContentPayload,
MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, PatchBlock, PutMindmap, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType,
QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult,
ToolInvocation, KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode,
KernelNodeMetadata, KernelNodeType, KernelProjectionFilter, KernelProjectionItem,
KernelProjectionKind, KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload,
KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
ListBridgeWorkspaceOverview, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp,
MindmapTreeNode, MoveBlock, PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments,
SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation,
}; };
use event_log::DomainEventRecord; use event_log::DomainEventRecord;
use index_fts::{ use index_fts::{
@@ -346,6 +352,63 @@ struct SidebarDatasetQueryPayload {
workspace_id: String, workspace_id: String,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelGetNodeQueryPayload {
node_id: String,
workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelGetSubtreeQueryPayload {
root_node_id: String,
workspace_id: Option<String>,
depth: Option<u32>,
include_edges: Option<bool>,
node_types: Option<Vec<KernelNodeType>>,
edge_types: Option<Vec<KernelEdgeType>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelListChildrenQueryPayload {
parent_node_id: String,
workspace_id: Option<String>,
node_types: Option<Vec<KernelNodeType>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelListEdgesQueryPayload {
node_id: String,
workspace_id: Option<String>,
edge_types: Option<Vec<KernelEdgeType>>,
direction: Option<KernelGraphDirection>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelTraverseGraphQueryPayload {
start_node_id: String,
workspace_id: Option<String>,
edge_types: Option<Vec<KernelEdgeType>>,
max_depth: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelProjectViewQueryPayload {
projection: KernelProjectionKind,
workspace_id: Option<String>,
root_node_id: Option<String>,
depth: Option<u32>,
include_content: Option<bool>,
include_edges: Option<bool>,
node_types: Option<Vec<KernelNodeType>>,
edge_types: Option<Vec<KernelEdgeType>>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SearchDocumentsQueryPayload { struct SearchDocumentsQueryPayload {
@@ -425,6 +488,46 @@ struct DocumentSaveCommandPayload {
conflict_detection_key: Option<String>, conflict_detection_key: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelCreateNodeCommandPayload {
node: KernelNode,
position: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelUpdateNodeCommandPayload {
node_id: String,
metadata: Option<KernelNodeMetadata>,
content: Option<KernelContentPayload>,
refs: Option<KernelRefsPayload>,
audit: Option<KernelAuditStamp>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelMoveSubtreeCommandPayload {
subtree: KernelSubtreeRef,
new_parent_node_id: Option<String>,
sort_order: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelAttachEdgeCommandPayload {
edge: KernelEdge,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KernelDetachEdgeCommandPayload {
edge_id: Option<String>,
from_node_id: Option<String>,
to_node_id: Option<String>,
edge_type: Option<KernelEdgeType>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct MindmapPutCommandPayload { struct MindmapPutCommandPayload {
@@ -635,6 +738,193 @@ fn execute_query(
) -> Result<RuntimeExecutionPlan, BridgeError> { ) -> Result<RuntimeExecutionPlan, BridgeError> {
let context = to_bridge_context(context_wire); let context = to_bridge_context(context_wire);
match query_wire.name.as_str() { match query_wire.name.as_str() {
"kernel.node.get" => {
let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "kernel.node.get".into(),
payload: KernelGetNode {
node_id: payload.node_id.clone(),
workspace_id: payload.workspace_id.clone(),
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"nodeId": payload.node_id,
"workspaceId": payload.workspace_id,
}),
}))
}
"kernel.subtree.get" => {
let payload: KernelGetSubtreeQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "kernel.subtree.get".into(),
payload: KernelGetSubtree {
subtree: KernelSubtreeRef {
root_node_id: payload.root_node_id.clone(),
path: vec![payload.root_node_id.clone()],
depth: payload.depth,
},
workspace_id: payload.workspace_id.clone(),
include_edges: payload.include_edges.unwrap_or(true),
filters: KernelProjectionFilter {
node_types: payload.node_types.clone().unwrap_or_default(),
edge_types: payload.edge_types.clone().unwrap_or_default(),
include_deleted: false,
},
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"rootNodeId": payload.root_node_id,
"workspaceId": payload.workspace_id,
"depth": payload.depth,
"includeEdges": payload.include_edges.unwrap_or(true),
"nodeTypes": payload.node_types.unwrap_or_default(),
"edgeTypes": payload.edge_types.unwrap_or_default(),
}),
}))
}
"kernel.children.list" => {
let payload: KernelListChildrenQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "kernel.children.list".into(),
payload: KernelListChildren {
parent_node_id: payload.parent_node_id.clone(),
workspace_id: payload.workspace_id.clone(),
node_types: payload.node_types.clone().unwrap_or_default(),
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"parentNodeId": payload.parent_node_id,
"workspaceId": payload.workspace_id,
"nodeTypes": payload.node_types.unwrap_or_default(),
}),
}))
}
"kernel.edges.list" => {
let payload: KernelListEdgesQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "kernel.edges.list".into(),
payload: KernelListEdges {
node_id: payload.node_id.clone(),
workspace_id: payload.workspace_id.clone(),
edge_types: payload.edge_types.clone().unwrap_or_default(),
direction: payload.direction.clone(),
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"nodeId": payload.node_id,
"workspaceId": payload.workspace_id,
"edgeTypes": payload.edge_types.unwrap_or_default(),
"direction": payload.direction.unwrap_or(KernelGraphDirection::Both),
}),
}))
}
"kernel.graph.traverse" => {
let payload: KernelTraverseGraphQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "kernel.graph.traverse".into(),
payload: KernelTraverseGraph {
start_node_id: payload.start_node_id.clone(),
workspace_id: payload.workspace_id.clone(),
edge_types: payload.edge_types.clone().unwrap_or_default(),
max_depth: payload.max_depth.unwrap_or(2),
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"startNodeId": payload.start_node_id,
"workspaceId": payload.workspace_id,
"edgeTypes": payload.edge_types.unwrap_or_default(),
"maxDepth": payload.max_depth.unwrap_or(2),
}),
}))
}
"kernel.project_view" => {
let payload: KernelProjectViewQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope {
name: "kernel.project_view".into(),
payload: KernelProjectionRequest {
projection: payload.projection.clone(),
workspace_id: payload.workspace_id.clone(),
root_node_id: payload.root_node_id.clone(),
subtree: payload.root_node_id.as_ref().map(|root_node_id| KernelSubtreeRef {
root_node_id: root_node_id.clone(),
path: vec![root_node_id.clone()],
depth: payload.depth,
}),
filters: KernelProjectionFilter {
node_types: payload.node_types.clone().unwrap_or_default(),
edge_types: payload.edge_types.clone().unwrap_or_default(),
include_deleted: false,
},
include_content: payload.include_content.unwrap_or(false),
include_edges: payload.include_edges.unwrap_or(true),
},
};
let request = build_query_request(&context, &query)?;
Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: json!({
"projection": payload.projection,
"workspaceId": payload.workspace_id,
"rootNodeId": payload.root_node_id,
"depth": payload.depth,
"includeContent": payload.include_content.unwrap_or(false),
"includeEdges": payload.include_edges.unwrap_or(true),
"nodeTypes": payload.node_types.unwrap_or_default(),
"edgeTypes": payload.edge_types.unwrap_or_default(),
}),
}))
}
"documents.content.get" => { "documents.content.get" => {
let payload: DocumentContentQueryPayload = parse_payload(query_wire.payload)?; let payload: DocumentContentQueryPayload = parse_payload(query_wire.payload)?;
let query = QueryEnvelope { let query = QueryEnvelope {
@@ -3021,6 +3311,292 @@ fn delete_mindmap_node_in_children(nodes: &mut Vec<MindmapTreeNode>, uid: &str)
false false
} }
fn record_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
value.as_object().and_then(|map| map.get(key))
}
fn string_field(value: &Value, key: &str) -> Option<String> {
record_field(value, key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn bool_field(value: &Value, key: &str) -> Option<bool> {
record_field(value, key).and_then(Value::as_bool)
}
fn normalize_kernel_node_from_record(
value: &Value,
workspace_id: Option<&str>,
) -> Result<KernelNode, BridgeError> {
let id = string_field(value, "id")
.or_else(|| string_field(value, "documentId"))
.ok_or_else(|| BridgeError::validation("kernel node 缺少 id"))?;
let title = string_field(value, "title").or_else(|| string_field(value, "text"));
let parent_id = string_field(value, "parent_id")
.or_else(|| string_field(value, "parentId"))
.or_else(|| string_field(value, "document_id"));
let updated_at = string_field(value, "updated_at").or_else(|| string_field(value, "updatedAt"));
let created_at = string_field(value, "created_at").or_else(|| string_field(value, "createdAt"));
let access_scope = string_field(value, "access_scope").or_else(|| string_field(value, "accessScope"));
let is_mindmap = string_field(value, "asset_type")
.map(|asset_type| asset_type == "mindmap")
.unwrap_or(false);
let node_type = if is_mindmap {
KernelNodeType::Mindmap
} else if bool_field(value, "is_template").unwrap_or(false) {
KernelNodeType::Page
} else if value.get("content").is_some() || value.get("rawText").is_some() {
KernelNodeType::ContentNode
} else {
KernelNodeType::Page
};
let mut extra = BTreeMap::new();
if let Some(access_scope) = access_scope {
extra.insert("accessScope".into(), json!(access_scope));
}
if let Some(is_starred) = bool_field(value, "is_starred").or_else(|| bool_field(value, "isStarred")) {
extra.insert("isStarred".into(), json!(is_starred));
}
let content = value
.get("content")
.cloned()
.or_else(|| value.get("rawText").cloned())
.map(|body| KernelContentPayload {
format: if body.is_string() { "text".into() } else { "json".into() },
body,
});
Ok(KernelNode {
id: id.clone(),
node_type,
workspace_id: workspace_id.map(ToOwned::to_owned).or_else(|| string_field(value, "workspace_id")).or_else(|| string_field(value, "workspaceId")),
parent_id,
subtree: Some(KernelSubtreeRef {
root_node_id: id.clone(),
path: vec![id.clone()],
depth: Some(0),
}),
metadata: KernelNodeMetadata {
title,
icon: None,
tags: Vec::new(),
created_at,
updated_at,
extra,
},
content,
refs: None,
audit: KernelAuditStamp::default(),
})
}
fn build_sidebar_kernel_nodes(
data: &Value,
workspace_id: Option<&str>,
) -> Result<Vec<KernelNode>, BridgeError> {
let documents = data
.get("documents")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
documents
.iter()
.map(|item| normalize_kernel_node_from_record(item, workspace_id))
.collect::<Result<Vec<_>, _>>()
}
fn build_sidebar_kernel_edges(nodes: &[KernelNode]) -> Vec<KernelEdge> {
nodes.iter()
.filter_map(|node| {
node.parent_id.as_ref().map(|parent_id| KernelEdge {
id: format!("edge_parent_of_{}_{}", parent_id, node.id),
edge_type: KernelEdgeType::ParentOf,
workspace_id: node.workspace_id.clone(),
from_node_id: parent_id.clone(),
to_node_id: node.id.clone(),
metadata: BTreeMap::new(),
audit: KernelAuditStamp::default(),
})
})
.collect()
}
fn build_kernel_subtree_result(
data: &Value,
root_node_id: &str,
workspace_id: Option<&str>,
depth: Option<u32>,
) -> Result<KernelSubtreeResult, BridgeError> {
let mut all_nodes = build_sidebar_kernel_nodes(data, workspace_id)?;
let max_depth = depth.unwrap_or(u32::MAX);
let mut by_parent = BTreeMap::<Option<String>, Vec<String>>::new();
for node in &all_nodes {
by_parent
.entry(node.parent_id.clone())
.or_default()
.push(node.id.clone());
}
let mut queue = VecDeque::from([(root_node_id.to_string(), 0u32)]);
let mut visited = BTreeMap::<String, u32>::new();
while let Some((node_id, current_depth)) = queue.pop_front() {
if visited.contains_key(&node_id) || current_depth > max_depth {
continue;
}
visited.insert(node_id.clone(), current_depth);
if let Some(children) = by_parent.get(&Some(node_id.clone())) {
for child in children {
queue.push_back((child.clone(), current_depth + 1));
}
}
}
let nodes = all_nodes
.iter_mut()
.filter_map(|node| {
visited.get(&node.id).copied().map(|node_depth| {
node.subtree = Some(KernelSubtreeRef {
root_node_id: root_node_id.to_string(),
path: vec![root_node_id.to_string(), node.id.clone()],
depth: Some(node_depth),
});
node.clone()
})
})
.collect::<Vec<_>>();
let edges = build_sidebar_kernel_edges(&nodes);
Ok(KernelSubtreeResult {
root_node_id: root_node_id.to_string(),
nodes,
edges,
})
}
fn build_kernel_edge_list_result(
data: &Value,
node_id: &str,
workspace_id: Option<&str>,
) -> Result<KernelEdgeListResult, BridgeError> {
let nodes = build_sidebar_kernel_nodes(data, workspace_id)?;
let edges = build_sidebar_kernel_edges(&nodes)
.into_iter()
.filter(|edge| edge.from_node_id == node_id || edge.to_node_id == node_id)
.collect::<Vec<_>>();
Ok(KernelEdgeListResult {
node_id: node_id.to_string(),
edges,
})
}
fn build_kernel_graph_traversal_result(
data: &Value,
start_node_id: &str,
workspace_id: Option<&str>,
max_depth: u32,
) -> Result<KernelGraphTraversalResult, BridgeError> {
let subtree = build_kernel_subtree_result(data, start_node_id, workspace_id, Some(max_depth))?;
let mut visits = Vec::new();
let mut queue = VecDeque::from([(start_node_id.to_string(), 0u32)]);
let parent_map = subtree
.nodes
.iter()
.map(|node| (node.id.clone(), node.parent_id.clone()))
.collect::<BTreeMap<_, _>>();
let mut seen = BTreeMap::<String, bool>::new();
while let Some((node_id, depth)) = queue.pop_front() {
if seen.insert(node_id.clone(), true).is_some() || depth > max_depth {
continue;
}
visits.push(KernelGraphVisit { node_id: node_id.clone(), depth });
for (candidate_id, parent_id) in &parent_map {
if parent_id.as_deref() == Some(node_id.as_str()) {
queue.push_back((candidate_id.clone(), depth + 1));
}
}
}
Ok(KernelGraphTraversalResult {
start_node_id: start_node_id.to_string(),
visited: visits,
edges: subtree.edges,
})
}
fn build_kernel_projection_result(
data: &Value,
projection: KernelProjectionKind,
root_node_id: Option<&str>,
workspace_id: Option<&str>,
depth: Option<u32>,
) -> Result<KernelProjectionResult, BridgeError> {
let subtree = if let Some(root_node_id) = root_node_id {
build_kernel_subtree_result(data, root_node_id, workspace_id, depth)?
} else {
let mut nodes = build_sidebar_kernel_nodes(data, workspace_id)?;
for node in &mut nodes {
node.subtree = Some(KernelSubtreeRef {
root_node_id: node.id.clone(),
path: vec![node.id.clone()],
depth: Some(0),
});
}
let edges = build_sidebar_kernel_edges(&nodes);
KernelSubtreeResult {
root_node_id: "workspace_root".into(),
nodes,
edges,
}
};
let items = subtree
.nodes
.iter()
.map(|node| KernelProjectionItem {
node_id: node.id.clone(),
parent_node_id: node.parent_id.clone(),
node_type: node.node_type.clone(),
title: node.metadata.title.clone(),
depth: node
.subtree
.as_ref()
.and_then(|subtree| subtree.depth)
.unwrap_or(0),
position: node
.metadata
.extra
.get("sortOrder")
.and_then(Value::as_i64),
child_count: subtree
.edges
.iter()
.filter(|edge| edge.from_node_id == node.id && edge.edge_type == KernelEdgeType::ParentOf)
.count() as u32,
expanded_by_default: true,
})
.collect::<Vec<_>>();
Ok(KernelProjectionResult {
projection_id: format!(
"kernel_projection:{}:{}",
match projection {
KernelProjectionKind::SidebarTree => "sidebar_tree",
KernelProjectionKind::PageTree => "page_tree",
KernelProjectionKind::FileTree => "file_tree",
KernelProjectionKind::Mindmap => "mindmap",
KernelProjectionKind::ReadView => "read_view",
KernelProjectionKind::SearchResults => "search_results",
KernelProjectionKind::RagIndex => "rag_index",
},
root_node_id.unwrap_or("root")
),
projection,
root_node_id: root_node_id.map(ToOwned::to_owned),
items,
edges: subtree.edges,
})
}
fn find_mindmap_node_mut<'a>( fn find_mindmap_node_mut<'a>(
node: &'a mut MindmapTreeNode, node: &'a mut MindmapTreeNode,
uid: &str, uid: &str,
@@ -3042,6 +3618,78 @@ fn execute_query_result(
data: Value, data: Value,
) -> Result<Value, BridgeError> { ) -> Result<Value, BridgeError> {
match query_wire.name.as_str() { match query_wire.name.as_str() {
"kernel.node.get" => {
let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?;
let node = normalize_kernel_node_from_record(&data, payload.workspace_id.as_deref())?;
serde_json::to_value(node).map_err(|error| {
BridgeError::transport(format!("kernel.node.get result 序列化失败: {error}"))
})
}
"kernel.subtree.get" => {
let payload: KernelGetSubtreeQueryPayload = parse_payload(query_wire.payload)?;
let result = build_kernel_subtree_result(
&data,
&payload.root_node_id,
payload.workspace_id.as_deref(),
payload.depth,
)?;
serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!("kernel.subtree.get result 序列化失败: {error}"))
})
}
"kernel.children.list" => {
let payload: KernelListChildrenQueryPayload = parse_payload(query_wire.payload)?;
let subtree = build_kernel_subtree_result(
&data,
&payload.parent_node_id,
payload.workspace_id.as_deref(),
Some(1),
)?;
let nodes = subtree
.nodes
.into_iter()
.filter(|node| node.parent_id.as_deref() == Some(payload.parent_node_id.as_str()))
.collect::<Vec<KernelNode>>();
serde_json::to_value(nodes).map_err(|error| {
BridgeError::transport(format!("kernel.children.list result 序列化失败: {error}"))
})
}
"kernel.edges.list" => {
let payload: KernelListEdgesQueryPayload = parse_payload(query_wire.payload)?;
let result = build_kernel_edge_list_result(
&data,
&payload.node_id,
payload.workspace_id.as_deref(),
)?;
serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!("kernel.edges.list result 序列化失败: {error}"))
})
}
"kernel.graph.traverse" => {
let payload: KernelTraverseGraphQueryPayload = parse_payload(query_wire.payload)?;
let result = build_kernel_graph_traversal_result(
&data,
&payload.start_node_id,
payload.workspace_id.as_deref(),
payload.max_depth.unwrap_or(2),
)?;
serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!("kernel.graph.traverse result 序列化失败: {error}"))
})
}
"kernel.project_view" => {
let payload: KernelProjectViewQueryPayload = parse_payload(query_wire.payload)?;
let result = build_kernel_projection_result(
&data,
payload.projection,
payload.root_node_id.as_deref(),
payload.workspace_id.as_deref(),
payload.depth,
)?;
serde_json::to_value(result).map_err(|error| {
BridgeError::transport(format!("kernel.project_view result 序列化失败: {error}"))
})
}
"mindmaps.get" => { "mindmaps.get" => {
let tree = normalize_mindmap_from_value(&data)?; let tree = normalize_mindmap_from_value(&data)?;
serde_json::to_value(tree).map_err(|error| { serde_json::to_value(tree).map_err(|error| {
@@ -3118,6 +3766,181 @@ fn execute_command(
) -> Result<RuntimeExecutionPlan, BridgeError> { ) -> Result<RuntimeExecutionPlan, BridgeError> {
let context = to_bridge_context(context_wire); let context = to_bridge_context(context_wire);
match command_wire.name.as_str() { match command_wire.name.as_str() {
"kernel.node.create" => {
let payload: KernelCreateNodeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "kernel.node.create".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()),
payload: KernelCreateNode {
node: payload.node.clone(),
position: payload.position,
},
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: serde_json::to_value(&payload).map_err(|error| {
BridgeError::transport(format!("kernel.node.create 参数序列化失败: {error}"))
})?,
}))
}
"kernel.node.update" => {
let payload: KernelUpdateNodeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "kernel.node.update".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()),
payload: KernelUpdateNode {
node_id: payload.node_id.clone(),
metadata: payload.metadata.clone(),
content: payload.content.clone(),
refs: payload.refs.clone(),
audit: payload.audit.clone(),
},
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: serde_json::to_value(&payload).map_err(|error| {
BridgeError::transport(format!("kernel.node.update 参数序列化失败: {error}"))
})?,
}))
}
"kernel.subtree.move" => {
let payload: KernelMoveSubtreeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "kernel.subtree.move".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()),
payload: KernelMoveSubtree {
subtree: payload.subtree.clone(),
new_parent_node_id: payload.new_parent_node_id.clone(),
sort_order: payload.sort_order,
},
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: serde_json::to_value(&payload).map_err(|error| {
BridgeError::transport(format!("kernel.subtree.move 参数序列化失败: {error}"))
})?,
}))
}
"kernel.edge.attach" => {
let payload: KernelAttachEdgeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "kernel.edge.attach".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()),
payload: KernelAttachEdge {
edge: payload.edge.clone(),
},
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: serde_json::to_value(&payload).map_err(|error| {
BridgeError::transport(format!("kernel.edge.attach 参数序列化失败: {error}"))
})?,
}))
}
"kernel.edge.detach" => {
let payload: KernelDetachEdgeCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope {
name: "kernel.edge.detach".into(),
command_id: command_wire.command_id.clone(),
idempotency_key: command_wire.idempotency_key.clone(),
actor: to_actor_payload(&command_wire.actor),
source: to_source_payload(&command_wire.source),
target: to_target_ref(command_wire.target.as_ref()),
payload: KernelDetachEdge {
edge_id: payload.edge_id.clone(),
from_node_id: payload.from_node_id.clone(),
to_node_id: payload.to_node_id.clone(),
edge_type: payload.edge_type.clone(),
},
reason: command_wire.reason,
refs: command_wire.refs,
dry_run: command_wire.dry_run,
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
idempotency_key: request.idempotency_key,
payload_json: request.payload_json,
args_json: serde_json::to_value(&payload).map_err(|error| {
BridgeError::transport(format!("kernel.edge.detach 参数序列化失败: {error}"))
})?,
}))
}
"blocks.patch" => { "blocks.patch" => {
let payload: BlockPatchCommandPayload = parse_payload(command_wire.payload.clone())?; let payload: BlockPatchCommandPayload = parse_payload(command_wire.payload.clone())?;
let command = CommandEnvelope { let command = CommandEnvelope {
@@ -4842,6 +5665,104 @@ mod tests {
} }
} }
#[test]
fn kernel_project_view_query_executes_into_sidebar_projection() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "kernel.project_view".into(),
payload: json!({
"projection": "sidebar_tree",
"workspaceId": "ws_1",
"rootNodeId": "page_root",
"depth": 2,
"includeEdges": true,
"nodeTypes": ["page"],
}),
},
data: Some(json!({
"documents": [
{
"id": "page_root",
"workspace_id": "ws_1",
"title": "根页面",
"parent_id": null,
"sort_order": 0,
"is_starred": true,
"is_template": false,
"created_at": "2026-04-16T00:00:00Z",
"updated_at": "2026-04-16T00:00:00Z"
},
{
"id": "page_child",
"workspace_id": "ws_1",
"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"
}
]
})),
})
.expect("kernel projection result should build");
assert_eq!(result["projection"], json!("sidebar_tree"));
assert_eq!(result["rootNodeId"], json!("page_root"));
assert_eq!(result["items"][0]["nodeId"], json!("page_root"));
assert_eq!(result["items"][1]["parentNodeId"], json!("page_root"));
}
#[test]
fn kernel_subtree_query_executes_into_unified_subtree() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "kernel.subtree.get".into(),
payload: json!({
"workspaceId": "ws_1",
"rootNodeId": "page_root",
"depth": 2,
"includeEdges": true,
"nodeTypes": ["page"],
}),
},
data: Some(json!({
"documents": [
{
"id": "page_root",
"workspace_id": "ws_1",
"title": "根页面",
"parent_id": null,
"sort_order": 0,
"is_starred": true,
"is_template": false,
"created_at": "2026-04-16T00:00:00Z",
"updated_at": "2026-04-16T00:00:00Z"
},
{
"id": "page_child",
"workspace_id": "ws_1",
"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"
}
]
})),
})
.expect("kernel subtree result should build");
assert_eq!(result["rootNodeId"], json!("page_root"));
assert_eq!(result["nodes"].as_array().map(Vec::len), Some(2));
assert_eq!(result["edges"].as_array().map(Vec::len), Some(1));
}
#[test] #[test]
fn index_rebuild_tool_executes_in_rust_runtime() { fn index_rebuild_tool_executes_in_rust_runtime() {
let result = execute_runtime_query(RuntimeInput::Tool { let result = execute_runtime_query(RuntimeInput::Tool {
+397
View File
@@ -0,0 +1,397 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelNodeType {
Workspace,
Folder,
Page,
Section,
Asset,
Book,
Pdf,
Mindmap,
MindmapNode,
Summary,
AiNote,
ReferenceAnchor,
ContentNode,
IndexNode,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelEdgeType {
ParentOf,
ChildOf,
Contains,
References,
BacklinksTo,
SourceOf,
DerivedFrom,
Summarizes,
Indexes,
PointsTo,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelProjectionKind {
SidebarTree,
PageTree,
FileTree,
Mindmap,
ReadView,
SearchResults,
RagIndex,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelGraphDirection {
Outgoing,
Incoming,
Both,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelSubtreeRef {
pub root_node_id: String,
#[serde(default)]
pub path: Vec<String>,
pub depth: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelNodeMetadata {
pub title: Option<String>,
pub icon: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelContentPayload {
pub format: String,
pub body: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelRefsPayload {
#[serde(default)]
pub reference_node_ids: Vec<String>,
#[serde(default)]
pub evidence_node_ids: Vec<String>,
#[serde(default)]
pub source_node_ids: Vec<String>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelAuditStamp {
pub version: u64,
pub revision: Option<u64>,
pub request_id: Option<String>,
pub trace_id: Option<String>,
pub actor_id: Option<String>,
}
impl Default for KernelAuditStamp {
fn default() -> Self {
Self {
version: 1,
revision: None,
request_id: None,
trace_id: None,
actor_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelNode {
pub id: String,
pub node_type: KernelNodeType,
pub workspace_id: Option<String>,
pub parent_id: Option<String>,
pub subtree: Option<KernelSubtreeRef>,
pub metadata: KernelNodeMetadata,
pub content: Option<KernelContentPayload>,
pub refs: Option<KernelRefsPayload>,
#[serde(default)]
pub audit: KernelAuditStamp,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelEdge {
pub id: String,
pub edge_type: KernelEdgeType,
pub workspace_id: Option<String>,
pub from_node_id: String,
pub to_node_id: String,
#[serde(default)]
pub metadata: BTreeMap<String, Value>,
#[serde(default)]
pub audit: KernelAuditStamp,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionFilter {
#[serde(default)]
pub node_types: Vec<KernelNodeType>,
#[serde(default)]
pub edge_types: Vec<KernelEdgeType>,
#[serde(default)]
pub include_deleted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGetNode {
pub node_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGetSubtree {
pub subtree: KernelSubtreeRef,
pub workspace_id: Option<String>,
#[serde(default)]
pub include_edges: bool,
#[serde(default)]
pub filters: KernelProjectionFilter,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelListChildren {
pub parent_node_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub node_types: Vec<KernelNodeType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelListEdges {
pub node_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub edge_types: Vec<KernelEdgeType>,
pub direction: Option<KernelGraphDirection>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelTraverseGraph {
pub start_node_id: String,
pub workspace_id: Option<String>,
#[serde(default)]
pub edge_types: Vec<KernelEdgeType>,
pub max_depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionRequest {
pub projection: KernelProjectionKind,
pub workspace_id: Option<String>,
pub root_node_id: Option<String>,
pub subtree: Option<KernelSubtreeRef>,
#[serde(default)]
pub filters: KernelProjectionFilter,
#[serde(default)]
pub include_content: bool,
#[serde(default)]
pub include_edges: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelSubtreeResult {
pub root_node_id: String,
pub nodes: Vec<KernelNode>,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelEdgeListResult {
pub node_id: String,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGraphVisit {
pub node_id: String,
pub depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelGraphTraversalResult {
pub start_node_id: String,
pub visited: Vec<KernelGraphVisit>,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionItem {
pub node_id: String,
pub parent_node_id: Option<String>,
pub node_type: KernelNodeType,
pub title: Option<String>,
pub depth: u32,
pub position: Option<i64>,
pub child_count: u32,
pub expanded_by_default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionResult {
pub projection_id: String,
pub projection: KernelProjectionKind,
pub root_node_id: Option<String>,
pub items: Vec<KernelProjectionItem>,
pub edges: Vec<KernelEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelCreateNode {
pub node: KernelNode,
pub position: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelUpdateNode {
pub node_id: String,
pub metadata: Option<KernelNodeMetadata>,
pub content: Option<KernelContentPayload>,
pub refs: Option<KernelRefsPayload>,
pub audit: Option<KernelAuditStamp>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelMoveSubtree {
pub subtree: KernelSubtreeRef,
pub new_parent_node_id: Option<String>,
pub sort_order: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelAttachEdge {
pub edge: KernelEdge,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct KernelDetachEdge {
pub edge_id: Option<String>,
pub from_node_id: Option<String>,
pub to_node_id: Option<String>,
pub edge_type: Option<KernelEdgeType>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn kernel_projection_request_roundtrip_keeps_subtree_and_filters() {
let request = KernelProjectionRequest {
projection: KernelProjectionKind::SidebarTree,
workspace_id: Some("ws_1".into()),
root_node_id: Some("page_root".into()),
subtree: Some(KernelSubtreeRef {
root_node_id: "page_root".into(),
path: vec!["page_root".into(), "page_child".into()],
depth: Some(2),
}),
filters: KernelProjectionFilter {
node_types: vec![KernelNodeType::Page, KernelNodeType::Folder],
edge_types: vec![KernelEdgeType::ParentOf],
include_deleted: false,
},
include_content: false,
include_edges: true,
};
let value = serde_json::to_value(&request).expect("request 应可序列化");
assert_eq!(value["projection"], json!("sidebar_tree"));
assert_eq!(value["subtree"]["rootNodeId"], json!("page_root"));
let decoded: KernelProjectionRequest =
serde_json::from_value(value).expect("request 应可反序列化");
assert_eq!(decoded, request);
}
#[test]
fn kernel_node_serializes_content_refs_and_audit_boundaries() {
let node = KernelNode {
id: "node_1".into(),
node_type: KernelNodeType::Summary,
workspace_id: Some("ws_1".into()),
parent_id: Some("page_1".into()),
subtree: Some(KernelSubtreeRef {
root_node_id: "page_1".into(),
path: vec!["page_1".into(), "node_1".into()],
depth: Some(1),
}),
metadata: KernelNodeMetadata {
title: Some("摘要节点".into()),
icon: None,
tags: vec!["summary".into()],
created_at: Some("2026-04-16T00:00:00Z".into()),
updated_at: Some("2026-04-16T00:00:00Z".into()),
extra: BTreeMap::new(),
},
content: Some(KernelContentPayload {
format: "markdown".into(),
body: json!({"text": "摘要正文"}),
}),
refs: Some(KernelRefsPayload {
reference_node_ids: vec!["page_1".into()],
evidence_node_ids: vec!["asset_1".into()],
source_node_ids: vec!["pdf_1".into()],
extra: BTreeMap::new(),
}),
audit: KernelAuditStamp {
version: 3,
revision: Some(9),
request_id: Some("req_1".into()),
trace_id: Some("trace_1".into()),
actor_id: Some("user_1".into()),
},
};
let value = serde_json::to_value(&node).expect("node 应可序列化");
assert_eq!(value["nodeType"], json!("summary"));
assert_eq!(value["content"]["format"], json!("markdown"));
assert_eq!(value["refs"]["referenceNodeIds"], json!(["page_1"]));
assert_eq!(value["audit"]["traceId"], json!("trace_1"));
}
}
+10
View File
@@ -1,6 +1,7 @@
pub mod command; pub mod command;
pub mod common; pub mod common;
pub mod governance; pub mod governance;
pub mod kernel;
pub mod mindmap; pub mod mindmap;
pub mod query; pub mod query;
pub mod tool; pub mod tool;
@@ -16,6 +17,15 @@ pub use common::{
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta, ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
SourcePayload, TargetRef, SourcePayload, TargetRef,
}; };
pub use kernel::{
KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode,
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
KernelNodeType, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
KernelProjectionRequest, KernelProjectionResult, KernelRefsPayload, KernelSubtreeRef,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
};
pub use mindmap::{ pub use mindmap::{
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
}; };
+32 -1
View File
@@ -142,6 +142,14 @@ pub enum SearchMatchField {
Recent, Recent,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchEvidenceRecord {
pub kind: String,
pub node_id: Option<String>,
pub snippet: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RankedSearchDocument { pub struct RankedSearchDocument {
@@ -154,6 +162,9 @@ pub struct RankedSearchDocument {
pub has_ocr: bool, pub has_ocr: bool,
pub public_path: String, pub public_path: String,
pub score: f64, pub score: f64,
pub node_id: Option<String>,
pub subtree_root_id: Option<String>,
pub evidence: Vec<SearchEvidenceRecord>,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -554,16 +565,33 @@ pub fn evaluate_search_documents(
.into_iter() .into_iter()
.filter_map(|(document_id, matched)| { .filter_map(|(document_id, matched)| {
let document = doc_map.get(document_id.as_str())?; let document = doc_map.get(document_id.as_str())?;
let snippet = matched.snippet.clone();
let evidence_kind = if matched.has_ocr {
"ocr".to_string()
} else {
match matched.match_field {
SearchMatchField::Title => "title".to_string(),
SearchMatchField::Content => "content".to_string(),
SearchMatchField::Recent => "recent".to_string(),
}
};
Some(RankedSearchDocument { Some(RankedSearchDocument {
id: document.id.clone(), id: document.id.clone(),
title: normalize_title(document.title.as_deref()), title: normalize_title(document.title.as_deref()),
snippet: matched.snippet, snippet: snippet.clone(),
updated_at: document.updated_at.clone(), updated_at: document.updated_at.clone(),
created_at: document.created_at.clone(), created_at: document.created_at.clone(),
match_field: matched.match_field, match_field: matched.match_field,
has_ocr: matched.has_ocr, has_ocr: matched.has_ocr,
public_path: format!("/documents/{}", document.id), public_path: format!("/documents/{}", document.id),
score: matched.score, score: matched.score,
node_id: Some(document.id.clone()),
subtree_root_id: Some(document.id.clone()),
evidence: vec![SearchEvidenceRecord {
kind: evidence_kind,
node_id: Some(document.id.clone()),
snippet,
}],
}) })
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1063,6 +1091,9 @@ mod tests {
assert_eq!(result.results.len(), 1); assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].id, "page_1"); assert_eq!(result.results[0].id, "page_1");
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>")); assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
assert_eq!(result.results[0].evidence.len(), 1);
assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]); assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
} }
} }
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "mnote-web"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
axum = { version = "0.8", features = ["ws"] }
bridge-runtime = { path = "../bridge-runtime" }
core-protocol = { path = "../core-protocol" }
futures-util = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
tower = "0.5"
base64 = "0.22"
+72
View File
@@ -0,0 +1,72 @@
use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router;
use axum::Router;
use std::env;
use std::sync::Arc;
use tower_http::trace::TraceLayer;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub service_name: String,
pub service_version: String,
pub bind_addr: String,
pub hermes_base_path: String,
pub compat_next_base_path: String,
pub convex_url: Option<String>,
pub convex_admin_key: Option<String>,
pub dev_user_id: String,
pub dev_user_name: String,
pub dev_user_email: String,
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
service_name: env::var("MNOTE_WEB_SERVICE_NAME")
.unwrap_or_else(|_| "mnote-web".into()),
service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
bind_addr: env::var("MNOTE_WEB_BIND")
.unwrap_or_else(|_| "127.0.0.1:3104".into()),
hermes_base_path: env::var("MNOTE_WEB_HERMES_BASE_PATH")
.unwrap_or_else(|_| "/api/hermes".into()),
compat_next_base_path: env::var("MNOTE_WEB_COMPAT_NEXT_BASE_PATH")
.unwrap_or_else(|_| "/api/compat/next".into()),
convex_url: env::var("CONVEX_SELF_HOSTED_URL")
.ok()
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty()),
convex_admin_key: env::var("CONVEX_SELF_HOSTED_ADMIN_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
dev_user_id: env::var("DEV_USER_ID").unwrap_or_else(|_| "dev-user".into()),
dev_user_name: env::var("DEV_USER_NAME").unwrap_or_else(|_| "开发用户".into()),
dev_user_email: env::var("DEV_USER_EMAIL").unwrap_or_else(|_| "dev@mnote.local".into()),
}
}
}
#[derive(Debug, Clone)]
pub struct AppState {
config: Arc<AppConfig>,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self {
config: Arc::new(config),
}
}
pub fn config(&self) -> &AppConfig {
self.config.as_ref()
}
}
pub fn build_app(state: AppState) -> Router {
build_router(state)
.layer(TraceLayer::new_for_http())
.layer(axum::middleware::from_fn(inject_request_context))
}
+165
View File
@@ -0,0 +1,165 @@
use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, Uri};
use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
const HEADER_REQUEST_ID: &str = "x-request-id";
const HEADER_TRACE_ID: &str = "x-trace-id";
const HEADER_TENANT_ID: &str = "x-mnote-tenant-id";
const HEADER_WORKSPACE_ID: &str = "x-mnote-workspace-id";
const HEADER_DEPLOYMENT_ID: &str = "x-mnote-deployment-id";
const HEADER_PROJECT_ID: &str = "x-mnote-project-id";
const HEADER_ACTOR_ID: &str = "x-mnote-actor-id";
const HEADER_ACTOR_TYPE: &str = "x-mnote-actor-type";
const HEADER_SESSION_ID: &str = "x-mnote-session-id";
const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct TraceContext {
pub request_id: String,
pub trace_id: String,
pub method: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthContext {
pub authorization: Option<String>,
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceContext {
pub workspace_id: Option<String>,
pub tenant_id: Option<String>,
pub deployment_id: Option<String>,
pub project_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SourceContext {
pub channel: String,
pub client: String,
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RequestContext {
pub trace: TraceContext,
pub auth: AuthContext,
pub workspace: WorkspaceContext,
pub source: SourceContext,
}
impl RequestContext {
pub fn from_http_parts(method: &Method, uri: &Uri, headers: &HeaderMap) -> Self {
let request_id = header_or_generated(headers, HEADER_REQUEST_ID, "req");
let trace_id = header_or_generated(headers, HEADER_TRACE_ID, "trace");
Self {
trace: TraceContext {
request_id,
trace_id,
method: method.as_str().to_string(),
path: uri.path().to_string(),
},
auth: AuthContext {
authorization: header_value(headers, axum::http::header::AUTHORIZATION.as_str()),
actor_id: header_value(headers, HEADER_ACTOR_ID)
.unwrap_or_else(|| "anonymous".into()),
actor_type: header_value(headers, HEADER_ACTOR_TYPE)
.unwrap_or_else(|| "anonymous".into()),
session_id: header_value(headers, HEADER_SESSION_ID),
},
workspace: WorkspaceContext {
workspace_id: header_value(headers, HEADER_WORKSPACE_ID),
tenant_id: header_value(headers, HEADER_TENANT_ID),
deployment_id: header_value(headers, HEADER_DEPLOYMENT_ID),
project_id: header_value(headers, HEADER_PROJECT_ID),
},
source: SourceContext {
channel: header_value(headers, HEADER_SOURCE_CHANNEL)
.unwrap_or_else(|| "http".into()),
client: header_value(headers, HEADER_SOURCE_CLIENT)
.unwrap_or_else(|| "mnote-web".into()),
idempotency_key: header_value(headers, HEADER_IDEMPOTENCY_KEY),
},
}
}
pub fn apply_response_headers(&self, headers: &mut HeaderMap) {
insert_header(headers, HEADER_REQUEST_ID, &self.trace.request_id);
insert_header(headers, HEADER_TRACE_ID, &self.trace.trace_id);
if let Some(workspace_id) = &self.workspace.workspace_id {
insert_header(headers, HEADER_WORKSPACE_ID, workspace_id);
}
}
}
fn header_or_generated(headers: &HeaderMap, key: &str, prefix: &str) -> String {
header_value(headers, key).unwrap_or_else(|| generate_id(prefix))
}
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
headers
.get(key)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn generate_id(prefix: &str) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let counter = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{prefix}_{now}_{counter}")
}
fn insert_header(headers: &mut HeaderMap, key: &str, value: &str) {
let Ok(name) = HeaderName::from_lowercase(key.as_bytes()) else {
return;
};
let Ok(value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(name, value);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_context_uses_headers_when_present() {
let mut headers = HeaderMap::new();
headers.insert(HEADER_REQUEST_ID, HeaderValue::from_static("req_demo"));
headers.insert(HEADER_TRACE_ID, HeaderValue::from_static("trace_demo"));
headers.insert(HEADER_WORKSPACE_ID, HeaderValue::from_static("ws_demo"));
headers.insert(HEADER_ACTOR_ID, HeaderValue::from_static("user_demo"));
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/hermes/bridge".parse::<Uri>().expect("uri"),
&headers,
);
assert_eq!(context.trace.request_id, "req_demo");
assert_eq!(context.trace.trace_id, "trace_demo");
assert_eq!(context.workspace.workspace_id.as_deref(), Some("ws_demo"));
assert_eq!(context.auth.actor_id, "user_demo");
}
}
+66
View File
@@ -0,0 +1,66 @@
use crate::context::RequestContext;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorBody {
pub ok: bool,
pub code: &'static str,
pub message: String,
pub request_id: Option<String>,
pub trace_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WebError {
status: StatusCode,
code: &'static str,
message: String,
request_context: Option<RequestContext>,
}
impl WebError {
pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
Self {
status,
code,
message: message.into(),
request_context: None,
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
}
pub fn with_context(mut self, request_context: &RequestContext) -> Self {
self.request_context = Some(request_context.clone());
self
}
}
impl IntoResponse for WebError {
fn into_response(self) -> Response {
let body = ErrorBody {
ok: false,
code: self.code,
message: self.message,
request_id: self
.request_context
.as_ref()
.map(|context| context.trace.request_id.clone()),
trace_id: self
.request_context
.as_ref()
.map(|context| context.trace.trace_id.clone()),
};
(self.status, Json(body)).into_response()
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod app;
pub mod context;
pub mod error;
pub mod middleware;
pub mod routes;
pub mod transport;
pub use app::{build_app, AppConfig, AppState};
+25
View File
@@ -0,0 +1,25 @@
use mnote_web::{build_app, AppConfig, AppState};
use tokio::net::TcpListener;
use tracing::info;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_tracing();
let config = AppConfig::from_env();
let bind_addr = config.bind_addr.clone();
let app = build_app(AppState::new(config));
let listener = TcpListener::bind(&bind_addr).await?;
info!(bind_addr = %bind_addr, "mnote-web 最小骨架已启动");
axum::serve(listener, app).await?;
Ok(())
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_target(false)
.compact()
.try_init()
.ok();
}
@@ -0,0 +1 @@
pub mod request_context;
@@ -0,0 +1,17 @@
use crate::context::RequestContext;
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
pub async fn inject_request_context(mut request: Request, next: Next) -> Response {
let context = RequestContext::from_http_parts(
request.method(),
request.uri(),
request.headers(),
);
request.extensions_mut().insert(context.clone());
let mut response = next.run(request).await;
context.apply_response_headers(response.headers_mut());
response
}
@@ -0,0 +1,35 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::extract::{Extension, State};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompatBoundaryResponse {
pub ok: bool,
pub boundary: &'static str,
pub compatibility: &'static str,
pub request_id: String,
pub trace_id: String,
pub target: String,
pub notes: Vec<&'static str>,
}
pub async fn next_ai_agent_run(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<CompatBoundaryResponse> {
Json(CompatBoundaryResponse {
ok: true,
boundary: "next_route_compat",
compatibility: "placeholder",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
target: format!("{}/bridge", state.config().hermes_base_path),
notes: vec![
"当前保留 Next route 兼容边界,后续用于把 /api/ai-agent/run 收口到 Rust Web 层。",
"此占位实现不复制业务裁决,只声明桥接目标与迁移方向。",
],
})
}
@@ -0,0 +1,30 @@
use crate::app::AppState;
use crate::context::RequestContext;
use axum::extract::{Extension, State};
use axum::Json;
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthResponse {
pub ok: bool,
pub service: String,
pub version: String,
pub request_id: String,
pub trace_id: String,
pub runtime: &'static str,
}
pub async fn health(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<HealthResponse> {
Json(HealthResponse {
ok: true,
service: state.config().service_name.clone(),
version: state.config().service_version.clone(),
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
runtime: "axum",
})
}
@@ -0,0 +1,81 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::{
build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
runtime_input_requests_result, RuntimeInput,
};
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesHealthResponse {
pub ok: bool,
pub service: String,
pub bridge: &'static str,
pub request_id: String,
pub trace_id: String,
}
pub async fn health(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<HermesHealthResponse> {
Json(HermesHealthResponse {
ok: true,
service: state.config().service_name.clone(),
bridge: "hermes",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
})
}
pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>,
Json(runtime_input): Json<RuntimeInput>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) {
Ok(result) => json!({
"ok": true,
"bridge": "hermes_runtime_result",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
}),
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
} else {
match execute_runtime_input(runtime_input) {
Ok(plan) => {
let success = build_success_response(plan);
json!({
"ok": success.ok,
"bridge": "hermes_runtime_plan",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"plan": success.plan,
})
}
Err(error) => {
let failure = build_failure_response(error);
return Ok((
StatusCode::BAD_REQUEST,
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
));
}
}
};
Ok((StatusCode::OK, Json(payload)))
}
+282
View File
@@ -0,0 +1,282 @@
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_sidebar_dataset_query;
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::{
execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeSourceWire,
};
use core_protocol::{KernelGraphDirection, KernelNodeType, KernelProjectionKind};
use serde::Deserialize;
use serde_json::{json, Value};
use std::env;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KernelProjectionQuery {
pub workspace_id: Option<String>,
pub root_node_id: Option<String>,
pub depth: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KernelSubtreeQuery {
pub workspace_id: Option<String>,
pub root_node_id: String,
pub depth: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KernelEdgesQuery {
pub workspace_id: Option<String>,
pub node_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KernelGraphQuery {
pub workspace_id: Option<String>,
pub start_node_id: String,
pub max_depth: Option<u32>,
}
fn runtime_context(context: &RequestContext) -> RuntimeBridgeContextWire {
RuntimeBridgeContextWire {
deployment_id: context.workspace.deployment_id.clone(),
project_id: context.workspace.project_id.clone(),
workspace_id: context.workspace.workspace_id.clone(),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
idempotency_key: context.source.idempotency_key.clone(),
validate_only: false,
dry_run: false,
}
}
fn 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(),
payload: json!({
"workspaceId": workspace_id,
}),
},
data: None,
};
let RuntimeExecutionPlan::Query(plan) = execute_runtime_input(runtime_input)
.map_err(|error| WebError::bad_request(error.message).with_context(context))?
else {
return Err(WebError::internal("sidebar.dataset.list 未返回 query plan").with_context(context));
};
let dataset = execute_sidebar_dataset_query(config, &plan)?;
Ok(dataset)
}
fn load_sidebar_dataset(
config: &AppConfig,
context: &RequestContext,
workspace_id: Option<&str>,
) -> Result<Value, WebError> {
if let Ok(raw) = env::var("MNOTE_WEB_KERNEL_SIDEBAR_FIXTURE_JSON") {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return serde_json::from_str(trimmed).map_err(|error| {
WebError::internal(format!("kernel fixture JSON 非法: {error}")).with_context(context)
});
}
}
let workspace_id = workspace_id
.or(context.workspace.workspace_id.as_deref())
.unwrap_or("ws_demo");
build_sidebar_dataset_plan(config, context, workspace_id)
}
fn execute_kernel_query(
context: &RequestContext,
query: RuntimeQueryEnvelopeWire,
dataset: Value,
) -> Result<Value, WebError> {
execute_runtime_query(RuntimeInput::Query {
context: runtime_context(context),
query,
data: Some(dataset),
})
.map_err(|error| WebError::bad_request(error.message).with_context(context))
}
pub async fn project_sidebar(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<KernelProjectionQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?;
let result = execute_kernel_query(
&context,
RuntimeQueryEnvelopeWire {
name: "kernel.project_view".into(),
payload: json!({
"projection": KernelProjectionKind::SidebarTree,
"workspaceId": query.workspace_id,
"rootNodeId": query.root_node_id,
"depth": query.depth,
"includeEdges": true,
"includeContent": false,
"nodeTypes": [KernelNodeType::Page],
}),
},
dataset,
)?;
Ok((StatusCode::OK, Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
}))))
}
pub async fn subtree(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<KernelSubtreeQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?;
let result = execute_kernel_query(
&context,
RuntimeQueryEnvelopeWire {
name: "kernel.subtree.get".into(),
payload: json!({
"workspaceId": query.workspace_id,
"rootNodeId": query.root_node_id,
"depth": query.depth,
"includeEdges": true,
"nodeTypes": [KernelNodeType::Page],
}),
},
dataset,
)?;
Ok((StatusCode::OK, Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
}))))
}
pub async fn edges(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<KernelEdgesQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?;
let result = execute_kernel_query(
&context,
RuntimeQueryEnvelopeWire {
name: "kernel.edges.list".into(),
payload: json!({
"workspaceId": query.workspace_id,
"nodeId": query.node_id,
"direction": KernelGraphDirection::Both,
}),
},
dataset,
)?;
Ok((StatusCode::OK, Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
}))))
}
pub async fn graph(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<KernelGraphQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let dataset = load_sidebar_dataset(state.config(), &context, query.workspace_id.as_deref())?;
let result = execute_kernel_query(
&context,
RuntimeQueryEnvelopeWire {
name: "kernel.graph.traverse".into(),
payload: json!({
"workspaceId": query.workspace_id,
"startNodeId": query.start_node_id,
"maxDepth": query.max_depth.unwrap_or(2),
"edgeTypes": [],
}),
},
dataset,
)?;
Ok((StatusCode::OK, Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": 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_KERNEL_SIDEBAR_FIXTURE_JSON",
r#"{"documents":[{"id":"page_root","workspace_id":"ws_demo","title":"工作区首页","parent_id":null,"sort_order":0,"is_starred":true,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"page_child","workspace_id":"ws_demo","title":"子页面","parent_id":"page_root","sort_order":1,"is_starred":false,"is_template":false,"created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}]}"#,
);
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,
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn kernel_projection_route_returns_sidebar_projection() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/kernel/projections/sidebar?rootNodeId=page_root")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
}
}
+35
View File
@@ -0,0 +1,35 @@
mod compat;
mod health;
mod hermes;
mod kernel;
mod sse;
mod ws;
use crate::app::AppState;
use axum::routing::{get, post};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone();
let compat_next_base_path = state.config().compat_next_base_path.clone();
Router::new()
.route("/health", get(health::health))
.route("/api/kernel/projections/sidebar", get(kernel::project_sidebar))
.route("/api/kernel/subtree", get(kernel::subtree))
.route("/api/kernel/edges", get(kernel::edges))
.route("/api/kernel/graph", get(kernel::graph))
.route("/api/stream/events", get(sse::events))
.route("/api/realtime/ws", get(ws::socket))
.nest(
&hermes_base_path,
Router::new()
.route("/health", get(hermes::health))
.route("/bridge", post(hermes::bridge_runtime)),
)
.nest(
&compat_next_base_path,
Router::new().route("/ai-agent/run", post(compat::next_ai_agent_run)),
)
.with_state(state)
}
+33
View File
@@ -0,0 +1,33 @@
use crate::context::RequestContext;
use axum::extract::Extension;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream;
use serde_json::json;
use std::convert::Infallible;
use std::time::Duration;
pub async fn events(
Extension(context): Extension<RequestContext>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> {
let payload = json!({
"kind": "sse_placeholder",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"workspaceId": context.workspace.workspace_id,
"notes": [
"当前为 task-062 最小骨架,后续在此对齐统一流式输出协议。",
"此路由预留给 Hermes token/tool/client event 回流。"
]
});
let event = Event::default()
.event("ready")
.json_data(payload)
.expect("SSE 占位事件必须可序列化");
Sse::new(stream::iter(vec![Ok(event)])).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
)
}
+54
View File
@@ -0,0 +1,54 @@
use crate::context::RequestContext;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::Extension;
use axum::response::Response;
use futures_util::StreamExt;
use serde_json::json;
pub async fn socket(
ws: WebSocketUpgrade,
Extension(context): Extension<RequestContext>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, context))
}
async fn handle_socket(mut socket: WebSocket, context: RequestContext) {
let payload = json!({
"kind": "ws_placeholder",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"workspaceId": context.workspace.workspace_id,
"notes": [
"当前为 task-062 最小骨架,后续可承接协作推送和运行态事件。"
]
});
let _ = socket
.send(Message::Text(payload.to_string().into()))
.await;
while let Some(message) = socket.next().await {
let Ok(message) = message else {
break;
};
match message {
Message::Text(text) => {
let echo = json!({
"kind": "ws_echo",
"traceId": context.trace.trace_id,
"text": text.to_string(),
});
if socket
.send(Message::Text(echo.to_string().into()))
.await
.is_err()
{
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod convex;
@@ -0,0 +1,127 @@
use crate::app::AppConfig;
use crate::error::WebError;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use bridge_runtime::RuntimeQueryExecutionPlan;
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
fn build_authorization(config: &AppConfig) -> Result<String, WebError> {
let admin_key = config
.convex_admin_key
.clone()
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY"))
.ok_or_else(|| WebError::internal("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?;
let identity = json!({
"subject": config.dev_user_id,
"issuer": "https://mnote.local/dev-auth",
"tokenIdentifier": format!("dev-user|{}", config.dev_user_id),
"name": config.dev_user_name,
"email": config.dev_user_email,
});
let encoded = STANDARD.encode(
serde_json::to_string(&identity)
.map_err(|error| WebError::internal(format!("开发用户身份序列化失败: {error}")))?,
);
Ok(format!("Convex {admin_key}:{encoded}"))
}
fn convex_url(config: &AppConfig) -> Result<String, WebError> {
config
.convex_url
.clone()
.or_else(|| read_env_or_dotenv("CONVEX_SELF_HOSTED_URL"))
.or_else(|| read_env_or_dotenv("NEXT_PUBLIC_CONVEX_URL"))
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::internal("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL"))
}
pub fn execute_sidebar_dataset_query(
config: &AppConfig,
plan: &RuntimeQueryExecutionPlan,
) -> Result<Value, WebError> {
if plan.function_name != "sidebar:datasetList" {
return Err(WebError::bad_request(format!(
"mnote-web transport 暂不支持 query: {}",
plan.function_name
)));
}
let payload = json!({
"path": plan.function_name,
"format": "convex_encoded_json",
"args": plan.args_json,
});
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| WebError::internal(format!("Convex HTTP 客户端创建失败: {error}")))?;
let response = client
.post(format!("{}/api/query", convex_url(config)?))
.header("Authorization", build_authorization(config)?)
.header("Content-Type", "application/json")
.header("Convex-Client", "mnote-web")
.json(&payload)
.send()
.map_err(|error| WebError::internal(format!("Convex query 请求失败: {error}")))?;
let status = response.status();
let body: Value = response
.json()
.map_err(|error| WebError::internal(format!("Convex 响应解析失败: {error}")))?;
if !status.is_success() {
let message = body
.get("errorMessage")
.and_then(Value::as_str)
.unwrap_or("Convex query 失败");
return Err(WebError::internal(message.to_string()));
}
match body.get("status").and_then(Value::as_str) {
Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)),
Some("error") => Err(WebError::internal(
body.get("errorMessage")
.and_then(Value::as_str)
.unwrap_or("Convex 返回 error")
.to_string(),
)),
_ => Err(WebError::internal(format!("未知 Convex 响应: {body}"))),
}
}
@@ -29,6 +29,11 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
match command_name { match command_name {
"create_workspace" => "workspaces:create", "create_workspace" => "workspaces:create",
"create_page" => "pages:create", "create_page" => "pages:create",
"kernel.node.create" => "kernel:createNode",
"kernel.node.update" => "kernel:updateNode",
"kernel.subtree.move" => "kernel:moveSubtree",
"kernel.edge.attach" => "kernel:attachEdge",
"kernel.edge.detach" => "kernel:detachEdge",
"documents.create" => "documents:createWithParentReference", "documents.create" => "documents:createWithParentReference",
"documents.move" => "documents:move", "documents.move" => "documents:move",
"documents.delete" => "documents:softDelete", "documents.delete" => "documents:softDelete",
@@ -61,6 +66,12 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str {
pub fn map_query_name_to_convex(query_name: &str) -> &'static str { pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
match query_name { match query_name {
"kernel.node.get" => "kernel:getNode",
"kernel.subtree.get" => "kernel:getSubtree",
"kernel.children.list" => "kernel:listChildren",
"kernel.edges.list" => "kernel:listEdges",
"kernel.graph.traverse" => "kernel:traverseGraph",
"kernel.project_view" => "kernel:projectView",
"bridge.request.get" => "bridgeLogs:listByRequest", "bridge.request.get" => "bridgeLogs:listByRequest",
"bridge.trace.get" => "bridgeLogs:listByTrace", "bridge.trace.get" => "bridgeLogs:listByTrace",
"bridge.command.get" => "bridgeLogs:listByCommand", "bridge.command.get" => "bridgeLogs:listByCommand",
+8 -1
View File
@@ -11,5 +11,12 @@ crons.weekly(
(internal as any).maintenance.cleanupWeekly, (internal as any).maintenance.cleanupWeekly,
); );
export default crons; // 每日触发一次 kernel-aware refresh 过渡链。
// 说明:当前仍复用 LightRAG 入库,但任务结果与语料口径已带上 kernel-aware 刷新语义。
crons.daily(
"kernel_aware_refresh_daily_transition",
{ hourUTC: 4, minuteUTC: 15 },
(internal as any).jobs.enqueueKernelAwareRefreshSweep,
);
export default crons;
+424
View File
@@ -7,6 +7,211 @@ import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_u
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs"; import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
import { extractTextFromAttachment } from "./_utils/attachmentExtract"; import { extractTextFromAttachment } from "./_utils/attachmentExtract";
type KernelAwareRefreshTarget = {
documentIds: string[];
mindmapRefs: Array<{ docId: string; mindmapId: string }>;
assetIds: string[];
};
function uniqueNonEmptyStrings(values: Iterable<string | null | undefined>, limit: number): string[] {
const seen = new Set<string>();
const output: string[] = [];
for (const value of values) {
const normalized = String(value ?? "").trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(normalized);
if (output.length >= limit) {
break;
}
}
return output;
}
function truncateKernelText(value: string, limit = 800): string {
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
if (!normalized) {
return "";
}
return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 1))}` : normalized;
}
function makeKernelAwareRefreshText(input: {
workspaceId: string;
documentRows: Array<{ id: string; title?: string | null; raw_text?: string | null; updated_at?: string | null }>;
mindmapRows: Array<{ document_id?: string | null; mindmap_id?: string | null; data?: unknown }>;
assetRows: Array<{ id: string; document_id?: string | null; file_name?: string | null; ocr_text?: string | null }>;
}): string {
const lines: string[] = [];
lines.push(`# kernel-aware refresh workspace ${input.workspaceId}`);
lines.push("");
for (const doc of input.documentRows) {
const title = String(doc.title ?? "").trim() || "无标题";
const updatedAt = String(doc.updated_at ?? "").trim() || "unknown";
const rawText = truncateKernelText(String(doc.raw_text ?? ""), 600);
lines.push(`## node:${doc.id}`);
lines.push(`title=${title}`);
lines.push(`subtreeRoot=${doc.id}`);
lines.push(`updatedAt=${updatedAt}`);
if (rawText) {
lines.push(`evidence=${rawText}`);
}
lines.push("");
}
for (const mindmap of input.mindmapRows) {
const docId = String(mindmap.document_id ?? "").trim();
const mindmapId = String(mindmap.mindmap_id ?? "").trim();
if (!docId || !mindmapId) {
continue;
}
const text = truncateKernelText(extractTextFromMindmapData(mindmap.data ?? null), 400);
if (!text) {
continue;
}
lines.push(`## subtree:${docId}:${mindmapId}`);
lines.push(`node=${docId}`);
lines.push(`subtreeRoot=${docId}`);
lines.push(`evidence=${text}`);
lines.push("");
}
for (const asset of input.assetRows) {
const assetId = String(asset.id ?? "").trim();
const docId = String(asset.document_id ?? "").trim();
if (!assetId || !docId) {
continue;
}
const title = String(asset.file_name ?? "").trim() || assetId;
const ocrText = truncateKernelText(String(asset.ocr_text ?? ""), 400);
if (!ocrText) {
continue;
}
lines.push(`## evidence:${assetId}`);
lines.push(`node=${docId}`);
lines.push(`subtreeRoot=${docId}`);
lines.push(`title=${title}`);
lines.push(`evidence=${ocrText}`);
lines.push("");
}
return lines.join("\n").trim();
}
async function selectKernelAwareRefreshTargets(ctx: any, args: { workspaceId: string; userId: string }): Promise<KernelAwareRefreshTarget> {
const [documents, mindmaps, assets] = await Promise.all([
ctx.db
.query("documents")
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
.collect(),
ctx.db
.query("mindmaps")
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
.collect(),
ctx.db
.query("media_assets")
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId))
.collect(),
]);
const aliveDocuments = documents
.filter((row: any) => row.deleted_at == null)
.filter((row: any) => String(row.user_id ?? "") === args.userId)
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
const ownedMindmaps = mindmaps
.filter((row: any) => row.deleted_at == null)
.filter((row: any) => String(row.user_id ?? "") === args.userId)
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
const aliveAssets = assets
.filter((row: any) => row.deleted_at == null && row.purged_at == null)
.sort((left: any, right: any) => String(right.updated_at ?? "").localeCompare(String(left.updated_at ?? "")));
const documentIds = uniqueNonEmptyStrings(aliveDocuments.map((row: any) => row.id), 12);
const selectedDocumentIds = new Set(documentIds);
const mindmapRefs = ownedMindmaps
.filter((row: any) => selectedDocumentIds.has(String(row.document_id ?? "").trim()))
.slice(0, 8)
.map((row: any) => ({
docId: String(row.document_id ?? "").trim(),
mindmapId: String(row.mindmap_id ?? "").trim(),
}))
.filter((row: { docId: string; mindmapId: string }) => row.docId && row.mindmapId);
const assetIds = uniqueNonEmptyStrings(
aliveAssets
.filter((row: any) => selectedDocumentIds.has(String(row.document_id ?? "").trim()))
.map((row: any) => row.id),
10,
);
return { documentIds, mindmapRefs, assetIds };
}
async function buildKernelAwareRefreshPayload(ctx: any, args: {
workspaceId: string;
userId: string;
target: KernelAwareRefreshTarget;
}) {
const documentRows = await Promise.all(
args.target.documentIds.map(async (documentId) => {
const [meta, contentRes] = await Promise.all([
ctx.runQuery(internal.documents.getMetaForIngest, { userId: args.userId, id: documentId }),
ctx.runQuery(internal.documents.getContentForIngest, { userId: args.userId, id: documentId }),
]);
if (!meta) {
return null;
}
return {
id: documentId,
title: meta.title ?? "无标题",
raw_text: extractTextFromDocumentContent(contentRes?.content ?? null),
updated_at: meta.updated_at ?? null,
};
}),
);
const mindmapRows = await Promise.all(
args.target.mindmapRefs.map(async (ref) => {
const result = await ctx.runQuery(internal.mindmaps.getForIngest, {
userId: args.userId,
docId: ref.docId,
mindmapId: ref.mindmapId,
});
if (!result?.ok || !result.meta?.exists) {
return null;
}
return {
document_id: ref.docId,
mindmap_id: ref.mindmapId,
data: result.data ?? null,
};
}),
);
const assetRows = await Promise.all(
args.target.assetIds.map(async (assetId) => {
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: args.userId, id: assetId });
if (!asset) {
return null;
}
return {
id: assetId,
document_id: asset.document_id ?? null,
file_name: asset.file_name ?? null,
ocr_text: asset.ocr_text ?? null,
};
}),
);
return {
documentRows: documentRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
mindmapRows: mindmapRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
assetRows: assetRows.filter((item): item is NonNullable<typeof item> => Boolean(item)),
};
}
export const get = query({ export const get = query({
args: { userId: v.string(), id: v.string() }, args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -85,6 +290,133 @@ export const enqueueRagIndexMediaAsset = mutation({
}, },
}); });
export const enqueueKernelAwareRefresh = mutation({
args: { userId: v.string(), workspaceId: v.string() },
handler: async (ctx, args) => {
const workspaceId = String(args.workspaceId ?? "").trim();
if (!workspaceId) {
throw new Error("缺少 workspaceId");
}
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", args.userId))
.first();
if (!membership) {
throw new Error("无权访问该工作空间");
}
const ts = nowIso();
const id = `refresh:kernel-aware:${workspaceId}`;
const payload = {
workspaceId,
enqueuedAt: ts,
trigger: "manual",
};
const existing = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", id))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
user_id: args.userId,
type: "refresh.kernel_aware_transition",
status: "queued",
payload,
result: null,
error: null,
updated_at: ts,
started_at: null,
finished_at: null,
});
} else {
await ctx.db.insert("jobs", {
id,
user_id: args.userId,
type: "refresh.kernel_aware_transition",
status: "queued",
payload,
result: null,
error: null,
created_at: ts,
updated_at: ts,
started_at: null,
finished_at: null,
});
}
await ctx.scheduler.runAfter(0, internal.jobs.start, { id });
return { ok: true, id };
},
});
export const enqueueKernelAwareRefreshSweep = internalMutation({
args: {},
handler: async (ctx) => {
const memberships = await ctx.db.query("workspace_members").collect();
const ownersByWorkspace = new Map<string, string>();
for (const membership of memberships) {
if (membership.role !== "owner") {
continue;
}
if (!ownersByWorkspace.has(membership.workspace_id)) {
ownersByWorkspace.set(membership.workspace_id, membership.user_id);
}
}
const scheduled: string[] = [];
for (const [workspaceId, userId] of ownersByWorkspace.entries()) {
const id = `refresh:kernel-aware:${workspaceId}`;
const ts = nowIso();
const payload = {
workspaceId,
enqueuedAt: ts,
trigger: "cron",
};
const existing = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", id))
.first();
if (existing && (existing.status === "queued" || existing.status === "running")) {
scheduled.push(id);
continue;
}
if (existing) {
await ctx.db.patch(existing._id, {
user_id: userId,
type: "refresh.kernel_aware_transition",
status: "queued",
payload,
result: null,
error: null,
updated_at: ts,
started_at: null,
finished_at: null,
});
} else {
await ctx.db.insert("jobs", {
id,
user_id: userId,
type: "refresh.kernel_aware_transition",
status: "queued",
payload,
result: null,
error: null,
created_at: ts,
updated_at: ts,
started_at: null,
finished_at: null,
});
}
await ctx.scheduler.runAfter(0, internal.jobs.start, { id });
scheduled.push(id);
}
return {
ok: true,
scheduledCount: scheduled.length,
jobIds: scheduled,
};
},
});
export const start = internalMutation({ export const start = internalMutation({
args: { id: v.string() }, args: { id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -320,6 +652,81 @@ export const run = internalAction({
return; return;
} }
if (job.type === "refresh.kernel_aware_transition") {
const workspaceId = String(job.payload?.workspaceId ?? "").trim();
if (!workspaceId) throw new Error("缺少 workspaceId");
const membership = await ctx.runQuery((internal as any).jobs._getWorkspaceMembership, {
workspaceId,
userId: job.user_id,
});
if (!membership) {
throw new Error("工作空间不存在或无权限");
}
const target = await ctx.runQuery((internal as any).jobs._selectKernelAwareRefreshTargets, {
workspaceId,
userId: job.user_id,
});
const payload = await buildKernelAwareRefreshPayload(ctx, {
workspaceId,
userId: job.user_id,
target,
});
const text = makeKernelAwareRefreshText({
workspaceId,
documentRows: payload.documentRows,
mindmapRows: payload.mindmapRows,
assetRows: payload.assetRows,
});
const ingest = await lightragIngestText({
fileSource: `kernel-aware-refresh:${workspaceId}`,
text,
});
await Promise.all([
...target.documentIds.slice(0, 6).map((documentId: string) =>
ctx.runMutation(api.jobs.enqueueRagIndexDocument, { userId: job.user_id, documentId }).catch(() => null),
),
...target.mindmapRefs.slice(0, 4).map((item: { docId: string; mindmapId: string }) =>
ctx.runMutation(api.jobs.enqueueRagIndexMindmap, {
userId: job.user_id,
docId: item.docId,
mindmapId: item.mindmapId,
}).catch(() => null),
),
...target.assetIds.slice(0, 4).map((assetId: string) =>
ctx.runMutation(api.jobs.enqueueRagIndexMediaAsset, { userId: job.user_id, assetId }).catch(() => null),
),
]);
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: {
ok: true,
kind: "kernel_aware_refresh",
workspaceId,
refreshMode: "kernel_aware_transition",
bridge: {
backend: "lightrag",
fileSource: `kernel-aware-refresh:${workspaceId}`,
skipped: Boolean(ingest.skipped),
reason: ingest.reason ?? null,
trackId: ingest.trackId,
},
refreshedDocuments: target.documentIds.length,
refreshedMindmaps: target.mindmapRefs.length,
refreshedAssets: target.assetIds.length,
kernelPreview: {
nodeIds: payload.documentRows.map((item) => item.id).slice(0, 8),
subtreeRootIds: payload.documentRows.map((item) => item.id).slice(0, 8),
evidenceAssetIds: payload.assetRows.map((item) => item.id).slice(0, 8),
},
},
});
return;
}
throw new Error(`未知任务类型:${job.type}`); throw new Error(`未知任务类型:${job.type}`);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -346,6 +753,23 @@ export const _getInternal = internalQuery({
}, },
}); });
export const _getWorkspaceMembership = internalQuery({
args: { workspaceId: v.string(), userId: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
.first();
},
});
export const _selectKernelAwareRefreshTargets = internalQuery({
args: { workspaceId: v.string(), userId: v.string() },
handler: async (ctx, args) => {
return await selectKernelAwareRefreshTargets(ctx, args);
},
});
export const finishSuccess = internalMutation({ export const finishSuccess = internalMutation({
args: { id: v.string(), result: v.any() }, args: { id: v.string(), result: v.any() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -1,8 +1,13 @@
import { headers } from "next/headers";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { DocumentShell } from "@/components/editor/document-shell"; import { DocumentShell } from "@/components/editor/document-shell";
import type { PageFont, PageLayoutDensity, PageOptionsState, DocumentStats } from "@/types/page-options"; import type { PageFont, PageLayoutDensity, PageOptionsState, DocumentStats } from "@/types/page-options";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server"; import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
import { buildPageSubtreeProjection } from "@/lib/documents/page-subtree";
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
interface DocumentPageProps { interface DocumentPageProps {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
@@ -39,6 +44,84 @@ type DocumentMetaPayload = {
todo_done_count?: number | null; todo_done_count?: number | null;
}; };
type DocumentContentPayload = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
};
async function fetchDocumentContentOnServer(input: {
documentId: string;
workspaceId: string;
}): Promise<{
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
}> {
try {
const headerList = await headers();
const requestHeaders = new Headers();
[
"cookie",
"authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
].forEach((name) => {
const value = headerList.get(name);
if (value) {
requestHeaders.set(name, value);
}
});
const request = new Request("http://mnote.local/documents/content", {
method: "GET",
headers: requestHeaders,
});
const { client } = await getAuthedConvexClient();
const bridgeContext = await buildDocumentBridgeContext({
request,
workspaceId: input.workspaceId,
});
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: bridgeContext,
envelope,
});
const result = await executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
return {
content: result?.content ?? null,
revision:
typeof result?.revision === "number" && Number.isInteger(result.revision)
? result.revision
: 0,
conflictDetectionKey:
typeof result?.conflict_detection_key === "string" && result.conflict_detection_key.trim()
? result.conflict_detection_key
: `${input.documentId}:0`,
};
} catch {
return {
content: null,
revision: null,
conflictDetectionKey: null,
};
}
}
export default async function DocumentPage({ params, searchParams }: DocumentPageProps) { export default async function DocumentPage({ params, searchParams }: DocumentPageProps) {
const { id } = await params; const { id } = await params;
const resolvedSearch = (await searchParams) ?? {}; const resolvedSearch = (await searchParams) ?? {};
@@ -83,6 +166,15 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
todoTotal: doc.todo_total ?? doc.todo_total_count ?? 0, todoTotal: doc.todo_total ?? doc.todo_total_count ?? 0,
todoDone: doc.todo_done ?? doc.todo_done_count ?? 0, todoDone: doc.todo_done ?? doc.todo_done_count ?? 0,
}; };
const initialDocumentContent = await fetchDocumentContentOnServer({
documentId: doc.id,
workspaceId: doc.workspace_id,
});
const initialPageSubtree = buildPageSubtreeProjection({
documentId: doc.id,
title: doc.title ?? "无标题",
content: initialDocumentContent.content,
});
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -92,9 +184,10 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
workspaceId={doc.workspace_id} workspaceId={doc.workspace_id}
title={doc.title ?? "无标题"} title={doc.title ?? "无标题"}
updatedAt={doc.updated_at} updatedAt={doc.updated_at}
initialContent={null} initialContent={initialDocumentContent.content}
initialContentRevision={null} initialContentRevision={initialDocumentContent.revision}
initialConflictDetectionKey={null} initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
initialPageSubtree={initialPageSubtree}
initialOptions={initialOptions} initialOptions={initialOptions}
initialStats={initialStats} initialStats={initialStats}
openTableId={openTableId} openTableId={openTableId}
@@ -43,6 +43,10 @@ type RequestPayload = {
mindmapId?: string; mindmapId?: string;
selectedUids?: string[]; selectedUids?: string[];
documentBlocks?: unknown; documentBlocks?: unknown;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
}; };
options?: { options?: {
searxng?: boolean; searxng?: boolean;
@@ -85,6 +89,18 @@ const makeRunId = () => {
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`; return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
}; };
const serializeContextSnapshot = (label: string, value: unknown, limit: number) => {
if (value === undefined) {
return null;
}
try {
const text = JSON.stringify(value);
return `${label}=${text.slice(0, limit)}`;
} catch {
return `${label}=provided`;
}
};
const clampSteps = (raw: unknown) => { const clampSteps = (raw: unknown) => {
const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS); const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS);
if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS; if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS;
@@ -157,12 +173,19 @@ const buildHermesInstructions = (
lines.push(`selectedUids=${selectedUids.join(",")}`); lines.push(`selectedUids=${selectedUids.join(",")}`);
} }
if (payload.context?.documentBlocks !== undefined) { if (payload.context?.documentBlocks !== undefined) {
try { lines.push(serializeContextSnapshot("documentBlocksSnapshot", payload.context.documentBlocks, 4000) ?? "documentBlocksSnapshot=provided");
const snapshot = JSON.stringify(payload.context.documentBlocks); }
lines.push(`documentBlocksSnapshot=${snapshot.slice(0, 4000)}`); if (payload.context?.node !== undefined) {
} catch { lines.push(serializeContextSnapshot("kernelNode", payload.context.node, 1800) ?? "kernelNode=provided");
lines.push("documentBlocksSnapshot=provided"); }
} if (payload.context?.subtree !== undefined) {
lines.push(serializeContextSnapshot("kernelSubtree", payload.context.subtree, 5000) ?? "kernelSubtree=provided");
}
if (payload.context?.outline !== undefined) {
lines.push(serializeContextSnapshot("kernelOutline", payload.context.outline, 2500) ?? "kernelOutline=provided");
}
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
} }
if (attachments.length > 0) { if (attachments.length > 0) {
lines.push( lines.push(
@@ -0,0 +1,30 @@
"use client";
import { useRouter } from "next/navigation";
import { StandaloneMindmapView } from "@/components/editor/blocks/MindmapBlock";
import type { MindmapProjection } from "@/lib/mindmap/mindmap-projection";
type MindmapPageClientProps = {
docId: string;
mindmapId: string;
initialProjection: MindmapProjection | null;
};
export default function MindmapPageClient({
docId,
mindmapId,
initialProjection,
}: MindmapPageClientProps) {
const router = useRouter();
return (
<div className="fixed inset-0 bg-white">
<StandaloneMindmapView
docId={docId}
mindmapId={mindmapId}
initialProjection={initialProjection}
onExitFullscreen={() => router.push(`/documents/${docId}`)}
/>
</div>
);
}
@@ -1,47 +1,110 @@
"use client"; import { headers } from "next/headers";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
import {
buildMindmapProjection,
defaultMindmapData,
type MindmapProjection,
} from "@/lib/mindmap/mindmap-projection";
import MindmapPageClient from "./mindmap-page-client";
import { useMemo } from "react"; type MindmapRouteQueryResult = {
import { useParams, useRouter } from "next/navigation"; data?: unknown;
import type { BlockNoteEditor } from "@blocknote/core"; meta?: unknown;
import type { CustomBlockSchema } from "@/components/editor/schema"; };
import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock";
const editorStub = { async function fetchMindmapProjectionOnServer(input: {
updateBlock: () => { docId: string;
/* 独立全屏页中跳过 BlockNote 持久化(思维导图数据由自身 API 管理) */ mindmapId: string;
}, }): Promise<MindmapProjection | null> {
} as unknown as BlockNoteEditor<CustomBlockSchema>; if (!isConvexEnabled()) {
return buildMindmapProjection({
documentId: input.docId,
mindmapId: input.mindmapId,
data: defaultMindmapData,
meta: null,
});
}
export default function MindmapFullscreenPage({ try {
}: Record<string, never>) { const headerList = await headers();
const router = useRouter(); const requestHeaders = new Headers();
const params = useParams<{ docId?: string; mindmapId?: string }>(); [
const docId = params?.docId ?? ""; "cookie",
const mindmapId = params?.mindmapId ?? ""; "authorization",
"x-request-id",
"x-trace-id",
"x-session-id",
"x-source-channel",
"x-source-client",
"user-agent",
].forEach((name) => {
const value = headerList.get(name);
if (value) {
requestHeaders.set(name, value);
}
});
const stubBlock = useMemo( const request = new Request("http://mnote.local/mindmap/projection", {
() => method: "GET",
({ headers: requestHeaders,
id: mindmapId, });
type: "mindmap", const { client } = await getAuthedConvexClient();
props: { const context = await buildDocumentBridgeContext({
docId, request,
data: defaultMindmapData, workspaceId: null,
}, });
content: [], const envelope = buildDocumentQueryEnvelope({
children: [], name: "mindmaps.get",
}) as any, payload: {
[docId, mindmapId], documentId: input.docId,
); mindmapId: input.mindmapId,
workspaceId: null,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const result = await executeRustBridgeQueryTransport<MindmapRouteQueryResult | null>({
client,
plan,
});
return buildMindmapProjection({
documentId: input.docId,
mindmapId: input.mindmapId,
data: result?.data ?? defaultMindmapData,
meta: result?.meta ?? {
requestId: context.requestId,
traceId: context.traceId,
workspaceId: context.workspaceId,
documentId: input.docId,
pageId: input.docId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
},
});
} catch {
return null;
}
}
export default async function MindmapFullscreenPage({
params,
}: {
params: Promise<{ docId: string; mindmapId: string }>;
}) {
const { docId, mindmapId } = await params;
const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId });
return ( return (
<div className="fixed inset-0 bg-white"> <MindmapPageClient
<MindmapBlockView docId={docId}
block={stubBlock} mindmapId={mindmapId}
editor={editorStub} initialProjection={initialProjection}
fullscreen />
onExitFullscreen={() => router.push(`/documents/${docId}`)}
/>
</div>
); );
} }
@@ -1,11 +1,20 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { useAiAgentUiStore } from "@/store/ai-agent-ui"; import { useAiAgentUiStore } from "@/store/ai-agent-ui";
import { AiAgentPanel } from "./AiAgentPanel";
const AiAgentPanelIsland = dynamic(
() => import("./AiAgentPanel").then((mod) => mod.AiAgentPanel),
{
ssr: false,
loading: () => null,
},
);
export function GlobalAiAgentHost() { export function GlobalAiAgentHost() {
const open = useAiAgentUiStore((s) => s.globalAgentOpen); const open = useAiAgentUiStore((s) => s.globalAgentOpen);
const activated = useAiAgentUiStore((s) => s.globalAgentActivated);
const setOpen = useAiAgentUiStore((s) => s.setGlobalAgentOpen); const setOpen = useAiAgentUiStore((s) => s.setGlobalAgentOpen);
return ( return (
@@ -16,7 +25,7 @@ export function GlobalAiAgentHost() {
className="w-[min(1500px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none" className="w-[min(1500px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
> >
<SheetTitle className="sr-only">MNOTE AI</SheetTitle> <SheetTitle className="sr-only">MNOTE AI</SheetTitle>
<AiAgentPanel onClose={() => setOpen(false)} /> {activated ? <AiAgentPanelIsland onClose={() => setOpen(false)} /> : null}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -37,6 +37,15 @@ import { emitAssetsChanged } from "@/lib/events";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { useQuery } from "convex/react"; import { useQuery } from "convex/react";
import {
buildMindmapProjection,
canonicalizeMindmapData,
defaultMindmapData,
extractMindmapTitle,
type MindMapData,
type MindmapProjection,
type MindmapRouteMeta,
} from "@/lib/mindmap/mindmap-projection";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document // 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
const loadIconModules = async () => { const loadIconModules = async () => {
@@ -59,6 +68,19 @@ const getImageSizeSafe = (url: string): Promise<{ width: number; height: number
const isRecord = (v: unknown): v is Record<string, unknown> => const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v); typeof v === "object" && v !== null && !Array.isArray(v);
const readInitialRequestMeta = (meta: unknown): { requestId: string; traceId: string } | null => {
if (!isRecord(meta)) return null;
const requestId = typeof meta.requestId === "string" ? meta.requestId.trim() : "";
const traceId = typeof meta.traceId === "string" ? meta.traceId.trim() : "";
if (!requestId || !traceId) return null;
return { requestId, traceId };
};
const readInitialWorkspaceId = (meta: unknown): string => {
if (!isRecord(meta)) return "";
return typeof meta.workspaceId === "string" ? meta.workspaceId.trim() : "";
};
type MindMapInstance = { type MindMapInstance = {
execCommand: (command: string, ...args: unknown[]) => void; execCommand: (command: string, ...args: unknown[]) => void;
destroy: () => void; destroy: () => void;
@@ -102,63 +124,115 @@ type MindMapInstance = {
setLayout: (layout: string) => void; setLayout: (layout: string) => void;
}; };
type MindMapData = { type MindmapDocumentBridge = {
data: Record<string, unknown>; updateBlockData?: (data: unknown) => void;
children?: unknown[]; removeBlock?: () => void;
}; };
type MindmapRouteMeta = { type MindmapPreviewCardProps = {
requestId?: string; docId?: string;
traceId?: string; mindmapId: string;
workspaceId?: string | null; title: string;
documentId?: string; projection: MindmapProjection | null;
pageId?: string; onOpenInline: () => void;
mindmapId?: string; onOpenFullscreen: () => void;
attachmentId?: string; onOpenStandalone: () => void;
updatedAt?: string | null;
}; };
export const defaultMindmapData = { type MindmapSurfaceViewProps = {
data: { text: "中心主题" }, docId?: string;
children: [], mindmapId: string;
initialData?: unknown;
initialProjection?: MindmapProjection | null;
standalone?: boolean;
fullscreen?: boolean;
onExitFullscreen?: () => void;
documentBridge?: MindmapDocumentBridge | null;
}; };
// simple-mind-map 的 RichText 插件初始化会对节点文本做 HTML 转义; const MindmapPreviewCard = ({
// 若数据里出现 `data.text === undefined`,会在内部调用 `undefined.replace(...)` 直接崩溃。 docId,
// 这里在“保存/恢复”链路上做一次兜底归一化,确保跨视图重建实例时不会白屏。 mindmapId,
const normalizeMindmapData = (input: unknown): unknown => { title,
if (!input || typeof input !== "object") return defaultMindmapData; projection,
const root = (input as { root?: unknown }).root ?? input; onOpenInline,
onOpenFullscreen,
onOpenStandalone,
}: MindmapPreviewCardProps) => {
const previewNodes = projection?.nodes.slice(0, 6) ?? [];
const walk = (node: any) => { return (
if (!node || typeof node !== "object") return; <div
if (!node.data || typeof node.data !== "object") node.data = {}; data-testid="mindmap-preview-card"
const rawText = (node.data as any).text; data-document-id={docId || undefined}
(node.data as any).text = typeof rawText === "string" ? rawText : String(rawText ?? ""); data-mindmap-id={mindmapId}
// 概要(generalization)数据结构里也存在 text 字段,缺失会导致 RichText 初始化崩溃 className="not-prose my-4 w-full overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
const gen = (node.data as any).generalization; >
const fixGen = (g: any) => { <div className="border-b border-slate-200 bg-gradient-to-r from-slate-50 via-white to-emerald-50/60 px-4 py-4">
if (!g || typeof g !== "object") return; <div className="flex flex-wrap items-start justify-between gap-3">
const t = (g as any).text; <div className="min-w-0">
(g as any).text = typeof t === "string" ? t : String(t ?? ""); <div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
};
if (Array.isArray(gen)) gen.forEach(fixGen); </div>
else fixGen(gen); <div className="mt-1 truncate text-base font-semibold text-slate-900">{title}</div>
if (Array.isArray(node.children)) node.children.forEach(walk); <div className="mt-1 text-xs text-slate-500">
};
</div>
walk(root); </div>
return input; <div className="flex flex-wrap items-center gap-2">
}; <button
type="button"
// 持久化/初始化统一使用"根节点对象"作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map className="rounded-full border border-slate-900 bg-slate-900 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-slate-800"
// 从而触发 RichText 对 wrapper.data 的处理(wrapper.data.text 可能不存在 → htmlEscape 崩溃)。 onClick={onOpenInline}
const canonicalizeMindmapData = (input: unknown): MindMapData => { >
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as any;
const root = (normalized && typeof normalized === "object" && "root" in normalized) </button>
? (normalized as any).root <button
: normalized; type="button"
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData; className="rounded-full border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
onClick={onOpenFullscreen}
>
</button>
<button
type="button"
className="rounded-full border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
onClick={onOpenStandalone}
>
</button>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1">
{projection?.nodeCount ?? 1}
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1">
{projection?.projection ?? "mindmap_subtree"}
</span>
<span className="rounded-full bg-slate-100 px-2.5 py-1"></span>
</div>
</div>
<div className="space-y-3 bg-[linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] px-4 py-4">
{previewNodes.length > 0 ? (
previewNodes.map((node) => (
<div
key={`${node.uid}:${node.depth}`}
className="rounded-2xl border border-slate-200/80 bg-white/85 px-3 py-2 shadow-sm"
style={{ marginLeft: `${node.depth * 14}px` }}
>
<div className="truncate text-sm font-medium text-slate-800">{node.text}</div>
<div className="mt-1 text-xs text-slate-500"> {node.childCount}</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-white/80 px-4 py-6 text-sm text-slate-500">
</div>
)}
</div>
</div>
);
}; };
// 将思维导图数据中的 asset:id 格式转换为实际的签名 URL // 将思维导图数据中的 asset:id 格式转换为实际的签名 URL
@@ -454,22 +528,16 @@ const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
return mm; return mm;
}; };
const MindmapBlockView = ({ const MindmapSurfaceView = ({
block, docId: docIdProp = "",
editor, mindmapId,
initialData,
initialProjection = null,
standalone = false,
fullscreen = false, fullscreen = false,
onExitFullscreen, onExitFullscreen,
}: { documentBridge = null,
block: SpecificBlock< }: MindmapSurfaceViewProps) => {
CustomBlockSchema,
"mindmap",
DefaultInlineContentSchema,
DefaultStyleSchema
>;
editor: BlockNoteEditor<CustomBlockSchema>;
fullscreen?: boolean;
onExitFullscreen?: () => void;
}) => {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null); const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
@@ -491,6 +559,7 @@ const MindmapBlockView = ({
const [showNoteModal, setShowNoteModal] = useState(false); const [showNoteModal, setShowNoteModal] = useState(false);
const [noteContent, setNoteContent] = useState(""); const [noteContent, setNoteContent] = useState("");
const [localFullscreen, setLocalFullscreen] = useState(false); const [localFullscreen, setLocalFullscreen] = useState(false);
const [showEmbedToolbar, setShowEmbedToolbar] = useState(false);
const effectiveFullscreen = fullscreen || localFullscreen; const effectiveFullscreen = fullscreen || localFullscreen;
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const hotkeyScopeRef = useRef(false); const hotkeyScopeRef = useRef(false);
@@ -516,18 +585,20 @@ const MindmapBlockView = ({
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null; return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]); }, [mindmap]);
const docId = useMemo( const docId = useMemo(() => docIdProp || "", [docIdProp]);
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const mindmapId = block.id;
const pageId = docId; const pageId = docId;
const attachmentId = mindmapId; const attachmentId = mindmapId;
const [requestMeta, setRequestMeta] = useState<{ requestId: string; traceId: string } | null>(null); const projectionData = initialProjection?.data ?? null;
const [requestMeta, setRequestMeta] = useState<{ requestId: string; traceId: string } | null>(
() => readInitialRequestMeta(initialProjection?.meta),
);
const [titleText, setTitleText] = useState(() =>
initialProjection?.title ?? extractMindmapTitle(projectionData ?? initialData ?? defaultMindmapData),
);
const syncTitleText = useCallback((data: unknown) => {
setTitleText(extractMindmapTitle(data));
}, []);
const syncMindmapRouteMeta = useCallback((meta: unknown) => { const syncMindmapRouteMeta = useCallback((meta: unknown) => {
if (!isRecord(meta)) return; if (!isRecord(meta)) return;
@@ -596,13 +667,22 @@ const MindmapBlockView = ({
try { try {
initialDataRef.current = canonicalizeMindmapData(JSON.parse(cached)); initialDataRef.current = canonicalizeMindmapData(JSON.parse(cached));
} catch { } catch {
initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData); initialDataRef.current = canonicalizeMindmapData(projectionData ?? initialData ?? defaultMindmapData);
} }
} else { } else {
initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData); initialDataRef.current = canonicalizeMindmapData(projectionData ?? initialData ?? defaultMindmapData);
} }
} }
useEffect(() => {
syncTitleText(initialDataRef.current ?? projectionData ?? initialData ?? defaultMindmapData);
}, [autosaveKey, initialData, projectionData, syncTitleText]);
useEffect(() => {
if (!initialProjection?.meta) return;
syncMindmapRouteMeta(initialProjection.meta);
}, [initialProjection, syncMindmapRouteMeta]);
// 优先加载本地文件,其次 Supabase(通过后端 API // 优先加载本地文件,其次 Supabase(通过后端 API
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -619,6 +699,7 @@ const MindmapBlockView = ({
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置” // 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return; if (hasLocalEditsRef.current) return;
initialDataRef.current = canonicalizeMindmapData(data); initialDataRef.current = canonicalizeMindmapData(data);
syncTitleText(initialDataRef.current);
if (mindmap) { if (mindmap) {
applyingRemoteRef.current = true; applyingRemoteRef.current = true;
try { try {
@@ -635,7 +716,7 @@ const MindmapBlockView = ({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [docId, mindmap, mindmapId]); }, [docId, mindmap, mindmapId, syncMindmapRouteMeta, syncTitleText]);
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。 // Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
useEffect(() => { useEffect(() => {
@@ -679,14 +760,15 @@ const MindmapBlockView = ({
const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData); const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData);
lastAppliedRemoteUpdatedAtRef.current = updatedAt; lastAppliedRemoteUpdatedAtRef.current = updatedAt;
initialDataRef.current = incoming; initialDataRef.current = incoming;
syncTitleText(incoming);
try { try {
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming)); window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
} catch { } catch {
// ignore // ignore
} }
if (!effectiveFullscreen) { if (!effectiveFullscreen && !standalone) {
try { try {
editor.updateBlock(block, { props: { ...block.props, data: incoming } }); documentBridge?.updateBlockData?.(incoming);
} catch { } catch {
// ignore // ignore
} }
@@ -704,14 +786,15 @@ const MindmapBlockView = ({
} }
}, [ }, [
autosaveKey, autosaveKey,
block,
docId, docId,
editor,
effectiveFullscreen, effectiveFullscreen,
mindmap, mindmap,
mindmapId, mindmapId,
onExitFullscreen, onExitFullscreen,
remoteMindmap, remoteMindmap,
standalone,
documentBridge,
syncTitleText,
]); ]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域 // 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
@@ -935,8 +1018,6 @@ const MindmapBlockView = ({
if (now - lastTextEditRefocusAtRef.current < 120) return; if (now - lastTextEditRefocusAtRef.current < 120) return;
lastTextEditRefocusAtRef.current = now; lastTextEditRefocusAtRef.current = now;
const editorEl =
(wrap.querySelector(".ql-editor") as HTMLElement | null) ?? wrap;
window.setTimeout(() => { window.setTimeout(() => {
if (!textEditOpenRef.current) return; if (!textEditOpenRef.current) return;
if (Date.now() < suppressTextEditRefocusUntilRef.current) return; if (Date.now() < suppressTextEditRefocusUntilRef.current) return;
@@ -1390,7 +1471,6 @@ const MindmapBlockView = ({
const persistData = useCallback( const persistData = useCallback(
(data: unknown) => { (data: unknown) => {
if (!editor) return;
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑 // 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
// 否则切换视图时会用旧数据,表现为"看起来没保存" // 否则切换视图时会用旧数据,表现为"看起来没保存"
let safe = canonicalizeMindmapData(data); let safe = canonicalizeMindmapData(data);
@@ -1399,14 +1479,15 @@ const MindmapBlockView = ({
safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current); safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current);
} }
initialDataRef.current = safe; initialDataRef.current = safe;
syncTitleText(safe);
window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlockBlockNote/ProseMirror // 注意:全屏(Portal 覆盖层)下如果调用 updateBlockBlockNote/ProseMirror
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框 // 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
// 被迫退出(表现为"有光标但过一会儿就消失")。 // 被迫退出(表现为"有光标但过一会儿就消失")。
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载) // 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
// 时再统一把最新数据写回 block。 // 时再统一把最新数据写回 block。
if (!effectiveFullscreen) { if (!effectiveFullscreen && !standalone) {
editor.updateBlock(block, { props: { ...block.props, data: safe } }); documentBridge?.updateBlockData?.(safe);
} }
if (docId) { if (docId) {
// 同步到本地文件 + Convex(弱依赖) // 同步到本地文件 + Convex(弱依赖)
@@ -1452,7 +1533,16 @@ const MindmapBlockView = ({
.catch(() => {}); .catch(() => {});
} }
}, },
[autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen], [
autosaveKey,
docId,
mindmapId,
effectiveFullscreen,
standalone,
documentBridge,
syncMindmapRouteMeta,
syncTitleText,
],
); );
useEffect(() => { useEffect(() => {
persistDataRef.current = persistData; persistDataRef.current = persistData;
@@ -1525,7 +1615,7 @@ const MindmapBlockView = ({
}); });
} }
})(); })();
}, [docId, mindmap, mindmapId, initialDataRef]); }, [docId, mindmap, mindmapId, syncMindmapRouteMeta]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择 // 初始选中根节点,后续不强制抢焦点,允许用户自由选择
useEffect(() => { useEffect(() => {
@@ -1690,7 +1780,7 @@ const MindmapBlockView = ({
{ name: "ExportXMind", plugin: ExportXMind }, { name: "ExportXMind", plugin: ExportXMind },
]; ];
plugins.forEach(({ name, plugin }) => { plugins.forEach(({ plugin }) => {
if (!plugin) { if (!plugin) {
return; return;
} }
@@ -1723,26 +1813,23 @@ const MindmapBlockView = ({
// 全屏/非全屏切换会重建实例:创建新实例前优先从本地缓存读取最新数据 // 全屏/非全屏切换会重建实例:创建新实例前优先从本地缓存读取最新数据
// 以避免“全屏里编辑 → 退出全屏后内容消失 / 反之亦然”。 // 以避免“全屏里编辑 → 退出全屏后内容消失 / 反之亦然”。
let dataForInitSource = "unknown";
const rawForInit = (() => { const rawForInit = (() => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
dataForInitSource = "ssr-fallback"; return initialData ?? initialDataRef.current ?? defaultMindmapData;
return block.props.data ?? initialDataRef.current ?? defaultMindmapData;
} }
try { try {
const cached = window.localStorage.getItem(autosaveKey); const cached = window.localStorage.getItem(autosaveKey);
if (cached) { if (cached) {
dataForInitSource = "localStorage";
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch { } catch {
// ignore // ignore
} }
dataForInitSource = block.props.data ? "block.props.data" : "initialDataRef"; return initialData ?? initialDataRef.current ?? defaultMindmapData;
return block.props.data ?? initialDataRef.current ?? defaultMindmapData;
})(); })();
const dataForInitCanonical = canonicalizeMindmapData(rawForInit); const dataForInitCanonical = canonicalizeMindmapData(rawForInit);
initialDataRef.current = dataForInitCanonical; initialDataRef.current = dataForInitCanonical;
syncTitleText(dataForInitCanonical);
// 将 asset:id 转换为签名 URL,并获取映射 // 将 asset:id 转换为签名 URL,并获取映射
const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical); const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical);
// 保存映射供后续保存时使用 // 保存映射供后续保存时使用
@@ -2067,15 +2154,18 @@ const MindmapBlockView = ({
if (data) { if (data) {
const safe = canonicalizeMindmapData(data); const safe = canonicalizeMindmapData(data);
initialDataRef.current = safe; initialDataRef.current = safe;
syncTitleText(safe);
try { try {
window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
} catch { } catch {
// ignore // ignore
} }
try { if (!standalone) {
editor?.updateBlock(block, { props: { ...block.props, data: safe } }); try {
} catch { documentBridge?.updateBlockData?.(safe);
// ignore } catch {
// ignore
}
} }
if (docId) { if (docId) {
fetch(`/api/mindmap/${docId}/${mindmapId}`, { fetch(`/api/mindmap/${docId}/${mindmapId}`, {
@@ -2116,7 +2206,7 @@ const MindmapBlockView = ({
mindmapReadyRef.current = false; mindmapReadyRef.current = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [block.id, effectiveFullscreen]); }, [docId, effectiveFullscreen, initialData, mindmapId, standalone, documentBridge, syncTitleText]);
const getInstanceCandidate = () => const getInstanceCandidate = () =>
mindmap ?? mindmap ??
@@ -2278,7 +2368,7 @@ const MindmapBlockView = ({
const [imageTitle, setImageTitle] = useState(""); const [imageTitle, setImageTitle] = useState("");
const [imagePosition, setImagePosition] = useState<string>("top"); const [imagePosition, setImagePosition] = useState<string>("top");
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
const [workspaceId, setWorkspaceId] = useState(""); const [workspaceId, setWorkspaceId] = useState(() => readInitialWorkspaceId(initialProjection?.meta));
const fileInputForImage = useRef<HTMLInputElement | null>(null); const fileInputForImage = useRef<HTMLInputElement | null>(null);
// 图片预览(双击节点图片) // 图片预览(双击节点图片)
@@ -2450,7 +2540,7 @@ const MindmapBlockView = ({
imgNodeRef.current = { node: null, imgNode: null }; imgNodeRef.current = { node: null, imgNode: null };
} }
}; };
const showToolbarOnClick = (node: MindMapNode, imgNode: any, _evt: Event | undefined) => { const showToolbarOnClick = (node: MindMapNode, imgNode: any) => {
imgNodeRef.current = { node, imgNode }; imgNodeRef.current = { node, imgNode };
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right"; const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
setImagePosition(placement); setImagePosition(placement);
@@ -2672,10 +2762,6 @@ const MindmapBlockView = ({
} }
setShowImageModal(false); setShowImageModal(false);
// 保存旧的 signed URL(如果存在),用于后续删除检测
const oldData = mindmap?.getData?.();
const oldUrls = oldData ? collectImageUrls(oldData) : [];
// 如果是 asset:id 格式,需要先转换为 signed URL 供显示 // 如果是 asset:id 格式,需要先转换为 signed URL 供显示
let displayUrl = url; let displayUrl = url;
if (url.startsWith("asset:")) { if (url.startsWith("asset:")) {
@@ -2816,7 +2902,7 @@ const MindmapBlockView = ({
}; };
const handleExportJson = () => { const handleExportJson = () => {
const data = mindmap?.getData?.(true) ?? block.props.data ?? defaultMindmapData; const data = mindmap?.getData?.(true) ?? initialDataRef.current ?? initialData ?? defaultMindmapData;
downloadJson(data, "mindmap"); downloadJson(data, "mindmap");
}; };
@@ -2840,7 +2926,7 @@ const MindmapBlockView = ({
const handleDeleteMindmap = useCallback(async () => { const handleDeleteMindmap = useCallback(async () => {
if (!docId) { if (!docId) {
deletingRef.current = true; deletingRef.current = true;
editor.removeBlocks([block.id]); documentBridge?.removeBlock?.();
return; return;
} }
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。"); const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
@@ -2861,11 +2947,11 @@ const MindmapBlockView = ({
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]); emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
// 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错 // 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错
try { try {
editor.removeBlocks([block.id]); documentBridge?.removeBlock?.();
} catch { } catch {
// ignore // ignore
} }
}, [autosaveKey, block.id, docId, editor, mindmapId]); }, [autosaveKey, docId, mindmapId, documentBridge]);
const toolbarProps = { const toolbarProps = {
canBack, canBack,
@@ -3191,6 +3277,11 @@ const MindmapBlockView = ({
</div> </div>
) : null; ) : null;
const toggleLocalFullscreen = () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
};
if (effectiveFullscreen) { if (effectiveFullscreen) {
const fullscreenView = ( const fullscreenView = (
<div <div
@@ -3206,8 +3297,21 @@ const MindmapBlockView = ({
tabIndex={0} tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white" className="fixed inset-0 z-[9999] overflow-hidden bg-white"
> >
<div className="fixed left-1/2 top-2 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4"> <div className="pointer-events-none fixed inset-x-0 top-0 z-20 px-6 py-4">
<MindmapToolbar {...toolbarProps} /> <div className="pointer-events-auto mx-auto flex max-w-[1600px] items-start justify-between gap-4">
<div className="rounded-2xl border border-slate-200/80 bg-white/92 px-4 py-3 shadow-lg shadow-slate-200/70 backdrop-blur">
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">
{standalone ? "独立导图页" : "导图全屏"}
</div>
<div className="mt-1 text-lg font-semibold text-slate-900">{titleText}</div>
<div className="mt-1 text-xs text-slate-500">
{docId ? `文档 ${docId}` : "未绑定文档"}
</div>
</div>
<div className="flex min-w-0 justify-end">
<MindmapToolbar {...toolbarProps} />
</div>
</div>
</div> </div>
<div className="relative h-full w-full pt-0"> <div className="relative h-full w-full pt-0">
<div <div
@@ -3242,10 +3346,7 @@ const MindmapBlockView = ({
toggleFullscreen={ toggleFullscreen={
fullscreen fullscreen
? undefined ? undefined
: () => { : toggleLocalFullscreen
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
} }
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)} onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap} miniMapOpen={showMiniMap}
@@ -3277,16 +3378,70 @@ const MindmapBlockView = ({
data-request-id={requestMeta?.requestId} data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId} data-trace-id={requestMeta?.traceId}
tabIndex={0} tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm" className="not-prose my-4 w-full overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
> >
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3"> <div className="border-b border-slate-200 bg-gradient-to-r from-slate-50 via-white to-emerald-50/50 px-4 py-4">
<div className="w-full"> <div className="flex flex-wrap items-start justify-between gap-3">
<MindmapToolbar {...toolbarProps} /> <div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
</div>
<div className="mt-1 truncate text-base font-semibold text-slate-900">{titleText}</div>
<div className="mt-1 text-xs text-slate-500">
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-full border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
onClick={() => {
if (!docId) return;
window.open(`/mindmap/${docId}/${mindmapId}`, "_blank", "noopener,noreferrer");
}}
>
</button>
<button
type="button"
className="rounded-full border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
onClick={toggleLocalFullscreen}
>
</button>
<button
type="button"
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${
showEmbedToolbar
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-300 bg-white text-slate-700 hover:border-slate-400 hover:bg-slate-50"
}`}
onClick={() => setShowEmbedToolbar((prev) => !prev)}
>
{showEmbedToolbar ? "收起工具" : "展开工具"}
</button>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1"></span>
<span className="rounded-full bg-slate-100 px-2.5 py-1"></span>
<span className="rounded-full bg-slate-100 px-2.5 py-1"></span>
</div>
<div
className={`grid transition-[grid-template-rows,opacity,margin] duration-200 ${
showEmbedToolbar ? "mt-4 grid-rows-[1fr] opacity-100" : "mt-0 grid-rows-[0fr] opacity-0"
}`}
>
<div className="min-h-0 overflow-hidden">
<div className="w-full">
<MindmapToolbar {...toolbarProps} />
</div>
</div>
</div> </div>
</div> </div>
<div <div
data-testid="mindmap-stage" data-testid="mindmap-stage"
className="relative h-[520px] w-full overflow-hidden bg-white" className="relative h-[420px] w-full overflow-hidden bg-[linear-gradient(180deg,#ffffff_0%,#f8fafc_100%)] md:h-[460px]"
onPointerDown={(e) => { onPointerDown={(e) => {
// 阻止事件冒泡到 BlockNote/ProseMirror,避免产生 NodeSelection 导致粘贴替换整块 // 阻止事件冒泡到 BlockNote/ProseMirror,避免产生 NodeSelection 导致粘贴替换整块
e.stopPropagation(); e.stopPropagation();
@@ -3347,10 +3502,7 @@ const MindmapBlockView = ({
toggleFullscreen={ toggleFullscreen={
fullscreen fullscreen
? undefined ? undefined
: () => { : toggleLocalFullscreen
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
} }
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)} onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap} miniMapOpen={showMiniMap}
@@ -3367,7 +3519,157 @@ const MindmapBlockView = ({
); );
}; };
export { MindmapBlockView }; const MindmapBlockView = ({
block,
editor,
fullscreen = false,
onExitFullscreen,
}: {
block: SpecificBlock<
CustomBlockSchema,
"mindmap",
DefaultInlineContentSchema,
DefaultStyleSchema
>;
editor: BlockNoteEditor<CustomBlockSchema>;
fullscreen?: boolean;
onExitFullscreen?: () => void;
}) => {
const documentBridge = useMemo<MindmapDocumentBridge>(
() => ({
updateBlockData: (data) => {
editor.updateBlock(block, { props: { ...block.props, data } });
},
removeBlock: () => {
editor.removeBlocks([block.id]);
},
}),
[block, editor],
);
const currentBlockKey = `${block.props.docId ?? ""}:${block.id}`;
const [inlineModeState, setInlineModeState] = useState<{
blockKey: string;
mode: "preview" | "editor" | "fullscreen";
}>({
blockKey: currentBlockKey,
mode: "preview",
});
const initialProjection = useMemo(
() =>
buildMindmapProjection({
documentId: block.props.docId ?? "",
mindmapId: block.id,
data: block.props.data ?? defaultMindmapData,
meta: null,
}),
[block.id, block.props.data, block.props.docId],
);
const inlineModeForCurrentBlock =
inlineModeState.blockKey === currentBlockKey ? inlineModeState.mode : "preview";
const createOnlySyncedBlockKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!block.props.docId) return;
if (createOnlySyncedBlockKeyRef.current === currentBlockKey) return;
createOnlySyncedBlockKeyRef.current = currentBlockKey;
const data = canonicalizeMindmapData(block.props.data ?? defaultMindmapData);
void fetch(`/api/mindmap/${block.props.docId}/${block.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data, createOnly: true }),
})
.then(async (resp) => {
if (!resp.ok) return null;
return (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
})
.then((payload) => {
const fileName = `mindmap-${block.id}.json`;
emitAssetsChanged(block.props.docId, {
id: block.id,
document_id: block.props.docId ?? "",
asset_type: "mindmap",
file_name: fileName,
file_url: `/documents/${block.props.docId}/${fileName}`,
});
return payload;
})
.catch(() => {
if (!block.props.docId) return;
const fileName = `mindmap-${block.id}.json`;
emitAssetsChanged(block.props.docId, {
id: block.id,
document_id: block.props.docId ?? "",
asset_type: "mindmap",
file_name: fileName,
file_url: `/documents/${block.props.docId}/${fileName}`,
});
});
}, [block.id, block.props.data, block.props.docId, currentBlockKey]);
if (!fullscreen && inlineModeForCurrentBlock === "preview") {
return (
<MindmapPreviewCard
docId={block.props.docId}
mindmapId={block.id}
title={initialProjection.title}
projection={initialProjection}
onOpenInline={() => {
setInlineModeState({ blockKey: currentBlockKey, mode: "editor" });
}}
onOpenFullscreen={() => {
setInlineModeState({ blockKey: currentBlockKey, mode: "fullscreen" });
}}
onOpenStandalone={() => {
if (!block.props.docId) return;
window.open(`/mindmap/${block.props.docId}/${block.id}`, "_blank", "noopener,noreferrer");
}}
/>
);
}
return (
<MindmapSurfaceView
docId={block.props.docId}
mindmapId={block.id}
initialData={block.props.data}
initialProjection={initialProjection}
fullscreen={fullscreen || inlineModeForCurrentBlock === "fullscreen"}
onExitFullscreen={
fullscreen
? onExitFullscreen
: inlineModeForCurrentBlock === "fullscreen"
? () => setInlineModeState({ blockKey: currentBlockKey, mode: "preview" })
: onExitFullscreen
}
documentBridge={documentBridge}
/>
);
};
const StandaloneMindmapView = ({
docId,
mindmapId,
initialProjection = null,
onExitFullscreen,
}: {
docId: string;
mindmapId: string;
initialProjection?: MindmapProjection | null;
onExitFullscreen?: () => void;
}) => (
<MindmapSurfaceView
docId={docId}
mindmapId={mindmapId}
initialData={initialProjection?.data ?? defaultMindmapData}
initialProjection={initialProjection}
standalone
fullscreen
onExitFullscreen={onExitFullscreen}
/>
);
export { MindmapBlockView, StandaloneMindmapView, defaultMindmapData };
type MindmapBlockViewProps = Parameters<typeof MindmapBlockView>[0]; type MindmapBlockViewProps = Parameters<typeof MindmapBlockView>[0];
@@ -21,6 +21,10 @@ import { useCommentsUiStore } from "@/store/comments-ui";
import { useAppPreferencesStore } from "@/store/app-preferences"; import { useAppPreferencesStore } from "@/store/app-preferences";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker"; import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view";
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
const BlockNoteEditor = dynamic( const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor), () => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -40,6 +44,7 @@ export interface DocumentContentProps {
initialContent: unknown; initialContent: unknown;
initialContentRevision?: number | null; initialContentRevision?: number | null;
initialConflictDetectionKey?: string | null; initialConflictDetectionKey?: string | null;
initialPageSubtree?: PageSubtreeProjection | null;
initialOptions: PageOptionsState; initialOptions: PageOptionsState;
initialStats: DocumentStats | null; initialStats: DocumentStats | null;
openTableId?: string | null; openTableId?: string | null;
@@ -64,6 +69,7 @@ const defaultOptions: PageOptionsState = {
embedDefaultBlockId: null, embedDefaultBlockId: null,
}; };
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 }; const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
const EDITOR_UNMOUNT_GRACE_MS = 1000;
export function DocumentContent({ export function DocumentContent({
documentId, documentId,
@@ -73,6 +79,7 @@ export function DocumentContent({
initialContent, initialContent,
initialContentRevision = null, initialContentRevision = null,
initialConflictDetectionKey = null, initialConflictDetectionKey = null,
initialPageSubtree = null,
initialOptions, initialOptions,
initialStats, initialStats,
openTableId, openTableId,
@@ -82,6 +89,7 @@ export function DocumentContent({
}: DocumentContentProps) { }: DocumentContentProps) {
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent); const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch); const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly;
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions); const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats); const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]); const [history, setHistory] = useState<DocumentSnapshot[]>([]);
@@ -100,10 +108,15 @@ export function DocumentContent({
const [contentError, setContentError] = useState<string | null>(null); const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0); const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false); const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const [isEditing, setIsEditing] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorUnmountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingOpenTableRef = useRef<string | null>(null); const pendingOpenTableRef = useRef<string | null>(null);
const latestBlocksRef = useRef<Json | null>(null); const latestBlocksRef = useRef<Json | null>(null);
const pageRootRef = useRef<HTMLDivElement>(null); const pageRootRef = useRef<HTMLDivElement>(null);
const readViewRootRef = useRef<HTMLDivElement>(null);
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
const lastCopyBlockedAtRef = useRef<number>(0); const lastCopyBlockedAtRef = useRef<number>(0);
useEffect(() => { useEffect(() => {
@@ -116,20 +129,22 @@ export function DocumentContent({
useEffect(() => { useEffect(() => {
if (!disableCopy) return; if (!disableCopy) return;
const toElement = (node: Node | null): Element | null => {
if (!node) return null;
if (node.nodeType === Node.TEXT_NODE) {
return node.parentElement;
}
return node instanceof Element ? node : null;
};
const isEventInsidePage = () => { const isEventInsidePage = () => {
const root = pageRootRef.current; const root = pageRootRef.current;
if (!root) return false; if (!root) return false;
const selection = typeof window !== "undefined" ? window.getSelection() : null; const selection = typeof window !== "undefined" ? window.getSelection() : null;
const anchor = selection?.anchorNode ?? null; const anchor = selection?.anchorNode ?? null;
const focus = selection?.focusNode ?? null; const focus = selection?.focusNode ?? null;
const anchorEl = const anchorEl = toElement(anchor);
anchor && "nodeType" in anchor && anchor.nodeType === Node.TEXT_NODE const focusEl = toElement(focus);
? anchor.parentElement
: (anchor as any as Element | null);
const focusEl =
focus && "nodeType" in focus && focus.nodeType === Node.TEXT_NODE
? focus.parentElement
: (focus as any as Element | null);
return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl))); return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl)));
}; };
@@ -219,6 +234,42 @@ export function DocumentContent({
setConflictDetectionKey(initialConflictDetectionKey); setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]); }, [initialConflictDetectionKey]);
useEffect(() => {
const nextBlocks = extractPageBlocks(content);
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
}, [content]);
useEffect(() => {
const shouldForceEdit = Boolean((openTableId ?? "").trim()) && canEditDocument;
if (shouldForceEdit) {
setIsEditing(true);
setKeepEditorMounted(true);
}
}, [canEditDocument, openTableId]);
useEffect(() => {
if (isEditing) {
if (editorUnmountTimerRef.current) {
clearTimeout(editorUnmountTimerRef.current);
editorUnmountTimerRef.current = null;
}
setKeepEditorMounted(true);
return;
}
if (editorUnmountTimerRef.current) {
clearTimeout(editorUnmountTimerRef.current);
}
editorUnmountTimerRef.current = setTimeout(() => {
setKeepEditorMounted(false);
editorUnmountTimerRef.current = null;
}, EDITOR_UNMOUNT_GRACE_MS);
return () => {
if (editorUnmountTimerRef.current) {
clearTimeout(editorUnmountTimerRef.current);
editorUnmountTimerRef.current = null;
}
};
}, [isEditing]);
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
@@ -321,14 +372,14 @@ export function DocumentContent({
}, 600); }, 600);
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => { const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (readOnly) return; if (!canEditDocument) return;
const value = event.target.value; const value = event.target.value;
setPageTitle(value); setPageTitle(value);
debouncedPersistTitle(value); debouncedPersistTitle(value);
}; };
const handleTitleBlur = () => { const handleTitleBlur = () => {
if (readOnly) return; if (!canEditDocument) return;
void persistTitle(pageTitle); void persistTitle(pageTitle);
}; };
@@ -409,7 +460,11 @@ export function DocumentContent({
); );
const handleSetEmbedDefaultToCursor = useCallback(() => { const handleSetEmbedDefaultToCursor = useCallback(() => {
if (readOnly) return; if (!canEditDocument) return;
if (!isEditing) {
setIsEditing(true);
return;
}
const blockId = editorBridge?.getCursorBlockId?.() ?? null; const blockId = editorBridge?.getCursorBlockId?.() ?? null;
if (!blockId) { if (!blockId) {
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块"); window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
@@ -417,7 +472,7 @@ export function DocumentContent({
} }
setOptionPatch({ embedDefaultBlockId: blockId }); setOptionPatch({ embedDefaultBlockId: blockId });
window.alert("已设置“嵌入默认位置”"); window.alert("已设置“嵌入默认位置”");
}, [editorBridge, readOnly, setOptionPatch]); }, [canEditDocument, editorBridge, isEditing, setOptionPatch]);
const handleClearEmbedDefault = useCallback(() => { const handleClearEmbedDefault = useCallback(() => {
if (readOnly) return; if (readOnly) return;
@@ -492,12 +547,20 @@ export function DocumentContent({
); );
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
if (!isEditing) {
setIsEditing(true);
return;
}
editorBridge?.undo?.(); editorBridge?.undo?.();
}, [editorBridge]); }, [editorBridge, isEditing]);
const handleRedo = useCallback(() => { const handleRedo = useCallback(() => {
if (!isEditing) {
setIsEditing(true);
return;
}
editorBridge?.redo?.(); editorBridge?.redo?.();
}, [editorBridge]); }, [editorBridge, isEditing]);
const handleDeletePage = useCallback(async () => { const handleDeletePage = useCallback(async () => {
if (readOnly) return; if (readOnly) return;
@@ -586,16 +649,17 @@ export function DocumentContent({
return; return;
} }
const latest = history[0]; const latest = history[0];
if (!latest) { const exportBlocks = latest?.blocks ?? latestBlocksRef.current;
if (!exportBlocks) {
window.alert("暂无可导出的内容"); window.alert("暂无可导出的内容");
return; return;
} }
const payload = JSON.stringify(latest.blocks, null, 2); const payload = JSON.stringify(exportBlocks, null, 2);
const blob = new Blob([payload], { type: "application/json" }); const blob = new Blob([payload], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`; anchor.download = `${title ?? "未命名页面"}-${new Date().toISOString()}.json`;
anchor.click(); anchor.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [disableDownload, history, title]); }, [disableDownload, history, title]);
@@ -609,8 +673,40 @@ export function DocumentContent({
options.smallText && "wolai-small-text", options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages", options.hideChildPages && "wolai-hide-child-pages",
); );
const pageSubtree = useMemo(() => {
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) {
return initialPageSubtree;
}
return buildPageSubtreeProjection({
documentId,
title: pageTitle,
content,
});
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]);
const readViewTocEntries = useMemo(
() =>
pageSubtree.outline
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
id: anchorBlockId as string,
level,
numbering,
title: entryTitle,
})),
[pageSubtree],
);
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => {
const targetRoot = !isEditing ? readViewRootRef.current : pageRootRef.current;
const target = targetRoot?.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [isEditing]);
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => { const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setContent(payload.blocks);
latestBlocksRef.current = payload.blocks; latestBlocksRef.current = payload.blocks;
setHistory((prev) => { setHistory((prev) => {
const now = Date.now(); const now = Date.now();
@@ -647,42 +743,86 @@ export function DocumentContent({
const handleRestoreSnapshot = useCallback( const handleRestoreSnapshot = useCallback(
(snapshot: DocumentSnapshot) => { (snapshot: DocumentSnapshot) => {
if (!editorBridge) { if (!isEditing || !editorBridge) {
window.alert("编辑器尚未准备好,无法恢复历史版本"); pendingRestoreSnapshotRef.current = snapshot;
setIsEditing(true);
return; return;
} }
editorBridge.replaceWithSnapshot(snapshot.blocks); editorBridge.replaceWithSnapshot(snapshot.blocks);
setHistoryOpen(false); setHistoryOpen(false);
}, },
[editorBridge], [editorBridge, isEditing],
); );
const handleEnterEditMode = useCallback(() => {
if (!canEditDocument) return;
setIsEditing(true);
}, [canEditDocument]);
const handleExitEditMode = useCallback(() => {
setIsEditing(false);
}, []);
useEffect(() => {
if (!isEditing) return;
if (!editorBridge) return;
const pendingSnapshot = pendingRestoreSnapshotRef.current;
if (!pendingSnapshot) return;
pendingRestoreSnapshotRef.current = null;
editorBridge.replaceWithSnapshot(pendingSnapshot.blocks);
setHistoryOpen(false);
}, [editorBridge, isEditing]);
return ( return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}> <ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className={pageRootClass} ref={pageRootRef}> <div className={pageRootClass} ref={pageRootRef}>
<div className="flex h-full flex-1 flex-col overflow-hidden"> <div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-wolai-border px-12 pb-6 pt-8"> <div className="border-b border-wolai-border px-12 pb-6 pt-8">
<div className="relative"> <div className="flex items-start justify-between gap-6">
<input <div className="min-w-0 flex-1">
value={pageTitle} {isEditing && canEditDocument ? (
onChange={handleTitleChange} <div className="relative">
onBlur={handleTitleBlur} <input
onKeyDown={handleTitleKeyDown} value={pageTitle}
placeholder="无标题" onChange={handleTitleChange}
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0" onBlur={handleTitleBlur}
aria-label="页面标题" onKeyDown={handleTitleKeyDown}
disabled={options.protectEditing || readOnly} placeholder="无标题"
spellCheck={spellCheck} className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
/> aria-label="页面标题"
disabled={options.protectEditing || readOnly}
spellCheck={spellCheck}
/>
</div>
) : (
<h1 className="break-words text-3xl font-semibold text-wolai-text-primary">
{pageTitle || "无标题"}
</h1>
)}
{readOnly ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : options.protectEditing && isEditing ? (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
) : !isEditing && canEditDocument ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : null}
<p className="text-sm text-wolai-text-secondary">{formattedUpdatedAt}</p>
</div>
{canEditDocument && (
<div className="flex shrink-0 items-center gap-2">
{isEditing ? (
<Button type="button" variant="outline" size="sm" onClick={handleExitEditMode}>
</Button>
) : (
<Button type="button" size="sm" onClick={handleEnterEditMode}>
</Button>
)}
</div>
)}
</div> </div>
{readOnly ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : options.protectEditing ? (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
) : null}
<p className="text-sm text-wolai-text-secondary">{formattedUpdatedAt}</p>
</div> </div>
<div className="flex-1 overflow-y-auto px-12 py-6"> <div className="relative flex-1 overflow-y-auto px-12 py-6">
{contentLoading ? ( {contentLoading ? (
showContentLoadingIndicator ? ( showContentLoadingIndicator ? (
<div className="flex h-64 items-center justify-center text-sm text-gray-400"> <div className="flex h-64 items-center justify-center text-sm text-gray-400">
@@ -707,22 +847,45 @@ export function DocumentContent({
</button> </button>
</div> </div>
) : ( ) : (
<BlockNoteEditor <div className="relative">
documentId={documentId} {keepEditorMounted && (
workspaceId={workspaceId} <div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
initialContent={content} <BlockNoteEditor
initialRevision={contentRevision} documentId={documentId}
initialConflictDetectionKey={conflictDetectionKey} workspaceId={workspaceId}
pageOptions={options} initialContent={content}
readOnly={readOnly} initialRevision={contentRevision}
onStatsChange={handleStatsChange} initialConflictDetectionKey={conflictDetectionKey}
onSnapshot={handleSnapshot} pageOptions={options}
onCloseToc={closeToc} readOnly={readOnly}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => { onStatsChange={handleStatsChange}
setContentRevision(revision); onSnapshot={handleSnapshot}
setConflictDetectionKey(nextConflictDetectionKey); onCloseToc={closeToc}
}} onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
/> setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/>
</div>
)}
{!isEditing && (
<div className="relative" ref={readViewRootRef}>
<DocumentReadView
content={content}
documentId={documentId}
options={options}
pageSubtree={pageSubtree}
className="mx-auto w-full max-w-[980px]"
/>
<DocumentToc
entries={readViewTocEntries}
visible={options.showToc}
onJump={jumpToHeading}
onClose={closeToc}
/>
</div>
)}
</div>
)} )}
<PageBacklinksPanel <PageBacklinksPanel
className="mt-10" className="mt-10"
@@ -740,18 +903,18 @@ export function DocumentContent({
onToggle={toggleOption} onToggle={toggleOption}
onSetPageFont={handleSetPageFont} onSetPageFont={handleSetPageFont}
onSetLayoutDensity={handleSetLayoutDensity} onSetLayoutDensity={handleSetLayoutDensity}
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor} onSetEmbedDefaultToCursor={inspectorCanUseEditorBridge ? handleSetEmbedDefaultToCursor : undefined}
onClearEmbedDefault={handleClearEmbedDefault} onClearEmbedDefault={handleClearEmbedDefault}
onExport={handleExport} onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)} onOpenHistory={() => setHistoryOpen(true)}
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })} onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
onUndo={handleUndo} onUndo={canEditDocument ? handleUndo : undefined}
onRedo={handleRedo} onRedo={canEditDocument ? handleRedo : undefined}
onDeletePage={handleDeletePage} onDeletePage={canEditDocument ? handleDeletePage : undefined}
onOpenMoveEmbedPicker={handleOpenMoveEmbed} onOpenMoveEmbedPicker={canEditDocument ? handleOpenMoveEmbed : undefined}
onCopyPageLink={handleCopyPageLink} onCopyPageLink={handleCopyPageLink}
onCopyPageReference={handleCopyPageReference} onCopyPageReference={handleCopyPageReference}
onAddToTemplates={handleAddToTemplates} onAddToTemplates={canEditDocument ? handleAddToTemplates : undefined}
/> />
)} )}
</div> </div>
@@ -762,7 +925,11 @@ export function DocumentContent({
onRestore={handleRestoreSnapshot} onRestore={handleRestoreSnapshot}
/> />
<DocumentCommentsDrawer /> <DocumentCommentsDrawer />
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} /> <DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={() => latestBlocksRef.current}
getLatestPageSubtree={() => pageSubtree}
/>
</ImagePickerProvider> </ImagePickerProvider>
); );
} }
@@ -0,0 +1,615 @@
"use client";
import Link from "next/link";
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc";
import {
buildPageSubtreeProjection,
clampHeadingLevel,
extractPageBlocks,
getInlineText,
getPageBlockChildren,
type PageOutlineEntry,
type PageSubtreeBlock,
type PageSubtreeInlineNode,
type PageSubtreeProjection,
} from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
interface DocumentReadViewProps {
content: unknown;
documentId: string;
options: PageOptionsState;
pageSubtree?: PageSubtreeProjection | null;
className?: string;
}
const TODO_STATUS_LABELS: Record<string, string> = {
todo: "未开始",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
};
const LIST_INDENT_CLASS = [
"",
"ml-5",
"ml-9",
"ml-12",
"ml-16",
];
const toInlineNodes = (value: unknown): PageSubtreeInlineNode[] => {
if (!Array.isArray(value)) {
return [];
}
return value as PageSubtreeInlineNode[];
};
const buildTextStyle = (styles: Record<string, unknown> | undefined): CSSProperties => {
const style: CSSProperties = {};
const textColor = styles?.textColor;
const backgroundColor = styles?.backgroundColor;
if (typeof textColor === "string" && textColor.trim()) {
style.color = textColor;
}
if (typeof backgroundColor === "string" && backgroundColor.trim()) {
style.backgroundColor = backgroundColor;
}
return style;
};
const applyInlineMarks = (
content: ReactNode,
styles: Record<string, unknown> | undefined,
key: string,
): ReactNode => {
let node = content;
if (styles?.bold) {
node = <strong key={`${key}-bold`}>{node}</strong>;
}
if (styles?.italic) {
node = <em key={`${key}-italic`}>{node}</em>;
}
if (styles?.underline) {
node = <u key={`${key}-underline`}>{node}</u>;
}
if (styles?.strike) {
node = <s key={`${key}-strike`}>{node}</s>;
}
if (styles?.code) {
node = (
<code
key={`${key}-code`}
className="rounded bg-[#f4f4f5] px-1 py-0.5 font-mono text-[0.92em] text-[#d97706]"
>
{node}
</code>
);
}
const style = buildTextStyle(styles);
if (Object.keys(style).length > 0) {
node = (
<span key={`${key}-style`} style={style}>
{node}
</span>
);
}
return node;
};
const renderInlineNode = (node: unknown, key: string): ReactNode => {
if (typeof node === "string") {
return node;
}
if (!node || typeof node !== "object") {
return null;
}
const typedNode = node as PageSubtreeInlineNode;
if (typedNode.type === "link") {
const href = typeof typedNode.href === "string" && typedNode.href.trim() ? typedNode.href : "#";
const textContent = renderInlineNodes(typedNode.content, `${key}-content`);
return (
<a
key={key}
href={href}
target={href.startsWith("/") ? undefined : "_blank"}
rel={href.startsWith("/") ? undefined : "noreferrer"}
className="text-[#2563eb] underline underline-offset-2"
>
{textContent.length > 0 ? textContent : href}
</a>
);
}
const text = typeof typedNode.text === "string" ? typedNode.text : "";
return (
<span key={key}>
{applyInlineMarks(text, typedNode.styles, key)}
</span>
);
};
const renderInlineNodes = (nodes: unknown, keyPrefix: string): ReactNode[] => {
return toInlineNodes(nodes).map((node, index) => renderInlineNode(node, `${keyPrefix}-${index}`));
};
const buildHeadingNumberingMap = (blocks: PageSubtreeBlock[]): Map<string, string> => {
const counters = [0, 0, 0, 0, 0];
const numberingById = new Map<string, string>();
const walk = (targetBlocks: PageSubtreeBlock[]) => {
targetBlocks.forEach((block) => {
if (block.type === "heading") {
const level = clampHeadingLevel(block.props?.level);
counters[level - 1] += 1;
for (let index = level; index < counters.length; index += 1) {
counters[index] = 0;
}
if (block.id) {
numberingById.set(
block.id,
counters
.slice(0, level)
.filter((value) => value > 0)
.join("."),
);
}
}
const children = getPageBlockChildren(block.children);
if (children.length > 0) {
walk(children);
}
});
};
walk(blocks);
return numberingById;
};
const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<string, string> => {
return new Map(
outline
.filter((entry) => entry.anchorBlockId)
.map((entry) => [entry.anchorBlockId as string, entry.numbering]),
);
};
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
return buildPageSubtreeProjection({
documentId: "preview",
title: "预览",
content: blocks,
}).outline.map(({ id, level, numbering, title }) => ({
id,
level,
numbering,
title,
}));
};
const renderChildren = (
block: PageSubtreeBlock,
options: PageOptionsState,
documentId: string,
headingNumberingById: Map<string, string>,
depth: number,
) => {
const children = getPageBlockChildren(block.children);
if (children.length === 0) {
return null;
}
return (
<div className={cn("mt-2 space-y-1", LIST_INDENT_CLASS[Math.min(depth + 1, LIST_INDENT_CLASS.length - 1)])}>
{renderBlocks(children, options, documentId, headingNumberingById, depth + 1)}
</div>
);
};
const renderMediaBlock = (block: PageSubtreeBlock) => {
const props = block.props ?? {};
const assetType = String(props.assetType ?? "image");
const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : "";
const thumbnailUrl =
typeof props.thumbnailUrl === "string" && props.thumbnailUrl.trim() ? props.thumbnailUrl : fileUrl;
const fileName =
typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源";
const caption = typeof props.caption === "string" ? props.caption.trim() : "";
if (!fileUrl) {
return (
<div className="rounded-2xl border border-dashed border-[#d4d4d8] bg-[#fafafa] px-4 py-6 text-sm text-[#71717a]">
</div>
);
}
if (assetType === "image") {
return (
<figure className="space-y-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbnailUrl}
alt={caption || fileName}
className="max-h-[560px] w-auto max-w-full rounded-2xl border border-[#f1f5f9] object-contain shadow-sm"
/>
{(caption || fileName) && (
<figcaption className="text-sm text-[#71717a]">{caption || fileName}</figcaption>
)}
</figure>
);
}
if (assetType === "video") {
return (
<figure className="space-y-3">
<video controls className="max-h-[560px] w-full rounded-2xl border border-[#f1f5f9] bg-black">
<source src={fileUrl} />
</video>
{(caption || fileName) && (
<figcaption className="text-sm text-[#71717a]">{caption || fileName}</figcaption>
)}
</figure>
);
}
if (assetType === "audio") {
return (
<div className="space-y-3 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] p-4">
<div className="text-sm font-medium text-[#27272a]">{caption || fileName}</div>
<audio controls className="w-full">
<source src={fileUrl} />
</audio>
</div>
);
}
return (
<a
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-3 text-sm text-[#27272a] transition hover:border-[#cbd5e1] hover:bg-white"
>
<span className="truncate">{caption || fileName}</span>
<span className="ml-4 shrink-0 text-xs text-[#71717a]"></span>
</a>
);
};
const renderBlock = (
block: PageSubtreeBlock,
options: PageOptionsState,
documentId: string,
headingNumberingById: Map<string, string>,
depth: number,
orderedIndex: number,
): ReactNode => {
const key = block.id ?? `${block.type ?? "block"}-${depth}-${orderedIndex}`;
const props = block.props ?? {};
const inlineContent = renderInlineNodes(block.content, key);
const plainText = getInlineText(block.content).trim();
const children = renderChildren(block, options, documentId, headingNumberingById, depth);
if (block.type === "pageReference" && Boolean(props.asChildPage) && options.hideChildPages) {
return null;
}
switch (block.type) {
case "heading": {
const level = clampHeadingLevel(props.level);
const numbering = block.id ? headingNumberingById.get(block.id) ?? "" : "";
const HeadingTag = (`h${level}` as "h1" | "h2" | "h3" | "h4" | "h5");
return (
<div key={key} className="space-y-2">
<HeadingTag
data-id={block.id}
id={block.id}
className={cn(
"scroll-mt-24 font-semibold tracking-tight text-[#18181b]",
level === 1 && "text-[2rem]",
level === 2 && "text-[1.6rem]",
level === 3 && "text-[1.3rem]",
level >= 4 && "text-[1.08rem]",
)}
>
{options.showHeadingNumbers && numbering ? (
<span className="mr-2 text-[#94a3b8]">{numbering}</span>
) : null}
{inlineContent.length > 0 ? inlineContent : "未命名标题"}
</HeadingTag>
{children}
</div>
);
}
case "bulletListItem":
return (
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
<span className="mt-2 text-sm text-[#64748b]"></span>
<div className="min-w-0 flex-1 space-y-2">
<div className="leading-7 text-[#27272a]">{inlineContent}</div>
{children}
</div>
</div>
);
case "numberedListItem":
return (
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
<span className="mt-1.5 min-w-5 text-right text-sm font-medium text-[#64748b]">{orderedIndex}.</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="leading-7 text-[#27272a]">{inlineContent}</div>
{children}
</div>
</div>
);
case "checkListItem": {
const checked = Boolean(props.checked);
return (
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
<span className="mt-1.5 text-lg leading-none text-[#2563eb]">{checked ? "☑" : "☐"}</span>
<div className={cn("min-w-0 flex-1 space-y-2 leading-7 text-[#27272a]", checked && "text-[#71717a] line-through")}>
<div>{inlineContent}</div>
{children}
</div>
</div>
);
}
case "quote":
return (
<blockquote
key={key}
className="border-l-4 border-[#dbeafe] bg-[#f8fbff] px-4 py-3 text-[#334155]"
>
<div className="leading-7">{inlineContent}</div>
{children}
</blockquote>
);
case "codeBlock":
return (
<div key={key} className="space-y-2">
<pre className="overflow-x-auto rounded-2xl bg-[#0f172a] p-4 text-sm text-[#e2e8f0]">
<code>{plainText}</code>
</pre>
{children}
</div>
);
case "pageReference": {
const pageId = typeof props.pageId === "string" ? props.pageId : "";
const title = typeof props.title === "string" && props.title.trim() ? props.title : "未命名页面";
return (
<div key={key} className="space-y-2">
<Link
href={pageId ? `/documents/${pageId}` : "#"}
className="inline-flex items-center gap-2 rounded-xl border border-[#e4e4e7] bg-[#fafafa] px-3 py-2 text-sm font-medium text-[#27272a] transition hover:border-[#cbd5e1] hover:bg-white"
>
<span className="text-[#94a3b8]"></span>
<span>{title}</span>
</Link>
{children}
</div>
);
}
case "blockReference": {
const sourceDocumentId = typeof props.sourceDocumentId === "string" ? props.sourceDocumentId : "";
const targetBlockId = typeof props.targetBlockId === "string" ? props.targetBlockId : "";
const href = sourceDocumentId ? `/documents/${sourceDocumentId}${targetBlockId ? `#${targetBlockId}` : ""}` : "#";
return (
<div key={key} className="space-y-2 rounded-2xl border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3">
<Link href={href} className="text-sm font-medium text-[#2563eb] underline underline-offset-2">
</Link>
{children}
</div>
);
}
case "advancedTodo": {
const status = String(props.status ?? "todo");
const faded = status === "done" || status === "cancelled";
return (
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-white px-4 py-3">
<div className="flex items-start gap-3">
<span
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
status === "done" && "bg-[#dcfce7] text-[#166534]",
status === "doing" && "bg-[#dbeafe] text-[#1d4ed8]",
status === "cancelled" && "bg-[#f3f4f6] text-[#6b7280]",
status === "todo" && "bg-[#fef3c7] text-[#92400e]",
)}
>
{TODO_STATUS_LABELS[status] ?? "未开始"}
</span>
<div className={cn("min-w-0 flex-1 leading-7 text-[#27272a]", faded && "text-[#71717a] line-through")}>
{inlineContent}
</div>
</div>
{children}
</div>
);
}
case "progressMeter": {
const percent = Math.min(100, Math.max(0, Number(props.percent ?? 0) || 0));
const summary = typeof props.summary === "string" && props.summary.trim() ? props.summary : "暂无条目";
return (
<div key={key} className="space-y-3 rounded-2xl border border-[#dbeafe] bg-[#f8fbff] px-4 py-4">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-[#1e3a8a]">{summary}</span>
<span className="text-[#64748b]">{percent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#dbeafe]">
<div className="h-full rounded-full bg-[#2563eb]" style={{ width: `${percent}%` }} />
</div>
{children}
</div>
);
}
case "media":
return (
<div key={key} className="space-y-2">
{renderMediaBlock(block)}
{children}
</div>
);
case "onlineTable": {
const tableId = typeof props.tableId === "string" ? props.tableId : "";
return (
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-4">
<div className="text-sm font-medium text-[#27272a]">
{typeof props.title === "string" && props.title.trim() ? props.title : "在线表格"}
</div>
<Link
href={tableId ? `/tables/${tableId}/view` : "#"}
className="inline-flex items-center text-sm text-[#2563eb] underline underline-offset-2"
>
</Link>
{children}
</div>
);
}
case "mindmap": {
const mindmapId = typeof block.id === "string" ? block.id : "";
const docId = typeof props.docId === "string" && props.docId.trim() ? props.docId : documentId;
return (
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-4">
<div className="text-sm font-medium text-[#27272a]"></div>
<Link
href={docId && mindmapId ? `/mindmap/${docId}/${mindmapId}` : "#"}
className="inline-flex items-center text-sm text-[#2563eb] underline underline-offset-2"
>
</Link>
{children}
</div>
);
}
case "paragraph":
return (
<div key={key} className="space-y-2">
<p className="min-h-7 whitespace-pre-wrap break-words leading-7 text-[#27272a]">
{inlineContent.length > 0 ? inlineContent : <span className="text-[#d4d4d8]"> </span>}
</p>
{children}
</div>
);
default:
if (inlineContent.length === 0 && !children) {
return null;
}
return (
<div key={key} className="space-y-2">
{inlineContent.length > 0 ? (
<div className="whitespace-pre-wrap break-words leading-7 text-[#27272a]">{inlineContent}</div>
) : null}
{children}
</div>
);
}
};
const renderBlocks = (
blocks: PageSubtreeBlock[],
options: PageOptionsState,
documentId: string,
headingNumberingById: Map<string, string>,
depth: number,
) => {
let orderedIndex = 0;
return blocks.map((block, index) => {
orderedIndex = block.type === "numberedListItem" ? orderedIndex + 1 : 0;
return renderBlock(block, options, documentId, headingNumberingById, depth, orderedIndex || index + 1);
});
};
function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeProjection }) {
const outlineEntries = pageSubtree.outline.slice(0, 10);
const evidenceEntries = pageSubtree.evidence.slice(0, 5);
return (
<section className="rounded-2xl border border-[#dbe4f0] bg-[#f8fbff] p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-[#1e293b]"> / Kernel Outline</div>
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
{pageSubtree.subtree.nodes.length}
</span>
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
{pageSubtree.stats.headingCount}
</span>
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
{pageSubtree.stats.evidenceCount}
</span>
</div>
<div className="mt-3 grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-[#64748b]"></div>
{outlineEntries.length > 0 ? (
<ul className="space-y-1.5 text-sm text-[#334155]">
{outlineEntries.map((entry) => (
<li key={entry.nodeId} className={cn(entry.level > 1 && "pl-4", entry.level > 2 && "pl-7", entry.level > 3 && "pl-10")}>
<span className="mr-2 font-mono text-[11px] text-[#94a3b8]">{entry.numbering}</span>
<span>{entry.title || "未命名标题"}</span>
</li>
))}
</ul>
) : (
<div className="text-sm text-[#64748b]"> subtree</div>
)}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-[#64748b]"></div>
{evidenceEntries.length > 0 ? (
<ul className="space-y-2">
{evidenceEntries.map((entry) => (
<li key={entry.id} className="rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-sm text-[#334155]">
<div className="text-[11px] uppercase tracking-[0.12em] text-[#94a3b8]">{entry.kind}</div>
<div className="mt-1 line-clamp-2">{entry.snippet}</div>
</li>
))}
</ul>
) : (
<div className="text-sm text-[#64748b]"></div>
)}
</div>
</div>
</section>
);
}
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
const blocks = extractReadViewBlocks(content);
const resolvedPageSubtree =
pageSubtree ??
buildPageSubtreeProjection({
documentId,
title: null,
content,
});
const headingNumberingById =
resolvedPageSubtree.outline.length > 0
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline)
: buildHeadingNumberingMap(blocks);
if (blocks.length === 0) {
return (
<div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
</div>
</div>
);
}
return (
<div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
</div>
);
}
@@ -1,37 +1,8 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import type { DocumentContentProps } from "@/components/editor/document-content"; import type { DocumentContentProps } from "@/components/editor/document-content";
import { DocumentContent } from "@/components/editor/document-content";
const DocumentContent = dynamic(
() => import("@/components/editor/document-content").then((mod) => mod.DocumentContent),
{
ssr: false,
loading: () => (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
),
},
);
export function DocumentShell(props: DocumentContentProps) { export function DocumentShell(props: DocumentContentProps) {
const [mounted, setMounted] = useState(false);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setMounted(true);
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
if (!mounted) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
);
}
return <DocumentContent {...props} />; return <DocumentContent {...props} />;
} }
@@ -0,0 +1,677 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Bot, Settings, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { clamp } from "@/lib/constants";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { useOnlyOfficeAiBridgeStore } from "@/store/onlyoffice-ai-bridge";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentMessage = { role: "user" | "assistant"; content: string };
type ToolLog =
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "info"; message: string }
| { type: "error"; message: string };
type PanelPage = "chat" | "tools" | "settings";
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
const DEFAULT_MESSAGES: AgentMessage[] = [
{
role: "assistant",
content:
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
},
];
const ONLINE_MODELS = [
"",
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-3-pro-preview",
"gemini-3-flash-preview",
] as const;
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ""));
reader.onerror = () => reject(new Error("读取图片失败(FileReader"));
reader.readAsDataURL(blob);
});
export function OnlyOfficeAiAgentPanelRuntime({
openFile,
initialOpen = false,
}: {
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
initialOpen?: boolean;
}) {
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const bridgePluginReady = useOnlyOfficeAiBridgeStore((s) => s.pluginReady);
const bridgeTargetOrigin = useOnlyOfficeAiBridgeStore((s) => s.targetOrigin);
const bridgeTargetWindow = useOnlyOfficeAiBridgeStore((s) => s.targetWindow);
const [open, setOpen] = useState(initialOpen);
const [page, setPage] = useState<PanelPage>("chat");
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10);
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
const pendingPluginCallsRef = useRef<
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
>(new Map());
const attachments = useMemo<AgentAttachment[]>(
() => [
{
id: openFile.id,
title: openFile.title,
fileUrl: openFile.fileUrl,
mimeType: openFile.mimeType ?? null,
},
],
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
}, []);
useEffect(() => {
writeAiPanelPrefs("onlyoffice_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
});
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => {
pluginTargetRef.current = {
win: bridgeTargetWindow,
origin: bridgeTargetOrigin || "*",
};
}, [bridgeTargetOrigin, bridgeTargetWindow]);
useEffect(() => {
const onMessage = (ev: MessageEvent) => {
const data = ev.data as unknown;
if (!isRecord(data)) return;
if (data.channel !== CHANNEL) return;
const type = String(data.type ?? "").trim();
if (type === "ready") {
return;
}
if (type === "result") {
const callId = String(data.callId ?? "").trim();
if (!callId) return;
const pending = pendingPluginCallsRef.current.get(callId);
if (!pending) return;
pendingPluginCallsRef.current.delete(callId);
window.clearTimeout(pending.timeoutId);
const ok = Boolean(data.ok);
if (ok) {
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
} else {
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
}
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, []);
useEffect(() => {
const pendingPluginCalls = pendingPluginCallsRef.current;
return () => {
abortRef.current?.abort();
abortRef.current = null;
pendingPluginCalls.forEach(({ reject, timeoutId }) => {
window.clearTimeout(timeoutId);
reject(new Error("OnlyOffice AI 面板已卸载"));
});
pendingPluginCalls.clear();
};
}, []);
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => {
const target = pluginTargetRef.current;
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
const result = await new Promise<unknown>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
pendingPluginCallsRef.current.delete(callId);
reject(new Error("插件调用超时"));
}, 60_000);
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
try {
target.win!.postMessage(payload, target.origin || "*");
} catch (e) {
window.clearTimeout(timeoutId);
pendingPluginCallsRef.current.delete(callId);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
return result;
};
const postClientToolResult = async ({
requestId,
callId,
ok,
result,
error,
}: {
requestId: string;
callId: string;
ok: boolean;
result?: unknown;
error?: string;
}) => {
await fetch("/api/ai-agent/client-tool-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId, callId, ok, result, error }),
});
};
const resolveImageRefToDataUrl = async (imageRef: string) => {
const s = String(imageRef ?? "").trim();
if (!s) throw new Error("缺少 imageRef");
// 1) 优先当作附件 id
const match = attachments.find((a) => a.id === s) ?? null;
const url = match ? match.fileUrl : s;
const res = await fetch(url);
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
const blob = await res.blob();
return await blobToDataUrl(blob);
};
const handleClientToolCall = async (payloadText: string) => {
let data: unknown = null;
try {
data = JSON.parse(payloadText || "null");
} catch {
return;
}
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
const requestId = String(obj.requestId ?? "").trim();
const callId = String(obj.callId ?? "").trim();
const tool = String(obj.tool ?? "").trim();
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
if (!requestId || !callId || !tool) return;
try {
let result: unknown = null;
if (tool === "oo_insert_image") {
const imageRef = String(args.imageRef ?? "").trim();
const src = await resolveImageRefToDataUrl(imageRef);
const width = Number(args.width ?? 0);
const height = Number(args.height ?? 0);
result = await callPlugin(callId, tool, { ...args, src, width, height });
} else {
result = await callPlugin(callId, tool, args);
}
await postClientToolResult({ requestId, callId, ok: true, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await postClientToolResult({ requestId, callId, ok: false, error: msg });
}
};
const stop = () => {
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
if (aiProvider === "codex") {
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
}
};
const send = async () => {
const text = input.trim();
if (!text) return;
if (loading) return;
const codexSessionIdForRequest =
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
setMessages(nextMessages);
setInput("");
setLoading(true);
setToolLogs([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch("/api/ai-agent/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
stream: true,
maxSteps,
scope: "onlyoffice",
messages: nextMessages.slice(-20),
attachments,
toolChoice: {
mode: toolAuto ? "auto" : "manual",
toolSets: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.docs_read",
"toolset.onlyoffice_editor",
],
},
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(aiModel || "").trim() } : {}),
},
},
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => null)) as unknown;
const err =
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
throw new Error(err || `HTTP ${res.status}`);
}
await parseSseChunks(res, (event, dataText) => {
if (event === "codex_session") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
const sid = String(obj.sessionId ?? "").trim();
if (sid) {
setCodexSessionId(sid);
}
} catch {
// ignore
}
return;
}
if (event === "assistant_message") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
} catch {
// ignore
}
return;
}
if (event === "client_tool_call") {
void handleClientToolCall(dataText);
return;
}
if (event === "tool_call") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
setToolLogs((prev) => [
...prev,
{
type: "tool_call",
id: String(obj.id ?? ""),
tool: String(obj.tool ?? ""),
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
},
]);
} catch {
// ignore
}
return;
}
if (event === "tool_result") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
setToolLogs((prev) => [
...prev,
{
type: "tool_result",
id: String(obj.id ?? ""),
tool: String(obj.tool ?? ""),
ok: Boolean(obj.ok),
ms: Number(obj.ms ?? 0),
result: "result" in obj ? obj.result : null,
},
]);
} catch {
// ignore
}
return;
}
if (event === "error") {
if (controller.signal.aborted && aiProvider === "codex") return;
try {
const d = JSON.parse(dataText || "null") as unknown;
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
} catch {
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
}
return;
}
});
} catch (e) {
if (controller.signal.aborted && aiProvider === "codex") return;
const msg = e instanceof Error ? e.message : String(e);
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
} finally {
abortRef.current = null;
setLoading(false);
}
};
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
return (
<>
<div className="fixed bottom-4 right-4 z-[60]">
<Button
className="shadow"
onClick={() => {
setOpen((v) => !v);
if (!open) setPage("chat");
}}
>
<Bot className="mr-2 h-4 w-4" />
AI
</Button>
</div>
{open ? (
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
<AiBridgePanel
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
subtitle="OnlyOffice AI"
status={loading ? "运行中" : "待命"}
onClose={() => setOpen(false)}
scrollBody={false}
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("chat")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Bot className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("tools")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("settings")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
</>
}
>
{page === "tools" ? (
<div className="space-y-3 p-3 text-sm">
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<input
type="checkbox"
checked={networkOn}
onChange={(e) => setNetworkOn(e.target.checked)}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<input
type="checkbox"
checked={toolAuto}
onChange={(e) => setToolAuto(e.target.checked)}
disabled={loading}
/>
</div>
<div className="rounded border p-2 text-xs text-muted-foreground">
<div>{bridgePluginReady ? "已连接" : "未连接(等待 ready"}</div>
<div>oo_* </div>
</div>
<details open className="rounded border p-2">
<summary className="cursor-pointer select-none text-sm font-medium"></summary>
<div className="mt-2 space-y-2">
{toolLogs.length === 0 ? <div className="text-muted-foreground"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</div>
);
}
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
</div>
);
})}
</div>
</details>
</div>
) : null}
{page === "settings" ? (
<div className="space-y-3 p-3 text-sm">
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
<input
className="w-[96px] rounded border px-2 py-1 text-xs"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium">AI </span>
<select
className="rounded border px-2 py-1 text-xs"
value={aiProvider}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
{aiProvider === "codex" ? (
<div className="text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#dev</code> <code className="rounded bg-muted px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
className="w-[220px] rounded border px-2 py-1 text-xs"
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
{ONLINE_MODELS.map((m) => (
<option key={m} value={m}>
{m || "默认"}
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="w-[320px] rounded border px-2 py-1 text-xs"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
className="w-[320px] rounded border px-2 py-1 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
disabled={loading}
list="oo-local-model-suggestions"
/>
)}
</label>
<datalist id="oo-local-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
) : null}
{page === "chat" ? (
<div className="flex h-[calc(100vh-96px)] flex-col">
<ScrollArea className="flex-1">
<div className="space-y-3 p-3 text-sm">
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
</ScrollArea>
<div className="border-t p-3">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
className="min-h-[72px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="flex flex-col gap-2">
<Button disabled={!canSend} onClick={() => void send()}>
</Button>
<Button variant="secondary" disabled={!loading} onClick={stop}>
{aiProvider === "codex" ? "暂停" : "停止"}
</Button>
</div>
</div>
</div>
</div>
) : null}
</AiBridgePanel>
</div>
) : null}
</>
);
}
export { OnlyOfficeAiAgentPanelRuntime as OnlyOfficeAiAgentPanel };
@@ -1,658 +1,70 @@
"use client"; "use client";
import React, { useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic";
import { Bot, Settings, Wrench } from "lucide-react"; import { useEffect, useState } from "react";
import { Bot } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AiBridgePanel } from "@/components/ai-agent/AiBridgePanel"; import { useOnlyOfficeAiBridgeStore } from "@/store/onlyoffice-ai-bridge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import { clamp } from "@/lib/constants";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { parseSseChunks, readAiPanelPrefs, type AiProvider, writeAiPanelPrefs } from "@/components/ai-agent/panelShared";
type AgentMessage = { role: "user" | "assistant"; content: string }; type OnlyOfficeAiAgentPanelProps = {
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
type ToolLog = };
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
| { type: "info"; message: string }
| { type: "error"; message: string };
type PanelPage = "chat" | "tools" | "settings";
type AgentAttachment = { id: string; title: string; fileUrl: string; mimeType?: string | null };
const CHANNEL = "mnote_onlyoffice_agent_tools_v1"; const CHANNEL = "mnote_onlyoffice_agent_tools_v1";
const DEFAULT_MESSAGES: AgentMessage[] = [ const OnlyOfficeAiAgentPanelRuntime = dynamic<OnlyOfficeAiAgentPanelProps & { initialOpen?: boolean }>(
() => import("./OnlyOfficeAiAgentPanel.runtime").then((mod) => mod.OnlyOfficeAiAgentPanelRuntime),
{ {
role: "assistant", ssr: false,
content: loading: () => null,
"你好,我是 OnlyOffice AI Agent。\n- 先选中一段文字,再说“改写/补全/翻译/润色/删除/插入”\n- 我会通过 oo_* 工具读取/替换选区\n- 需要引用资料时可联网检索或用 LightRAG/文档检索",
}, },
]; );
const ONLINE_MODELS = [ // 轻量 host:常驻接住插件 ready 握手,真正的面板 runtime 在首次点击后再挂载。
"", export function OnlyOfficeAiAgentPanel(props: OnlyOfficeAiAgentPanelProps) {
"gemini-2.5-flash", const captureReady = useOnlyOfficeAiBridgeStore((state) => state.captureReady);
"gemini-2.5-pro", const reset = useOnlyOfficeAiBridgeStore((state) => state.reset);
"gemini-3-pro-preview", const [activated, setActivated] = useState(false);
"gemini-3-flash-preview",
] as const;
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ""));
reader.onerror = () => reject(new Error("读取图片失败(FileReader"));
reader.readAsDataURL(blob);
});
export function OnlyOfficeAiAgentPanel({
openFile,
}: {
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
}) {
const flightMode = useAppPreferencesStore((s) => s.flightMode);
const [open, setOpen] = useState(false);
const [page, setPage] = useState<PanelPage>("chat");
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_MESSAGES);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
const [networkOn, setNetworkOn] = useState(() => !flightMode);
const [toolAuto, setToolAuto] = useState(true);
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
const [aiModel, setAiModel] = useState<string>("");
const [maxSteps, setMaxSteps] = useState<number>(10);
// Codex:每个面板对话维护一个 session,支持连续对话与“暂停(类似 ESC)”
const [codexSessionId, setCodexSessionId] = useState<string | null>(null);
const [pluginReady, setPluginReady] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const pluginTargetRef = useRef<{ win: Window | null; origin: string }>({ win: null, origin: "*" });
const pendingPluginCallsRef = useRef<
Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timeoutId: number }>
>(new Map());
const attachments = useMemo<AgentAttachment[]>(
() => [
{
id: openFile.id,
title: openFile.title,
fileUrl: openFile.fileUrl,
mimeType: openFile.mimeType ?? null,
},
],
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
);
useEffect(() => {
if (flightMode) {
setNetworkOn(false);
}
}, [flightMode]);
useEffect(() => {
const prefs = readAiPanelPrefs("onlyoffice_ai", { provider: "online", model: "", maxSteps: 10 });
setAiProvider(prefs.provider);
setAiModel(prefs.model);
setMaxSteps(clamp(Math.floor(prefs.maxSteps), 1, 24));
}, []);
useEffect(() => {
writeAiPanelPrefs("onlyoffice_ai", {
provider: aiProvider,
model: aiModel,
maxSteps: clamp(Math.floor(maxSteps), 1, 24),
});
}, [aiProvider, aiModel, maxSteps]);
useEffect(() => { useEffect(() => {
const onMessage = (ev: MessageEvent) => { const onMessage = (ev: MessageEvent) => {
const data = ev.data as unknown; const data = ev.data as unknown;
if (!isRecord(data)) return; if (!data || typeof data !== "object") return;
if (data.channel !== CHANNEL) return;
const type = String(data.type ?? "").trim(); const channel = "channel" in (data as Record<string, unknown>) ? String((data as Record<string, unknown>).channel ?? "") : "";
if (type === "ready") { const type = "type" in (data as Record<string, unknown>) ? String((data as Record<string, unknown>).type ?? "") : "";
// 记录插件窗口与来源,后续回发消息更稳 if (channel !== CHANNEL || type !== "ready") return;
pluginTargetRef.current = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
win: (ev.source as any) && typeof (ev.source as any).postMessage === "function" ? ((ev.source as any) as Window) : null,
origin: String(ev.origin || "*"),
};
setPluginReady(true);
return;
}
if (type === "result") { const targetWindow =
const callId = String(data.callId ?? "").trim(); ev.source && typeof (ev.source as Window).postMessage === "function" ? (ev.source as Window) : null;
if (!callId) return; captureReady({
const pending = pendingPluginCallsRef.current.get(callId); targetOrigin: String(ev.origin || "*"),
if (!pending) return; targetWindow,
pendingPluginCallsRef.current.delete(callId); });
window.clearTimeout(pending.timeoutId);
const ok = Boolean(data.ok);
if (ok) {
pending.resolve("result" in data ? (data as Record<string, unknown>).result : null);
} else {
pending.reject(new Error(String((data as Record<string, unknown>).error ?? "插件执行失败")));
}
}
}; };
window.addEventListener("message", onMessage); window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage); return () => {
}, []); window.removeEventListener("message", onMessage);
reset();
};
}, [captureReady, reset]);
const callPlugin = async (callId: string, tool: string, args: Record<string, unknown>) => { if (!activated) {
const target = pluginTargetRef.current; return (
if (!target.win) throw new Error("插件未就绪(未收到 ready),请稍等或刷新文档");
const payload = { channel: CHANNEL, type: "call", callId, tool, args };
const result = await new Promise<unknown>((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
pendingPluginCallsRef.current.delete(callId);
reject(new Error("插件调用超时"));
}, 60_000);
pendingPluginCallsRef.current.set(callId, { resolve, reject, timeoutId });
try {
target.win!.postMessage(payload, target.origin || "*");
} catch (e) {
window.clearTimeout(timeoutId);
pendingPluginCallsRef.current.delete(callId);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
return result;
};
const postClientToolResult = async ({
requestId,
callId,
ok,
result,
error,
}: {
requestId: string;
callId: string;
ok: boolean;
result?: unknown;
error?: string;
}) => {
await fetch("/api/ai-agent/client-tool-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestId, callId, ok, result, error }),
});
};
const resolveImageRefToDataUrl = async (imageRef: string) => {
const s = String(imageRef ?? "").trim();
if (!s) throw new Error("缺少 imageRef");
// 1) 优先当作附件 id
const match = attachments.find((a) => a.id === s) ?? null;
const url = match ? match.fileUrl : s;
const res = await fetch(url);
if (!res.ok) throw new Error(`图片下载失败:HTTP ${res.status}`);
const blob = await res.blob();
return await blobToDataUrl(blob);
};
const handleClientToolCall = async (payloadText: string) => {
let data: unknown = null;
try {
data = JSON.parse(payloadText || "null");
} catch {
return;
}
const obj = isRecord(data) ? data : ({} as Record<string, unknown>);
const requestId = String(obj.requestId ?? "").trim();
const callId = String(obj.callId ?? "").trim();
const tool = String(obj.tool ?? "").trim();
const args = isRecord(obj.args) ? (obj.args as Record<string, unknown>) : {};
if (!requestId || !callId || !tool) return;
try {
let result: unknown = null;
if (tool === "oo_insert_image") {
const imageRef = String(args.imageRef ?? "").trim();
const src = await resolveImageRefToDataUrl(imageRef);
const width = Number(args.width ?? 0);
const height = Number(args.height ?? 0);
result = await callPlugin(callId, tool, { ...args, src, width, height });
} else {
result = await callPlugin(callId, tool, args);
}
await postClientToolResult({ requestId, callId, ok: true, result });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
await postClientToolResult({ requestId, callId, ok: false, error: msg });
}
};
const stop = () => {
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
if (aiProvider === "codex") {
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
}
};
const send = async () => {
const text = input.trim();
if (!text) return;
if (loading) return;
const codexSessionIdForRequest =
aiProvider === "codex" ? String(codexSessionId ?? "").trim() || null : null;
const nextMessages: AgentMessage[] = [...messages, { role: "user", content: text }];
setMessages(nextMessages);
setInput("");
setLoading(true);
setToolLogs([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const res = await fetch("/api/ai-agent/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
stream: true,
maxSteps,
scope: "onlyoffice",
messages: nextMessages.slice(-20),
attachments,
toolChoice: {
mode: toolAuto ? "auto" : "manual",
toolSets: [
"toolset.readonly",
"toolset.rag_read",
"toolset.media_read",
"toolset.docs_read",
"toolset.onlyoffice_editor",
],
},
options: {
searxng: networkOn,
ai: {
provider: aiProvider,
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
...(aiProvider !== "codex" && String(aiModel || "").trim() ? { model: String(aiModel || "").trim() } : {}),
},
},
}),
});
if (!res.ok) {
const j = (await res.json().catch(() => null)) as unknown;
const err =
typeof j === "object" && j && "error" in j ? String((j as Record<string, unknown>).error ?? "") : "";
throw new Error(err || `HTTP ${res.status}`);
}
await parseSseChunks(res, (event, dataText) => {
if (event === "codex_session") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
const sid = String(obj.sessionId ?? "").trim();
if (sid) {
setCodexSessionId(sid);
}
} catch {
// ignore
}
return;
}
if (event === "assistant_message") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const t = isRecord(d) && "text" in d ? String(d.text ?? "") : "";
if (t) setMessages((prev) => [...prev, { role: "assistant", content: t }]);
} catch {
// ignore
}
return;
}
if (event === "client_tool_call") {
void handleClientToolCall(dataText);
return;
}
if (event === "tool_call") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
setToolLogs((prev) => [
...prev,
{
type: "tool_call",
id: String(obj.id ?? ""),
tool: String(obj.tool ?? ""),
args: (isRecord(obj.args) ? obj.args : {}) as Record<string, unknown>,
},
]);
} catch {
// ignore
}
return;
}
if (event === "tool_result") {
try {
const d = JSON.parse(dataText || "null") as unknown;
const obj = isRecord(d) ? d : ({} as Record<string, unknown>);
setToolLogs((prev) => [
...prev,
{
type: "tool_result",
id: String(obj.id ?? ""),
tool: String(obj.tool ?? ""),
ok: Boolean(obj.ok),
ms: Number(obj.ms ?? 0),
result: "result" in obj ? obj.result : null,
},
]);
} catch {
// ignore
}
return;
}
if (event === "error") {
if (controller.signal.aborted && aiProvider === "codex") return;
try {
const d = JSON.parse(dataText || "null") as unknown;
const msg = isRecord(d) && "message" in d ? String(d.message ?? "") : "";
setToolLogs((prev) => [...prev, { type: "error", message: msg || "未知错误" }]);
} catch {
setToolLogs((prev) => [...prev, { type: "error", message: "未知错误" }]);
}
return;
}
});
} catch (e) {
if (controller.signal.aborted && aiProvider === "codex") return;
const msg = e instanceof Error ? e.message : String(e);
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
} finally {
abortRef.current = null;
setLoading(false);
}
};
const canSend = useMemo(() => input.trim().length > 0 && !loading, [input, loading]);
return (
<>
<div className="fixed bottom-4 right-4 z-[60]"> <div className="fixed bottom-4 right-4 z-[60]">
<Button <Button
className="shadow" className="shadow"
onClick={() => { onClick={() => {
setOpen((v) => !v); setActivated(true);
if (!open) setPage("chat");
}} }}
> >
<Bot className="mr-2 h-4 w-4" /> <Bot className="mr-2 h-4 w-4" />
AI AI
</Button> </Button>
</div> </div>
);
}
{open ? ( return <OnlyOfficeAiAgentPanelRuntime {...props} initialOpen />;
<div className="fixed inset-y-0 right-0 z-[70] w-[min(960px,calc(100vw-24px))] p-3">
<AiBridgePanel
title={page === "chat" ? "OnlyOffice AI" : page === "tools" ? "OnlyOffice 工具" : "OnlyOffice 设置"}
subtitle="OnlyOffice AI"
status={loading ? "运行中" : "待命"}
onClose={() => setOpen(false)}
scrollBody={false}
className="h-[calc(100vh-24px)] min-h-0 rounded-[24px] border-white/10 shadow-none"
secondaryActions={
<>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("chat")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Bot className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("tools")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Wrench className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setPage("settings")}
className="rounded-xl border border-white/10 bg-white/[0.03] text-white/80 hover:bg-white/[0.08] hover:text-white"
>
<Settings className="h-4 w-4" />
</Button>
</>
}
>
{page === "tools" ? (
<div className="space-y-3 p-3 text-sm">
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<input
type="checkbox"
checked={networkOn}
onChange={(e) => setNetworkOn(e.target.checked)}
disabled={loading}
/>
</div>
<div className="flex items-center justify-between">
<div className="font-medium"></div>
<input
type="checkbox"
checked={toolAuto}
onChange={(e) => setToolAuto(e.target.checked)}
disabled={loading}
/>
</div>
<div className="rounded border p-2 text-xs text-muted-foreground">
<div>{pluginReady ? "已连接" : "未连接(等待 ready"}</div>
<div>oo_* </div>
</div>
<details open className="rounded border p-2">
<summary className="cursor-pointer select-none text-sm font-medium"></summary>
<div className="mt-2 space-y-2">
{toolLogs.length === 0 ? <div className="text-muted-foreground"></div> : null}
{toolLogs.map((l, idx) => {
if (l.type === "error") {
return (
<div key={idx} className="rounded border border-red-200 bg-red-50 p-2 text-red-700">
{l.message}
</div>
);
}
if (l.type === "info") {
return (
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
{l.message}
</div>
);
}
if (l.type === "tool_call") {
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">tool_call · {l.id}</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.args, null, 2)}</pre>
</div>
);
}
return (
<div key={idx} className="rounded border p-2">
<div className="text-xs text-muted-foreground">
tool_result · {l.id} · {l.ok ? "ok" : "fail"} · {l.ms}ms
</div>
<div className="font-medium">{l.tool}</div>
<pre className="mt-1 whitespace-pre-wrap text-xs">{JSON.stringify(l.result, null, 2)}</pre>
</div>
);
})}
</div>
</details>
</div>
) : null}
{page === "settings" ? (
<div className="space-y-3 p-3 text-sm">
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
<input
className="w-[96px] rounded border px-2 py-1 text-xs"
type="number"
min={1}
max={24}
step={1}
value={maxSteps}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isFinite(v)) return;
setMaxSteps(clamp(Math.floor(v), 1, 24));
}}
disabled={loading}
/>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium">AI </span>
<select
className="rounded border px-2 py-1 text-xs"
value={aiProvider}
onChange={(e) => {
const v = String(e.target.value || "").trim();
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
else setAiProvider("online");
}}
disabled={loading}
>
<option value="online">线</option>
<option value="local"></option>
<option value="ollama">Ollama</option>
<option value="codex">Codex</option>
</select>
</label>
<label className="flex items-center justify-between gap-2">
<span className="font-medium"></span>
{aiProvider === "codex" ? (
<div className="text-xs text-muted-foreground">
<code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
<code className="rounded bg-muted px-1 py-0.5">#dev</code> <code className="rounded bg-muted px-1 py-0.5">#chat</code>
</div>
) : aiProvider === "online" ? (
<select
className="w-[220px] rounded border px-2 py-1 text-xs"
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
{ONLINE_MODELS.map((m) => (
<option key={m} value={m}>
{m || "默认"}
</option>
))}
</select>
) : aiProvider === "ollama" ? (
<select
className="w-[320px] rounded border px-2 py-1 text-xs"
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
onChange={(e) => setAiModel(e.target.value)}
disabled={loading}
>
<option value="">{OLLAMA_QWEN3_30B}</option>
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
</select>
) : (
<input
className="w-[320px] rounded border px-2 py-1 text-xs"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="默认(ai.local.md/环境变量)"
disabled={loading}
list="oo-local-model-suggestions"
/>
)}
</label>
<datalist id="oo-local-model-suggestions">
<option value={OLLAMA_QWEN3_30B} />
</datalist>
</div>
) : null}
{page === "chat" ? (
<div className="flex h-[calc(100vh-96px)] flex-col">
<ScrollArea className="flex-1">
<div className="space-y-3 p-3 text-sm">
{messages.map((m, idx) => (
<div key={idx} className="space-y-1">
<div className="text-xs text-muted-foreground">{m.role === "user" ? "用户" : "AI"}</div>
<div className="whitespace-pre-wrap">{m.content}</div>
</div>
))}
</div>
</ScrollArea>
<div className="border-t p-3">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入你的需求(Enter 发送,Shift+Enter 换行)"
className="min-h-[72px] flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (canSend) void send();
}
}}
/>
<div className="flex flex-col gap-2">
<Button disabled={!canSend} onClick={() => void send()}>
</Button>
<Button variant="secondary" disabled={!loading} onClick={stop}>
{aiProvider === "codex" ? "暂停" : "停止"}
</Button>
</div>
</div>
</div>
</div>
) : null}
</AiBridgePanel>
</div>
) : null}
</>
);
} }
@@ -0,0 +1,50 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect } from "react";
import { useSearchPaletteStore } from "@/store/search-palette";
type SearchPaletteProps = {
workspaceId: string | null;
};
const SearchPaletteRuntime = dynamic<SearchPaletteProps>(
() => import("./search-palette.runtime").then((mod) => mod.SearchPalette),
{
ssr: false,
loading: () => null,
},
);
// 轻量 host:负责首开前热键与懒加载,重型搜索面板首次打开后再进入运行态。
export function SearchPaletteHost({ workspaceId }: SearchPaletteProps) {
const openSearch = useSearchPaletteStore((state) => state.openSearch);
const openReference = useSearchPaletteStore((state) => state.openReference);
const activated = useSearchPaletteStore((state) => state.activated);
useEffect(() => {
if (activated) {
return undefined;
}
const handleGlobalHotkey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault();
openSearch();
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
event.preventDefault();
openReference();
}
};
window.addEventListener("keydown", handleGlobalHotkey);
return () => window.removeEventListener("keydown", handleGlobalHotkey);
}, [activated, openReference, openSearch]);
if (!activated) {
return null;
}
return <SearchPaletteRuntime workspaceId={workspaceId} />;
}
@@ -0,0 +1,657 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DragEvent as ReactDragEvent } from "react";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useReferenceComposer } from "@/hooks/use-reference-composer";
import { recordRecentPage } from "@/lib/search/record-recent";
import { PageHoverCard } from "@/components/reference/page-hover-card";
import { buildSearchRequest } from "@/lib/search/request";
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
interface SearchPaletteProps {
workspaceId: string | null;
}
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
any: "全部时间",
"7d": "最近 7 天",
"30d": "最近 30 天",
};
const formatDate = (value: string | null) => {
if (!value) return "未知时间";
const date = new Date(value);
return date.toLocaleString();
};
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
const router = useRouter();
const segments = useSelectedLayoutSegments();
const activeDocumentId = segments?.[1] ?? null;
const inputRef = useRef<HTMLInputElement>(null);
const {
open,
mode,
query,
filters,
timeRange,
referenceMode,
alias,
recent,
setRecent,
openSearch,
openReference,
close,
setQuery,
toggleFilter,
setTimeRange,
setTimeField,
setCustomRange,
setReferenceMode,
setAlias,
rememberResult,
} = useSearchPaletteStore();
const [highlightedIndex, setHighlightedIndex] = useState(0);
const { insertReference } = useReferenceComposer({
workspaceId,
sourcePageId: activeDocumentId ?? null,
});
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
const updateCustomRange = useCallback(
(patch: { from?: string; to?: string }) => {
const next = { ...(filters.customRange ?? {}), ...patch };
if (!next.from && !next.to) {
setCustomRange(null);
} else {
setCustomRange(next);
}
},
[filters.customRange, setCustomRange],
);
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
const requestPayload = useMemo<DocumentSearchRequest | null>(
() =>
buildSearchRequest({
workspaceId,
activeDocumentId,
query,
filters,
timeRange,
}),
[workspaceId, activeDocumentId, query, filters, timeRange],
);
const searchQueryEnabled = Boolean(open && workspaceId);
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
useEffect(() => {
if (data?.recent) {
setRecent(data.recent);
}
}, [data?.recent, setRecent]);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (mode === "reference") {
setReferenceFiltersOpen(true);
} else {
setReferenceFiltersOpen(false);
}
}, [mode]);
/* eslint-enable react-hooks/set-state-in-effect */
const searchResults = data?.results;
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
const showRecent = !query.trim() && recent.length > 0;
const enableRecentTab = showRecent;
const effectiveTab = enableRecentTab ? resultTab : "all";
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
useEffect(() => {
if (!open) {
return undefined;
}
inputRef.current?.focus();
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
return () => cancelAnimationFrame(frame);
}, [open]);
useEffect(() => {
if (highlightedIndex >= activeResults.length) {
const frame = requestAnimationFrame(() =>
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
);
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [activeResults.length, highlightedIndex]);
useEffect(() => {
const handleGlobalHotkey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault();
openSearch();
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
event.preventDefault();
openReference();
}
};
window.addEventListener("keydown", handleGlobalHotkey);
return () => window.removeEventListener("keydown", handleGlobalHotkey);
}, [openSearch, openReference]);
const handleOpenResult = useCallback(
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
if (openMode === "new-window") {
window.open(result.publicPath, "_blank", "noopener,noreferrer");
} else if (openMode === "sidebar") {
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
} else {
router.push(result.publicPath);
}
void recordRecentPage(workspaceId, result.id);
rememberResult(result);
close();
},
[router, workspaceId, rememberResult, close],
);
const handleInsertReference = useCallback(
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
const run = async () => {
try {
const effectiveMode = overrideMode ?? referenceMode;
await insertReference(result, {
mode: effectiveMode,
alias: effectiveMode === "inline" ? alias : undefined,
});
rememberResult(result);
close();
} catch (error) {
console.error(error);
}
};
void run();
},
[alias, close, insertReference, referenceMode, rememberResult],
);
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
if (typeof window === "undefined") {
return;
}
const payload = `((${result.id}))`;
if (navigator?.clipboard) {
navigator.clipboard
.writeText(payload)
.then(() => window.alert("块引用已复制"))
.catch(() => {
window.prompt("复制失败,请手动复制引用内容", payload);
});
} else {
window.prompt("复制块引用", payload);
}
}, []);
useEffect(() => {
if (!open) {
return;
}
const handleKey = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
if (activeResults.length === 0) {
return;
}
const result = activeResults[highlightedIndex] ?? activeResults[0];
if (!result) return;
if (mode === "search") {
const openMode = resolveSearchOpenMode(event);
handleOpenResult(result, openMode);
} else {
handleInsertReference(result);
}
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
const pending = isLoading || isFetching;
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent className="max-h-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
<DialogDescription className="sr-only">
OCR/
</DialogDescription>
<div className="flex h-[min(78vh,720px)] min-h-[560px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue.trim().length > 0 && resultTab !== "all") {
setResultTab("all");
}
setQuery(nextValue);
}}
placeholder={mode === "search" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
<CalendarClock className="h-4 w-4" />
{TIME_RANGE_LABEL[timeRange]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
<DropdownMenuItem
key={range}
onClick={() => setTimeRange(range)}
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
>
{TIME_RANGE_LABEL[range]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-xs text-gray-600">
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => setTimeField("updated")}
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTimeField("created")}
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
<FilterToggle
label="当前页面"
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
disabled={!activeDocumentId}
onClick={() => toggleFilter("onlyCurrentPage")}
/>
<FilterToggle
label="搜索附件内容"
active={filters.includeOcr}
onClick={() => toggleFilter("includeOcr")}
/>
{mode === "reference" && (
<Button
type="button"
size="sm"
variant="ghost"
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
>
<ChevronsUpDown className="h-3 w-3" />
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
</Button>
)}
</div>
{mode === "reference" && referenceFiltersOpen && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<ReferenceModeToggle
label="行内引用"
active={referenceMode === "inline"}
onClick={() => setReferenceMode("inline")}
/>
<ReferenceModeToggle
label="嵌入块"
active={referenceMode === "embed"}
onClick={() => setReferenceMode("embed")}
/>
{referenceMode === "inline" && (
<Input
value={alias}
onChange={(event) => setAlias(event.target.value)}
placeholder="引用别名"
className="h-8 w-40 text-xs"
/>
)}
<span className="text-[11px] text-gray-400"> [[ / # </span>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span></span>
<Input
type="date"
value={filters.customRange?.from ?? ""}
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Input
type="date"
value={filters.customRange?.to ?? ""}
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-gray-500"
onClick={clearCustomRange}
disabled={!filters.customRange?.from && !filters.customRange?.to}
>
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden">
{enableRecentTab && (
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
<TabButton
label="最近访问"
active={resultTab === "recent"}
onClick={() => setResultTab("recent")}
disabled={!enableRecentTab}
/>
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
</div>
)}
{pending ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
...
</div>
) : error ? (
<div className="flex h-full items-center justify-center text-sm text-red-500">
{(error as Error).message}
</div>
) : activeResults.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
</div>
) : (
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
{activeResults.map((result, index) => (
<ResultRow
key={`result-${result.id}-${index}-${resultTab}`}
result={result}
highlighted={highlightedIndex === index}
mode={mode}
onOpen={() => handleOpenResult(result, "main")}
onReference={() => handleInsertReference(result)}
onEmbed={() => handleInsertReference(result, "embed")}
onCopy={() => handleCopyReference(result)}
/>
))}
</div>
)}
</div>
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
{mode === "search" ? (
<div className="flex items-center justify-between">
<span>Enter · Ctrl/Cmd+Enter · Alt+Enter </span>
<span>Esc </span>
</div>
) : (
<div className="flex items-center justify-between">
<span>
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"}
</span>
<span>Esc </span>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ResultRow({
result,
highlighted,
mode,
onOpen,
onReference,
onEmbed,
onCopy,
}: {
result: DocumentSearchResult;
highlighted: boolean;
mode: SearchPaletteMode;
onOpen: () => void;
onReference: () => void;
onEmbed: () => void;
onCopy: () => void;
}) {
const handleActivate = () => {
if (mode === "search") {
onOpen();
} else {
onReference();
}
};
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
if (mode !== "reference") return;
if (event.dataTransfer) {
event.dataTransfer.setData("text/plain", `((${result.id}))`);
event.dataTransfer.effectAllowed = "copy";
}
};
const content = (
<div
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleActivate();
}
}}
className={cn(
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
highlighted && "bg-[#e3ecff]",
)}
draggable={mode === "reference"}
onDragStart={handleDragStart}
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
{result.title || "无标题"}
{result.matchField !== "recent" && (
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
</Badge>
)}
{result.hasOcr && (
<Badge variant="outline" className="text-[10px] text-[#475569]">
OCR
</Badge>
)}
</div>
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
<div className="mt-2 text-[11px] text-gray-400">
{formatDate(result.updatedAt)} · {formatDate(result.createdAt)}
</div>
{mode === "reference" && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onReference();
}}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onEmbed();
}}
>
<Layers className="h-3 w-3" />
...
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCopy();
}}
>
<Copy className="h-3 w-3" />
</Button>
<span className="ml-auto text-[10px] text-gray-400"></span>
</div>
)}
</div>
);
return (
<HoverCard openDelay={250}>
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
<HoverCardContent align="start" className="w-[min(720px,90vw)] max-w-[720px]">
<PageHoverCard result={result} onPreview={onOpen} />
</HoverCardContent>
</HoverCard>
);
}
interface FilterToggleProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"h-7 rounded-full border px-3 text-xs",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface TabButtonProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"rounded-full px-4 text-xs",
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface ReferenceModeToggleProps {
label: string;
active: boolean;
onClick: () => void;
}
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"h-7 rounded-full border px-3 text-[11px]",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
@@ -1,657 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SearchPaletteHost } from "./SearchPaletteHost";
import type { DragEvent as ReactDragEvent } from "react";
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { useSearchPaletteStore, type ReferenceInsertMode, type SearchPaletteMode } from "@/store/search-palette";
import type { DocumentSearchResult, DocumentSearchRequest, DocumentSearchTimeRange } from "@/types/search";
import { useDocumentSearch } from "@/hooks/use-document-search";
import { useReferenceComposer } from "@/hooks/use-reference-composer";
import { recordRecentPage } from "@/lib/search/record-recent";
import { PageHoverCard } from "@/components/reference/page-hover-card";
import { buildSearchRequest } from "@/lib/search/request";
import { resolveSearchOpenMode } from "@/lib/search/shortcuts";
interface SearchPaletteProps { // 轻量入口:供全局布局接线,真正的搜索运行态由 host 首次打开时按需加载。
workspaceId: string | null; export const SearchPalette = SearchPaletteHost;
}
const TIME_RANGE_LABEL: Record<DocumentSearchTimeRange, string> = {
any: "全部时间",
"7d": "最近 7 天",
"30d": "最近 30 天",
};
const formatDate = (value: string | null) => {
if (!value) return "未知时间";
const date = new Date(value);
return date.toLocaleString();
};
export function SearchPalette({ workspaceId }: SearchPaletteProps) {
const router = useRouter();
const segments = useSelectedLayoutSegments();
const activeDocumentId = segments?.[1] ?? null;
const inputRef = useRef<HTMLInputElement>(null);
const {
open,
mode,
query,
filters,
timeRange,
referenceMode,
alias,
recent,
setRecent,
openSearch,
openReference,
close,
setQuery,
toggleFilter,
setTimeRange,
setTimeField,
setCustomRange,
setReferenceMode,
setAlias,
rememberResult,
} = useSearchPaletteStore();
const [highlightedIndex, setHighlightedIndex] = useState(0);
const { insertReference } = useReferenceComposer({
workspaceId,
sourcePageId: activeDocumentId ?? null,
});
const [resultTab, setResultTab] = useState<"all" | "recent">(recent.length > 0 ? "recent" : "all");
const [referenceFiltersOpen, setReferenceFiltersOpen] = useState(mode === "reference");
const updateCustomRange = useCallback(
(patch: { from?: string; to?: string }) => {
const next = { ...(filters.customRange ?? {}), ...patch };
if (!next.from && !next.to) {
setCustomRange(null);
} else {
setCustomRange(next);
}
},
[filters.customRange, setCustomRange],
);
const clearCustomRange = useCallback(() => setCustomRange(null), [setCustomRange]);
const requestPayload = useMemo<DocumentSearchRequest | null>(
() =>
buildSearchRequest({
workspaceId,
activeDocumentId,
query,
filters,
timeRange,
}),
[workspaceId, activeDocumentId, query, filters, timeRange],
);
const searchQueryEnabled = Boolean(open && workspaceId);
const { data, isLoading, isFetching, error } = useDocumentSearch(requestPayload, searchQueryEnabled);
useEffect(() => {
if (data?.recent) {
setRecent(data.recent);
}
}, [data?.recent, setRecent]);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (mode === "reference") {
setReferenceFiltersOpen(true);
} else {
setReferenceFiltersOpen(false);
}
}, [mode]);
/* eslint-enable react-hooks/set-state-in-effect */
const searchResults = data?.results;
const remoteResults = useMemo(() => searchResults ?? [], [searchResults]);
const showRecent = !query.trim() && recent.length > 0;
const enableRecentTab = showRecent;
const effectiveTab = enableRecentTab ? resultTab : "all";
const activeResults = effectiveTab === "recent" ? recent : remoteResults;
useEffect(() => {
if (!open) {
return undefined;
}
inputRef.current?.focus();
const frame = requestAnimationFrame(() => setHighlightedIndex(0));
return () => cancelAnimationFrame(frame);
}, [open]);
useEffect(() => {
if (highlightedIndex >= activeResults.length) {
const frame = requestAnimationFrame(() =>
setHighlightedIndex(activeResults.length > 0 ? activeResults.length - 1 : 0),
);
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [activeResults.length, highlightedIndex]);
useEffect(() => {
const handleGlobalHotkey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault();
openSearch();
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "r") {
event.preventDefault();
openReference();
}
};
window.addEventListener("keydown", handleGlobalHotkey);
return () => window.removeEventListener("keydown", handleGlobalHotkey);
}, [openSearch, openReference]);
const handleOpenResult = useCallback(
(result: DocumentSearchResult, openMode: "main" | "new-window" | "sidebar") => {
if (openMode === "new-window") {
window.open(result.publicPath, "_blank", "noopener,noreferrer");
} else if (openMode === "sidebar") {
window.open(`${result.publicPath}?preview=sidebar`, "_blank", "noopener,noreferrer");
} else {
router.push(result.publicPath);
}
void recordRecentPage(workspaceId, result.id);
rememberResult(result);
close();
},
[router, workspaceId, rememberResult, close],
);
const handleInsertReference = useCallback(
(result: DocumentSearchResult, overrideMode?: ReferenceInsertMode) => {
const run = async () => {
try {
const effectiveMode = overrideMode ?? referenceMode;
await insertReference(result, {
mode: effectiveMode,
alias: effectiveMode === "inline" ? alias : undefined,
});
rememberResult(result);
close();
} catch (error) {
console.error(error);
}
};
void run();
},
[alias, close, insertReference, referenceMode, rememberResult],
);
const handleCopyReference = useCallback((result: DocumentSearchResult) => {
if (typeof window === "undefined") {
return;
}
const payload = `((${result.id}))`;
if (navigator?.clipboard) {
navigator.clipboard
.writeText(payload)
.then(() => window.alert("块引用已复制"))
.catch(() => {
window.prompt("复制失败,请手动复制引用内容", payload);
});
} else {
window.prompt("复制块引用", payload);
}
}, []);
useEffect(() => {
if (!open) {
return;
}
const handleKey = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((prev) => Math.min(activeResults.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
if (activeResults.length === 0) {
return;
}
const result = activeResults[highlightedIndex] ?? activeResults[0];
if (!result) return;
if (mode === "search") {
const openMode = resolveSearchOpenMode(event);
handleOpenResult(result, openMode);
} else {
handleInsertReference(result);
}
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [open, activeResults, highlightedIndex, mode, handleOpenResult, handleInsertReference, close]);
const pending = isLoading || isFetching;
const dialogTitle = mode === "search" ? "页面搜索面板" : "引用选择面板";
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent className="max-h-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
<DialogDescription className="sr-only">
OCR/
</DialogDescription>
<div className="flex h-[min(78vh,720px)] min-h-[560px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue.trim().length > 0 && resultTab !== "all") {
setResultTab("all");
}
setQuery(nextValue);
}}
placeholder={mode === "search" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"}
className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2 text-xs text-gray-600">
<CalendarClock className="h-4 w-4" />
{TIME_RANGE_LABEL[timeRange]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(Object.keys(TIME_RANGE_LABEL) as DocumentSearchTimeRange[]).map((range) => (
<DropdownMenuItem
key={range}
onClick={() => setTimeRange(range)}
className={cn(range === timeRange && "bg-[#eef2ff] text-[#2563eb]")}
>
{TIME_RANGE_LABEL[range]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="text-xs text-gray-600">
{filters.timeField === "updated" ? "按编辑时间" : "按创建时间"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={() => setTimeField("updated")}
className={cn(filters.timeField === "updated" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTimeField("created")}
className={cn(filters.timeField === "created" && "bg-[#eef2ff] text-[#2563eb]")}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<FilterToggle label="仅标题" active={filters.titleOnly} onClick={() => toggleFilter("titleOnly")} />
<FilterToggle label="精确匹配" active={filters.exact} onClick={() => toggleFilter("exact")} />
<FilterToggle
label="当前页面"
active={filters.onlyCurrentPage && Boolean(activeDocumentId)}
disabled={!activeDocumentId}
onClick={() => toggleFilter("onlyCurrentPage")}
/>
<FilterToggle
label="搜索附件内容"
active={filters.includeOcr}
onClick={() => toggleFilter("includeOcr")}
/>
{mode === "reference" && (
<Button
type="button"
size="sm"
variant="ghost"
className="ml-auto flex items-center gap-1 rounded-full text-xs text-gray-600"
onClick={() => setReferenceFiltersOpen((prev) => !prev)}
>
<ChevronsUpDown className="h-3 w-3" />
{referenceFiltersOpen ? "隐藏引用筛选" : "展开引用筛选"}
</Button>
)}
</div>
{mode === "reference" && referenceFiltersOpen && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<ReferenceModeToggle
label="行内引用"
active={referenceMode === "inline"}
onClick={() => setReferenceMode("inline")}
/>
<ReferenceModeToggle
label="嵌入块"
active={referenceMode === "embed"}
onClick={() => setReferenceMode("embed")}
/>
{referenceMode === "inline" && (
<Input
value={alias}
onChange={(event) => setAlias(event.target.value)}
placeholder="引用别名"
className="h-8 w-40 text-xs"
/>
)}
<span className="text-[11px] text-gray-400"> [[ / # </span>
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span></span>
<Input
type="date"
value={filters.customRange?.from ?? ""}
onChange={(event) => updateCustomRange({ from: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Input
type="date"
value={filters.customRange?.to ?? ""}
onChange={(event) => updateCustomRange({ to: event.target.value || undefined })}
className="h-8 w-36 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-gray-500"
onClick={clearCustomRange}
disabled={!filters.customRange?.from && !filters.customRange?.to}
>
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden">
{enableRecentTab && (
<div className="flex items-center gap-2 border-b border-[#eef2ff] px-4 py-2 text-xs">
<TabButton
label="最近访问"
active={resultTab === "recent"}
onClick={() => setResultTab("recent")}
disabled={!enableRecentTab}
/>
<TabButton label="全部页面" active={resultTab === "all"} onClick={() => setResultTab("all")} />
</div>
)}
{pending ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
...
</div>
) : error ? (
<div className="flex h-full items-center justify-center text-sm text-red-500">
{(error as Error).message}
</div>
) : activeResults.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
{effectiveTab === "recent" ? "暂无最近访问记录" : "暂无匹配结果"}
</div>
) : (
<div className="h-full divide-y divide-[#f1f5f9] overflow-y-auto">
{activeResults.map((result, index) => (
<ResultRow
key={`result-${result.id}-${index}-${resultTab}`}
result={result}
highlighted={highlightedIndex === index}
mode={mode}
onOpen={() => handleOpenResult(result, "main")}
onReference={() => handleInsertReference(result)}
onEmbed={() => handleInsertReference(result, "embed")}
onCopy={() => handleCopyReference(result)}
/>
))}
</div>
)}
</div>
<div className="border-t border-[#eef2ff] px-4 py-2 text-xs text-gray-500">
{mode === "search" ? (
<div className="flex items-center justify-between">
<span>Enter · Ctrl/Cmd+Enter · Alt+Enter </span>
<span>Esc </span>
</div>
) : (
<div className="flex items-center justify-between">
<span>
{referenceMode === "inline" ? "插入行内引用(链接高亮)" : "插入一个嵌入的页面块"}
</span>
<span>Esc </span>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ResultRow({
result,
highlighted,
mode,
onOpen,
onReference,
onEmbed,
onCopy,
}: {
result: DocumentSearchResult;
highlighted: boolean;
mode: SearchPaletteMode;
onOpen: () => void;
onReference: () => void;
onEmbed: () => void;
onCopy: () => void;
}) {
const handleActivate = () => {
if (mode === "search") {
onOpen();
} else {
onReference();
}
};
const handleDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
if (mode !== "reference") return;
if (event.dataTransfer) {
event.dataTransfer.setData("text/plain", `((${result.id}))`);
event.dataTransfer.effectAllowed = "copy";
}
};
const content = (
<div
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleActivate();
}
}}
className={cn(
"cursor-pointer px-4 py-3 transition-colors hover:bg-[#eef2ff]",
highlighted && "bg-[#e3ecff]",
)}
draggable={mode === "reference"}
onDragStart={handleDragStart}
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
{result.title || "无标题"}
{result.matchField !== "recent" && (
<Badge variant="secondary" className="bg-[#edf2ff] text-xs text-[#2563eb]">
{result.matchField === "title" ? "标题匹配" : "正文匹配"}
</Badge>
)}
{result.hasOcr && (
<Badge variant="outline" className="text-[10px] text-[#475569]">
OCR
</Badge>
)}
</div>
<div className="mt-1 text-xs text-gray-500" dangerouslySetInnerHTML={{ __html: result.snippet }} />
<div className="mt-2 text-[11px] text-gray-400">
{formatDate(result.updatedAt)} · {formatDate(result.createdAt)}
</div>
{mode === "reference" && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onReference();
}}
>
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onEmbed();
}}
>
<Layers className="h-3 w-3" />
...
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="flex h-7 items-center gap-1 rounded-full px-3"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onCopy();
}}
>
<Copy className="h-3 w-3" />
</Button>
<span className="ml-auto text-[10px] text-gray-400"></span>
</div>
)}
</div>
);
return (
<HoverCard openDelay={250}>
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
<HoverCardContent align="start" className="w-[min(720px,90vw)] max-w-[720px]">
<PageHoverCard result={result} onPreview={onOpen} />
</HoverCardContent>
</HoverCard>
);
}
interface FilterToggleProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function FilterToggle({ label, active, onClick, disabled }: FilterToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"h-7 rounded-full border px-3 text-xs",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface TabButtonProps {
label: string;
active: boolean;
onClick: () => void;
disabled?: boolean;
}
function TabButton({ label, active, onClick, disabled }: TabButtonProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
disabled={disabled}
className={cn(
"rounded-full px-4 text-xs",
active ? "bg-[#2563eb] text-white" : "text-gray-600 hover:bg-[#eef2ff]",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface ReferenceModeToggleProps {
label: string;
active: boolean;
onClick: () => void;
}
function ReferenceModeToggle({ label, active, onClick }: ReferenceModeToggleProps) {
return (
<Button
type="button"
size="sm"
variant={active ? "default" : "ghost"}
className={cn(
"h-7 rounded-full border px-3 text-[11px]",
active
? "border-[#2563eb] bg-[#2563eb] text-white"
: "border-transparent text-gray-500 hover:bg-gray-100",
)}
onClick={onClick}
>
{label}
</Button>
);
}
@@ -16,18 +16,18 @@ import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; 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 { DocumentNode } from "@/lib/documents"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { flattenDocumentTree } from "@/lib/sidebar-tree"; import { flattenDocumentTree } from "@/lib/sidebar-tree";
interface PrivateTreeProps { interface PrivateTreeProps {
nodes: DocumentNode[]; nodes: SidebarTreeNode[];
expanded: Set<string>; expanded: Set<string>;
activeId: string; activeId: string;
onToggleExpand: (id: string) => void; onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void; onMove: (nodeId: string, parentId: string | null, index: number) => void;
onCreateChild: (parentId: string | null) => void; onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void; onContextMenu: (event: React.MouseEvent, node: SidebarTreeNode) => void;
} }
const ROW_HEIGHT = 36; const ROW_HEIGHT = 36;
@@ -172,7 +172,7 @@ function VirtualRow({
} }
interface SortableTreeRowProps { interface SortableTreeRowProps {
node: DocumentNode; node: SidebarTreeNode;
depth: number; depth: number;
expanded: boolean; expanded: boolean;
hasChildren: boolean; hasChildren: boolean;
@@ -33,11 +33,10 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { DocumentNode } from "@/lib/documents"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { buildDocumentTree } from "@/lib/documents";
import { useSidebarStore } from "@/store/sidebar"; import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useConvexSidebarData } from "@/hooks/use-convex-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, flattenDocumentTree } from "@/lib/sidebar-tree";
import { useSearchPaletteStore } from "@/store/search-palette"; import { useSearchPaletteStore } from "@/store/search-palette";
@@ -127,7 +126,7 @@ interface SidebarProps {
} }
interface ContextMenuState { interface ContextMenuState {
node: DocumentNode; node: SidebarTreeNode;
x: number; x: number;
y: number; y: number;
} }
@@ -138,8 +137,8 @@ export function Sidebar({ initialData }: SidebarProps) {
// Convex 模式专用组件 - 只调用 Convex hooks // Convex 模式专用组件 - 只调用 Convex hooks
function SidebarConvex({ initialData }: SidebarProps) { function SidebarConvex({ initialData }: SidebarProps) {
const convexData = useConvexSidebarData(initialData.activeWorkspaceId); const sidebarData = useSidebarData(initialData);
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />; return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} />;
} }
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
@@ -173,7 +172,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<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents)); 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);
@@ -227,7 +226,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []); const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false); const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move"); const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null); const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>( const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null, null,
); );
@@ -244,11 +243,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
useEffect(() => { useEffect(() => {
setTree(() => { setTree(() => {
const nextTree = buildDocumentTree(sidebarData.documents); const nextTree = sidebarData.kernelSidebarTree ?? [];
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev))); setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
return nextTree; return nextTree;
}); });
}, [sidebarData.documents]); }, [sidebarData.kernelSidebarTree]);
useEffect(() => { useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []); setMediaAssets(sidebarData.mediaAssets ?? []);
@@ -436,8 +435,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]); const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
const nodeById = useMemo(() => { const nodeById = useMemo(() => {
const map = new Map<string, DocumentNode>(); const map = new Map<string, SidebarTreeNode>();
const walk = (nodes: DocumentNode[]) => { const walk = (nodes: SidebarTreeNode[]) => {
nodes.forEach((node) => { nodes.forEach((node) => {
map.set(node.id, node); map.set(node.id, node);
if (node.children.length > 0) { if (node.children.length > 0) {
@@ -450,10 +449,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}, [tree]); }, [tree]);
const publicGroupNodesByGroupId = useMemo(() => { const publicGroupNodesByGroupId = useMemo(() => {
const map = new Map<string, DocumentNode[]>(); const map = new Map<string, SidebarTreeNode[]>();
for (const g of groupPublicSummary) { for (const g of groupPublicSummary) {
const nodes: DocumentNode[] = []; const nodes: SidebarTreeNode[] = [];
const uniq = new Map<string, DocumentNode>(); const uniq = new Map<string, SidebarTreeNode>();
for (const d of g.documents ?? []) { for (const d of g.documents ?? []) {
const node = nodeById.get(d.documentId); const node = nodeById.get(d.documentId);
if (node) { if (node) {
@@ -631,23 +630,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[router, setOpen], [router, setOpen],
); );
const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => { const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id); const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url; const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制"); await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
}, []); }, []);
const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => { const handleCopyReference = useCallback(async (node: SidebarTreeNode, mode: "inline" | "embed") => {
const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`; const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制"); await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
}, []); }, []);
const handleCopyId = useCallback(async (node: DocumentNode) => { const handleCopyId = useCallback(async (node: SidebarTreeNode) => {
await copyText(node.id, "页面 ID 已复制"); await copyText(node.id, "页面 ID 已复制");
}, []); }, []);
const handleDuplicateDocument = useCallback( const handleDuplicateDocument = useCallback(
async (node: DocumentNode) => { async (node: SidebarTreeNode) => {
const response = await fetch("/api/documents/duplicate", { const response = await fetch("/api/documents/duplicate", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -662,13 +661,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[refreshTree], [refreshTree],
); );
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => { const openMoveEmbedPicker = useCallback((node: SidebarTreeNode, nextMode: MoveEmbedMode) => {
setMoveEmbedSource(node); setMoveEmbedSource(node);
setMoveEmbedMode(nextMode); setMoveEmbedMode(nextMode);
setMoveEmbedOpen(true); setMoveEmbedOpen(true);
}, []); }, []);
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => { const handleEmbedPrompt = useCallback(async (node: SidebarTreeNode) => {
openMoveEmbedPicker(node, "embed"); openMoveEmbedPicker(node, "embed");
}, [openMoveEmbedPicker]); }, [openMoveEmbedPicker]);
@@ -1380,19 +1379,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return; return;
} }
const payload = (await response.json()) as DocumentNode; const payload = (await response.json()) as SidebarTreeNode;
const nextNode: DocumentNode = { const nextNode: SidebarTreeNode = {
...payload, ...payload,
access_scope: payload.access_scope ?? "private", access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false, is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at, updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题", title: payload.title ?? "无标题",
children: [], children: [],
kernel: {
nodeType: "page",
depth: 0,
position: payload.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
}; };
setTree((prev) => { setTree((prev) => {
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”) // 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: DocumentNode[]): boolean => { const exists = (nodes: SidebarTreeNode[]): boolean => {
for (const node of nodes) { for (const node of nodes) {
if (node.id === nextNode.id) return true; if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true; if (node.children.length > 0 && exists(node.children)) return true;
@@ -1442,7 +1448,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[refreshTree], [refreshTree],
); );
const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => { const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree); const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId); const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) { if (!removed) {
@@ -1730,7 +1736,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
); );
const handleMovePrompt = useCallback( const handleMovePrompt = useCallback(
async (node: DocumentNode) => { async (node: SidebarTreeNode) => {
openMoveEmbedPicker(node, "move"); openMoveEmbedPicker(node, "move");
}, },
[openMoveEmbedPicker], [openMoveEmbedPicker],
@@ -1753,7 +1759,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
); );
const handleDeleteFromContextMenuNode = useCallback( const handleDeleteFromContextMenuNode = useCallback(
async (node: DocumentNode) => { async (node: SidebarTreeNode) => {
if (viewMode === "filesystem") { if (viewMode === "filesystem") {
await handleDeleteFileTreeSelection(); await handleDeleteFileTreeSelection();
return; return;
@@ -2140,7 +2146,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
} }
}, [router, signOut, signingOut]); }, [router, signOut, signingOut]);
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => { const openContextMenu = useCallback((event: React.MouseEvent, node: SidebarTreeNode) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setContextMenu({ setContextMenu({
@@ -2150,7 +2156,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}); });
}, []); }, []);
const openShareDialog = useCallback((node: DocumentNode) => { const openShareDialog = useCallback((node: SidebarTreeNode) => {
setShareTarget({ setShareTarget({
id: node.id, id: node.id,
title: node.title ?? null, title: node.title ?? null,
@@ -2264,7 +2270,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
</div> </div>
<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: DocumentNode[]) => { const renderList = (nodes: SidebarTreeNode[]) => {
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes)); const flat = flattenDocumentTree(nodes, collectNodeIds(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>;
@@ -2842,7 +2848,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
interface SectionListProps { interface SectionListProps {
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
nodes: DocumentNode[]; nodes: SidebarTreeNode[];
collapsed: boolean; collapsed: boolean;
onToggle: () => void; onToggle: () => void;
} }
@@ -2885,14 +2891,14 @@ function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListPro
interface ContextMenuProps { interface ContextMenuProps {
contextMenu: ContextMenuState; contextMenu: ContextMenuState;
onClose: () => void; onClose: () => void;
onOpenRight: (node: DocumentNode) => void; onOpenRight: (node: SidebarTreeNode) => void;
onShare: (node: DocumentNode) => void; onShare: (node: SidebarTreeNode) => void;
onMove: (node: DocumentNode) => void; onMove: (node: SidebarTreeNode) => void;
onEmbed: (node: DocumentNode) => void; onEmbed: (node: SidebarTreeNode) => void;
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void; onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void;
onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void; onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void;
onCopyId: (node: DocumentNode) => void; onCopyId: (node: SidebarTreeNode) => void;
onDuplicate: (node: DocumentNode) => void; onDuplicate: (node: SidebarTreeNode) => void;
onRename: () => void; onRename: () => void;
onCreateChild: () => void; onCreateChild: () => void;
onConvertChild: () => void; onConvertChild: () => void;
@@ -3064,17 +3070,17 @@ function ContextMenu({
); );
} }
const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] => const cloneNodes = (nodes: SidebarTreeNode[]): SidebarTreeNode[] =>
nodes.map((node) => ({ nodes.map((node) => ({
...node, ...node,
children: cloneNodes(node.children), children: cloneNodes(node.children),
})); }));
const removeNode = ( const removeNode = (
nodes: DocumentNode[], nodes: SidebarTreeNode[],
targetId: string, targetId: string,
): { removed: DocumentNode | null; tree: DocumentNode[] } => { ): { removed: SidebarTreeNode | null; tree: SidebarTreeNode[] } => {
let removed: DocumentNode | null = null; let removed: SidebarTreeNode | null = null;
const nextTree = nodes const nextTree = nodes
.map((node) => { .map((node) => {
if (removed) return node; if (removed) return node;
@@ -3089,11 +3095,11 @@ const removeNode = (
} }
return node; return node;
}) })
.filter(Boolean) as DocumentNode[]; .filter(Boolean) as SidebarTreeNode[];
return { removed, tree: nextTree }; return { removed, tree: nextTree };
}; };
const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => { const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
if (!parentId) { if (!parentId) {
const next = [...nodes]; const next = [...nodes];
next.splice(Math.min(index, next.length), 0, newNode); next.splice(Math.min(index, next.length), 0, newNode);
@@ -3110,11 +3116,11 @@ const insertNode = (nodes: DocumentNode[], parentId: string | null, index: numbe
}); });
}; };
const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => { const filterTree = (nodes: SidebarTreeNode[], keyword: string): SidebarTreeNode[] => {
if (!keyword) { if (!keyword) {
return nodes; return nodes;
} }
const filtered: DocumentNode[] = []; const filtered: SidebarTreeNode[] = [];
nodes.forEach((node) => { nodes.forEach((node) => {
const childMatches = filterTree(node.children, keyword); const childMatches = filterTree(node.children, keyword);
const title = (node.title ?? "").toLowerCase(); const title = (node.title ?? "").toLowerCase();
@@ -3125,7 +3131,7 @@ const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
return filtered; return filtered;
}; };
function collectNodeIds(nodes: DocumentNode[], bag: Set<string> = new Set()): Set<string> { function collectNodeIds(nodes: SidebarTreeNode[], bag: Set<string> = new Set()): Set<string> {
nodes.forEach((node) => { nodes.forEach((node) => {
bag.add(node.id); bag.add(node.id);
collectNodeIds(node.children, bag); collectNodeIds(node.children, bag);
@@ -1,6 +1,7 @@
import type { DocumentRecord } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces"; import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates"; export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
@@ -16,6 +17,8 @@ export interface SidebarInitialData {
activeWorkspaceId: string; activeWorkspaceId: string;
workspaces: WorkspaceSummary[]; workspaces: WorkspaceSummary[];
documents: DocumentRecord[]; documents: DocumentRecord[];
kernelSidebarProjection?: KernelSidebarProjection | null;
kernelSidebarTree?: SidebarTreeNode[];
trashedDocuments: TrashRecord[]; trashedDocuments: TrashRecord[];
trashedMediaAssets?: MediaAsset[]; trashedMediaAssets?: MediaAsset[];
trashedMindmapAssets?: MediaAsset[]; trashedMindmapAssets?: MediaAsset[];
@@ -0,0 +1,387 @@
export type PageSubtreeInlineNode = {
type?: string;
text?: string;
href?: string;
content?: unknown;
styles?: Record<string, unknown>;
};
export type PageSubtreeBlock = {
id?: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: unknown;
};
export type PageSubtreeNodeType =
| "page"
| "section"
| "content_node"
| "reference_anchor"
| "mindmap";
export type PageSubtreeNode = {
id: string;
parentNodeId: string | null;
nodeType: PageSubtreeNodeType;
blockId: string | null;
anchorBlockId: string | null;
depth: number;
metadata: {
title: string | null;
textSnippet: string | null;
blockType: string | null;
headingLevel: number | null;
numbering: string | null;
childCount: number;
order: number;
path: string[];
};
};
export type PageOutlineEntry = {
id: string;
nodeId: string;
anchorBlockId: string | null;
title: string;
level: number;
numbering: string;
};
export type PageEvidenceItem = {
id: string;
nodeId: string;
blockId: string | null;
kind:
| "page"
| "heading"
| "paragraph"
| "list"
| "todo"
| "quote"
| "code"
| "media"
| "reference"
| "table"
| "mindmap"
| "text";
snippet: string;
};
export type PageSubtreeProjection = {
projectionId: string;
projection: "page_tree";
rootNodeId: string;
rootNode: PageSubtreeNode;
subtree: {
rootNodeId: string;
nodes: PageSubtreeNode[];
};
outline: PageOutlineEntry[];
evidence: PageEvidenceItem[];
stats: {
blockCount: number;
headingCount: number;
evidenceCount: number;
maxDepth: number;
};
};
const SNIPPET_MAX_LENGTH = 220;
const pickFirstText = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
};
const normalizeSnippet = (value: string) => value.replace(/\s+/g, " ").trim().slice(0, SNIPPET_MAX_LENGTH);
export const clampHeadingLevel = (value: unknown) => {
const level = Number(value);
if (Number.isNaN(level) || !Number.isFinite(level)) {
return 1;
}
return Math.min(5, Math.max(1, Math.trunc(level)));
};
export const extractPageBlocks = (content: unknown): PageSubtreeBlock[] => {
if (Array.isArray(content)) {
return content as PageSubtreeBlock[];
}
if (content && typeof content === "object") {
const blocks = (content as { blocks?: unknown }).blocks;
if (Array.isArray(blocks)) {
return blocks as PageSubtreeBlock[];
}
}
return [];
};
export const getPageBlockChildren = (value: unknown): PageSubtreeBlock[] => {
if (!Array.isArray(value)) {
return [];
}
return value as PageSubtreeBlock[];
};
export const getInlineText = (value: unknown): string => {
if (typeof value === "string") {
return value;
}
if (!Array.isArray(value)) {
return "";
}
return value
.map((node) => {
if (typeof node === "string") {
return node;
}
if (!node || typeof node !== "object") {
return "";
}
const typedNode = node as PageSubtreeInlineNode;
if (typedNode.type === "link") {
return getInlineText(typedNode.content);
}
return typeof typedNode.text === "string" ? typedNode.text : "";
})
.join("");
};
const getBlockSnippet = (block: PageSubtreeBlock): string => {
const props = block.props ?? {};
const inlineText = normalizeSnippet(getInlineText(block.content));
if (inlineText) {
return inlineText;
}
return normalizeSnippet(
pickFirstText(
props.title,
props.caption,
props.summary,
props.fileName,
props.name,
props.alt,
props.status,
),
);
};
const getBlockDisplayTitle = (block: PageSubtreeBlock, snippet: string) => {
const props = block.props ?? {};
switch (block.type) {
case "heading":
return snippet || "未命名标题";
case "pageReference":
return pickFirstText(props.title, snippet, "页面引用");
case "blockReference":
return pickFirstText(props.title, snippet, "块引用");
case "onlineTable":
return pickFirstText(props.title, snippet, "在线表格");
case "mindmap":
return pickFirstText(props.title, snippet, "思维导图");
case "media":
return pickFirstText(props.caption, props.fileName, snippet, "附件");
case "codeBlock":
return snippet || "代码块";
case "advancedTodo":
return snippet || "任务";
case "quote":
return snippet || "引用";
default:
return snippet || null;
}
};
const getNodeType = (block: PageSubtreeBlock): PageSubtreeNodeType => {
switch (block.type) {
case "heading":
return "section";
case "blockReference":
case "pageReference":
return "reference_anchor";
case "mindmap":
return "mindmap";
default:
return "content_node";
}
};
const getEvidenceKind = (block: PageSubtreeBlock): PageEvidenceItem["kind"] => {
switch (block.type) {
case "heading":
return "heading";
case "paragraph":
return "paragraph";
case "bulletListItem":
case "numberedListItem":
case "checkListItem":
return "list";
case "advancedTodo":
return "todo";
case "quote":
return "quote";
case "codeBlock":
return "code";
case "media":
return "media";
case "pageReference":
case "blockReference":
return "reference";
case "onlineTable":
return "table";
case "mindmap":
return "mindmap";
default:
return "text";
}
};
export function buildPageSubtreeProjection(input: {
documentId: string;
title: string | null;
content: unknown;
}): PageSubtreeProjection {
const documentId = String(input.documentId ?? "").trim();
const rootNodeId = documentId || "page:unknown";
const blocks = extractPageBlocks(input.content);
const rootTitle = pickFirstText(input.title, "无标题");
const rootNode: PageSubtreeNode = {
id: rootNodeId,
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title: rootTitle,
textSnippet: null,
blockType: "page",
headingLevel: null,
numbering: null,
childCount: blocks.length,
order: 0,
path: [rootNodeId],
},
};
const nodes: PageSubtreeNode[] = [rootNode];
const outline: PageOutlineEntry[] = [];
const evidence: PageEvidenceItem[] = [];
const headingCounters = [0, 0, 0, 0, 0];
const headingStack: Array<{ level: number; nodeId: string }> = [];
let order = 0;
let maxDepth = 0;
const walk = (items: PageSubtreeBlock[], parentBlockNodeId: string | null, depth: number, path: number[]) => {
items.forEach((block, index) => {
const blockId = typeof block.id === "string" && block.id.trim() ? block.id.trim() : null;
const nodeId = blockId ? `block:${blockId}` : `block:auto:${[...path, index].join(".")}`;
const snippet = getBlockSnippet(block);
const headingLevel = block.type === "heading" ? clampHeadingLevel(block.props?.level) : null;
let parentNodeId = parentBlockNodeId ?? headingStack.at(-1)?.nodeId ?? rootNodeId;
let numbering: string | null = null;
if (headingLevel != null) {
while (headingStack.length > 0 && headingStack[headingStack.length - 1]!.level >= headingLevel) {
headingStack.pop();
}
parentNodeId = headingStack.at(-1)?.nodeId ?? parentBlockNodeId ?? rootNodeId;
headingCounters[headingLevel - 1] += 1;
for (let counterIndex = headingLevel; counterIndex < headingCounters.length; counterIndex += 1) {
headingCounters[counterIndex] = 0;
}
numbering = headingCounters
.slice(0, headingLevel)
.filter((value) => value > 0)
.join(".");
}
order += 1;
const children = getPageBlockChildren(block.children);
const node: PageSubtreeNode = {
id: nodeId,
parentNodeId,
nodeType: getNodeType(block),
blockId,
anchorBlockId: blockId,
depth: depth + 1,
metadata: {
title: getBlockDisplayTitle(block, snippet),
textSnippet: snippet || null,
blockType: typeof block.type === "string" ? block.type : null,
headingLevel,
numbering,
childCount: children.length,
order,
path: [rootNodeId, ...path.map(String), String(index)],
},
};
nodes.push(node);
maxDepth = Math.max(maxDepth, node.depth);
if (headingLevel != null) {
outline.push({
id: blockId ?? node.id,
nodeId: node.id,
anchorBlockId: blockId,
title: node.metadata.title ?? "未命名标题",
level: headingLevel,
numbering: numbering ?? "",
});
headingStack.push({ level: headingLevel, nodeId: node.id });
}
if (snippet) {
evidence.push({
id: `evidence:${node.id}`,
nodeId: node.id,
blockId,
kind: getEvidenceKind(block),
snippet,
});
}
if (children.length > 0) {
walk(children, node.id, depth + 1, [...path, index]);
}
});
};
walk(blocks, null, 0, []);
if (rootTitle) {
evidence.unshift({
id: `evidence:${rootNodeId}`,
nodeId: rootNodeId,
blockId: null,
kind: "page",
snippet: rootTitle,
});
}
return {
projectionId: `page_subtree:${rootNodeId}`,
projection: "page_tree",
rootNodeId,
rootNode,
subtree: {
rootNodeId,
nodes,
},
outline,
evidence,
stats: {
blockCount: Math.max(0, nodes.length - 1),
headingCount: outline.length,
evidenceCount: evidence.length,
maxDepth,
},
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { DocumentNode } from "@/lib/documents"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
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";
@@ -12,7 +12,7 @@ export function buildVisibleRows({
assetChildrenByAssetId, assetChildrenByAssetId,
expandedAssetFolderIds, expandedAssetFolderIds,
}: { }: {
nodes: DocumentNode[]; nodes: SidebarTreeNode[];
expanded: Set<string>; expanded: Set<string>;
assetsByDoc: Record<string, MediaAsset[]>; assetsByDoc: Record<string, MediaAsset[]>;
assetChildrenByAssetId?: Record<string, MediaAsset[]>; assetChildrenByAssetId?: Record<string, MediaAsset[]>;
@@ -21,7 +21,7 @@ export function buildVisibleRows({
const rows: FileTreeRow[] = []; const rows: FileTreeRow[] = [];
const visitedDocIds = new Set<string>(); const visitedDocIds = new Set<string>();
const walk = (node: DocumentNode, depth: number) => { const walk = (node: SidebarTreeNode, depth: number) => {
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。 // 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。 // 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
if (visitedDocIds.has(node.id)) return; if (visitedDocIds.has(node.id)) return;
+3 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { DocumentNode } from "@/lib/documents"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder"; export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";
@@ -60,7 +60,7 @@ export type FileTreeRow =
depth: number; depth: number;
docId: string; docId: string;
parentDocId: string | null; parentDocId: string | null;
node: DocumentNode; node: SidebarTreeNode;
hasChildren: boolean; hasChildren: boolean;
isExpanded: boolean; isExpanded: boolean;
} }
@@ -70,7 +70,7 @@ export type FileTreeRow =
depth: number; depth: number;
docId: string; docId: string;
parentDocId: string; parentDocId: string;
node: DocumentNode; node: SidebarTreeNode;
} }
| { | {
kind: "asset-folder"; kind: "asset-folder";
+213
View File
@@ -0,0 +1,213 @@
import type { DocumentRecord } from "@/lib/documents";
export type KernelSidebarProjectionEdge = {
id: string;
edgeType: "parent_of";
workspaceId: string | null;
fromNodeId: string;
toNodeId: string;
};
export type KernelSidebarProjectionItem = {
nodeId: string;
parentNodeId: string | null;
nodeType: "page";
title: string | null;
depth: number;
position: number | null;
childCount: number;
expandedByDefault: boolean;
};
export type KernelSidebarProjection = {
projectionId: string;
projection: "sidebar_tree";
rootNodeId: string | null;
items: KernelSidebarProjectionItem[];
edges: KernelSidebarProjectionEdge[];
};
export type SidebarTreeNode = DocumentRecord & {
children: SidebarTreeNode[];
kernel?: {
nodeType: "page";
depth: number;
position: number | null;
childCount: number;
expandedByDefault: boolean;
};
};
function sortRecords(records: DocumentRecord[]) {
return [...records].sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
}
function dedupeRecords(records: DocumentRecord[]) {
const seen = new Set<string>();
const unique: DocumentRecord[] = [];
for (let index = records.length - 1; index >= 0; index -= 1) {
const record = records[index]!;
if (seen.has(record.id)) {
continue;
}
seen.add(record.id);
unique.push(record);
}
unique.reverse();
return unique;
}
function buildChildrenByParent(records: DocumentRecord[]) {
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
const recordIds = new Set(records.map((record) => record.id));
for (const record of records) {
const parentId =
record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
const bucket = childrenByParentId.get(parentId) ?? [];
bucket.push(record);
childrenByParentId.set(parentId, bucket);
}
for (const [parentId, bucket] of childrenByParentId.entries()) {
childrenByParentId.set(parentId, sortRecords(bucket));
}
return childrenByParentId;
}
export function buildKernelSidebarProjection(
records: DocumentRecord[],
): KernelSidebarProjection {
const uniqueRecords = dedupeRecords(records);
const recordById = new Map(uniqueRecords.map((record) => [record.id, record]));
const childrenByParentId = buildChildrenByParent(uniqueRecords);
const items: KernelSidebarProjectionItem[] = [];
const edges: KernelSidebarProjectionEdge[] = [];
const visited = new Set<string>();
const walk = (parentId: string | null, depth: number) => {
const children = childrenByParentId.get(parentId) ?? [];
for (const child of children) {
if (visited.has(child.id)) {
continue;
}
visited.add(child.id);
const childNodes = childrenByParentId.get(child.id) ?? [];
items.push({
nodeId: child.id,
parentNodeId: parentId,
nodeType: "page",
title: child.title ?? "无标题",
depth,
position: child.sort_order ?? null,
childCount: childNodes.length,
expandedByDefault: true,
});
if (parentId && recordById.has(parentId)) {
edges.push({
id: `edge:${parentId}:${child.id}:parent_of`,
edgeType: "parent_of",
workspaceId: child.workspace_id ?? null,
fromNodeId: parentId,
toNodeId: child.id,
});
}
walk(child.id, depth + 1);
}
};
walk(null, 0);
return {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items,
edges,
};
}
export function buildSidebarTreeFromKernelProjection(input: {
records: DocumentRecord[];
projection: KernelSidebarProjection;
}): SidebarTreeNode[] {
const recordById = new Map(input.records.map((record) => [record.id, record]));
const nodeMap = new Map<string, SidebarTreeNode>();
const itemById = new Map(input.projection.items.map((item) => [item.nodeId, item]));
for (const item of input.projection.items) {
const record = recordById.get(item.nodeId);
nodeMap.set(item.nodeId, {
access_scope: record?.access_scope ?? "private",
id: item.nodeId,
workspace_id: record?.workspace_id ?? "",
title: record?.title ?? item.title ?? "无标题",
parent_id: record?.parent_id ?? item.parentNodeId,
sort_order: record?.sort_order ?? item.position,
is_starred: record?.is_starred ?? false,
is_template: record?.is_template ?? false,
created_at: record?.created_at ?? "",
updated_at: record?.updated_at ?? null,
children: [],
kernel: {
nodeType: item.nodeType,
depth: item.depth,
position: item.position,
childCount: item.childCount,
expandedByDefault: item.expandedByDefault,
},
});
}
const roots: SidebarTreeNode[] = [];
for (const item of input.projection.items) {
const node = nodeMap.get(item.nodeId);
if (!node) continue;
const parentId = item.parentNodeId;
if (parentId && nodeMap.has(parentId)) {
nodeMap.get(parentId)!.children.push(node);
continue;
}
roots.push(node);
}
const sortTree = (nodes: SidebarTreeNode[]) => {
nodes.sort((a, b) => {
const orderA = a.kernel?.position ?? a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.kernel?.position ?? b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
nodes.forEach((node) => sortTree(node.children));
};
sortTree(roots);
// 防御性处理:如果 projection 丢了节点,但 records 里还在,补到根节点,避免页面从主导航消失。
const missingRoots = input.records
.filter((record) => !itemById.has(record.id))
.map((record) => ({
...record,
children: [],
kernel: {
nodeType: "page" as const,
depth: 0,
position: record.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
}));
if (missingRoots.length > 0) {
roots.push(...missingRoots);
sortTree(roots);
}
return roots;
}
@@ -0,0 +1,150 @@
export type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
export type MindmapRouteMeta = {
requestId?: string;
traceId?: string;
workspaceId?: string | null;
documentId?: string;
pageId?: string;
mindmapId?: string;
attachmentId?: string;
updatedAt?: string | null;
};
export type MindmapProjectionNode = {
uid: string;
text: string;
depth: number;
childCount: number;
};
export type MindmapProjection = {
projectionId: string;
projection: "mindmap_subtree";
documentId: string;
mindmapId: string;
rootNodeId: string | null;
title: string;
nodeCount: number;
nodes: MindmapProjectionNode[];
data: MindMapData;
meta: MindmapRouteMeta | null;
};
export const defaultMindmapData: MindMapData = {
data: { text: "中心主题" },
children: [],
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
// simple-mind-map 在文本字段缺失时会直接崩溃,这里统一做兜底归一化。
export const normalizeMindmapData = (input: unknown): unknown => {
if (!input || typeof input !== "object") return defaultMindmapData;
const root = (input as { root?: unknown }).root ?? input;
const walk = (node: unknown) => {
if (!isRecord(node)) return;
if (!isRecord(node.data)) {
node.data = {};
}
const rawText = node.data.text;
node.data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
const gen = node.data.generalization;
const fixGen = (value: unknown) => {
if (!isRecord(value)) return;
const text = value.text;
value.text = typeof text === "string" ? text : String(text ?? "");
};
if (Array.isArray(gen)) gen.forEach(fixGen);
else fixGen(gen);
if (Array.isArray(node.children)) {
node.children.forEach(walk);
}
};
walk(root);
return input;
};
// 持久化/初始化统一使用根节点对象,避免 wrapper 误传给 simple-mind-map。
export const canonicalizeMindmapData = (input: unknown): MindMapData => {
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as Record<string, unknown>;
const root =
normalized && typeof normalized === "object" && "root" in normalized
? normalized.root
: normalized;
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
};
export const extractMindmapTitle = (input: unknown): string => {
const canonical = canonicalizeMindmapData(input);
const text = canonical?.data?.text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
return "未命名导图";
};
export const summarizeMindmapProjectionNodes = (input: unknown): MindmapProjectionNode[] => {
const root = canonicalizeMindmapData(input);
const queue: Array<{ node: MindMapData; depth: number }> = [{ node: root, depth: 0 }];
const nodes: MindmapProjectionNode[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (!current) break;
const uidRaw = current.node?.data?.uid;
const textRaw = current.node?.data?.text;
const children = Array.isArray(current.node?.children) ? current.node.children : [];
nodes.push({
uid:
typeof uidRaw === "string" && uidRaw.trim()
? uidRaw
: `depth:${current.depth}:index:${nodes.length}`,
text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
depth: current.depth,
childCount: children.length,
});
children.forEach((child) => {
queue.push({
node: canonicalizeMindmapData(child),
depth: current.depth + 1,
});
});
}
return nodes;
};
export const buildMindmapProjection = (input: {
documentId: string;
mindmapId: string;
data: unknown;
meta?: unknown;
}): MindmapProjection => {
const data = canonicalizeMindmapData(input.data);
const nodes = summarizeMindmapProjectionNodes(data);
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
const rootNodeId = nodes[0]?.uid ?? null;
return {
projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
projection: "mindmap_subtree",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
title: extractMindmapTitle(data),
nodeCount: nodes.length,
nodes,
data,
meta,
};
};
@@ -44,6 +44,13 @@ type SearchDocumentsRustResult = {
hasOcr: boolean; hasOcr: boolean;
publicPath: string; publicPath: string;
score: number; score: number;
nodeId?: string | null;
subtreeRootId?: string | null;
evidence?: Array<{
kind: string;
nodeId?: string | null;
snippet: string;
}>;
}>; }>;
enqueueAssetIds?: string[]; enqueueAssetIds?: string[];
}; };
@@ -145,6 +152,15 @@ function mapRecentRowsToResults(input: {
hasOcr: false, hasOcr: false,
publicPath: `/documents/${item.id}`, publicPath: `/documents/${item.id}`,
score: 0, score: 0,
nodeId: item.id,
subtreeRootId: item.id,
evidence: [
{
kind: "recent",
nodeId: item.id,
snippet: item.title ?? "最近访问页面",
},
],
})); }));
} }
@@ -161,6 +177,18 @@ function mapRustResultsToResponse(
hasOcr: Boolean(item.hasOcr), hasOcr: Boolean(item.hasOcr),
publicPath: item.publicPath, publicPath: item.publicPath,
score: item.score, score: item.score,
nodeId: item.nodeId ?? item.id,
subtreeRootId: item.subtreeRootId ?? item.id,
evidence:
Array.isArray(item.evidence) && item.evidence.length > 0
? item.evidence
: [
{
kind: item.matchField,
nodeId: item.nodeId ?? item.id,
snippet: item.snippet || item.title || "命中文档",
},
],
})); }));
} }
@@ -136,6 +136,8 @@ describe("buildSidebarInitialData", () => {
}); });
expect(payload.activeWorkspaceId).toBe("ws_1"); expect(payload.activeWorkspaceId).toBe("ws_1");
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
expect(payload.mindmapDocs).toEqual(["doc_1"]); expect(payload.mindmapDocs).toEqual(["doc_1"]);
expect(payload.mindmapAssetChildren).toEqual({ expect(payload.mindmapAssetChildren).toEqual({
mind_1: ["img_a", "img_b"], mind_1: ["img_a", "img_b"],
@@ -210,6 +212,24 @@ describe("buildSidebarInitialData", () => {
updated_at: null, updated_at: null,
}, },
], ],
kernel_sidebar_projection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [
{
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
title: "页面 1",
depth: 0,
position: 1,
childCount: 0,
expandedByDefault: true,
},
],
edges: [],
},
trashed_documents: [], trashed_documents: [],
media_assets: [], media_assets: [],
trashed_media_assets: [], trashed_media_assets: [],
@@ -247,6 +267,46 @@ describe("buildSidebarInitialData", () => {
updated_at: null, updated_at: null,
}, },
], ],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [
{
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
title: "页面 1",
depth: 0,
position: 1,
childCount: 0,
expandedByDefault: true,
},
],
edges: [],
},
kernelSidebarTree: [
{
access_scope: "private",
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: 1,
childCount: 0,
expandedByDefault: true,
},
},
],
trashedDocuments: [], trashedDocuments: [],
trashedMediaAssets: [], trashedMediaAssets: [],
trashedMindmapAssets: [], trashedMindmapAssets: [],
+15
View File
@@ -1,5 +1,10 @@
import type { SidebarInitialData } from "@/components/sidebar/types"; import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import {
buildKernelSidebarProjection,
buildSidebarTreeFromKernelProjection,
type KernelSidebarProjection,
} from "@/lib/kernel-sidebar";
import type { WorkspaceSummary } from "@/lib/workspaces"; import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
@@ -46,6 +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;
trashed_documents: SidebarInitialData["trashedDocuments"]; trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[]; media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[]; trashed_media_assets: MediaAsset[];
@@ -213,11 +219,13 @@ export function buildSidebarDatasetListQueryResult(
input: SidebarDatasetInput, input: SidebarDatasetInput,
): SidebarDatasetListQueryResult { ): SidebarDatasetListQueryResult {
const derived = deriveSidebarDataset(input); const derived = deriveSidebarDataset(input);
const kernelSidebarProjection = buildKernelSidebarProjection(input.documents);
return { return {
active_workspace_id: input.activeWorkspaceId, active_workspace_id: input.activeWorkspaceId,
workspaces: [...input.workspaces], workspaces: [...input.workspaces],
documents: [...input.documents], documents: [...input.documents],
kernel_sidebar_projection: kernelSidebarProjection,
trashed_documents: [...input.trashedDocuments], trashed_documents: [...input.trashedDocuments],
media_assets: [...(input.mediaAssets ?? [])], media_assets: [...(input.mediaAssets ?? [])],
trashed_media_assets: [...(input.trashedMediaAssets ?? [])], trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
@@ -233,10 +241,17 @@ 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,
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
records: result.documents,
projection: kernelSidebarProjection,
}),
trashedDocuments: [...result.trashed_documents], trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets], trashedMediaAssets: [...result.trashed_media_assets],
trashedMindmapAssets: [...result.trashed_mindmap_assets], trashedMindmapAssets: [...result.trashed_mindmap_assets],
+18 -11
View File
@@ -1,8 +1,12 @@
import type { DocumentRecord, DocumentNode } from "@/lib/documents"; import type { DocumentRecord } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types"; import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase"; import type { Database } from "@/types/supabase";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { buildDocumentTree } from "@/lib/documents"; import {
buildKernelSidebarProjection,
buildSidebarTreeFromKernelProjection,
type SidebarTreeNode,
} from "@/lib/kernel-sidebar";
type TypedClient = { type TypedClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -138,10 +142,10 @@ export async function fetchSidebarDataset(
}; };
} }
type NodePredicate = (node: DocumentNode) => boolean; type NodePredicate = (node: SidebarTreeNode) => boolean;
function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] { function projectTree(nodes: SidebarTreeNode[], predicate: NodePredicate): SidebarTreeNode[] {
const result: DocumentNode[] = []; const result: SidebarTreeNode[] = [];
nodes.forEach((node) => { nodes.forEach((node) => {
const projectedChildren = projectTree(node.children, predicate); const projectedChildren = projectTree(node.children, predicate);
if (predicate(node)) { if (predicate(node)) {
@@ -157,12 +161,12 @@ function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentN
} }
export function flattenDocumentTree( export function flattenDocumentTree(
nodes: DocumentNode[], nodes: SidebarTreeNode[],
expanded: Set<string>, expanded: Set<string>,
depth = 0, depth = 0,
parentId: string | null = null, parentId: string | null = null,
): Array<{ node: DocumentNode; depth: number; parentId: string | null }> { ): Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> {
const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = []; const list: Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> = [];
nodes.forEach((node) => { nodes.forEach((node) => {
list.push({ node, depth, parentId }); list.push({ node, depth, parentId });
if (node.children.length > 0 && expanded.has(node.id)) { if (node.children.length > 0 && expanded.has(node.id)) {
@@ -184,16 +188,19 @@ type SidebarSectionSnapshot = {
id: SidebarSectionId; id: SidebarSectionId;
title: string; title: string;
icon?: string; icon?: string;
nodes: DocumentNode[]; nodes: SidebarTreeNode[];
}; };
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] { export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
const tree = buildDocumentTree(records); const tree = buildSidebarTreeFromKernelProjection({
records,
projection: buildKernelSidebarProjection(records),
});
return buildSidebarSectionsFromTree(tree); return buildSidebarSectionsFromTree(tree);
} }
export function buildSidebarSectionsFromTree( export function buildSidebarSectionsFromTree(
tree: DocumentNode[], tree: SidebarTreeNode[],
): SidebarSectionSnapshot[] { ): SidebarSectionSnapshot[] {
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [ const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
{ id: "starred", predicate: (node) => Boolean(node.is_starred) }, { id: "starred", predicate: (node) => Boolean(node.is_starred) },
@@ -6,7 +6,9 @@ describe("useAiAgentUiStore", () => {
useAiAgentUiStore.setState({ useAiAgentUiStore.setState({
documentAgentAvailable: false, documentAgentAvailable: false,
documentAgentOpen: false, documentAgentOpen: false,
documentAgentActivated: false,
globalAgentOpen: false, globalAgentOpen: false,
globalAgentActivated: false,
}); });
}); });
@@ -15,8 +17,10 @@ describe("useAiAgentUiStore", () => {
state.toggleGlobalAgentOpen(); state.toggleGlobalAgentOpen();
expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(true); expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(true);
expect(useAiAgentUiStore.getState().globalAgentActivated).toBe(true);
state.toggleGlobalAgentOpen(); state.toggleGlobalAgentOpen();
expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(false); expect(useAiAgentUiStore.getState().globalAgentOpen).toBe(false);
expect(useAiAgentUiStore.getState().globalAgentActivated).toBe(true);
}); });
}); });
+22 -4
View File
@@ -4,8 +4,10 @@ import { create } from "zustand";
type AiAgentUiState = { type AiAgentUiState = {
globalAgentOpen: boolean; globalAgentOpen: boolean;
globalAgentActivated: boolean;
documentAgentAvailable: boolean; documentAgentAvailable: boolean;
documentAgentOpen: boolean; documentAgentOpen: boolean;
documentAgentActivated: boolean;
setGlobalAgentOpen: (open: boolean) => void; setGlobalAgentOpen: (open: boolean) => void;
toggleGlobalAgentOpen: () => void; toggleGlobalAgentOpen: () => void;
setDocumentAgentAvailable: (available: boolean) => void; setDocumentAgentAvailable: (available: boolean) => void;
@@ -15,18 +17,34 @@ type AiAgentUiState = {
export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({ export const useAiAgentUiStore = create<AiAgentUiState>((set, get) => ({
globalAgentOpen: false, globalAgentOpen: false,
globalAgentActivated: false,
documentAgentAvailable: false, documentAgentAvailable: false,
documentAgentOpen: false, documentAgentOpen: false,
setGlobalAgentOpen: (open) => set({ globalAgentOpen: open }), documentAgentActivated: false,
setGlobalAgentOpen: (open) =>
set((state) => ({
globalAgentOpen: open,
globalAgentActivated: state.globalAgentActivated || open,
})),
toggleGlobalAgentOpen: () => { toggleGlobalAgentOpen: () => {
const s = get(); const s = get();
set({ globalAgentOpen: !s.globalAgentOpen }); set({
globalAgentOpen: !s.globalAgentOpen,
globalAgentActivated: s.globalAgentActivated || !s.globalAgentOpen,
});
}, },
setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }), setDocumentAgentAvailable: (available) => set({ documentAgentAvailable: available }),
setDocumentAgentOpen: (open) => set({ documentAgentOpen: open }), setDocumentAgentOpen: (open) =>
set((state) => ({
documentAgentOpen: open,
documentAgentActivated: state.documentAgentActivated || open,
})),
toggleDocumentAgentOpen: () => { toggleDocumentAgentOpen: () => {
const s = get(); const s = get();
if (!s.documentAgentAvailable) return; if (!s.documentAgentAvailable) return;
set({ documentAgentOpen: !s.documentAgentOpen }); set({
documentAgentOpen: !s.documentAgentOpen,
documentAgentActivated: s.documentAgentActivated || !s.documentAgentOpen,
});
}, },
})); }));
@@ -0,0 +1,33 @@
"use client";
import { create } from "zustand";
type OnlyOfficeAiBridgeState = {
pluginReady: boolean;
targetOrigin: string;
targetWindow: Window | null;
lastReadyAt: number | null;
captureReady: (payload: { targetOrigin: string; targetWindow: Window | null }) => void;
reset: () => void;
};
export const useOnlyOfficeAiBridgeStore = create<OnlyOfficeAiBridgeState>((set) => ({
pluginReady: false,
targetOrigin: "*",
targetWindow: null,
lastReadyAt: null,
captureReady: ({ targetOrigin, targetWindow }) =>
set({
pluginReady: Boolean(targetWindow),
targetOrigin: targetOrigin || "*",
targetWindow,
lastReadyAt: Date.now(),
}),
reset: () =>
set({
pluginReady: false,
targetOrigin: "*",
targetWindow: null,
lastReadyAt: null,
}),
}));
@@ -14,6 +14,7 @@ export type FilterKey = "titleOnly" | "exact" | "onlyCurrentPage" | "includeOcr"
interface SearchPaletteState { interface SearchPaletteState {
open: boolean; open: boolean;
activated: boolean;
mode: SearchPaletteMode; mode: SearchPaletteMode;
query: string; query: string;
filters: Omit<DocumentSearchFilters, "timeRange">; filters: Omit<DocumentSearchFilters, "timeRange">;
@@ -47,6 +48,7 @@ const defaultFilters: Omit<DocumentSearchFilters, "timeRange"> = {
export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({ export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({
open: false, open: false,
activated: false,
mode: "search", mode: "search",
query: "", query: "",
filters: { ...defaultFilters }, filters: { ...defaultFilters },
@@ -58,12 +60,14 @@ export const useSearchPaletteStore = create<SearchPaletteState>((set) => ({
openSearch: () => openSearch: () =>
set((state) => ({ set((state) => ({
open: true, open: true,
activated: true,
mode: "search", mode: "search",
query: state.query, query: state.query,
})), })),
openReference: (options) => openReference: (options) =>
set({ set({
open: true, open: true,
activated: true,
mode: "reference", mode: "reference",
referenceMode: options?.referenceMode ?? "inline", referenceMode: options?.referenceMode ?? "inline",
alias: options?.aliasSeed ?? "", alias: options?.aliasSeed ?? "",
+7
View File
@@ -34,6 +34,13 @@ export interface DocumentSearchResult {
hasOcr: boolean; hasOcr: boolean;
publicPath: string; publicPath: string;
score: number; score: number;
nodeId?: string | null;
subtreeRootId?: string | null;
evidence?: Array<{
kind: string;
nodeId?: string | null;
snippet: string;
}>;
} }
export interface DocumentSearchResponse { export interface DocumentSearchResponse {