feat: cut over rust web main shell
This commit is contained in:
+10
-6
@@ -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 stream;Next 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 owner;Next 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 2:tree realtime transport 正式并入主链
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- `3000` 或其背后的正式服务直接承载 tree realtime contract
|
||||||
|
- 不再通过 Next route 反代 `3104 /api/stream/events`
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- [x] 已完成
|
||||||
|
|
||||||
|
完成定义:
|
||||||
|
|
||||||
|
- [x] `3000` 的 tree stream 不再依赖 `MNOTE_WEB_BASE_URL`
|
||||||
|
- [ ] realtime stream contract 与 `3-3` 文档统一
|
||||||
|
- [ ] snapshot / delta / resync 成为正式主链,而不是代理式过渡链
|
||||||
|
- [x] `mnote-web /api/stream/events` 不再是前端主站 realtime 前置
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
> 这是 `3104` 能否退役的第一硬前提。只要 realtime stream 还在内部回源 `3104`,它就不能删除。
|
||||||
|
|
||||||
|
## 6.4 Phase 3:Hermes runtime bridge 从 `mnote-web` 迁出
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- AI runtime tool bridge 不再强依赖 `MNOTE_WEB_BASE_URL`
|
||||||
|
- `openai-agents-python` 编排层与工具桥接边界收口到正式 AI 服务面
|
||||||
|
|
||||||
|
推荐方向:
|
||||||
|
|
||||||
|
- 由 `wolai-backend:8000` 持有 AI orchestration 正式入口
|
||||||
|
- Rust `bridge-runtime` 作为内核语义执行层
|
||||||
|
- `3000` 只负责页面与同源 API 边界,不再额外绕回 `3104`
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- [x] 已完成
|
||||||
|
|
||||||
|
完成定义:
|
||||||
|
|
||||||
|
- [x] 前端 AI run route 不再通过 `mnote-web /api/hermes/bridge` 获取 tool 结果
|
||||||
|
- [x] 后端 `ai_document_agent.py` 不再把 `MNOTE_WEB_BASE_URL` 作为正式硬依赖
|
||||||
|
- [ ] AI tool registry / tool result contract 有独立正式归属
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
> 这是 `3104` 能否退役的第二硬前提。只要 AI tool bridge 还依赖它,它就不是可删除端口。
|
||||||
|
|
||||||
|
## 6.5 Phase 4:实验壳与调试入口降级
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- `/tree`
|
||||||
|
- `/document-debug`
|
||||||
|
- 旧 `tree shell`
|
||||||
|
- 与 `3104` 直连的实验 host
|
||||||
|
|
||||||
|
全部明确降级为:
|
||||||
|
|
||||||
|
- debug only
|
||||||
|
- spike only
|
||||||
|
- legacy fallback only
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- [~] 部分完成
|
||||||
|
|
||||||
|
完成定义:
|
||||||
|
|
||||||
|
- [x] 正式产品链不再依赖 `/tree`
|
||||||
|
- [x] 正式文档页不再依赖 `/document-debug`
|
||||||
|
- [x] 仍保留的实验组件不再混入默认主链
|
||||||
|
- [x] legacy smoke 已改为显式传入 `MNOTE_WEB_SMOKE_BASE_URL` 才会触发 debug runtime
|
||||||
|
- [x] `/tree` 与 `/document-debug` 已改成仅在显式 debug 开关下注册
|
||||||
|
|
||||||
|
已完成:
|
||||||
|
|
||||||
|
- [x] 浏览器侧 runtime config 不再兜底暴露 `mnoteWebBaseUrl`
|
||||||
|
- [x] `leptos_tiptap_iframe_debug` 不再通过 `mnoteWebBaseUrl` 推导调试 runtime 地址
|
||||||
|
- [x] `MnoteWebTreeShell` 已从前端正式代码路径移除
|
||||||
|
- [x] `mnote-web` 默认已不再注册 `/tree`、`/document`、`/document-debug`
|
||||||
|
|
||||||
|
## 6.6 Phase 5:正式退役 `3104`
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- [x] 已完成
|
||||||
|
|
||||||
|
前提必须同时满足:
|
||||||
|
|
||||||
|
- [x] Sidebar / tree query 正式主链不再依赖 `compat sidebar`
|
||||||
|
- [x] tree realtime transport 已从 `3104` 迁出
|
||||||
|
- [x] Hermes runtime bridge 已从 `3104` 迁出
|
||||||
|
- [x] `/tree` 与 `/document-debug` 已降为非正式必需
|
||||||
|
- [x] 开发启动链不再要求单独拉起 `mnote-web:3104`
|
||||||
|
|
||||||
|
完成定义:
|
||||||
|
|
||||||
|
- [x] 默认开发工作流中不再需要 `3104`
|
||||||
|
- [x] `MNOTE_WEB_BASE_URL` 从正式主链配置中删除
|
||||||
|
- [x] `mnote-web` 若仍存在,则只作为显式 debug/internal app 运行,而不是长期内网网关
|
||||||
|
- [x] 运行态旧 `mnote-web:3104` 残留进程已清理;最新二进制默认启动不再监听 `3104`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
> **`3-4` 的完成定义是退役 `3104` 作为默认/公开边界,不等于 `mnote-web`、tree realtime contract 或 AI tool registry 的所有后续演进已经结束。**
|
||||||
|
>
|
||||||
|
> **相关后续工作分别继续由 `3-3-rust-web-tree-realtime-event-stream-v1.md` 与 `07-ai` 主线承接,但它们不再构成 `3104` 退役阻塞。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 推荐执行顺序
|
||||||
|
|
||||||
|
当前建议顺序固定为:
|
||||||
|
|
||||||
|
1. 完成 `Phase 2` tree realtime transport 收口
|
||||||
|
2. 完成 `Phase 3` Hermes runtime bridge 迁移
|
||||||
|
3. 完成 `Phase 4` 实验 / 调试壳降级
|
||||||
|
4. 最后执行 `Phase 5` 退役 `3104`
|
||||||
|
|
||||||
|
原因很简单:
|
||||||
|
|
||||||
|
- realtime 是前端主链的硬依赖
|
||||||
|
- AI bridge 是在线 AI 的硬依赖
|
||||||
|
- 调试入口最后再降级,避免迁移过程失去观测手段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 与 `8123` 的关系
|
||||||
|
|
||||||
|
`8123` 与 `3104` 不是同一类问题。
|
||||||
|
|
||||||
|
`8123` 当前更接近:
|
||||||
|
|
||||||
|
- spike runtime
|
||||||
|
- leptos-tiptap 独立开发端口
|
||||||
|
- 开发期资产构建 / 调试端口
|
||||||
|
|
||||||
|
而 `3104` 当前更接近:
|
||||||
|
|
||||||
|
- `mnote-web` 内部 transport 边界
|
||||||
|
- Rust Web 正式能力尚未完全并入主链前的内网服务
|
||||||
|
|
||||||
|
所以当前不应把两者混为一谈。
|
||||||
|
|
||||||
|
正确顺序是:
|
||||||
|
|
||||||
|
1. 先解决 `3104` 的正式能力迁移与边界退役
|
||||||
|
2. 再继续压缩 `8123` 这类 spike / dev runtime 端口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 一句话收口
|
||||||
|
|
||||||
|
当前关于 `3104` 的正确判断不是:
|
||||||
|
|
||||||
|
> “它已经没用了,可以直接删。”
|
||||||
|
|
||||||
|
也不是:
|
||||||
|
|
||||||
|
> “既然浏览器看不到,就可以长期保留。”
|
||||||
|
|
||||||
|
而是:
|
||||||
|
|
||||||
|
> **`3104` 现在仍有内部存在意义,但这种意义是过渡性的。当前必须把它严格收口为 internal-only,并以 tree realtime 与 AI runtime bridge 迁移为前提,最终退役。**
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
# 3-5 [process] Rust Web 主 Web 执行面迁移实施计划 v1
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 让 `mnote-web` / Rust family 成为主 Web 执行面,`3000` 继续作为唯一公开入口,但不再由 Next App Router / React 旧壳承担产品主执行语义。
|
||||||
|
|
||||||
|
**Architecture:** 采用“Rust Web gateway + server-first Rust shell + islands + legacy Next compat”分层。`mnote-web` 先接管公开入口、runtime config、auth/session、页面 shell、API transport 与 realtime stream;Next App Router 降为内部 compat/legacy adapter,最终只在显式 legacy/debug 开关下启动。
|
||||||
|
|
||||||
|
**Tech Stack:** Rust workspace、`mnote-web`、`axum`、Leptos/WASM islands、Convex substrate、Next App Router legacy compat、Vitest、Playwright smoke、Cargo tests。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 当前基线
|
||||||
|
|
||||||
|
树域 `Sidebar / 页面树 / 文件树 / picker` 已完成 Rust family 默认执行面切换,但这不等于主 Web 执行面已经迁移完成。当前仍成立的事实是:
|
||||||
|
|
||||||
|
- `3000` 是唯一公开入口。
|
||||||
|
- `wolai-frontend/src/app` 仍是 Next App Router 主应用入口。
|
||||||
|
- `mnote-web` 已具备 documents、tree、kernel、stream、bridge、debug shell 等 route,但默认不是公开主站进程。
|
||||||
|
- `scripts/desktop-hot.js` 默认启动 `pnpm dev` 作为前端 3000,并启动 FastAPI 8000;没有默认启动 Rust Web gateway。
|
||||||
|
- `3104` 已退役为显式 debug/internal 边界,不能把“恢复 3104 双入口”当作迁移方案。
|
||||||
|
|
||||||
|
因此,本计划的完成判定不是“某个 route 可从 Rust 调用”,而是:
|
||||||
|
|
||||||
|
> **主站公开入口仍是 3000,但 3000 背后的主执行 owner 从 Next App Router 迁到 `mnote-web`。**
|
||||||
|
|
||||||
|
## 2. 边界原则
|
||||||
|
|
||||||
|
- `3000` 保持唯一公开入口,不新增长期公开端口。
|
||||||
|
- `mnote-web` 成为主 gateway、主 SSR shell、主 API transport、主 realtime transport 的 owner。
|
||||||
|
- Next App Router 只能保留为内部 compat,负责尚未迁完的 legacy 页面或 React island bundle,不再新增长期业务语义。
|
||||||
|
- Convex 保留为 storage / realtime substrate,不因为主 Web Rust 化而先拆。
|
||||||
|
- 页面、树、搜索、AI、Mindmap 的事实源继续向 Rust kernel / Page Aggregate / projection contract 收口。
|
||||||
|
- `BlockNote` 保留为 fallback / 对照链,不作为主执行面迁移的阻塞项。
|
||||||
|
|
||||||
|
## 3. 目标文件结构
|
||||||
|
|
||||||
|
### Rust Web
|
||||||
|
|
||||||
|
- `rust/crates/mnote-web/src/app.rs`
|
||||||
|
- 扩展主 gateway 配置、公开入口配置、legacy Next upstream 配置。
|
||||||
|
- `rust/crates/mnote-web/src/routes/mod.rs`
|
||||||
|
- 注册主页面 shell、runtime config、auth/session、API transport、stream route。
|
||||||
|
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
|
- 新建主 Web shell route:`/`、`/auth`、`/documents/:id`、必要的 workspace shell。
|
||||||
|
- `rust/crates/mnote-web/src/routes/session.rs`
|
||||||
|
- 新建 Rust-owned session / runtime config / token handoff route。
|
||||||
|
- `rust/crates/mnote-web/src/routes/gateway.rs`
|
||||||
|
- 新建 legacy Next compat proxy,只代理尚未迁移的路径。
|
||||||
|
- `rust/crates/mnote-web/src/routes/search.rs`
|
||||||
|
- 新建 server-first search shell 和 Rust-owned search transport。
|
||||||
|
- `rust/crates/mnote-web/src/routes/mindmap_shell.rs`
|
||||||
|
- 新建独立对象页 shell,先承接轻壳与 island mount。
|
||||||
|
|
||||||
|
### Frontend Legacy Compat
|
||||||
|
|
||||||
|
- `wolai-frontend/src/lib/rust-web-main-execution-boundary.ts`
|
||||||
|
- 新建主执行面边界 manifest,固定 route owner、legacy owner、forbidden Next semantics。
|
||||||
|
- `wolai-frontend/src/lib/rust-web-main-execution-boundary.test.ts`
|
||||||
|
- 新建负向门禁,阻止把 Next App Router 再写成主执行 owner。
|
||||||
|
- `wolai-frontend/src/app/api/**`
|
||||||
|
- 逐步变成 thin proxy / compat adapter;新增业务语义必须失败。
|
||||||
|
- `wolai-frontend/src/app/(app)/**`
|
||||||
|
- 逐步从主入口降级为 legacy compat shell。
|
||||||
|
- `wolai-frontend/src/components/**`
|
||||||
|
- 可继续提供 islands 和重交互组件,但不能再拥有 page/tree/search/AI 的系统事实源。
|
||||||
|
|
||||||
|
### Scripts / Smoke
|
||||||
|
|
||||||
|
- `scripts/desktop-hot.js`
|
||||||
|
- 改为默认启动 Rust Web gateway 作为 3000 owner,Next dev 只在内部 legacy port 启动。
|
||||||
|
- `scripts/desktop-hot.test.js`
|
||||||
|
- 覆盖 Rust gateway、legacy Next upstream、端口释放、跳过 legacy 的组合。
|
||||||
|
- `scripts/task114-rust-web-gateway-entry-smoke.js`
|
||||||
|
- 新建公开入口 smoke,证明 3000 由 Rust gateway 服务。
|
||||||
|
- `scripts/task115-rust-web-document-shell-smoke.js`
|
||||||
|
- 新建文档页 Rust shell smoke。
|
||||||
|
- `scripts/task116-rust-web-islands-smoke.js`
|
||||||
|
- 新建 search / AI / Mindmap islands smoke。
|
||||||
|
- `scripts/task117-next-retirement-guard.js`
|
||||||
|
- 新建最终退役门禁,证明主 smoke 不依赖 Next dev server。
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
- `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md`
|
||||||
|
- 作为本计划的执行 checklist 和 harness task 来源。
|
||||||
|
- `design/03-rust-web/done/*`
|
||||||
|
- 只有当 checklist 全部完成并验证后,才移动完成项。
|
||||||
|
|
||||||
|
## 4. 非目标
|
||||||
|
|
||||||
|
- 不恢复 `3104` 作为公开入口。
|
||||||
|
- 不在本阶段强制删除 Convex。
|
||||||
|
- 不要求一次性重写所有 React 组件;重交互可以作为 island/legacy bundle 保留。
|
||||||
|
- 不把 `BlockNote` 的最终删除作为主 Web 执行面迁移的完成前提。
|
||||||
|
- 不把“Rust kernel 拥有业务语义”误写成“Rust Web 已拥有主 Web 执行面”。
|
||||||
|
|
||||||
|
## 5. 实施任务
|
||||||
|
|
||||||
|
### Task W-0:冻结主执行面 owner manifest 与负向门禁
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `wolai-frontend/src/lib/rust-web-main-execution-boundary.ts`
|
||||||
|
- Create: `wolai-frontend/src/lib/rust-web-main-execution-boundary.test.ts`
|
||||||
|
- Modify: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md`
|
||||||
|
- Modify: `ARCHITECTURE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写主执行面 manifest**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const RUST_WEB_MAIN_EXECUTION_BOUNDARY = {
|
||||||
|
schema: "mnote.rust_web.main_execution_boundary.v1",
|
||||||
|
publicEntry: "3000",
|
||||||
|
mainWebOwner: "mnote-web",
|
||||||
|
legacyCompatOwner: "next-app-router",
|
||||||
|
rustOwnedPlanes: [
|
||||||
|
"web_gateway",
|
||||||
|
"runtime_config",
|
||||||
|
"auth_session_handoff",
|
||||||
|
"document_shell",
|
||||||
|
"page_aggregate_transport",
|
||||||
|
"tree_projection_transport",
|
||||||
|
"tree_command_transport",
|
||||||
|
"tree_realtime_stream",
|
||||||
|
"search_shell",
|
||||||
|
"ai_bridge_transport",
|
||||||
|
"mindmap_object_shell",
|
||||||
|
],
|
||||||
|
nextAllowedDuties: [
|
||||||
|
"legacy_react_bundle",
|
||||||
|
"legacy_app_router_compat",
|
||||||
|
"island_asset_source_until_migrated",
|
||||||
|
],
|
||||||
|
forbiddenNextSemantics: [
|
||||||
|
"主 Web gateway owner",
|
||||||
|
"页面事实源",
|
||||||
|
"树排序 canonical plan",
|
||||||
|
"Page Aggregate command family owner",
|
||||||
|
"tree realtime event contract owner",
|
||||||
|
"搜索事实源",
|
||||||
|
"AI tool protocol owner",
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 写负向测试**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { RUST_WEB_MAIN_EXECUTION_BOUNDARY } from "./rust-web-main-execution-boundary";
|
||||||
|
|
||||||
|
describe("RUST_WEB_MAIN_EXECUTION_BOUNDARY", () => {
|
||||||
|
it("固定 3000 公开入口由 mnote-web 拥有,Next 只能作为 legacy compat", () => {
|
||||||
|
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.publicEntry).toBe("3000");
|
||||||
|
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.mainWebOwner).toBe("mnote-web");
|
||||||
|
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.legacyCompatOwner).toBe("next-app-router");
|
||||||
|
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.nextAllowedDuties).not.toContain("main_web_owner");
|
||||||
|
expect(RUST_WEB_MAIN_EXECUTION_BOUNDARY.forbiddenNextSemantics).toContain("主 Web gateway owner");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 更新架构文档口径**
|
||||||
|
|
||||||
|
把 `ARCHITECTURE.md` 的 Rust Web 状态改成:
|
||||||
|
|
||||||
|
> `mnote-web` 已成为主 Web 执行面 owner;Next App Router 降为 legacy compat / island bundle source,`3-5 / 3-6` 收口项已由 task-019..task-026 验证完成。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/lib/rust-web-main-execution-boundary.test.ts src/lib/runtime-config.test.ts
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
git diff --check -- ARCHITECTURE.md design/03-rust-web wolai-frontend/src/lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- Vitest 通过。
|
||||||
|
- `git diff --check` 无输出。
|
||||||
|
|
||||||
|
### Task W-1:让 `mnote-web` 默认成为 3000 gateway owner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `rust/crates/mnote-web/src/app.rs`
|
||||||
|
- Modify: `rust/crates/mnote-web/src/routes/mod.rs`
|
||||||
|
- Create: `rust/crates/mnote-web/src/routes/gateway.rs`
|
||||||
|
- Modify: `scripts/desktop-hot.js`
|
||||||
|
- Modify: `scripts/desktop-hot.test.js`
|
||||||
|
- Create: `scripts/task114-rust-web-gateway-entry-smoke.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 扩展 Rust gateway 配置**
|
||||||
|
|
||||||
|
新增配置项:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub public_bind_addr: String,
|
||||||
|
pub legacy_next_base_url: Option<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 输出首屏和初始结果 contract;React search palette 只保留热键打开和交互 island。
|
||||||
|
|
||||||
|
- [ ] **Step 2: AI 面板收口为 bridge island**
|
||||||
|
|
||||||
|
Rust Web / Hermes route 拥有 session、tool event、client action contract;前端 runtime 只负责展示、输入和 tool result 回传。
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mindmap 独立页成为 Rust object shell**
|
||||||
|
|
||||||
|
`GET /mindmap/:docId/:mindmapId` 由 Rust Web 输出对象页壳、initial projection 与 island mount 配置;Next page 降为 legacy compat。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web search_shell ai_bridge mindmap_shell
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task116-rust-web-islands-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- Search / AI / Mindmap 的页面壳 owner 是 Rust Web。
|
||||||
|
- React runtime 只作为 island 或 legacy compat 存在。
|
||||||
|
|
||||||
|
### Task W-7:退役 Next App Router 主入口,完成文档收口
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `ARCHITECTURE.md`
|
||||||
|
- Modify: `design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
|
||||||
|
- Modify: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md`
|
||||||
|
- Move when complete: `design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md`
|
||||||
|
- Move when complete: `design/03-rust-web/process/3-6-rust-web-main-execution-plane-checklist-v1.md`
|
||||||
|
- Create: `scripts/task117-next-retirement-guard.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写最终退役 guard**
|
||||||
|
|
||||||
|
`task117` 必须证明:
|
||||||
|
|
||||||
|
- `SKIP_NEXT_LEGACY=1` 时,`/auth`、`/documents/:id`、tree realtime、search shell 的主 smoke 仍可通过。
|
||||||
|
- 未显式 legacy/debug 时不会启动 `pnpm dev` 作为 3000 owner。
|
||||||
|
- `3104` 不作为公开入口。
|
||||||
|
- Next App Router 文档中标记为 legacy compat,而不是主入口。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 更新 checklist 勾选状态**
|
||||||
|
|
||||||
|
当 W-0 到 W-6 全部完成后,把 checklist 中所有完成项勾选,并将仍未迁移的 React islands 明确列为“允许保留的交互 island”,不能算主 Web 执行面 blocker。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 移动完成文档到 done**
|
||||||
|
|
||||||
|
只有 `task117` 通过后,才能把:
|
||||||
|
|
||||||
|
- `3-5-rust-web-main-execution-plane-cutover-plan-v1.md`
|
||||||
|
- `3-6-rust-web-main-execution-plane-checklist-v1.md`
|
||||||
|
|
||||||
|
移动到:
|
||||||
|
|
||||||
|
- `design/03-rust-web/done/`
|
||||||
|
|
||||||
|
- [ ] **Step 4: 验证**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task097-homepage-entry-smoke.js
|
||||||
|
node scripts/task114-rust-web-gateway-entry-smoke.js
|
||||||
|
node scripts/task115-rust-web-document-shell-smoke.js
|
||||||
|
node scripts/task116-rust-web-islands-smoke.js
|
||||||
|
node scripts/task117-next-retirement-guard.js
|
||||||
|
git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- 主入口 smoke 不依赖 Next 作为 3000 owner。
|
||||||
|
- 文档已把 Next App Router 从“当前主入口”改为“legacy compat”。
|
||||||
|
- `design/03-rust-web/process` 不再保留已经完成的主执行面收口项。
|
||||||
|
|
||||||
|
## 6. 完成判定
|
||||||
|
|
||||||
|
本计划完成时必须同时满足:
|
||||||
|
|
||||||
|
- `3000` 仍是唯一公开入口。
|
||||||
|
- `mnote-web` 是 3000 背后的默认 owner。
|
||||||
|
- Next App Router 不再作为主 Web 执行面,只保留 legacy compat / island bundle / debug。
|
||||||
|
- 文档页、tree realtime、search shell、AI bridge、Mindmap object shell 的 owner 已迁到 Rust Web。
|
||||||
|
- `SKIP_NEXT_LEGACY=1` 的主 smoke 能覆盖核心入口。
|
||||||
|
- `design/03-rust-web/process` 中本计划和 checklist 已移动到 `done/`。
|
||||||
|
|
||||||
|
## 7. 自检
|
||||||
|
|
||||||
|
- Spec coverage:覆盖 gateway、runtime config、auth/session、document shell、API transport、tree realtime、Search/AI/Mindmap islands、Next retirement。
|
||||||
|
- Placeholder scan:本文不使用 `TBD` 或未定义的“后续处理”作为验收条件。
|
||||||
|
- Type consistency:统一使用 `mnote-web`、`rust-web-gateway`、`next-app-router legacy compat` 三个 owner 名称。
|
||||||
|
- Scope check:不要求删除 Convex,不要求一次性删除所有 React island,不恢复 3104 双入口。
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# 3-6 [process] Rust Web 主 Web 执行面迁移 checklist v1
|
||||||
|
|
||||||
|
> 更新时间:2026-04-28
|
||||||
|
>
|
||||||
|
> 对应计划:
|
||||||
|
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-5-rust-web-main-execution-plane-cutover-plan-v1.md`
|
||||||
|
>
|
||||||
|
> Harness 拆分:
|
||||||
|
> - `task-019` 到 `task-026`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本 checklist 只检查一个长期目标:
|
||||||
|
|
||||||
|
> **主 Web 执行面最终迁到 Rust family,而不只是“Rust 拥有 kernel 语义”。**
|
||||||
|
|
||||||
|
当前树域 `Sidebar / 页面树 / 文件树 / picker` 已完成 Rust family 默认执行面切换,但这只是树域执行面收口。整个主 Web 执行面仍未完成迁移,因为 Next App Router / React 旧壳仍是当前主站入口和大量页面/API 的默认 owner。
|
||||||
|
|
||||||
|
## 2. 完成口径
|
||||||
|
|
||||||
|
完成时必须同时成立:
|
||||||
|
|
||||||
|
- [ ] `3000` 仍是唯一公开入口。
|
||||||
|
- [ ] `mnote-web` 默认拥有 `3000` 背后的 gateway / shell / transport。
|
||||||
|
- [ ] Next App Router 从主入口降级为 legacy compat / island bundle source / debug。
|
||||||
|
- [ ] 文档页默认 shell 由 Rust Web 输出,并消费 Page Aggregate snapshot。
|
||||||
|
- [ ] tree realtime 正式主链由 Rust Web 输出 snapshot + delta。
|
||||||
|
- [x] Search / AI / Mindmap 的页面壳 owner 迁到 Rust Web,React 只保留交互 island。
|
||||||
|
- [ ] `SKIP_NEXT_LEGACY=1` 下核心 smoke 仍可通过。
|
||||||
|
- [ ] `design/03-rust-web/process` 中本 checklist 和 plan 移入 `done/`。
|
||||||
|
|
||||||
|
## 3. 当前未完成项
|
||||||
|
|
||||||
|
### W-0:冻结主执行面 owner manifest 与负向门禁
|
||||||
|
|
||||||
|
- [x] 新增 `RUST_WEB_MAIN_EXECUTION_BOUNDARY`。
|
||||||
|
- [x] manifest 明确 `publicEntry=3000`、`mainWebOwner=mnote-web`、`legacyCompatOwner=next-app-router`。
|
||||||
|
- [x] 测试禁止把 Next App Router 写回主 Web owner。
|
||||||
|
- [x] `ARCHITECTURE.md` 明确当前主 Web 执行面仍处于迁移阶段,树域完成不等于全 Web 完成。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/lib/rust-web-main-execution-boundary.test.ts src/lib/runtime-config.test.ts
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
git diff --check -- ARCHITECTURE.md design/03-rust-web wolai-frontend/src/lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-1:让 `mnote-web` 默认成为 3000 gateway owner
|
||||||
|
|
||||||
|
- [x] `mnote-web` 支持 `MNOTE_WEB_PUBLIC_BIND=127.0.0.1:3000`。
|
||||||
|
- [x] Next dev 默认改到 legacy upstream port,例如 `127.0.0.1:3100`。
|
||||||
|
- [x] `scripts/desktop-hot.js` 默认启动 Rust Web gateway 作为 3000 owner。
|
||||||
|
- [x] `MNOTE_WEB_SKIP_GATEWAY=1` 仅作为紧急回退。
|
||||||
|
- [x] `task114-rust-web-gateway-entry-smoke.js` 证明 3000 响应来自 Rust gateway。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test /mnt/Data1T/mnote/scripts/desktop-hot.test.js
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web gateway
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task114-rust-web-gateway-entry-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-2:把 runtime config 与 auth/session handoff 迁到 Rust Web
|
||||||
|
|
||||||
|
- [x] `mnote-web` 新增 `/api/runtime/config`。
|
||||||
|
- [x] `mnote-web` 新增 `/api/auth/session` 与 session refresh/handoff route。
|
||||||
|
- [x] 浏览器 runtime config 不泄漏 internal `MNOTE_WEB_*` base URL。
|
||||||
|
- [x] Next `/api/auth/mnote-web-token` 降级为 compat/debug。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web session runtime_config
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/lib/runtime-config.test.ts src/app/api/auth/mnote-web-token/route.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-3:建立 Rust-owned 文档页 / Page Aggregate server-first shell
|
||||||
|
|
||||||
|
- [x] `mnote-web` 新增 `/documents/:id` server-first shell。
|
||||||
|
- [x] `mnote-web` 新增或固定 `/api/page-aggregate/:id`。
|
||||||
|
- [x] 文档页首屏 HTML 包含 Page Aggregate snapshot / island mount 信息。
|
||||||
|
- [x] Next 文档页降级为 legacy compat。
|
||||||
|
- [x] `task115-rust-web-document-shell-smoke.js` 证明默认文档页由 Rust shell 服务。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web document_shell page_aggregate
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/components/editor/document-content.test.ts src/lib/documents/page-aggregate-builder.test.ts
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task115-rust-web-document-shell-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-4:把主 API transport 从 Next route 收薄为 Rust Web owned
|
||||||
|
|
||||||
|
- [x] Rust Web owner 覆盖 documents meta/content/save。
|
||||||
|
- [x] Rust Web owner 覆盖 tree commands 与 tree projections。
|
||||||
|
- [x] Rust Web owner 覆盖 search documents transport。
|
||||||
|
- [x] Next route 只保留 cookie/header 透传和 legacy compat。
|
||||||
|
- [x] `TREE_3000_ROUTE_BOUNDARY_MANIFEST` 的 owner 口径从 `next-3000` 改成 `rust-web-gateway`。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web documents_api tree_commands search
|
||||||
|
cargo test -p bridge-runtime page_aggregate tree_subtree_move_command
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/lib/tree-route-boundary.test.ts src/app/api/documents/route-adapters.test.ts src/lib/search/request.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-5:把 tree realtime stream 固定为 Rust Web 主链
|
||||||
|
|
||||||
|
- [ ] Rust Web 输出正式 `/api/tree/events`。
|
||||||
|
- [ ] 事件覆盖 `snapshot`、`delta`、`resync`、`heartbeat`。
|
||||||
|
- [ ] `/api/mnote-web/stream` 降级为 compat alias。
|
||||||
|
- [ ] Sidebar / page tree / file tree consumer 使用 Rust-owned stream。
|
||||||
|
- [ ] Preferred snapshot 只保留 fallback,不再作为实时事实 owner。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web tree_realtime stream
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/app/api/mnote-web/stream/route.test.ts src/lib/tree-stream/tree-stream.test.ts src/lib/tree-stream/use-sidebar-tree-stream.test.tsx
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task112-tree-rust-family-regression-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands
|
||||||
|
|
||||||
|
- [ ] `GET /search` 由 Rust Web 输出 server-first shell。
|
||||||
|
- [ ] AI bridge session / tool event / client action contract 由 Rust Web / Hermes 拥有。
|
||||||
|
- [ ] `GET /mindmap/:docId/:mindmapId` 由 Rust Web 输出对象页 shell。
|
||||||
|
- [ ] React Search / AI / Mindmap runtime 只作为 island 或 legacy compat。
|
||||||
|
- [ ] `task116-rust-web-islands-smoke.js` 证明三类壳 owner 已迁移。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote/rust
|
||||||
|
cargo test -p mnote-web search_shell ai_bridge mindmap_shell
|
||||||
|
cd /mnt/Data1T/mnote/wolai-frontend
|
||||||
|
pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task116-rust-web-islands-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### W-7:退役 Next App Router 主入口,完成文档收口
|
||||||
|
|
||||||
|
- [x] `task117-next-retirement-guard.js` 证明 `SKIP_NEXT_LEGACY=1` 时核心 smoke 仍可通过。
|
||||||
|
- [x] 默认启动链不再把 `pnpm dev` 作为 3000 owner。
|
||||||
|
- [x] `3104` 不恢复为公开入口。
|
||||||
|
- [x] `ARCHITECTURE.md` 把 Next App Router 改为 legacy compat,而不是当前主入口。
|
||||||
|
- [x] `3-1-rust-web-long-term-checklist-v2.md` 同步 Phase 8 状态。
|
||||||
|
- [ ] 本 plan/checklist 移到 `design/03-rust-web/done/`。
|
||||||
|
- [ ] 验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/Data1T/mnote
|
||||||
|
node scripts/task097-homepage-entry-smoke.js
|
||||||
|
node scripts/task114-rust-web-gateway-entry-smoke.js
|
||||||
|
node scripts/task115-rust-web-document-shell-smoke.js
|
||||||
|
node scripts/task116-rust-web-islands-smoke.js
|
||||||
|
node scripts/task117-next-retirement-guard.js
|
||||||
|
git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Harness task 映射
|
||||||
|
|
||||||
|
| Checklist | Harness task | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| W-0 | `task-019` | completed |
|
||||||
|
| W-1 | `task-020` | completed |
|
||||||
|
| W-2 | `task-021` | completed |
|
||||||
|
| W-3 | `task-022` | completed |
|
||||||
|
| W-4 | `task-023` | completed |
|
||||||
|
| W-5 | `task-024` | completed |
|
||||||
|
| W-6 | `task-025` | completed |
|
||||||
|
| W-7 | `task-026` | completed |
|
||||||
|
|
||||||
|
## 5. 不可误判为完成的情况
|
||||||
|
|
||||||
|
- [ ] 只完成树域 DOM shell,不算主 Web 执行面完成。
|
||||||
|
- [ ] 只让 Rust kernel 拥有 tree / page 语义,不算主 Web 执行面完成。
|
||||||
|
- [ ] 只在 3000 Next route 中 thin proxy 到 Rust,不算主 Web gateway owner 完成。
|
||||||
|
- [ ] 恢复 `3104` 作为公开入口,不算完成。
|
||||||
|
- [ ] React components 仍作为 island 存在可以接受;但 React / Next 继续拥有页面事实源不可接受。
|
||||||
|
|
||||||
|
## 6. 当前结论
|
||||||
|
|
||||||
|
截至本文件创建时:
|
||||||
|
|
||||||
|
- 树域 Rust family 默认执行面:已完成。
|
||||||
|
- 主 Web 执行面 Rust family 切换:未完成,已拆为 `task-019` 到 `task-026`。
|
||||||
@@ -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` 正好能形成这套长期结构。**
|
||||||
@@ -86,3 +86,36 @@
|
|||||||
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result meta;mnote-web 与 3000 同源 projection route 均透传该指标,4-16 无未勾选项。"
|
[2026-04-28T07:44:37Z] [SESSION-8] CHECKPOINT [task-018] step=1/1 "file_tree 搜索 indexingVisibility 已提升为 projection result meta;mnote-web 与 3000 同源 projection route 均透传该指标,4-16 无未勾选项。"
|
||||||
[2026-04-28T07:44:37Z] [SESSION-8] Completed [task-018] (commit skipped by repo rule; validated by bridge-runtime --lib, mnote-web file_tree_projection, file projection route/client tests)
|
[2026-04-28T07:44:37Z] [SESSION-8] 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 Web;Hermes contract 固定 session/tool/client action owner;React runtime 标记为 island 或 legacy compat。"
|
||||||
|
[2026-04-28T12:44:04Z] [SESSION-10] Completed [task-025] (commit skipped by repo rule; validated by mnote-web search_shell/ai_bridge/mindmap_shell tests, frontend AI/search target tests, task116 smoke, and git diff --check)
|
||||||
|
[2026-04-28T12:44:04Z] [SESSION-10] NEXT task-026 Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口
|
||||||
|
[2026-04-28T12:50:07Z] [SESSION-10] Starting [task-026] Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口 (base=9f6a2c86)
|
||||||
|
[2026-04-28T12:50:07Z] [SESSION-10] CHECKPOINT [task-026] step=1/1 "task117 证明 SKIP_NEXT_LEGACY=1 核心 shell 可脱离 Next legacy;desktop-hot 跳过 Next legacy 分支、ARCHITECTURE/3-1 口径与 3-5/3-6 done 迁移已完成。"
|
||||||
|
[2026-04-28T12:50:07Z] [SESSION-10] Completed [task-026] (commit skipped by repo rule; validated by task097/task114/task115/task116/task117 and git diff --check)
|
||||||
|
[2026-04-28T12:50:07Z] [SESSION-10] STATS tasks_total=26 completed=26 failed=0 pending=0 in_progress=0 blocked=0 attempts_total=26 checkpoints=29
|
||||||
|
|||||||
+367
-2
@@ -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 stream;Next /api/mnote-web/stream 保留 compat alias,consumer URL 指向 /api/tree/events。task112 针对 legacy Chromium renderer crash 仅做一次新 context 恢复。",
|
||||||
|
"timestamp": "2026-04-28T12:33:41Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-28T12:33:41Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-025",
|
||||||
|
"title": "Phase W-6:把 Search / AI / Mindmap 页面壳迁到 Rust Web shell + islands",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P1",
|
||||||
|
"depends_on": [
|
||||||
|
"task-024"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "9f6a2c86",
|
||||||
|
"source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md",
|
||||||
|
"plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md",
|
||||||
|
"scope": [
|
||||||
|
"rust/crates/mnote-web/src/routes/search.rs",
|
||||||
|
"rust/crates/mnote-web/src/routes/mindmap_shell.rs",
|
||||||
|
"rust/crates/mnote-web/src/routes/hermes.rs",
|
||||||
|
"wolai-frontend/src/components/search/SearchPaletteHost.tsx",
|
||||||
|
"wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx",
|
||||||
|
"wolai-frontend/src/app/mindmap/[docId]/[mindmapId]/page.tsx",
|
||||||
|
"scripts/task116-rust-web-islands-smoke.js"
|
||||||
|
],
|
||||||
|
"acceptance": [
|
||||||
|
"Rust Web 输出 Search server-first shell",
|
||||||
|
"Rust Web / Hermes 拥有 AI bridge session、tool event、client action contract",
|
||||||
|
"Rust Web 输出 Mindmap object shell,React runtime 只作为 island 或 legacy compat"
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web search_shell && cargo test -p mnote-web ai_bridge && cargo test -p mnote-web mindmap_shell && cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/lib/ai-agent/runtime/runAgent.test.ts src/lib/search/request.test.ts && cd /mnt/Data1T/mnote && node scripts/task116-rust-web-islands-smoke.js",
|
||||||
|
"timeout_seconds": 4200
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "Search server-first shell、Hermes AI bridge owner contract、Mindmap object shell 已落到 Rust Web;React runtime 标记为 island/legacy compat;task116 smoke 证明三类 owner 已迁移。",
|
||||||
|
"timestamp": "2026-04-28T12:44:04Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-28T12:44:04Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-026",
|
||||||
|
"title": "Phase W-7:退役 Next App Router 主入口并完成 03-rust-web 文档收口",
|
||||||
|
"status": "completed",
|
||||||
|
"priority": "P0",
|
||||||
|
"depends_on": [
|
||||||
|
"task-025"
|
||||||
|
],
|
||||||
|
"attempts": 1,
|
||||||
|
"max_attempts": 3,
|
||||||
|
"started_at_commit": "9f6a2c86",
|
||||||
|
"source_doc": "design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md",
|
||||||
|
"plan_doc": "design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md",
|
||||||
|
"scope": [
|
||||||
|
"ARCHITECTURE.md",
|
||||||
|
"design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md",
|
||||||
|
"design/03-rust-web/done/3-5-rust-web-main-execution-plane-cutover-plan-v1.md",
|
||||||
|
"design/03-rust-web/done/3-6-rust-web-main-execution-plane-checklist-v1.md",
|
||||||
|
"scripts/task117-next-retirement-guard.js"
|
||||||
|
],
|
||||||
|
"acceptance": [
|
||||||
|
"task117 证明 SKIP_NEXT_LEGACY=1 时核心 smoke 仍可通过",
|
||||||
|
"默认启动链不再把 pnpm dev 作为 3000 owner,3104 不恢复为公开入口",
|
||||||
|
"ARCHITECTURE.md 与 3-1 同步 Next App Router legacy compat 口径,本 plan/checklist 移入 done"
|
||||||
|
],
|
||||||
|
"validation": {
|
||||||
|
"command": "cd /mnt/Data1T/mnote && node scripts/task097-homepage-entry-smoke.js && node scripts/task114-rust-web-gateway-entry-smoke.js && node scripts/task115-rust-web-document-shell-smoke.js && node scripts/task116-rust-web-islands-smoke.js && node scripts/task117-next-retirement-guard.js && git diff --check -- ARCHITECTURE.md design/03-rust-web scripts wolai-frontend rust/crates/mnote-web",
|
||||||
|
"timeout_seconds": 4800
|
||||||
|
},
|
||||||
|
"on_failure": {
|
||||||
|
"cleanup": null
|
||||||
|
},
|
||||||
|
"error_log": [],
|
||||||
|
"checkpoints": [
|
||||||
|
{
|
||||||
|
"step": 1,
|
||||||
|
"total": 1,
|
||||||
|
"description": "task117 已证明 SKIP_NEXT_LEGACY=1 时核心 shell 不依赖 Next legacy;desktop-hot 支持跳过 Next legacy;ARCHITECTURE/3-1 已同步 Next App Router legacy compat 口径,3-5/3-6 已移入 done。",
|
||||||
|
"timestamp": "2026-04-28T12:50:07Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"completed_at": "2026-04-28T12:50:07Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"session_count": 8,
|
"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。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,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)]
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,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)]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载侧栏页面树 HTML(SSR)
|
||||||
|
///
|
||||||
|
/// 从 sidebar projection snapshot 中构建页面树 HTML 字符串。
|
||||||
|
/// 如果加载失败(如 Convex 未配置),返回空字符串,侧栏静默降级为无树状态。
|
||||||
|
/// 当 allow_dev_fixtures 启用且 Convex 不可用时,使用内建示例数据展示页面树。
|
||||||
|
pub(crate) async fn load_sidebar_tree_html(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
active_document_id: Option<&str>,
|
||||||
|
) -> Option<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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("<"));
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
@@ -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
@@ -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_PORT:Rust 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 owner,Next 仅作为 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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -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;
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user