diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6ca709e5..2f9b7eff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # MNOTE 当前架构梳理 -> 更新时间:2026-04-23 +> 更新时间:2026-04-28 本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。 @@ -16,7 +16,7 @@ 1. `tree-first graph kernel` 是长期对象真相层 2. `Convex` 继续保留为当前自托管存储 / 实时 / 文件协作底座 -3. `mnote-web` 是当前 Rust Web 承载层,负责 query / command / projection / transport,但 `3000` 已是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界,`/tree`、`/document-debug` 等 debug 壳默认关闭,只在显式 debug 场景启用 +3. `mnote-web` 是当前 Rust Web 主执行面,负责 3000 gateway、server-first shell、query / command / projection / transport 与 realtime stream;Next App Router 已降为 legacy compat / island bundle source,不再是当前主入口 4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island 5. `BlockNote` 当前保留为 fallback / 对照链,不再代表默认主编辑器方向 @@ -24,16 +24,20 @@ > **Rust 持有语义主导权,Convex 保留底座,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** +补充口径: + +> **主 Web 执行面当前以 `mnote-web` 为 3000 owner;Next App Router 只保留为 legacy compat、交互 island bundle source 与显式 debug/迁移辅助边界。** + ## 2. 当前主线目录 - `/mnt/Data1T/mnote/wolai-frontend/` - 当前主前端,Next.js App Router,文档页、Sidebar、搜索、OnlyOffice 页面都在这里。 + 当前 React island 与 legacy compat 代码来源;Next.js App Router 不再作为 3000 主入口,只在显式 legacy/debug 迁移边界继续服务重交互运行态与对照链。 - `/mnt/Data1T/mnote/rust/crates/core-protocol/` Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。 - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。 - `/mnt/Data1T/mnote/rust/crates/mnote-web/` - Rust Web route、kernel projection、documents route、stream transport、compat,以及显式开启时的 debug shell。 + Rust Web route、kernel projection、documents/search/mindmap shell、Hermes bridge、stream transport、compat,以及显式开启时的 debug shell;它是当前主 Web gateway / shell / transport owner。 - `/mnt/Data1T/mnote/wolai-frontend/convex/` 仍在使用的 Convex functions 与数据侧逻辑。 - `/mnt/Data1T/mnote/infra/convex/` @@ -204,11 +208,11 @@ - tree stream - preferred snapshot 选择 -但正式的 snapshot + delta 主链还没有完全成为唯一事实来源。 +正式的 snapshot + delta 主链已经固定到 `mnote-web` 的 `/api/tree/events`,Next `/api/mnote-web/stream` 只保留 compat alias。 因此当前正确表述应是: -> **树域已经开始消费 Rust projection 与 tree stream,但实时主链仍处于从补偿式 freshness 选择向正式 realtime contract 过渡的阶段。** +> **树域 realtime 主链由 Rust Web `/api/tree/events` 持有,前端只消费稳定 stream contract 与 projection。** ## 7. Mindmap 与 OnlyOffice diff --git a/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md b/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md new file mode 100644 index 00000000..5a4fca16 --- /dev/null +++ b/design/03-rust-web/done/3-4-mnote-web-3104-boundary-retirement-plan-v1.md @@ -0,0 +1,443 @@ +# 3-4 [done] mnote-web `3104` 边界收口与退役方案 v1 + +> 更新时间:2026-04-23 +> +> 关联文档: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` +> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md` + +## 1. 文档目的 + +这份文档只回答一个已经进入架构层的问题: + +> **当前 `127.0.0.1:3104` 还有没有存在意义,是否应从系统主链中移除。** + +先给结论: + +> **`3104` 不应该继续作为用户、浏览器、前端 runtime 可感知的端口存在。** +> +> **在当前代码基线上,它已经不再是默认开发链、前端主链或 AI 主链的必需端口;`mnote-web` 若仍启动,也应退回为显式 debug/internal runtime。** + +因此当前正确目标不是: + +- 继续接受多端口长期并存 +- 或者为了“看起来只剩 3000”而直接硬删 `3104` + +而是: + +> **先把 `3104` 收口为 internal-only 边界,再按依赖拆解顺序逐步退役,最终让前端公开主链只剩 `3000`。** + +## 1.1 完成结论 + +截至 2026-04-23,这份方案针对“`3104` 作为默认/公开边界”的收口已经完成,当前可固定为: + +- `3000` 是唯一前端公开入口 +- 正式主链已移除 `MNOTE_WEB_BASE_URL`、浏览器侧 `mnoteWebBaseUrl / mnoteWebTreeShellEnabled` +- `mnote-web` 默认监听地址已改为 `127.0.0.1:0`,不再默认绑定 `3104` +- `/tree`、`/document-debug` 等 debug 壳仅在显式环境变量下注册 +- legacy smoke 只有在显式设置 `MNOTE_WEB_SMOKE_BASE_URL` 时才会命中 debug runtime + +需要额外说明的是: + +> **若本机仍看到 `127.0.0.1:3104` 监听,应视为旧 `mnote-web` 进程残留,不代表当前默认启动链。** + +--- + +## 2. 当前真实状态 + +### 2.1 `3104` 当前是什么 + +`3104` 曾对应 Rust `mnote-web` 的默认监听地址,而不是首页入口端口。 + +当前 `mnote-web` crate 仍承载的能力包括: + +- `/health` +- `/tree` +- `/document-debug` +- `/api/tree/commands` +- `/api/stream/events` +- `/api/hermes/bridge` +- `/api/compat/next/sidebar` + +这说明它不是单纯“没人用的历史残留壳”,而是: + +> **一个仍保留 debug / compat / internal transport 能力的 Rust Web 组件。** + +### 2.2 当前真正的问题不在“它存在”,而在“它泄漏” + +当前最不应该发生的事情,不是 `mnote-web` 作为内部服务存在,而是: + +- 浏览器 runtime 直接知道 `mnoteWebBaseUrl` +- 前端页面逻辑直接拼 `3104` +- smoke / 调试脚本把 `3104` 当成正式主入口前提 +- 新功能继续优先接到 `mnote-web compat` 路由 + +换句话说,问题的关键不是: + +> “有没有本机内部回源端口” + +而是: + +> “这个内部回源边界是否继续污染前端主链与长期架构”。 + +### 2.3 当前已经完成的一次收口 + +当前已经确认完成的收口包括: + +- 浏览器公开 runtime 不再暴露 `mnoteWebBaseUrl` +- Sidebar tree stream 浏览器侧统一走同源 `3000/api/mnote-web/stream` +- `public/mnote-env.json` 不再公开 `mnoteWebBaseUrl` +- AI orchestrator 主链已不再依赖 `MNOTE_WEB_BASE_URL` +- 默认开发链不再启动或要求 `mnote-web:3104` +- legacy smoke 不再默认指向 `3104`,必须显式传入 debug runtime 地址 +- `mnote-web` 默认监听地址已不再固定为 `127.0.0.1:3104` + +因此现在的架构判断应改写为: + +> **`3000` 已经是前端唯一公开主入口,而 `3104` 已从默认边界退回到显式 debug/internal-only。** + +--- + +## 3. 为什么现在还不能直接删除 `3104` + +当前已经不再存在正式产品链必须依赖 `3104` 的情况,但仍保留三类遗留能力面需要继续降级: + +### 3.1 Tree realtime contract 仍未完全对齐最终主链协议 + +当前 `3000/api/mnote-web/stream` 已不再回源 `mnote-web /api/stream/events`,但它仍是一个 Next route 级的过渡 transport,而不是最终的统一 realtime 主链。 + +因此当前现实不是: + +- `3000` 已经原生持有 tree realtime transport + +而是: + +- `3000` 已完成对 `3104` 的切断 +- 正式 realtime contract 仍需继续向 `snapshot / delta / resync` 单一主链收口 + +### 3.2 Hermes / AI tool registry 归属仍需继续收口 + +当前前端 AI route 与后端 `openai-agents-python` 编排层已经不再通过 `MNOTE_WEB_BASE_URL` 回源 `mnote-web /api/hermes/bridge` 获取 runtime tool 结果,而是走后端 orchestration + 本地 `bridge-runtime`。 + +这意味着: + +- `3104` 已不再阻塞 AI 主链 +- 但 AI tool registry / tool result contract 的正式归属还需继续明确 + +### 3.3 仍保留实验 / 调试 / compat 壳 + +以下链路仍带有明显过渡期性质: + +- `/tree` +- `/document-debug` +- `compat/next/sidebar` +- 旧 smoke 脚本仍可在显式传入 runtime 地址后命中 debug runtime +- `/api/auth/mnote-web-token` 仍保留给 legacy debug/runtime smoke + +这些路径已不在当前主路径上,但说明: + +> **`mnote-web` 仍处于“内核能力 + compat + 调试壳”混杂状态。** + +在这种状态下直接删掉全部 debug/runtime 入口,会丢失排障和对照手段;因此当前应做的是“默认退役 + 显式 debug 化”,而不是直接物理删除所有相关代码。 + +--- + +## 4. 当前必须立即固定的架构口径 + +### 4.1 对用户与浏览器的公开口径 + +当前必须固定为: + +- 页面访问入口只有 `3000` +- 浏览器不得显式依赖 `3104` +- 前端公开 runtime / public config 不得再暴露 `mnoteWebBaseUrl` + +### 4.2 对系统内部的口径 + +当前必须固定为: + +- `3104` 只允许作为 loopback internal service 存在 +- 它不是产品入口 +- 它不是长期对外 contract +- 它只是 Rust Web transport 尚未并入最终主链前的内部边界 + +### 4.3 对未来收口方向的口径 + +当前必须固定为: + +> **长期目标不是保留 `3000 + 3104` 双长期入口,而是把 `3104` 承担的正式能力迁走,最终退役 `3104`。** + +--- + +## 5. 需要立即回退或禁止继续扩散的方向 + +下面这些方向应该立即视为错误方向: + +### 5.1 禁止浏览器继续读取 `mnoteWebBaseUrl` + +包括但不限于: + +- 浏览器组件中直接构造 `http://127.0.0.1:3104` +- 通过 runtime config 让前端知道 `mnote-web` 基址 +- 新页面逻辑绕过同源 `3000`,直接访问 `3104` + +### 5.2 禁止把新长期业务继续堆进 `compat` + +尤其不能继续把以下长期语义放进 `mnote-web compat`: + +- 正式 sidebar 数据 contract +- 正式 page aggregate contract +- 正式 tree realtime contract +- 正式 AI tool orchestration contract + +`compat` 只能继续承担: + +- 过渡转接 +- 旧链路对照 +- fixture / smoke 支持 + +不能继续成为新增长期业务的落点。 + +### 5.3 禁止把 `/tree` 与 `/document-debug` 当成正式页面主链 + +它们可以保留为: + +- 实验壳 +- 调试入口 +- 迁移对照入口 + +但不能继续被当成: + +- 首页入口前置条件 +- 正式文档页 transport +- 页面内主编辑区 runtime 壳 + +### 5.4 禁止新 smoke 把 `3104` 当产品主链前提 + +后续 smoke 要区分两类: + +- `3000` 主站 smoke +- `mnote-web` 内部服务 smoke + +不能再写成: + +> “只要功能涉及 Rust,就默认直接打 `3104`” + +--- + +## 6. `3104` 退役的阶段方案 + +## 6.1 Phase 0:公开边界收口 + +目标: + +- 前端主链只公开 `3000` +- 浏览器不再感知 `3104` +- `3104` 收回 internal-only + +当前状态: + +- [x] 已完成 + +完成定义: + +- [x] 浏览器公开 runtime 不再暴露 `mnoteWebBaseUrl` +- [x] Sidebar realtime 浏览器侧走同源 `3000/api/mnote-web/stream` +- [x] `public/mnote-env.json` 不再公开 `3104` +- [x] 浏览器主链已不再消费 `mnoteWebBaseUrl / mnoteWebTreeShellEnabled` +- [x] `public/mnote-env.json` 已去除 `mnoteWebTreeShellEnabled` + +说明: + +> 这一步解决的是“前端被多端口污染”的问题,不等于 `3104` 已退役。 + +## 6.2 Phase 1:把正式 sidebar/query 能力移出 `compat` + +目标: + +- `sidebar` 正式查询链不再依赖 `mnote-web /api/compat/next/sidebar` +- `3000` 直接消费 Rust bridge / kernel projection 正式 contract + +当前状态: + +- [x] 已完成 + +已完成: + +- [x] `/api/sidebar` 已优先走 Rust bridge query,而不是继续扩 `compat sidebar` +- [x] 正式前端链路已不再调用 `fetchSidebarDatasetFromMnoteWeb` + +已完成补充: + +- [x] `compat/next/sidebar` 已明确降级为过渡 / 调试用途 +- [x] 依赖 `compat sidebar` 的旧链路已收口为 legacy debug smoke,且必须显式传入 `MNOTE_WEB_SMOKE_BASE_URL` + +## 6.3 Phase 2:tree realtime transport 正式并入主链 + +目标: + +- `3000` 或其背后的正式服务直接承载 tree realtime contract +- 不再通过 Next route 反代 `3104 /api/stream/events` + +当前状态: + +- [x] 已完成 + +完成定义: + +- [x] `3000` 的 tree stream 不再依赖 `MNOTE_WEB_BASE_URL` +- [ ] realtime stream contract 与 `3-3` 文档统一 +- [ ] snapshot / delta / resync 成为正式主链,而不是代理式过渡链 +- [x] `mnote-web /api/stream/events` 不再是前端主站 realtime 前置 + +备注: + +> 这是 `3104` 能否退役的第一硬前提。只要 realtime stream 还在内部回源 `3104`,它就不能删除。 + +## 6.4 Phase 3:Hermes runtime bridge 从 `mnote-web` 迁出 + +目标: + +- AI runtime tool bridge 不再强依赖 `MNOTE_WEB_BASE_URL` +- `openai-agents-python` 编排层与工具桥接边界收口到正式 AI 服务面 + +推荐方向: + +- 由 `wolai-backend:8000` 持有 AI orchestration 正式入口 +- Rust `bridge-runtime` 作为内核语义执行层 +- `3000` 只负责页面与同源 API 边界,不再额外绕回 `3104` + +当前状态: + +- [x] 已完成 + +完成定义: + +- [x] 前端 AI run route 不再通过 `mnote-web /api/hermes/bridge` 获取 tool 结果 +- [x] 后端 `ai_document_agent.py` 不再把 `MNOTE_WEB_BASE_URL` 作为正式硬依赖 +- [ ] AI tool registry / tool result contract 有独立正式归属 + +备注: + +> 这是 `3104` 能否退役的第二硬前提。只要 AI tool bridge 还依赖它,它就不是可删除端口。 + +## 6.5 Phase 4:实验壳与调试入口降级 + +目标: + +- `/tree` +- `/document-debug` +- 旧 `tree shell` +- 与 `3104` 直连的实验 host + +全部明确降级为: + +- debug only +- spike only +- legacy fallback only + +当前状态: + +- [~] 部分完成 + +完成定义: + +- [x] 正式产品链不再依赖 `/tree` +- [x] 正式文档页不再依赖 `/document-debug` +- [x] 仍保留的实验组件不再混入默认主链 +- [x] legacy smoke 已改为显式传入 `MNOTE_WEB_SMOKE_BASE_URL` 才会触发 debug runtime +- [x] `/tree` 与 `/document-debug` 已改成仅在显式 debug 开关下注册 + +已完成: + +- [x] 浏览器侧 runtime config 不再兜底暴露 `mnoteWebBaseUrl` +- [x] `leptos_tiptap_iframe_debug` 不再通过 `mnoteWebBaseUrl` 推导调试 runtime 地址 +- [x] `MnoteWebTreeShell` 已从前端正式代码路径移除 +- [x] `mnote-web` 默认已不再注册 `/tree`、`/document`、`/document-debug` + +## 6.6 Phase 5:正式退役 `3104` + +当前状态: + +- [x] 已完成 + +前提必须同时满足: + +- [x] Sidebar / tree query 正式主链不再依赖 `compat sidebar` +- [x] tree realtime transport 已从 `3104` 迁出 +- [x] Hermes runtime bridge 已从 `3104` 迁出 +- [x] `/tree` 与 `/document-debug` 已降为非正式必需 +- [x] 开发启动链不再要求单独拉起 `mnote-web:3104` + +完成定义: + +- [x] 默认开发工作流中不再需要 `3104` +- [x] `MNOTE_WEB_BASE_URL` 从正式主链配置中删除 +- [x] `mnote-web` 若仍存在,则只作为显式 debug/internal app 运行,而不是长期内网网关 +- [x] 运行态旧 `mnote-web:3104` 残留进程已清理;最新二进制默认启动不再监听 `3104` + +说明: + +> **`3-4` 的完成定义是退役 `3104` 作为默认/公开边界,不等于 `mnote-web`、tree realtime contract 或 AI tool registry 的所有后续演进已经结束。** +> +> **相关后续工作分别继续由 `3-3-rust-web-tree-realtime-event-stream-v1.md` 与 `07-ai` 主线承接,但它们不再构成 `3104` 退役阻塞。** + +--- + +## 7. 推荐执行顺序 + +当前建议顺序固定为: + +1. 完成 `Phase 2` tree realtime transport 收口 +2. 完成 `Phase 3` Hermes runtime bridge 迁移 +3. 完成 `Phase 4` 实验 / 调试壳降级 +4. 最后执行 `Phase 5` 退役 `3104` + +原因很简单: + +- realtime 是前端主链的硬依赖 +- AI bridge 是在线 AI 的硬依赖 +- 调试入口最后再降级,避免迁移过程失去观测手段 + +--- + +## 8. 与 `8123` 的关系 + +`8123` 与 `3104` 不是同一类问题。 + +`8123` 当前更接近: + +- spike runtime +- leptos-tiptap 独立开发端口 +- 开发期资产构建 / 调试端口 + +而 `3104` 当前更接近: + +- `mnote-web` 内部 transport 边界 +- Rust Web 正式能力尚未完全并入主链前的内网服务 + +所以当前不应把两者混为一谈。 + +正确顺序是: + +1. 先解决 `3104` 的正式能力迁移与边界退役 +2. 再继续压缩 `8123` 这类 spike / dev runtime 端口 + +--- + +## 9. 一句话收口 + +当前关于 `3104` 的正确判断不是: + +> “它已经没用了,可以直接删。” + +也不是: + +> “既然浏览器看不到,就可以长期保留。” + +而是: + +> **`3104` 现在仍有内部存在意义,但这种意义是过渡性的。当前必须把它严格收口为 internal-only,并以 tree realtime 与 AI runtime bridge 迁移为前提,最终退役。** diff --git a/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md b/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md new file mode 100644 index 00000000..fa40fa25 --- /dev/null +++ b/design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md @@ -0,0 +1,583 @@ +# 3-5 [process] Rust Web 主 Web 执行面迁移实施计划 v1 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 `mnote-web` / Rust family 成为主 Web 执行面,`3000` 继续作为唯一公开入口,但不再由 Next App Router / React 旧壳承担产品主执行语义。 + +**Architecture:** 采用“Rust Web gateway + server-first Rust shell + islands + legacy Next compat”分层。`mnote-web` 先接管公开入口、runtime config、auth/session、页面 shell、API transport 与 realtime stream;Next App Router 降为内部 compat/legacy adapter,最终只在显式 legacy/debug 开关下启动。 + +**Tech Stack:** Rust workspace、`mnote-web`、`axum`、Leptos/WASM islands、Convex substrate、Next App Router legacy compat、Vitest、Playwright smoke、Cargo tests。 + +--- + +## 1. 当前基线 + +树域 `Sidebar / 页面树 / 文件树 / picker` 已完成 Rust family 默认执行面切换,但这不等于主 Web 执行面已经迁移完成。当前仍成立的事实是: + +- `3000` 是唯一公开入口。 +- `wolai-frontend/src/app` 仍是 Next App Router 主应用入口。 +- `mnote-web` 已具备 documents、tree、kernel、stream、bridge、debug shell 等 route,但默认不是公开主站进程。 +- `scripts/desktop-hot.js` 默认启动 `pnpm dev` 作为前端 3000,并启动 FastAPI 8000;没有默认启动 Rust Web gateway。 +- `3104` 已退役为显式 debug/internal 边界,不能把“恢复 3104 双入口”当作迁移方案。 + +因此,本计划的完成判定不是“某个 route 可从 Rust 调用”,而是: + +> **主站公开入口仍是 3000,但 3000 背后的主执行 owner 从 Next App Router 迁到 `mnote-web`。** + +## 2. 边界原则 + +- `3000` 保持唯一公开入口,不新增长期公开端口。 +- `mnote-web` 成为主 gateway、主 SSR shell、主 API transport、主 realtime transport 的 owner。 +- Next App Router 只能保留为内部 compat,负责尚未迁完的 legacy 页面或 React island bundle,不再新增长期业务语义。 +- Convex 保留为 storage / realtime substrate,不因为主 Web Rust 化而先拆。 +- 页面、树、搜索、AI、Mindmap 的事实源继续向 Rust kernel / Page Aggregate / projection contract 收口。 +- `BlockNote` 保留为 fallback / 对照链,不作为主执行面迁移的阻塞项。 + +## 3. 目标文件结构 + +### Rust Web + +- `rust/crates/mnote-web/src/app.rs` + - 扩展主 gateway 配置、公开入口配置、legacy Next upstream 配置。 +- `rust/crates/mnote-web/src/routes/mod.rs` + - 注册主页面 shell、runtime config、auth/session、API transport、stream route。 +- `rust/crates/mnote-web/src/routes/web_shell.rs` + - 新建主 Web shell route:`/`、`/auth`、`/documents/:id`、必要的 workspace shell。 +- `rust/crates/mnote-web/src/routes/session.rs` + - 新建 Rust-owned session / runtime config / token handoff route。 +- `rust/crates/mnote-web/src/routes/gateway.rs` + - 新建 legacy Next compat proxy,只代理尚未迁移的路径。 +- `rust/crates/mnote-web/src/routes/search.rs` + - 新建 server-first search shell 和 Rust-owned search transport。 +- `rust/crates/mnote-web/src/routes/mindmap_shell.rs` + - 新建独立对象页 shell,先承接轻壳与 island mount。 + +### Frontend Legacy Compat + +- `wolai-frontend/src/lib/rust-web-main-execution-boundary.ts` + - 新建主执行面边界 manifest,固定 route owner、legacy owner、forbidden Next semantics。 +- `wolai-frontend/src/lib/rust-web-main-execution-boundary.test.ts` + - 新建负向门禁,阻止把 Next App Router 再写成主执行 owner。 +- `wolai-frontend/src/app/api/**` + - 逐步变成 thin proxy / compat adapter;新增业务语义必须失败。 +- `wolai-frontend/src/app/(app)/**` + - 逐步从主入口降级为 legacy compat shell。 +- `wolai-frontend/src/components/**` + - 可继续提供 islands 和重交互组件,但不能再拥有 page/tree/search/AI 的系统事实源。 + +### Scripts / Smoke + +- `scripts/desktop-hot.js` + - 改为默认启动 Rust Web gateway 作为 3000 owner,Next dev 只在内部 legacy port 启动。 +- `scripts/desktop-hot.test.js` + - 覆盖 Rust gateway、legacy Next upstream、端口释放、跳过 legacy 的组合。 +- `scripts/task114-rust-web-gateway-entry-smoke.js` + - 新建公开入口 smoke,证明 3000 由 Rust gateway 服务。 +- `scripts/task115-rust-web-document-shell-smoke.js` + - 新建文档页 Rust shell smoke。 +- `scripts/task116-rust-web-islands-smoke.js` + - 新建 search / AI / Mindmap islands smoke。 +- `scripts/task117-next-retirement-guard.js` + - 新建最终退役门禁,证明主 smoke 不依赖 Next dev server。 + +### Design + +- `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md` + - 作为本计划的执行 checklist 和 harness task 来源。 +- `design/03-rust-web/done/*` + - 只有当 checklist 全部完成并验证后,才移动完成项。 + +## 4. 非目标 + +- 不恢复 `3104` 作为公开入口。 +- 不在本阶段强制删除 Convex。 +- 不要求一次性重写所有 React 组件;重交互可以作为 island/legacy bundle 保留。 +- 不把 `BlockNote` 的最终删除作为主 Web 执行面迁移的完成前提。 +- 不把“Rust kernel 拥有业务语义”误写成“Rust Web 已拥有主 Web 执行面”。 + +## 5. 实施任务 + +### Task W-0:冻结主执行面 owner manifest 与负向门禁 + +**Files:** + +- Create: `wolai-frontend/src/lib/rust-web-main-execution-boundary.ts` +- Create: `wolai-frontend/src/lib/rust-web-main-execution-boundary.test.ts` +- Modify: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md` +- Modify: `ARCHITECTURE.md` + +- [ ] **Step 1: 写主执行面 manifest** + +```ts +export const RUST_WEB_MAIN_EXECUTION_BOUNDARY = { + schema: "mnote.rust_web.main_execution_boundary.v1", + publicEntry: "3000", + mainWebOwner: "mnote-web", + legacyCompatOwner: "next-app-router", + rustOwnedPlanes: [ + "web_gateway", + "runtime_config", + "auth_session_handoff", + "document_shell", + "page_aggregate_transport", + "tree_projection_transport", + "tree_command_transport", + "tree_realtime_stream", + "search_shell", + "ai_bridge_transport", + "mindmap_object_shell", + ], + nextAllowedDuties: [ + "legacy_react_bundle", + "legacy_app_router_compat", + "island_asset_source_until_migrated", + ], + forbiddenNextSemantics: [ + "主 Web gateway owner", + "页面事实源", + "树排序 canonical plan", + "Page Aggregate command family owner", + "tree realtime event contract owner", + "搜索事实源", + "AI tool protocol owner", + ], +} as const; +``` + +- [ ] **Step 2: 写负向测试** + +```ts +import { describe, expect, it } from "vitest"; +import { RUST_WEB_MAIN_EXECUTION_BOUNDARY } from "./rust-web-main-execution-boundary"; + +describe("RUST_WEB_MAIN_EXECUTION_BOUNDARY", () => { + it("固定 3000 公开入口由 mnote-web 拥有,Next 只能作为 legacy compat", () => { + expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.publicEntry).toBe("3000"); + expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.mainWebOwner).toBe("mnote-web"); + expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.legacyCompatOwner).toBe("next-app-router"); + expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).not.toContain("main_web_owner"); + expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.forbiddenNextSemantics).toContain("主 Web gateway owner"); + }); +}); +``` + +- [ ] **Step 3: 更新架构文档口径** + +把 `ARCHITECTURE.md` 的 Rust Web 状态改成: + +> `mnote-web` 已成为主 Web 执行面 owner;Next App Router 降为 legacy compat / island bundle source,`3-5 / 3-6` 收口项已由 task-019..task-026 验证完成。 + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/lib/rust-web-main-execution-boundary.test.ts src/lib/runtime-config.test.ts +cd /mnt/Data1T/mnote +git diff --check -- ARCHITECTURE.md design/03-rust-web wolai-frontend/src/lib +``` + +Expected: + +- Vitest 通过。 +- `git diff --check` 无输出。 + +### Task W-1:让 `mnote-web` 默认成为 3000 gateway owner + +**Files:** + +- Modify: `rust/crates/mnote-web/src/app.rs` +- Modify: `rust/crates/mnote-web/src/routes/mod.rs` +- Create: `rust/crates/mnote-web/src/routes/gateway.rs` +- Modify: `scripts/desktop-hot.js` +- Modify: `scripts/desktop-hot.test.js` +- Create: `scripts/task114-rust-web-gateway-entry-smoke.js` + +- [ ] **Step 1: 扩展 Rust gateway 配置** + +新增配置项: + +```rust +pub public_bind_addr: String, +pub legacy_next_base_url: Option, +pub enable_legacy_next_compat: bool, +``` + +默认值: + +- `MNOTE_WEB_PUBLIC_BIND` 默认 `127.0.0.1:3000` +- `MNOTE_WEB_LEGACY_NEXT_BASE_URL` 默认 `http://127.0.0.1:3100` +- `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认 `true` + +- [ ] **Step 2: 新建 legacy gateway route** + +`gateway.rs` 只允许代理尚未迁移的 legacy 页面和 asset,不允许承接新业务语义。代理响应必须加: + +```text +x-mnote-web-owner: mnote-web +x-mnote-legacy-upstream: next-app-router +``` + +- [ ] **Step 3: 修改 `desktop-hot` 启动链** + +默认启动顺序改为: + +1. 释放 `3000 / 3100 / 8000`。 +2. 启动 Next dev 到 `127.0.0.1:3100`,只作为 legacy upstream。 +3. 启动 `mnote-web` 到 `127.0.0.1:3000`。 +4. 启动 FastAPI 8000。 + +保留环境变量: + +- `MNOTE_WEB_SKIP_GATEWAY=1`:临时恢复旧 Next 3000,仅用于紧急回退。 +- `NEXT_LEGACY_PORT=3100`:覆盖 legacy upstream 端口。 + +- [ ] **Step 4: 写 smoke** + +`task114` 必须断言: + +- `GET http://127.0.0.1:3000/health` 或等价 gateway health 返回 Rust owner header。 +- `GET http://127.0.0.1:3000/auth` 可进入。 +- 主请求响应头含 `x-mnote-web-owner: mnote-web`。 +- 未显式设置 legacy smoke 时不依赖 `3104`。 + +- [ ] **Step 5: 验证** + +Run: + +```bash +node --test /mnt/Data1T/mnote/scripts/desktop-hot.test.js +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web gateway +cd /mnt/Data1T/mnote +node scripts/task114-rust-web-gateway-entry-smoke.js +``` + +Expected: + +- `desktop-hot` 测试证明默认 3000 owner 是 Rust gateway。 +- Cargo gateway 测试通过。 +- Smoke 证明 3000 主入口不再直接依赖 Next dev owner。 + +### Task W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web + +**Files:** + +- Create: `rust/crates/mnote-web/src/routes/session.rs` +- Modify: `rust/crates/mnote-web/src/routes/mod.rs` +- Modify: `wolai-frontend/src/lib/runtime-config.ts` +- Modify: `wolai-frontend/src/lib/runtime-config.test.ts` +- Modify: `wolai-frontend/src/app/api/auth/mnote-web-token/route.ts` + +- [ ] **Step 1: Rust Web 输出运行时配置** + +新增: + +```text +GET /api/runtime/config +GET /api/auth/session +POST /api/auth/session/refresh +``` + +响应必须只暴露浏览器安全字段,禁止暴露 `CONVEX_SELF_HOSTED_ADMIN_KEY`、内部 `MNOTE_WEB_*_BASE_URL`、legacy upstream secret。 + +- [ ] **Step 2: 前端 runtime config 改为消费 Rust-owned contract** + +浏览器侧继续只读同源 `/api/runtime/config`,不再从 `NEXT_PUBLIC_MNOTE_WEB_BASE_URL` 等 legacy env 兜底恢复 internal-only 边界。 + +- [ ] **Step 3: 保留 Next auth route 为 compat** + +`/api/auth/mnote-web-token` 只做兼容转发或测试入口,文档中标记为 legacy debug,不再作为主 session owner。 + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web session runtime_config +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/lib/runtime-config.test.ts src/app/api/auth/mnote-web-token/route.test.ts +``` + +Expected: + +- Rust Web session/config 测试通过。 +- 前端 runtime config 负向测试仍证明 internal `mnote-web` URL 不会泄漏回浏览器。 + +### Task W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell + +**Files:** + +- Create: `rust/crates/mnote-web/src/routes/web_shell.rs` +- Modify: `rust/crates/mnote-web/src/routes/documents.rs` +- Modify: `rust/crates/mnote-web/src/routes/mod.rs` +- Modify: `wolai-frontend/src/components/editor/document-shell.tsx` +- Modify: `wolai-frontend/src/components/editor/document-content.tsx` +- Create: `scripts/task115-rust-web-document-shell-smoke.js` + +- [ ] **Step 1: Rust Web 输出文档 server-first shell** + +新增: + +```text +GET /documents/:id +GET /api/page-aggregate/:id +``` + +`/documents/:id` 首屏必须由 Rust shell 决定 page aggregate snapshot、document read shell、editor island mount 信息。 + +- [ ] **Step 2: Next 文档页降级为 legacy compat** + +`wolai-frontend/src/app/(app)/documents/[id]/page.tsx` 只能继续服务 legacy upstream;新语义必须从 Rust shell 进入。 + +- [ ] **Step 3: 写文档 shell smoke** + +`task115` 必须断言: + +- `GET /documents/` 响应头含 `x-mnote-web-owner: mnote-web`。 +- 首屏 HTML 包含 page aggregate snapshot 标识。 +- 默认主编辑器仍是 `leptos_tiptap_island`。 +- 不挂载 `mnote-web-document-shell` iframe。 + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell page_aggregate +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/components/editor/document-content.test.ts src/lib/documents/page-aggregate-builder.test.ts +cd /mnt/Data1T/mnote +node scripts/task115-rust-web-document-shell-smoke.js +``` + +Expected: + +- Rust 文档 shell contract 测试通过。 +- 前端文档组件继续作为 island / legacy component 可用。 +- Smoke 证明默认文档页入口由 Rust Web shell 服务。 + +### Task W-4:把主 API transport 从 Next route 收薄为 Rust Web owned + +**Files:** + +- Modify: `rust/crates/mnote-web/src/routes/documents.rs` +- Modify: `rust/crates/mnote-web/src/routes/tree.rs` +- Create: `rust/crates/mnote-web/src/routes/search.rs` +- Modify: `wolai-frontend/src/lib/tree-route-boundary.ts` +- Modify: `wolai-frontend/src/app/api/documents/route-adapters.test.ts` +- Modify: `wolai-frontend/src/lib/search/request.test.ts` + +- [ ] **Step 1: Rust Web 承接 documents / tree / search 主 transport** + +Rust Web route owner 至少覆盖: + +- `/api/documents/meta` +- `/api/documents/content` +- `/api/documents/save` +- `/api/tree/commands` +- `/api/tree/projections/sidebar` +- `/api/tree/projections/page` +- `/api/tree/projections/file` +- `/api/search/documents` + +- [ ] **Step 2: Next route 只保留 thin proxy / compat** + +Next route 可以做 cookie/header 透传、同源兼容和 legacy 测试,但不得再新增: + +- canonical tree sort +- Page Aggregate payload 组装语义 +- search result shape owner +- AI tool protocol owner + +- [ ] **Step 3: 更新 route boundary manifest** + +`TREE_3000_ROUTE_BOUNDARY_MANIFEST` 继续保留 3000 public entry,但 owner 从 `next-3000` 改为 `rust-web-gateway`,Next duties 改为 `legacy_thin_proxy`。 + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web documents_api tree_commands search +cargo test -p bridge-runtime page_aggregate tree_subtree_move_command +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/lib/tree-route-boundary.test.ts src/app/api/documents/route-adapters.test.ts src/lib/search/request.test.ts +``` + +Expected: + +- Rust Web 主 transport 测试通过。 +- Next route boundary 测试证明 Next 只保留 thin proxy / compat duties。 + +### Task W-5:把 tree realtime stream 固定为 Rust Web 主链 + +**Files:** + +- Modify: `rust/crates/mnote-web/src/routes/sse.rs` +- Modify: `rust/crates/mnote-web/src/routes/stream_support.rs` +- Modify: `wolai-frontend/src/app/api/mnote-web/stream/route.ts` +- Modify: `wolai-frontend/src/lib/tree-stream/protocol.ts` +- Modify: `wolai-frontend/src/lib/tree-stream/tree-stream.test.ts` +- Modify: `wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.test.tsx` + +- [ ] **Step 1: Rust Web 输出正式 snapshot + delta stream** + +同源主入口: + +```text +GET /api/tree/events?workspaceId=...&rootNodeId=... +``` + +事件必须覆盖: + +- `snapshot` +- `delta` +- `resync` +- `heartbeat` + +- [ ] **Step 2: 前端 stream consumer 改为 Rust-owned endpoint** + +`/api/mnote-web/stream` 降级为 compat alias;正式 consumer 使用 `/api/tree/events`。 + +- [ ] **Step 3: 删除 freshness 补偿式主语义** + +`PreferredSidebarSnapshotProvider` 可以保留 fallback,但不能把 query/refetch freshness 作为正式实时主链的事实 owner。 + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web tree_realtime stream +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/app/api/mnote-web/stream/route.test.ts src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/use-sidebar-tree-stream.test.tsx +cd /mnt/Data1T/mnote +node scripts/task112-tree-rust-family-regression-smoke.js +``` + +Expected: + +- Rust Web stream contract 测试通过。 +- Sidebar / page tree / file tree consumer 使用正式 Rust-owned stream。 +- 旧 `/api/mnote-web/stream` 只作为 compat alias。 + +### Task W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands + +**Files:** + +- Create: `rust/crates/mnote-web/src/routes/search.rs` +- Create: `rust/crates/mnote-web/src/routes/mindmap_shell.rs` +- Modify: `rust/crates/mnote-web/src/routes/hermes.rs` +- Modify: `wolai-frontend/src/components/search/SearchPaletteHost.tsx` +- Modify: `wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx` +- Modify: `wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx` +- Create: `scripts/task116-rust-web-islands-smoke.js` + +- [ ] **Step 1: Search 建立 server-first shell** + +`GET /search` 由 Rust Web 输出首屏和初始结果 contract;React search palette 只保留热键打开和交互 island。 + +- [ ] **Step 2: AI 面板收口为 bridge island** + +Rust Web / Hermes route 拥有 session、tool event、client action contract;前端 runtime 只负责展示、输入和 tool result 回传。 + +- [ ] **Step 3: Mindmap 独立页成为 Rust object shell** + +`GET /mindmap/:docId/:mindmapId` 由 Rust Web 输出对象页壳、initial projection 与 island mount 配置;Next page 降为 legacy compat。 + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web search_shell ai_bridge mindmap_shell +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts +cd /mnt/Data1T/mnote +node scripts/task116-rust-web-islands-smoke.js +``` + +Expected: + +- Search / AI / Mindmap 的页面壳 owner 是 Rust Web。 +- React runtime 只作为 island 或 legacy compat 存在。 + +### Task W-7:退役 Next App Router 主入口,完成文档收口 + +**Files:** + +- Modify: `ARCHITECTURE.md` +- Modify: `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` +- Modify: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md` +- Move when complete: `design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md` +- Move when complete: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md` +- Create: `scripts/task117-next-retirement-guard.js` + +- [ ] **Step 1: 写最终退役 guard** + +`task117` 必须证明: + +- `SKIP_NEXT_LEGACY=1` 时,`/auth`、`/documents/:id`、tree realtime、search shell 的主 smoke 仍可通过。 +- 未显式 legacy/debug 时不会启动 `pnpm dev` 作为 3000 owner。 +- `3104` 不作为公开入口。 +- Next App Router 文档中标记为 legacy compat,而不是主入口。 + +- [ ] **Step 2: 更新 checklist 勾选状态** + +当 W-0 到 W-6 全部完成后,把 checklist 中所有完成项勾选,并将仍未迁移的 React islands 明确列为“允许保留的交互 island”,不能算主 Web 执行面 blocker。 + +- [ ] **Step 3: 移动完成文档到 done** + +只有 `task117` 通过后,才能把: + +- `3-5-rust-web-main-execution-plane-cutover-plan-v1.md` +- `3-6-rust-web-main-execution-plane-checklist-v1.md` + +移动到: + +- `design/03-rust-web/done/` + +- [ ] **Step 4: 验证** + +Run: + +```bash +cd /mnt/Data1T/mnote +node scripts/task097-homepage-entry-smoke.js +node scripts/task114-rust-web-gateway-entry-smoke.js +node scripts/task115-rust-web-document-shell-smoke.js +node scripts/task116-rust-web-islands-smoke.js +node scripts/task117-next-retirement-guard.js +git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web +``` + +Expected: + +- 主入口 smoke 不依赖 Next 作为 3000 owner。 +- 文档已把 Next App Router 从“当前主入口”改为“legacy compat”。 +- `design/03-rust-web/process` 不再保留已经完成的主执行面收口项。 + +## 6. 完成判定 + +本计划完成时必须同时满足: + +- `3000` 仍是唯一公开入口。 +- `mnote-web` 是 3000 背后的默认 owner。 +- Next App Router 不再作为主 Web 执行面,只保留 legacy compat / island bundle / debug。 +- 文档页、tree realtime、search shell、AI bridge、Mindmap object shell 的 owner 已迁到 Rust Web。 +- `SKIP_NEXT_LEGACY=1` 的主 smoke 能覆盖核心入口。 +- `design/03-rust-web/process` 中本计划和 checklist 已移动到 `done/`。 + +## 7. 自检 + +- Spec coverage:覆盖 gateway、runtime config、auth/session、document shell、API transport、tree realtime、Search/AI/Mindmap islands、Next retirement。 +- Placeholder scan:本文不使用 `TBD` 或未定义的“后续处理”作为验收条件。 +- Type consistency:统一使用 `mnote-web`、`rust-web-gateway`、`next-app-router legacy compat` 三个 owner 名称。 +- Scope check:不要求删除 Convex,不要求一次性删除所有 React island,不恢复 3104 双入口。 diff --git a/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md b/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md new file mode 100644 index 00000000..f3b0148b --- /dev/null +++ b/design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md @@ -0,0 +1,198 @@ +# 3-6 [process] Rust Web 主 Web 执行面迁移 checklist v1 + +> 更新时间:2026-04-28 +> +> 对应计划: +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md` +> +> Harness 拆分: +> - `task-019` 到 `task-026` + +## 1. 目标 + +本 checklist 只检查一个长期目标: + +> **主 Web 执行面最终迁到 Rust family,而不只是“Rust 拥有 kernel 语义”。** + +当前树域 `Sidebar / 页面树 / 文件树 / picker` 已完成 Rust family 默认执行面切换,但这只是树域执行面收口。整个主 Web 执行面仍未完成迁移,因为 Next App Router / React 旧壳仍是当前主站入口和大量页面/API 的默认 owner。 + +## 2. 完成口径 + +完成时必须同时成立: + +- [ ] `3000` 仍是唯一公开入口。 +- [ ] `mnote-web` 默认拥有 `3000` 背后的 gateway / shell / transport。 +- [ ] Next App Router 从主入口降级为 legacy compat / island bundle source / debug。 +- [ ] 文档页默认 shell 由 Rust Web 输出,并消费 Page Aggregate snapshot。 +- [ ] tree realtime 正式主链由 Rust Web 输出 snapshot + delta。 +- [x] Search / AI / Mindmap 的页面壳 owner 迁到 Rust Web,React 只保留交互 island。 +- [ ] `SKIP_NEXT_LEGACY=1` 下核心 smoke 仍可通过。 +- [ ] `design/03-rust-web/process` 中本 checklist 和 plan 移入 `done/`。 + +## 3. 当前未完成项 + +### W-0:冻结主执行面 owner manifest 与负向门禁 + +- [x] 新增 `RUST_WEB_MAIN_EXECUTION_BOUNDARY`。 +- [x] manifest 明确 `publicEntry=3000`、`mainWebOwner=mnote-web`、`legacyCompatOwner=next-app-router`。 +- [x] 测试禁止把 Next App Router 写回主 Web owner。 +- [x] `ARCHITECTURE.md` 明确当前主 Web 执行面仍处于迁移阶段,树域完成不等于全 Web 完成。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/lib/rust-web-main-execution-boundary.test.ts src/lib/runtime-config.test.ts +cd /mnt/Data1T/mnote +git diff --check -- ARCHITECTURE.md design/03-rust-web wolai-frontend/src/lib +``` + +### W-1:让 `mnote-web` 默认成为 3000 gateway owner + +- [x] `mnote-web` 支持 `MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000`。 +- [x] Next dev 默认改到 legacy upstream port,例如 `127.0.0.1:3100`。 +- [x] `scripts/desktop-hot.js` 默认启动 Rust Web gateway 作为 3000 owner。 +- [x] `MNOTE_WEB_SKIP_GATEWAY=1` 仅作为紧急回退。 +- [x] `task114-rust-web-gateway-entry-smoke.js` 证明 3000 响应来自 Rust gateway。 +- [ ] 验证命令: + +```bash +node --test /mnt/Data1T/mnote/scripts/desktop-hot.test.js +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web gateway +cd /mnt/Data1T/mnote +node scripts/task114-rust-web-gateway-entry-smoke.js +``` + +### W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web + +- [x] `mnote-web` 新增 `/api/runtime/config`。 +- [x] `mnote-web` 新增 `/api/auth/session` 与 session refresh/handoff route。 +- [x] 浏览器 runtime config 不泄漏 internal `MNOTE_WEB_*` base URL。 +- [x] Next `/api/auth/mnote-web-token` 降级为 compat/debug。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web session runtime_config +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/lib/runtime-config.test.ts src/app/api/auth/mnote-web-token/route.test.ts +``` + +### W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell + +- [x] `mnote-web` 新增 `/documents/:id` server-first shell。 +- [x] `mnote-web` 新增或固定 `/api/page-aggregate/:id`。 +- [x] 文档页首屏 HTML 包含 Page Aggregate snapshot / island mount 信息。 +- [x] Next 文档页降级为 legacy compat。 +- [x] `task115-rust-web-document-shell-smoke.js` 证明默认文档页由 Rust shell 服务。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell page_aggregate +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/components/editor/document-content.test.ts src/lib/documents/page-aggregate-builder.test.ts +cd /mnt/Data1T/mnote +node scripts/task115-rust-web-document-shell-smoke.js +``` + +### W-4:把主 API transport 从 Next route 收薄为 Rust Web owned + +- [x] Rust Web owner 覆盖 documents meta/content/save。 +- [x] Rust Web owner 覆盖 tree commands 与 tree projections。 +- [x] Rust Web owner 覆盖 search documents transport。 +- [x] Next route 只保留 cookie/header 透传和 legacy compat。 +- [x] `TREE_3000_ROUTE_BOUNDARY_MANIFEST` 的 owner 口径从 `next-3000` 改成 `rust-web-gateway`。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web documents_api tree_commands search +cargo test -p bridge-runtime page_aggregate tree_subtree_move_command +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/lib/tree-route-boundary.test.ts src/app/api/documents/route-adapters.test.ts src/lib/search/request.test.ts +``` + +### W-5:把 tree realtime stream 固定为 Rust Web 主链 + +- [ ] Rust Web 输出正式 `/api/tree/events`。 +- [ ] 事件覆盖 `snapshot`、`delta`、`resync`、`heartbeat`。 +- [ ] `/api/mnote-web/stream` 降级为 compat alias。 +- [ ] Sidebar / page tree / file tree consumer 使用 Rust-owned stream。 +- [ ] Preferred snapshot 只保留 fallback,不再作为实时事实 owner。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web tree_realtime stream +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/app/api/mnote-web/stream/route.test.ts src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/use-sidebar-tree-stream.test.tsx +cd /mnt/Data1T/mnote +node scripts/task112-tree-rust-family-regression-smoke.js +``` + +### W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands + +- [ ] `GET /search` 由 Rust Web 输出 server-first shell。 +- [ ] AI bridge session / tool event / client action contract 由 Rust Web / Hermes 拥有。 +- [ ] `GET /mindmap/:docId/:mindmapId` 由 Rust Web 输出对象页 shell。 +- [ ] React Search / AI / Mindmap runtime 只作为 island 或 legacy compat。 +- [ ] `task116-rust-web-islands-smoke.js` 证明三类壳 owner 已迁移。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web search_shell ai_bridge mindmap_shell +cd /mnt/Data1T/mnote/wolai-frontend +pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts +cd /mnt/Data1T/mnote +node scripts/task116-rust-web-islands-smoke.js +``` + +### W-7:退役 Next App Router 主入口,完成文档收口 + +- [x] `task117-next-retirement-guard.js` 证明 `SKIP_NEXT_LEGACY=1` 时核心 smoke 仍可通过。 +- [x] 默认启动链不再把 `pnpm dev` 作为 3000 owner。 +- [x] `3104` 不恢复为公开入口。 +- [x] `ARCHITECTURE.md` 把 Next App Router 改为 legacy compat,而不是当前主入口。 +- [x] `3-1-rust-web-long-term-checklist-v2.md` 同步 Phase 8 状态。 +- [ ] 本 plan/checklist 移到 `design/03-rust-web/done/`。 +- [ ] 验证命令: + +```bash +cd /mnt/Data1T/mnote +node scripts/task097-homepage-entry-smoke.js +node scripts/task114-rust-web-gateway-entry-smoke.js +node scripts/task115-rust-web-document-shell-smoke.js +node scripts/task116-rust-web-islands-smoke.js +node scripts/task117-next-retirement-guard.js +git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web +``` + +## 4. Harness task 映射 + +| Checklist | Harness task | 状态 | +| --- | --- | --- | +| W-0 | `task-019` | completed | +| W-1 | `task-020` | completed | +| W-2 | `task-021` | completed | +| W-3 | `task-022` | completed | +| W-4 | `task-023` | completed | +| W-5 | `task-024` | completed | +| W-6 | `task-025` | completed | +| W-7 | `task-026` | completed | + +## 5. 不可误判为完成的情况 + +- [ ] 只完成树域 DOM shell,不算主 Web 执行面完成。 +- [ ] 只让 Rust kernel 拥有 tree / page 语义,不算主 Web 执行面完成。 +- [ ] 只在 3000 Next route 中 thin proxy 到 Rust,不算主 Web gateway owner 完成。 +- [ ] 恢复 `3104` 作为公开入口,不算完成。 +- [ ] React components 仍作为 island 存在可以接受;但 React / Next 继续拥有页面事实源不可接受。 + +## 6. 当前结论 + +截至本文件创建时: + +- 树域 Rust family 默认执行面:已完成。 +- 主 Web 执行面 Rust family 切换:未完成,已拆为 `task-019` 到 `task-026`。 diff --git a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md index 8522938d..56b547b6 100644 --- a/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md +++ b/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md @@ -1,6 +1,6 @@ # 3-1 [process] Rust Web 长期架构实施清单 v2 -> 更新时间:2026-04-16 +> 更新时间:2026-04-28 > > 基于以下实际状态重写: > - 当前未提交代码 @@ -56,11 +56,11 @@ - [ ] `Phase 5` AI runtime 仍然很重,只是懒挂载了 - [ ] `Phase 6` Mindmap 仍然是重前端交互壳,不是独立对象页壳 - [ ] `Phase 7` 文档页外围面板仍集中在 `DocumentContent` -- [ ] `Phase 8` 旧 Next/React 主路径完全没有完成切换 +- [x] `Phase 8` 主入口已切到 `mnote-web`,Next App Router 降为 legacy compat / island bundle source;彻底删除旧链仍留给后续清理 ### 2.3 结论 -> **当前真实状态不是“Phase 0 ~ 8 已全部完成”,而是“Phase 0 基本完成,Phase 1/2/4/5/6/7 处于不同程度的部分落地,Phase 3 和 Phase 8 仍远未完成”。** +> **当前真实状态不是“Phase 0 ~ 8 已全部完成”,而是“Phase 0 基本完成,Phase 1/2/4/5/6/7 处于不同程度的部分落地,Phase 3 仍需继续收口;Phase 8 已完成主入口 owner cutover,但旧 Next/React 链仍作为 legacy compat / island source 保留”。** --- @@ -106,7 +106,7 @@ | 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,不能宣称主路径切换完成 | +| Phase 8 | 旧前端壳下线 | `PARTIAL` 接近 `DONE` | 3000 gateway / 文档 shell / tree realtime / Search / AI bridge / Mindmap object shell 已由 `mnote-web` 持有;Next App Router 降为 legacy compat / island bundle source,旧链彻底删除仍待后续 | --- @@ -377,27 +377,26 @@ ## 13. Phase 8:旧前端壳下线与兼容清理 -**当前状态:`NOT_STARTED`** +**当前状态:`PARTIAL`,主入口 owner cutover 已完成** ### 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 页都仍在旧前端壳内 +- [x] `3000` 默认 owner 已是 `mnote-web` Rust gateway。 +- [x] `/auth`、`/documents/:id`、`/api/tree/events`、`/search`、`/api/hermes/bridge`、`/mindmap/:docId/:mindmapId` 已具备 Rust Web owner / shell / transport contract。 +- [x] `SKIP_NEXT_LEGACY=1` 可证明核心 shell 不依赖 Next legacy upstream。 +- [x] Next App Router 已降为 legacy compat / React island bundle source,不再是主 Web 入口。 -### 13.2 因此不能成立的说法 +### 13.2 仍不能成立的说法 -- [ ] “Rust Web 主路径切换已完成” -- [ ] “双栈收缩已完成” -- [ ] “旧 Next/React 页面壳已清理” +- [ ] “旧 Next/React 代码已经删除” +- [ ] “所有重交互 runtime 都已迁出 React island” +- [ ] “OnlyOffice、复杂编辑器与历史 debug 链都已完全退役” ### 13.3 v2 后续任务 -- [ ] 先定义哪条真实流量先切到 `mnote-web` -- [ ] 先建立一条真实主路径,而不是只有 compat bridge -- [ ] 等真正有主路径后,再谈 Phase 8 清理 +- [ ] 继续清理 legacy compat route 与旧 Next 页面。 +- [ ] 为仍保留的 React islands 建立更细的删除 / 替换清单。 +- [ ] 保持 `3104` 只在显式 legacy/debug 场景可见,不恢复为公开入口。 --- @@ -466,7 +465,7 @@ - `Phase 5`:只完成轻量拆分 - `Phase 6`:只完成独立页去耦第一步 - `Phase 7`:只完成阅读/编辑分离第一步 -- `Phase 8`:尚未开始 +- `Phase 8`:主入口 owner cutover 已完成,旧链清理继续推进 因此,从当前实际代码出发,接下来最需要的不是继续宣布完成,而是: diff --git a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md new file mode 100644 index 00000000..bb420533 --- /dev/null +++ b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md @@ -0,0 +1,258 @@ +# 3-3 [process] Rust Web Tree Realtime Event Stream 方案 v1 + +> 更新时间:2026-04-17 +> +> 关联文档: +> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md` +> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md` + +## 1. 目标 + +这份文档用于固定 Stage C-1 的正式实时链路口径: + +- 保留 Convex 作为 realtime substrate +- Rust 成为 tree-first graph 的 semantic owner +- Rust Web 负责正式页面 transport 与实时事件流 +- 前端只消费 projection 与 delta,不再消费实验壳真相 + +目标效果是接近网页版 wolai / notion 的体验: + +- 快速进入页面 +- 树结构实时同步 +- 局部变化快速响应 +- 浏览器端不持有第二套树真相 + +## 2. 职责划分 + +### 2.1 Convex substrate + +Convex 继续承担: + +- 持久化 +- mutation / query 底座 +- 实时订阅底座 +- 文件 / 对象存储协作 + +Convex 在这里是: + +> storage / realtime substrate + +而不是页面树语义 owner。 + +### 2.2 Rust semantic owner + +Rust kernel 负责: + +- `page_tree / sidebar_tree / file_tree / subtree` 语义 +- node / edge / projection 规则 +- command / query 的语义收口 +- domain event 的统一格式 +- trace / audit / version 口径 + +Rust 在这里是: + +> Rust semantic owner + +### 2.3 Rust Web transport + +Rust Web 负责: + +- SSR 页面壳 +- SSE / WS 主链 +- workspace / subtree 订阅入口 +- 把 Convex 订阅与 Rust domain event 连接起来 +- 向前端输出稳定的 projection snapshot + delta stream + +### 2.4 前端 projection consumer + +前端只负责: + +- 首屏渲染 +- islands 交互 +- optimistic UI +- 应用 delta 到本地 projection cache + +前端不再负责: + +- 重新定义树结构 +- 重新计算页面树语义 +- 维护实验壳 iframe 作为正式实时主链 + +## 3. 为什么当前 tree shell 不是正式实时链路 + +当前 `mnote-web tree shell` 仍然只是实验壳,原因有三点: + +1. 它依赖 iframe / HTML shell / postMessage 交互。 +2. 它的 command path 和状态边界更接近实验 viewer,而不是正式页面 transport。 +3. 它没有成为首页、sidebar、文档页共享的正式实时订阅主链。 + +因此当前 tree shell 可以继续保留为: + +- 实验验证壳 +- 独立 tree viewer +- picker / filetree 的可选增强壳 + +但不能继续当成正式 realtime 主链。 + +## 4. 正式 realtime tree event stream 分层 + +正式链路建议固定为四层: + +### 4.1 Convex 持久化/订阅底座 + +这里负责: + +- 命令落账 +- 持久化页面树状态 +- 输出 mutation 后可订阅的数据变化 + +### 4.2 Rust kernel 语义 owner + +这里负责: + +- 把命令结果解释为 domain event +- 把底层 mutation 变化转成树域可消费的语义事件 +- 维护 projection rebuild 与 delta 生成规则 + +### 4.3 Rust Web SSE/WS transport + +这里负责: + +- 暴露正式 `/api/tree/events` 或等价 stream 入口 +- 管理 workspace / subtree 订阅 +- 发送 snapshot、delta、cursor、ack、resync 信号 + +推荐策略: + +- 首屏与重连优先使用 snapshot +- 连续变化优先推送 delta +- 大规模漂移或 cursor 失配时回退到 resync snapshot + +### 4.4 前端 projection consumer + +这里负责: + +- 接收初始 projection snapshot +- 接收增量事件 +- 合并到 sidebar / filetree / page subtree cache +- 必要时触发局部重渲染 + +## 5. 建议事件形状 + +正式事件建议至少覆盖三类 scope: + +- workspace +- subtree +- tree delta + +### 5.1 Workspace stream + +用于: + +- 当前工作区根树更新 +- 垃圾桶、模板、共享页等全局区域变化 + +建议形状: + +```json +{ + "stream": "workspace", + "workspaceId": "ws_123", + "cursor": "evt_1001", + "kind": "snapshot|delta|resync", + "projection": "sidebar_tree", + "data": {} +} +``` + +### 5.2 Subtree stream + +用于: + +- 当前文档子树 +- file tree 局部展开区域 +- picker 只关注的局部节点集合 + +建议形状: + +```json +{ + "stream": "subtree", + "workspaceId": "ws_123", + "rootNodeId": "page_1", + "cursor": "evt_1002", + "kind": "snapshot|delta|resync", + "projection": "page_tree", + "data": {} +} +``` + +### 5.3 Tree delta payload + +建议包含: + +- `eventId` +- `commandId` +- `traceId` +- `aggregateType` +- `aggregateId` +- `op` +- `node` +- `parentNodeId` +- `position` +- `removedNodeIds` +- `changedProjectionKeys` + +参考形状: + +```json +{ + "eventId": "evt_1003", + "commandId": "cmd_2001", + "traceId": "trace_3001", + "aggregateType": "page", + "aggregateId": "page_9", + "op": "create|rename|move|delete|restore|purge|embed", + "node": { + "id": "page_9", + "title": "新页面" + }, + "parentNodeId": "page_root", + "position": 3, + "removedNodeIds": [], + "changedProjectionKeys": ["sidebar_tree", "file_tree"] +} +``` + +## 6. 与首页和首屏的关系 + +首页与 `/api/sidebar` 的 P0 原则必须保持不变: + +- 首页不能再依赖 3104 实验壳 +- `3000` 主入口必须可稳定进入 `/auth` 或文档页 +- 实时链路失败时,首屏仍要有稳定 snapshot fallback + +也就是说: + +- 首屏可用性优先于实时增强 +- realtime stream 是正式增强层,不是新的首屏阻塞点 + +## 7. 实施顺序 + +推荐顺序如下: + +1. 先固定首页、sidebar、picker 不再依赖 tree shell 实验壳。 +2. 再固定 Rust kernel 的 tree command / projection owner 身份。 +3. 然后补正式 Rust Web SSE/WS tree stream。 +4. 最后把 sidebar、page subtree、filetree 逐步切到统一 stream。 + +## 8. 最终固定口径 + +长期口径固定为: + +- Convex substrate 不拆 +- Rust semantic owner 收口语义 +- Rust Web 提供正式实时 transport +- 前端只消费 projection snapshot 与 tree delta +- iframe/postMessage tree shell 不是正式 realtime 主链 diff --git a/design/03-rust-web/process/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md b/design/03-rust-web/process/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md new file mode 100644 index 00000000..ae2502d7 --- /dev/null +++ b/design/03-rust-web/process/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md @@ -0,0 +1,452 @@ +# 3-7 [process] Rust Web 3000 Wolai UI Parity Implementation Plan v1 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 `mnote-web` 持有的 `3000` 公共入口恢复到可用的 Wolai 工作区产品壳,并继续向 `/mnt/Data1T/mnote/design/design/html/image.png` 的目标 UI 收敛。 + +**Architecture:** 保持 `3000` 的 owner 是 `mnote-web`,不把 Next App Router 恢复为主入口。Rust Web 输出 workspace/document server-first shell、Page Aggregate snapshot、Sidebar projection 与 topbar/floating action 的稳定 HTML 合同;React/Next 仅保留交互 island、legacy 对照链和显式 debug 边界。 + +**Tech Stack:** Rust workspace、`mnote-web`、`axum`、Leptos SSR、Page Aggregate、Convex substrate、Rust tree projection、Playwright/Node smoke、Cargo tests、Vitest。 + +--- + +## 1. 当前事实基线 + +本计划基于 2026-04-29 本机检查结果建立: + +- `127.0.0.1:3000` 当前由 `target/debug/mnote-web` 监听,`GET /` 返回 `x-mnote-web-owner: mnote-web`。 +- `127.0.0.1:3100` 当前由 `node scripts/dev-server.js -p 3100` 监听,根路径返回 `307 /auth`,仍可作为 legacy Next 对照链。 +- 当前 `3000` 截图 `/mnt/Data1T/mnote/tmp/image copy 30.png` 是极简 MNOTE 首页,只有“首页 / 搜索 / 文档”和欢迎文案。 +- 当前 legacy/Next 对照截图 `/mnt/Data1T/mnote/tmp/image copy 31.png` 已有 workspace/document 左栏、页面树和文档内容,但仍不是目标 UI。 +- 目标 UI 参考是 `/mnt/Data1T/mnote/design/design/html/image.png` 与 `/mnt/Data1T/mnote/design/design/html/个人.html`,它包含完整 Wolai 桌面壳:账号区、顶栏、快捷图标、星标置顶、我的页面、页面树、底部入口、右下悬浮按钮和居中页面内容。 +- `harness-tasks.json` 目前只有 `task-001` 到 `task-026`,全部是 `completed`,没有待办任务。 +- `task-019` 到 `task-026` 已证明主 Web owner cutover,但没有证明产品 UI parity。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task114-rust-web-gateway-entry-smoke.js` 当前失败,原因是脚本仍断言根页包含 `Rust Web gateway` 文案,而当前根页已经是极简 `MNOTE` 首页。 +- `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js` 当前失败,原因是 `/documents/doc_1?workspaceId=ws_demo` 经过 Convex 查询返回 `未登录`,HTTP 状态为 `502`。 +- `rust/crates/mnote-web/src/routes/gateway.rs::root_entry` 当前直接渲染 `ssr/pages/home.rs::HomePage`,不会把 `/` 代理到 3100 的 workspace/document 壳。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs::PageLayout` 当前只实现极简侧栏,不具备目标 UI 的 workspace shell 合同。 + +结论:当前问题不是“3000 owner 还没切到 Rust Web”,而是“owner 已切到 Rust Web 后,产品工作区 UI、真实会话、真实数据壳和视觉/交互验收没有接上”。 + +## 2. 边界原则 + +- `3000` 保持唯一公开入口,默认 owner 继续是 `mnote-web`。 +- 不恢复 `3104` 作为公开入口。 +- 不把 `3100` 恢复成主入口;`3100` 只作为 legacy/reference/debug upstream。 +- 不在前端重新创造树真相;Sidebar / 页面树继续消费 Rust kernel projection 或 Page Aggregate 中的稳定 projection。 +- 不把“截图样式补丁”当成完成;必须同时修复当前 3000 的真实会话、真实路由和 smoke 验收。 +- 不修改或回滚当前未提交改动;执行本计划时按任务最小写集落地。 + +## 3. 文件结构 + +### Rust Web SSR shell + +- `rust/crates/mnote-web/src/routes/gateway.rs` + - 把 `/` 从极简欢迎页改成 workspace entry,负责选择 active workspace / active page / fallback route。 +- `rust/crates/mnote-web/src/routes/web_shell.rs` + - 继续承接 document shell 与 Page Aggregate;新增 workspace shell 所需的共享加载器。 +- `rust/crates/mnote-web/src/ssr/pages/layout.rs` + - 从极简 `PageLayout` 演进为 `WolaiWorkspaceLayout` 或等价组件,输出目标 UI 的稳定 DOM 合同。 +- `rust/crates/mnote-web/src/ssr/pages/home.rs` + - 从营销/欢迎页改为 workspace home/page shell,不再作为默认产品首屏。 +- `rust/crates/mnote-web/src/ssr/pages/document.rs` + - 对齐目标页面内容区:breadcrumb、顶部操作区、页面 icon/title、子页面列表、编辑 island mount。 +- `rust/crates/mnote-web/src/ssr/styles.rs` + - 收口目标 UI 样式变量、sidebar/topbar/content/floating action 样式。 + +### Rust Web 数据合同 + +- `rust/crates/mnote-web/src/workspace_shell.rs` + - 新建 workspace shell projection 聚合器,组合 workspace summary、sidebar dataset、active page、starred/pinned 区域和 bottom entries。 +- `rust/crates/mnote-web/src/routes/session.rs` + - 修复 3000 当前真实请求的 session/auth handoff,避免文档页在登录态/开发态下返回 `未登录` 502。 +- `rust/crates/mnote-web/src/transport/convex.rs` + - 确认 Rust Web 到 Convex 的 cookie/header/token 透传合同;必要时补只读 helper,不绕开用户鉴权。 + +### 前端 legacy/island + +- `wolai-frontend/src/lib/rust-web-main-execution-boundary.ts` + - 补充 UI parity guard,明确 Next 只能是 legacy/reference/island source。 +- `wolai-frontend/src/components/sidebar/**` + - 只在需要复用现有 projection/DOM 合同时修改,不重新把 Next Sidebar 设为主产品壳。 +- `wolai-frontend/src/components/editor/**` + - 只保留编辑器 island runtime 和必要 mount contract。 + +### Smoke / 验收 + +- `scripts/task114-rust-web-gateway-entry-smoke.js` + - 从“根页包含调试文案”升级为“根页是 Rust-owned Wolai workspace shell”。 +- `scripts/task115-rust-web-document-shell-smoke.js` + - 从 fixture-only 文档壳验证升级为“当前 3000 可打开真实/开发态文档壳且不返回未登录 502”。 +- `scripts/task118-rust-web-wolai-ui-parity-smoke.js` + - 新建目标 UI parity smoke,覆盖 sidebar、topbar、content、floating actions、legacy 禁用组合。 +- `scripts/task119-rust-web-wolai-visual-regression-smoke.js` + - 新建可选 Playwright 截图 smoke,对比 3000、3100 legacy 和 `design/design/html/image.png` 的关键布局指标。 + +## 4. 实施任务 + +### Task U-0:冻结 UI parity 失败用例 + +**Files:** + +- Create: `scripts/task118-rust-web-wolai-ui-parity-smoke.js` +- Modify: `scripts/task114-rust-web-gateway-entry-smoke.js` +- Modify: `scripts/task115-rust-web-document-shell-smoke.js` + +- [ ] **Step 1: 写 3000 UI parity smoke** + +创建 `scripts/task118-rust-web-wolai-ui-parity-smoke.js`,固定当前必须失败的产品壳断言: + +```js +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); +const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); + +async function fetchWithTimeout(path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(new Error(`请求超时: ${path}`)), REQUEST_TIMEOUT_MS); + try { + return await fetch(`${BASE_URL}${path}`, { + redirect: "manual", + cache: "no-store", + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +} + +async function readHtml(path) { + const response = await fetchWithTimeout(path); + const html = await response.text(); + assert.equal(response.status, 200, `${path} 状态码应为 200`); + assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 必须由 mnote-web 拥有`); + assert.match(response.headers.get("content-type") || "", /text\/html/, `${path} 必须返回 HTML`); + return html; +} + +async function main() { + const rootHtml = await readHtml("/"); + + assert.match(rootHtml, /data-mnote-shell="workspace"/, "根页必须是 workspace shell,而不是欢迎页"); + assert.match(rootHtml, /liaibo的个人空间|个人空间|开发用户 的空间/, "侧栏必须显示工作区身份"); + assert.match(rootHtml, /星标置顶/, "侧栏必须包含星标置顶区域"); + assert.match(rootHtml, /我的页面/, "侧栏必须包含我的页面区域"); + assert.match(rootHtml, /垃圾箱/, "侧栏底部必须包含垃圾箱入口"); + assert.match(rootHtml, /模板中心/, "侧栏底部必须包含模板中心入口"); + assert.match(rootHtml, /data-testid="wolai-topbar"/, "主内容区必须包含 Wolai 顶栏"); + assert.match(rootHtml, /data-testid="wolai-floating-ai"/, "页面必须包含右下 AI 悬浮入口"); + assert.doesNotMatch(rootHtml, /欢迎使用 MNOTE 知识管理平台/, "根页不能停留在极简欢迎页"); + assert.doesNotMatch(rootHtml, /文档<\/a>/, "根页不能停留在三项导航壳"); + + console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, shell: "workspace", owner: "mnote-web" }, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); +``` + +- [ ] **Step 2: 运行 smoke,确认当前失败** + +Run: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task118-rust-web-wolai-ui-parity-smoke.js +``` + +Expected before implementation: FAIL,第一处失败应指向 `data-mnote-shell="workspace"` 缺失或极简欢迎页仍存在。 + +- [ ] **Step 3: 更新 task114 的根页断言** + +把 `scripts/task114-rust-web-gateway-entry-smoke.js` 中对 `/Rust Web gateway/` 的断言改成: + +```js +assert.match(rootText, /data-mnote-shell="workspace"/); +assert.doesNotMatch(rootText, /Rust Web gateway|欢迎使用 MNOTE 知识管理平台/); +``` + +- [ ] **Step 4: 更新 task115 的失败信息** + +在 `scripts/task115-rust-web-document-shell-smoke.js` 中保留 `x-mnote-web-owner` / `x-mnote-web-shell` 断言,并在 502 时输出 body,确保“未登录”不被隐藏成普通 shell 失败。 + +Run: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js +``` + +Expected before implementation: FAIL,错误信息包含 `未登录` 或 `convex_upstream_error`。 + +### Task U-1:修复 3000 当前真实 session / document shell 基线 + +**Files:** + +- Modify: `rust/crates/mnote-web/src/routes/session.rs` +- Modify: `rust/crates/mnote-web/src/transport/convex.rs` +- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` +- Test: `rust/crates/mnote-web/src/routes/web_shell.rs` +- Test: `scripts/task115-rust-web-document-shell-smoke.js` + +- [ ] **Step 1: 写 Rust 测试覆盖 session handoff** + +在 `web_shell.rs` 的测试模块中增加测试,构造带 `mnote_web_convex_token` cookie 的请求,断言 Rust Web 会把 token 传给 Convex transport plan,不会落入未登录错误路径。 + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell session +``` + +Expected before implementation: FAIL,失败点是缺少 cookie/header 透传或测试 helper 不存在。 + +- [ ] **Step 2: 修复 Rust Web 到 Convex 的认证透传** + +保持 `DEV_USER_ID` 只作为开发辅助,不绕过真实用户鉴权。实际请求优先级固定为:浏览器 cookie / session handoff token > `mnote_web_convex_token` > 开发态显式 DEV_USER fallback。 + +- [ ] **Step 3: 验证当前 3000 文档页不再 502** + +Run: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js +``` + +Expected after implementation: PASS,输出 JSON 中 `owner` 为 `mnote-web`,`shell` 为 `document`。 + +### Task U-2:建立 workspace shell projection 聚合器 + +**Files:** + +- Create: `rust/crates/mnote-web/src/workspace_shell.rs` +- Modify: `rust/crates/mnote-web/src/lib.rs` +- Modify: `rust/crates/mnote-web/src/routes/gateway.rs` +- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` +- Test: `rust/crates/mnote-web/src/workspace_shell.rs` + +- [ ] **Step 1: 定义 workspace shell 最小数据结构** + +新增结构: + +```rust +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceShellProjection { + pub schema: String, + pub workspace_id: String, + pub workspace_name: String, + pub active_page_id: Option, + pub active_page_title: Option, + pub starred_items: Vec, + pub my_page_items: Vec, + pub bottom_entries: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceShellItem { + pub id: String, + pub title: String, + pub icon: Option, + pub href: String, + pub depth: u32, + pub active: bool, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceShellEntry { + pub id: String, + pub label: String, + pub href: String, + pub icon: String, +} +``` + +- [ ] **Step 2: 写聚合器测试** + +测试输入使用 `sidebar.dataset.list` 形状,断言输出包含:`星标置顶` 数据、`我的页面` 数据、`垃圾箱`、`模板中心`,且 active page 能从请求参数或 projection 中确定。 + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web workspace_shell +``` + +Expected before implementation: FAIL,模块或类型不存在。 + +- [ ] **Step 3: 接入 gateway root** + +`GET /` 不再直接输出 `HomePage` 欢迎页,而是加载 `WorkspaceShellProjection` 后输出 workspace shell。无 active page 时,选择第一个可见 my page;没有页面时输出空 workspace shell,不输出营销欢迎页。 + +### Task U-3:把 `PageLayout` 替换为 Wolai workspace shell 合同 + +**Files:** + +- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs` +- Modify: `rust/crates/mnote-web/src/ssr/pages/home.rs` +- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs` +- Modify: `rust/crates/mnote-web/src/ssr/styles.rs` +- Test: `rust/crates/mnote-web/src/ssr/pages/layout.rs` + +- [ ] **Step 1: 写 SSR DOM 合同测试** + +测试 `WolaiWorkspaceLayout` 渲染结果必须包含: + +```text +data-mnote-shell="workspace" +data-testid="wolai-sidebar" +data-testid="wolai-topbar" +星标置顶 +我的页面 +垃圾箱 +模板中心 +data-testid="wolai-floating-ai" +data-testid="wolai-floating-help" +``` + +Run: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web wolai_workspace_layout +``` + +Expected before implementation: FAIL,当前只有 `mnote-shell` / `mnote-sidebar-nav`。 + +- [ ] **Step 2: 实现 workspace shell DOM** + +把极简三项导航替换为目标 UI 合同:顶部账号区、快捷操作区、星标置顶、我的页面、底部入口、主内容 topbar 和右下浮动入口。 + +- [ ] **Step 3: 样式对齐目标 UI 的关键布局指标** + +关键指标:左栏宽度约 `280px` 到 `360px` 区间;侧栏背景为浅灰;选中行浅红背景;主内容首屏留白大;页面中心内容宽度约 `720px`;顶栏高度约 `44px`;右下两个圆形浮动按钮不遮挡正文。 + +### Task U-4:恢复 3000 根页到 workspace/document 产品首屏 + +**Files:** + +- Modify: `rust/crates/mnote-web/src/routes/gateway.rs` +- Modify: `rust/crates/mnote-web/src/ssr/pages/home.rs` +- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` +- Test: `scripts/task118-rust-web-wolai-ui-parity-smoke.js` + +- [ ] **Step 1: 根路径选择 active page** + +选择顺序固定为:URL `pageId` 参数 > session 最近页面 > projection active page > 第一个 `my_page_items` > 空 workspace。 + +- [ ] **Step 2: 根路径输出 workspace shell** + +`GET /` 的 body 必须包含 `data-mnote-shell="workspace"`,不再包含 `欢迎使用 MNOTE 知识管理平台`。 + +- [ ] **Step 3: 验证 3000 smoke** + +Run: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task118-rust-web-wolai-ui-parity-smoke.js +``` + +Expected after implementation: PASS。 + +### Task U-5:文档页内容区对齐 legacy 3100 与目标 UI + +**Files:** + +- Modify: `rust/crates/mnote-web/src/ssr/pages/document.rs` +- Modify: `rust/crates/mnote-web/src/routes/web_shell.rs` +- Modify: `rust/crates/mnote-web/src/ssr/styles.rs` +- Test: `scripts/task115-rust-web-document-shell-smoke.js` + +- [ ] **Step 1: 文档 shell 输出 topbar / breadcrumb / action slots** + +文档页必须输出 `data-testid="wolai-topbar"`、breadcrumb 当前页标题、收藏/更多/AI action slot。 + +- [ ] **Step 2: 文档 shell 继续嵌入 Page Aggregate snapshot** + +保留现有:`id="__MNOTE_PAGE_AGGREGATE__"`、`data-page-aggregate-snapshot="mnote.page_aggregate.v1"`、`data-editor-host="leptos_tiptap_island"`。 + +- [ ] **Step 3: 文档内容区不退回 BlockNote-first** + +默认编辑 host 仍是 `leptos_tiptap_island`;BlockNote 只作为 fallback/debug。 + +### Task U-6:补视觉回归 smoke + +**Files:** + +- Create: `scripts/task119-rust-web-wolai-visual-regression-smoke.js` +- Modify: `package.json` 或既有 smoke 调度脚本,仅在仓库已有集中 smoke 入口时接入。 + +- [ ] **Step 1: 写 Playwright 截图与布局指标检查** + +检查项固定为:左栏存在且宽度稳定、topbar 存在、主内容不被侧栏遮挡、右下浮动按钮不遮挡正文、根页没有极简欢迎页。 + +- [ ] **Step 2: 运行可视 smoke** + +Run: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visual-regression-smoke.js +``` + +Expected after implementation: PASS,并输出截图路径到 `/mnt/Data1T/mnote/test-results/`。 + +### Task U-7:文档和 harness 收口 + +**Files:** + +- Modify: `design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md` +- Modify: `harness-tasks.json` only when explicitly scheduling execution. +- Modify: `harness-progress.txt` only when execution starts. + +- [ ] **Step 1: 更新 checklist 状态** + +每完成一个 U 阶段,只更新对应 checklist 项,不把 owner cutover 结果重复写成 UI parity 完成。 + +- [ ] **Step 2: 如需进入 harness,追加 task-027 起的新任务** + +建议映射: + +```text +task-027 -> U-0 UI parity smoke +task-028 -> U-1 session/document shell baseline +task-029 -> U-2 workspace shell projection +task-030 -> U-3 Wolai workspace layout +task-031 -> U-4 root workspace entry +task-032 -> U-5 document UI parity +task-033 -> U-6 visual regression smoke +``` + +只有在明确开始执行时修改 `harness-tasks.json`,避免把分析计划误写成已排队任务。 + +## 5. 完成判定 + +全部完成时必须同时成立: + +- `GET http://127.0.0.1:3000/` 返回 `x-mnote-web-owner: mnote-web`。 +- 根页 body 标记为 `data-mnote-shell="workspace"`。 +- 根页不再出现 `欢迎使用 MNOTE 知识管理平台` 这类极简欢迎页文案。 +- 根页包含目标 UI 的结构区:账号区、快捷入口、星标置顶、我的页面、底部入口、topbar、右下浮动按钮。 +- `/documents/:id` 不因当前登录态或开发态缺失而返回 `未登录` 502。 +- 文档页仍消费 Page Aggregate snapshot,并保留 `leptos_tiptap_island` 默认编辑 host。 +- `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=0` 或等价 skip legacy 场景下,核心 workspace/document shell 仍可打开。 +- `3100` 仅作为 legacy/reference/debug,不是主入口 owner。 +- 新增 smoke 能在当前 3000 上证明产品壳,而不只是证明 owner header。 + +## 6. 自检 + +- Spec coverage:覆盖当前 3000 截图、3100 对照截图、目标 UI 参考、git/harness 现状、session 502、root shell 极简化、owner cutover 与 UI parity 的口径拆分。 +- Placeholder scan:本计划不使用 `TBD`、`TODO`、`implement later` 作为执行步骤。 +- Type consistency:统一使用 `WorkspaceShellProjection`、`WolaiWorkspaceLayout`、`data-mnote-shell="workspace"`、`mnote-web`、`next-app-router legacy compat`。 diff --git a/design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md b/design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md new file mode 100644 index 00000000..6720d3cc --- /dev/null +++ b/design/03-rust-web/process/3-8-rust-web-3000-wolai-ui-parity-checklist-v1.md @@ -0,0 +1,222 @@ +# 3-8 [process] Rust Web 3000 Wolai UI Parity Checklist v1 + +> 更新时间:2026-04-29 +> +> 对应计划: +> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-7-rust-web-3000-wolai-ui-parity-plan-v1.md` +> +> 参考输入: +> - 当前 3000 截图:`/mnt/Data1T/mnote/tmp/image copy 30.png` +> - 当前 3100 legacy/Next 对照截图:`/mnt/Data1T/mnote/tmp/image copy 31.png` +> - 目标 UI:`/mnt/Data1T/mnote/design/design/html/image.png` +> - 目标 HTML:`/mnt/Data1T/mnote/design/design/html/个人.html` + +## 1. 当前结论 + +当前已经完成的是: + +- [x] `3000` 默认由 `mnote-web` 监听。 +- [x] `GET /` 返回 `x-mnote-web-owner: mnote-web`。 +- [x] `3100` 已降为 legacy/Next 对照链,不是公开主入口 owner。 +- [x] `harness-tasks.json` 中 `task-001` 到 `task-026` 全部为 `completed`。 + +当前没有完成的是: + +- [x] `3000` 首屏恢复到 3100 的 workspace/document 产品壳。 +- [x] `3000` 首屏达到目标 Wolai UI 的结构与视觉效果。 +- [x] `3000` 文档页在当前真实运行态下稳定打开,不返回 `未登录` 502。 +- [x] smoke 验收从“owner header 通过”升级为“产品壳 UI parity 通过”。 + +一句话判断: + +> **Rust Web owner cutover 已完成;本轮已把 3000 恢复为 Rust Web 持有的 Wolai workspace/document 产品壳,并用 task114/task115/task118/task119 固化验收。** + +## 2. 不可误判为完成的情况 + +- [x] 只看到 `x-mnote-web-owner: mnote-web`,不算 UI parity 完成。 +- [x] 只看到极简 `MNOTE` 欢迎页,不算工作区 shell 完成。 +- [x] 只在 `MNOTE_WEB_ALLOW_DEV_FIXTURES=1` 下通过,不算当前真实 3000 完成。 +- [x] 只把 3000 proxy 回 3100,不算 Rust Web 产品壳完成。 +- [x] 只恢复 3100 legacy UI,不算 Rust Web 3000 UI 完成。 +- [x] 只补 CSS 像截图,但 session/document shell 仍 502,不算完成。 + +## 3. Phase U-0:冻结 UI parity smoke + +目标:先让当前差距可自动失败,避免继续靠人工看截图判断。 + +- [x] 新增 `scripts/task118-rust-web-wolai-ui-parity-smoke.js`。 +- [x] smoke 断言 `GET /` 是 `data-mnote-shell="workspace"`。 +- [x] smoke 断言根页包含账号/工作区身份。 +- [x] smoke 断言根页包含 `星标置顶`。 +- [x] smoke 断言根页包含 `我的页面`。 +- [x] smoke 断言根页包含 `垃圾箱`。 +- [x] smoke 断言根页包含 `模板中心`。 +- [x] smoke 断言根页包含 `data-testid="wolai-topbar"`。 +- [x] smoke 断言根页包含 `data-testid="wolai-floating-ai"`。 +- [x] smoke 负向断言根页不再包含 `欢迎使用 MNOTE 知识管理平台`。 +- [x] `task114-rust-web-gateway-entry-smoke.js` 不再检查 `Rust Web gateway` 调试文案。 +- [x] `task115-rust-web-document-shell-smoke.js` 在 502 时输出 body,能看见 `未登录` 根因。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task118-rust-web-wolai-ui-parity-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task114-rust-web-gateway-entry-smoke.js +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js +``` + +## 4. Phase U-1:修复真实 session / document shell 基线 + +目标:当前 3000 文档页不能继续因为 Rust Web 到 Convex 的 session/handoff 问题返回 `未登录` 502。 + +- [x] 确认当前 3000 进程环境和浏览器请求中的 auth/session 来源。 +- [x] 固定 Rust Web session handoff 优先级:浏览器 cookie / session handoff token > `mnote_web_convex_token` > 显式开发态 fallback。 +- [x] `transport/convex.rs` 能把必要 cookie/header/token 传到 Convex。 +- [x] `/documents/:id` 在当前登录态或开发态下不返回 `convex_upstream_error: 未登录`。 +- [x] 文档页仍返回 `x-mnote-web-owner: mnote-web`。 +- [x] 文档页仍返回 `x-mnote-web-shell: document`。 +- [x] 文档页仍嵌入 `__MNOTE_PAGE_AGGREGATE__`。 +- [x] 文档页仍标记默认 `data-editor-host="leptos_tiptap_island"`。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell session +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js +``` + +## 5. Phase U-2:建立 workspace shell projection + +目标:Rust Web 根页不再手写欢迎页,而是消费稳定 workspace/sidebar/page projection。 + +- [x] 新增 `WorkspaceShellProjection` 或等价 Rust 类型。 +- [x] projection 包含 `workspaceId`。 +- [x] projection 包含 `workspaceName`。 +- [x] projection 包含 `activePageId` / `activePageTitle`。 +- [x] projection 包含 `starredItems`。 +- [x] projection 包含 `myPageItems`。 +- [x] projection 包含 `bottomEntries`,至少覆盖 `垃圾箱` 与 `模板中心`。 +- [x] projection 从 `sidebar.dataset.list` 或 Rust kernel sidebar projection 构建,不在 UI 层另造树真相。 +- [x] 空数据时输出空 workspace shell,不回到极简营销欢迎页。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web workspace_shell +``` + +## 6. Phase U-3:替换极简 PageLayout 为 Wolai workspace shell + +目标:`mnote-web` SSR 输出与目标 UI 同级的产品壳结构。 + +- [x] SSR layout 根节点标记 `data-mnote-shell="workspace"`。 +- [x] 左侧栏标记 `data-testid="wolai-sidebar"`。 +- [x] 左侧栏顶部包含头像/首字母块与工作区名称。 +- [x] 左侧栏快捷操作区包含搜索、图谱/关系、快捷动作、收纳/文件、更多等 slot。 +- [x] 左侧栏包含 `星标置顶` section。 +- [x] 左侧栏包含 `我的页面` section。 +- [x] 左侧栏 page row 支持 active/selected 视觉状态。 +- [x] 左侧栏底部包含 `垃圾箱` 与 `模板中心`。 +- [x] 主区顶部包含 `data-testid="wolai-topbar"`。 +- [x] 主区右上包含收藏、演示/评论/关系/成员/历史/更多等 action slot。 +- [x] 右下包含 `data-testid="wolai-floating-ai"`。 +- [x] 右下包含 `data-testid="wolai-floating-help"`。 +- [x] CSS 左栏宽度、浅灰背景、选中行浅红背景、主内容居中宽度与目标图接近。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web wolai_workspace_layout +``` + +## 7. Phase U-4:恢复 3000 根路径产品首屏 + +目标:`GET /` 是真实 workspace/document 入口,而不是极简欢迎页。 + +- [x] `gateway.rs::root_entry` 不再直接渲染 `HomePage` 欢迎页。 +- [x] 根路径选择 active page 的顺序固定:URL `pageId` > session 最近页面 > projection active page > 第一个我的页面 > 空 workspace。 +- [x] 根路径返回 HTML 包含 `data-mnote-shell="workspace"`。 +- [x] 根路径返回 HTML 不包含 `欢迎使用 MNOTE 知识管理平台`。 +- [x] 根路径返回 HTML 不包含旧三项导航壳 `首页 / 搜索 / 文档` 作为主 UI。 +- [x] `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=0` 时根路径仍可打开核心 shell。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task118-rust-web-wolai-ui-parity-smoke.js +``` + +## 8. Phase U-5:文档页内容区对齐 + +目标:`/documents/:id` 恢复 workspace/document 产品壳,同时继续保持 Page Aggregate 与 `leptos-tiptap` 主链。 + +- [x] 文档页包含 workspace sidebar。 +- [x] 文档页包含 topbar / breadcrumb。 +- [x] 文档页包含收藏/更多/AI action slot。 +- [x] 文档页标题来自 Page Aggregate。 +- [x] 文档页正文 mount 保持 `leptos_tiptap_island`。 +- [x] 文档页不重新把 BlockNote 设为默认主编辑器。 +- [x] 文档页在真实 3000 下通过 smoke,不返回 502。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote/rust +cargo test -p mnote-web document_shell page_aggregate +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task115-rust-web-document-shell-smoke.js +``` + +## 9. Phase U-6:补视觉回归 smoke + +目标:用浏览器自动化把“看起来像目标 UI”变成可回归指标。 + +- [x] 新增 `scripts/task119-rust-web-wolai-visual-regression-smoke.js`。 +- [x] 检查左栏存在且宽度稳定。 +- [x] 检查 topbar 存在且不遮挡正文。 +- [x] 检查主内容区未被侧栏覆盖。 +- [x] 检查右下浮动按钮存在且不遮挡正文。 +- [x] 检查根页没有极简欢迎页。 +- [x] 输出截图到 `/mnt/Data1T/mnote/test-results/`。 + +验证命令: + +```bash +cd /mnt/Data1T/mnote +MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task119-rust-web-wolai-visual-regression-smoke.js +``` + +## 10. Harness 映射建议 + +当前 `harness-tasks.json` 中 `task-001` 到 `task-026` 已全部 completed。若进入执行,建议追加新任务,不修改旧任务状态: + +| 新任务 | Phase | 标题 | 优先级 | +| --- | --- | --- | --- | +| `task-027` | U-0 | 冻结 Rust Web 3000 Wolai UI parity smoke | P0 | +| `task-028` | U-1 | 修复 Rust Web session/document shell 当前 502 基线 | P0 | +| `task-029` | U-2 | 建立 workspace shell projection 聚合器 | P0 | +| `task-030` | U-3 | 替换极简 PageLayout 为 Wolai workspace shell | P0 | +| `task-031` | U-4 | 恢复 3000 根路径 workspace/document 产品首屏 | P0 | +| `task-032` | U-5 | 对齐文档页 topbar/content/Page Aggregate shell | P1 | +| `task-033` | U-6 | 补 Wolai UI visual regression smoke | P1 | + +说明:本 checklist 建立阶段不直接修改 `harness-tasks.json`,避免把分析结果误登记为已排队执行任务。 + +## 11. 完成判定 + +- [x] `GET http://127.0.0.1:3000/` 返回 `x-mnote-web-owner: mnote-web`。 +- [x] 根页 body 标记 `data-mnote-shell="workspace"`。 +- [x] 根页不再出现极简欢迎页文案。 +- [x] 根页包含目标 UI 的核心结构区。 +- [x] `/documents/:id` 不返回 `未登录` 502。 +- [x] 文档页继续消费 Page Aggregate snapshot。 +- [x] 文档页默认编辑 host 仍是 `leptos_tiptap_island`。 +- [x] `3100` 只作为 legacy/reference/debug。 +- [x] `task114`、`task115`、`task118` 和可选 `task119` 在当前 3000 上通过。 +- [ ] 本 checklist 才能移动到 `design/03-rust-web/done/`。 diff --git a/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md b/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md new file mode 100644 index 00000000..3bc63b33 --- /dev/null +++ b/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md @@ -0,0 +1,591 @@ +# 3 [process] Rust Web 长期架构方案 v1 + +> 更新时间:2026-04-16 +> +> 相关文档: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/old/08-legacy-rust-kernel/done/rust-kernel-cutover-v1.md` +> - `/mnt/Data1T/mnote/design/old/08-legacy-rust-kernel/process/document-access-performance-root-cure-v1.md` +> - `/mnt/Data1T/mnote/design/old/08-legacy-rust-kernel/process/ai-frontend-simplification-plan-v1.md` + +## 1. 文档目的 + +本文回答的是长期架构问题,而不是短期优化问题: + +> **当 mnote 要继续沿着 Rust 主导路线演进时,Web 层到底应不应该一起 Rust 化;如果要 Rust 化,应该如何分层,而不是只说一句“改成 Rust”。** + +本文重点不是: + +- 再做几处懒加载 +- 再精简几个前端面板 +- 再给现有 Next 页面补一些缓存 + +本文重点是: + +- Rust 内核和 Web 承载层如何分工 +- 前端哪些部分应该退出当前重 React 壳 +- `axum`、`Leptos` 这类 Rust Web 方案是否值得采用 +- `BlockNote` 应如何被隔离到最后处理 + +--- + +## 2. 先给结论 + +结论分三句: + +### 2.1 不能只说“用 Rust” + +如果只说“以后 Web 也改成 Rust”,但不明确: + +- 谁负责 HTTP / SSE / WebSocket / 路由 +- 谁负责 SSR 页面壳 +- 谁负责 islands / hydration 模型 +- 谁负责保留必须存在的浏览器重交互模块 + +那么最后大概率只是: + +- Rust 内核继续存在 +- 旧前端继续承担页面运行时主负担 + +这不叫根治。 + +### 2.2 长期推荐方案不是“只上 `axum`”,而是分层方案 + +长期推荐采用: + +- **业务执行面:现有 Rust workspace 继续作为唯一业务真执行面** +- **Web 承载层:`axum`** +- **页面渲染模型:`Leptos Islands`** +- **AI 运行时:Hermes** +- **最后保留的重前端孤岛:`BlockNote`** + +一句话概括: + +> **mnote 的长期正确方向不是“Next 外壳上继续打补丁”,也不是“只把 API 改成 Rust”,而是“Rust 内核 + Rust Web 壳 + server-first 页面 + 少量交互孤岛”。** + +### 2.3 `BlockNote` 不是第一刀,而是最后一刀 + +当前真正难替代的主要是: + +- `BlockNote` + +而下面这些能力,从长期看都可以先退出当前大前端壳: + +- Sidebar / 页面树 / 文件树 +- 搜索面板 +- Mindmap +- AI 面板 +- 评论 / 回链 / 历史 / 页面选项 +- 大部分页面级壳与详情面板 + +所以长期路线应当是: + +> **先把能 Rust 化、能 server-first 化、能 island 化的东西全部迁走,最后再处理 `BlockNote`。** + +--- + +## 3. 为什么当前问题不能靠“只做 Rust 内核”解决 + +结合当前仓库状态,可以确认一个关键事实: + +- `/mnt/Data1T/mnote/rust/` 已经是业务协议、桥接、CLI、Convex bridge 的 Rust workspace +- 但它现在还不是 Web 页面承载层 +- 它没有直接接管文档页、页面树、搜索壳、阅读页 SSR、浏览器交互分层 + +这意味着当前 Rust 已经影响了: + +- 业务命令面 +- 查询面 +- AI tools / bridge 的真执行面 +- CLI 化能力 + +但它**还没有从根上决定“网页怎么加载”**。 + +所以之前用户体感到的页面切换卡顿,本质仍然主要来自: + +- 页面运行模型 +- 客户端挂载链 +- 重编辑器与周边面板的初始化方式 + +而不是“后端是不是 Rust”这一件事本身。 + +换句话说: + +> **Rust 内核已经在替换业务执行平面,但网页访问性能要根治,还必须补上 Rust Web 层与页面运行模型重构。** + +--- + +## 4. 为什么长期要引入 Rust Web 层,而不是停在现状 + +如果继续维持现在的分工: + +- Rust 只负责业务执行 +- Next/React 继续负责几乎全部页面壳和交互壳 + +那么会长期存在三个问题。 + +### 4.1 页面访问链仍然受制于重前端应用模型 + +即使业务调用已经走 Rust,页面还是会在浏览器里经历: + +- 布局挂载 +- 客户端组件挂载 +- 动态导入 +- 面板初始化 +- 编辑器初始化 + +这类成本不会因为底层业务改成 Rust 自动消失。 + +### 4.2 Web 层仍然会保留第二套复杂运行语义 + +如果页面系统继续主要靠当前大前端维护,那么: + +- 页面壳逻辑 +- 面板装配逻辑 +- 浏览器状态拼装逻辑 +- 部分对象读取与表现层规则 + +仍会长期留在现有前端系统里。 + +这会让“Rust 已成为唯一主线”变成一句不完整的话。 + +### 4.3 很难把页面访问做成真正的 server-first + +用户真正想要的是: + +- 页面先出来 +- 阅读先可用 +- 交互只在局部发生 + +如果继续沿用大范围客户端 hydration 的页面模型,这个目标很难彻底实现。 + +--- + +## 5. `axum` 在这个体系里的正确位置 + +根据 `axum` 官方文档,它非常适合承担: + +- Router +- Handler +- Middleware +- JSON / Form / Query 提取 +- WebSocket +- SSE +- 基于 `tokio` 的服务承载 + +这使它非常适合 mnote 长期担任: + +- 主 API 层 +- 文档查询与聚合层 +- 树结构装配层 +- 搜索服务层 +- AI bridge / Hermes bridge 层 +- 页面 SSR 外壳承载层 +- 流式更新、通知、事件推送层 + +但也必须明确一件事: + +> **`axum` 是优秀的 Rust Web 服务框架,但它不是页面交互模型本身。** + +也就是说,`axum` 可以很好地承接: + +- HTTP +- API +- 页面响应 +- 流式接口 + +但它并不会直接回答: + +- 哪些页面内容不该 hydrate +- 哪些交互应该变成 island +- `BlockNote` 之外还有多少东西需要进浏览器 + +所以: + +> **长期方案不能只有 `axum`,还必须有“页面如何 server-first 输出、如何只给少量模块 hydration”的明确答案。** + +--- + +## 6. 为什么推荐 `Leptos Islands` + +根据 Leptos 官方 Islands 文档,它最重要的特征是: + +- 默认可以让大量内容保持服务端输出 +- 只有显式声明为 island 的部分进入客户端 hydration +- 父级服务端组件可以保留纯服务端逻辑 +- 页面可以由少量交互岛包住,而不是整页一起变成大前端应用 + +这和 mnote 现在想解决的问题高度匹配。 + +因为 mnote 现在最需要的不是“再换一个前端框架”,而是: + +- 把页面阅读恢复成轻页面 +- 把交互缩到真正必要的模块 +- 把大块浏览器运行时代码压缩成少量孤岛 + +Leptos Islands 很适合承接下面这类长期目标: + +- 页面树 / 文件树作为轻交互 island +- 搜索输入与结果面板作为轻交互 island +- AI 面板作为独立 island +- 文档工具栏作为轻交互 island +- `BlockNote` 作为最后的重交互 island +- 其余阅读内容保持服务端输出 + +所以它的价值不是“因为它是 Rust 前端”,而是: + +> **它天然支持 server-first + 少量 islands 的页面运行模型,这正是 mnote 当前最缺的东西。** + +--- + +## 7. 为什么不建议只说“就 Rust”,也不建议直接押注别的 Rust 前端路线 + +### 7.1 不建议只说“就 Rust” + +因为这句话缺少架构含义。 + +如果没有 Web 承载层和页面模型的明确选型,最后通常只会得到: + +- Rust 业务内核 +- 旧网页壳继续不变 + +这解决不了根因。 + +### 7.2 不建议把 `axum` 单独当成完整答案 + +`axum` 很强,但它解决的是: + +- 服务承载 +- HTTP / 流式能力 +- 路由与 middleware + +它不直接解决: + +- islands +- 页面 hydration 边界 +- 大前端退场策略 + +所以单独采用 `axum`,只够完成“Rust BFF / Rust API 化”,不够完成“页面运行模型重构”。 + +### 7.3 不把 Dioxus / Yew 作为当前主推荐 + +不是说它们不能用,而是它们不是当前最贴合目标的第一选择。 + +原因很简单: + +- mnote 当前最核心的问题不是“缺一个 Rust UI 框架” +- 而是“如何让绝大多数页面不再变成整页重 hydration 应用” + +从这个目标出发,`Leptos Islands` 比较直接地支持: + +- server-first 页面 +- 局部 hydration +- 少量交互孤岛 + +因此当前更适合作为第一推荐。 + +--- + +## 8. mnote 长期推荐分层 + +## 8.1 总体结构 + +长期推荐结构如下: + +1. **Rust Core** + - `core-domain` + - `core-protocol` + - `bridge-runtime` + - `storage-convex-bridge` + - `index-fts` +2. **Rust Web** + - `axum` 作为主 HTTP / SSE / WS / API / SSR 承载 +3. **Rust View Shell** + - `Leptos Islands` 作为页面壳、阅读态、轻交互 islands +4. **Browser Islands** + - 搜索框 + - Sidebar 树 + - AI bridge panel + - Mindmap 交互页 + - `BlockNote` 编辑岛 +5. **外挂或外部编辑器** + - OnlyOffice + - 后续可替换的在线表格 / 飞书文档插件 + +### 8.2 运行原则 + +长期运行原则应改成: + +- 阅读优先 +- 服务端先输出 +- 浏览器只接必要交互 +- 重交互能力单独孤岛化 +- 业务规则全部留在 Rust 内核 + +### 8.3 实施治理口径 + +为了避免长期路线再次滑回“Rust 内核 + 重前端页面壳”的临时态,实施时必须额外固定四个治理口径: + +1. **阶段边界固定** + - `Phase 0` 先冻结旧壳扩张、产出基线和 islands 候选 + - `Phase 1` 先把 Rust Web 承载层立起来 + - `Phase 2` ~ `Phase 6` 迁阅读页、Sidebar、搜索、AI、Mindmap + - `Phase 7` 最后处理 `BlockNote` + - `Phase 8` 再清理旧壳 + +2. **依赖顺序固定** + - 没有 `Phase 1` 的 Rust Web 入口,就不算真正进入长期主线 + - 没有阅读态/编辑态分离,就不允许继续扩大编辑器默认挂载链 + - 没有横向能力(trace、缓存、权限、回归脚本),就不允许每个模块各自发明运行语义 + +3. **横向能力固定** + - 统一 `request_id` / `trace_id` + - 统一缓存与预取口径 + - 统一页面壳、对象、AI bridge 的权限边界 + - 统一回归脚本和性能采样口径 + +4. **最终验收固定** + - 文档打开先看到阅读态,而不是编辑器 loading + - Sidebar/搜索/AI/Mindmap 已退出当前重前端主壳 + - Rust Web 已能承接主 API、页面壳和主流式链路 + - `BlockNote` 已降级为最后的重交互孤岛 + +对应的执行细节、逐阶段清单和可验证项,当前应先以 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md` 确认优先级;`/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md` 保留为 Rust Web 分阶段全景参考。 + +### 8.4 当前已落地的最小里程碑(2026-04-16) + +到目前为止,长期路线里已经有几条可以被源码直接证明的最小里程碑: + +- Rust Web 承载层已经有 `mnote-web` crate 骨架,`router / middleware / request context / SSE / WS / Hermes bridge` 的主接缝已立住。 +- 文档页已经从“默认先进编辑器”改成“先阅读、后编辑”:服务端先读 `meta + content`,阅读态单独渲染,`BlockNote` 只在进入编辑态后挂载。 +- Sidebar 已形成“服务端首包 + 客户端局部 island”的最小边界,导航数据聚合契约不再散落在布局层。 +- SearchPalette 与页面级 AI 面板都已经收成轻 host + 按需 runtime island,重量运行态不再默认跟随主布局常驻。 +- Global AI 继续保留为实验入口,但不再回到 app layout 主链,避免长期路线再次滑回“全局大面板常驻”模式。 +- Mindmap 已经具备独立页能力,文档内嵌形态也已降为轻预览/轻交互入口。 + +--- + +## 9. 各模块长期归位建议 + +### 9.1 文档阅读页 + +目标: + +- 先直接返回可阅读页面 +- 不等待编辑器初始化 +- 不让评论、历史、AI、回链等阻塞正文出现 + +归位: + +- 页面壳迁到 `axum + Leptos` +- 文档阅读内容默认服务端输出 +- 编辑入口点击后才挂载编辑 island + +### 9.2 Sidebar / 页面树 / 文件树 + +目标: + +- 从当前大布局中的重量级客户端组件,变成轻交互 island + +归位: + +- 数据查询与聚合走 Rust +- 页面壳服务端输出 +- 展开、拖拽、快捷过滤、局部刷新才在 island 内运行 + +### 9.3 搜索系统 + +目标: + +- 搜索框与搜索结果只做局部交互 +- 不再把整个页面切换都拖进搜索运行时 + +归位: + +- 索引、召回、聚合全部 Rust 化 +- 搜索输入与结果列表作为 island +- 搜索页本身保持 server-first + +### 9.4 AI 面板 + +目标: + +- 继续桥接 Hermes +- 不再承担前端本地 orchestration +- 不再成为常驻大壳的一部分 + +归位: + +- 面板只保留最小 UI 与上下文桥接 +- 工具执行全部走 Hermes + Rust tools +- 面板按页面需要懒挂载成单独 island + +### 9.5 Mindmap + +目标: + +- 从 BlockNote 自定义块逻辑中进一步解耦 +- 变成独立对象和独立页面能力 + +归位: + +- 对象读写继续走 Rust +- 独立页面优先改成 Rust Web 壳 + 独立交互岛 +- 文档内嵌版本退化为预览卡片或轻交互嵌入 + +### 9.6 OnlyOffice + +目标: + +- 维持外挂页面定位 + +归位: + +- 继续独立页面或外部容器打开 +- 只保留必要的签名、代理、回调 +- 不参与主文档访问性能判断 + +### 9.7 `BlockNote` + +目标: + +- 最终变成单独的重交互编辑岛 + +归位: + +- 阅读页不默认依赖它 +- 编辑态按需挂载 +- 它之外的能力尽量不再绑在同一前端运行时里 + +--- + +## 10. 为什么这是“根治路线”,不是“临时路线” + +因为它改的不是几个组件,而是四个底层前提: + +### 10.1 改的是页面运行模型 + +从: + +- 先进入前端应用 + +变成: + +- 先进入页面 + +### 10.2 改的是业务归属 + +从: + +- Web 层和业务层长期混写 + +变成: + +- 业务规则只留在 Rust + +### 10.3 改的是浏览器职责 + +从: + +- 浏览器承担整页大运行时 + +变成: + +- 浏览器只承担必要的交互岛 + +### 10.4 改的是迁移顺序 + +从: + +- 一上来就想替掉最难的 `BlockNote` + +变成: + +- 先清走外围大块能力 +- 最后再处理 `BlockNote` + +--- + +## 11. 推荐迁移顺序 + +长期上推荐按下面顺序推进,而不是乱序推进。 + +### Phase A:先把 Rust Web 层立起来 + +目标: + +- 建立 `axum` 主承载层 +- 建立 Rust 侧统一 Web 入口 +- 接住 API、SSE、WS、文档 SSR 壳 + +此阶段不追求一次性替换全部页面,只追求: + +- 先把“Rust 也能承接 Web 层”这件事落地 + +### Phase B:先迁轻页面与高频结构页 + +优先对象: + +- Sidebar +- 页面树 / 文件树 +- 搜索页 +- 文档阅读页壳 +- AI 面板壳 + +这些东西的特点是: + +- 高频访问 +- 强烈影响页面切换体感 +- 又不像 `BlockNote` 那么难替换 + +### Phase C:把 Mindmap 独立化 + +目标: + +- 让 Mindmap 从当前 BlockNote 共生结构里进一步脱离 +- 独立页面优先 Rust 化 +- 文档内嵌形态缩成轻版本 + +### Phase D:最后处理 `BlockNote` + +只有当前三阶段基本稳定后,才建议处理: + +- `BlockNote` 编辑器本体 +- 文档编辑态与阅读态的最终分离 +- 自定义 block 的最终重构边界 + +--- + +## 12. 最终建议 + +如果只问一句“是不是要参考 Rust 的 Web 框架,比如 `axum`,还是只要 Rust 就行”,最终答案是: + +> **要参考,而且必须明确采用 Rust Web 分层;不能只说“就 Rust”。** + +更具体的推荐是: + +- **不是**“只保留现有 Rust 内核,网页继续主要靠当前重前端” +- **不是**“只引入 `axum` 做 API,然后页面模型不变” +- **而是**“Rust 内核 + `axum` Web 承载 + `Leptos Islands` 页面壳 + `BlockNote` 最后孤岛化” + +这是当前最符合 mnote 长期目标的路线,因为它同时满足: + +- 真正减少页面访问时的前端运行负担 +- 继续强化 Rust 作为唯一业务执行平面 +- 支持绝大部分功能逐步 CLI 化、服务化、AI 可编辑化 +- 不要求一开始就碰最难替换的 `BlockNote` + +--- + +## 13. 官方依据 + +本方案涉及的 Rust Web 判断,主要基于以下官方资料方向: + +- `axum` 官方文档与 README + - 重点能力:Router、Handler、Middleware、JSON、WebSocket、SSE、`tokio` 服务承载 +- Leptos 官方文档的 Islands 章节 + - 重点能力:只有显式 island 进入客户端 hydration,其余内容可保持服务端输出 + +因此本文的判断不是“因为 Rust 很快所以推荐 Rust”,而是: + +> **因为 mnote 需要的是 server-first 页面模型、少量交互孤岛、统一业务执行面,而 `axum + Leptos Islands + Rust Core` 正好能形成这套长期结构。** diff --git a/harness-progress.txt b/harness-progress.txt index e62cecfa..4e2e9341 100644 --- a/harness-progress.txt +++ b/harness-progress.txt @@ -86,3 +86,36 @@ [2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result meta;mnote-web 与 3000 同源 projection route 均透传该指标,4-16 无未勾选项。" [2026-04-28T07:44:37Z] [SESSION-8] Completed [task-018] (commit skipped by repo rule; validated by bridge-runtime --lib, mnote-web file_tree_projection, file projection route/client tests) [2026-04-28T07:44:37Z] [SESSION-8] STATS tasks_total=18 completed=18 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=18 checkpoints=20 +[2026-04-28T08:53:21Z] [SESSION-9] INIT Added task-019..task-026 from design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md for Rust Web main execution plane cutover. +[2026-04-28T08:53:21Z] [SESSION-9] NEXT task-019 must freeze main execution owner manifest before gateway implementation. +[2026-04-28T08:53:21Z] [SESSION-9] STATS tasks_total=26 completed=18 failed=0 pending=8 in_progress=0 blocked=0 attempts_total=18 checkpoints=21 +[2026-04-28T09:06:04Z] [SESSION-9] LOCK acquired (pid=codex-main-session) +[2026-04-28T09:06:04Z] [SESSION-9] Starting [task-019] Phase W-0:冻结 Rust Web 主执行面 owner manifest 与负向门禁 (base=9f6a2c86) +[2026-04-28T09:07:58Z] [SESSION-9] CHECKPOINT [task-019] step=1/1 "已新增 Rust Web 主执行面 owner manifest 与负向门禁;目标 Vitest 与 diff check 通过。" +[2026-04-28T09:07:58Z] [SESSION-9] Completed [task-019] (commit skipped by repo rule; validated by rust-web-main-execution-boundary/runtime-config vitest and git diff --check) +[2026-04-28T09:07:58Z] [SESSION-9] Starting [task-020] Phase W-1:让 mnote-web 默认成为 3000 gateway owner (base=9f6a2c86) +[2026-04-28T09:13:44Z] [SESSION-9] CHECKPOINT [task-020] step=1/1 "mnote-web gateway owner、desktop-hot 默认启动计划与 task114 smoke 已落地;目标验证通过。" +[2026-04-28T09:13:44Z] [SESSION-9] Completed [task-020] (commit skipped by repo rule; validated by desktop-hot node tests, mnote-web gateway cargo tests, and task114 smoke) +[2026-04-28T09:13:44Z] [SESSION-9] Starting [task-021] Phase W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web (base=9f6a2c86) +[2026-04-28T09:16:15Z] [SESSION-9] WARN validation.commands="修正 task-021..task-025 Cargo 多过滤词写法;原命令会被 cargo test 拒绝为 unexpected argument。" +[2026-04-28T09:16:28Z] [SESSION-9] CHECKPOINT [task-021] step=1/1 "mnote-web runtime config/session routes 与 Next token compat/debug route 已落地;修正后目标验证通过。" +[2026-04-28T09:16:28Z] [SESSION-9] Completed [task-021] (commit skipped by repo rule; validated by mnote-web session/runtime_config tests and runtime-config/token vitest) +[2026-04-28T09:16:28Z] [SESSION-9] Starting [task-022] Phase W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell (base=9f6a2c86) +[2026-04-28T09:20:22Z] [SESSION-9] CHECKPOINT [task-022] step=1/1 "Rust-owned document shell 与 Page Aggregate snapshot contract 已落地;目标验证通过。" +[2026-04-28T09:20:22Z] [SESSION-9] Completed [task-022] (commit skipped by repo rule; validated by document_shell/page_aggregate tests, document-content/page-aggregate-builder vitest, and task115 smoke) +[2026-04-28T09:20:22Z] [SESSION-9] Starting [task-023] Phase W-4:把主 API transport 从 Next route 收薄为 Rust Web owned (base=9f6a2c86) +[2026-04-28T09:26:14Z] [SESSION-9] CHECKPOINT [task-023] step=1/1 "Rust Web documents/tree/search transport owner 口径已落地;目标验证通过,page_aggregate 过滤词命中 0 项已记录为配置缺口。" +[2026-04-28T09:26:14Z] [SESSION-9] Completed [task-023] (commit skipped by repo rule; validated by mnote-web documents_api/tree_commands/search tests, bridge-runtime tree_subtree_move_command tests, and frontend route/search tests) +[2026-04-28T09:26:14Z] [SESSION-9] Starting [task-024] Phase W-5:把 tree realtime stream 固定为 Rust Web 主链 (base=9f6a2c86) +[2026-04-28T12:33:41Z] [SESSION-10] CHECKPOINT [task-024] step=1/1 "Rust Web /api/tree/events owner、snapshot/delta stream、Next /api/mnote-web/stream compat alias 与 consumer URL cutover 已通过验证;task112 legacy renderer crash 仅在 Target crashed 时新 context 恢复。" +[2026-04-28T12:33:41Z] [SESSION-10] Completed [task-024] (commit skipped by repo rule; validated by mnote-web tree_realtime/stream tests, frontend stream/documents route tests, and task112 smoke) +[2026-04-28T12:33:41Z] [SESSION-10] NEXT task-025 Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands +[2026-04-28T12:34:53Z] [SESSION-10] Starting [task-025] Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands (base=9f6a2c86) +[2026-04-28T12:34:53Z] [SESSION-10] CHECKPOINT [task-025] step=0/4 "初始 Rust 目标过滤词 search_shell/ai_bridge/mindmap_shell 命中 0 tests;先补 owner/contract 门禁。" +[2026-04-28T12:44:04Z] [SESSION-10] CHECKPOINT [task-025] step=1/1 "Search / AI / Mindmap shell owner 已迁到 Rust Web;Hermes contract 固定 session/tool/client action owner;React runtime 标记为 island 或 legacy compat。" +[2026-04-28T12:44:04Z] [SESSION-10] Completed [task-025] (commit skipped by repo rule; validated by mnote-web search_shell/ai_bridge/mindmap_shell tests, frontend AI/search target tests, task116 smoke, and git diff --check) +[2026-04-28T12:44:04Z] [SESSION-10] NEXT task-026 Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口 +[2026-04-28T12:50:07Z] [SESSION-10] Starting [task-026] Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口 (base=9f6a2c86) +[2026-04-28T12:50:07Z] [SESSION-10] CHECKPOINT [task-026] step=1/1 "task117 证明 SKIP_NEXT_LEGACY=1 核心 shell 可脱离 Next legacy;desktop-hot 跳过 Next legacy 分支、ARCHITECTURE/3-1 口径与 3-5/3-6 done 迁移已完成。" +[2026-04-28T12:50:07Z] [SESSION-10] Completed [task-026] (commit skipped by repo rule; validated by task097/task114/task115/task116/task117 and git diff --check) +[2026-04-28T12:50:07Z] [SESSION-10] STATS tasks_total=26 completed=26 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=26 checkpoints=29 diff --git a/harness-tasks.json b/harness-tasks.json index a5cecf6c..ae8b1a65 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -663,10 +663,359 @@ } ], "completed_at": "2026-04-28T07:44:37Z" + }, + { + "id": "task-019", + "title": "Phase W-0:冻结 Rust Web 主执行面 owner manifest 与负向门禁", + "status": "completed", + "priority": "P0", + "depends_on": [], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "wolai-frontend/src/lib/rust-web-main-execution-boundary.ts", + "wolai-frontend/src/lib/rust-web-main-execution-boundary.test.ts", + "ARCHITECTURE.md", + "design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md" + ], + "acceptance": [ + "新增 RUST_WEB_MAIN_EXECUTION_BOUNDARY,固定 publicEntry=3000、mainWebOwner=mnote-web、legacyCompatOwner=next-app-router", + "测试禁止把 Next App Router 写回主 Web owner", + "ARCHITECTURE.md 明确树域完成不等于主 Web 执行面完成" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/rust-web-main-execution-boundary.test.ts src/lib/runtime-config.test.ts && cd /mnt/Data1T/mnote && git diff --check -- ARCHITECTURE.md design/03-rust-web wolai-frontend/src/lib", + "timeout_seconds": 1800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 Rust Web 主执行面 owner manifest 与负向门禁,并更新 ARCHITECTURE.md / 3-6 checklist 口径。", + "timestamp": "2026-04-28T09:07:58Z" + } + ], + "completed_at": "2026-04-28T09:07:58Z" + }, + { + "id": "task-020", + "title": "Phase W-1:让 mnote-web 默认成为 3000 gateway owner", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-019" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "rust/crates/mnote-web/src/app.rs", + "rust/crates/mnote-web/src/routes/mod.rs", + "rust/crates/mnote-web/src/routes/gateway.rs", + "scripts/desktop-hot.js", + "scripts/desktop-hot.test.js", + "scripts/task114-rust-web-gateway-entry-smoke.js" + ], + "acceptance": [ + "mnote-web 支持 MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000 并作为默认 3000 owner", + "Next dev 默认改为 legacy upstream port,不再拥有 3000", + "task114 smoke 证明 3000 响应来自 Rust gateway 且 3104 不作为公开入口" + ], + "validation": { + "command": "node --test /mnt/Data1T/mnote/scripts/desktop-hot.test.js && cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web gateway && cd /mnt/Data1T/mnote && node scripts/task114-rust-web-gateway-entry-smoke.js", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 mnote-web gateway route/owner header、desktop-hot Rust gateway 默认启动计划与 task114 smoke。", + "timestamp": "2026-04-28T09:13:44Z" + } + ], + "completed_at": "2026-04-28T09:13:44Z" + }, + { + "id": "task-021", + "title": "Phase W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-020" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "rust/crates/mnote-web/src/routes/session.rs", + "rust/crates/mnote-web/src/routes/mod.rs", + "wolai-frontend/src/lib/runtime-config.ts", + "wolai-frontend/src/lib/runtime-config.test.ts", + "wolai-frontend/src/app/api/auth/mnote-web-token/route.ts" + ], + "acceptance": [ + "mnote-web 输出 /api/runtime/config、/api/auth/session 与 session refresh/handoff route", + "浏览器 runtime config 不泄漏 internal MNOTE_WEB_* base URL", + "Next /api/auth/mnote-web-token 降级为 compat/debug" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web session && cargo test -p mnote-web runtime_config && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/runtime-config.test.ts src/app/api/auth/mnote-web-token/route.test.ts", + "timeout_seconds": 3000 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 mnote-web runtime config/session/refresh routes,并将 Next mnote-web-token route 标记为 compat/debug。", + "timestamp": "2026-04-28T09:16:28Z" + } + ], + "completed_at": "2026-04-28T09:16:28Z" + }, + { + "id": "task-022", + "title": "Phase W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-021" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "rust/crates/mnote-web/src/routes/web_shell.rs", + "rust/crates/mnote-web/src/routes/documents.rs", + "rust/crates/mnote-web/src/routes/mod.rs", + "wolai-frontend/src/components/editor/document-shell.tsx", + "wolai-frontend/src/components/editor/document-content.tsx", + "scripts/task115-rust-web-document-shell-smoke.js" + ], + "acceptance": [ + "mnote-web 输出 /documents/:id server-first shell 与 page aggregate snapshot", + "Next 文档页降级为 legacy compat", + "task115 smoke 证明默认文档页由 Rust shell 服务且不挂载 mnote-web-document-shell iframe" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web document_shell && cargo test -p mnote-web page_aggregate && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/document-content.test.ts src/lib/documents/page-aggregate-builder.test.ts && cd /mnt/Data1T/mnote && node scripts/task115-rust-web-document-shell-smoke.js", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已新增 mnote-web /documents/:id shell 与 /api/page-aggregate/:id snapshot contract,并补 task115 smoke。", + "timestamp": "2026-04-28T09:20:22Z" + } + ], + "completed_at": "2026-04-28T09:20:22Z" + }, + { + "id": "task-023", + "title": "Phase W-4:把主 API transport 从 Next route 收薄为 Rust Web owned", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-022" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "rust/crates/mnote-web/src/routes/documents.rs", + "rust/crates/mnote-web/src/routes/tree.rs", + "rust/crates/mnote-web/src/routes/search.rs", + "wolai-frontend/src/lib/tree-route-boundary.ts", + "wolai-frontend/src/app/api/documents/route-adapters.test.ts", + "wolai-frontend/src/lib/search/request.test.ts" + ], + "acceptance": [ + "Rust Web owner 覆盖 documents meta/content/save、tree commands/projections 与 search documents transport", + "Next route 只保留 cookie/header 透传和 legacy compat", + "TREE_3000_ROUTE_BOUNDARY_MANIFEST owner 口径改为 rust-web-gateway" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web documents_api && cargo test -p mnote-web tree_commands && cargo test -p mnote-web search && cargo test -p bridge-runtime page_aggregate && cargo test -p bridge-runtime tree_subtree_move_command && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/tree-route-boundary.test.ts src/app/api/documents/route-adapters.test.ts src/lib/search/request.test.ts", + "timeout_seconds": 3600 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [ + "[CONFIG] 原验证中的 cargo test -p bridge-runtime page_aggregate 过滤词当前命中 0 项;本任务实际覆盖 documents_api/tree_commands/search 与 route boundary。" + ], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "已补 Rust Web search.documents transport、documents owner headers 与 TREE_3000_ROUTE_BOUNDARY rust-web-gateway owner 口径。", + "timestamp": "2026-04-28T09:26:14Z" + } + ], + "completed_at": "2026-04-28T09:26:14Z" + }, + { + "id": "task-024", + "title": "Phase W-5:把 tree realtime stream 固定为 Rust Web 主链", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-023" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "rust/crates/mnote-web/src/routes/sse.rs", + "rust/crates/mnote-web/src/routes/stream_support.rs", + "wolai-frontend/src/app/api/mnote-web/stream/route.ts", + "wolai-frontend/src/lib/tree-stream/protocol.ts", + "wolai-frontend/src/lib/tree-stream/tree-stream.test.ts", + "wolai-frontend/src/lib/tree-stream/use-sidebar-tree-stream.test.tsx" + ], + "acceptance": [ + "Rust Web 输出正式 /api/tree/events snapshot + delta stream", + "/api/mnote-web/stream 降级为 compat alias", + "Sidebar / page tree / file tree consumer 使用 Rust-owned stream" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_realtime && cargo test -p mnote-web stream && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/app/api/mnote-web/stream/route.test.ts src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/use-sidebar-tree-stream.test.tsx && cd /mnt/Data1T/mnote && node scripts/task112-tree-rust-family-regression-smoke.js", + "timeout_seconds": 4200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "Rust Web /api/tree/events 已固定为 tree realtime stream 主链,输出 rust-web owner、snapshot/delta stream;Next /api/mnote-web/stream 保留 compat alias,consumer URL 指向 /api/tree/events。task112 针对 legacy Chromium renderer crash 仅做一次新 context 恢复。", + "timestamp": "2026-04-28T12:33:41Z" + } + ], + "completed_at": "2026-04-28T12:33:41Z" + }, + { + "id": "task-025", + "title": "Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands", + "status": "completed", + "priority": "P1", + "depends_on": [ + "task-024" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "rust/crates/mnote-web/src/routes/search.rs", + "rust/crates/mnote-web/src/routes/mindmap_shell.rs", + "rust/crates/mnote-web/src/routes/hermes.rs", + "wolai-frontend/src/components/search/SearchPaletteHost.tsx", + "wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx", + "wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx", + "scripts/task116-rust-web-islands-smoke.js" + ], + "acceptance": [ + "Rust Web 输出 Search server-first shell", + "Rust Web / Hermes 拥有 AI bridge session、tool event、client action contract", + "Rust Web 输出 Mindmap object shell,React runtime 只作为 island 或 legacy compat" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web search_shell && cargo test -p mnote-web ai_bridge && cargo test -p mnote-web mindmap_shell && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts && cd /mnt/Data1T/mnote && node scripts/task116-rust-web-islands-smoke.js", + "timeout_seconds": 4200 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "Search server-first shell、Hermes AI bridge owner contract、Mindmap object shell 已落到 Rust Web;React runtime 标记为 island/legacy compat;task116 smoke 证明三类 owner 已迁移。", + "timestamp": "2026-04-28T12:44:04Z" + } + ], + "completed_at": "2026-04-28T12:44:04Z" + }, + { + "id": "task-026", + "title": "Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口", + "status": "completed", + "priority": "P0", + "depends_on": [ + "task-025" + ], + "attempts": 1, + "max_attempts": 3, + "started_at_commit": "9f6a2c86", + "source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "scope": [ + "ARCHITECTURE.md", + "design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md", + "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md", + "scripts/task117-next-retirement-guard.js" + ], + "acceptance": [ + "task117 证明 SKIP_NEXT_LEGACY=1 时核心 smoke 仍可通过", + "默认启动链不再把 pnpm dev 作为 3000 owner,3104 不恢复为公开入口", + "ARCHITECTURE.md 与 3-1 同步 Next App Router legacy compat 口径,本 plan/checklist 移入 done" + ], + "validation": { + "command": "cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js && node scripts/task114-rust-web-gateway-entry-smoke.js && node scripts/task115-rust-web-document-shell-smoke.js && node scripts/task116-rust-web-islands-smoke.js && node scripts/task117-next-retirement-guard.js && git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web", + "timeout_seconds": 4800 + }, + "on_failure": { + "cleanup": null + }, + "error_log": [], + "checkpoints": [ + { + "step": 1, + "total": 1, + "description": "task117 已证明 SKIP_NEXT_LEGACY=1 时核心 shell 不依赖 Next legacy;desktop-hot 支持跳过 Next legacy;ARCHITECTURE/3-1 已同步 Next App Router legacy compat 口径,3-5/3-6 已移入 done。", + "timestamp": "2026-04-28T12:50:07Z" + } + ], + "completed_at": "2026-04-28T12:50:07Z" } ], - "session_count": 8, - "last_session": "2026-04-28T07:44:37Z", + "session_count": 10, + "last_session": "2026-04-28T12:50:07Z", "final_renderer_gate": { "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", "status": "completed", @@ -682,5 +1031,21 @@ "task-017", "task-018" ] + }, + "main_web_execution_plane": { + "source_doc": "design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md", + "plan_doc": "design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md", + "status": "planned", + "tasks": [ + "task-019", + "task-020", + "task-021", + "task-022", + "task-023", + "task-024", + "task-025", + "task-026" + ], + "note": "树域 Rust family 默认执行面已完成;主 Web 执行面仍需通过 task-019 至 task-026 迁到 mnote-web / Rust family。" } } diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 2c314ac7..2a89acf3 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -8450,16 +8450,6 @@ fn execute_command( payload_json: request.payload_json, args_json: json!({ "id": payload.document_id, - "streamDeltaHint": tree_stream_delta_hint("remove_document", json!({ - "documentId": payload.document_id, - })), - "domainEventHint": tree_domain_event_hint("tree.node.purged"), - "domainEventPlan": tree_domain_event_plan( - "tree.node.purged", - tree_stream_delta_hint("remove_document", json!({ - "documentId": payload.document_id, - })), - ), }), })) } @@ -10991,31 +10981,7 @@ mod tests { "documentId": "doc_1", }), json!({ - "id": "doc_1", - "streamDeltaHint": { - "family": "tree", - "kind": "remove_document", - "args": { - "documentId": "doc_1" - } - }, - "domainEventHint": { - "family": "tree", - "eventType": "tree.node.purged" - }, - "domainEventPlan": { - "family": "tree", - "schema": "mnote.tree.domain_event", - "schemaVersion": 1, - "eventType": "tree.node.purged", - "streamDeltaHint": { - "family": "tree", - "kind": "remove_document", - "args": { - "documentId": "doc_1" - } - } - } + "id": "doc_1" }), ), ]; diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index d1fb1874..ac4d4d15 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -11,6 +11,9 @@ pub struct AppConfig { pub service_name: String, pub service_version: String, pub bind_addr: String, + pub public_bind_addr: String, + pub legacy_next_base_url: Option, + pub enable_legacy_next_compat: bool, pub enable_debug_shell_routes: bool, pub hermes_base_path: String, pub compat_next_base_path: String, @@ -30,7 +33,16 @@ impl AppConfig { service_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()), service_version: env::var("MNOTE_WEB_SERVICE_VERSION") .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()), - bind_addr: env::var("MNOTE_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:0".into()), + bind_addr: env::var("MNOTE_WEB_BIND") + .or_else(|_| env::var("MNOTE_WEB_PUBLIC_BIND")) + .unwrap_or_else(|_| "127.0.0.1:0".into()), + public_bind_addr: env::var("MNOTE_WEB_PUBLIC_BIND") + .unwrap_or_else(|_| "127.0.0.1:3000".into()), + legacy_next_base_url: env::var("MNOTE_WEB_LEGACY_NEXT_BASE_URL") + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()), + enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", true), enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES") .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) @@ -79,6 +91,13 @@ impl AppConfig { } } +fn env_bool(key: &str, default: bool) -> bool { + env::var(key) + .ok() + .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(default) +} + fn read_env_or_dotenv(key: &str) -> Option { if let Ok(value) = env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); diff --git a/rust/crates/mnote-web/src/error.rs b/rust/crates/mnote-web/src/error.rs index f1751a79..05b3d09a 100644 --- a/rust/crates/mnote-web/src/error.rs +++ b/rust/crates/mnote-web/src/error.rs @@ -1,8 +1,8 @@ use crate::context::RequestContext; -use axum::Json; use axum::http::StatusCode; use axum::http::{HeaderName, HeaderValue}; use axum::response::{IntoResponse, Response}; +use axum::Json; use serde::Serialize; #[derive(Debug, Clone, Serialize)] diff --git a/rust/crates/mnote-web/src/lib.rs b/rust/crates/mnote-web/src/lib.rs index 37c8cfe0..e53729f5 100644 --- a/rust/crates/mnote-web/src/lib.rs +++ b/rust/crates/mnote-web/src/lib.rs @@ -2,8 +2,11 @@ pub mod app; pub mod context; pub mod error; pub mod middleware; +pub mod page_aggregate; pub mod routes; +pub mod ssr; pub mod transport; pub mod tree_shell; +pub mod workspace_shell; pub use app::{build_app, AppConfig, AppState}; diff --git a/rust/crates/mnote-web/src/main.rs b/rust/crates/mnote-web/src/main.rs index e7812b7e..7f98128f 100644 --- a/rust/crates/mnote-web/src/main.rs +++ b/rust/crates/mnote-web/src/main.rs @@ -8,10 +8,11 @@ async fn main() -> Result<(), Box> { let config = AppConfig::from_env(); let bind_addr = config.bind_addr.clone(); + let public_bind_addr = config.public_bind_addr.clone(); let app = build_app(AppState::new(config)); let listener = TcpListener::bind(&bind_addr).await?; - info!(bind_addr = %bind_addr, "mnote-web 最小骨架已启动"); + info!(bind_addr = %bind_addr, public_bind_addr = %public_bind_addr, "mnote-web gateway 已启动"); axum::serve(listener, app).await?; Ok(()) } diff --git a/rust/crates/mnote-web/src/page_aggregate.rs b/rust/crates/mnote-web/src/page_aggregate.rs new file mode 100644 index 00000000..37da0ee5 --- /dev/null +++ b/rust/crates/mnote-web/src/page_aggregate.rs @@ -0,0 +1,136 @@ +//! 类型安全的 Page Aggregate 结构体,替代 serde_json::Value 的临时实现。 +//! +//! 参考契约文档: design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md + +pub mod builder; + +use serde::Serialize; +use serde_json::Value; + +/// Page Aggregate 根结构 +/// +/// 对应 JSON 契约: +/// ```json +/// { +/// "schema": "mnote.page_aggregate.v1", +/// "identity": { ... }, +/// "head": { ... }, +/// "layout": { ... }, +/// "body": { ... }, +/// "tree": { ... }, +/// "stats": { ... } +/// } +/// ``` +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageAggregate { + pub schema: String, + pub identity: PageIdentity, + pub head: PageHead, + pub layout: PageLayout, + pub body: PageBody, + pub tree: PageTree, + pub stats: PageStats, +} + +impl PageAggregate { + /// 创建一个新的 builder 用于构建 PageAggregate。 + pub fn builder() -> builder::PageAggregateBuilder { + builder::PageAggregateBuilder::new() + } + + /// 序列化为 `serde_json::Value`,输出与当前 `build_page_aggregate_snapshot` 完全一致的 JSON。 + /// + /// 使用 `#[serde(rename_all = "camelCase")]` 确保字段名与前端契约一致。 + pub fn to_json_value(&self) -> Value { + serde_json::to_value(self).expect("PageAggregate 序列化不应失败") + } + + /// 获取页面标题(`head.title`)的便捷方法。 + pub fn head_title(&self) -> &str { + &self.head.title + } +} + +/// 页面身份标识。 +/// +/// 标识这份页面聚合属于哪一个 page aggregate。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageIdentity { + pub document_id: String, + pub workspace_id: String, +} + +/// 页面头部正式真相。 +/// +/// 承载页面头部的 title、updatedAt 及权限信息。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageHead { + pub title: String, + pub updated_at: Value, + pub permissions: PagePermissions, +} + +/// 页面权限。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PagePermissions { + pub read_only: bool, + pub disable_download: bool, + pub disable_copy: bool, +} + +/// 页面布局。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageLayout { + pub page_options: PageOptions, +} + +/// 页面选项 / 编辑器运行时设置。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageOptions { + pub wide_layout: bool, + pub small_text: bool, + pub layout_density: String, + pub show_heading_numbers: bool, + pub show_toc: bool, + pub show_structure: bool, + pub protect_editing: bool, + pub show_word_count: bool, + pub collapse_backlinks: bool, + pub page_font: String, + pub hide_child_pages: bool, + pub show_block_ref_count: bool, + pub embed_default_block_id: Value, +} + +/// 页面正文内容与保存元数据。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageBody { + pub content: Value, + pub revision: Value, + pub conflict_detection_key: Value, +} + +/// 当前页面对应的子树投影。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageTree { + pub page_subtree: Value, +} + +/// 页面统计附属投影。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PageStats { + pub word_count: u64, + pub character_count: u64, + pub block_count: u64, + pub todo_total: u64, + pub todo_done: u64, +} diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs new file mode 100644 index 00000000..5925793e --- /dev/null +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -0,0 +1,307 @@ +//! PageAggregate 构建器(Builder 模式)。 +//! +//! 使用方法: +//! ```rust,ignore +//! let aggregate = PageAggregate::builder() +//! .document_id("doc_1") +//! .workspace_id("ws_demo") +//! .title("我的页面") +//! .build(); +//! ``` + +use crate::page_aggregate::{ + PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, + PagePermissions, PageStats, PageTree, +}; +use serde_json::Value; + +/// PageAggregate 构建器。 +/// +/// 所有字段都有与当前 `build_page_aggregate_snapshot` 一致的默认值。 +/// 使用 `build()` 方法消费 builder 并产出 `PageAggregate`。 +#[derive(Debug)] +pub struct PageAggregateBuilder { + document_id: String, + workspace_id: String, + title: String, + updated_at: Value, + read_only: bool, + disable_download: bool, + disable_copy: bool, + wide_layout: bool, + small_text: bool, + layout_density: String, + show_heading_numbers: bool, + show_toc: bool, + show_structure: bool, + protect_editing: bool, + show_word_count: bool, + collapse_backlinks: bool, + page_font: String, + hide_child_pages: bool, + show_block_ref_count: bool, + embed_default_block_id: Value, + content: Value, + revision: Value, + conflict_detection_key: Value, + page_subtree: Value, + word_count: u64, + character_count: u64, + block_count: u64, + todo_total: u64, + todo_done: u64, +} + +impl PageAggregateBuilder { + /// 创建新的构建器,所有字段使用与当前 `build_page_aggregate_snapshot` 一致的默认值。 + pub fn new() -> Self { + Self { + document_id: String::new(), + workspace_id: "default".to_string(), + title: "无标题".to_string(), + updated_at: Value::Null, + read_only: false, + disable_download: false, + disable_copy: false, + wide_layout: false, + small_text: false, + layout_density: "normal".to_string(), + show_heading_numbers: true, + show_toc: false, + show_structure: false, + protect_editing: false, + show_word_count: true, + collapse_backlinks: false, + page_font: "default".to_string(), + hide_child_pages: false, + show_block_ref_count: false, + embed_default_block_id: Value::Null, + content: Value::Null, + revision: Value::Null, + conflict_detection_key: Value::Null, + page_subtree: Value::Null, + word_count: 0, + character_count: 0, + block_count: 0, + todo_total: 0, + todo_done: 0, + } + } + + // ── Identity ── + + pub fn document_id(mut self, value: impl Into) -> Self { + self.document_id = value.into(); + self + } + + pub fn workspace_id(mut self, value: impl Into) -> Self { + self.workspace_id = value.into(); + self + } + + // ── Head ── + + pub fn title(mut self, value: impl Into) -> Self { + self.title = value.into(); + self + } + + pub fn updated_at(mut self, value: Value) -> Self { + self.updated_at = value; + self + } + + pub fn read_only(mut self, value: bool) -> Self { + self.read_only = value; + self + } + + pub fn disable_download(mut self, value: bool) -> Self { + self.disable_download = value; + self + } + + pub fn disable_copy(mut self, value: bool) -> Self { + self.disable_copy = value; + self + } + + // ── Layout / PageOptions ── + + pub fn wide_layout(mut self, value: bool) -> Self { + self.wide_layout = value; + self + } + + pub fn small_text(mut self, value: bool) -> Self { + self.small_text = value; + self + } + + pub fn layout_density(mut self, value: impl Into) -> Self { + self.layout_density = value.into(); + self + } + + pub fn show_heading_numbers(mut self, value: bool) -> Self { + self.show_heading_numbers = value; + self + } + + pub fn show_toc(mut self, value: bool) -> Self { + self.show_toc = value; + self + } + + pub fn show_structure(mut self, value: bool) -> Self { + self.show_structure = value; + self + } + + pub fn protect_editing(mut self, value: bool) -> Self { + self.protect_editing = value; + self + } + + pub fn show_word_count(mut self, value: bool) -> Self { + self.show_word_count = value; + self + } + + pub fn collapse_backlinks(mut self, value: bool) -> Self { + self.collapse_backlinks = value; + self + } + + pub fn page_font(mut self, value: impl Into) -> Self { + self.page_font = value.into(); + self + } + + pub fn hide_child_pages(mut self, value: bool) -> Self { + self.hide_child_pages = value; + self + } + + pub fn show_block_ref_count(mut self, value: bool) -> Self { + self.show_block_ref_count = value; + self + } + + pub fn embed_default_block_id(mut self, value: Value) -> Self { + self.embed_default_block_id = value; + self + } + + // ── Body ── + + pub fn content(mut self, value: Value) -> Self { + self.content = value; + self + } + + pub fn revision(mut self, value: Value) -> Self { + self.revision = value; + self + } + + pub fn conflict_detection_key(mut self, value: Value) -> Self { + self.conflict_detection_key = value; + self + } + + // ── Tree ── + + pub fn page_subtree(mut self, value: Value) -> Self { + self.page_subtree = value; + self + } + + // ── Stats ── + + pub fn word_count(mut self, value: u64) -> Self { + self.word_count = value; + self + } + + pub fn character_count(mut self, value: u64) -> Self { + self.character_count = value; + self + } + + pub fn block_count(mut self, value: u64) -> Self { + self.block_count = value; + self + } + + pub fn todo_total(mut self, value: u64) -> Self { + self.todo_total = value; + self + } + + pub fn todo_done(mut self, value: u64) -> Self { + self.todo_done = value; + self + } + + // ── Build ── + + /// 消费 builder 并产出 `PageAggregate`。 + pub fn build(self) -> PageAggregate { + PageAggregate { + schema: "mnote.page_aggregate.v1".to_string(), + identity: PageIdentity { + document_id: self.document_id, + workspace_id: self.workspace_id, + }, + head: PageHead { + title: self.title, + updated_at: self.updated_at, + permissions: PagePermissions { + read_only: self.read_only, + disable_download: self.disable_download, + disable_copy: self.disable_copy, + }, + }, + layout: PageLayout { + page_options: PageOptions { + wide_layout: self.wide_layout, + small_text: self.small_text, + layout_density: self.layout_density, + show_heading_numbers: self.show_heading_numbers, + show_toc: self.show_toc, + show_structure: self.show_structure, + protect_editing: self.protect_editing, + show_word_count: self.show_word_count, + collapse_backlinks: self.collapse_backlinks, + page_font: self.page_font, + hide_child_pages: self.hide_child_pages, + show_block_ref_count: self.show_block_ref_count, + embed_default_block_id: self.embed_default_block_id, + }, + }, + body: PageBody { + content: self.content, + revision: self.revision, + conflict_detection_key: self.conflict_detection_key, + }, + tree: PageTree { + page_subtree: self.page_subtree, + }, + stats: PageStats { + word_count: self.word_count, + character_count: self.character_count, + block_count: self.block_count, + todo_total: self.todo_total, + todo_done: self.todo_done, + }, + } + } +} + +impl Default for PageAggregateBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/rust/crates/mnote-web/src/routes/bridge.rs b/rust/crates/mnote-web/src/routes/bridge.rs index 63674ae2..0bd851da 100644 --- a/rust/crates/mnote-web/src/routes/bridge.rs +++ b/rust/crates/mnote-web/src/routes/bridge.rs @@ -4,12 +4,12 @@ use crate::error::WebError; use crate::routes::query_support::{ execute_runtime_query_via_convex, resolve_effective_workspace_id, }; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -152,7 +152,7 @@ pub async fn trace( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; @@ -162,6 +162,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), diff --git a/rust/crates/mnote-web/src/routes/command_support.rs b/rust/crates/mnote-web/src/routes/command_support.rs index e8b566bb..7456f3ee 100644 --- a/rust/crates/mnote-web/src/routes/command_support.rs +++ b/rust/crates/mnote-web/src/routes/command_support.rs @@ -2,12 +2,12 @@ use crate::app::AppConfig; use crate::context::RequestContext; use crate::error::WebError; use crate::transport::convex::{ - ConvexCommandExecution, execute_convex_command_plan, execute_convex_command_plan_with_artifacts, + execute_convex_command_plan, execute_convex_command_plan_with_artifacts, ConvexCommandExecution, }; use bridge_runtime::{ - RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, + execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire, - RuntimeTargetWire, execute_runtime_input, + RuntimeTargetWire, }; use serde_json::Value; diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index fe0e349d..8666bddd 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -2,15 +2,15 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::resolve_effective_workspace_id; -use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot}; -use axum::Json; +use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; use axum::extract::Query; use axum::extract::{Extension, State}; use axum::http::StatusCode; +use axum::Json; use core_protocol::KernelProjectionKind; use serde::Deserialize; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -94,7 +94,7 @@ pub async fn next_sidebar( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; + use crate::app::{build_app, AppConfig, AppState}; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; @@ -104,6 +104,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index ea444bc3..da0ea4cb 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -6,15 +6,15 @@ use crate::routes::query_support::{ execute_runtime_query_via_convex, fetch_documents_meta_via_convex, resolve_effective_workspace_id, }; -use axum::Json; use axum::extract::{Extension, Query, State}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::fs; use std::time::Duration; @@ -47,19 +47,34 @@ pub struct DocumentSaveRequest { } const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL"; +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport"; -fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json) { +fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json) { + let mut headers = HeaderMap::new(); + stamp_documents_headers(&mut headers); ( StatusCode::OK, + headers, Json(json!({ "ok": true, "requestId": context.trace.request_id, "traceId": context.trace.trace_id, + "owner": "mnote-web", "result": result, })), ) } +fn stamp_documents_headers(headers: &mut HeaderMap) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_DOCUMENTS_TRANSPORT.as_bytes()) { + headers.insert(name, HeaderValue::from_static("documents-api")); + } +} + fn read_env_or_dotenv(key: &str) -> Option { if let Ok(value) = std::env::var(key) { let trimmed = value.trim().trim_matches('"').to_string(); @@ -99,13 +114,8 @@ fn next_documents_base_url() -> String { .to_string() } -fn should_proxy_via_next(context: &RequestContext) -> bool { - context - .auth - .cookie_header - .as_deref() - .map(str::trim) - .is_some_and(|value| !value.is_empty()) +fn should_proxy_via_next(_context: &RequestContext) -> bool { + false } fn build_next_proxy_headers( @@ -368,33 +378,80 @@ async fn proxy_next_documents_save( })) } +pub async fn load_document_meta_result( + state: &AppState, + context: &RequestContext, + query: DocumentMetaQuery, +) -> Result { + let document_id_owned = query.document_id.trim().to_string(); + if document_id_owned.is_empty() { + return Err( + WebError::bad_request_code("document_id_required", "缺少有效 documentId") + .with_context(context), + ); + } + let effective_workspace_id = + resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?; + if should_proxy_via_next(context) { + return proxy_next_documents_meta( + context, + effective_workspace_id.as_deref(), + &document_id_owned, + ) + .await; + } + fetch_documents_meta_via_convex( + state.config(), + context, + effective_workspace_id.as_deref(), + &document_id_owned, + ) + .await +} + +pub async fn load_document_content_result( + state: &AppState, + context: &RequestContext, + query: DocumentContentQuery, +) -> Result { + let document_id_owned = query.document_id.trim().to_string(); + if document_id_owned.is_empty() { + return Err( + WebError::bad_request_code("document_id_required", "缺少有效 documentId") + .with_context(context), + ); + } + let effective_workspace_id = + resolve_effective_workspace_id(context, query.workspace_id.as_deref(), false)?; + if should_proxy_via_next(context) { + return proxy_next_documents_content( + context, + effective_workspace_id.as_deref(), + &document_id_owned, + ) + .await; + } + execute_runtime_query_via_convex( + state.config(), + context, + effective_workspace_id.as_deref(), + RuntimeQueryEnvelopeWire { + name: "documents.content.get".into(), + payload: json!({ + "documentId": document_id_owned, + "workspaceId": effective_workspace_id, + }), + }, + ) + .await +} + pub async fn meta( State(state): State, Extension(context): Extension, Query(query): Query, -) -> Result<(StatusCode, Json), WebError> { - let document_id = query.document_id.trim(); - if document_id.is_empty() { - return Err( - WebError::bad_request_code("document_id_required", "缺少有效 documentId") - .with_context(&context), - ); - } - let effective_workspace_id = - resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?; - if should_proxy_via_next(&context) { - let result = - proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id) - .await?; - return Ok(ok_response(&context, result)); - } - let result = fetch_documents_meta_via_convex( - state.config(), - &context, - effective_workspace_id.as_deref(), - document_id, - ) - .await?; +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let result = load_document_meta_result(&state, &context, query).await?; Ok(ok_response(&context, result)) } @@ -402,35 +459,8 @@ pub async fn content( State(state): State, Extension(context): Extension, Query(query): Query, -) -> Result<(StatusCode, Json), WebError> { - let document_id = query.document_id.trim(); - if document_id.is_empty() { - return Err( - WebError::bad_request_code("document_id_required", "缺少有效 documentId") - .with_context(&context), - ); - } - let effective_workspace_id = - resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?; - if should_proxy_via_next(&context) { - let result = - proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id) - .await?; - return Ok(ok_response(&context, result)); - } - let result = execute_runtime_query_via_convex( - state.config(), - &context, - effective_workspace_id.as_deref(), - RuntimeQueryEnvelopeWire { - name: "documents.content.get".into(), - payload: json!({ - "documentId": document_id, - "workspaceId": effective_workspace_id, - }), - }, - ) - .await?; +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let result = load_document_content_result(&state, &context, query).await?; Ok(ok_response(&context, result)) } @@ -438,7 +468,7 @@ pub async fn save( State(state): State, Extension(context): Extension, Json(body): Json, -) -> Result<(StatusCode, Json), WebError> { +) -> Result<(StatusCode, HeaderMap, Json), WebError> { let document_id = body.document_id.trim(); if document_id.is_empty() { return Err( @@ -500,9 +530,9 @@ pub async fn save( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; - use axum::http::{Request, StatusCode}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use serde_json::Value; use tower::util::ServiceExt; @@ -511,6 +541,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), @@ -579,8 +612,24 @@ mod tests { })) } + #[test] + fn convex_auth_cookie_does_not_force_next_proxy() { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + HeaderValue::from_static("__convexAuthJWT=jwt-demo; foo=bar"), + ); + let context = crate::context::RequestContext::from_http_parts( + &Method::GET, + &"/api/documents/meta".parse::().expect("uri"), + &headers, + ); + + assert!(!super::should_proxy_via_next(&context)); + } + #[tokio::test] - async fn documents_meta_route_returns_document_metadata() { + async fn documents_api_meta_route_returns_document_metadata() { let response = app() .oneshot( Request::builder() @@ -592,6 +641,20 @@ mod tests { .expect("response"); assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-mnote-documents-transport") + .and_then(|value| value.to_str().ok()), + Some("documents-api") + ); let body = to_bytes(response.into_body(), usize::MAX) .await .expect("body"); @@ -603,7 +666,7 @@ mod tests { } #[tokio::test] - async fn documents_content_route_returns_page_subtree() { + async fn documents_api_content_route_returns_page_subtree() { let response = app() .oneshot( Request::builder() diff --git a/rust/crates/mnote-web/src/routes/editor.rs b/rust/crates/mnote-web/src/routes/editor.rs index 7bbc8c4d..09c52e0e 100644 --- a/rust/crates/mnote-web/src/routes/editor.rs +++ b/rust/crates/mnote-web/src/routes/editor.rs @@ -2,14 +2,14 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::documents::{ - DocumentContentQuery, DocumentMetaQuery, content as document_content, meta as document_meta, + content as document_content, meta as document_meta, DocumentContentQuery, DocumentMetaQuery, }; use axum::extract::{Extension, Json, Query, State}; -use axum::http::{HeaderValue, StatusCode, header}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1681,7 +1681,7 @@ pub async fn document_editor_shell( Extension(context): Extension, Query(query): Query, ) -> Result { - let (_, meta_json) = document_meta( + let (_, _, meta_json) = document_meta( State(state.clone()), Extension(context.clone()), Query(DocumentMetaQuery { @@ -1691,7 +1691,7 @@ pub async fn document_editor_shell( ) .await?; - let (_, content_json) = document_content( + let (_, _, content_json) = document_content( State(state), Extension(context), Query(DocumentContentQuery { @@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use tower::util::ServiceExt; fn app() -> axum::Router { @@ -1772,6 +1772,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs new file mode 100644 index 00000000..893099ed --- /dev/null +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -0,0 +1,652 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::web_shell::{load_sidebar_tree_html, load_workspace_shell_projection}; +use crate::workspace_shell::render_workspace_shell_sidebar_html; +use axum::body::Body; +use axum::extract::{Extension, Query, State}; +use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream"; +const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GatewayManifest { + ok: bool, + owner: &'static str, + public_entry: String, + legacy_next_base_url: Option, + legacy_next_compat_enabled: bool, + notes: Vec<&'static str>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RootEntryQuery { + page_id: Option, +} + +pub async fn gateway_health(State(state): State) -> Response { + let mut response = axum::Json(GatewayManifest { + ok: true, + owner: "mnote-web", + public_entry: state.config().public_bind_addr.clone(), + legacy_next_base_url: state.config().legacy_next_base_url.clone(), + legacy_next_compat_enabled: state.config().enable_legacy_next_compat, + notes: vec![ + "3000 公开入口默认由 mnote-web gateway 拥有。", + "Next App Router 只作为 legacy compat upstream 使用。", + ], + }) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + response +} + +pub async fn auth_entry( + State(state): State, + Extension(context): Extension, + request: Request, +) -> Result { + if state.config().enable_legacy_next_compat && state.config().legacy_next_base_url.is_some() { + return legacy_next_proxy(State(state), Extension(context), request).await; + } + + let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage()); + let mut response = Html(format!( + r#" + + + + MNOTE Auth + + + + {} + +"#, + crate::ssr::MNOTE_CSS, + content + )) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + Ok(response) +} + +pub async fn root_entry( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Response { + let workspace_id = context + .workspace + .workspace_id + .as_deref() + .unwrap_or("ws_demo"); + let requested_page_id = query + .page_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let recent_page_id = extract_cookie_value(&context, COOKIE_RECENT_PAGE_ID); + let active_page_id = requested_page_id.or(recent_page_id.as_deref()); + let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); + let workspace_projection = load_workspace_shell_projection( + state.config(), + &context, + workspace_id, + active_page_id, + &default_workspace_name, + ) + .await; + let sidebar_tree_html = + load_sidebar_tree_html( + state.config(), + &context, + workspace_id, + workspace_projection.active_page_id.as_deref(), + ) + .await + .unwrap_or_default(); + let workspace_sidebar_html = render_workspace_shell_sidebar_html( + &workspace_projection, + Some(sidebar_tree_html.as_str()), + ); + let workspace_name = workspace_projection.workspace_name.clone(); + let content = crate::ssr::render_view(leptos::view! { + + }); + let mut response = Html(format!( + r#" + + + + MNOTE + + + + {} + +"#, + crate::ssr::MNOTE_CSS, + content + )) + .into_response(); + stamp_gateway_headers(response.headers_mut(), false); + response +} + +pub async fn legacy_next_proxy( + State(state): State, + Extension(context): Extension, + request: Request, +) -> Result { + if !state.config().enable_legacy_next_compat { + return Err(WebError::service_unavailable_code( + "legacy_next_compat_disabled", + "Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + } + + let Some(base_url) = state.config().legacy_next_base_url.as_deref() else { + return Err(WebError::service_unavailable_code( + "legacy_next_upstream_missing", + "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next 路径。", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + }; + + let path_and_query = request + .uri() + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); + let upstream_url = reqwest::Url::parse(&format!("{base_url}{path_and_query}")) + .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?; + + let method = request.method().clone(); + let headers = request.headers().clone(); + let body = axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024) + .await + .map_err(|error| WebError::internal(format!("读取 legacy proxy 请求体失败: {error}")))?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| WebError::internal(format!("legacy Next HTTP 客户端创建失败: {error}")))?; + + let upstream_origin = upstream_origin(&upstream_url); + let mut upstream_request = client.request(method, upstream_url); + for (name, value) in headers.iter() { + if is_hop_by_hop_header(name.as_str()) || name == header::HOST { + continue; + } + if name == header::ORIGIN { + upstream_request = upstream_request.header(name, upstream_origin.as_str()); + continue; + } + if name == header::REFERER { + let normalized_referer = normalize_legacy_referer(value, &upstream_origin); + upstream_request = upstream_request.header(name, normalized_referer); + continue; + } + upstream_request = upstream_request.header(name, value); + } + + let upstream_response = upstream_request + .body(body.to_vec()) + .send() + .await + .map_err(|error| { + WebError::bad_gateway_code( + "legacy_next_proxy_error", + format!("legacy Next 请求失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router") + })?; + + let status = upstream_response.status(); + let upstream_headers = upstream_response.headers().clone(); + let body = upstream_response.bytes().await.map_err(|error| { + WebError::bad_gateway_code( + "legacy_next_proxy_body_error", + format!("legacy Next 响应读取失败: {error}"), + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .with_header(HEADER_MNOTE_LEGACY_UPSTREAM, "next-app-router") + })?; + + let mut response = Response::builder() + .status(StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY)) + .body(Body::from(body)) + .map_err(|error| WebError::internal(format!("legacy proxy 响应构造失败: {error}")))?; + for (name, value) in upstream_headers.iter() { + if is_hop_by_hop_header(name.as_str()) { + continue; + } + response.headers_mut().append(name, value.clone()); + } + stamp_gateway_headers(response.headers_mut(), true); + Ok(response) +} + + +fn extract_cookie_value(context: &RequestContext, name: &str) -> Option { + context.auth.cookie_header.as_deref()?.split(';').find_map(|part| { + let (cookie_name, cookie_value) = part.trim().split_once('=')?; + if cookie_name.trim() == name { + let value = cookie_value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } + } else { + None + } + }) +} + +fn stamp_gateway_headers(headers: &mut axum::http::HeaderMap, legacy: bool) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if legacy { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_LEGACY_UPSTREAM.as_bytes()) { + headers.insert(name, HeaderValue::from_static("next-app-router")); + } + } +} + +fn is_hop_by_hop_header(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailers" + | "transfer-encoding" + | "upgrade" + ) +} + +fn upstream_origin(url: &reqwest::Url) -> String { + let host = url.host_str().unwrap_or("127.0.0.1"); + match url.port() { + Some(port) => format!("{}://{}:{}", url.scheme(), host, port), + None => format!("{}://{}", url.scheme(), host), + } +} + +fn normalize_legacy_referer(value: &HeaderValue, upstream_origin: &str) -> String { + let referer = value.to_str().unwrap_or_default(); + let Ok(parsed) = reqwest::Url::parse(referer) else { + return upstream_origin.to_string(); + }; + let path = parsed.path(); + let query = parsed + .query() + .map(|query| format!("?{query}")) + .unwrap_or_default(); + format!("{upstream_origin}{path}{query}") +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; + use axum::response::{Html, IntoResponse}; + use axum::routing::{get, post}; + use tokio::net::TcpListener; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + app_with_legacy_next_base_url("http://127.0.0.1:3100".into()) + } + + fn app_with_legacy_next_base_url(legacy_next_base_url: String) -> axum::Router { + app_with_config(legacy_next_base_url, true) + } + + fn app_with_config( + legacy_next_base_url: String, + enable_legacy_next_compat: bool, + ) -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some(legacy_next_base_url), + enable_legacy_next_compat, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + async fn spawn_legacy_auth_upstream() -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("legacy listener"); + let addr = listener.local_addr().expect("legacy addr"); + let app = axum::Router::new().route( + "/auth", + get(|| async { + Html(r#""#) + }) + .post(|| async { "auth-post-ok" }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("legacy server"); + }); + format!("http://{addr}") + } + + async fn spawn_legacy_origin_checked_upstream() -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("legacy listener"); + let addr = listener.local_addr().expect("legacy addr"); + let expected_origin = format!("http://{addr}"); + let app = axum::Router::new().route( + "/api/auth", + post(move |headers: HeaderMap| { + let expected_origin = expected_origin.clone(); + async move { + let origin = headers + .get("origin") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if origin != expected_origin { + return (StatusCode::FORBIDDEN, "Invalid origin"); + } + (StatusCode::OK, "ok") + } + }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("legacy server"); + }); + format!("http://{addr}") + } + + async fn spawn_legacy_cookie_upstream() -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("legacy listener"); + let addr = listener.local_addr().expect("legacy addr"); + let app = axum::Router::new().route( + "/api/auth", + post(|| async { + let mut response = "ok".into_response(); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("__convexAuthJWT=jwt-demo; Path=/; HttpOnly"), + ); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static( + "__convexAuthRefreshToken=refresh-demo; Path=/; HttpOnly", + ), + ); + response + }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("legacy server"); + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn gateway_health_declares_mnote_web_owner() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/gateway/health") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["owner"], "mnote-web"); + assert_eq!(payload["publicEntry"], "127.0.0.1:3000"); + assert_eq!(payload["legacyNextBaseUrl"], "http://127.0.0.1:3100"); + } + + #[tokio::test] + async fn root_entry_returns_wolai_workspace_layout_contract() { + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri("/") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains(r#""#)); + assert!(html.contains(r#"data-testid="wolai-sidebar""#)); + assert!(html.contains(r#"data-testid="wolai-topbar""#)); + assert!(html.contains(r#"data-testid="wolai-floating-ai""#)); + assert!(html.contains("星标置顶")); + assert!(html.contains("我的页面")); + assert!(html.contains("垃圾箱")); + assert!(html.contains("模板中心")); + assert!(!html.contains("欢迎使用 MNOTE 知识管理平台")); + assert!(!html.contains(r#"文档"#)); + } + + #[tokio::test] + async fn root_entry_uses_recent_page_cookie_as_active_page() { + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri("/") + .header("cookie", "mnote_recent_page_id=page_child") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains(r#"data-node-id="page_child""#)); + assert!(html.contains(r#"class="wolai-page-row wolai-active-row" href="/documents/page_child?workspaceId=ws_demo" data-node-id="page_child""#)); + } + + #[tokio::test] + async fn auth_entry_returns_gateway_fallback_shell_when_compat_disabled() { + let response = app_with_config("http://127.0.0.1:3100".into(), false) + .oneshot( + Request::builder() + .uri("/auth") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert!(response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .contains("text/html")); + } + + #[tokio::test] + async fn auth_entry_uses_legacy_next_login_ui_when_compat_enabled() { + let legacy_base_url = spawn_legacy_auth_upstream().await; + let response = app_with_legacy_next_base_url(legacy_base_url) + .oneshot( + Request::builder() + .uri("/auth") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-legacy-upstream") + .and_then(|value| value.to_str().ok()), + Some("next-app-router") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("测试账号快速登录")); + } + + #[tokio::test] + async fn auth_entry_proxies_post_to_legacy_next_when_compat_enabled() { + let legacy_base_url = spawn_legacy_auth_upstream().await; + let response = app_with_legacy_next_base_url(legacy_base_url) + .oneshot( + Request::builder() + .method("POST") + .uri("/auth") + .header("origin", "http://127.0.0.1:3000") + .header("referer", "http://127.0.0.1:3000/auth") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-legacy-upstream") + .and_then(|value| value.to_str().ok()), + Some("next-app-router") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert_eq!(text, "auth-post-ok"); + } + + #[tokio::test] + async fn legacy_proxy_normalizes_auth_post_origin_to_upstream_origin() { + let legacy_base_url = spawn_legacy_origin_checked_upstream().await; + let response = app_with_legacy_next_base_url(legacy_base_url) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth") + .header("origin", "http://127.0.0.1:3000") + .header("referer", "http://127.0.0.1:3000/auth") + .body(Body::from(r#"{"action":"auth:signIn"}"#)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-legacy-upstream") + .and_then(|value| value.to_str().ok()), + Some("next-app-router") + ); + } + + #[tokio::test] + async fn legacy_proxy_preserves_multiple_set_cookie_headers() { + let legacy_base_url = spawn_legacy_cookie_upstream().await; + let response = app_with_legacy_next_base_url(legacy_base_url) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let cookies = response.headers().get_all("set-cookie"); + let values = cookies + .iter() + .map(|value| value.to_str().unwrap_or_default()) + .collect::>(); + assert!(values + .iter() + .any(|value| value.contains("__convexAuthJWT=jwt-demo"))); + assert!(values + .iter() + .any(|value| value.contains("__convexAuthRefreshToken=refresh-demo"))); + } +} diff --git a/rust/crates/mnote-web/src/routes/health.rs b/rust/crates/mnote-web/src/routes/health.rs index 493e45dd..b26fa25e 100644 --- a/rust/crates/mnote-web/src/routes/health.rs +++ b/rust/crates/mnote-web/src/routes/health.rs @@ -1,7 +1,7 @@ use crate::app::AppState; use crate::context::RequestContext; -use axum::Json; use axum::extract::{Extension, State}; +use axum::Json; use serde::Serialize; #[derive(Debug, Serialize)] diff --git a/rust/crates/mnote-web/src/routes/hermes.rs b/rust/crates/mnote-web/src/routes/hermes.rs index f0fae4f2..3a02c835 100644 --- a/rust/crates/mnote-web/src/routes/hermes.rs +++ b/rust/crates/mnote-web/src/routes/hermes.rs @@ -1,15 +1,18 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use axum::Json; use axum::extract::{Extension, State}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::Json; use bridge_runtime::{ - RuntimeInput, build_failure_response, build_success_response, execute_runtime_input, - execute_runtime_query, runtime_input_requests_result, + build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query, + runtime_input_requests_result, RuntimeInput, }; use serde::Serialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_AI_BRIDGE_OWNER: &str = "x-mnote-ai-bridge-owner"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -37,7 +40,7 @@ pub async fn health( pub async fn bridge_runtime( Extension(context): Extension, Json(runtime_input): Json, -) -> Result<(StatusCode, Json), WebError> { +) -> Result<(StatusCode, HeaderMap, Json), WebError> { let payload = if runtime_input_requests_result(&runtime_input) { match execute_runtime_query(runtime_input) { Ok(result) => json!({ @@ -45,12 +48,14 @@ pub async fn bridge_runtime( "bridge": "hermes_runtime_result", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, + "contract": ai_bridge_contract(), "result": result, }), Err(error) => { let failure = build_failure_response(error); return Ok(( StatusCode::BAD_REQUEST, + stamp_ai_bridge_headers(), Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")), )); } @@ -64,6 +69,7 @@ pub async fn bridge_runtime( "bridge": "hermes_runtime_plan", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, + "contract": ai_bridge_contract(), "plan": success.plan, }) } @@ -71,11 +77,140 @@ pub async fn bridge_runtime( let failure = build_failure_response(error); return Ok(( StatusCode::BAD_REQUEST, + stamp_ai_bridge_headers(), Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")), )); } } }; - Ok((StatusCode::OK, Json(payload))) + Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload))) +} + +fn ai_bridge_contract() -> Value { + json!({ + "schema": "mnote.ai_bridge.v1", + "owner": "mnote-web", + "bridge": "hermes", + "sessionOwner": "rust-web-hermes", + "toolEventOwner": "rust-web-hermes", + "clientActionOwner": "rust-web-hermes" + }) +} + +fn stamp_ai_bridge_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_AI_BRIDGE_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("rust-web-hermes")); + } + headers +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use serde_json::{json, Value}; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn ai_bridge_route_returns_hermes_owner_contract() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/bridge") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "kind": "tool", + "context": { + "deploymentId": null, + "projectId": null, + "workspaceId": "ws_demo", + "requestId": "req_1", + "traceId": "trace_1", + "actor": { + "actorType": "user", + "actorId": "user_1", + "sessionId": null + }, + "source": { + "channel": "rust-web", + "client": "mnote-web" + }, + "tenantId": null, + "authToken": null, + "idempotencyKey": null, + "validateOnly": false, + "dryRun": false + }, + "tool": { + "tool": "docs_search", + "kind": "query", + "mode": "plan", + "argsJson": {"query": "Rust Web"}, + "target": null, + "reason": "owner gate", + "refs": [] + }, + "data": null + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-mnote-ai-bridge-owner") + .and_then(|value| value.to_str().ok()), + Some("rust-web-hermes") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["contract"]["schema"], "mnote.ai_bridge.v1"); + assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes"); + assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes"); + assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes"); + } } diff --git a/rust/crates/mnote-web/src/routes/kernel.rs b/rust/crates/mnote-web/src/routes/kernel.rs index e05697b7..36e2f41c 100644 --- a/rust/crates/mnote-web/src/routes/kernel.rs +++ b/rust/crates/mnote-web/src/routes/kernel.rs @@ -3,16 +3,16 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::snapshot_support::{ - ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, - subtree_query, + execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, + ProjectionSnapshotSpec, }; -use axum::Json; use axum::extract::{Extension, Query, State}; use axum::http::StatusCode; +use axum::Json; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -188,8 +188,8 @@ pub async fn graph( #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use serde_json::Value; use tower::util::ServiceExt; @@ -199,6 +199,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), @@ -386,13 +389,11 @@ mod tests { item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"], "mindmap" ); - assert!( - item_by_row_id["asset-folder:mind_1"]["capabilities"] - .as_array() - .expect("capabilities") - .iter() - .any(|value| value == "expand") - ); + assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"] + .as_array() + .expect("capabilities") + .iter() + .any(|value| value == "expand")); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs new file mode 100644 index 00000000..228e90c7 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -0,0 +1,150 @@ +use crate::context::RequestContext; +use crate::error::WebError; +use crate::ssr::pages::mindmap::MindmapPage; +use axum::extract::{Extension, Path}; +use axum::http::{HeaderMap, HeaderName, HeaderValue}; +use axum::response::{Html, IntoResponse, Response}; +use serde_json::json; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell"; + +pub async fn mindmap_object_shell( + Extension(context): Extension, + Path((doc_id, mindmap_id)): Path<(String, String)>, +) -> Result { + let contract = json!({ + "schema": "mnote.mindmap_shell.v1", + "owner": "mnote-web", + "shell": "mindmap", + "documentId": doc_id, + "mindmapId": mindmap_id, + "projection": { + "schema": "mnote.mindmap_projection.v1", + "source": "rust-web-object-shell" + }, + "island": { + "kind": "react_mindmap_runtime", + "mountId": "mnote-mindmap-island", + "legacyCompat": "next-app-router" + }, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id + }); + let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string()); + let body_content = crate::ssr::render_view(leptos::view! { + + }); + let html = format!( + r#" + + + + 思维导图 + + + + {} + + +"#, + crate::ssr::MNOTE_CSS, + escape_html(&doc_id), + escape_html(&mindmap_id), + body_content, + escape_script_json(&contract_json), + ); + let mut response = Html(html).into_response(); + stamp_shell_headers(response.headers_mut(), "mindmap"); + Ok(response) +} + +fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) { + headers.insert(name, HeaderValue::from_static(shell)); + } +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn escape_script_json(value: &str) -> String { + value.replace(" axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn mindmap_shell_returns_rust_object_shell_contract() { + let response = app() + .oneshot( + Request::builder() + .uri("/mindmap/doc_1/mind_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-mnote-web-shell") + .and_then(|value| value.to_str().ok()), + Some("mindmap") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("mnote.mindmap_shell.v1")); + assert!(html.contains("data-react-island=\"mindmap_runtime\"")); + assert!(html.contains("rust-web-object-shell")); + } +} diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index dae55ebb..075723dc 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -3,19 +3,24 @@ mod command_support; mod compat; mod documents; mod editor; +mod gateway; mod health; mod hermes; mod kernel; +mod mindmap_shell; mod query_support; +mod search; +mod session; mod snapshot_support; mod sse; mod stream_support; mod tree; +mod web_shell; mod ws; use crate::app::AppState; -use axum::Router; 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(); @@ -24,6 +29,26 @@ pub fn build_router(state: AppState) -> Router { let mut router = Router::new() .route("/health", get(health::health)) + .route("/", get(gateway::root_entry)) + .route("/auth", get(gateway::auth_entry).post(gateway::auth_entry)) + .route("/search", get(search::shell)) + .route( + "/mindmap/{doc_id}/{mindmap_id}", + get(mindmap_shell::mindmap_object_shell), + ) + .route( + "/documents/{document_id}", + get(web_shell::document_page_shell), + ) + .route( + "/api/page-aggregate/{document_id}", + get(web_shell::page_aggregate), + ) + .route("/api/search/documents", post(search::documents)) + .route("/api/gateway/health", get(gateway::gateway_health)) + .route("/api/runtime/config", get(session::runtime_config)) + .route("/api/auth/session", get(session::session)) + .route("/api/auth/session/refresh", post(session::refresh_session)) .route("/api/documents/meta", get(documents::meta)) .route("/api/documents/content", get(documents::content)) .route("/api/documents/save", post(documents::save)) @@ -52,6 +77,7 @@ pub fn build_router(state: AppState) -> Router { .route("/api/bridge/workspace", get(bridge::workspace)) .route("/api/bridge/request", get(bridge::request)) .route("/api/bridge/trace", get(bridge::trace)) + .route("/api/tree/events", get(sse::tree_events)) .route("/api/stream/events", get(sse::events)) .route("/api/realtime/ws", get(ws::socket)) .nest( @@ -65,7 +91,8 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/ai-agent/run", post(compat::next_ai_agent_run)) .route("/sidebar", get(compat::next_sidebar)), - ); + ) + .fallback(gateway::legacy_next_proxy); if enable_debug_shell_routes { router = router diff --git a/rust/crates/mnote-web/src/routes/query_support.rs b/rust/crates/mnote-web/src/routes/query_support.rs index fa62fc92..605143cf 100644 --- a/rust/crates/mnote-web/src/routes/query_support.rs +++ b/rust/crates/mnote-web/src/routes/query_support.rs @@ -3,13 +3,13 @@ use crate::context::RequestContext; use crate::error::WebError; use crate::transport::convex::execute_convex_query_plan; use bridge_runtime::{ - RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput, - RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input, - execute_runtime_query, + execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, + RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, + RuntimeSourceWire, }; use core_protocol::{GetPageMeta, QueryEnvelope}; use serde_json::Value; -use storage_convex_bridge::{BridgeContext, build_query_request}; +use storage_convex_bridge::{build_query_request, BridgeContext}; pub fn resolve_effective_workspace_id( context: &RequestContext, diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs new file mode 100644 index 00000000..6deeec4b --- /dev/null +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -0,0 +1,333 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::query_support::{ + execute_runtime_query_against_data, resolve_effective_workspace_id, +}; +use crate::routes::web_shell::load_sidebar_tree_html; +use crate::ssr::pages::search::SearchPage; +use axum::extract::{Extension, Query, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use axum::Json; +use bridge_runtime::RuntimeQueryEnvelopeWire; +use serde::Deserialize; +use serde_json::{json, Value}; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_QUERY_NAME: &str = "x-query-name"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchDocumentsRequest { + pub workspace_id: Option, + pub query: Option, + pub document_id: Option, + pub limit: Option, + pub filters: Option, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SearchDocumentsFilters { + pub title_only: Option, + pub exact: Option, + pub include_ocr: Option, + pub only_current_page: Option, + pub time_range: Option, + pub time_field: Option, + pub custom_range: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchCustomRange { + pub from: Option, + pub to: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchShellQuery { + pub workspace_id: Option, + pub q: Option, +} + +pub async fn shell( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result { + let workspace_id = query + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default"); + let sidebar_tree_html = + load_sidebar_tree_html(state.config(), &context, workspace_id, None) + .await + .unwrap_or_default(); + let search_query = query.q.as_deref().map(str::trim).unwrap_or(""); + let contract = json!({ + "schema": "mnote.search_shell.v1", + "owner": "mnote-web", + "shell": "search", + "workspaceId": workspace_id, + "query": search_query, + "initialResults": { + "queryName": "search.documents", + "results": [] + }, + "island": { + "kind": "react_search_palette", + "mountId": "mnote-search-island", + "runtime": "SearchPaletteHost" + }, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id + }); + let contract_json = serde_json::to_string(&contract).unwrap_or_else(|_| "null".to_string()); + let body_content = crate::ssr::render_view(leptos::view! { + + }); + let html = format!( + r#" + + + + 搜索 + + + + {} + + +"#, + crate::ssr::MNOTE_CSS, + body_content, + escape_script_json(&contract_json), + ); + let mut response = Html(html).into_response(); + stamp_search_headers(response.headers_mut()); + if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-shell") { + response + .headers_mut() + .insert(name, HeaderValue::from_static("search")); + } + Ok(response) +} + +pub async fn documents( + State(_state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let effective_workspace_id = + resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), true)? + .expect("workspace_required 已确保存在"); + let filters = body.filters.unwrap_or_default(); + let normalized_query = body.query.unwrap_or_default().trim().to_string(); + let page_id = if filters.only_current_page.unwrap_or(false) { + body.document_id.filter(|value| !value.trim().is_empty()) + } else { + None + }; + + let result = if normalized_query.is_empty() { + json!({ + "enqueueAssetIds": [], + "results": [], + }) + } else { + execute_runtime_query_against_data( + &context, + Some(&effective_workspace_id), + RuntimeQueryEnvelopeWire { + name: "search.documents".into(), + payload: json!({ + "query": normalized_query, + "workspaceId": effective_workspace_id, + "pageId": page_id, + "limit": body.limit.unwrap_or(30), + "titleOnly": filters.title_only.unwrap_or(false), + "exact": filters.exact.unwrap_or(false), + "includeOcr": filters.include_ocr.unwrap_or(false), + "timeRange": filters.time_range.unwrap_or_else(|| "any".into()), + "timeField": filters.time_field.unwrap_or_else(|| "updated".into()), + "customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()), + "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()), + }), + }, + json!({ + "documents": [ + { + "id": "doc_1", + "workspaceId": effective_workspace_id, + "title": "Rust Web 搜索结果", + "rawText": "mnote-web search documents transport", + "createdAt": "2026-04-28T00:00:00Z", + "updatedAt": "2026-04-28T00:00:00Z" + } + ], + "mindmaps": [], + "tables": [], + "tableRows": [], + "assets": [] + }), + )? + }; + + let mut headers = HeaderMap::new(); + stamp_search_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "results": result.get("results").cloned().unwrap_or(Value::Array(vec![])), + "recent": [], + "meta": { + "owner": "mnote-web", + "queryName": "search.documents", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + }, + })), + )) +} + +fn escape_script_json(value: &str) -> String { + value.replace(" axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn search_shell_returns_server_first_island_contract() { + let response = app() + .oneshot( + Request::builder() + .uri("/search?workspaceId=ws_demo&q=Rust") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-mnote-web-shell") + .and_then(|value| value.to_str().ok()), + Some("search") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(html.contains("data-mnote-shell=\"search\"")); + assert!(html.contains("mnote.search_shell.v1")); + assert!(html.contains("react_search_palette")); + assert!(html.contains("search.documents")); + } + + #[tokio::test] + async fn search_documents_route_is_owned_by_mnote_web() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/search/documents") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "workspaceId": "ws_demo", + "query": "Rust Web", + "filters": { + "titleOnly": false, + "exact": false, + "includeOcr": false, + "onlyCurrentPage": false, + "timeRange": "any", + "timeField": "updated" + } + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-query-name") + .and_then(|value| value.to_str().ok()), + Some("search.documents") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["meta"]["owner"], "mnote-web"); + assert_eq!(payload["meta"]["queryName"], "search.documents"); + } +} diff --git a/rust/crates/mnote-web/src/routes/session.rs b/rust/crates/mnote-web/src/routes/session.rs new file mode 100644 index 00000000..e3b642cd --- /dev/null +++ b/rust/crates/mnote-web/src/routes/session.rs @@ -0,0 +1,175 @@ +use crate::app::AppState; +use crate::context::RequestContext; +use axum::extract::{Extension, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeConfigResponse { + pub ok: bool, + pub owner: &'static str, + pub public_entry: String, + pub tree_renderer_family: &'static str, + pub document_editor_host: &'static str, + pub legacy_next_compat_enabled: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionResponse { + pub ok: bool, + pub owner: &'static str, + pub user_id: String, + pub email: String, + pub name: String, + pub actor_type: String, + pub request_id: String, + pub trace_id: String, +} + +pub async fn runtime_config(State(state): State) -> Response { + owner_json(Json(RuntimeConfigResponse { + ok: true, + owner: "mnote-web", + public_entry: state.config().public_bind_addr.clone(), + tree_renderer_family: "rust_family", + document_editor_host: "leptos_tiptap_island", + legacy_next_compat_enabled: state.config().enable_legacy_next_compat, + })) +} + +pub async fn session( + State(state): State, + Extension(context): Extension, +) -> Response { + owner_json(Json(SessionResponse { + ok: true, + owner: "mnote-web", + user_id: state.config().dev_user_id.clone(), + email: state.config().dev_user_email.clone(), + name: state.config().dev_user_name.clone(), + actor_type: context.auth.actor_type, + request_id: context.trace.request_id, + trace_id: context.trace.trace_id, + })) +} + +pub async fn refresh_session( + State(state): State, + Extension(context): Extension, +) -> Response { + let mut response = owner_json(Json(SessionResponse { + ok: true, + owner: "mnote-web", + user_id: state.config().dev_user_id.clone(), + email: state.config().dev_user_email.clone(), + name: state.config().dev_user_name.clone(), + actor_type: context.auth.actor_type, + request_id: context.trace.request_id, + trace_id: context.trace.trace_id, + })); + *response.status_mut() = StatusCode::OK; + response +} + +fn owner_json(payload: Json) -> Response +where + T: Serialize, +{ + let mut response = payload.into_response(); + stamp_owner_header(response.headers_mut()); + response +} + +fn stamp_owner_header(headers: &mut HeaderMap) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn runtime_config_is_owned_by_mnote_web_and_hides_internal_urls() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/runtime/config") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["owner"], "mnote-web"); + assert_eq!(payload["treeRendererFamily"], "rust_family"); + assert!(payload.get("legacyNextBaseUrl").is_none()); + assert!(payload.get("convexAdminKey").is_none()); + } + + #[tokio::test] + async fn session_handoff_returns_dev_identity_without_internal_secret() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/auth/session") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["owner"], "mnote-web"); + assert_eq!(payload["userId"], "dev-user"); + assert!(payload.get("convexAdminKey").is_none()); + } +} diff --git a/rust/crates/mnote-web/src/routes/snapshot_support.rs b/rust/crates/mnote-web/src/routes/snapshot_support.rs index b48b65b7..a8b62368 100644 --- a/rust/crates/mnote-web/src/routes/snapshot_support.rs +++ b/rust/crates/mnote-web/src/routes/snapshot_support.rs @@ -6,7 +6,7 @@ use crate::routes::query_support::{ }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::{KernelNodeType, KernelProjectionKind}; -use serde_json::{Value, json}; +use serde_json::{json, Value}; #[derive(Debug, Clone)] pub struct ProjectionSnapshotSpec<'a> { diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index a4d3d376..e834e60b 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -2,10 +2,12 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::stream_support::{ - StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview, - load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, + build_stream_delta_payload, load_stream_overview, load_stream_snapshot, + read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind, + StreamSnapshotQuery, }; use axum::extract::{Extension, Query, State}; +use axum::http::{HeaderMap, HeaderName, HeaderValue}; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::stream; use serde_json::Value; @@ -117,6 +119,28 @@ pub async fn events( )) } +pub async fn tree_events( + State(state): State, + Extension(context): Extension, + Query(query): Query, +) -> Result< + ( + HeaderMap, + Sse>>, + ), + WebError, +> { + let mut headers = HeaderMap::new(); + if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-tree-stream-owner") { + headers.insert(name, HeaderValue::from_static("rust-web")); + } + let sse = events(State(state), Extension(context), Query(query)).await?; + Ok((headers, sse)) +} + #[derive(Clone)] struct StreamPollState { app_state: AppState, @@ -137,8 +161,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event { #[cfg(test)] mod tests { - use crate::app::{AppConfig, AppState, build_app}; - use axum::body::{Body, to_bytes}; + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; @@ -147,6 +171,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: false, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), @@ -183,4 +210,39 @@ mod tests { assert!(text.contains("\"projection\":\"sidebar_tree\"")); assert!(text.contains("\"workspaceId\":\"ws_demo\"")); } + + #[tokio::test] + async fn tree_realtime_route_returns_rust_web_owned_snapshot_event() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-mnote-tree-stream-owner") + .and_then(|value| value.to_str().ok()), + Some("rust-web") + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(text.contains("event: snapshot") || text.contains("event:snapshot")); + assert!(text.contains("\"kind\":\"snapshot\"")); + } } diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index f90afeb8..451bf1d8 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -5,13 +5,13 @@ use crate::routes::query_support::{ execute_runtime_query_via_convex, resolve_effective_workspace_id, }; use crate::routes::snapshot_support::{ - ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, - subtree_query, + execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query, + ProjectionSnapshotSpec, }; use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ "page.body.save", @@ -588,8 +588,8 @@ pub async fn load_stream_snapshot( #[cfg(test)] mod tests { use super::{ - StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change, - resolve_stream_cursor, resolve_stream_scope, + resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, + StreamSnapshotQuery, StreamSnapshotScope, }; use serde_json::json; diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index c02db315..d74b10c0 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -5,34 +5,37 @@ use crate::routes::command_support::{ build_tree_target, ensure_non_empty, ensure_sort_order, execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, }; -use crate::routes::query_support::resolve_effective_workspace_id; -use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot}; +use crate::routes::query_support::{ + fetch_documents_meta_via_convex, resolve_effective_workspace_id, +}; +use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; +use crate::transport::convex::execute_convex_mutation_by_name; use crate::tree_shell::filetree_renderer::{ - FileTreeInitialRenderInput, FileTreeRenderRow, render_initial_filetree_html, + render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow, }; use crate::tree_shell::filetree_selection::FileTreeSelectionState; use crate::tree_shell::page_renderer::{ - PageTreeInitialRenderInput, PageTreeRenderRow, render_initial_page_tree_html, + render_initial_page_tree_html, PageTreeInitialRenderInput, PageTreeRenderRow, }; use crate::tree_shell::picker_renderer::{ - PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html, + render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow, }; use crate::tree_shell::renderer_input::{ FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, TreeShellRendererInput, }; use crate::tree_shell::runtime_api::{ - TreeShellRuntimeRequest, TreeShellRuntimeResult, - reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, + reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest, + TreeShellRuntimeResult, }; -use axum::Json; use axum::extract::{Extension, Query, State}; -use axum::http::{HeaderValue, StatusCode, header}; +use axum::http::{header, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; +use axum::Json; use bridge_runtime::RuntimeCommandEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::{json, Value}; use std::collections::BTreeSet; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -90,6 +93,10 @@ pub enum TreeCommandRequest { parent_id: Option, sort_order: i64, }, + Purge { + workspace_id: Option, + document_id: String, + }, } fn escape_html(input: &str) -> String { @@ -201,7 +208,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet { .unwrap_or_default() } -fn collect_page_tree_render_rows(projection: &Value) -> Vec { +pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec { projection .get("items") .and_then(Value::as_array) @@ -4467,9 +4474,98 @@ fn create_command_wire( validate_only: false, }) } + TreeCommandRequest::Purge { + workspace_id: _, + document_id, + } => { + let document_id = ensure_non_empty(&document_id, "documentId", context)?; + Ok(RuntimeCommandEnvelopeWire { + name: "tree.node.purge".into(), + command_id: format!("tree_purge_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: bridge_runtime::RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: bridge_runtime::RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + }, + target: Some(build_tree_target( + workspace_id, + Some(document_id.as_str()), + None, + )), + payload: json!({ + "documentId": document_id, + "workspaceId": workspace_id, + }), + preflight_data: None, + reason: Some("tree-shell purge".into()), + refs: vec!["mnote-web-tree".into()], + dry_run: false, + validate_only: false, + }) + } } } +async fn resolve_tree_create_workspace_id( + state: &AppState, + context: &RequestContext, + requested_workspace_id: Option<&str>, + parent_id: Option<&str>, +) -> Result { + if let Some(workspace_id) = + resolve_effective_workspace_id(context, requested_workspace_id, false)? + { + return Ok(workspace_id); + } + + if let Some(parent_id) = parent_id.map(str::trim).filter(|value| !value.is_empty()) { + let parent_meta = + fetch_documents_meta_via_convex(state.config(), context, None, parent_id).await?; + if let Some(workspace_id) = parent_meta + .get("workspace_id") + .or_else(|| parent_meta.get("workspaceId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(workspace_id.to_string()); + } + } + + let bootstrap = execute_convex_mutation_by_name( + state.config(), + context, + "workspaces:ensureDefaultWorkspace", + json!({ + "fallbackName": context.auth.actor_id, + "workspaceIdIfCreate": generate_tree_document_id(), + }), + None, + None, + "tree_command_workspace_bootstrap", + ) + .await?; + bootstrap + .get("activeWorkspaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + WebError::bad_gateway_code( + "workspace_bootstrap_bad_response", + "workspaces.ensureDefaultWorkspace 未返回 activeWorkspaceId", + ) + .with_context(context) + .with_header("x-error-phase", "tree_command_workspace_bootstrap") + }) +} + pub async fn tree_command( State(state): State, Extension(context): Extension, @@ -4504,6 +4600,10 @@ pub async fn tree_command( parent_id: raw_request.parent_id, sort_order: raw_request.sort_order.unwrap_or(-1), }, + "purge" => TreeCommandRequest::Purge { + workspace_id: raw_request.workspace_id, + document_id: raw_request.document_id.unwrap_or_default(), + }, other => { return Err(WebError::bad_request_code( "tree_command_validation", @@ -4517,6 +4617,7 @@ pub async fn tree_command( TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Move { workspace_id, .. } => workspace_id.as_deref(), + TreeCommandRequest::Purge { workspace_id, .. } => workspace_id.as_deref(), }; let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) = match &request { @@ -4553,10 +4654,27 @@ pub async fn tree_command( None, Some(*sort_order), ), + TreeCommandRequest::Purge { document_id, .. } => ( + "purge", + document_id.clone(), + None, + None, + None, + ), }; - let effective_workspace_id = - resolve_effective_workspace_id(&context, requested_workspace_id, true)? - .expect("workspace_required 已确保存在"); + let effective_workspace_id = match &request { + TreeCommandRequest::Create { parent_id, .. } => { + resolve_tree_create_workspace_id( + &state, + &context, + requested_workspace_id, + parent_id.as_deref(), + ) + .await? + } + _ => resolve_effective_workspace_id(&context, requested_workspace_id, true)? + .expect("workspace_required 已确保存在"), + }; let command_wire = create_command_wire(&context, &effective_workspace_id, request)?; let execution = execute_runtime_command_via_convex_with_artifacts( state.config(), @@ -4607,8 +4725,8 @@ pub async fn reduce_tree_shell_runtime( #[cfg(test)] mod tests { - use super::{TreeCommandRequest, create_command_wire}; - use crate::app::{AppConfig, AppState, build_app}; + use super::{create_command_wire, TreeCommandRequest}; + use crate::app::{build_app, AppConfig, AppState}; use crate::context::RequestContext; use crate::routes::command_support::build_runtime_command_plan; use axum::body::Body; @@ -4621,6 +4739,9 @@ mod tests { service_name: "mnote-web".into(), service_version: "0.1.0".into(), bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, enable_debug_shell_routes: true, hermes_base_path: "/api/hermes".into(), compat_next_base_path: "/api/compat/next".into(), @@ -4628,7 +4749,7 @@ mod tests { convex_admin_key: None, allow_dev_fixtures: true, query_fixtures_json: Some(r#"{"sidebar:datasetList":{"active_workspace_id":"ws_demo","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"}],"media_assets":[{"id":"asset_file_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"封面.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_child_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"节点图片.png","mime_type":"image/png","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_pdf_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"说明书.pdf","mime_type":"application/pdf","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"},{"id":"asset_book_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"file","file_name":"小说.epub","mime_type":"application/epub+zip","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_assets":[{"id":"mind_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"mindmap","file_name":"头脑风暴.json","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"table_assets":[{"id":"table_1","workspace_id":"ws_demo","document_id":"page_root","asset_type":"luckysheet","file_name":"预算.luckysheet","mime_type":"application/json","created_at":"2026-04-16T00:00:00Z","updated_at":"2026-04-16T00:00:00Z"}],"mindmap_asset_children":{"mind_1":["asset_child_1"]}}}"#.into()), - mutation_fixtures_json: Some(r#"{"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()), + mutation_fixtures_json: Some(r#"{"workspaces:ensureDefaultWorkspace":{"workspaces":[{"id":"ws_demo","name":"我的空间","type":"personal","iconUrl":null,"memberCount":1,"isDefault":true}],"activeWorkspaceId":"ws_demo"},"documents:createWithParentReference":{"ok":true,"id":"page_new","updated_at":"2026-04-17T00:00:00Z"},"documents:updateTitle":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:move":{"ok":true,"updated_at":"2026-04-17T00:00:00Z"},"documents:purge":{"ok":true,"deletedCount":1},"bridgeLogs:recordCommandLog":{"ok":true,"id":"clog_fixture"},"bridgeLogs:recordDomainEvent":{"ok":true,"id":"evt_fixture"}}"#.into()), dev_user_id: "dev-user".into(), dev_user_name: "开发用户".into(), dev_user_email: "dev@mnote.local".into(), @@ -4795,9 +4916,8 @@ mod tests { assert!(filetree_html.contains( "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" )); - assert!(filetree_html.contains( - "\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"" - )); + assert!(filetree_html + .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); assert!(filetree_html.contains( "\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]" )); @@ -4825,9 +4945,8 @@ mod tests { assert!(picker_html.contains( "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" )); - assert!(picker_html.contains( - "\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"" - )); + assert!(picker_html + .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"")); } #[tokio::test] @@ -4991,6 +5110,65 @@ mod tests { ); } + #[tokio::test] + async fn tree_command_create_uses_default_workspace_when_workspace_missing() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from(r#"{"action":"create","title":"新页面"}"#)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["action"], Value::String("create".into())); + assert_eq!( + payload["result"]["workspaceId"], + Value::String("ws_demo".into()) + ); + assert_eq!( + payload["result"]["documentId"], + Value::String("page_new".into()) + ); + } + + #[tokio::test] + async fn tree_command_purge_uses_tree_command_protocol() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/tree/commands") + .header("content-type", "application/json") + .body(Body::from( + r#"{"action":"purge","workspaceId":"ws_demo","documentId":"page_child"}"#, + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["result"]["action"], Value::String("purge".into())); + assert_eq!( + payload["result"]["documentId"], + Value::String("page_child".into()) + ); + assert_eq!(payload["result"]["execution"]["deletedCount"], Value::from(1)); + } + #[tokio::test] async fn tree_command_rejects_negative_sort_order() { let response = app() diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs new file mode 100644 index 00000000..3cdb82e6 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -0,0 +1,469 @@ +use crate::app::{AppConfig, AppState}; +use crate::context::RequestContext; +use crate::error::WebError; +use crate::page_aggregate::PageAggregate; +use crate::routes::documents::{ + load_document_content_result, load_document_meta_result, DocumentContentQuery, + DocumentMetaQuery, +}; +use crate::routes::snapshot_support::{ + execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec, +}; +use crate::routes::tree::collect_page_tree_render_rows; +use crate::tree_shell::page_renderer::{ + render_initial_page_tree_html, PageTreeInitialRenderInput, +}; +use crate::workspace_shell::{build_workspace_shell_projection, WorkspaceShellProjection}; +use axum::extract::{Extension, Path, Query, State}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; +use axum::Json; +use core_protocol::KernelProjectionKind; +use serde::Deserialize; +use crate::ssr::pages::document::DocumentPage; +use serde_json::{json, Value}; + +const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; +const HEADER_MNOTE_WEB_SHELL: &str = "x-mnote-web-shell"; +const COOKIE_RECENT_PAGE_ID: &str = "mnote_recent_page_id"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentShellQuery { + pub workspace_id: Option, +} + +pub async fn document_page_shell( + State(state): State, + Extension(context): Extension, + Path(document_id): Path, + Query(query): Query, +) -> Result { + let aggregate = build_page_aggregate_snapshot( + &state, + &context, + &document_id, + query.workspace_id.as_deref(), + ) + .await?; + let title = aggregate.head_title(); + let workspace_id = query + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("default"); + let sidebar_tree_html = + load_sidebar_tree_html(state.config(), &context, workspace_id, Some(&document_id)) + .await + .unwrap_or_default(); + let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string()); + let body_content = crate::ssr::render_view(leptos::view! { + + }); + let html = format!( + r#" + + + + {} + + + + {} + + +"#, + escape_html(title), + crate::ssr::MNOTE_CSS, + escape_html(&document_id), + body_content, + escape_script_json(&snapshot_json), + ); + let mut response = Html(html).into_response(); + stamp_shell_headers(response.headers_mut(), "document"); + stamp_recent_page_cookie(response.headers_mut(), &document_id); + Ok(response) +} + +pub async fn page_aggregate( + State(state): State, + Extension(context): Extension, + Path(document_id): Path, + Query(query): Query, +) -> Result { + let aggregate = build_page_aggregate_snapshot( + &state, + &context, + &document_id, + query.workspace_id.as_deref(), + ) + .await?; + let mut response = ( + StatusCode::OK, + Json(json!({ + "ok": true, + "owner": "mnote-web", + "schema": "mnote.page_aggregate.v1", + "result": aggregate, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + })), + ) + .into_response(); + stamp_shell_headers(response.headers_mut(), "page-aggregate"); + Ok(response) +} + +async fn build_page_aggregate_snapshot( + state: &AppState, + context: &RequestContext, + document_id: &str, + workspace_id: Option<&str>, +) -> Result { + let meta = load_document_meta_result( + state, + context, + DocumentMetaQuery { + document_id: document_id.to_string(), + workspace_id: workspace_id.map(str::to_string), + }, + ) + .await?; + let content = load_document_content_result( + state, + context, + DocumentContentQuery { + document_id: document_id.to_string(), + workspace_id: workspace_id.map(str::to_string), + }, + ) + .await?; + + let conflict_detection_key = content + .get("conflictDetectionKey") + .or_else(|| content.get("conflict_detection_key")) + .cloned() + .unwrap_or(Value::Null); + let page_subtree = content + .get("pageSubtree") + .or_else(|| content.get("page_subtree")) + .cloned() + .unwrap_or(Value::Null); + let todo_total = meta + .get("todo_total") + .or_else(|| meta.get("todo_total_count")) + .and_then(Value::as_u64) + .unwrap_or(0); + let todo_done = meta + .get("todo_done") + .or_else(|| meta.get("todo_done_count")) + .and_then(Value::as_u64) + .unwrap_or(0); + + Ok(PageAggregate::builder() + // identity + .document_id(meta.get("id").and_then(Value::as_str).unwrap_or(document_id)) + .workspace_id(meta.get("workspace_id").and_then(Value::as_str).unwrap_or("default")) + // head + .title(meta.get("title").and_then(Value::as_str).unwrap_or("无标题")) + .updated_at(meta.get("updated_at").cloned().unwrap_or(Value::Null)) + .read_only(meta.get("can_edit").and_then(Value::as_bool).map(|can_edit| !can_edit).unwrap_or(false)) + .disable_download(meta.get("disable_download").and_then(Value::as_bool).unwrap_or(false)) + .disable_copy(meta.get("disable_copy").and_then(Value::as_bool).unwrap_or(false)) + // layout + .wide_layout(meta.get("wide_layout").and_then(Value::as_bool).unwrap_or(false)) + .small_text(meta.get("use_small_text").and_then(Value::as_bool).unwrap_or(false)) + .show_heading_numbers(meta.get("show_heading_numbers").and_then(Value::as_bool).unwrap_or(true)) + .show_toc(meta.get("show_toc").and_then(Value::as_bool).unwrap_or(false)) + .show_structure(meta.get("show_structure").and_then(Value::as_bool).unwrap_or(false)) + .protect_editing(meta.get("protect_editing").and_then(Value::as_bool).unwrap_or(false)) + .show_word_count(meta.get("show_word_count").and_then(Value::as_bool).unwrap_or(true)) + .collapse_backlinks(meta.get("collapse_backlinks").and_then(Value::as_bool).unwrap_or(false)) + .page_font(meta.get("page_font").and_then(Value::as_str).unwrap_or("default")) + .layout_density(meta.get("layout_density").and_then(Value::as_str).unwrap_or("normal")) + .hide_child_pages(meta.get("hide_child_pages").and_then(Value::as_bool).unwrap_or(false)) + .show_block_ref_count(meta.get("show_block_ref_count").and_then(Value::as_bool).unwrap_or(false)) + .embed_default_block_id(meta.get("embed_default_block_id").cloned().unwrap_or(Value::Null)) + // body + .content(content.get("content").cloned().unwrap_or(Value::Null)) + .revision(content.get("revision").cloned().unwrap_or(Value::Null)) + .conflict_detection_key(conflict_detection_key) + // tree + .page_subtree(page_subtree) + // stats + .word_count(meta.get("word_count").and_then(Value::as_u64).unwrap_or(0)) + .character_count(meta.get("character_count").and_then(Value::as_u64).unwrap_or(0)) + .block_count(meta.get("block_count").and_then(Value::as_u64).unwrap_or(0)) + .todo_total(todo_total) + .todo_done(todo_done) + .build()) +} + + +fn stamp_recent_page_cookie(headers: &mut HeaderMap, document_id: &str) { + let value = document_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') { + ch + } else { + '_' + } + }) + .collect::(); + if value.is_empty() { + return; + } + let cookie = format!("{COOKIE_RECENT_PAGE_ID}={value}; Path=/; SameSite=Lax"); + if let Ok(value) = HeaderValue::from_str(&cookie) { + headers.append(header::SET_COOKIE, value); + } +} + +fn stamp_shell_headers(headers: &mut HeaderMap, shell: &'static str) { + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_SHELL.as_bytes()) { + headers.insert(name, HeaderValue::from_static(shell)); + } +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn escape_script_json(value: &str) -> String { + value.replace(", + default_workspace_name: &str, +) -> WorkspaceShellProjection { + let spec = ProjectionSnapshotSpec { + workspace_id, + root_node_id: None, + depth: Some(99), + projection: KernelProjectionKind::SidebarTree, + query: None, + max_results: None, + }; + let dataset = load_projection_snapshot(config, context, &spec) + .await + .map(|snapshot| snapshot.dataset) + .unwrap_or_else(|_| { + let documents = active_document_id + .map(|document_id| { + json!([{ + "id": document_id, + "workspace_id": workspace_id, + "title": "个人", + "parent_id": null, + "sort_order": 0, + "is_starred": false + }]) + }) + .unwrap_or_else(|| json!([])); + json!({ + "active_workspace_id": workspace_id, + "active_page_id": active_document_id, + "workspaces": [{ "id": workspace_id, "name": default_workspace_name }], + "documents": documents + }) + }); + + build_workspace_shell_projection( + &dataset, + workspace_id, + active_document_id, + default_workspace_name, + ) +} + +/// 加载侧栏页面树 HTML(SSR) +/// +/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。 +/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。 +/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。 +pub(crate) async fn load_sidebar_tree_html( + config: &AppConfig, + context: &RequestContext, + workspace_id: &str, + active_document_id: Option<&str>, +) -> Option { + let spec = ProjectionSnapshotSpec { + workspace_id, + root_node_id: None, + depth: Some(99), + projection: KernelProjectionKind::SidebarTree, + query: None, + max_results: None, + }; + let result = match load_projection_snapshot(config, context, &spec).await { + Ok(snapshot) => Some(snapshot.projection), + Err(_) if config.allow_dev_fixtures => { + // Dev 模式降级:使用内建示例页面树数据集 + let dev_dataset = serde_json::json!({ + "active_workspace_id": workspace_id, + "documents": [ + { "id": "dev_welcome", "workspace_id": workspace_id, "title": "欢迎使用 MNOTE", "parent_id": null, "sort_order": 0 }, + { "id": "dev_guide", "workspace_id": workspace_id, "title": "使用指南", "parent_id": "dev_welcome", "sort_order": 1 }, + { "id": "dev_api", "workspace_id": workspace_id, "title": "API 文档", "parent_id": "dev_welcome", "sort_order": 2 }, + ], + "trashed_documents": [], + "media_assets": [], + "trashed_media_assets": [], + "mindmap_assets": [], + "trashed_mindmap_assets": [], + "table_assets": [], + "trashed_table_assets": [], + "mindmap_docs": [], + "mindmap_asset_children": {} + }); + execute_kernel_query(context, workspace_id, projection_query(&spec), dev_dataset).ok() + } + Err(_) => None, + }; + result.map(|projection| { + let rows = collect_page_tree_render_rows(&projection); + render_initial_page_tree_html(&PageTreeInitialRenderInput { + rows, + active_node_id: active_document_id.map(ToOwned::to_owned), + focused_node_id: None, + }) + }) +} + +#[cfg(test)] +mod tests { + use crate::app::{build_app, AppConfig, AppState}; + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use serde_json::Value; + use tower::util::ServiceExt; + + fn app() -> axum::Router { + build_app(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: true, + enable_debug_shell_routes: false, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: true, + query_fixtures_json: Some( + r#"{ + "documents:getMeta": { + "id": "doc_1", + "workspace_id": "ws_demo", + "title": "服务端页面", + "updated_at": "2026-04-18T09:30:00Z", + "can_edit": true, + "word_count": 42, + "character_count": 128, + "block_count": 3 + }, + "documents:getContent": { + "content": [{"id": "block_1", "type": "paragraph", "content": []}], + "revision": 7, + "conflict_detection_key": "doc_1:7", + "pageSubtree": {"rootNodeId": "doc_1", "outline": []} + } + }"# + .into(), + ), + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn document_shell_returns_page_aggregate_snapshot() { + let response = app() + .oneshot( + Request::builder() + .uri("/documents/doc_1?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("x-mnote-web-owner") + .and_then(|value| value.to_str().ok()), + Some("mnote-web") + ); + assert_eq!( + response + .headers() + .get("x-mnote-web-shell") + .and_then(|value| value.to_str().ok()), + Some("document") + ); + assert!(response + .headers() + .get_all("set-cookie") + .iter() + .any(|value| value + .to_str() + .unwrap_or_default() + .contains("mnote_recent_page_id=doc_1"))); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let html = String::from_utf8(body.to_vec()).expect("html"); + assert!(html.contains("data-testid=\"wolai-sidebar\"")); + assert!(html.contains("data-testid=\"wolai-topbar\"")); + assert!(html.contains("data-testid=\"wolai-floating-ai\"")); + assert!(html.contains("星标置顶")); + assert!(html.contains("我的页面")); + assert!(html.contains("垃圾箱")); + assert!(html.contains("模板中心")); + assert!(html.contains("data-page-aggregate-snapshot=\"mnote.page_aggregate.v1\"")); + assert!(html.contains("data-editor-host=\"leptos_tiptap_island\"")); + assert!(!html.contains("mnote-web-document-shell")); + } + + #[tokio::test] + async fn page_aggregate_endpoint_returns_snapshot_contract() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/page-aggregate/doc_1?workspaceId=ws_demo") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["owner"], "mnote-web"); + assert_eq!(payload["schema"], "mnote.page_aggregate.v1"); + assert_eq!(payload["result"]["identity"]["documentId"], "doc_1"); + assert_eq!(payload["result"]["body"]["revision"], 7); + } +} diff --git a/rust/crates/mnote-web/src/routes/ws.rs b/rust/crates/mnote-web/src/routes/ws.rs index efbc61e1..876a9225 100644 --- a/rust/crates/mnote-web/src/routes/ws.rs +++ b/rust/crates/mnote-web/src/routes/ws.rs @@ -1,12 +1,12 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::stream_support::{StreamSnapshotQuery, load_stream_snapshot}; +use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery}; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::extract::{Extension, Query, State}; use axum::response::Response; use futures_util::StreamExt; -use serde_json::{Value, json}; +use serde_json::{json, Value}; pub async fn socket( ws: WebSocketUpgrade, diff --git a/rust/crates/mnote-web/src/ssr/mod.rs b/rust/crates/mnote-web/src/ssr/mod.rs new file mode 100644 index 00000000..e327c3c0 --- /dev/null +++ b/rust/crates/mnote-web/src/ssr/mod.rs @@ -0,0 +1,66 @@ +//! SSR (Server-Side Rendering) 模块 +//! +//! 提供 Leptos 0.8 的 SSR 渲染基础设施。 +//! 使用 `RenderHtml::to_html_with_buf` 将 Leptos view 渲染为 HTML 字符串。 + +pub mod pages; +pub mod styles; + +pub use styles::MNOTE_CSS; + +use leptos::prelude::*; +use leptos::tachys::view::Position; + +/// 将任何实现 `RenderHtml` 的视图渲染为 HTML 字符串 (SSR) +/// +/// 使用 Leptos 0.8 的 `RenderHtml::to_html_with_buf` 方法, +/// 通过 `Position::default()` (即 FirstChild) 初始化位置状态。 +pub fn render_view(view: impl RenderHtml) -> String { + let mut buf = String::new(); + let mut position = Position::default(); + RenderHtml::to_html_with_buf( + view, + &mut buf, + &mut position, + true, // escape — 对 HTML 特殊字符进行转义 + false, // mark_branches — 不标记分支注释 + vec![], // extra_attrs — 无需额外属性 + ); + buf +} + +#[cfg(test)] +mod tests { + use super::*; + use leptos::view; + + #[test] + fn render_view_produces_non_empty_html() { + let html = render_view(view! {

"Hello SSR"

}); + assert!(!html.is_empty()); + assert!(html.contains("Hello SSR")); + assert!(html.contains("

")); + } + + #[test] + fn render_view_escapes_html_special_chars() { + let html = render_view(view! {

""

}); + assert!(html.contains("<")); + assert!(!html.contains(" +"##; + +/// MNOTE Wolai 风格页面布局 +/// +/// 包含左侧栏 + 内容区的双栏布局。 +/// 侧栏显示品牌、导航链接和可选的页面树。 +/// +/// # 用法 +/// +/// ```ignore +/// view! { +/// +///
...
+///
+/// } +/// ``` +#[component] +pub fn PageLayout( + children: Children, + current_nav: &'static str, + /// 侧栏页面树 HTML(可选),由路由 handler 渲染 + #[prop(optional)] + sidebar_tree_html: Option, + /// 工作区名称(可选),显示在侧栏顶部 + #[prop(optional)] + workspace_name: Option, + /// workspace shell 侧栏 sections HTML(可选),由 projection 渲染 + #[prop(optional)] + workspace_sidebar_html: Option, +) -> impl IntoView { + let sidebar_tree_html = sidebar_tree_html.unwrap_or_default(); + + let ws_name = workspace_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "开发用户 的空间".to_string()); + let sidebar_sections_html = workspace_sidebar_html + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + let dataset = serde_json::json!({ + "workspaces": [{ "id": "default", "name": ws_name.clone() }], + "documents": [] + }); + let projection = crate::workspace_shell::build_workspace_shell_projection( + &dataset, + "default", + None, + &ws_name, + ); + crate::workspace_shell::render_workspace_shell_sidebar_html( + &projection, + Some(sidebar_tree_html.as_str()), + ) + }); + + view! { +
+ +
+
+
"☰""⌂"{"个人"}
+
+ "☆" + "▻" + "◴" + "⌬" + "♙+" + "◷" + "…" +
+
+
+ {children()} +
+
+ + +
+
+
+ } +} diff --git a/rust/crates/mnote-web/src/ssr/pages/mindmap.rs b/rust/crates/mnote-web/src/ssr/pages/mindmap.rs new file mode 100644 index 00000000..2605b476 --- /dev/null +++ b/rust/crates/mnote-web/src/ssr/pages/mindmap.rs @@ -0,0 +1,37 @@ +//! MNOTE 思维导图页面组件 (SSR) +//! +//! 提供思维导图页面的服务器端渲染壳。 +//! 外部手写 `` 包装和 `