feat: cut over rust web main shell

This commit is contained in:
lix-2026
2026-04-29 12:24:44 +08:00
parent 7965c6c107
commit 048fe28a4d
97 changed files with 9396 additions and 1263 deletions
+10 -6
View File
@@ -1,6 +1,6 @@
# MNOTE 当前架构梳理 # MNOTE 当前架构梳理
> 更新时间:2026-04-23 > 更新时间:2026-04-28
本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。 本文只描述当前仓库中真实成立的主线结构,以及当前最优先的架构收口点。
@@ -16,7 +16,7 @@
1. `tree-first graph kernel` 是长期对象真相层 1. `tree-first graph kernel` 是长期对象真相层
2. `Convex` 继续保留为当前自托管存储 / 实时 / 文件协作底座 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 streamNext App Router 已降为 legacy compat / island bundle source,不再是当前主入口
4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island 4. 文档页默认主编辑器已切到页面内 `leptos-tiptap` island
5. `BlockNote` 当前保留为 fallback / 对照链,不再代表默认主编辑器方向 5. `BlockNote` 当前保留为 fallback / 对照链,不再代表默认主编辑器方向
@@ -24,16 +24,20 @@
> **Rust 持有语义主导权,Convex 保留底座,前端逐步从重壳转向消费稳定 projection 与少量交互 island。** > **Rust 持有语义主导权,Convex 保留底座,前端逐步从重壳转向消费稳定 projection 与少量交互 island。**
补充口径:
> **主 Web 执行面当前以 `mnote-web` 为 3000 ownerNext App Router 只保留为 legacy compat、交互 island bundle source 与显式 debug/迁移辅助边界。**
## 2. 当前主线目录 ## 2. 当前主线目录
- `/mnt/Data1T/mnote/wolai-frontend/` - `/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/` - `/mnt/Data1T/mnote/rust/crates/core-protocol/`
Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。 Kernel 类型、projection 协议、编辑器协议、树 / 图核心术语。
- `/mnt/Data1T/mnote/rust/crates/bridge-runtime/` - `/mnt/Data1T/mnote/rust/crates/bridge-runtime/`
Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。 Kernel query / command、bridge transport、Tiptap <-> editor document 适配、命令解释层。
- `/mnt/Data1T/mnote/rust/crates/mnote-web/` - `/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/` - `/mnt/Data1T/mnote/wolai-frontend/convex/`
仍在使用的 Convex functions 与数据侧逻辑。 仍在使用的 Convex functions 与数据侧逻辑。
- `/mnt/Data1T/mnote/infra/convex/` - `/mnt/Data1T/mnote/infra/convex/`
@@ -204,11 +208,11 @@
- tree stream - tree stream
- preferred snapshot 选择 - 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 ## 7. Mindmap 与 OnlyOffice
@@ -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 2tree 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 3Hermes 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 迁移为前提,最终退役。**
@@ -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 streamNext 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 ownerNext 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 执行面 ownerNext 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<String>,
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/<id>` 响应头含 `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 输出首屏和初始结果 contractReact 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 双入口。
@@ -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 WebReact 只保留交互 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`
@@ -1,6 +1,6 @@
# 3-1 [process] Rust Web 长期架构实施清单 v2 # 3-1 [process] Rust Web 长期架构实施清单 v2
> 更新时间:2026-04-16 > 更新时间:2026-04-28
> >
> 基于以下实际状态重写: > 基于以下实际状态重写:
> - 当前未提交代码 > - 当前未提交代码
@@ -56,11 +56,11 @@
- [ ] `Phase 5` AI runtime 仍然很重,只是懒挂载了 - [ ] `Phase 5` AI runtime 仍然很重,只是懒挂载了
- [ ] `Phase 6` Mindmap 仍然是重前端交互壳,不是独立对象页壳 - [ ] `Phase 6` Mindmap 仍然是重前端交互壳,不是独立对象页壳
- [ ] `Phase 7` 文档页外围面板仍集中在 `DocumentContent` - [ ] `Phase 7` 文档页外围面板仍集中在 `DocumentContent`
- [ ] `Phase 8` 旧 Next/React 主路径完全没有完成切换 - [x] `Phase 8` 主入口已切到 `mnote-web`Next App Router 降为 legacy compat / island bundle source;彻底删除旧链仍留给后续清理
### 2.3 结论 ### 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 5 | AI 面板 bridge island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 Hermes,但 runtime 仍重,协议也未统一到真正“最小壳” |
| Phase 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 | | Phase 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 |
| Phase 7 | BlockNote 孤岛化 | `PARTIAL` | 阅读态已直接消费 `pageSubtree`,编辑器按需挂载,但外围 drawer/panel 仍集中在同一内容组件 | | 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:旧前端壳下线与兼容清理 ## 13. Phase 8:旧前端壳下线与兼容清理
**当前状态:`NOT_STARTED`** **当前状态:`PARTIAL`,主入口 owner cutover 已完成**
### 13.1 直接证据 ### 13.1 直接证据
- [x] 当前主应用入口仍是 Next App Router - [x] `3000` 默认 owner 已是 `mnote-web` Rust gateway。
- [layout.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/layout.tsx) - [x] `/auth``/documents/:id``/api/tree/events``/search``/api/hermes/bridge``/mindmap/:docId/:mindmapId` 已具备 Rust Web owner / shell / transport contract。
- [x] 当前主文档页仍是 Next 页面 - [x] `SKIP_NEXT_LEGACY=1` 可证明核心 shell 不依赖 Next legacy upstream。
- [page.tsx](/mnt/Data1T/mnote/wolai-frontend/src/app/(app)/documents/[id]/page.tsx) - [x] Next App Router 已降为 legacy compat / React island bundle source,不再是主 Web 入口。
- [x] 当前主搜索、主 Sidebar、主 Mindmap 页都仍在旧前端壳内
### 13.2 因此不能成立的说法 ### 13.2 不能成立的说法
- [ ]Rust Web 主路径切换已完成 - [ ]旧 Next/React 代码已经删除
- [ ]双栈收缩已完成 - [ ]所有重交互 runtime 都已迁出 React island
- [ ]旧 Next/React 页面壳已清理 - [ ]OnlyOffice、复杂编辑器与历史 debug 链都已完全退役
### 13.3 v2 后续任务 ### 13.3 v2 后续任务
- [ ] 先定义哪条真实流量先切到 `mnote-web` - [ ] 继续清理 legacy compat route 与旧 Next 页面。
- [ ] 先建立一条真实主路径,而不是只有 compat bridge - [ ] 为仍保留的 React islands 建立更细的删除 / 替换清单。
- [ ] 等真正有主路径后,再谈 Phase 8 清理 - [ ] 保持 `3104` 只在显式 legacy/debug 场景可见,不恢复为公开入口。
--- ---
@@ -466,7 +465,7 @@
- `Phase 5`:只完成轻量拆分 - `Phase 5`:只完成轻量拆分
- `Phase 6`:只完成独立页去耦第一步 - `Phase 6`:只完成独立页去耦第一步
- `Phase 7`:只完成阅读/编辑分离第一步 - `Phase 7`:只完成阅读/编辑分离第一步
- `Phase 8`尚未开始 - `Phase 8`主入口 owner cutover 已完成,旧链清理继续推进
因此,从当前实际代码出发,接下来最需要的不是继续宣布完成,而是: 因此,从当前实际代码出发,接下来最需要的不是继续宣布完成,而是:
@@ -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 主链
@@ -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 href="\/documents">文档<\/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<String>,
pub active_page_title: Option<String>,
pub starred_items: Vec<WorkspaceShellItem>,
pub my_page_items: Vec<WorkspaceShellItem>,
pub bottom_entries: Vec<WorkspaceShellEntry>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
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`
@@ -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/`
@@ -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` 正好能形成这套长期结构。**
+33
View File
@@ -86,3 +86,36 @@
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result metamnote-web 与 3000 同源 projection route 均透传该指标,4-16 无未勾选项。" [2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result metamnote-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] 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-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 WebHermes contract 固定 session/tool/client action ownerReact 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 legacydesktop-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
+367 -2
View File
@@ -663,10 +663,359 @@
} }
], ],
"completed_at": "2026-04-28T07:44:37Z" "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 streamNext /api/mnote-web/stream 保留 compat aliasconsumer 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 shellReact 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 WebReact runtime 标记为 island/legacy compattask116 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 owner3104 不恢复为公开入口",
"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 legacydesktop-hot 支持跳过 Next legacyARCHITECTURE/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, "session_count": 10,
"last_session": "2026-04-28T07:44:37Z", "last_session": "2026-04-28T12:50:07Z",
"final_renderer_gate": { "final_renderer_gate": {
"source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md", "source_doc": "design/04-tree-domain/process/4-18-tree-final-dom-shell-cutover-hard-gate-v1.md",
"status": "completed", "status": "completed",
@@ -682,5 +1031,21 @@
"task-017", "task-017",
"task-018" "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。"
} }
} }
+1 -35
View File
@@ -8450,16 +8450,6 @@ fn execute_command(
payload_json: request.payload_json, payload_json: request.payload_json,
args_json: json!({ args_json: json!({
"id": payload.document_id, "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", "documentId": "doc_1",
}), }),
json!({ json!({
"id": "doc_1", "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"
}
}
}
}), }),
), ),
]; ];
+20 -1
View File
@@ -11,6 +11,9 @@ pub struct AppConfig {
pub service_name: String, pub service_name: String,
pub service_version: String, pub service_version: String,
pub bind_addr: String, pub bind_addr: String,
pub public_bind_addr: String,
pub legacy_next_base_url: Option<String>,
pub enable_legacy_next_compat: bool,
pub enable_debug_shell_routes: bool, pub enable_debug_shell_routes: bool,
pub hermes_base_path: String, pub hermes_base_path: String,
pub compat_next_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_name: env::var("MNOTE_WEB_SERVICE_NAME").unwrap_or_else(|_| "mnote-web".into()),
service_version: env::var("MNOTE_WEB_SERVICE_VERSION") service_version: env::var("MNOTE_WEB_SERVICE_VERSION")
.unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()), .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").into()),
bind_addr: env::var("MNOTE_WEB_BIND").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") enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES")
.ok() .ok()
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) .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<String> { fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = env::var(key) { if let Ok(value) = env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string(); let trimmed = value.trim().trim_matches('"').to_string();
+1 -1
View File
@@ -1,8 +1,8 @@
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::Json;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::http::{HeaderName, HeaderValue}; use axum::http::{HeaderName, HeaderValue};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
+3
View File
@@ -2,8 +2,11 @@ pub mod app;
pub mod context; pub mod context;
pub mod error; pub mod error;
pub mod middleware; pub mod middleware;
pub mod page_aggregate;
pub mod routes; pub mod routes;
pub mod ssr;
pub mod transport; pub mod transport;
pub mod tree_shell; pub mod tree_shell;
pub mod workspace_shell;
pub use app::{build_app, AppConfig, AppState}; pub use app::{build_app, AppConfig, AppState};
+2 -1
View File
@@ -8,10 +8,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = AppConfig::from_env(); let config = AppConfig::from_env();
let bind_addr = config.bind_addr.clone(); let bind_addr = config.bind_addr.clone();
let public_bind_addr = config.public_bind_addr.clone();
let app = build_app(AppState::new(config)); let app = build_app(AppState::new(config));
let listener = TcpListener::bind(&bind_addr).await?; 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?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }
+136
View File
@@ -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,
}
@@ -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<String>) -> Self {
self.document_id = value.into();
self
}
pub fn workspace_id(mut self, value: impl Into<String>) -> Self {
self.workspace_id = value.into();
self
}
// ── Head ──
pub fn title(mut self, value: impl Into<String>) -> 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<String>) -> 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<String>) -> 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()
}
}
+6 -3
View File
@@ -4,12 +4,12 @@ use crate::error::WebError;
use crate::routes::query_support::{ use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id, execute_runtime_query_via_convex, resolve_effective_workspace_id,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{json, Value};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -152,7 +152,7 @@ pub async fn trace(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -162,6 +162,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
@@ -2,12 +2,12 @@ use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::transport::convex::{ 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::{ use bridge_runtime::{
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire, execute_runtime_input, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeCommandEnvelopeWire,
RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire, RuntimeCommandExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeSourceWire,
RuntimeTargetWire, execute_runtime_input, RuntimeTargetWire,
}; };
use serde_json::Value; use serde_json::Value;
+7 -4
View File
@@ -2,15 +2,15 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot}; use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use axum::Json;
use axum::extract::Query; use axum::extract::Query;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{json, Value};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -94,7 +94,7 @@ pub async fn next_sidebar(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -104,6 +104,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
+132 -69
View File
@@ -6,15 +6,15 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex, execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id, resolve_effective_workspace_id,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; 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::RuntimeQueryEnvelopeWire;
use bridge_runtime::{ use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{json, Value};
use std::fs; use std::fs;
use std::time::Duration; use std::time::Duration;
@@ -47,19 +47,34 @@ pub struct DocumentSaveRequest {
} }
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL"; 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<Value>) { fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMap, Json<Value>) {
let mut headers = HeaderMap::new();
stamp_documents_headers(&mut headers);
( (
StatusCode::OK, StatusCode::OK,
headers,
Json(json!({ Json(json!({
"ok": true, "ok": true,
"requestId": context.trace.request_id, "requestId": context.trace.request_id,
"traceId": context.trace.trace_id, "traceId": context.trace.trace_id,
"owner": "mnote-web",
"result": result, "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<String> { fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) { if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string(); let trimmed = value.trim().trim_matches('"').to_string();
@@ -99,13 +114,8 @@ fn next_documents_base_url() -> String {
.to_string() .to_string()
} }
fn should_proxy_via_next(context: &RequestContext) -> bool { fn should_proxy_via_next(_context: &RequestContext) -> bool {
context false
.auth
.cookie_header
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
} }
fn build_next_proxy_headers( 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<Value, WebError> {
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<Value, WebError> {
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( pub async fn meta(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>, Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = query.document_id.trim(); let result = load_document_meta_result(&state, &context, query).await?;
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?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -402,35 +459,8 @@ pub async fn content(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>, Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = query.document_id.trim(); let result = load_document_content_result(&state, &context, query).await?;
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?;
Ok(ok_response(&context, result)) Ok(ok_response(&context, result))
} }
@@ -438,7 +468,7 @@ pub async fn save(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>, Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim(); let document_id = body.document_id.trim();
if document_id.is_empty() { if document_id.is_empty() {
return Err( return Err(
@@ -500,9 +530,9 @@ pub async fn save(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::{Body, to_bytes}; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde_json::Value; use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -511,6 +541,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".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::<Uri>().expect("uri"),
&headers,
);
assert!(!super::should_proxy_via_next(&context));
}
#[tokio::test] #[tokio::test]
async fn documents_meta_route_returns_document_metadata() { async fn documents_api_meta_route_returns_document_metadata() {
let response = app() let response = app()
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -592,6 +641,20 @@ mod tests {
.expect("response"); .expect("response");
assert_eq!(response.status(), StatusCode::OK); 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) let body = to_bytes(response.into_body(), usize::MAX)
.await .await
.expect("body"); .expect("body");
@@ -603,7 +666,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn documents_content_route_returns_page_subtree() { async fn documents_api_content_route_returns_page_subtree() {
let response = app() let response = app()
.oneshot( .oneshot(
Request::builder() Request::builder()
+11 -8
View File
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::documents::{ 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::extract::{Extension, Json, Query, State};
use axum::http::{HeaderValue, StatusCode, header}; use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response}; use axum::response::{Html, IntoResponse, Response};
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession}; use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{json, Value};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -1681,7 +1681,7 @@ pub async fn document_editor_shell(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentEditorShellQuery>, Query(query): Query<DocumentEditorShellQuery>,
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let (_, meta_json) = document_meta( let (_, _, meta_json) = document_meta(
State(state.clone()), State(state.clone()),
Extension(context.clone()), Extension(context.clone()),
Query(DocumentMetaQuery { Query(DocumentMetaQuery {
@@ -1691,7 +1691,7 @@ pub async fn document_editor_shell(
) )
.await?; .await?;
let (_, content_json) = document_content( let (_, _, content_json) = document_content(
State(state), State(state),
Extension(context), Extension(context),
Query(DocumentContentQuery { Query(DocumentContentQuery {
@@ -1761,10 +1761,10 @@ pub async fn transform_runtime_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::{Body, to_bytes}; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::{Value, json}; use serde_json::{json, Value};
use tower::util::ServiceExt; use tower::util::ServiceExt;
fn app() -> axum::Router { fn app() -> axum::Router {
@@ -1772,6 +1772,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
+652
View File
@@ -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<String>,
legacy_next_compat_enabled: bool,
notes: Vec<&'static str>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RootEntryQuery {
page_id: Option<String>,
}
pub async fn gateway_health(State(state): State<AppState>) -> 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<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
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#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE Auth</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="auth">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
Ok(response)
}
pub async fn root_entry(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> 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! {
<crate::ssr::pages::home::HomePage sidebar_tree_html={sidebar_tree_html} workspace_name={workspace_name} workspace_sidebar_html={workspace_sidebar_html} />
});
let mut response = Html(format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>MNOTE</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
{}
</body>
</html>"#,
crate::ssr::MNOTE_CSS,
content
))
.into_response();
stamp_gateway_headers(response.headers_mut(), false);
response
}
pub async fn legacy_next_proxy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
) -> Result<Response, WebError> {
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<String> {
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#"<html><body><button>测试账号快速登录</button></body></html>"#)
})
.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#"<body data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">"#));
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#"<a href="/documents">文档</a>"#));
}
#[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::<Vec<_>>();
assert!(values
.iter()
.any(|value| value.contains("__convexAuthJWT=jwt-demo")));
assert!(values
.iter()
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo")));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::Json;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::Json;
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
+142 -7
View File
@@ -1,15 +1,18 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use axum::Json;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
use bridge_runtime::{ use bridge_runtime::{
RuntimeInput, build_failure_response, build_success_response, execute_runtime_input, build_failure_response, build_success_response, execute_runtime_input, execute_runtime_query,
execute_runtime_query, runtime_input_requests_result, runtime_input_requests_result, RuntimeInput,
}; };
use serde::Serialize; 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)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -37,7 +40,7 @@ pub async fn health(
pub async fn bridge_runtime( pub async fn bridge_runtime(
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
Json(runtime_input): Json<RuntimeInput>, Json(runtime_input): Json<RuntimeInput>,
) -> Result<(StatusCode, Json<Value>), WebError> { ) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let payload = if runtime_input_requests_result(&runtime_input) { let payload = if runtime_input_requests_result(&runtime_input) {
match execute_runtime_query(runtime_input) { match execute_runtime_query(runtime_input) {
Ok(result) => json!({ Ok(result) => json!({
@@ -45,12 +48,14 @@ pub async fn bridge_runtime(
"bridge": "hermes_runtime_result", "bridge": "hermes_runtime_result",
"requestId": context.trace.request_id, "requestId": context.trace.request_id,
"traceId": context.trace.trace_id, "traceId": context.trace.trace_id,
"contract": ai_bridge_contract(),
"result": result, "result": result,
}), }),
Err(error) => { Err(error) => {
let failure = build_failure_response(error); let failure = build_failure_response(error);
return Ok(( return Ok((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")), Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")),
)); ));
} }
@@ -64,6 +69,7 @@ pub async fn bridge_runtime(
"bridge": "hermes_runtime_plan", "bridge": "hermes_runtime_plan",
"requestId": context.trace.request_id, "requestId": context.trace.request_id,
"traceId": context.trace.trace_id, "traceId": context.trace.trace_id,
"contract": ai_bridge_contract(),
"plan": success.plan, "plan": success.plan,
}) })
} }
@@ -71,11 +77,140 @@ pub async fn bridge_runtime(
let failure = build_failure_response(error); let failure = build_failure_response(error);
return Ok(( return Ok((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
stamp_ai_bridge_headers(),
Json(serde_json::to_value(failure).expect("runtime failure 应可序列化")), 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");
}
} }
+14 -13
View File
@@ -3,16 +3,16 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
subtree_query, ProjectionSnapshotSpec,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelGraphDirection, KernelProjectionKind}; use core_protocol::{KernelGraphDirection, KernelProjectionKind};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{json, Value};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -188,8 +188,8 @@ pub async fn graph(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::{Body, to_bytes}; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use serde_json::Value; use serde_json::Value;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -199,6 +199,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".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"], item_by_row_id["asset-folder:mind_1"]["resourceMeta"]["resourceKind"],
"mindmap" "mindmap"
); );
assert!( assert!(item_by_row_id["asset-folder:mind_1"]["capabilities"]
item_by_row_id["asset-folder:mind_1"]["capabilities"] .as_array()
.as_array() .expect("capabilities")
.expect("capabilities") .iter()
.iter() .any(|value| value == "expand"));
.any(|value| value == "expand")
);
} }
#[tokio::test] #[tokio::test]
@@ -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<RequestContext>,
Path((doc_id, mindmap_id)): Path<(String, String)>,
) -> Result<Response, WebError> {
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! {
<MindmapPage
document_id={doc_id.clone()}
mindmap_id={mindmap_id.clone()}
/>
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>思维导图</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="mindmap" data-document-id="{}" data-mindmap-id="{}">
{}
<script id="__MNOTE_MINDMAP_SHELL__" type="application/json">{}</script>
</body>
</html>"#,
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
#[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 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"));
}
}
+29 -2
View File
@@ -3,19 +3,24 @@ mod command_support;
mod compat; mod compat;
mod documents; mod documents;
mod editor; mod editor;
mod gateway;
mod health; mod health;
mod hermes; mod hermes;
mod kernel; mod kernel;
mod mindmap_shell;
mod query_support; mod query_support;
mod search;
mod session;
mod snapshot_support; mod snapshot_support;
mod sse; mod sse;
mod stream_support; mod stream_support;
mod tree; mod tree;
mod web_shell;
mod ws; mod ws;
use crate::app::AppState; use crate::app::AppState;
use axum::Router;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::Router;
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone(); 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() let mut router = Router::new()
.route("/health", get(health::health)) .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/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content)) .route("/api/documents/content", get(documents::content))
.route("/api/documents/save", post(documents::save)) .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/workspace", get(bridge::workspace))
.route("/api/bridge/request", get(bridge::request)) .route("/api/bridge/request", get(bridge::request))
.route("/api/bridge/trace", get(bridge::trace)) .route("/api/bridge/trace", get(bridge::trace))
.route("/api/tree/events", get(sse::tree_events))
.route("/api/stream/events", get(sse::events)) .route("/api/stream/events", get(sse::events))
.route("/api/realtime/ws", get(ws::socket)) .route("/api/realtime/ws", get(ws::socket))
.nest( .nest(
@@ -65,7 +91,8 @@ pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/ai-agent/run", post(compat::next_ai_agent_run)) .route("/ai-agent/run", post(compat::next_ai_agent_run))
.route("/sidebar", get(compat::next_sidebar)), .route("/sidebar", get(compat::next_sidebar)),
); )
.fallback(gateway::legacy_next_proxy);
if enable_debug_shell_routes { if enable_debug_shell_routes {
router = router router = router
@@ -3,13 +3,13 @@ use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::transport::convex::execute_convex_query_plan; use crate::transport::convex::execute_convex_query_plan;
use bridge_runtime::{ use bridge_runtime::{
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput, execute_runtime_input, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire,
RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeSourceWire, execute_runtime_input, RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
execute_runtime_query, RuntimeSourceWire,
}; };
use core_protocol::{GetPageMeta, QueryEnvelope}; use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value; 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( pub fn resolve_effective_workspace_id(
context: &RequestContext, context: &RequestContext,
+333
View File
@@ -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<String>,
pub query: Option<String>,
pub document_id: Option<String>,
pub limit: Option<u32>,
pub filters: Option<SearchDocumentsFilters>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsFilters {
pub title_only: Option<bool>,
pub exact: Option<bool>,
pub include_ocr: Option<bool>,
pub only_current_page: Option<bool>,
pub time_range: Option<String>,
pub time_field: Option<String>,
pub custom_range: Option<SearchCustomRange>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchCustomRange {
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchShellQuery {
pub workspace_id: Option<String>,
pub q: Option<String>,
}
pub async fn shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<SearchShellQuery>,
) -> Result<Response, WebError> {
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! {
<SearchPage
workspace_id={workspace_id.to_string()}
search_query={search_query.to_string()}
sidebar_tree_html={sidebar_tree_html}
/>
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title></title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="search" data-search-shell-owner="rust-web">
{}
<script id="__MNOTE_SEARCH_SHELL__" type="application/json">{}</script>
</body>
</html>"#,
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<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<SearchDocumentsRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), 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("</script", "<\\/script")
}
fn stamp_search_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_QUERY_NAME.as_bytes()) {
headers.insert(name, HeaderValue::from_static("search.documents"));
}
}
#[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 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");
}
}
+175
View File
@@ -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<AppState>) -> 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<AppState>,
Extension(context): Extension<RequestContext>,
) -> 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<AppState>,
Extension(context): Extension<RequestContext>,
) -> 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<T>(payload: Json<T>) -> 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());
}
}
@@ -6,7 +6,7 @@ use crate::routes::query_support::{
}; };
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{KernelNodeType, KernelProjectionKind}; use core_protocol::{KernelNodeType, KernelProjectionKind};
use serde_json::{Value, json}; use serde_json::{json, Value};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProjectionSnapshotSpec<'a> { pub struct ProjectionSnapshotSpec<'a> {
+66 -4
View File
@@ -2,10 +2,12 @@ use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::stream_support::{ use crate::routes::stream_support::{
StreamChangeKind, StreamSnapshotQuery, build_stream_delta_payload, load_stream_overview, build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
StreamSnapshotQuery,
}; };
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream; use futures_util::stream;
use serde_json::Value; use serde_json::Value;
@@ -117,6 +119,28 @@ pub async fn events(
)) ))
} }
pub async fn tree_events(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<StreamSnapshotQuery>,
) -> Result<
(
HeaderMap,
Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>>,
),
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)] #[derive(Clone)]
struct StreamPollState { struct StreamPollState {
app_state: AppState, app_state: AppState,
@@ -137,8 +161,8 @@ fn stream_event(event_name: &str, payload: &Value) -> Event {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use axum::body::{Body, to_bytes}; use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -147,6 +171,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
@@ -183,4 +210,39 @@ mod tests {
assert!(text.contains("\"projection\":\"sidebar_tree\"")); assert!(text.contains("\"projection\":\"sidebar_tree\""));
assert!(text.contains("\"workspaceId\":\"ws_demo\"")); 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\""));
}
} }
@@ -5,13 +5,13 @@ use crate::routes::query_support::{
execute_runtime_query_via_convex, resolve_effective_workspace_id, execute_runtime_query_via_convex, resolve_effective_workspace_id,
}; };
use crate::routes::snapshot_support::{ use crate::routes::snapshot_support::{
ProjectionSnapshotSpec, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, execute_kernel_query, load_projection_snapshot, load_sidebar_dataset, subtree_query,
subtree_query, ProjectionSnapshotSpec,
}; };
use bridge_runtime::RuntimeQueryEnvelopeWire; use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{json, Value};
const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [ const TREE_STREAM_NOOP_COMMANDS: [&str; 6] = [
"page.body.save", "page.body.save",
@@ -588,8 +588,8 @@ pub async fn load_stream_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_change, resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind,
resolve_stream_cursor, resolve_stream_scope, StreamSnapshotQuery, StreamSnapshotScope,
}; };
use serde_json::json; use serde_json::json;
+201 -23
View File
@@ -5,34 +5,37 @@ use crate::routes::command_support::{
build_tree_target, ensure_non_empty, ensure_sort_order, build_tree_target, ensure_non_empty, ensure_sort_order,
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty, execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
}; };
use crate::routes::query_support::resolve_effective_workspace_id; use crate::routes::query_support::{
use crate::routes::snapshot_support::{ProjectionSnapshotSpec, load_projection_snapshot}; 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::{ 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::filetree_selection::FileTreeSelectionState;
use crate::tree_shell::page_renderer::{ 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::{ use crate::tree_shell::picker_renderer::{
PickerInitialRenderInput, PickerRenderRow, render_initial_picker_html, render_initial_picker_html, PickerInitialRenderInput, PickerRenderRow,
}; };
use crate::tree_shell::renderer_input::{ use crate::tree_shell::renderer_input::{
FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher, FileTreeRendererInput, PageTreeRendererInput, PickerRendererInput, TreeShellCommandDispatcher,
TreeShellRendererInput, TreeShellRendererInput,
}; };
use crate::tree_shell::runtime_api::{ use crate::tree_shell::runtime_api::{
TreeShellRuntimeRequest, TreeShellRuntimeResult, reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeRequest,
reduce_tree_shell_runtime as reduce_tree_shell_runtime_request, TreeShellRuntimeResult,
}; };
use axum::Json;
use axum::extract::{Extension, Query, State}; 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::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeCommandEnvelopeWire; use bridge_runtime::RuntimeCommandEnvelopeWire;
use core_protocol::KernelProjectionKind; use core_protocol::KernelProjectionKind;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{json, Value};
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -90,6 +93,10 @@ pub enum TreeCommandRequest {
parent_id: Option<String>, parent_id: Option<String>,
sort_order: i64, sort_order: i64,
}, },
Purge {
workspace_id: Option<String>,
document_id: String,
},
} }
fn escape_html(input: &str) -> String { fn escape_html(input: &str) -> String {
@@ -201,7 +208,7 @@ fn collect_expanded_ids(projection: &Value) -> BTreeSet<String> {
.unwrap_or_default() .unwrap_or_default()
} }
fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> { pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeRenderRow> {
projection projection
.get("items") .get("items")
.and_then(Value::as_array) .and_then(Value::as_array)
@@ -4467,9 +4474,98 @@ fn create_command_wire(
validate_only: false, 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<String, WebError> {
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( pub async fn tree_command(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -4504,6 +4600,10 @@ pub async fn tree_command(
parent_id: raw_request.parent_id, parent_id: raw_request.parent_id,
sort_order: raw_request.sort_order.unwrap_or(-1), 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 => { other => {
return Err(WebError::bad_request_code( return Err(WebError::bad_request_code(
"tree_command_validation", "tree_command_validation",
@@ -4517,6 +4617,7 @@ pub async fn tree_command(
TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Create { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(), TreeCommandRequest::Rename { workspace_id, .. } => workspace_id.as_deref(),
TreeCommandRequest::Move { 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) = let (action, requested_document_id, requested_parent_id, requested_title, requested_sort_order) =
match &request { match &request {
@@ -4553,10 +4654,27 @@ pub async fn tree_command(
None, None,
Some(*sort_order), Some(*sort_order),
), ),
TreeCommandRequest::Purge { document_id, .. } => (
"purge",
document_id.clone(),
None,
None,
None,
),
}; };
let effective_workspace_id = let effective_workspace_id = match &request {
resolve_effective_workspace_id(&context, requested_workspace_id, true)? TreeCommandRequest::Create { parent_id, .. } => {
.expect("workspace_required 已确保存在"); 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 command_wire = create_command_wire(&context, &effective_workspace_id, request)?;
let execution = execute_runtime_command_via_convex_with_artifacts( let execution = execute_runtime_command_via_convex_with_artifacts(
state.config(), state.config(),
@@ -4607,8 +4725,8 @@ pub async fn reduce_tree_shell_runtime(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{TreeCommandRequest, create_command_wire}; use super::{create_command_wire, TreeCommandRequest};
use crate::app::{AppConfig, AppState, build_app}; use crate::app::{build_app, AppConfig, AppState};
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::routes::command_support::build_runtime_command_plan; use crate::routes::command_support::build_runtime_command_plan;
use axum::body::Body; use axum::body::Body;
@@ -4621,6 +4739,9 @@ mod tests {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: true,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
@@ -4628,7 +4749,7 @@ mod tests {
convex_admin_key: None, convex_admin_key: None,
allow_dev_fixtures: true, 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()), 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_id: "dev-user".into(),
dev_user_name: "开发用户".into(), dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(), dev_user_email: "dev@mnote.local".into(),
@@ -4795,9 +4916,8 @@ mod tests {
assert!(filetree_html.contains( assert!(filetree_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
)); ));
assert!(filetree_html.contains( assert!(filetree_html
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"" .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
));
assert!(filetree_html.contains( assert!(filetree_html.contains(
"\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]" "\"outputChannels\":[\"commandDispatchEvent\",\"domPatch\",\"intentEvent\"]"
)); ));
@@ -4825,9 +4945,8 @@ mod tests {
assert!(picker_html.contains( assert!(picker_html.contains(
"\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\"" "\"wasmModuleUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime_bg.wasm\""
)); ));
assert!(picker_html.contains( assert!(picker_html
"\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\"" .contains("\"jsGlueUrl\":\"/api/tree-shell-runtime/mnote-tree-shell-runtime.js\""));
));
} }
#[tokio::test] #[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] #[tokio::test]
async fn tree_command_rejects_negative_sort_order() { async fn tree_command_rejects_negative_sort_order() {
let response = app() let response = app()
@@ -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<String>,
}
pub async fn document_page_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
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! {
<DocumentPage title={title.to_string()} sidebar_tree_html={sidebar_tree_html} />
});
let html = format!(
r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{}</title>
<style>{}</style>
</head>
<body data-mnote-web-owner="mnote-web" data-mnote-shell="document" data-document-id="{}">
{}
<script id="__MNOTE_PAGE_AGGREGATE__" type="application/json">{}</script>
</body>
</html>"#,
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<AppState>,
Extension(context): Extension<RequestContext>,
Path(document_id): Path<String>,
Query(query): Query<DocumentShellQuery>,
) -> Result<Response, WebError> {
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<PageAggregate, WebError> {
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::<String>();
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn escape_script_json(value: &str) -> String {
value.replace("</script", "<\\/script")
}
pub(crate) async fn load_workspace_shell_projection(
config: &AppConfig,
context: &RequestContext,
workspace_id: &str,
active_document_id: Option<&str>,
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,
)
}
/// 加载侧栏页面树 HTMLSSR
///
/// 从 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<String> {
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);
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; 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::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State}; use axum::extract::{Extension, Query, State};
use axum::response::Response; use axum::response::Response;
use futures_util::StreamExt; use futures_util::StreamExt;
use serde_json::{Value, json}; use serde_json::{json, Value};
pub async fn socket( pub async fn socket(
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
+66
View File
@@ -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! { <h1>"Hello SSR"</h1> });
assert!(!html.is_empty());
assert!(html.contains("Hello SSR"));
assert!(html.contains("<h1>"));
}
#[test]
fn render_view_escapes_html_special_chars() {
let html = render_view(view! { <p>"<script>alert('xss')</script>"</p> });
assert!(html.contains("&lt;"));
assert!(!html.contains("<script>"));
}
#[test]
fn render_view_renders_nested_elements() {
let html = render_view(view! {
<main>
<header><h1>"Title"</h1></header>
<section><p>"Content"</p></section>
</main>
});
assert!(html.contains("<main>"));
assert!(html.contains("<header>"));
assert!(html.contains("<h1>Title</h1>"));
assert!(html.contains("<section>"));
assert!(html.contains("<p>Content</p>"));
}
}
@@ -0,0 +1,21 @@
//! MNOTE 登录页面组件
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 登录页面
///
/// 当 legacy compat 关闭时由 `auth_entry` 路由使用。
#[component]
pub fn AuthPage() -> impl IntoView {
view! {
<PageLayout current_nav="home">
<section class="mnote-auth">
<h1>MNOTE </h1>
<p>
"此页面由 mnote-web gateway 提供。"
</p>
</section>
</PageLayout>
}
}
@@ -0,0 +1,38 @@
//! MNOTE 文档页面组件 (SSR)
//!
//! 提供文档编辑器的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
/// MNOTE 文档页面
///
/// 渲染文档编辑器的 SSR 壳结构:
/// - `<PageLayout current_nav="documents">`
/// - 标题区域
/// - 页面聚合快照标记
/// - 编辑器占位区
#[component]
pub fn DocumentPage(
/// 文档标题
title: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island">
<header class="document-shell-header">
<h1>{title}</h1>
</header>
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island"></section>
</main>
</PageLayout>
}
}
@@ -0,0 +1,36 @@
//! MNOTE 首页组件(Wolai 风格)
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 首页。
#[component]
pub fn HomePage(
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选)
#[prop(optional)]
workspace_sidebar_html: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="home" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()}>
<div class="mnote-home">
<div class="mnote-home-icon" aria-hidden="true">""</div>
<h1>"个人"</h1>
<div class="mnote-home-links">
<a href="/documents/backup"><span>""</span>"正版软件备份"</a>
<a href="/documents/growth"><span>""</span>"个人发展"</a>
<a href="/documents/notes"><span>""</span>"杂记"</a>
<a href="/documents/passwords"><span>""</span>"密码"</a>
<a href="/documents/wolai-guide"><span>""</span>"Wolai 教程"</a>
<a href="/documents/health"><span>""</span>"个人健康"</a>
<a href="/documents/software"><span>""</span>"软件开发"</a>
</div>
</div>
</PageLayout>
}
}
@@ -0,0 +1,148 @@
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
use leptos::prelude::*;
const SIDEBAR_TREE_JS: &str = r##"
<script>
(function(){
var tree = document.getElementById('sidebar-tree-root');
if (!tree) return;
// 高亮当前页
var currentPath = window.location.pathname;
var match = currentPath.match(/^\/documents\/([^\/]+)/);
if (match) {
var activeId = match[1];
var links = tree.querySelectorAll('[data-rust-action="open"]');
for (var i = 0; i < links.length; i++) {
if (links[i].getAttribute('data-node-id') === activeId) {
links[i].closest('.tree-row').setAttribute('data-active', 'true');
}
}
}
// 展开/折叠
tree.addEventListener('click', function(e) {
var btn = e.target.closest('[data-rust-action]');
if (!btn) return;
var nodeId = btn.getAttribute('data-node-id');
var action = btn.getAttribute('data-rust-action');
if (action === 'toggle') {
var row = btn.closest('.tree-row');
if (!row) return;
var li = row.parentElement;
var children = li.querySelector(':scope > .tree-children');
if (children) {
children.classList.toggle('tree-children--collapsed');
var isCollapsed = children.classList.contains('tree-children--collapsed');
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
btn.textContent = isCollapsed ? '▸' : '';
}
e.preventDefault();
} else if (action === 'open') {
window.location.href = '/documents/' + encodeURIComponent(nodeId);
e.preventDefault();
}
});
})();
</script>
"##;
/// MNOTE Wolai 风格页面布局
///
/// 包含左侧栏 + 内容区的双栏布局。
/// 侧栏显示品牌、导航链接和可选的页面树。
///
/// # 用法
///
/// ```ignore
/// view! {
/// <PageLayout current_nav="home" sidebar_tree_html={None}>
/// <section>...</section>
/// </PageLayout>
/// }
/// ```
#[component]
pub fn PageLayout(
children: Children,
current_nav: &'static str,
/// 侧栏页面树 HTML(可选),由路由 handler 渲染
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选),显示在侧栏顶部
#[prop(optional)]
workspace_name: Option<String>,
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
#[prop(optional)]
workspace_sidebar_html: Option<String>,
) -> 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! {
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
<div class="mnote-sidebar-header wolai-sidebar-header" data-testid="wolai-workspace-identity">
<a href="/" class="mnote-sidebar-brand wolai-avatar" aria-label="工作区首页">"L"</a>
<span class="sidebar-workspace-name">{ws_name}</span>
<span class="wolai-sidebar-chevron" aria-hidden="true">""</span>
</div>
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
<a href="/search" class:active={current_nav == "search"} title="搜索"><span class="nav-icon">""</span></a>
<a href="/graph" title="关系图"><span class="nav-icon">""</span></a>
<a href="/actions" title="快捷动作"><span class="nav-icon">""</span></a>
<a href="/inbox" title="收纳"><span class="nav-icon">""</span></a>
<a href="/files" title="文件"><span class="nav-icon">""</span></a>
<a href="/more" title="更多"><span class="nav-icon">""</span></a>
</nav>
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
</aside>
<div class="mnote-main">
<header class="wolai-topbar" data-testid="wolai-topbar">
<div class="wolai-topbar-left"><span class="wolai-menu-icon">""</span><span class="wolai-home-icon">""</span><span>{"个人"}</span></div>
<div class="wolai-topbar-actions" aria-label="页面操作">
<span title="收藏">""</span>
<span title="演示">""</span>
<span title="评论">""</span>
<span title="关系">""</span>
<span title="成员">"♙+"</span>
<span title="历史">""</span>
<span title="更多">""</span>
</div>
</header>
<article class="mnote-content">
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button">"AI"</button>
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button">"?"</button>
</div>
</div>
</div>
}
}
@@ -0,0 +1,37 @@
//! MNOTE 思维导图页面组件 (SSR)
//!
//! 提供思维导图页面的服务器端渲染壳。
//! 外部手写 `<body>` 包装和 `<script>` 数据嵌入由路由 handler 处理。
use leptos::prelude::*;
use super::layout::PageLayout;
/// MNOTE 思维导图页面
///
/// 渲染思维导图的 SSR 壳结构:
/// - `<main id="mnote-mindmap-shell" data-object-shell="mindmap">`
/// - 思维导图标题区域
/// - React 运行时挂载占位区
#[component]
pub fn MindmapPage(
/// 文档 ID
document_id: String,
/// 思维导图 ID
mindmap_id: String,
) -> impl IntoView {
view! {
<PageLayout current_nav="documents">
<main
id="mnote-mindmap-shell"
data-object-shell="mindmap"
data-document-id={document_id}
data-mindmap-id={mindmap_id}
>
<header>
<h1>{"思维导图"}</h1>
</header>
<section id="mnote-mindmap-island" data-react-island="mindmap_runtime"></section>
</main>
</PageLayout>
}
}
@@ -0,0 +1,8 @@
//! SSR 页面组件
pub mod auth;
pub mod document;
pub mod home;
pub mod layout;
pub mod mindmap;
pub mod search;
@@ -0,0 +1,80 @@
//! MNOTE 搜索页面组件 (SSR)
//!
//! 提供搜索页面的服务器端渲染壳。
//! 由 `<body>` 外层包装、搜索契约 JSON 嵌入脚本由路由 handler 处理。
use leptos::prelude::*;
use crate::ssr::pages::layout::PageLayout;
/// MNOTE 搜索页面
///
/// 渲染搜索页面的 SSR 壳结构:
/// - `<main id="mnote-search-shell" data-island-host="react_search_palette">`
/// - 搜索标题区域
/// - 搜索岛占位区
#[component]
pub fn SearchPage(
/// 工作区 ID
#[prop(into)]
workspace_id: String,
/// 搜索查询
#[prop(into)]
search_query: String,
/// 侧栏页面树 HTML(可选)
#[prop(optional)]
sidebar_tree_html: Option<String>,
/// 工作区名称(可选)
#[prop(optional)]
workspace_name: Option<String>,
) -> impl IntoView {
view! {
<PageLayout current_nav="search" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()}>
<main id="mnote-search-shell" data-island-host="react_search_palette">
<header class="search-header">
<h1>{"搜索"}</h1>
<p class="search-meta">
{format!("工作区: {}", workspace_id)}
</p>
<p class="search-query">
{if search_query.is_empty() {
"请输入关键词".to_string()
} else {
format!("查询: {}", search_query)
}}
</p>
</header>
<section id="mnote-search-island" data-react-island="search_palette"></section>
</main>
</PageLayout>
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ssr::render_view;
#[test]
fn search_page_renders_correct_structure() {
let html = render_view(view! {
<SearchPage workspace_id="ws_demo" search_query="Rust" />
});
assert!(html.contains("mnote-search-shell"));
assert!(html.contains("data-island-host=\"react_search_palette\""));
assert!(html.contains("mnote-search-island"));
assert!(html.contains("data-react-island=\"search_palette\""));
assert!(html.contains("搜索"));
}
#[test]
fn search_page_contains_layout() {
let html = render_view(view! {
<SearchPage workspace_id="ws_demo" search_query="" />
});
assert!(html.contains("mnote-shell"));
assert!(html.contains("mnote-sidebar"));
assert!(html.contains("mnote-sidebar-brand"));
assert!(html.contains("mnote-sidebar-nav"));
assert!(html.contains("mnote-content"));
}
}
+878
View File
@@ -0,0 +1,878 @@
//! MNOTE CSS 样式常量
//!
//! 为所有 Leptos SSR 页面提供 wolai / Notion 风格样式,
//! 匹配 3100 (Next.js) 前端的 wolai 设计系统。
/// MNOTE 完整 CSS 样式字符串
///
/// Wolai / Notion 风格:白色背景、近黑正文、浅色侧栏、极简边框。
/// 覆盖所有页面组件使用的 class 和 id。
pub const MNOTE_CSS: &str = r##"
/* ===== 基础重置 ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ===== Wolai 设计变量(来自设计稿) ===== */
:root {
/* 背景色 */
--color-basic-50: #F7F7F5;
--color-basic-75: #F0F0EE;
--color-basic-100: #EBEBEB;
--color-basic-200: #E3E3E0;
--color-basic-300: #D4D4D1;
--color-basic-400: #C4C4C1;
--color-basic-500: #A9A9A6;
--color-basic-600: #9B9A97;
--color-basic-700: #6D6D69;
--color-basic-800: #504F4B;
--color-basic-900: #37352F;
/* wolai 语义变量 */
--wolai-bg: #FFFFFF;
--wolai-bg-sidebar: var(--color-basic-50);
--wolai-bg-hover: rgba(55, 53, 47, 0.08);
--wolai-bg-active: rgba(55, 53, 47, 0.12);
--wolai-text-primary: var(--color-basic-900);
--wolai-text-secondary: var(--color-basic-600);
--wolai-border: #E9E9E8;
--wolai-brand: #22A559;
--wolai-accent: #2563eb;
--wolai-accent-hover: #1d4ed8;
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
--wolai-radius: 4px;
}
/* ===== 基础排版 ===== */
html, body {
height: 100%;
}
body {
font-family: var(--wolai-font-sans);
color: var(--wolai-text-primary);
background: var(--wolai-bg);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: var(--wolai-accent);
text-decoration: none;
cursor: pointer;
}
a:hover {
color: var(--wolai-accent-hover);
}
/* ===== 滚动条(悬停时显示) ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 4px;
}
:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
}
/* ===== 页面布局:侧栏 + 内容 ===== */
.mnote-shell {
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
background: var(--wolai-bg);
}
.wolai-workspace-shell {
--wolai-bg-sidebar: #F7F7F6;
--wolai-bg-selected: #F8E6E7;
--wolai-accent-red: #E0525B;
}
.wolai-sidebar {
width: 288px;
background: var(--wolai-bg-sidebar);
}
.wolai-sidebar-header {
height: 52px;
gap: 10px;
padding: 12px 16px 8px;
justify-content: flex-start;
}
.wolai-avatar {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: #D6545D;
color: #fff;
font-size: 15px;
font-weight: 700;
flex: 0 0 auto;
}
.wolai-sidebar-header .sidebar-workspace-name {
max-width: 190px;
color: #202124;
font-size: 16px;
font-weight: 650;
}
.wolai-sidebar-chevron {
margin-left: auto;
color: var(--wolai-text-secondary);
font-size: 16px;
}
.wolai-quick-actions {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
padding: 8px 12px 18px;
}
.wolai-quick-actions a {
height: 28px;
justify-content: center;
padding: 0;
margin: 0;
color: #4B4B4B;
}
.wolai-quick-actions a .nav-icon {
margin: 0;
}
.wolai-sidebar-section {
padding: 4px 8px 14px;
}
.wolai-section-title {
height: 28px;
display: flex;
align-items: center;
gap: 4px;
color: #B5B5B2;
font-size: 14px;
padding: 0 8px;
}
.wolai-section-icon {
color: #FFB33F;
}
.wolai-section-caret {
color: #B5B5B2;
}
.wolai-section-add {
margin-left: auto;
font-size: 22px;
color: #B5B5B2;
}
.wolai-page-row {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
padding: 0 12px;
color: #535353;
font-size: 17px;
line-height: 1.2;
}
.wolai-page-row:hover {
background: var(--wolai-bg-hover);
color: #202124;
}
.wolai-muted-row {
color: #747471;
}
.wolai-active-row {
background: var(--wolai-bg-selected);
color: var(--wolai-accent-red);
}
.wolai-row-icon {
width: 22px;
color: #111;
text-align: center;
flex: 0 0 auto;
}
.wolai-sidebar-footer {
margin-top: auto;
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid var(--wolai-border);
background: #FBFBFA;
}
.wolai-footer-entry {
height: 46px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #6D6D69;
font-size: 15px;
}
.wolai-footer-entry + .wolai-footer-entry {
border-left: 1px solid var(--wolai-border);
}
.wolai-topbar {
height: 46px;
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 auto;
padding: 0 18px;
color: #222;
font-size: 17px;
}
.wolai-topbar-left,
.wolai-topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.wolai-topbar-actions {
color: #222;
font-size: 20px;
}
.wolai-menu-icon {
color: #4A4A4A;
font-size: 22px;
}
.wolai-home-icon {
color: #000;
font-size: 24px;
font-weight: 700;
}
.wolai-floating-actions {
position: fixed;
right: 24px;
bottom: 24px;
display: flex;
flex-direction: column;
gap: 16px;
z-index: 20;
}
.wolai-floating-button {
width: 50px;
height: 50px;
border: 1px solid #ECECF1;
border-radius: 50%;
background: #fff;
color: #222;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
font-size: 18px;
cursor: pointer;
}
/* 侧栏导航 */
.mnote-sidebar {
width: 288px;
flex-shrink: 0;
background: var(--wolai-bg-sidebar);
border-right: 1px solid var(--wolai-border);
display: flex;
flex-direction: column;
overflow-y: auto;
user-select: none;
}
.mnote-sidebar-header {
padding: 14px 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.mnote-sidebar-brand {
font-size: 15px;
font-weight: 600;
color: var(--wolai-text-primary);
text-decoration: none;
}
.mnote-sidebar-brand:hover {
color: var(--wolai-text-primary);
}
.mnote-sidebar-nav {
flex: 1;
padding: 4px 8px;
}
.mnote-sidebar-nav a {
display: flex;
align-items: center;
height: 30px;
padding: 4px 8px;
margin-bottom: 2px;
border-radius: var(--wolai-radius);
font-size: 14px;
line-height: 1.4;
color: var(--wolai-text-primary);
text-decoration: none;
transition: none;
}
.mnote-sidebar-nav a:hover {
background: var(--wolai-bg-hover);
}
.mnote-sidebar-nav a.active {
background: var(--wolai-bg-active);
}
.mnote-sidebar-nav a .nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-right: 5px;
font-size: 18px;
flex-shrink: 0;
}
/* 工作区名称 */
.sidebar-workspace-name {
font-size: 12px;
color: var(--wolai-text-secondary);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 侧栏页面树区 */
.sidebar-tree-section {
border-top: 1px solid var(--wolai-border);
margin-top: 4px;
padding-top: 4px;
}
.sidebar-tree-divider {
display: none;
}
.sidebar-tree {
padding: 2px 0;
}
.sidebar-tree .tree-root {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-tree .tree-node {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-tree .tree-row {
display: flex;
align-items: center;
height: 30px;
padding: 0 8px;
border-radius: var(--wolai-radius);
cursor: pointer;
font-size: 14px;
color: var(--wolai-text-primary);
gap: 2px;
}
.sidebar-tree .tree-row:hover {
background: var(--wolai-bg-hover);
}
.sidebar-tree .tree-row[data-active="true"] {
background: var(--wolai-bg-active);
}
.sidebar-tree .tree-row[data-active="true"] .tree-link-title {
font-weight: 500;
}
.sidebar-tree .tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
border: none;
background: none;
cursor: pointer;
font-size: 12px;
color: var(--wolai-text-secondary);
padding: 0;
border-radius: 3px;
}
.sidebar-tree .tree-toggle:hover {
background: var(--wolai-bg-hover);
}
.sidebar-tree .tree-spacer {
display: inline-flex;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.sidebar-tree .tree-kind-badge {
display: none;
}
.sidebar-tree .tree-link {
flex: 1;
display: flex;
align-items: center;
border: none;
background: none;
cursor: pointer;
padding: 0 4px;
font-size: 14px;
color: var(--wolai-text-primary);
overflow: hidden;
min-width: 0;
}
.sidebar-tree .tree-link-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-tree .tree-actions {
display: none;
gap: 2px;
flex-shrink: 0;
}
.sidebar-tree .tree-row:hover .tree-actions {
display: flex;
}
.sidebar-tree .tree-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
color: var(--wolai-text-secondary);
border-radius: 3px;
padding: 0;
}
.sidebar-tree .tree-action:hover {
background: var(--wolai-bg-hover);
}
.sidebar-tree .tree-children {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-tree .tree-children--collapsed {
display: none;
}
/* 树行缩进 — 通过嵌套深度自动偏移 */
.sidebar-tree .tree-node .tree-children .tree-row {
padding-left: 28px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-row {
padding-left: 48px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-row {
padding-left: 68px;
}
.sidebar-tree .tree-node .tree-children .tree-children .tree-children .tree-children .tree-row {
padding-left: 88px;
}
/* 内容区域 */
.mnote-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wolai-bg);
}
.mnote-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* ===== 首页 ===== */
.mnote-home {
max-width: 720px;
margin: 0 auto;
padding: 72px 64px;
}
.mnote-home-icon {
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 22px;
color: #000;
font-size: 92px;
line-height: 1;
}
.mnote-home h1 {
text-align: center;
font-size: 44px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 34px;
color: var(--wolai-text-primary);
}
.mnote-home p {
font-size: 16px;
color: var(--wolai-text-secondary);
line-height: 1.6;
margin-bottom: 32px;
}
.mnote-home-links {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;
margin: 0 auto;
}
.mnote-home-links a {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
border-radius: var(--wolai-radius);
font-size: 21px;
font-weight: 650;
color: var(--wolai-text-primary);
text-decoration: none;
transition: background-color 80ms ease;
}
.mnote-home-links a:hover {
background: var(--wolai-bg-hover);
}
.mnote-home-links a::before {
content: none;
}
.mnote-home-links a span {
width: 26px;
text-align: center;
color: #111;
}
/* ===== 文档壳 ===== */
.document-shell {
width: 100%;
min-height: 100vh;
margin: 0 auto;
}
.document-shell-header {
padding: 48px 0 0;
margin: 0 auto 8px;
max-width: 760px;
}
.document-shell-header h1 {
font-size: 40px;
font-weight: 700;
line-height: 1.2;
color: var(--wolai-text-primary);
word-break: break-word;
padding: 3px 0;
}
#mnote-editor-island {
margin: 0 15px;
max-width: 760px;
width: calc(100% - 30px);
min-height: 300px;
padding-bottom: 48px;
}
#mnote-editor-island .bn-editor {
padding: 0;
max-width: none;
margin: 0;
}
/* ===== 搜索壳 ===== */
#mnote-search-shell {
max-width: 800px;
min-height: 100vh;
margin: 0 auto;
padding: 48px 64px;
}
#mnote-search-shell header {
margin-bottom: 24px;
}
#mnote-search-shell h1 {
font-size: 32px;
font-weight: 700;
color: var(--wolai-text-primary);
margin-bottom: 4px;
}
.search-meta,
.search-query {
color: var(--wolai-text-secondary);
font-size: 14px;
}
#mnote-search-island {
min-height: 200px;
}
/* ===== 思维导图壳 ===== */
#mnote-mindmap-shell {
max-width: 1000px;
min-height: 100vh;
margin: 0 auto;
padding: 48px 64px;
}
#mnote-mindmap-shell header {
margin-bottom: 24px;
}
#mnote-mindmap-shell h1 {
font-size: 32px;
font-weight: 700;
color: var(--wolai-text-primary);
}
#mnote-mindmap-island {
min-height: 400px;
}
/* ===== 认证页面 ===== */
.mnote-auth {
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
background: var(--wolai-bg);
}
.mnote-auth-card {
text-align: center;
padding: 48px 32px;
max-width: 340px;
}
.mnote-auth-card h1 {
font-size: 32px;
font-weight: 700;
color: var(--wolai-text-primary);
margin-bottom: 8px;
}
.mnote-auth-card p {
color: var(--wolai-text-secondary);
font-size: 15px;
line-height: 1.5;
}
/* ===== ProseMirror 兼容 ===== */
.ProseMirror {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.ProseMirror pre {
white-space: pre-wrap;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.mnote-sidebar {
width: 200px;
}
.mnote-home {
padding: 48px 24px;
}
.mnote-home h1 {
font-size: 28px;
}
.document-shell-header {
padding: 24px 0 0;
max-width: 100%;
}
.document-shell-header h1 {
font-size: 28px;
}
#mnote-editor-island {
margin: 0 24px;
width: auto;
padding-bottom: 24px;
}
#mnote-search-shell,
#mnote-mindmap-shell {
padding: 24px;
}
#mnote-search-shell h1,
#mnote-mindmap-shell h1 {
font-size: 24px;
}
}
@media (max-width: 480px) {
.mnote-sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--wolai-border);
}
.mnote-shell {
flex-direction: column;
}
.mnote-home {
padding: 32px 16px;
}
.mnote-home h1 {
font-size: 24px;
}
.document-shell-header {
padding: 16px 0 0;
}
.document-shell-header h1 {
font-size: 24px;
}
#mnote-editor-island {
margin: 0 16px;
width: auto;
padding-bottom: 16px;
}
}
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mnote_css_contains_core_selectors() {
// Wolai 设计系统 CSS 变量
assert!(MNOTE_CSS.contains("--wolai-text-primary"));
assert!(MNOTE_CSS.contains("--wolai-text-secondary"));
assert!(MNOTE_CSS.contains("--wolai-border"));
assert!(MNOTE_CSS.contains("--wolai-bg-sidebar"));
assert!(MNOTE_CSS.contains("--wolai-accent"));
// 布局结构
assert!(MNOTE_CSS.contains(".mnote-shell"));
assert!(MNOTE_CSS.contains(".mnote-sidebar"));
assert!(MNOTE_CSS.contains(".mnote-main"));
assert!(MNOTE_CSS.contains(".mnote-content"));
}
#[test]
fn mnote_css_contains_shell_ids() {
assert!(MNOTE_CSS.contains(".document-shell"));
assert!(MNOTE_CSS.contains("#mnote-search-shell"));
assert!(MNOTE_CSS.contains("#mnote-mindmap-shell"));
assert!(MNOTE_CSS.contains("#mnote-editor-island"));
assert!(MNOTE_CSS.contains("#mnote-search-island"));
assert!(MNOTE_CSS.contains("#mnote-mindmap-island"));
}
#[test]
fn mnote_css_contains_responsive_breakpoints() {
assert!(MNOTE_CSS.contains("@media (max-width: 768px)"));
assert!(MNOTE_CSS.contains("@media (max-width: 480px)"));
}
#[test]
fn mnote_css_contains_scrollbar_styles() {
assert!(MNOTE_CSS.contains("::-webkit-scrollbar"));
assert!(MNOTE_CSS.contains("::-webkit-scrollbar-thumb"));
}
#[test]
fn mnote_css_contains_prosemirror_styles() {
assert!(MNOTE_CSS.contains(".ProseMirror"));
}
#[test]
fn mnote_css_is_reasonably_sized() {
// 至少 2000 字符才能包含完整样式
assert!(MNOTE_CSS.len() > 2000);
// 最多 20000 字符避免过于臃肿
assert!(MNOTE_CSS.len() < 20000);
}
}
+76 -13
View File
@@ -2,13 +2,14 @@ use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use bridge_runtime::{ use bridge_runtime::{
RuntimeBridgeContextWire, RuntimeCommandArtifactPlan, RuntimeCommandEnvelopeWire, build_runtime_command_artifact_plan, RuntimeBridgeContextWire, RuntimeCommandArtifactPlan,
RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan, build_runtime_command_artifact_plan, RuntimeCommandEnvelopeWire, RuntimeCommandExecutionPlan, RuntimeQueryExecutionPlan,
}; };
use serde_json::{Value, json}; use serde_json::{json, Value};
use std::fs; use std::fs;
use std::time::Duration; use std::time::Duration;
use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use base64::Engine;
const HEADER_REQUEST_ID: &str = "x-request-id"; const HEADER_REQUEST_ID: &str = "x-request-id";
const HEADER_TRACE_ID: &str = "x-trace-id"; const HEADER_TRACE_ID: &str = "x-trace-id";
@@ -17,6 +18,7 @@ const HEADER_SOURCE_CHANNEL: &str = "x-mnote-source-channel";
const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client"; const HEADER_SOURCE_CLIENT: &str = "x-mnote-source-client";
const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key"; const HEADER_IDEMPOTENCY_KEY: &str = "x-idempotency-key";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token"; const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
fn read_env_or_dotenv(key: &str) -> Option<String> { fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) { if let Ok(value) = std::env::var(key) {
@@ -60,6 +62,10 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
return Ok(authorization.to_string()); return Ok(authorization.to_string());
} }
if let Some(convex_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) {
return Ok(format!("Bearer {convex_token}"));
}
if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) { if let Some(convex_token) = extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN) {
return Ok(format!("Bearer {convex_token}")); return Ok(format!("Bearer {convex_token}"));
} }
@@ -78,12 +84,23 @@ fn build_authorization(config: &AppConfig, context: &RequestContext) -> Result<S
.with_header("x-upstream-service", "convex") .with_header("x-upstream-service", "convex")
})?; })?;
// 说明: let dev_user_id = config.dev_user_id.trim();
// - Next 侧 `setAdminAuth(adminKey)` 走的是纯 admin auth,而不是伪造用户身份。 if dev_user_id.is_empty() {
// - Convex 业务函数内部会在缺少真实 token 时自行回退到 DEV_USER_ID。 return Ok(format!("Convex {admin_key}"));
// - 这里若强行附带伪造 identity,会让 getAuthUserId(ctx) 命中一个未映射用户, }
// 反而绕过 DEV_USER_ID fallback,导致 workspace membership 校验失败。
Ok(format!("Convex {admin_key}")) // 说明:Rust Web 直接调用 Convex HTTP API 时没有 Next/Convex Auth cookie。
// 自托管开发态用 admin auth 携带 acting identity,让 @convex-dev/auth 的
// getAuthUserId(ctx) 能得到 DEV_USER_ID,从而和 Next 开发态免登录语义一致。
let identity = json!({
"subject": format!("{}|mnote-web-dev-session", dev_user_id),
"issuer": "mnote-web-dev",
"name": config.dev_user_name,
"email": config.dev_user_email,
});
let identity_encoded = base64::engine::general_purpose::STANDARD
.encode(identity.to_string().as_bytes());
Ok(format!("Convex {admin_key}:{identity_encoded}"))
} }
fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> { fn extract_cookie_value(context: &RequestContext, name: &str) -> Option<String> {
@@ -459,7 +476,7 @@ fn now_iso_like() -> String {
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into()) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
} }
async fn execute_convex_mutation_by_name( pub async fn execute_convex_mutation_by_name(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
function_name: &str, function_name: &str,
@@ -699,12 +716,16 @@ mod tests {
use crate::app::AppConfig; use crate::app::AppConfig;
use crate::context::RequestContext; use crate::context::RequestContext;
use axum::http::{HeaderMap, HeaderValue, Method, Uri}; use axum::http::{HeaderMap, HeaderValue, Method, Uri};
use base64::Engine;
fn config() -> AppConfig { fn config() -> AppConfig {
AppConfig { AppConfig {
service_name: "mnote-web".into(), service_name: "mnote-web".into(),
service_version: "0.1.0".into(), service_version: "0.1.0".into(),
bind_addr: "127.0.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, enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(), hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(), compat_next_base_path: "/api/compat/next".into(),
@@ -744,11 +765,21 @@ mod tests {
} }
#[test] #[test]
fn build_authorization_falls_back_to_plain_admin_auth() { fn build_authorization_falls_back_to_dev_admin_identity() {
let authorization = build_authorization(&config(), &request_context(HeaderMap::new())) let authorization = build_authorization(&config(), &request_context(HeaderMap::new()))
.expect("authorization"); .expect("authorization");
assert_eq!(authorization, "Convex admin-demo"); assert!(authorization.starts_with("Convex admin-demo:"));
let encoded = authorization
.trim_start_matches("Convex admin-demo:")
.trim();
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("identity base64");
let identity: serde_json::Value = serde_json::from_slice(&decoded).expect("identity json");
assert_eq!(identity["subject"], "dev-user|mnote-web-dev-session");
assert_eq!(identity["issuer"], "mnote-web-dev");
assert_eq!(identity["email"], "dev@mnote.local");
} }
#[test] #[test]
@@ -766,4 +797,36 @@ mod tests {
assert_eq!(authorization, "Bearer token-from-cookie"); assert_eq!(authorization, "Bearer token-from-cookie");
} }
#[test]
fn build_authorization_prefers_convex_auth_jwt_over_legacy_handoff_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static(
"mnote_web_convex_token=legacy-token; __convexAuthJWT=jwt-from-convex-auth",
),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer jwt-from-convex-auth");
}
#[test]
fn build_authorization_reads_convex_auth_jwt_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
"cookie",
HeaderValue::from_static(
"foo=bar; __convexAuthJWT=jwt-from-convex-auth; __convexAuthRefreshToken=refresh",
),
);
let authorization =
build_authorization(&config(), &request_context(headers)).expect("authorization");
assert_eq!(authorization, "Bearer jwt-from-convex-auth");
}
} }
@@ -23,17 +23,4 @@ impl TreeShellCommandAction {
Self::Copy => "tree.subtree.copy", Self::Copy => "tree.subtree.copy",
} }
} }
pub fn compat_command_name(self) -> &'static str {
match self {
Self::Create => "documents.create",
Self::Rename => "documents.title.update",
Self::Move => "documents.move",
Self::Archive => "documents.delete",
Self::Restore => "documents.restore",
Self::Purge => "documents.purge",
Self::Embed => "documents.embed",
Self::Copy => "documents.copy_tree",
}
}
} }
@@ -545,9 +545,7 @@ mod tests {
copy: false, copy: false,
}, },
); );
assert!(rejected assert!(rejected.outputs.contains(&FileTreeRuntimeOutput::DomPatch));
.outputs
.contains(&FileTreeRuntimeOutput::DomPatch));
assert!(!rejected.outputs.iter().any(|output| matches!( assert!(!rejected.outputs.iter().any(|output| matches!(
output, output,
FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. }) FileTreeRuntimeOutput::Intent(FileTreeIntentEvent::InternalDrop { .. })
@@ -399,8 +399,8 @@ fn transition<const N: usize>(
mod tests { mod tests {
use super::{ use super::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeRow, PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment,
PageTreeRuntimeOutput, PageTreeRuntimeState, PageTreeRuntimeOutput, PageTreeRuntimeRow, PageTreeRuntimeState,
}; };
fn ids(values: &[&str]) -> Vec<String> { fn ids(values: &[&str]) -> Vec<String> {
@@ -247,12 +247,10 @@ mod tests {
}); });
assert_eq!(filetree.mode, TreeShellRendererMode::FileTree); assert_eq!(filetree.mode, TreeShellRendererMode::FileTree);
assert_eq!(filetree.focused_id.as_deref(), Some("asset:a")); assert_eq!(filetree.focused_id.as_deref(), Some("asset:a"));
assert!( assert!(filetree
filetree .filetree_selection
.filetree_selection .selected_row_ids
.selected_row_ids .contains("asset:a"));
.contains("asset:a")
);
assert!(filetree.page_focus_keyboard_reducer.is_none()); assert!(filetree.page_focus_keyboard_reducer.is_none());
assert_eq!( assert_eq!(
filetree filetree
@@ -334,74 +332,52 @@ mod tests {
assert!(artifact.runtime_api.state_snapshots.contains("fileTree")); assert!(artifact.runtime_api.state_snapshots.contains("fileTree"));
assert!(artifact.runtime_api.state_snapshots.contains("picker")); assert!(artifact.runtime_api.state_snapshots.contains("picker"));
assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState")); assert!(artifact.runtime_api.dom_patch_kinds.contains("pageState"));
assert!( assert!(artifact
artifact .runtime_api
.runtime_api .dom_patch_kinds
.dom_patch_kinds .contains("fileTreeState"));
.contains("fileTreeState")
);
assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState")); assert!(artifact.runtime_api.dom_patch_kinds.contains("pickerState"));
assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen")); assert!(artifact.runtime_api.host_event_kinds.contains("pageOpen"));
assert!( assert!(artifact
artifact .runtime_api
.runtime_api .host_event_kinds
.host_event_kinds .contains("fileTreeOpen"));
.contains("fileTreeOpen") assert!(artifact
); .runtime_api
assert!( .host_event_kinds
artifact .contains("fileTreeInternalDrop"));
.runtime_api assert!(artifact
.host_event_kinds .runtime_api
.contains("fileTreeInternalDrop") .host_event_kinds
); .contains("fileTreeExternalDrop"));
assert!( assert!(artifact
artifact .runtime_api
.runtime_api .host_event_kinds
.host_event_kinds .contains("pickerPickDocument"));
.contains("fileTreeExternalDrop") assert!(artifact
); .runtime_api
assert!( .command_event_kinds
artifact .contains("createNode"));
.runtime_api assert!(artifact
.host_event_kinds .runtime_api
.contains("pickerPickDocument") .command_event_kinds
); .contains("renameNode"));
assert!( assert!(artifact
artifact .runtime_api
.runtime_api .command_event_kinds
.command_event_kinds .contains("moveSubtree"));
.contains("createNode") assert!(artifact
); .runtime_api
assert!( .command_event_kinds
artifact .contains("copyResource"));
.runtime_api assert!(artifact
.command_event_kinds .runtime_api
.contains("renameNode") .command_event_kinds
); .contains("moveResource"));
assert!( assert!(artifact
artifact .runtime_api
.runtime_api .command_event_kinds
.command_event_kinds .contains("uploadResource"));
.contains("moveSubtree")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("copyResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("moveResource")
);
assert!(
artifact
.runtime_api
.command_event_kinds
.contains("uploadResource")
);
assert!(artifact.event_kinds.contains("focus")); assert!(artifact.event_kinds.contains("focus"));
assert!(artifact.event_kinds.contains("keyboard")); assert!(artifact.event_kinds.contains("keyboard"));
assert!(artifact.event_kinds.contains("expandCollapse")); assert!(artifact.event_kinds.contains("expandCollapse"));
@@ -4,9 +4,8 @@ use super::filetree_runtime::{
FileTreeRuntimeState, FileTreeRuntimeState,
}; };
use super::page_runtime::{ use super::page_runtime::{
PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeCommandDispatchEvent, PageTreeDropFeedback, PageTreeDropPosition, PageTreeIntentEvent,
PageTreeIntentEvent, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeAction, PageTreeRuntimeEnvironment, PageTreeRuntimeOutput, PageTreeRuntimeState,
PageTreeRuntimeOutput, PageTreeRuntimeState,
}; };
use super::picker_runtime::{ use super::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget, PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeOutput, PickerRuntimePickTarget,
@@ -393,6 +392,7 @@ mod tests {
reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent, reduce_tree_shell_runtime, TreeShellCommandEvent, TreeShellDomPatch, TreeShellHostEvent,
TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot, TreeShellRuntimeMode, TreeShellRuntimeRequest, TreeShellRuntimeStateSnapshot,
}; };
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use crate::tree_shell::filetree_runtime::{ use crate::tree_shell::filetree_runtime::{
FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow, FileTreeOpenTarget, FileTreeRuntimeAction, FileTreeRuntimeEnvironment, FileTreeRuntimeRow,
FileTreeRuntimeState, FileTreeRuntimeState,
@@ -407,7 +407,6 @@ mod tests {
use crate::tree_shell::picker_runtime::{ use crate::tree_shell::picker_runtime::{
PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState, PickerRuntimeAction, PickerRuntimeEnvironment, PickerRuntimeItem, PickerRuntimeState,
}; };
use crate::tree_shell::drag_drop_state::TreeShellDragEffect;
use serde_json::json; use serde_json::json;
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -0,0 +1,333 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellProjection {
pub schema: String,
pub workspace_id: String,
pub workspace_name: String,
pub active_page_id: Option<String>,
pub active_page_title: Option<String>,
pub starred_items: Vec<WorkspaceShellItem>,
pub my_page_items: Vec<WorkspaceShellItem>,
pub bottom_entries: Vec<WorkspaceShellEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellItem {
pub id: String,
pub title: String,
pub icon: Option<String>,
pub href: String,
pub depth: u32,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceShellEntry {
pub id: String,
pub label: String,
pub href: String,
pub icon: String,
}
pub fn build_workspace_shell_projection(
dataset: &Value,
workspace_id: &str,
active_page_id: Option<&str>,
default_workspace_name: &str,
) -> WorkspaceShellProjection {
let active_page_id = active_page_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let documents = dataset
.get("documents")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let workspace_name = resolve_workspace_name(dataset, workspace_id)
.unwrap_or_else(|| default_workspace_name.trim().to_string())
.if_empty_else(|| "个人空间".to_string());
let mut my_page_items = documents
.iter()
.filter_map(|document| document_to_item(document, workspace_id, None))
.collect::<Vec<_>>();
my_page_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_id = active_page_id
.or_else(|| {
dataset
.get("active_page_id")
.or_else(|| dataset.get("activePageId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.or_else(|| my_page_items.first().map(|item| item.id.clone()));
for item in &mut my_page_items {
item.active = active_page_id.as_deref().is_some_and(|active_id| active_id == item.id);
}
let mut starred_items = documents
.iter()
.filter(|document| {
document
.get("is_starred")
.or_else(|| document.get("isStarred"))
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|document| document_to_item(document, workspace_id, active_page_id.as_deref()))
.collect::<Vec<_>>();
starred_items.sort_by_key(|item| (item.depth, item.title.clone(), item.id.clone()));
let active_page_title = active_page_id.as_deref().and_then(|active_id| {
my_page_items
.iter()
.find(|item| item.id == active_id)
.map(|item| item.title.clone())
});
WorkspaceShellProjection {
schema: "mnote.workspace_shell.v1".into(),
workspace_id: workspace_id.to_string(),
workspace_name,
active_page_id,
active_page_title,
starred_items,
my_page_items,
bottom_entries: vec![
WorkspaceShellEntry {
id: "trash".into(),
label: "垃圾箱".into(),
href: "/trash".into(),
icon: "🗑".into(),
},
WorkspaceShellEntry {
id: "templates".into(),
label: "模板中心".into(),
href: "/templates".into(),
icon: "📦".into(),
},
],
}
}
fn resolve_workspace_name(dataset: &Value, workspace_id: &str) -> Option<String> {
dataset
.get("workspaces")
.and_then(Value::as_array)
.and_then(|workspaces| {
workspaces.iter().find_map(|workspace| {
let id = workspace
.get("id")
.or_else(|| workspace.get("workspace_id"))
.or_else(|| workspace.get("workspaceId"))
.and_then(Value::as_str)?;
if id != workspace_id {
return None;
}
workspace
.get("name")
.or_else(|| workspace.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
})
}
fn document_to_item(
document: &Value,
workspace_id: &str,
active_page_id: Option<&str>,
) -> Option<WorkspaceShellItem> {
let document_workspace_id = document
.get("workspace_id")
.or_else(|| document.get("workspaceId"))
.and_then(Value::as_str)
.unwrap_or(workspace_id);
if document_workspace_id != workspace_id {
return None;
}
if document
.get("deleted_at")
.or_else(|| document.get("deletedAt"))
.is_some_and(|value| !value.is_null())
{
return None;
}
let id = document
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let title = document
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("无标题");
let depth = document
.get("depth")
.and_then(Value::as_u64)
.or_else(|| {
document
.get("parent_id")
.or_else(|| document.get("parentId"))
.and_then(Value::as_str)
.map(|_| 1)
})
.unwrap_or(0) as u32;
Some(WorkspaceShellItem {
id: id.to_string(),
title: title.to_string(),
icon: document
.get("icon")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
href: format!("/documents/{id}?workspaceId={workspace_id}"),
depth,
active: active_page_id.is_some_and(|active_id| active_id == id),
})
}
trait StringEmptyExt {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String;
}
impl StringEmptyExt for String {
fn if_empty_else(self, fallback: impl FnOnce() -> String) -> String {
if self.trim().is_empty() {
fallback()
} else {
self
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn workspace_shell_projection_contains_sidebar_sections_and_bottom_entries() {
let dataset = json!({
"active_workspace_id": "ws_demo",
"workspaces": [{ "id": "ws_demo", "name": "开发用户 的工作区" }],
"documents": [
{ "id": "page_home", "workspace_id": "ws_demo", "title": "个人", "parent_id": null, "sort_order": 0, "is_starred": true },
{ "id": "page_child", "workspace_id": "ws_demo", "title": "软件开发", "parent_id": "page_home", "sort_order": 1, "is_starred": false }
]
});
let projection = build_workspace_shell_projection(
&dataset,
"ws_demo",
Some("page_home"),
"开发用户 的工作区",
);
assert_eq!(projection.schema, "mnote.workspace_shell.v1");
assert_eq!(projection.workspace_id, "ws_demo");
assert_eq!(projection.workspace_name, "开发用户 的工作区");
assert_eq!(projection.active_page_id.as_deref(), Some("page_home"));
assert_eq!(projection.active_page_title.as_deref(), Some("个人"));
assert_eq!(projection.starred_items.len(), 1);
assert_eq!(projection.starred_items[0].title, "个人");
assert_eq!(projection.my_page_items.len(), 2);
assert!(projection.my_page_items[0].active);
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "垃圾箱"));
assert!(projection.bottom_entries.iter().any(|entry| entry.label == "模板中心"));
}
}
pub fn render_workspace_shell_sidebar_html(
projection: &WorkspaceShellProjection,
sidebar_tree_html: Option<&str>,
) -> String {
let starred_rows = if projection.starred_items.is_empty() {
String::new()
} else {
projection
.starred_items
.iter()
.map(render_item_row)
.collect::<Vec<_>>()
.join("")
};
let projected_my_pages = projection
.my_page_items
.iter()
.map(render_item_row)
.collect::<Vec<_>>()
.join("");
let tree_html = sidebar_tree_html
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|html| {
format!(
r#"<div class="sidebar-tree-section"><div class="sidebar-tree-divider"></div><div id="sidebar-tree-root" class="sidebar-tree">{html}</div></div>"#
)
})
.unwrap_or_default();
let my_pages = if projected_my_pages.is_empty() {
tree_html
} else {
format!("{projected_my_pages}{tree_html}")
};
let bottom_entries = projection
.bottom_entries
.iter()
.map(|entry| {
format!(
r#"<a href="{}" class="wolai-footer-entry"><span>{}</span>{}</a>"#,
escape_html(&entry.href),
escape_html(&entry.icon),
escape_html(&entry.label),
)
})
.collect::<Vec<_>>()
.join("");
format!(
r#"<section class="wolai-sidebar-section wolai-starred-section" aria-label="星标置顶"><div class="wolai-section-title"><span class="wolai-section-icon">★</span>星标置顶<span class="wolai-section-caret">⌄</span></div>{starred_rows}</section><section class="wolai-sidebar-section wolai-my-pages-section" aria-label="我的页面"><div class="wolai-section-title"><span>我的页面</span><span class="wolai-section-caret">⌄</span><span class="wolai-section-add">+</span></div>{my_pages}</section><div class="wolai-sidebar-footer">{bottom_entries}</div>"#
)
}
fn render_item_row(item: &WorkspaceShellItem) -> String {
let active_class = if item.active { " wolai-active-row" } else { "" };
let depth_style = if item.depth == 0 {
String::new()
} else {
format!(r#" style="padding-left:{}px""#, 12 + item.depth.min(6) * 18)
};
format!(
r#"<a class="wolai-page-row{active_class}" href="{}" data-node-id="{}"{depth_style}><span class="wolai-row-icon">{}</span>{}</a>"#,
escape_html(&item.href),
escape_html(&item.id),
escape_html(item.icon.as_deref().unwrap_or("")),
escape_html(&item.title),
)
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}} {"rustc_fingerprint":9228011546279038255,"outputs":{"11652014622397750202":{"success":true,"status":"","code":0,"stdout":"___.wasm\nlib___.rlib\n___.wasm\nlib___.a\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\n___\ndebug_assertions\npanic=\"abort\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"wasm32\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"wasm\"\ntarget_feature=\"bulk-memory\"\ntarget_feature=\"multivalue\"\ntarget_feature=\"mutable-globals\"\ntarget_feature=\"nontrapping-fptoint\"\ntarget_feature=\"reference-types\"\ntarget_feature=\"sign-ext\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"unknown\"\ntarget_pointer_width=\"32\"\ntarget_vendor=\"unknown\"\n","stderr":"warning: dropping unsupported crate type `dylib` for target `wasm32-unknown-unknown`\n\nwarning: dropping unsupported crate type `proc-macro` for target `wasm32-unknown-unknown`\n\nwarning: 2 warnings emitted\n\n"},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}
+207 -110
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* 同时热启动前端FastAPI Celery * 同时热启动前端FastAPI Celery
* 可使用以下环境变量调整行为 * 可使用以下环境变量调整行为
* - FRONTEND_CMD覆盖前端启动命令默认为 "pnpm dev" * - FRONTEND_CMD覆盖前端启动命令默认为 "pnpm dev"
@@ -11,14 +11,17 @@
* - CELERY_BIN只在 CELERY_CMD 未覆盖时设置 Celery 可执行文件默认 "celery" * - CELERY_BIN只在 CELERY_CMD 未覆盖时设置 Celery 可执行文件默认 "celery"
* - REDIS_URL仅用于探测 Redis 是否就绪默认 "redis://localhost:6379/0" * - REDIS_URL仅用于探测 Redis 是否就绪默认 "redis://localhost:6379/0"
* - SKIP_CELERY设为 "1" or "true" 可跳过 Celery * - SKIP_CELERY设为 "1" or "true" 可跳过 Celery
* - MNOTE_WEB_SKIP_GATEWAY设为 "1" or "true" 临时恢复旧 Next 3000 入口
* - NEXT_LEGACY_PORTRust gateway 模式下 Next legacy upstream 端口默认 3100
* - SKIP_NEXT_LEGACY设为 "1" or "true" 时只启动 Rust gateway不启动 Next legacy upstream
*/ */
const { spawn, execSync } = require("child_process"); const { spawn, execSync } = require("child_process");
const path = require("path"); const path = require("path");
const net = require("net"); const net = require("net");
const { URL } = require("url"); const { URL } = require("url");
const fs = require("fs"); const fs = require("fs");
const rootDir = path.resolve(__dirname, ".."); const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend"); const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend"); const backendDir = path.join(rootDir, "wolai-backend");
@@ -59,22 +62,85 @@ const celeryCmdFromEnv = process.env.CELERY_CMD;
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0"; const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000); const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000);
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000); const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
const nextLegacyPortFromEnv = Number(process.env.NEXT_LEGACY_PORT || 3100);
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100);
const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY);
const skipNextLegacy = !skipGateway && isEnabledEnv(env.SKIP_NEXT_LEGACY);
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100;
const legacyUrl = `http://127.0.0.1:${legacyPort}`;
const legacyCompatEnabled = skipNextLegacy ? "0" : env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT || "1";
return {
skipGateway,
skipNextLegacy,
publicPort,
legacyPort,
publicUrl: `http://localhost:${publicPort}`,
legacyUrl,
frontendTaskName: skipGateway ? "frontend" : skipNextLegacy ? null : "next-legacy",
frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`,
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
mnoteWebEnv: skipGateway
? {}
: {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `127.0.0.1:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
...(skipNextLegacy
? {}
: { MNOTE_WEB_LEGACY_NEXT_BASE_URL: env.MNOTE_WEB_LEGACY_NEXT_BASE_URL || legacyUrl }),
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
},
};
}
const runtimePlan = resolveRuntimePlan(process.env);
const skipMnoteWebGateway = runtimePlan.skipGateway;
const frontendTasks = runtimePlan.frontendTaskName
? [
{
name: runtimePlan.frontendTaskName,
command: runtimePlan.frontendCommand,
cwd: frontendDir,
},
]
: [];
const tasks = [ const tasks = [
{ ...frontendTasks,
name: "frontend", ...(skipMnoteWebGateway
command: process.env.FRONTEND_CMD || "pnpm dev", ? []
cwd: frontendDir, : [
}, {
name: "mnote-web",
command:
process.env.MNOTE_WEB_CMD ||
runtimePlan.mnoteWebCommand,
cwd: path.join(rootDir, "rust"),
},
]),
{ {
name: "backend", name: "backend",
command: command:
process.env.BACKEND_CMD || process.env.BACKEND_CMD ||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`, `${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
cwd: backendDir, cwd: backendDir,
}, },
]; ];
function findTask(name) {
return tasks.find((task) => task.name === name);
}
const children = []; const children = [];
let shuttingDown = false; let shuttingDown = false;
@@ -288,16 +354,16 @@ function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {}; if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf8"); const content = fs.readFileSync(filePath, "utf8");
return content return content
.split(/\r?\n/) .split(/\r?\n/)
.filter((line) => line.trim() && !line.trim().startsWith("#")) .filter((line) => line.trim() && !line.trim().startsWith("#"))
.reduce((acc, line) => { .reduce((acc, line) => {
const idx = line.indexOf("="); const idx = line.indexOf("=");
if (idx === -1) return acc; if (idx === -1) return acc;
const key = line.slice(0, idx).trim(); const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim(); const value = line.slice(idx + 1).trim();
acc[key] = value; acc[key] = value;
return acc; return acc;
}, {}); }, {});
} }
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。 // 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
@@ -311,7 +377,7 @@ const mergedEnv = {
...process.env, ...process.env,
...envFromAll, ...envFromAll,
}; };
function logPrefix(name, message) { function logPrefix(name, message) {
console.log(`[${name}] ${message}`); console.log(`[${name}] ${message}`);
} }
@@ -330,31 +396,31 @@ function buildDefaultCeleryCommand() {
function startTask(task) { function startTask(task) {
logPrefix(task.name, `启动命令:${task.command}`); logPrefix(task.name, `启动命令:${task.command}`);
const child = spawn(task.command, { const child = spawn(task.command, {
cwd: task.cwd, cwd: task.cwd,
stdio: "inherit", stdio: "inherit",
shell: true, shell: true,
env: mergedEnv, env: mergedEnv,
}); });
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
if (shuttingDown) { if (shuttingDown) {
return; return;
} }
const status = const status =
signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`; signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`); logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0); shutdown(code ?? 0);
}); });
child.on("error", (err) => { child.on("error", (err) => {
logPrefix(task.name, `启动失败:${err.message}`); logPrefix(task.name, `启动失败:${err.message}`);
shutdown(1); shutdown(1);
}); });
children.push(child); children.push(child);
} }
function shutdown(code) { function shutdown(code) {
if (shuttingDown) { if (shuttingDown) {
return; return;
@@ -388,40 +454,40 @@ function shutdown(code) {
setTimeout(() => process.exit(code), 200); setTimeout(() => process.exit(code), 200);
} }
process.on("SIGINT", () => shutdown(0)); process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0));
async function checkRedisReachable(urlString, timeoutMs = 2000) { async function checkRedisReachable(urlString, timeoutMs = 2000) {
try { try {
const url = new URL(urlString); const url = new URL(urlString);
const host = url.hostname || "localhost"; const host = url.hostname || "localhost";
const port = Number(url.port) || 6379; const port = Number(url.port) || 6379;
return await new Promise((resolve) => { return await new Promise((resolve) => {
const socket = net.createConnection({ host, port }); const socket = net.createConnection({ host, port });
const timer = setTimeout(() => { const timer = setTimeout(() => {
socket.destroy(); socket.destroy();
resolve(false); resolve(false);
}, timeoutMs); }, timeoutMs);
socket.once("connect", () => { socket.once("connect", () => {
clearTimeout(timer); clearTimeout(timer);
socket.end(); socket.end();
resolve(true); resolve(true);
}); });
socket.once("error", () => { socket.once("error", () => {
clearTimeout(timer); clearTimeout(timer);
resolve(false); resolve(false);
}); });
}); });
} catch (error) { } catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`); logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true; return true;
} }
} }
async function main() { async function main() {
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。 // 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
// 这里在启动前做一次“安全清理”,避免重启后仍卡住。 // 这里在启动前做一次“安全清理”,避免重启后仍卡住。
@@ -437,8 +503,9 @@ async function main() {
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。 // 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。 // 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
const desiredFrontendPort = frontendPortFromEnv; const desiredFrontendPort = runtimePlan.publicPort;
const frontendPortOk = await ensurePortFree(desiredFrontendPort, "frontend"); const frontendOwnerName = skipMnoteWebGateway ? "frontend" : "mnote-web";
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
if (!frontendPortOk) { if (!frontendPortOk) {
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`); console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
process.exit(1); process.exit(1);
@@ -446,11 +513,36 @@ async function main() {
const frontendPort = desiredFrontendPort; const frontendPort = desiredFrontendPort;
const frontendUrl = `http://localhost:${frontendPort}`; const frontendUrl = `http://localhost:${frontendPort}`;
let nextLegacyPort = null;
if (!skipMnoteWebGateway) {
nextLegacyPort = runtimePlan.legacyPort;
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
if (!nextLegacyPortOk) {
console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`);
process.exit(1);
}
}
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。 // 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。 // 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
tasks[0].command = process.env.FRONTEND_CMD || `pnpm dev -p ${frontendPort}`; const frontendTask = skipMnoteWebGateway ? findTask("frontend") : findTask("next-legacy");
logPrefix("frontend", `前端目录:${frontendDir}`); if (!frontendTask) {
logPrefix("frontend", `前端地址:${frontendUrl}`); throw new Error("缺少前端任务配置");
}
frontendTask.command = runtimePlan.frontendCommand;
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
if (skipMnoteWebGateway) {
logPrefix("frontend", `前端地址:${frontendUrl}`);
} else {
const legacyUrl = runtimePlan.legacyUrl;
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
logPrefix("next-legacy", `Next legacy upstream${legacyUrl}`);
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
if (gatewayTask) {
gatewayTask.command = runtimePlan.mnoteWebCommand;
}
Object.assign(mergedEnv, runtimePlan.mnoteWebEnv);
}
const desiredBackendPort = backendPortFromEnv; const desiredBackendPort = backendPortFromEnv;
if (!process.env.BACKEND_CMD) { if (!process.env.BACKEND_CMD) {
@@ -459,7 +551,11 @@ async function main() {
console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`); console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`);
process.exit(1); process.exit(1);
} }
tasks[1].command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`; const backendTask = findTask("backend");
if (!backendTask) {
throw new Error("缺少后端任务配置");
}
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
} }
if (!skipCelery) { if (!skipCelery) {
@@ -480,23 +576,23 @@ async function main() {
tasks.push(celeryTask); tasks.push(celeryTask);
} else { } else {
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。 // Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
logPrefix( logPrefix(
"celery", "celery",
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过。`, `检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过。`,
); );
} }
} }
if (tasks.length === 0) { if (tasks.length === 0) {
console.error("未配置任何可运行的任务,检查环境变量设置。"); console.error("未配置任何可运行的任务,检查环境变量设置。");
process.exit(1); process.exit(1);
} }
for (const task of tasks) { for (const task of tasks) {
startTask(task); startTask(task);
} }
} }
if (require.main === module) { if (require.main === module) {
main().catch((error) => { main().catch((error) => {
logPrefix("system", `启动失败:${error.message}`); logPrefix("system", `启动失败:${error.message}`);
@@ -509,6 +605,7 @@ module.exports = {
getListeningPidsByPort, getListeningPidsByPort,
getProcessNameByPid, getProcessNameByPid,
isPortFree, isPortFree,
resolveRuntimePlan,
resolveBackendExecutable, resolveBackendExecutable,
terminatePid, terminatePid,
}; };
+69 -1
View File
@@ -2,7 +2,12 @@ const assert = require("node:assert");
const { spawn } = require("node:child_process"); const { spawn } = require("node:child_process");
const net = require("node:net"); const net = require("node:net");
const { test } = require("node:test"); const { test } = require("node:test");
const { resolveBackendExecutable, ensurePortFree, isPortFree } = require("./desktop-hot.js"); const {
resolveBackendExecutable,
ensurePortFree,
isPortFree,
resolveRuntimePlan,
} = require("./desktop-hot.js");
function findFreePort() { function findFreePort() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -108,3 +113,66 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
assert.equal(await isPortFree("127.0.0.1", port), true); assert.equal(await isPortFree("127.0.0.1", port), true);
await waitForExit(child); await waitForExit(child);
}); });
test("默认热启动计划使用 mnote-web 作为 3000 ownerNext 仅作为 legacy upstream", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100",
});
assert.equal(plan.skipGateway, false);
assert.equal(plan.publicPort, 3000);
assert.equal(plan.legacyPort, 3100);
assert.equal(plan.frontendTaskName, "next-legacy");
assert.equal(plan.frontendCommand, "pnpm dev -p 3100");
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
assert.deepEqual(plan.mnoteWebEnv, {
MNOTE_WEB_BIND: "127.0.0.1:3000",
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: "1",
});
});
test("默认任务数组顺序变化时后端命令不应覆盖 mnote-web gateway", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100",
BACKEND_CMD: "/custom/backend",
});
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
assert.equal(plan.frontendCommand, "pnpm dev -p 3100");
});
test("MNOTE_WEB_SKIP_GATEWAY 可临时恢复旧 Next 3000 入口", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100",
MNOTE_WEB_SKIP_GATEWAY: "1",
});
assert.equal(plan.skipGateway, true);
assert.equal(plan.frontendTaskName, "frontend");
assert.equal(plan.frontendCommand, "pnpm dev -p 3000");
assert.deepEqual(plan.mnoteWebEnv, {});
});
test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next legacy upstream", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100",
SKIP_NEXT_LEGACY: "1",
});
assert.equal(plan.skipGateway, false);
assert.equal(plan.skipNextLegacy, true);
assert.equal(plan.frontendTaskName, null);
assert.equal(plan.mnoteWebCommand, "cargo run -p mnote-web --bin mnote-web");
assert.deepEqual(plan.mnoteWebEnv, {
MNOTE_WEB_BIND: "127.0.0.1:3000",
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
});
});
@@ -2,12 +2,13 @@
// 说明: // 说明:
// - 这份 smoke 用来覆盖 tree rust family checklist 第 9 节的通用回归要求。 // - 这份 smoke 用来覆盖 tree rust family checklist 第 9 节的通用回归要求。
// - 宿主页负责验证真实导航、上下文菜单、picker 对话框与 stream fallback // - legacy React island 负责验证真实导航、上下文菜单、picker 对话框3000 Rust gateway 负责 API 与 stream。
// - `/api/tree/shell` 直连页已退到显式 debug/internal 边界,默认不再进入这条链路。 // - `/api/tree/shell` 直连页已退到显式 debug/internal 边界,默认不再进入这条链路。
const { chromium } = require("playwright"); const { chromium } = require("playwright");
const { const {
BASE_URL, BASE_URL,
DOCUMENT_UI_BASE_URL,
UI_TIMEOUT_MS, UI_TIMEOUT_MS,
assert, assert,
createTempDocument, createTempDocument,
@@ -25,6 +26,7 @@ const RUN_DIRECT_TREE_SHELL_CHECKS =
String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1"; String(process.env.MNOTE_TREE_SHELL_DIRECT_SMOKE || "").trim() === "1";
const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST = const ALLOW_LEGACY_TREE_SHELL_IFRAME_HOST =
String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1"; String(process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST || "").trim() === "1";
const CHROMIUM_STABLE_ARGS = ["--disable-dev-shm-usage", "--disable-gpu"];
function buildTreeShellUrl(workspaceId, params = {}) { function buildTreeShellUrl(workspaceId, params = {}) {
const search = new URLSearchParams({ const search = new URLSearchParams({
@@ -302,10 +304,19 @@ async function openPageTreeContextMenuFromHost(page, driver, documentId) {
} else { } else {
const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`); const row = driver.scope.locator(`.tree-row[data-shell-mode="page"][data-node-id="${documentId}"]`);
await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await row.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await row.locator('[data-testid="tree-action-menu"]').click({ timeout: UI_TIMEOUT_MS, force: true }); const menuButton = row.locator('[data-testid="tree-action-menu"]');
await menuButton.waitFor({ state: "attached", timeout: UI_TIMEOUT_MS });
await menuButton.evaluate((button) => {
if (!(button instanceof HTMLButtonElement)) {
throw new Error("页面树更多操作按钮不存在");
}
button.click();
});
} }
await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await page.getByText("重命名", { exact: true }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.keyboard.press("Escape"); await page.evaluate(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
});
} }
async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) { async function doubleClickFileTreeDocumentFromHost(page, driver, documentId) {
@@ -874,6 +885,11 @@ async function runFileTreeHostDoubleClickCheck(context, fixture) {
} }
} }
function isPlaywrightTargetCrash(error) {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Target crashed");
}
async function waitForPickerEmptyState(page, dialog, pickerUsesIframe) { async function waitForPickerEmptyState(page, dialog, pickerUsesIframe) {
if (!pickerUsesIframe) { if (!pickerUsesIframe) {
const emptyText = dialog.getByText("没有匹配结果"); const emptyText = dialog.getByText("没有匹配结果");
@@ -1113,56 +1129,29 @@ async function runPickerDialogChecks(context, fixture) {
} }
async function runStreamFallbackCheck(context, fixture) { async function runStreamFallbackCheck(context, fixture) {
const page = await context.newPage(); const response = await context.request.fetch(
let streamRequestCount = 0; `${BASE_URL}/api/tree/events?workspaceId=${encodeURIComponent(fixture.workspaceId)}&maxPolls=0`,
const snapshot = await requestJson(context.request, `/api/sidebar?workspaceId=${encodeURIComponent(fixture.workspaceId)}`); { timeout: UI_TIMEOUT_MS },
);
const body = await response.text();
assert(response.ok(), `/api/tree/events 请求失败: ${response.status()} ${body.slice(0, 200)}`);
assert(
(response.headers()["content-type"] || "").includes("text/event-stream"),
"/api/tree/events 未返回 SSE 内容类型",
);
assert(response.headers()["x-mnote-web-owner"] === "mnote-web", "/api/tree/events 缺少 mnote-web owner header");
assert(
response.headers()["x-mnote-tree-stream-owner"] === "rust-web",
"/api/tree/events 缺少 rust-web stream owner header",
);
assert(body.includes("event: snapshot") || body.includes("event:snapshot"), "/api/tree/events 未返回 snapshot event");
assert(body.includes('"kind":"snapshot"'), "/api/tree/events snapshot payload 缺少 kind=snapshot");
await page.route("**/api/mnote-web/stream**", async (route) => { return {
streamRequestCount += 1; streamFallback: false,
await route.fulfill({ rustTreeEvents: true,
status: 200, streamRequestCount: 1,
headers: { };
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
},
body: buildSnapshotSseBody(snapshot, fixture.workspaceId),
});
});
try {
await openDocument(page, fixture.workspaceId, fixture.parentId);
await waitForPageTitleInput(page);
await page.waitForTimeout(600);
assert(streamRequestCount > 0, "stream fallback smoke 未命中 /api/mnote-web/stream");
const sidebarRefetchResponse = page.waitForResponse(
(response) =>
response.url().includes(`/api/sidebar?workspaceId=${encodeURIComponent(fixture.workspaceId)}`) &&
response.request().method() === "GET" &&
response.ok(),
{ timeout: UI_TIMEOUT_MS },
);
await renameThroughPageHead(page, fixture.parentId, fixture.fallbackTitle);
await sidebarRefetchResponse;
await waitForBreadcrumbTitle(page, fixture.fallbackTitle);
await page.waitForFunction(
(expectedTitle) => {
const input = document.querySelector('input[aria-label="页面标题"]');
return input instanceof HTMLInputElement && input.value.includes(expectedTitle);
},
fixture.fallbackTitle,
{ timeout: UI_TIMEOUT_MS },
);
return {
streamFallback: true,
streamRequestCount,
fallbackTitle: fixture.fallbackTitle,
};
} finally {
await page.close().catch(() => undefined);
}
} }
async function main() { async function main() {
@@ -1172,11 +1161,12 @@ async function main() {
: process.env.MNOTE_SMOKE_HEADLESS === "0" : process.env.MNOTE_SMOKE_HEADLESS === "0"
? false ? false
: !process.env.DISPLAY; : !process.env.DISPLAY;
const browser = await chromium.launch({ headless }); const browser = await chromium.launch({ headless, args: CHROMIUM_STABLE_ARGS });
const context = await browser.newContext({ const browserContextOptions = {
viewport: { width: 1440, height: 960 }, viewport: { width: 1440, height: 960 },
}); };
const page = await context.newPage(); let context = await browser.newContext(browserContextOptions);
let page = await context.newPage();
let fixture = null; let fixture = null;
let caughtError = null; let caughtError = null;
@@ -1186,13 +1176,35 @@ async function main() {
fixture = await prepareFixture(context.request); fixture = await prepareFixture(context.request);
const pageTreeHost = await runPageTreeHostChecks(page, fixture); const pageTreeHost = await runPageTreeHostChecks(page, fixture);
const storageState = await context.storageState();
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
context = await browser.newContext({
...browserContextOptions,
storageState,
});
page = null;
const pageTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS const pageTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
? await runPageTreeShellChecks(context, fixture) ? await runPageTreeShellChecks(context, fixture)
: { skipped: true, reason: "direct_tree_shell_debug_disabled" }; : { skipped: true, reason: "direct_tree_shell_debug_disabled" };
const fileTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS const fileTreeShell = RUN_DIRECT_TREE_SHELL_CHECKS
? await runFileTreeShellChecks(context, fixture) ? await runFileTreeShellChecks(context, fixture)
: { skipped: true, reason: "direct_tree_shell_debug_disabled" }; : { skipped: true, reason: "direct_tree_shell_debug_disabled" };
const fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture); let fileTreeHost;
try {
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
} catch (error) {
if (!isPlaywrightTargetCrash(error)) {
throw error;
}
await context.close().catch(() => undefined);
context = await browser.newContext({
...browserContextOptions,
storageState,
});
fileTreeHost = await runFileTreeHostDoubleClickCheck(context, fixture);
fileTreeHost.recoveredFromRendererCrash = true;
}
const picker = await runPickerDialogChecks(context, fixture); const picker = await runPickerDialogChecks(context, fixture);
const streamFallback = await runStreamFallbackCheck(context, fixture); const streamFallback = await runStreamFallbackCheck(context, fixture);
@@ -1201,6 +1213,7 @@ async function main() {
{ {
ok: true, ok: true,
baseUrl: BASE_URL, baseUrl: BASE_URL,
documentBaseUrl: DOCUMENT_UI_BASE_URL,
viewerUserId: viewer.userId, viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId, workspaceId: fixture.workspaceId,
parentId: fixture.parentId, parentId: fixture.parentId,
@@ -1241,7 +1254,9 @@ async function main() {
} }
} }
await page.close().catch(() => undefined); if (page) {
await page.close().catch(() => undefined);
}
await context.close().catch(() => undefined); await context.close().catch(() => undefined);
await browser.close().catch(() => undefined); await browser.close().catch(() => undefined);
} }
@@ -0,0 +1,179 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const net = require("node:net");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
const EXTERNAL_BASE_URL = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, "");
function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("无法分配测试端口")));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function fetchWithTimeout(url, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error(`请求超时: ${url}`)), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, {
redirect: "manual",
cache: "no-store",
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
async function waitForGateway(baseUrl) {
const deadline = Date.now() + REQUEST_TIMEOUT_MS;
let lastError = null;
while (Date.now() < deadline) {
try {
const response = await fetchWithTimeout(`${baseUrl}/api/gateway/health`);
if (response.ok && response.headers.get("x-mnote-web-owner") === "mnote-web") {
return;
}
lastError = new Error(`gateway health 未就绪: ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw lastError || new Error("gateway health 未就绪");
}
function startGateway(port) {
const env = {
...process.env,
...buildDocumentFixtureEnv(),
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
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: "1",
};
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env,
stdio: ["ignore", "pipe", "pipe"],
});
}
function buildDocumentFixtureEnv() {
return {
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
"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: [] },
},
}),
};
}
async function validateGateway(baseUrl) {
const health = await fetchWithTimeout(`${baseUrl}/api/gateway/health`);
const healthPayload = await health.json();
assert.equal(health.status, 200, `gateway health 失败: ${health.status}`);
assert.equal(health.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(healthPayload.owner, "mnote-web");
assert.equal(healthPayload.publicEntry, "127.0.0.1:3000");
const auth = await fetchWithTimeout(`${baseUrl}/auth`);
const authText = await auth.text();
assert.equal(auth.status, 200, `/auth 失败: ${auth.status}`);
assert.equal(auth.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(auth.headers.get("content-type") || "", /text\/html/);
const legacyUpstream = auth.headers.get("x-mnote-legacy-upstream");
if (legacyUpstream === "next-app-router") {
assert.match(authText, /Wolai Clone|测试账号快速登录|加载中/);
} else {
assert.match(authText, /data-mnote-web-owner="mnote-web"/);
}
const root = await fetchWithTimeout(`${baseUrl}/`);
const rootText = await root.text();
assert.equal(root.status, 200, `/ 失败: ${root.status}`);
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(rootText, /data-mnote-shell="workspace"/);
}
async function main() {
let gateway = null;
let baseUrl = EXTERNAL_BASE_URL;
if (!baseUrl) {
const port = await findFreePort();
baseUrl = `http://127.0.0.1:${port}`;
gateway = startGateway(port);
await waitForGateway(baseUrl);
}
try {
await validateGateway(baseUrl);
console.log(
JSON.stringify(
{
ok: true,
baseUrl,
owner: "mnote-web",
publicEntry: "3000",
legacy3104: "not-required",
},
null,
2,
),
);
} finally {
if (gateway && gateway.pid) {
gateway.kill("SIGTERM");
setTimeout(() => {
if (!gateway.killed) {
gateway.kill("SIGKILL");
}
}, 2000).unref();
}
}
}
module.exports = {
buildDocumentFixtureEnv,
fetchWithTimeout,
findFreePort,
startGateway,
validateGateway,
waitForGateway,
};
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,168 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const {
fetchWithTimeout,
findFreePort,
startGateway,
waitForGateway,
} = require("./task114-rust-web-gateway-entry-smoke.js");
const EXTERNAL_BASE_URL = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, "");
const CONFIGURED_DOCUMENT_ID = (process.env.MNOTE_DOCUMENT_ID || "").trim();
const CONFIGURED_WORKSPACE_ID = (process.env.MNOTE_WORKSPACE_ID || "").trim();
const CONFIGURED_TITLE = (process.env.MNOTE_DOCUMENT_TITLE || "").trim();
async function readJsonResponse(response, label) {
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
throw new Error(`${label} 返回了非 JSON 内容: ${text.slice(0, 1200)}`);
}
assert(response.ok, `${label} 请求失败: ${response.status}; body: ${text.slice(0, 1200)}`);
return payload;
}
async function createTempDocument(baseUrl) {
const title = `task115-rust-web-${Date.now()}`;
const requestBody = {
action: "create",
title,
};
if (CONFIGURED_WORKSPACE_ID) {
requestBody.workspaceId = CONFIGURED_WORKSPACE_ID;
}
const response = await fetchWithTimeout(`${baseUrl}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
});
const payload = await readJsonResponse(response, "创建临时文档");
const result = payload && typeof payload.result === "object" ? payload.result : null;
const documentId = result && typeof result.documentId === "string" ? result.documentId : "";
const workspaceId = result && typeof result.workspaceId === "string" ? result.workspaceId : "";
assert(documentId, "创建临时文档缺少 documentId");
assert(workspaceId, "创建临时文档缺少 workspaceId");
return { documentId, workspaceId, title };
}
async function purgeTempDocument(baseUrl, target) {
const response = await fetchWithTimeout(`${baseUrl}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "purge",
workspaceId: target.workspaceId,
documentId: target.documentId,
}),
});
await readJsonResponse(response, "清理临时文档");
}
async function validateDocumentShell(baseUrl, target) {
const targetPath = `/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await fetchWithTimeout(`${baseUrl}${targetPath}`);
const html = await response.text();
assert.equal(response.status, 200, `文档 shell 请求失败: ${response.status}; body: ${html.slice(0, 1200)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(response.headers.get("x-mnote-web-shell"), "document");
assert.match(response.headers.get("content-type") || "", /text\/html/);
assert.match(html, /data-mnote-shell="document"/);
assert.match(html, /data-testid="wolai-sidebar"/);
assert.match(html, /data-testid="wolai-topbar"/);
assert.match(html, /data-testid="wolai-floating-ai"/);
assert.match(html, /data-page-aggregate-snapshot="mnote\.page_aggregate\.v1"/);
assert.match(html, /id="__MNOTE_PAGE_AGGREGATE__"/);
assert.match(html, /data-editor-host="leptos_tiptap_island"/);
assert.doesNotMatch(html, /mnote-web-document-shell/);
assert.doesNotMatch(html, /convex_upstream_error|未登录/);
const aggregate = await fetchWithTimeout(
`${baseUrl}/api/page-aggregate/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
);
const aggregateText = await aggregate.text();
assert.equal(aggregate.status, 200, `page aggregate 请求失败: ${aggregate.status}; body: ${aggregateText.slice(0, 1200)}`);
const payload = JSON.parse(aggregateText);
assert.equal(aggregate.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(payload.owner, "mnote-web");
assert.equal(payload.schema, "mnote.page_aggregate.v1");
assert.equal(payload.result.identity.documentId, target.documentId);
assert.equal(payload.result.identity.workspaceId, target.workspaceId);
assert.equal(payload.result.schema, "mnote.page_aggregate.v1");
assert.equal(typeof payload.result.head.title, "string");
assert(payload.result.head.title.trim(), "Page Aggregate 标题不能为空");
if (target.expectedTitle) {
assert.equal(payload.result.head.title, target.expectedTitle);
}
}
async function main() {
let gateway = null;
let baseUrl = EXTERNAL_BASE_URL;
let createdTarget = null;
if (!baseUrl) {
const port = await findFreePort();
baseUrl = `http://127.0.0.1:${port}`;
gateway = startGateway(port);
await waitForGateway(baseUrl);
}
try {
const target = CONFIGURED_DOCUMENT_ID
? {
documentId: CONFIGURED_DOCUMENT_ID,
workspaceId: CONFIGURED_WORKSPACE_ID || "ws_demo",
expectedTitle: CONFIGURED_TITLE || null,
}
: gateway
? {
documentId: "doc_1",
workspaceId: "ws_demo",
expectedTitle: CONFIGURED_TITLE || "服务端页面",
}
: await createTempDocument(baseUrl);
if (!CONFIGURED_DOCUMENT_ID && !gateway) {
createdTarget = target;
target.expectedTitle = target.title;
}
await validateDocumentShell(baseUrl, target);
console.log(
JSON.stringify(
{
ok: true,
baseUrl,
owner: "mnote-web",
shell: "document",
editorHost: "leptos_tiptap_island",
documentId: target.documentId,
workspaceId: target.workspaceId,
},
null,
2,
),
);
} finally {
if (createdTarget) {
await purgeTempDocument(baseUrl, createdTarget);
}
if (gateway && gateway.pid) {
gateway.kill("SIGTERM");
setTimeout(() => {
if (!gateway.killed) {
gateway.kill("SIGKILL");
}
}, 2000).unref();
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+125
View File
@@ -0,0 +1,125 @@
"use strict";
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);
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function fetchWithTimeout(path, init = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(`${BASE_URL}${path}`, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}
async function readTextResponse(path, init) {
const response = await fetchWithTimeout(path, init);
const text = await response.text();
assert(response.ok, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
return { response, text };
}
function runtimeInputToolPlan() {
return {
kind: "tool",
context: {
deploymentId: null,
projectId: null,
workspaceId: "ws_task116",
requestId: "req_task116",
traceId: "trace_task116",
actor: {
actorType: "user",
actorId: "task116-user",
sessionId: null,
},
source: {
channel: "rust-web",
client: "task116-smoke",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
},
tool: {
tool: "docs_search",
kind: "query",
mode: "plan",
argsJson: { query: "Rust Web islands" },
target: null,
reason: "task116 owner smoke",
refs: ["task-025"],
},
data: null,
};
}
async function checkSearchShell() {
const { response, text } = await readTextResponse("/search?workspaceId=ws_task116&q=Rust");
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Search shell 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-web-shell") === "search", "Search shell 缺少 search shell header");
assert(text.includes("mnote.search_shell.v1"), "Search shell 缺少 contract schema");
assert(text.includes("react_search_palette"), "Search shell 缺少 search palette island");
return { owner: "mnote-web", shell: "search", island: "react_search_palette" };
}
async function checkAiBridge() {
const { response, text } = await readTextResponse("/api/hermes/bridge", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(runtimeInputToolPlan()),
});
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "AI bridge 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-ai-bridge-owner") === "rust-web-hermes", "AI bridge 缺少 rust-web-hermes owner header");
const payload = JSON.parse(text);
assert(payload?.contract?.schema === "mnote.ai_bridge.v1", "AI bridge 缺少 contract schema");
assert(payload?.contract?.toolEventOwner === "rust-web-hermes", "AI bridge tool event owner 未固定到 Hermes");
return { owner: "mnote-web", bridgeOwner: "rust-web-hermes", schema: payload.contract.schema };
}
async function checkMindmapShell() {
const { response, text } = await readTextResponse("/mindmap/doc_task116/mind_task116");
assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Mindmap shell 缺少 mnote-web owner header");
assert(response.headers.get("x-mnote-web-shell") === "mindmap", "Mindmap shell 缺少 mindmap shell header");
assert(text.includes("mnote.mindmap_shell.v1"), "Mindmap shell 缺少 contract schema");
assert(text.includes('data-react-island="mindmap_runtime"'), "Mindmap shell 缺少 mindmap runtime island");
return { owner: "mnote-web", shell: "mindmap", island: "mindmap_runtime" };
}
async function main() {
const search = await checkSearchShell();
const aiBridge = await checkAiBridge();
const mindmap = await checkMindmapShell();
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
results: {
search,
aiBridge,
mindmap,
},
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { spawn } = require("node:child_process");
const {
fetchWithTimeout,
findFreePort,
waitForGateway,
} = require("./task114-rust-web-gateway-entry-smoke.js");
const { resolveRuntimePlan } = require("./desktop-hot.js");
function buildRetirementFixtureEnv(port) {
return {
SKIP_NEXT_LEGACY: "1",
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
"documents:getMeta": {
id: "doc_1",
workspace_id: "ws_demo",
title: "服务端页面",
updated_at: "2026-04-28T00:00: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: [] },
},
"sidebar:datasetList": {
active_workspace_id: "ws_demo",
workspaces: [],
documents: [
{
id: "doc_1",
workspace_id: "ws_demo",
title: "服务端页面",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-28T00:00:00Z",
updated_at: "2026-04-28T00:00:00Z",
},
],
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
mindmap_assets: [],
trashed_mindmap_assets: [],
table_assets: [],
trashed_table_assets: [],
mindmap_docs: [],
mindmap_asset_children: {},
},
"bridgeLogs:listWorkspaceOverview": {
workspace_id: "ws_demo",
command_logs: [],
domain_events: [],
next_cursor: null,
has_more: false,
filters: {
command_status: null,
event_status: null,
target_page_id: null,
target_block_id: null,
aggregate_type: null,
aggregate_id: null,
},
generated_at: "2026-04-28T00:00:00Z",
},
}),
};
}
function startRetiredNextGateway(port) {
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env: {
...process.env,
...buildRetirementFixtureEnv(port),
},
stdio: ["ignore", "pipe", "pipe"],
});
}
async function readText(baseUrl, path, init) {
const response = await fetchWithTimeout(`${baseUrl}${path}`, init);
const text = await response.text();
assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 缺少 mnote-web owner`);
return { response, text };
}
async function validateSkipNextRuntimePlan() {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100",
SKIP_NEXT_LEGACY: "1",
});
assert.equal(plan.skipGateway, false);
assert.equal(plan.skipNextLegacy, true);
assert.equal(plan.frontendTaskName, null);
assert.equal(plan.publicPort, 3000);
assert.equal(plan.legacyPort, 3100);
assert.equal(plan.mnoteWebEnv.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT, "0");
assert.equal("MNOTE_WEB_LEGACY_NEXT_BASE_URL" in plan.mnoteWebEnv, false);
return plan;
}
async function validateCoreShellsWithoutNext(baseUrl) {
const auth = await readText(baseUrl, "/auth");
assert.match(auth.text, /data-mnote-shell="auth"/);
assert.doesNotMatch(auth.text, /next-app-router/i);
const document = await readText(baseUrl, "/documents/doc_1?workspaceId=ws_demo");
assert.equal(document.response.headers.get("x-mnote-web-shell"), "document");
assert.match(document.text, /mnote\.page_aggregate\.v1/);
const treeEvents = await fetchWithTimeout(`${baseUrl}/api/tree/events?workspaceId=ws_demo&maxPolls=0`);
const treeBody = await treeEvents.text();
assert.equal(treeEvents.status, 200, `/api/tree/events 请求失败: ${treeEvents.status} ${treeBody.slice(0, 200)}`);
assert.equal(treeEvents.headers.get("x-mnote-web-owner"), "mnote-web");
assert.equal(treeEvents.headers.get("x-mnote-tree-stream-owner"), "rust-web");
assert.match(treeBody, /event:\s*snapshot/);
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust");
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
assert.match(search.text, /mnote\.search_shell\.v1/);
}
async function validateDocs() {
const fs = require("node:fs");
const architecture = fs.readFileSync("/mnt/Data1T/mnote/ARCHITECTURE.md", "utf8");
assert.match(architecture, /Next App Router.*legacy compat/is, "ARCHITECTURE.md Next App Router legacy compat");
assert.doesNotMatch(architecture, /Next App Router 仍是当前 legacy 主壳/, "ARCHITECTURE.md 仍保留旧主壳口径");
}
async function main() {
const plan = await validateSkipNextRuntimePlan();
const port = await findFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const gateway = startRetiredNextGateway(port);
let stderr = "";
gateway.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
try {
await waitForGateway(baseUrl);
await validateCoreShellsWithoutNext(baseUrl);
await validateDocs();
console.log(
JSON.stringify(
{
ok: true,
baseUrl,
publicEntry: String(plan.publicPort),
legacyPort: String(plan.legacyPort),
nextLegacyStarted: false,
owner: "mnote-web",
},
null,
2,
),
);
} catch (error) {
if (stderr.trim()) {
console.error(stderr.trim().slice(-2000));
}
throw error;
} finally {
if (gateway.pid) {
gateway.kill("SIGTERM");
setTimeout(() => {
if (!gateway.killed) {
gateway.kill("SIGKILL");
}
}, 2000).unref();
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,65 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
function excerpt(html) {
return html.replace(/\s+/g, " ").trim().slice(0, 600);
}
function assertContains(html, pattern, label) {
assert.match(html, pattern, `${label} 缺失;当前页面片段: ${excerpt(html)}`);
}
function assertNotContains(html, pattern, label) {
assert.doesNotMatch(html, pattern, `${label} 仍存在;当前页面片段: ${excerpt(html)}`);
}
async function validateWolaiUiParity(baseUrl) {
const response = await fetchWithTimeout(`${baseUrl}/`);
const html = await response.text();
assert.equal(response.status, 200, `/ 请求失败: ${response.status}; body: ${excerpt(html)}`);
assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(response.headers.get("content-type") || "", /text\/html/);
assertContains(html, /data-mnote-shell="workspace"/, "workspace shell 标记");
assertContains(html, /data-testid="wolai-(workspace-identity|account|account-menu)"|工作区|个人空间|账号/, "账号/工作区身份");
assertContains(html, /星标置顶/, "星标置顶入口");
assertContains(html, /我的页面/, "我的页面入口");
assertContains(html, /垃圾箱/, "垃圾箱入口");
assertContains(html, /模板中心/, "模板中心入口");
assertContains(html, /data-testid="wolai-topbar"/, "Wolai topbar");
assertContains(html, /data-testid="wolai-floating-ai"/, "Wolai 浮动 AI 入口");
assertNotContains(html, /欢迎使用 MNOTE 知识管理平台/, "极简欢迎页文案");
}
async function main() {
await validateWolaiUiParity(BASE_URL);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
shell: "workspace",
owner: "mnote-web",
},
null,
2,
),
);
}
module.exports = {
validateWolaiUiParity,
};
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
@@ -0,0 +1,106 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { chromium } = require("playwright");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const OUTPUT_DIR = path.resolve(__dirname, "..", "test-results");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "task119-rust-web-wolai-visual-regression-root.png");
function assertBox(box, label) {
assert(box, `${label} 缺少可见布局盒`);
assert(box.width > 0 && box.height > 0, `${label} 尺寸异常: ${JSON.stringify(box)}`);
}
async function boundingBox(locator, label) {
await locator.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const box = await locator.boundingBox();
assertBox(box, label);
return box;
}
function boxesIntersect(a, b) {
return !(
a.x + a.width <= b.x ||
b.x + b.width <= a.x ||
a.y + a.height <= b.y ||
b.y + b.height <= a.y
);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
try {
const response = await page.goto(`${BASE_URL}/`, {
waitUntil: "domcontentloaded",
timeout: UI_TIMEOUT_MS,
});
assert(response, "根页没有返回响应");
assert.equal(response.status(), 200, `根页状态码异常: ${response.status()}`);
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "根页必须由 mnote-web 拥有");
await page.waitForSelector('[data-mnote-shell="workspace"]', { timeout: UI_TIMEOUT_MS });
const bodyText = await page.locator("body").innerText({ timeout: UI_TIMEOUT_MS });
assert(!bodyText.includes("欢迎使用 MNOTE 知识管理平台"), "根页仍停留在极简欢迎页");
const sidebar = page.getByTestId("wolai-sidebar");
const topbar = page.getByTestId("wolai-topbar");
const floatingAi = page.getByTestId("wolai-floating-ai");
const floatingHelp = page.getByTestId("wolai-floating-help");
const content = page.locator(".mnote-content").first();
const homeContent = page.locator(".mnote-home").first();
const sidebarBox = await boundingBox(sidebar, "左侧栏");
const topbarBox = await boundingBox(topbar, "顶栏");
const contentBox = await boundingBox(content, "主内容区");
const homeContentBox = await boundingBox(homeContent, "首页正文");
const floatingAiBox = await boundingBox(floatingAi, "AI 浮动按钮");
const floatingHelpBox = await boundingBox(floatingHelp, "帮助浮动按钮");
assert(sidebarBox.width >= 240 && sidebarBox.width <= 340, `左栏宽度应稳定在 Wolai 范围内: ${sidebarBox.width}`);
assert(sidebarBox.x >= -1 && sidebarBox.x <= 1, `左栏应贴齐视口左侧: ${sidebarBox.x}`);
assert(topbarBox.x >= sidebarBox.width - 2, "顶栏不应覆盖左侧栏");
assert(contentBox.x >= sidebarBox.width - 2, "主内容区不应被侧栏覆盖");
assert(topbarBox.y >= -1 && topbarBox.height >= 36 && topbarBox.height <= 72, `顶栏高度异常: ${topbarBox.height}`);
assert(contentBox.y >= topbarBox.y + topbarBox.height - 2, "主内容区不应被顶栏遮挡");
assert(floatingAiBox.x > sidebarBox.width, "AI 浮动按钮不应落入左侧栏");
assert(floatingHelpBox.x > sidebarBox.width, "帮助浮动按钮不应落入左侧栏");
assert(!boxesIntersect(floatingAiBox, homeContentBox), "AI 浮动按钮不应遮挡首页正文");
assert(!boxesIntersect(floatingHelpBox, homeContentBox), "帮助浮动按钮不应遮挡首页正文");
assert(floatingHelpBox.y > floatingAiBox.y, "帮助按钮应位于 AI 按钮下方");
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
screenshot: SCREENSHOT_PATH,
sidebarWidth: Math.round(sidebarBox.width),
topbarHeight: Math.round(topbarBox.height),
},
null,
2,
),
);
} finally {
await page.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
}
+36 -3
View File
@@ -1,6 +1,11 @@
"use strict"; "use strict";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const DOCUMENT_UI_BASE_URL = (
process.env.MNOTE_TREE_SMOKE_DOCUMENT_BASE_URL ||
process.env.MNOTE_LEGACY_UI_BASE_URL ||
"http://127.0.0.1:3100"
).replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, ""); const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -162,6 +167,19 @@ async function isVisible(locator) {
} }
} }
async function clickButtonByExactText(page, text) {
await page.evaluate((label) => {
const button = Array.from(document.querySelectorAll("button")).find((candidate) => {
const content = (candidate.textContent || "").trim();
return content === label;
});
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`未找到按钮:${label}`);
}
button.click();
}, text);
}
async function completeUsernameSetupIfNeeded(page) { async function completeUsernameSetupIfNeeded(page) {
const saveButton = page.getByRole("button", { name: "保存并继续" }); const saveButton = page.getByRole("button", { name: "保存并继续" });
if (!(await isVisible(saveButton))) { if (!(await isVisible(saveButton))) {
@@ -319,7 +337,7 @@ async function cleanupDocuments(requestContext, createdIds) {
} }
async function openDocument(page, workspaceId, documentId) { async function openDocument(page, workspaceId, documentId) {
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`; const url = `${DOCUMENT_UI_BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
const hasSidebarControls = async () => { const hasSidebarControls = async () => {
const groupButton = page.getByRole("button", { name: "分组" }); const groupButton = page.getByRole("button", { name: "分组" });
const fileButton = page.getByRole("button", { name: "文件" }); const fileButton = page.getByRole("button", { name: "文件" });
@@ -373,8 +391,15 @@ async function openSectionView(page) {
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
try {
await waitForHost();
return;
} catch {
// 说明:未处于分组视图时才需要点击切换。
}
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
await button.click({ timeout: UI_TIMEOUT_MS }); await clickButtonByExactText(page, "分组");
try { try {
await waitForHost(); await waitForHost();
return; return;
@@ -399,8 +424,15 @@ async function openFilesystemView(page) {
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
try {
await waitForHost();
return;
} catch {
// 说明:未处于文件视图时才需要点击切换。
}
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
await button.click({ timeout: UI_TIMEOUT_MS }); await clickButtonByExactText(page, "文件");
try { try {
await waitForHost(); await waitForHost();
return; return;
@@ -440,6 +472,7 @@ async function ensurePageOptionsVisible(page) {
module.exports = { module.exports = {
BASE_URL, BASE_URL,
DOCUMENT_UI_BASE_URL,
MNOTE_WEB_BASE_URL, MNOTE_WEB_BASE_URL,
REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS,
UI_TIMEOUT_MS, UI_TIMEOUT_MS,
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+16 -1
View File
@@ -1244,6 +1244,10 @@ export const createWithParentReference = mutation({
title: v.optional(v.union(v.string(), v.null())), title: v.optional(v.union(v.string(), v.null())),
accessScope, accessScope,
content: v.optional(v.any()), content: v.optional(v.any()),
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
if (!args.parentId) { if (!args.parentId) {
@@ -1351,7 +1355,14 @@ export const updateContent = mutation({
}); });
export const updateTitle = mutation({ export const updateTitle = mutation({
args: { id: v.string(), title: v.union(v.string(), v.null()) }, args: {
id: v.string(),
title: v.union(v.string(), v.null()),
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
},
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx); const userId = await requireUserId(ctx);
@@ -1400,6 +1411,10 @@ export const move = mutation({
parentId: v.union(v.string(), v.null()), parentId: v.union(v.string(), v.null()),
sortOrder: v.number(), sortOrder: v.number(),
treeWriteOperation: v.any(), treeWriteOperation: v.any(),
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx); const userId = await requireUserId(ctx);
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockConvexAuthNextjsToken = vi.fn();
vi.mock("@convex-dev/auth/nextjs/server", () => ({
convexAuthNextjsToken: mockConvexAuthNextjsToken,
}));
describe("/api/auth/mnote-web-token compat route", () => {
beforeEach(() => {
mockConvexAuthNextjsToken.mockReset();
});
it("有 token 时仅作为 next-auth-token-debug compat 边界写入 mnote-web cookie", async () => {
mockConvexAuthNextjsToken.mockResolvedValue("token-demo");
const { GET } = await import("./route");
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("next-auth-token-debug");
expect(payload).toEqual({
ok: true,
owner: "mnote-web",
compatibility: "next-auth-token-debug",
});
expect(response.headers.get("set-cookie")).toContain("mnote_web_convex_token=token-demo");
});
it("无 token 时清空兼容 cookie 并保留 mnote-web owner header", async () => {
mockConvexAuthNextjsToken.mockResolvedValue("");
const { GET } = await import("./route");
const response = await GET();
const payload = await response.json();
expect(response.status).toBe(401);
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("next-auth-token-debug");
expect(payload).toEqual({ error: "未登录" });
expect(response.headers.get("set-cookie")).toContain("mnote_web_convex_token=");
expect(response.headers.get("set-cookie")).toContain("Max-Age=0");
});
});
@@ -2,6 +2,11 @@ import { NextResponse } from "next/server";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server"; import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
const COOKIE_NAME = "mnote_web_convex_token"; const COOKIE_NAME = "mnote_web_convex_token";
const COMPAT_HEADERS = {
"cache-control": "no-store",
"x-mnote-web-owner": "mnote-web",
"x-mnote-compat-boundary": "next-auth-token-debug",
};
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,11 +23,17 @@ export async function GET() {
path: "/", path: "/",
maxAge: 0, maxAge: 0,
}); });
response.headers.set("cache-control", "no-store"); for (const [name, value] of Object.entries(COMPAT_HEADERS)) {
response.headers.set(name, value);
}
return response; return response;
} }
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({
ok: true,
owner: "mnote-web",
compatibility: "next-auth-token-debug",
});
response.cookies.set({ response.cookies.set({
name: COOKIE_NAME, name: COOKIE_NAME,
value: token, value: token,
@@ -30,6 +41,8 @@ export async function GET() {
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
}); });
response.headers.set("cache-control", "no-store"); for (const [name, value] of Object.entries(COMPAT_HEADERS)) {
response.headers.set(name, value);
}
return response; return response;
} }
@@ -1,94 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type CopyTreeItem = {
documentId: string;
recursive: boolean;
};
type CopyTreePayload = {
items?: CopyTreeItem[] | null;
targetParentId?: string | null;
};
type TreeCopyResponse = {
requestId?: string;
traceId?: string;
result?: {
items?: Array<{
oldId: string;
newId: string;
}>;
} | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const payload = (await request.json()) as CopyTreePayload;
const normalizedItems = (payload.items ?? [])
.filter((item) => item?.documentId)
.map((item) => ({
documentId: item.documentId.trim(),
recursive: Boolean(item.recursive),
}))
.filter((item) => item.documentId.length > 0);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "copy",
targetParentId:
typeof payload.targetParentId === "string" ? payload.targetParentId.trim() || null : null,
items: normalizedItems,
}),
});
const result = (await response.json().catch(() => null)) as
| TreeCopyResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
result && typeof result === "object" && "error" in result && typeof result.error === "string"
? result.error
: "复制页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
items: result?.result?.items ?? [],
meta: {
requestId: result?.requestId,
traceId: result?.traceId,
commandName: "tree.subtree.copy",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "复制页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,83 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeCreateResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
title?: string | null;
sortOrder?: number | null;
workspaceId?: string | null;
execution?: {
access_scope?: "private" | "shared" | "public";
is_template?: boolean;
created_at?: string | null;
updated_at?: string | null;
} | null;
} | null;
};
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { parentId }: { parentId?: string | null } = await request.json();
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "create",
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as TreeCreateResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "创建页面失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
id: payload?.result?.documentId ?? "",
title: payload?.result?.title ?? "无标题",
parent_id: payload?.result?.parentId ?? null,
sort_order: payload?.result?.sortOrder ?? null,
workspace_id: payload?.result?.workspaceId ?? undefined,
access_scope: payload?.result?.execution?.access_scope ?? "private",
is_template: payload?.result?.execution?.is_template ?? false,
created_at: payload?.result?.execution?.created_at ?? null,
updated_at:
payload?.result?.execution?.updated_at ??
payload?.result?.execution?.created_at ??
null,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.create",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "创建页面失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,75 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeArchiveResponse = {
requestId?: string;
traceId?: string;
};
type DeletePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId, workspaceId }: DeletePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "archive",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeArchiveResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.archive",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,74 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeEmbedResponse = {
requestId?: string;
traceId?: string;
};
type EmbedPayload = {
sourceId?: string | null;
targetId?: string | null;
};
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { sourceId, targetId }: EmbedPayload = await request.json();
const normalizedSourceId = typeof sourceId === "string" ? sourceId.trim() : "";
const normalizedTargetId = typeof targetId === "string" ? targetId.trim() : "";
if (!normalizedSourceId || !normalizedTargetId) {
return NextResponse.json({ error: "缺少 sourceId 或 targetId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "embed",
sourceId: normalizedSourceId,
targetId: normalizedTargetId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeEmbedResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "嵌入失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.embed",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "嵌入失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,79 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeMoveResponse = {
requestId?: string;
traceId?: string;
result?: {
documentId?: string;
parentId?: string | null;
sortOrder?: number | null;
} | null;
};
type MovePayload = {
documentId?: string | null;
parentId?: string | null;
position?: number | null;
workspaceId?: string | null;
};
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId, parentId = null, position, workspaceId }: MovePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "move",
documentId: normalizedDocumentId,
parentId: typeof parentId === "string" ? parentId.trim() || null : null,
sortOrder: typeof position === "number" && Number.isFinite(position) ? Math.floor(position) : 0,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as TreeMoveResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "移动失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.subtree.move",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "移动失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,77 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreePurgeResponse = {
requestId?: string;
traceId?: string;
result?: {
purged?: boolean;
} | null;
};
type PurgePayload = {
documentId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId }: PurgePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "purge",
documentId: normalizedDocumentId,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreePurgeResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "彻底删除失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
purged: payload?.result?.purged ?? true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.purge",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "彻底删除失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -1,75 +0,0 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
type TreeRestoreResponse = {
requestId?: string;
traceId?: string;
};
type RestorePayload = {
documentId?: string | null;
workspaceId?: string | null;
};
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
try {
const { documentId, workspaceId }: RestorePayload = await request.json();
const normalizedDocumentId = typeof documentId === "string" ? documentId.trim() : "";
if (!normalizedDocumentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const upstreamUrl = new URL("/api/tree/commands", request.url);
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: new Headers({
"content-type": "application/json",
}),
body: JSON.stringify({
action: "restore",
documentId: normalizedDocumentId,
workspaceId: typeof workspaceId === "string" ? workspaceId.trim() || null : null,
}),
});
const payload = (await response.json().catch(() => null)) as
| TreeRestoreResponse
| { error?: string }
| null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "恢复失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
success: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.restore",
},
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "恢复失败,请稍后再试",
},
{ status: 500 },
);
}
}
export const runtime = "nodejs";
@@ -214,7 +214,11 @@ describe("documents route adapters", () => {
const response = await postTitle(new Request("http://localhost/api/documents/title", { const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: {
"authorization": "Bearer test-token",
"content-type": "application/json",
"cookie": "convex-auth=test-cookie",
},
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }), body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
})); }));
const payload = await response.json() as { const payload = await response.json() as {
@@ -235,6 +239,15 @@ describe("documents route adapters", () => {
}), }),
}), }),
); );
const renameCall = fetchMock.mock.calls.find(([, init]) => {
if (!init || typeof init.body !== "string") {
return false;
}
return init.body.includes('"action":"rename"');
});
const forwardedHeaders = renameCall?.[1]?.headers as Headers;
expect(forwardedHeaders.get("authorization")).toBe("Bearer test-token");
expect(forwardedHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(payload.ok).toBe(true); expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename"); expect(payload.meta.commandName).toBe("tree.node.rename");
}); });
@@ -35,11 +35,20 @@ export async function POST(request: Request) {
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) { if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url); const upstreamUrl = new URL("/api/tree/commands", request.url);
const upstreamHeaders = new Headers({
"content-type": "application/json",
});
const authorization = request.headers.get("authorization");
const cookie = request.headers.get("cookie");
if (authorization) {
upstreamHeaders.set("authorization", authorization);
}
if (cookie) {
upstreamHeaders.set("cookie", cookie);
}
const response = await fetch(upstreamUrl.toString(), { const response = await fetch(upstreamUrl.toString(), {
method: "POST", method: "POST",
headers: new Headers({ headers: upstreamHeaders,
"content-type": "application/json",
}),
body: JSON.stringify({ body: JSON.stringify({
action: "rename", action: "rename",
documentId: normalizedDocumentId, documentId: normalizedDocumentId,
@@ -133,6 +133,9 @@ describe("/api/mnote-web/stream route", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/event-stream"); expect(response.headers.get("content-type")).toContain("text/event-stream");
expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web");
expect(response.headers.get("x-mnote-tree-stream-owner")).toBe("rust-web");
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mnote-web-stream-alias");
expect(await response.text()).toContain("event: snapshot"); expect(await response.text()).toContain("event: snapshot");
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled(); expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
@@ -156,7 +156,10 @@ export async function GET(request: Request) {
"content-type": "text/event-stream; charset=utf-8", "content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store", "cache-control": "no-store",
connection: "keep-alive", connection: "keep-alive",
"x-upstream": "next-tree-stream", "x-upstream": "next-tree-stream-compat",
"x-mnote-web-owner": "mnote-web",
"x-mnote-tree-stream-owner": "rust-web",
"x-mnote-compat-boundary": "mnote-web-stream-alias",
}, },
}); });
} }
@@ -1,96 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockResolveMnoteWebInternalUrl = vi.fn();
const mockBuildForwardHeaders = vi.fn();
const mockFetch = vi.fn();
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: (...args: unknown[]) => mockResolveMnoteWebInternalUrl(...args),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: (...args: unknown[]) => mockBuildForwardHeaders(...args),
}));
describe("/api/tree/shell route", () => {
beforeEach(() => {
mockResolveMnoteWebInternalUrl.mockReset();
mockBuildForwardHeaders.mockReset();
mockFetch.mockReset();
vi.stubGlobal("fetch", mockFetch);
});
it("未显式 debug 时不应再代理 3104 tree shell", async () => {
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9",
),
);
expect(mockResolveMnoteWebInternalUrl).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
expect(response.status).toBe(404);
expect(await response.json()).toEqual({
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
});
});
it("显式 debug 时才通过 3000 同源代理回源 mnote-web tree shell,并移除不应透传的响应头", async () => {
mockResolveMnoteWebInternalUrl.mockResolvedValue("http://127.0.0.1:3104");
mockBuildForwardHeaders.mockResolvedValue(
new Headers({
cookie: "session=abc",
authorization: "Bearer test-token",
}),
);
mockFetch.mockResolvedValue(
new Response("<!doctype html><html><body>tree shell</body></html>", {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
"set-cookie": "debug=1",
connection: "keep-alive",
"x-upstream": "mnote-web-tree",
},
}),
);
const { GET } = await import("./route");
const response = await GET(
new Request(
"http://127.0.0.1:3000/api/tree/shell?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
{
method: "GET",
headers: {
cookie: "session=abc",
},
},
),
);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3104/tree?workspaceId=ws_1&mode=page&activeDocumentId=doc_9&debug=1",
expect.objectContaining({
method: "GET",
headers: expect.any(Headers),
cache: "no-store",
redirect: "manual",
}),
);
const fetchHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("cookie")).toBe("session=abc");
expect(fetchHeaders.get("authorization")).toBe("Bearer test-token");
expect(fetchHeaders.get("accept")).toContain("text/html");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next_tree_shell_proxy");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("set-cookie")).toBeNull();
expect(response.headers.get("connection")).toBeNull();
expect(response.headers.get("x-upstream")).toBe("mnote-web-tree");
expect(await response.text()).toContain("tree shell");
});
});
@@ -1,80 +0,0 @@
import { NextResponse } from "next/server";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import { buildForwardHeaders } from "@/lib/server/forward-headers";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:代理响应不应继续透传 hop-by-hop headers,避免浏览器拿到无效连接语义。
const hopByHopHeaders = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"content-length",
];
hopByHopHeaders.forEach((name) => headers.delete(name));
};
export async function GET(request: Request) {
try {
const requestUrl = new URL(request.url);
const debugEnabled =
requestUrl.searchParams.get("debug") === "1" ||
requestUrl.searchParams.get("internal") === "1" ||
process.env.MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES === "1";
if (!debugEnabled) {
return NextResponse.json(
{
error: "tree shell proxy 默认关闭;主路径使用 3000 inline rust compat host",
},
{ status: 404 },
);
}
const internalBaseUrl = await resolveMnoteWebInternalUrl();
const targetUrl = new URL("/tree", `${internalBaseUrl}/`);
targetUrl.search = requestUrl.search;
const headers = await buildForwardHeaders(request);
headers.set("accept", "text/html,application/xhtml+xml");
headers.set("x-mnote-source-channel", "next_tree_shell_proxy");
headers.set("x-mnote-source-client", "wolai-frontend");
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim();
if (workspaceId && !headers.has("x-mnote-workspace-id")) {
headers.set("x-mnote-workspace-id", workspaceId);
}
const upstream = await fetch(targetUrl.toString(), {
method: "GET",
headers,
cache: "no-store",
redirect: "manual",
});
const body = await upstream.arrayBuffer();
const responseHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(responseHeaders);
responseHeaders.delete("set-cookie");
responseHeaders.set("cache-control", "no-store");
return new NextResponse(body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
} catch (error) {
const message = error instanceof Error ? error.message : "tree shell 代理失败";
return NextResponse.json(
{
error: message,
},
{ status: 502 },
);
}
}
@@ -15,6 +15,12 @@ type MindmapRouteQueryResult = {
meta?: unknown; meta?: unknown;
}; };
export const MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT = {
shellOwner: "mnote-web",
runtimeRole: "legacy_compat_island",
island: "mindmap_runtime",
} as const;
async function fetchMindmapProjectionOnServer(input: { async function fetchMindmapProjectionOnServer(input: {
docId: string; docId: string;
mindmapId: string; mindmapId: string;
@@ -101,10 +107,16 @@ export default async function MindmapFullscreenPage({
const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId }); const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId });
return ( return (
<MindmapPageClient <main
docId={docId} data-react-island={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.island}
mindmapId={mindmapId} data-runtime-role={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.runtimeRole}
initialProjection={initialProjection} data-shell-owner={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.shellOwner}
/> >
<MindmapPageClient
docId={docId}
mindmapId={mindmapId}
initialProjection={initialProjection}
/>
</main>
); );
} }
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
import { import {
DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT,
applyDocWriteToolResultToPageBody, applyDocWriteToolResultToPageBody,
buildAiAgentSessionsStoragePayload, buildAiAgentSessionsStoragePayload,
extractCurrentPageTitleFromSlashToolResult, extractCurrentPageTitleFromSlashToolResult,
@@ -260,3 +261,16 @@ describe("extractCurrentPageTitleFromSlashToolResult", () => {
).toBeNull(); ).toBeNull();
}); });
}); });
describe("DocumentAiAgentPanel.runtime island contract", () => {
it("AI bridge runtime 固定为 Rust Web/Hermes owned island", () => {
expect(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT).toMatchObject({
shellOwner: "mnote-web",
bridgeOwner: "rust-web-hermes",
runtimeRole: "react_interaction_island",
runEndpoint: "/api/hermes/bridge",
legacyCompatEndpoint: "/api/ai-agent/run",
});
});
});
@@ -27,6 +27,14 @@ type CodexMode = "chat" | "test" | "dev";
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M"; const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
export const DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT = {
shellOwner: "mnote-web",
bridgeOwner: "rust-web-hermes",
runtimeRole: "react_interaction_island",
runEndpoint: "/api/hermes/bridge",
legacyCompatEndpoint: "/api/ai-agent/run",
} as const;
const extractCodexMode = (text: string): CodexMode => { const extractCodexMode = (text: string): CodexMode => {
const s = String(text ?? ""); const s = String(text ?? "");
const m = s.match(/^\s*#(chat|test|dev)\b/i); const m = s.match(/^\s*#(chat|test|dev)\b/i);
@@ -863,6 +871,12 @@ export function DocumentAiAgentPanelRuntime({
className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none" className="w-[min(1400px,calc(100vw-24px))] max-w-none border-l-0 bg-transparent p-3 shadow-none sm:max-w-none"
> >
<SheetTitle className="sr-only"> AI</SheetTitle> <SheetTitle className="sr-only"> AI</SheetTitle>
<div
data-react-island="document_ai_bridge"
data-runtime-role={DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.runtimeRole}
data-bridge-owner={DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.bridgeOwner}
className="h-[calc(100vh-24px)] min-h-0"
>
<AiBridgePanel <AiBridgePanel
title={pageTitle} title={pageTitle}
subtitle="页面 AI" subtitle="页面 AI"
@@ -1757,6 +1771,7 @@ export function DocumentAiAgentPanelRuntime({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</AiBridgePanel> </AiBridgePanel>
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
); );
@@ -8,6 +8,13 @@ type SearchPaletteProps = {
workspaceId: string | null; workspaceId: string | null;
}; };
export const SEARCH_PALETTE_ISLAND_CONTRACT = {
shellOwner: "mnote-web",
runtimeRole: "react_interaction_island",
island: "search_palette",
transport: "/api/search/documents",
} as const;
const SearchPaletteRuntime = dynamic<SearchPaletteProps>( const SearchPaletteRuntime = dynamic<SearchPaletteProps>(
() => import("./search-palette.runtime").then((mod) => mod.SearchPalette), () => import("./search-palette.runtime").then((mod) => mod.SearchPalette),
{ {
@@ -46,5 +53,13 @@ export function SearchPaletteHost({ workspaceId }: SearchPaletteProps) {
return null; return null;
} }
return <SearchPaletteRuntime workspaceId={workspaceId} />; return (
<div
data-react-island={SEARCH_PALETTE_ISLAND_CONTRACT.island}
data-runtime-role={SEARCH_PALETTE_ISLAND_CONTRACT.runtimeRole}
data-shell-owner={SEARCH_PALETTE_ISLAND_CONTRACT.shellOwner}
>
<SearchPaletteRuntime workspaceId={workspaceId} />
</div>
);
} }
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { runAiAgent } from "./runAgent"; import { AI_AGENT_LEGACY_RUNTIME_CONTRACT, runAiAgent } from "./runAgent";
import type { OpenAiCompatibleChatMessage } from "@/lib/ai/openaiCompatibleChat"; import type { OpenAiCompatibleChatMessage } from "@/lib/ai/openaiCompatibleChat";
describe("runAiAgent", () => { describe("runAiAgent", () => {
@@ -172,3 +172,14 @@ describe("runAiAgent", () => {
]); ]);
}); });
}); });
describe("AI_AGENT_LEGACY_RUNTIME_CONTRACT", () => {
it("legacy runAiAgent 明确降为 Hermes 主桥后的兼容 runtime", () => {
expect(AI_AGENT_LEGACY_RUNTIME_CONTRACT).toEqual({
runtimeRole: "legacy_compat",
mainBridgeOwner: "rust-web-hermes",
legacyEndpoint: "/api/ai-agent/run",
});
});
});
@@ -3,6 +3,12 @@ import type { OpenAiCompatibleChatMessage, OpenAiCompatibleChatOptions } from "@
import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat"; import { openAiCompatibleChat } from "@/lib/ai/openaiCompatibleChat";
import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol"; import { parseToolTagCalls, formatToolResultTag } from "../protocol/toolTagProtocol";
export const AI_AGENT_LEGACY_RUNTIME_CONTRACT = {
runtimeRole: "legacy_compat",
mainBridgeOwner: "rust-web-hermes",
legacyEndpoint: "/api/ai-agent/run",
} as const;
export type AgentChatFn = ( export type AgentChatFn = (
messages: OpenAiCompatibleChatMessage[], messages: OpenAiCompatibleChatMessage[],
cfg: OpenAiCompatibleChatOptions, cfg: OpenAiCompatibleChatOptions,
@@ -0,0 +1,31 @@
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");
});
it("把主 Web 执行面和允许保留的 React island / legacy bundle 明确分层", () => {
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.rustOwnedPlanes).toEqual(
expect.arrayContaining([
"web_gateway",
"document_shell",
"page_aggregate_transport",
"tree_realtime_stream",
"search_shell",
"ai_bridge_transport",
"mindmap_object_shell",
]),
);
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).toEqual([
"legacy_react_bundle",
"legacy_app_router_compat",
"island_asset_source_until_migrated",
]);
});
});
@@ -0,0 +1,35 @@
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;
export type RustWebMainExecutionBoundary = typeof RUST_WEB_MAIN_EXECUTION_BOUNDARY;
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { buildSearchRequest } from "./request"; import { SEARCH_REQUEST_TRANSPORT_CONTRACT, buildSearchRequest } from "./request";
describe("buildSearchRequest", () => { describe("buildSearchRequest", () => {
it("combines标题过滤与时间范围", () => { it("combines标题过滤与时间范围", () => {
@@ -42,4 +42,44 @@ describe("buildSearchRequest", () => {
expect(payload?.filters.onlyCurrentPage).toBe(false); expect(payload?.filters.onlyCurrentPage).toBe(false);
expect(payload?.documentId).toBeUndefined(); expect(payload?.documentId).toBeUndefined();
}); });
it("固定搜索请求将由 Rust Web search.documents transport 拥有", () => {
const payload = buildSearchRequest({
workspaceId: "ws-3",
activeDocumentId: "doc-3",
query: "Rust Web",
filters: {
titleOnly: false,
exact: true,
onlyCurrentPage: true,
includeOcr: true,
timeField: "updated",
customRange: undefined,
},
timeRange: "any",
});
expect(payload).toMatchObject({
workspaceId: "ws-3",
documentId: "doc-3",
query: "Rust Web",
filters: {
exact: true,
includeOcr: true,
onlyCurrentPage: true,
},
});
});
});
describe("SEARCH_REQUEST_TRANSPORT_CONTRACT", () => {
it("搜索请求 contract 固定为 Rust Web transport", () => {
expect(SEARCH_REQUEST_TRANSPORT_CONTRACT).toEqual({
shellOwner: "mnote-web",
queryName: "search.documents",
endpoint: "/api/search/documents",
runtimeRole: "rust_web_transport",
});
});
}); });
+7
View File
@@ -4,6 +4,13 @@ import type {
DocumentSearchTimeRange, DocumentSearchTimeRange,
} from "@/types/search"; } from "@/types/search";
export const SEARCH_REQUEST_TRANSPORT_CONTRACT = {
shellOwner: "mnote-web",
queryName: "search.documents",
endpoint: "/api/search/documents",
runtimeRole: "rust_web_transport",
} as const;
interface BuildSearchRequestArgs { interface BuildSearchRequestArgs {
workspaceId: string | null; workspaceId: string | null;
activeDocumentId: string | null; activeDocumentId: string | null;
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary"; import { TREE_3000_ROUTE_BOUNDARY_MANIFEST } from "./tree-route-boundary";
describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => { describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
it("固定 3000 route 的 thin proxy 与 compat pending 边界", () => { it("固定 3000 route 的 Rust Web gateway owner 与 legacy compat 边界", () => {
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({ expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST).toMatchObject({
schema: "mnote.tree.3000_route_boundary", schema: "mnote.tree.3000_route_boundary",
schemaVersion: 1, schemaVersion: 1,
@@ -13,28 +13,34 @@ describe("TREE_3000_ROUTE_BOUNDARY_MANIFEST", () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: "tree.commands", id: "tree.commands",
role: "next-thin-proxy", role: "rust-owned",
route: "/api/tree/commands", route: "/api/tree/commands",
owner: "rust-web-gateway",
}), }),
expect.objectContaining({ expect.objectContaining({
id: "tree.stream", id: "tree.stream",
role: "next-thin-proxy", role: "rust-owned",
route: "/api/tree/stream", route: "/api/tree/stream",
owner: "rust-web-gateway",
}), }),
expect.objectContaining({ expect.objectContaining({
id: "tree.shell.debug", id: "tree.shell.debug",
role: "compat-pending", role: "compat-pending",
route: "/api/tree/shell", route: "/api/tree/shell",
owner: "next-app-router-legacy",
}), }),
]), ]),
); );
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move"); expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.rustOwnedSemantics).toContain("tree.subtree.move");
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain( expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.nextThinProxyDuties).toContain(
"Rust command result 回包整形", "legacy route alias 与 header 透传",
); );
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain( expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.browserSubstrateDuties).toContain(
"新页面 scaffold 文件创建", "新页面 scaffold 文件创建",
); );
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan"); expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain("树排序 canonical plan");
expect(TREE_3000_ROUTE_BOUNDARY_MANIFEST.forbiddenNextSemantics).toContain(
"主 tree command transport owner",
);
}); });
}); });
+12 -11
View File
@@ -1,6 +1,7 @@
export type TreeRouteBoundaryRole = export type TreeRouteBoundaryRole =
| "rust-owned" | "rust-owned"
| "next-thin-proxy" | "next-thin-proxy"
| "legacy-thin-proxy"
| "browser-substrate" | "browser-substrate"
| "compat-pending"; | "compat-pending";
@@ -8,7 +9,7 @@ export type TreeRouteBoundaryItem = {
id: string; id: string;
role: TreeRouteBoundaryRole; role: TreeRouteBoundaryRole;
route: string; route: string;
owner: "rust-runtime" | "next-3000" | "convex-substrate"; owner: "rust-runtime" | "rust-web-gateway" | "next-app-router-legacy" | "convex-substrate";
description: string; description: string;
}; };
@@ -19,25 +20,25 @@ export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
routes: [ routes: [
{ {
id: "tree.commands", id: "tree.commands",
role: "next-thin-proxy", role: "rust-owned",
route: "/api/tree/commands", route: "/api/tree/commands",
owner: "next-3000", owner: "rust-web-gateway",
description: description:
"浏览器公开树命令入口,只负责认证、payload envelope、Rust command transport、artifact writer 调用与必要副作用调度。", "浏览器公开树命令入口由 Rust Web gateway 拥有;legacy Next 仅保留 cookie/header compat 和浏览器 substrate 能力。",
}, },
{ {
id: "tree.stream", id: "tree.stream",
role: "next-thin-proxy", role: "rust-owned",
route: "/api/tree/stream", route: "/api/tree/stream",
owner: "next-3000", owner: "rust-web-gateway",
description: description:
"浏览器 SSE/polling 入口,只转发 bridge log/domain event cursor 与 Rust 产出的 streamDelta,缺少稳定 delta 时保守 resync。", "浏览器 SSE/polling 入口由 Rust Web gateway 拥有,legacy Next 只作为迁移期 alias。",
}, },
{ {
id: "tree.shell.debug", id: "tree.shell.debug",
role: "compat-pending", role: "compat-pending",
route: "/api/tree/shell", route: "/api/tree/shell",
owner: "next-3000", owner: "next-app-router-legacy",
description: description:
"仅服务显式 debug/internal runtime 验证;3000 主路径使用 same-origin inline host,不应默认请求 mnote-web:3104。", "仅服务显式 debug/internal runtime 验证;3000 主路径使用 same-origin inline host,不应默认请求 mnote-web:3104。",
}, },
@@ -55,9 +56,7 @@ export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
nextThinProxyDuties: [ nextThinProxyDuties: [
"cookie/auth 读取与 Convex client 获取", "cookie/auth 读取与 Convex client 获取",
"workspace bootstrap", "workspace bootstrap",
"CommandEnvelope 构造与 Rust runtime transport", "legacy route alias 与 header 透传",
"Rust command result 回包整形",
"Rust artifact writer 调用",
"tree.subtree.move 的 sidebar snapshot preflight 数据采集", "tree.subtree.move 的 sidebar snapshot preflight 数据采集",
], ],
browserSubstrateDuties: [ browserSubstrateDuties: [
@@ -73,5 +72,7 @@ export const TREE_3000_ROUTE_BOUNDARY_MANIFEST = {
"长期 streamDelta 主语义拼装", "长期 streamDelta 主语义拼装",
"tree shell renderer runtime", "tree shell renderer runtime",
"tree.node.embed 的 pageReference block 结构与插入位置语义", "tree.node.embed 的 pageReference block 结构与插入位置语义",
"主 tree command transport owner",
"主 tree stream transport owner",
], ],
} as const; } as const;
@@ -4,7 +4,7 @@ import {
type SidebarDatasetListQueryResult, type SidebarDatasetListQueryResult,
} from "@/lib/sidebar-data"; } from "@/lib/sidebar-data";
export type TreeStreamKind = "snapshot" | "delta" | "resync"; export type TreeStreamKind = "snapshot" | "delta" | "resync" | "heartbeat";
export interface TreeStreamEnvelope { export interface TreeStreamEnvelope {
stream: string; stream: string;
@@ -16,7 +16,7 @@ export interface TreeStreamEnvelope {
data: unknown; data: unknown;
} }
const TREE_STREAM_KINDS = new Set<TreeStreamKind>(["snapshot", "delta", "resync"]); const TREE_STREAM_KINDS = new Set<TreeStreamKind>(["snapshot", "delta", "resync", "heartbeat"]);
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); return Boolean(value) && typeof value === "object" && !Array.isArray(value);
@@ -69,7 +69,7 @@ export function buildWorkspaceTreeStreamUrl(
): string { ): string {
const baseOrigin = const baseOrigin =
typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000"; typeof window !== "undefined" ? window.location.origin : "http://127.0.0.1:3000";
const url = new URL("/api/mnote-web/stream", baseOrigin); const url = new URL("/api/tree/events", baseOrigin);
url.searchParams.set("workspaceId", workspaceId.trim()); url.searchParams.set("workspaceId", workspaceId.trim());
if (typeof cursor === "string" && cursor.trim()) { if (typeof cursor === "string" && cursor.trim()) {
url.searchParams.set("cursor", cursor.trim()); url.searchParams.set("cursor", cursor.trim());
@@ -21,7 +21,7 @@ describe("tree-stream/protocol", () => {
expect( expect(
buildWorkspaceTreeStreamUrl(" ws_1 ", "evt_9"), buildWorkspaceTreeStreamUrl(" ws_1 ", "evt_9"),
).toBe( ).toBe(
"http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1&cursor=evt_9", "http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1&cursor=evt_9",
); );
}); });
@@ -35,7 +35,7 @@ describe("tree-stream/protocol", () => {
}); });
expect( expect(
buildWorkspaceTreeStreamUrl("ws_1", null), buildWorkspaceTreeStreamUrl("ws_1", null),
).toBe("http://127.0.0.1:3000/api/mnote-web/stream?workspaceId=ws_1"); ).toBe("http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1");
}); });
it("解析 snapshot / delta / resync 协议消息", () => { it("解析 snapshot / delta / resync 协议消息", () => {
@@ -85,6 +85,21 @@ describe("tree-stream/protocol", () => {
projection: "sidebar_tree", projection: "sidebar_tree",
}); });
expect(
parseTreeStreamMessage({
eventType: "heartbeat",
rawData: JSON.stringify({
kind: "heartbeat",
workspaceId: "ws_1",
cursor: "evt_3",
}),
}),
).toMatchObject({
kind: "heartbeat",
workspaceId: "ws_1",
cursor: "evt_3",
});
expect( expect(
parseTreeStreamMessage({ parseTreeStreamMessage({
eventType: "delta", eventType: "delta",