收口 Rust Web 入口与 AI 写入链

- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。

- 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。

- 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。

- 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
This commit is contained in:
lix-2026
2026-05-06 21:44:20 +08:00
parent 98b6360595
commit e8ba12e461
86 changed files with 8872 additions and 1716 deletions
+1
View File
@@ -77,3 +77,4 @@ design/05-editor-mainline/reference-code
# 下载的 Wolai 静态页面参考包,不作为可维护设计稿入库
design/design/html/
recycle
+6
View File
@@ -79,6 +79,12 @@
- 后端开发:`cd /mnt/Data1T/mnote/wolai-backend && uvicorn app.main:app --reload --port 8000`
- Convex 自托管:参考 `/mnt/Data1T/mnote/infra/convex/README.md`
## 默认测试账号
- 后续网页测试、浏览和 smoke 默认使用当前 Convex Auth 测试账号:邮箱 `mnote.e2e@example.com`,密码 `MnoteE2E123!`,用户名 `mnote-e2e`
- 优先从 `http://localhost:3000/auth` 点击“测试账号快速登录”进入;若需手动注册,也必须注册同一组账号,不要改用 `MNOTE_DEV_AUTH=1` 跳过真实 auth。
- 需要核验登录是否真实生效时,优先检查当前 auth cookie/JWT 是否能读取到 Convex `users.currentUser`,避免把 `devFallback` 当成真实登录。
## 前端测试方法
- 看页面当前真实渲染结果、登录态下实际内容、JS 渲染后的 `localhost` 页面时,优先用 `/doko`;它适合读取真实 Chrome 中已经渲染完成的页面。
-22
View File
@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -47,7 +47,7 @@
- [x] `Phase 2` 文档阅读态分离已经有实质代码
- [x] `Phase 3` 主 Sidebar 已出现 `kernelSidebarTree` 消费接缝
- [x] `Phase 4` 搜索已做 host/runtime 拆分,并开始返回 `nodeId` / `subtreeRootId` / `evidence`
- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 Hermes bridge 传递 `node` / `subtree` / `outline` / `evidence`
- [x] `Phase 5` AI 面板已做 host/runtime 拆分,并开始向 AI bridge 传递 `node` / `subtree` / `outline` / `evidence`;长期执行面应收口到 `mnote-cli`Web 只作为 CLI host / client`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent
- [x] `Phase 6` Mindmap 独立页已去掉 `editorStub` 主入口
- [x] `Phase 7` 阅读态已直接消费 `pageSubtree``BlockNote` 也已不是文档页默认唯一入口
@@ -107,7 +107,7 @@
| Phase 2 | 文档阅读页 server-first 化 | `PARTIAL` 接近 `DONE` | 阅读态/编辑态已明显分离,但仍有旧链回退和大量客户端状态集中在 `DocumentContent` |
| Phase 3 | Sidebar / 树结构 Rust 化 | `PARTIAL` 偏早期 | 主 Sidebar 已以 `kernelSidebarTree` 作为主树来源,但整体仍是超大客户端组件 |
| Phase 4 | 搜索 Rust 化与 island 化 | `PARTIAL` | host/runtime 懒加载拆分已做,结果形状也开始带 `nodeId` / `subtreeRootId` / `evidence`,但仍未完成 server-first 搜索页 |
| Phase 5 | AI 面板 bridge island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 Hermes,但 runtime 仍重,协议也未统一到真正“最小壳” |
| Phase 5 | AI 面板 bridge island 化 | `PARTIAL` | host/runtime 拆分已做,且已开始把 `node` / `subtree` / `outline` / `evidence` 送入 AI bridge;长期执行统一落在 `mnote-cli`Web 面板只做 host / adapter,但 runtime 仍重,协议也未统一到真正“最小壳” |
| Phase 6 | Mindmap 独立对象化 | `PARTIAL` | 独立页脱离 editor stub 主入口,并补出 `standalone` / `documentBridge` 边界,但仍是客户端重壳 |
| Phase 7 | BlockNote 孤岛化 | `PARTIAL` | 阅读态已直接消费 `pageSubtree`,编辑器按需挂载,但外围 drawer/panel 仍集中在同一内容组件 |
| 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,旧链彻底删除仍待后续 |
@@ -154,7 +154,7 @@
- [x] 已有 request context / middleware
- [context.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/context.rs)
- [request_context.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/middleware/request_context.rs)
- [x] 已有最小 Hermes bridge route
- [x] 已有最小 Hermes bridge route;当前仅按兼容 AI bridge 记录,不作为长期 agent 执行面
- [hermes.rs](/mnt/Data1T/mnote/rust/crates/mnote-web/src/routes/hermes.rs)
- [x] 已有 SSE / WS / compat route 骨架
- [x] 已有真实 bridge / compat 接缝
@@ -290,7 +290,7 @@
---
## 10. Phase 5AI 面板进一步收口为纯桥接 island
## 10. Phase 5AI 面板进一步收口为 `mnote-cli` host / 纯桥接 island
**当前状态:`PARTIAL`**
@@ -301,7 +301,7 @@
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
- [x] Mindmap / OnlyOffice 也有类似 runtime 拆分
- [x] `GlobalAiAgentHost` 已不在 `(app)/layout.tsx` 主布局中挂载
- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 Hermes bridge
- [x] 文档页 AI 已开始把 `node` / `subtree` / `outline` / `evidence` 传给 AI bridge;长期执行面应收口到 `mnote-cli`,页面 AI 只保留为 CLI host / client`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent
- [DocumentAiAgentPanel.runtime.tsx](/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx)
- [route.ts](/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts)
@@ -311,7 +311,7 @@
- [ ] “最小会话协议、统一流式协议、页面上下文协议”还更多体现在组件内部,不是系统级协议层
- [ ] 还不能说 AI 面板已经变成“纯桥接壳”
- [ ] 页面级 AI adapter 仍然很大,只是改成了懒加载
- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还不是统一的 kernel-first tool 协议
- [ ] AI 已能消费 `node` / `subtree` / `outline` / `evidence` 上下文,但还不是统一的 `mnote-cli` kernel-first tool 协议;Web 面板也还没有彻底退成单纯 host / adapter
### 10.3 v2 后续任务
@@ -2,7 +2,7 @@
## 目标
将 Next App Router 从 3000 默认主链降级为显式 legacy/debug/internal 兼容边界。默认首页、文档页、搜索页、导图页、tree SSE 与 Hermes bridge 均由 `mnote-web` 承接。
将 Next App Router 从 3000 默认主链降级为显式 legacy/debug/internal 兼容边界。默认首页、文档页、搜索页、导图页、tree SSE 与 AI bridge host 均由 `mnote-web` 承接;长期 agent 执行面收口到 `mnote-cli`
## 当前 owner
@@ -11,7 +11,7 @@
- `/search`Rust Web `search::shell`Search projection owner `rust-kernel`
- `/mindmap/{doc_id}/{mindmap_id}`Rust Web `mindmap_shell::mindmap_object_shell`Mindmap projection owner `rust-kernel`
- `/api/tree/events`Rust Web SSEstream owner `rust-web`
- `/api/hermes/bridge`Rust Web Hermes bridgeAI bridge owner `rust-web-hermes`
- `/api/hermes/bridge`Rust Web 兼容 AI bridge;仅作为过渡边界,不是长期 agent 执行面
- `/api/compat/next/*`legacy compat boundary,仅用于迁移期兼容与调试。
## Gate
@@ -19,12 +19,12 @@
- `MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT` 默认关闭;只有显式设置为 `1/true/yes` 才允许 fallback proxy。
- 默认主路径不得返回 `x-mnote-legacy-upstream: next-app-router`
- 导图、搜索、文档页 contract 不得再把 `next-app-router` 声明为主 runtime。
- `/api/ai-agent/run` 仅声明 canonical route `/api/hermes/bridge`,结构化写入必须经 Hermes/Rust bridge
- `/api/ai-agent/run` 当前仍保留兼容 route,但长期应降为 `mnote-cli` host / adapter,结构化写入必须经 Rust runtime,外置 agent 不得拥有第二执行面
## 删除条件
- 删除 `/api/compat/next/sidebar`Sidebar、workspace shell、file tree smoke 均证明 Rust projection 可覆盖默认入口。
- 删除 `/api/compat/next/ai-agent/run`AI 面板默认请求 Hermestask126 smoke 通过,并且 legacy 调用方清零
- 删除 `/api/compat/next/ai-agent/run`AI 面板默认请求统一 CLI hostlegacy 调用方清零;Hermes 仅保留为可插拔外置 agent
- 删除 fallback proxy`task117` 默认关闭 legacy compat 后覆盖首页、文档页、搜索页、导图页与 tree SSE。
## 验收命令
@@ -25,6 +25,7 @@
- Rust 内核和 Web 承载层如何分工
- 前端哪些部分应该退出当前重 React 壳
- `axum``Leptos` 这类 Rust Web 方案是否值得采用
- `mnote-cli` 应如何成为唯一长期 agent 执行面
- `BlockNote` 应如何被隔离到最后处理
---
@@ -56,7 +57,7 @@
- **业务执行面:现有 Rust workspace 继续作为唯一业务真执行面**
- **Web 承载层:`axum`**
- **页面渲染模型:`Leptos Islands`**
- **AI 运行时:Hermes**
- **AI 执行面:`mnote-cli`**
- **最后保留的重前端孤岛:`BlockNote`**
一句话概括:
@@ -179,7 +180,7 @@
- 文档查询与聚合层
- 树结构装配层
- 搜索服务层
- AI bridge / Hermes bridge 层
- AI bridge / CLI host 层;长期 agent 执行面只收口到 `mnote-cli`Hermes 仅作为历史兼容 bridge / 外置 adapter
- 页面 SSR 外壳承载层
- 流式更新、通知、事件推送层
@@ -356,7 +357,7 @@ Leptos Islands 很适合承接下面这类长期目标:
到目前为止,长期路线里已经有几条可以被源码直接证明的最小里程碑:
- Rust Web 承载层已经有 `mnote-web` crate 骨架,`router / middleware / request context / SSE / WS / Hermes bridge` 的主接缝已立住。
- Rust Web 承载层已经有 `mnote-web` crate 骨架,`router / middleware / request context / SSE / WS / AI bridge` 的主接缝已立住;历史上这条线曾经包含 Hermes bridge
- 文档页已经从“默认先进编辑器”改成“先阅读、后编辑”:服务端先读 `meta + content`,阅读态单独渲染,`BlockNote` 只在进入编辑态后挂载。
- Sidebar 已形成“服务端首包 + 客户端局部 island”的最小边界,导航数据聚合契约不再散落在布局层。
- SearchPalette 与页面级 AI 面板都已经收成轻 host + 按需 runtime island,重量运行态不再默认跟随主布局常驻。
@@ -410,14 +411,14 @@ Leptos Islands 很适合承接下面这类长期目标:
目标:
- 继续桥接 Hermes
- 继续桥接统一 CLI host
- 不再承担前端本地 orchestration
- 不再成为常驻大壳的一部分
归位:
- 面板只保留最小 UI 与上下文桥接
- 工具执行全部走 Hermes + Rust tools
- 工具执行全部走 `mnote-cli` + Rust tools
- 面板按页面需要懒挂载成单独 island
### 9.5 Mindmap
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

@@ -0,0 +1,541 @@
# 5-10 [process] Wolai 页面设置与 AI 交互壳对齐方案 v1
> 更新时间:2026-05-06
>
> 关联文档:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-7-wolai-page-tree-main-editor-experience-restoration-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md`
> - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`
## 1. 文档目的
这份文档只回答一个问题:
> **在不制造第二份页面真相、不重开一条 AI 执行面的前提下,如何把 `3000` 文档页的“页面设置”和“AI 界面”收口成更接近 Wolai 的交互壳。**
这次不处理整条文档页体验复刻,也不把评论、协作、演示模式一起打包推进。
本轮优先级固定为:
1. 页面设置入口与面板形态
2. 页面级 AI 入口与右侧抽屉
3. 两者与现有 `Page Aggregate` / `mnote-cli` 主线的接线方式
一句话收口:
> **先把“入口和容器”做对,再逐项把内部动作接到现有正式命令链。**
---
## 2. 当前判断
### 2.1 当前 `3000` 的问题不是“没有能力”,而是“能力挂错了壳”
当前仓库里已经有可复用能力:
- 页面设置 UI 已存在:`PageOptionsSidebar`
- 页面设置写链已存在:`page.layout.updateOptions`
- 页面 AI host 已存在:`DocumentAiAgentPanel`
- AI 执行面已收口到 `mnote-cli` host / client
- 评论 drawer、历史 drawer、分享 dialog 都已有各自最小实现
但当前现网文档页仍然存在两个明显错位:
- 页面设置还是“右侧整块 inspector”思路,不是 Wolai 的右上角 `...` 贴边 popover
- 页面 AI 主要还是顶栏“页面AI”按钮思路,不是 Wolai 的右下角浮动入口 + 右侧抽屉
因此本轮不应重写一套新产品,而应优先做:
> **现有能力重新编排到正确交互壳。**
### 2.2 当前不该做的事
本轮明确不做:
- 不新造第二套页面设置数据结构
- 不新造第二套页面 AI runtime
- 不把 Web AI 面板重新升格成独立 AI 编排主线
- 不为了像 Wolai 而把评论、协作、成员、演示模式一并拉进首批实现
- 不在 Rust SSR 和 React compat 层同时各做一套相同按钮逻辑
---
## 3. Wolai 基线
2026-05-06 已通过 `wolai-aline` 基线取证得到当前 Wolai 行为证据,截图目录:
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/`
关键结论如下。
### 3.1 页面设置
- 入口位于右上角 `...`
- hover tooltip 为“页面选项和全局选项”
- 点击后打开右侧贴边 `popover`,不是 modal,也不是新页面
- URL 保持不变
- 没有遮罩,不压暗背景
- 关闭方式为:
- `Esc`
- 点击页面空白处
- 再次点击同一入口
- 顶部为 `页面选项 / 自定义页面 / 全局选项` 三个 tab
- 可见开关为 checkbox 形态
- 当前主 tab 内有:
- `自适应宽度`
- `小字体`
- `标题目录`
- `标题自动编号`
- `编辑保护`
- 下方还有动作项:
- `删除页面`
- `移动到...`
- `嵌入到...`
- `页面历史...`
- `公开分享页面...`
### 3.2 AI 界面
- 入口位于右下角浮动 `AI` 按钮
- 点击后打开右侧抽屉,不改 URL
- 抽屉没有遮罩,不压暗背景
- 当前关闭方式只有右上角 `X`
- `Esc` 无效
- 点击页面空白处无效
- 入口按钮在抽屉打开后被覆盖,不承担 toggle 关闭语义
- 抽屉标题为“智能问答”
- 中部存在推荐问题/快捷入口
- 底部存在会话区、模型入口、输入框和发送按钮
这说明本轮对齐的最小行为合同已经足够明确:
> **页面设置是“顶栏入口 + 贴边 popover”;页面 AI 是“右下角入口 + 右侧抽屉”。**
---
## 4. 设计原则
### 4.1 页面设置继续服从 `Page Aggregate`
页面设置只能是:
- `pageOptions`
- `editorRuntimePageOptions`
- 对应的 `page.layout.updateOptions`
不能变成:
- 某个单独 UI 组件内部维护的临时配置真相
- 一套只在文档页壳层生效、却不进入主编辑器 runtime 的新设置体系
因此,页面设置改造的重点不是多做几个开关,而是:
> **把现有设置项放进更接近 Wolai 的容器,同时继续显式区分“已正式接通”和“仅保存字段/待接通”。**
### 4.2 页面 AI 继续服从 `CLI-first`
页面 AI 界面可以继续做产品壳,但它不能重新变成独立执行面。
当前长期方向已经明确:
- `mnote-cli` 是唯一长期 agent 执行面
- Web AI 面板只是 host / client
因此本轮 AI 界面改造只处理:
- 入口位置
- 抽屉壳形态
- 页面级上下文绑定
- 与现有 `DocumentAiAgentPanel` 的挂载方式
本轮不处理:
- AI runtime 重写
- 新模型编排
- 新工具协议
- 第二套全局 AI 路由
### 4.3 先对齐容器语义,再推进动作项
Wolai 里页面设置和 AI 的第一感受,首先是:
- 从哪里打开
- 打开成什么容器
- URL 是否变化
- 如何关闭
这四项比内部是否一开始就全部可用更重要。
因此本轮优先级必须是:
1. 入口位置和按钮密度对齐
2. 容器形态和开关语义对齐
3. 内部动作项逐项复用现有能力
---
## 5. 本轮范围
### 5.1 范围内
- 文档页 owner 态右上角 `...` 页面设置入口
- 页面设置 `popover` 容器
- 页面设置 `tablist` 和最小项分组
- 页面设置最小动作项接线策略
- 文档页右下角浮动 `AI` 入口
- 页面 AI 右侧抽屉容器
- 页面 AI 与现有 `DocumentAiAgentPanel` 的页面级绑定
- 相应 smoke、截图和差异矩阵
### 5.2 暂不进入本轮
- 演示模式
- 页面评论
- 页面协作
- 成员邀请
- 完整公开分享体验重做
- 全局 AI 面板收口
- 帮助中心浮动入口的完整 Wolai 化
- AI 内部推荐内容、模型文案、提示词体系大改
补充:
- `页面历史...``公开分享页面...` 因为当前已有最小能力,可以作为本轮页面设置动作项的优先复用对象
- 评论、协作、成员这类能力虽然在 Wolai 顶栏可见,但当前不进入首批实现
---
## 6. 目标交互合同
## 6.1 页面设置
### 6.1.1 入口
- 入口继续位于文档页右上角操作区
- 视觉上以 `...` 弱按钮承载
- tooltip 调整为“页面选项和全局选项”
- 不再把“页面设置”理解成常驻右侧栏
### 6.1.2 容器
- 使用右侧贴边 `popover`
- 不使用全屏 modal
- 不使用带遮罩的 `Sheet`
- 打开后 URL 不变
- 面板宽度以 Wolai 的紧凑设置面板为准,不沿用当前宽侧栏比例
### 6.1.3 关闭语义
- `Esc` 关闭
- 点击空白关闭
- 再次点击入口关闭
### 6.1.4 内容结构
顶部固定三 tab
- `页面选项`
- `自定义页面`
- `全局选项`
页面选项首批保留并对齐的设置项:
- `wideLayout`
- `smallText`
- `showToc`
- `showHeadingNumbers`
- `protectEditing`
自定义页面首批保留:
- `pageFont`
- `layoutDensity`
- `collapseBacklinks`
- `hideChildPages`
- `showBlockRefCount`
- `embedDefaultBlockId`
全局选项首批只保留当前已有全局偏好中与文档体验直接相关的最小项,不在本轮扩面。
### 6.1.5 动作项
页面设置面板中的动作项分三类:
1. 本轮直接接线
- `页面历史...`
- `公开分享页面...`
2. 本轮保留入口但延后落地
- `移动到...`
- `嵌入到...`
- `删除页面`
3. 本轮不放入页面设置面板
- 评论
- 协作
- 成员邀请
- 演示模式
设计原因:
- `历史``分享` 当前已有组件可复用
- `移动到 / 嵌入到 / 删除` 与树命令、确认流和 picker 交互更深,应拆成后续小任务
- 评论/协作不在本轮优先级内
## 6.2 页面 AI
### 6.2.1 入口
- 文档页主入口改为右下角浮动 `AI` 按钮
- 顶栏“页面AI”不再作为主入口
- 首批可以先去掉顶栏 `页面AI`,或把它降为 debug/临时入口,不再作为默认视觉路径
### 6.2.2 容器
- 使用右侧抽屉
- 不使用遮罩
- 打开后 URL 不变
- 抽屉属于“页面级 AI”,不是全局 AI
### 6.2.3 关闭语义
- 右上角 `X` 关闭
- `Esc` 不关闭
- 点击页面空白不关闭
- 不要求入口按钮承担 toggle 关闭
### 6.2.4 内容结构
抽屉内部继续复用现有 `DocumentAiAgentPanel` 与其 runtime。
本轮只改:
- 外层 chrome
- 标题区与关闭按钮
- 页面级容器布局
- 文档页入口位置和打开方式
本轮不改:
- `mnote-cli` 执行链
- 工具注册表
- 页面 AI 读写命令族
- AI 输出协议
### 6.2.5 页面级与全局级边界
页面文档内优先展示“页面级 AI”。
全局 AI 继续保留其现有能力,但不在本轮文档页里抢主入口。长期关系应为:
- 文档页浮动 AI:页面上下文优先
- 全局 AI:跨页面/全局工作区能力
---
## 7. 实现落点
## 7.1 顶栏与入口层
当前文档页右上角操作主入口仍由:
- `wolai-frontend/src/components/breadcrumb.tsx`
负责。
因此本轮入口层优先落在 React compat 主路径,而不是先去大改 Rust SSR 占位按钮。
首批调整建议:
- `Breadcrumb` 负责右上角 `...` 入口
- `Breadcrumb` 不再突出“页面AI”主按钮
- 浮动 AI 入口改由文档页内容壳或页面级 host 提供
理由:
- 当前现网入口真正在这里
- 直接改这里可以最小改动验证体验
- 先把交互壳对齐,再决定 Rust SSR 侧是否同步收口
## 7.2 页面设置面板组件
当前 `PageOptionsSidebar` 是一个“常驻侧栏”组件,形态上不适合直接复用为 Wolai popover。
建议拆成两层:
1. `PageOptionsPanelContent`
- 只负责 tab、设置项、动作项内容
- 不假设自己是 `aside`
2. `PageOptionsPopover`
- 负责定位、宽度、开关语义、关闭语义
- 作为文档页 owner 态页面设置入口的正式容器
同时保留:
- `PageOptionsSidebar`
作为兼容/开发容器,避免 dev playground 与旧测试全部失效。
### 7.2.1 页面设置状态
当前 `usePageLayoutStore.showInspector` 语义过于偏“右侧常驻 inspector”。
本轮建议把它改造成更接近“页面 chrome 状态”的命名,例如:
- `pageSettingsOpen`
或等价 surface state,但不要把 AI 和页面设置混成一个大 store。
最小原则:
- 页面设置开关状态独立
- 不影响 AI drawer store
- 不重新发明 `pageOptions` 数据真相
## 7.3 页面设置动作项接线
动作项接线优先复用现有能力:
- `页面历史...` -> `DocumentHistoryDrawer`
- `公开分享页面...` -> `DocumentShareDialog`
本轮延后:
- `移动到...`
- `嵌入到...`
- `删除页面`
这些项可以先作为 disabled/coming soon,或只在 checklist 中保留为后续项,但不要假装完成。
## 7.4 页面 AI host
页面 AI 的正式 runtime 继续使用:
- `DocumentAiAgentPanel`
- `DocumentAiAgentPanel.runtime`
本轮只需要把“谁来打开它、以什么容器打开它”改对。
建议:
- 页面级浮动 AI 入口挂在 `DocumentContent` 这一层
- 页面 AI 打开状态继续用 `useAiAgentUiStore.documentAgentOpen`
- 页面 AI 可用态继续由 `DocumentAiAgentPanel` mount 生命周期维护
需要避免:
- 再新增一份 `PageAiDrawer` 独立 runtime
- 把页面 AI 又接回 `GlobalAiAgentHost`
## 7.5 与 Rust SSR 的关系
Rust `mnote-web` 当前在文档壳中已有顶栏占位按钮与右下角浮动按钮。
本轮不把 Rust SSR 作为首批真实交互 owner,原因是:
- 当前现网文档页真实入口仍由 React compat 壳主导
- 先在 React compat 层做对入口与容器,风险更低
- 等行为稳定后,再决定是否把 Rust SSR 占位按钮收口到同一 contract
这意味着本轮的正确做法是:
> **先统一产品行为合同,再决定是否让 Rust SSR 与 React compat 共用同一套入口渲染。**
---
## 8. 分阶段实施建议
## 8.1 Phase A:设计与基线冻结
- 固定 Wolai 页面设置和 AI 抽屉的动作链基线
- 新增专属 checklist
- 明确哪些页面设置项本轮保留、隐藏、降级
- 明确页面 AI 与全局 AI 的入口边界
## 8.2 Phase B:页面设置交互壳
-`PageOptionsSidebar` 抽成可复用内容组件
- 文档页顶栏 `...` 接入 `PageOptionsPopover`
- 对齐关闭语义、URL 不变、无遮罩
- 最小接线 `历史``分享`
## 8.3 Phase C:页面 AI 交互壳
- 增加右下角浮动 `AI` 入口
- 让页面 AI 通过右侧抽屉打开
- 去掉顶栏“页面AI”主入口地位
- 对齐 AI drawer 的关闭语义
## 8.4 Phase D:动作项与回归补齐
- 页面设置中逐步接通 `移动到 / 嵌入到 / 删除`
- 评估是否追加帮助浮动入口对齐
- 为 owner/published 两种状态补差异 smoke
---
## 9. 风险与约束
### 9.1 双 owner 风险
当前 `3000` 仍有:
- Rust SSR 产品壳
- React compat 真正交互壳
如果同时在两边各自补交互,容易再次分裂。
本轮约束:
> **入口行为先只认一套真实 owner。**
### 9.2 页面设置“看起来能改,实际没生效”的风险
这仍是页面设置线的核心风险。
本轮约束:
- 未接通项继续显式降级
- 不因为换成 Wolai 容器就把所有项都宣称正式可用
### 9.3 页面 AI 重复造轮子的风险
如果为了像 Wolai 而新做一层 AI drawer runtime,会直接偏离 `CLI-first`
本轮约束:
> **页面 AI 只换壳,不换执行面。**
### 9.4 范围失控风险
页面设置一旦带上评论、成员、协作、演示模式,很容易从“交互壳对齐”膨胀成“整页顶栏重做”。
本轮约束:
- 优先做页面设置
- 优先做页面 AI
- 其他项延后
---
## 10. 验收标准
只有同时满足以下条件,才可以说这条线进入实现阶段:
- 页面设置入口、容器、关闭语义已和 Wolai 基线一致
- 页面 AI 入口、容器、关闭语义已和 Wolai 基线一致
- 页面设置仍继续走 `page.layout.updateOptions`
- 页面 AI 仍继续走 `DocumentAiAgentPanel -> mnote-cli`
- 没有新增第二套页面设置真相
- 没有新增第二套页面 AI 执行面
- 评论、协作、成员、演示模式没有被混入首批范围
本轮完成后的正确口径应是:
> **文档页“页面设置”和“AI 界面”的交互壳开始按 Wolai 收口,但页面域单一真源与 CLI-first AI 的主线保持不变。**
@@ -0,0 +1,359 @@
# 5-11 [process] Wolai 页面设置与 AI 交互壳连续执行 checklist v1
> 更新时间:2026-05-06
>
> 本清单服务于:
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-10-wolai-page-settings-and-ai-surface-alignment-v1.md`
>
> 执行口径继续统一服从:
> - `/home/lix/.codex/skills/wolai-aline/SKILL.md`
> - `/mnt/Data1T/mnote/design/08-wolai-aline-test-flow/process/wolai-aline-test-flow-v1.md`
## 1. 使用方式
每次只领取一个最小行为,按下面闭环推进:
1. 用一句话定义行为,例如“右上角 `...` 打开页面设置 popover 且 URL 不变”。
2. 派 subagent 先取 Wolai 基线。
3. 主线程复核截图并填写差异矩阵。
4. 新增或更新本地 smoke,先让当前实现失败。
5. 小范围实现,不新造第二份页面真相或 AI 执行面。
6. 运行本地 smoke 和相关单测。
7. 再派 subagent 对 Wolai 与本地复测。
8. 主线程复核最终截图。
9. 更新本清单状态、证据路径和剩余差异。
状态标记:
- `TODO`:未开始
- `BASELINE`Wolai 基线已取
- `RED`:本地失败 smoke 已存在
- `GREEN`:本地实现与 smoke 已通过
- `PARITY`:subagent 复测和主线程截图复核通过
- `BLOCKED`:存在阻塞
---
## 2. 当前基线证据
2026-05-06 已完成 Wolai 页面设置与 AI 界面的只读基线取证,目录:
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/`
当前已确认事实:
| 项 | Wolai 证据 | 当前结论 |
| --- | --- | --- |
| 页面设置入口 | `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/10-page-settings-open.png` | 右上角 `...` 打开右侧贴边 popoverURL 不变 |
| 页面设置关闭 | `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/11-page-settings-after-esc.png` `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/13-page-settings-after-outside-click.png` `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/15-page-settings-after-reclick.png` | `Esc`、点击空白、再次点击入口都可关闭 |
| 页面设置结构 | `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/10-page-settings-open.json` | 3 个 tab,内部主控件是 checkbox 形态 |
| AI 入口 | `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/20-ai-open.png` | 右下角浮动 `AI` 按钮打开右侧抽屉,URL 不变 |
| AI 关闭 | `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/21-ai-after-esc.png` `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/23-ai-after-outside-click.png` `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/27-ai-after-close-button.png` | `Esc` 和点击空白无效,只能点右上角 `X` |
| AI 结构 | `/mnt/Data1T/mnote/tmp/wolai-editor-parity/page-settings-ai-baseline/20-ai-open.json` | 标题“智能问答”,有会话区、模型区、输入框和发送按钮 |
### 2.1 用户追加截图复核(2026-05-06
用户追加的当前 `3000` 截图:
- `/mnt/Data1T/mnote/tmp/image copy 44.png`
- `/mnt/Data1T/mnote/tmp/image copy 45.png`
当前补充判断:
- `image copy 44.png``image copy 45.png` 暴露的是三类真实问题:页面设置 tab-panel 混显、页面设置保存后回退、页面 AI 抽屉没有真实文本返回。
- 这三类问题已经在本轮代码修复中逐项落地:
- `styles.rs`:补 `[hidden]` surface 样式,修复 `页面选项 / 自定义页面 / 全局选项` 混显。
- `documents.rs + bridge-runtime + document.rs + layout.rs`:修复页面设置写链、读链和 SSR 首屏注入,解决“闪一下又复原”。
- `compat.rs + layout.rs`:修复真实 `/api/ai-agent/run` 返回文本,页面 AI 默认 provider 对齐为 `hermes`
---
## 3. 通用证据矩阵
每项必须记录下列字段:
| 字段 | 要求 |
| --- | --- |
| Wolai 截图 | 绝对路径,保存在 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/<task>/` |
| 本地截图 | 同视口、同动作链截图 |
| 入口 | 按钮、菜单、浮动入口、快捷键 |
| 关闭路径 | `Esc`、点击空白、再次点击入口、关闭按钮 |
| URL | 操作前后是否变化 |
| 容器类型 | `popover``drawer``sheet``modal` |
| 控件类型 | `checkbox``button``tab``textarea` 等 |
| 控件状态 | `checked``selected``focused``disabled``active` |
| DOM/ARIA | `role``aria-*``data-testid`、可聚焦性 |
| smoke | 对应 `scripts/task*-smoke.js` 或测试文件 |
| 剩余差异 | 不允许只写“基本一致” |
---
## 4. Phase A:页面设置基线与壳层
| ID | 状态 | 任务 | 验收要点 |
| --- | --- | --- | --- |
| A1 | BASELINE | Wolai 页面设置基线 | 已有 2026-05-06 Wolai 基线截图和 JSON |
| A2 | GREEN | 本地页面设置入口现状基线 | `task160` 已记录并复测当前文档页右上角 `更多` 打开页面设置容器,URL 不变 |
| A3 | GREEN | 页面设置入口 tooltip | `task160` 已断言入口 `title="页面选项和全局选项"` |
| A4 | GREEN | 页面设置容器类型 | `task160` 已断言本地打开右侧贴边 `popover`,不再是空按钮 active |
| A5 | GREEN | 页面设置 URL 合同 | `task160` 已断言打开和关闭页面设置时 URL 不变化 |
| A6 | GREEN | 页面设置关闭语义 | `task160` 已断言 `Esc`、点击空白、再次点击入口全部有效 |
| A7 | GREEN | 页面设置 3 tab | `task160` 已在真实后端端口上断言 `页面选项 / 自定义页面 / 全局选项` 的 tab-panel 隔离行为 |
| A8 | GREEN | 页面选项主控件形态 | `task160` 已断言首 tab 使用 checkbox 行,而不是旧 inspector 大块按钮 |
| A9 | GREEN | 最小设置项保留清单 | `task160` 已断言 `wideLayout / smallText / showToc / showHeadingNumbers / protectEditing` 出现在首 tab |
| A10 | GREEN | 未接通项显式降级 | `task160` 已断言 `showToc / protectEditing` 当前 disabled 且带“待接线”说明 |
### 4.1 本阶段 smoke 建议
- `scripts/task160-wolai-page-settings-shell-smoke.js`
首轮最小断言建议:
- 点击右上角 `...` 后 URL 不变
- 出现页面设置 `popover`
- 出现三 tab
- `Esc` 可关闭
- 点击页面空白可关闭
- 再次点击入口可关闭
### 4.2 task160 页面设置壳执行记录
2026-05-06 已新增并执行 `scripts/task160-wolai-page-settings-shell-smoke.js`,用于固化 A2-A7 的本地 RED 基线。
RED
- 命令:`node scripts/task160-wolai-page-settings-shell-smoke.js`
- 结果:失败
- 失败信息:`页面设置入口必须打开右侧贴边 popover`
- 当前本地状态:
- 点击右上角 `更多` 后 URL 保持文档页不变
- 顶栏 `更多` 获得焦点,但没有打开页面设置容器
- 当前页面中未出现页面设置 popover;探测到的唯一 `tablist` 仍是左侧 `我的页面 / Explorer / +`
证据路径:
- 本地点击后截图:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/01-after-click-more.png`
- 本地点击后状态:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/01-after-click-more.json`
- 本地失败截图:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/failure.png`
- 本地失败状态:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/failure-state.json`
当前判断:
- A2 已有本地基线
- A4 已形成真实 RED
- A5 目前从 failure-state 可见点击后 URL 未变化,但完整打开/关闭合同仍待容器实现后继续验证
- A6-A7 仍待容器出现后继续验证
GREEN
- 命令:`node scripts/task160-wolai-page-settings-shell-smoke.js`
- 结果:通过
- 当前通过行为:
- 右上角 `更多` 打开页面设置 `popover`
- URL 保持不变
- `Esc`、点击空白、再次点击入口均可关闭
- 顶部存在 `页面选项 / 自定义页面 / 全局选项`
- 页面设置首 tab 使用 checkbox 行
- `showToc / protectEditing` 当前显式降级
新增证据:
- `task160` 最终截图:
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/01-after-click-more.png`
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/02-after-esc.png`
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/03-after-outside-click.png`
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task160-page-settings-shell/04-after-reclick.png`
修复补充(2026-05-06):
-`styles.rs` 中补了 `.wolai-page-settings-section[hidden] { display: none !important; }`
- `task160` 随后已在真实后端端口 `41327` 上验证 `页面选项` 不再混出 `自定义页面` 字段,`自定义页面 / 全局选项` 的 panel 行为恢复正常
---
## 5. Phase B:页面设置动作项与现有能力接线
| ID | 状态 | 任务 | 验收要点 |
| --- | --- | --- | --- |
| B1 | GREEN | `页面历史...` 接线 | `task160` 已断言页面设置动作项可打开 Rust Web 页面历史抽屉 |
| B2 | GREEN | `公开分享页面...` 接线 | `task160` 已断言页面设置动作项可打开 Rust Web 公开分享对话框 |
| B3 | GREEN | `移动到...` 入口策略 | `task160` 已断言当前显式为 disabled/placeholder |
| B4 | GREEN | `嵌入到...` 入口策略 | `task160` 已断言当前显式为 disabled/placeholder |
| B5 | GREEN | `删除页面` 入口策略 | `task160` 已断言当前显式为 disabled/placeholder |
| B6 | GREEN | 统计信息布局 | `task160` 已断言底部存在紧凑统计信息区 |
| B7 | GREEN | 页面设置项真实生效 | `task160` 已在真实后端端口上断言 `wideLayout``layoutDensity` 持久化并在刷新后回读成功 |
### 5.1 本阶段 smoke 建议
-`task160` 基础上扩展动作项断言
- 新增针对已接通选项的可见结果断言
最低要求:
- 历史入口可打开现有历史抽屉
- 分享入口可打开现有分享对话框
- `wideLayout``smallText``layoutDensity` 这类已接通项能在页面可见变化中被捕获
### 5.2 task160 Phase B 执行记录
2026-05-06 `task160` 已继续覆盖 B1-B7,当前通过:
- `页面历史...` 可打开 `data-testid="wolai-page-history-drawer"`
- `公开分享页面...` 可打开 `data-testid="wolai-page-share-dialog"`
- `移动到... / 嵌入到... / 删除页面` 当前显式 disabled
- 页面设置底部显示字数、字符、块数、待办统计
- `wideLayout` 写链真实命中 `/api/documents/options`
- `layoutDensity` 写链真实命中 `/api/documents/options`
- `wideLayout` 打开后 `.document-shell` 宽度真实增大
修复补充(2026-05-06):
- 先修了 `mnote-web` 页面设置 route payload 与 `bridge-runtime` command plan:不再把 `workspaceId` 和一组 `null` 选项继续下发到 `documents:updateOptions`
- 再修了 `DocumentPage` 的 SSR `data-page-*` 首屏注入,以及 `SIDEBAR_TREE_JS` 初始化顺序,避免在 `__MNOTE_PAGE_AGGREGATE__` 还未进入 DOM 时把默认 page options 缓存死
- 之后通过真实后端端口 `41327` 验证:
- `/api/documents/options` 返回 200
- `/api/page-aggregate/:id` 能读回 `wideLayout=true``layoutDensity=compact`
- `task160` 刷新后 checkbox 和壳层 attr 不再回退
---
## 6. Phase C:页面 AI 入口与抽屉壳
| ID | 状态 | 任务 | 验收要点 |
| --- | --- | --- | --- |
| C1 | BASELINE | Wolai 页面 AI 基线 | 已有 2026-05-06 Wolai 基线截图和 JSON |
| C2 | GREEN | 本地页面 AI 入口现状基线 | `task161` 已记录并复测右下角 AI 可打开页面 AI 抽屉,URL 不变 |
| C3 | GREEN | 页面 AI 主入口迁到右下角 | `task161` 已断言页面级 AI 主入口为右下角浮动按钮 |
| C4 | GREEN | 顶栏 `页面AI` 降级 | `task161` 已断言顶栏不再保留 `页面AI` 主入口 |
| C5 | GREEN | 页面 AI 容器类型 | `task161` 已断言页面 AI 以右侧抽屉打开、无遮罩、不跳新页面 |
| C6 | GREEN | 页面 AI 关闭语义 | `task161` 已断言 `Esc`、点击空白无效,右上角 `X` 有效 |
| C7 | GREEN | 页面 AI 页面级绑定 | `task161` 已断言 `/api/ai-agent/run` 请求体携带当前 `documentId``pageOptions` |
| C8 | GREEN | 页面 AI 执行面不分裂 | 真实 `/api/ai-agent/run` 已改为 `Next 兼容优先 -> 本地 mnote-cli fallback -> 旧 orchestrator 兜底``task162` 已验证真实页面可返回文本结果 |
| C9 | GREEN | AI chrome 与 Wolai 接近 | `task161` 已断言标题、输入区、`新会话 / 历史会话 / mnote-cli` chrome 可见 |
### 6.1 本阶段 smoke 建议
- `scripts/task161-wolai-page-ai-shell-smoke.js`
首轮最小断言建议:
- 点击右下角 AI 后 URL 不变
- 打开右侧抽屉
- 抽屉无遮罩
- `Esc` 不关闭
- 点击页面空白不关闭
- 点右上角关闭按钮可关闭
### 6.2 task161 页面 AI 壳执行记录
2026-05-06 已新增并执行 `scripts/task161-wolai-page-ai-shell-smoke.js`,用于固化 C2-C6 的本地 RED 基线。
RED
- 命令:`node scripts/task161-wolai-page-ai-shell-smoke.js`
- 结果:失败
- 失败信息:`页面 AI 入口必须打开右侧抽屉`
- 当前本地状态:
- 点击右下角 `AI 助手` 后 URL 保持文档页不变
- 浮动 AI 按钮没有打开抽屉
- 当前页面中未出现页面 AI 容器、关闭按钮、标题或输入框
证据路径:
- 本地点击后截图:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/01-after-click-ai.png`
- 本地点击后状态:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/01-after-click-ai.json`
- 本地失败截图:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/failure.png`
- 本地失败状态:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/failure-state.json`
当前判断:
- C2 已有本地基线
- C5 已形成真实 RED
- C6 仍待抽屉出现后继续验证
- C7-C8 仍待实现阶段继续验证
GREEN
- 命令:`node scripts/task161-wolai-page-ai-shell-smoke.js`
- 结果:通过
- 当前通过行为:
- 右下角 AI 入口打开页面级右侧抽屉
- URL 保持不变
- `Esc` 和点击空白不会关闭
- 右上角关闭按钮可关闭
- 抽屉标题、输入区、会话/模型 chrome 可见
- 发送动作继续命中 `/api/ai-agent/run`
- 请求体继续携带当前 `documentId``pageOptions`
新增证据:
- `task161` 最终截图:
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/01-after-click-ai.png`
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/02-after-esc.png`
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/03-after-outside-click.png`
- `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task161-page-ai-shell/04-after-close-button.png`
修复补充(2026-05-06):
- `compat.rs``/api/ai-agent/run` 已改为:
- 先尝试代理到 Next 的 `/api/ai-agent/run`
- 失败时本地直接 `mnote-cli` host fallback
- 再失败才走旧 orchestrator
- 同时补了认证头显式转发,避免被 Next 侧 307 `/auth` 拦截
- 页面 AI drawer 默认 provider 改为 `hermes`
- 新增 `task162-wolai-page-ai-real-response-smoke.js`,并在真实后端端口 `41327` 上验证:页面 AI 不再显示 `page_ai_failed_502` / “没有返回文本结果”,而是会回填真实文本
### 6.3 task162 页面 AI 真实返回执行记录
2026-05-06 已新增并执行 `scripts/task162-wolai-page-ai-real-response-smoke.js`,用于补齐 C8 的真实返回验证。
- 命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:41327 node scripts/task162-wolai-page-ai-real-response-smoke.js`
- 结果:通过
- 当前通过行为:
- 页面 AI 不再出现 `page_ai_failed_502`
- 不再显示“当前页面 AI 已接通 \`mnote-cli\`,但这次没有返回文本结果。”
- 页面 AI 会回填真实 `mnote-cli` 输出文本
---
## 7. Phase D:页面 AI 与页面设置的集成护栏
| ID | 状态 | 任务 | 验收要点 |
| --- | --- | --- | --- |
| D1 | GREEN | 不新造页面设置真相 | 页面设置继续围绕 `currentPageOptions -> /api/documents/options -> page.layout.updateOptions` |
| D2 | GREEN | 不新造 AI 执行面 | 页面 AI 继续围绕 `/api/ai-agent/run -> mnote-cli` |
| D3 | GREEN | 文档页 owner 行为合同固定 | `task160/task161` 已断言 owner 态页面设置和 AI 都不跳新页面、不改 URL |
| D4 | GREEN | mobile / 窄屏退化 | `task160/task161` 已断言移动视口下 popover / drawer 不超出视口 |
| D5 | GREEN | React compat 与 Rust SSR 边界 | 本轮实现仅落在 `mnote-web` Rust SSR 壳、脚本与样式;未再把入口回接 React compat |
| D6 | GREEN | checklist 与失败模式回填 | 已向 `wolai-aline/references/failure-patterns.md` 新增“本地 3000 进程未重启,smoke 误打旧实现” |
---
## 8. Deferred 清单
以下项当前明确延后,不混入首批验收:
| ID | 状态 | 任务 | 说明 |
| --- | --- | --- | --- |
| X1 | TODO | 演示模式 | 不是本轮页面设置与 AI 壳的主线 |
| X2 | TODO | 页面评论 | 用户已明确可延后 |
| X3 | TODO | 页面协作 | 用户已明确可延后 |
| X4 | TODO | 成员邀请 | 顶栏 owner 态能力,后续单拆 |
| X5 | TODO | 全局 AI 入口重构 | 本轮只做页面级 AI |
| X6 | TODO | 帮助浮动入口完全对齐 | 本轮优先 AI,不把帮助入口一起扩面 |
---
## 9. 完成判定
本清单只有在以下条件同时成立时,才可以视为本轮设计进入可实现状态:
- 页面设置 `popover` 行为已通过本地 smoke 和 Wolai 基线复核
- 页面 AI 抽屉行为已通过本地 smoke 和 Wolai 基线复核
- 页面设置仍然围绕 `page.layout.updateOptions`
- 页面 AI 仍然围绕 `/api/ai-agent/run -> mnote-cli`
- deferred 项没有被误报为已完成
本轮完成后的正确口径:
> **页面设置与页面 AI 的交互壳已开始对齐 Wolai,但评论、协作、成员、演示模式仍是后续独立任务。**
@@ -390,6 +390,16 @@ GREEN
- E24 图片最小真源闭环:2026-05-01 已按 Wolai E13 slash 基线确认 `媒体与附件` 分组存在 `图片 /tp``最近上传图片 /tp`;第一段实现 `图片 /tp` 最小切片,参考 `leptos-tiptap` 官方 `TiptapImageResource` / `set_image` bridge`src/api/types/extensions/image.rs``src/api/commands.rs``tiptap/src/extensions/tiptap_image.ts`)与 template `components/tiptap-node/image-node/*``image-upload-node/*`,不手写 image JSON。当前本地 `SLASH_ACTIONS` 已补 `图片 /tp``媒体与附件` 分组,`p0_extensions()` 显式启用 `TiptapExtension::Image`,点击后调用 `editor.set_image(TiptapImageResource { ... })` 插入真实 Tiptap `image` 节点;`core-protocol` 新增 `EditorBlockType::Image` / `TiptapNode::Image``bridge-runtime` 保存为 legacy `image` 且保留 `props.tiptapImage``mnote-web` bootstrap 从 content API 恢复为 `<img>`;本地只读占位资源走 `/api/editor/image-placeholder.svg`,避免 smoke 依赖外网或破图。第二段补图片点击选中态与图片专用 floating toolbar,参考 `tiptap-notion-like-registry/materialized/tiptap-node/image-node/image-node-floating.tsx``image-node-extension.ts``tiptap-ui/image-align-button/use-image-align.ts``tiptap-ui/image-download-button/use-image-download.ts`:本地点击 `<img>` 后展示 `image-floating-toolbar`,提供左/中/右对齐和同源图片下载,删除入口本切片保持禁用;图片对齐通过 `Image.extend({ addAttributes: { "data-align" } })` 扩展官方 Image schema 后调用 `editor.update_attributes(TiptapSchemaTarget::Node(TiptapNodeName::Image), ...)`,不是 UI 伪样式;同源下载只读当前 NodeSelection 图片 DOM 的 `src/title/alt`,用隐藏 `<a download>` 触发,不触发保存链。`task152-e24-image-smoke.js` 覆盖 slash 入口、DOM `<img>`、图片实际加载、图片 toolbar、同源下载事件、删除入口禁用态、居中对齐 `data-align`、保存请求 `tiptapDocument``/api/documents/content` image 真源与 reload 恢复;通过截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task152-e24-image-download-green`。剩余:真实上传、最近上传图片、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、上传失败态另拆后续。
- E25 TOC 最小真源闭环:2026-05-01 已按 Wolai slash 基线确认 `页面目录 /toc`,并参考 `tiptap-notion-like-template/src/components/tiptap-node/toc-node/*``toc-show-title-button/*``toc-sidebar/*``notion-like-editor.tsx``TableOfContents.configure({ onUpdate })` 的数据链路。当前本地先完成第一段:`leptos-tiptap` 新增 `toc_node` bridge 扩展,真实 Tiptap schema 名为 `tocNode`slash `页面目录 /toc` 调用 `editor.insert_toc_node(TiptapTocNodeAttrs { top_offset: 0, max_show_count: 20, show_title: true })`,不是普通 div 或手写 spike JSONNodeView 从当前 Tiptap doc 的 heading 节点派生目录项,支持点击目录项定位并更新 hash,支持标题显示开关并通过 `updateAttributes("tocNode", { showTitle })` 持久化。保存链新增 `EditorBlockType::Toc` / `TiptapNode::TocNode``bridge-runtime` legacy content 保留 `props.tiptapTocNode``mnote-web` reload 恢复 `tocNode``task153-e25-toc-smoke.js` 覆盖 `/toc` 入口、真实 tocNode DOM、heading 列表派生、动态 heading 更新、点击定位、标题开关、保存请求、content API 和 reload 恢复。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task153-e25-toc-local-smoke`。剩余:接入 Tiptap 官方 `@tiptap/extension-table-of-contents` v3 数据源或完成等价 bridge 评估、TOC sidebar、active heading 高亮、完整块菜单标题开关入口、Wolai 像素级视觉、移动端行为、UniqueID/anchor 统一化。
- E26 执行前口径纠偏:2026-05-02 已按 `5-2``5-4``5-5``5-6``5-7` 设计确认,E26 不是“前端引入 Tiptap UniqueID 即完成”。正式块身份必须来自 Rust `EditorBlock.block_id`,经 Tiptap bridge 映射为 `data-block-id` 供浏览器 runtime 使用,再通过 `page.body.save` 写回 Convex-backed 持久化底座;复制锚点和 hash 定位只能消费这个真源 id。Tiptap `UniqueID` 可以参考其节点身份策略,但不得生成长期块 id、不得绕过 Rust `EditorBlockDocument`、不得绕过 Page Aggregate / `page.body.save`
- E26 执行边界:实现与 smoke 必须把 `EditorBlockDocument -> Tiptap attrs.blockId/data-block-id -> DOM 锚点 -> page.body.save -> /api/documents/content -> reload/hash 定位` 串成一条链。允许在浏览器 runtime 内用 `UniqueID` 或等价插件帮助定位节点,但只能读取/补齐已有 Rust block id;若某块缺少正式 id,应通过 Rust/保存适配链生成并持久化,而不是让前端临时 id 成为长期合同。Convex 只验证持久化结果可读可刷新,不作为语义真源。
- E26 最小真源闭环:2026-05-02 已完成第一段 anchor 切片。`leptos-tiptap` paragraph/heading/blockquote/codeBlock/image/table 的 `blockId` 渲染同时输出 `data-block-id` 与 DOM `id`,浏览器 hash 命中走 `id=:blockId` + CSS `:target`,不是手工给 ProseMirror DOM 写临时 classRust runtime 复制链接和 hash 滚动继续只消费 `EditorBlock.block_id` 派生的 `attrs.blockId``mnote-web` 保存 payload 不再发送空 `content: []`,而是派生 `editorDocument`、legacy `content``tiptapDocument``blockCount` 写回 Convex-backed 持久化底座;reload 侧 legacy block 恢复继续补 `attrs.blockId`。本地 smoke `node scripts/task154-e26-anchor-smoke.js` 已覆盖保存请求、`/api/documents/content`、复制链接、DOM `id/:target` 和 reload/hash 定位,截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke`。剩余:列表项/分割线等更多块类型的统一 anchor attr 覆盖、Wolai 视觉细节、块引用/页面引用预览与移动端行为另拆后续。
- E27 执行前口径纠偏:AI 编辑能力包不是“补 AI 菜单文案”或“点击后显示 feedback”。最小闭环必须从 `leptos-tiptap` 的 slash / 块菜单入口出发,携带当前 `documentId``workspaceId`、Rust `blockId`、selection 摘要和 Tiptap JSON 快照,调用 mnote 现有 AI 真源边界。2026-05-05 起长期口径进一步修正:`/api/ai-agent/run` 不应长期绑定 `openai-agents-python` sidecar,而应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter`openai-agents-python``Hermes``Codex` 只允许作为可插拔外置 agent。结构化写入仍必须回到 Rust tool / `page.body.save` / Convex-backed content 链。E27 第一刀已完成的 sidecar 路径只作为过渡基线,不宣称是长期主线。
- E27 AI agent 入口与写入闭环:2026-05-02 已完成主路径纠偏,修正此前把 `/api/hermes/bridge` 当作 E27 主入口的误导口径。块菜单 `AI 助理` 现在从当前 `leptos-tiptap` editor 读取 `documentId``workspaceId`、Rust `blockId`、selection state、selected text 与 `tiptapDocument` 快照,发起 `/api/ai-agent/run` 请求,payload 固定 `scope=document``stream=true``options.ai.provider=online`,并显示 `mnote-leptos-tiptap-ai-status``idle/pending/ready/error` 状态;请求上下文标记 `source=leptos-tiptap-island``action=ask_ai`Next sidecar adapter 已透传 `workspaceId / selectedBlockId / selection / tiptapDocument` 等 block 级上下文给 `openai-agents-python`。本地 smoke `node scripts/task155-e27-ai-edit-smoke.js` 已先 RED 于 Hermes 主入口,再 GREEN 覆盖请求 URL、payload 真源字段和 ready 状态;`node scripts/task156-e27-ai-writeback-smoke.js` 已先 RED 于只返回不写入,再 GREEN 覆盖 SSE `doc_replace_range` tool_result -> 编辑器改写 -> `/api/documents/save` -> `/api/documents/content` 真源读回 -> reload 后页面读回。截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke`。剩余:Wolai AI 菜单视觉基线断流未完成、slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate 子命令、SSE 分段 UI 状态、`doc_insert_blocks` 多块插入和标题 `slash_run` 应用另拆后续。
- E27 Auth 真源纠偏:2026-05-02 补充确认,AI 编辑和在线 smoke 的用户身份必须以真实 Convex Auth 为主线;不同账号的数据隔离依赖 Convex `getAuthUserId(ctx)` 解析真实 identity subject,并由 workspace/member 权限链校验。`/auth` 复用既有 Convex Auth 页面和统一测试账号 `test@example.com` / `Test123456``/api/auth/session``/api/auth/whoami`、AI orchestrator 转发和 Convex transport 均应真实 token / forwarded actor 优先。`DEV_USER_ID` / admin acting identity 只允许作为本地底层 fallback 或显式 `MNOTE_DEV_AUTH=1` 联调模式,不能作为 E27 在线验收、多账号隔离或正式数据真源。
- E27 当前推进备注:2026-05-03 主线程先切到 E28 Mention / EmojiE27 暂停在 online smoke 模型网关层排障状态。当前已确认 `3000 -> 8000` orchestrator 主链可达,本地 `task155/task156` 通过;剩余在线阻塞点在 `20128 /v1/responses` 上游模型/渠道可用性,以及后续真实 actor 收口。恢复 E27 时应从这两点继续,不要回退成“主入口未迁完”。
- E28/E29 暂停与 E30 先行口径:2026-05-03 主线程先暂停 E28 Mention / Emoji 与 E29 Comment / History,转入 E30 Menu / Floating 状态机能力包。E28 已有 Wolai Hermes baseline 显示正文输入 `@` 当前弹出提醒/会议/成员候选,并可插入成员 mention,不是页面引用搜索;证据目录为 `/mnt/Data1T/mnote/tmp/wolai-editor-parity/task157-e28-wolai-mention-baseline/`,恢复 E28 时需先重新定 `@mention` 口径,不要按“页面引用第一刀”继续。E29 在评论/历史后端边界未重新确认前保持暂停。E30 第一刀只收敛已有 slash、块菜单、二级菜单、selection/image/table floating toolbar 的打开互斥、Esc、外部点击、方向键、Enter 与层级收起,不扩新业务命令。
- E30 Menu / Floating 状态机第一刀:2026-05-03 已参考 `use-floating-element``use-menu-navigation``use-floating-toolbar-visibility` 的集中可见性/键盘/关闭模型,把本地已有 slash、块菜单、selection toolbar、image toolbar、table toolbar/options 的关闭与互斥收口到 `close_editor_floating_overlays*``open_*_overlay` 入口;`task158-e30-menu-state-smoke.js` 覆盖 slash `ArrowDown` active、Esc 关闭、块菜单二级菜单、selection color panel、image toolbar、table options、slash 打开关闭 selection toolbar、块菜单打开关闭 image toolbar。Wolai 基线目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-wolai-baseline/`;本地主线程截图目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-menu-state-local-smoke-main3`;本地 subagent 复测目录:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-local-postfix-subagent`。剩余:Wolai slash / 块菜单二级菜单在只读条件下未稳定验证,Enter 选中、外部点击和更多三级菜单收起需后续 editable-test 小切片继续补。
- E31 Auth 入口回收:2026-05-04 已把 `3000``/auth` 收回为本地 `mnote-web` SSR 登录页,不再代理 3100;未登录访问 `/` 现在 303 到 `/auth`,已登录(forwarded actor / Convex Auth cookie)才进入工作区。`task159-auth-entry-smoke.js` 覆盖 `/auth` 本地 200、`/` 未登录 303、已登录 200、无第三方快捷登录/隐私政策文案,`task114-rust-web-gateway-entry-smoke.js``task117-next-retirement-guard.js` 已同步更新。截图待复核:`/mnt/Data1T/mnote/tmp/wolai-editor-parity/task159-auth-entry-baseline/`
| ID | 状态 | 任务 | 验收要点 |
@@ -409,11 +419,11 @@ GREEN
| E23 | PARTIAL | TableKit 能力包 | 已完成简单表格可验收切片:`leptos-tiptap` 增加 table/tableRow/tableCell/tableHeader schemaslash `简单表格 /jdbg` 按 Wolai 基线插入 `4x3` 真实 `<table>`,本地 table toolbar 支持下方插入行、右侧插入列、清空当前单元格、删除当前行/列/表格,并补 `选项` 菜单的标题行、标题列、隐藏边框线 switch;已补行侧/列顶 aux controls 和最小 TableSelectionOverlay,点击行/列 aux 会插入行/列并显示对应淡红选择覆盖层,toolbar 保持可用;`core-protocol`/`bridge-runtime`/`mnote-web` 保存与刷新恢复保留 `table` Tiptap 快照、`tableHeader``attrs.hiddenBorders`;本地 smoke `task151-e23-table-smoke.js` 覆盖入口、DOM、row/column aux 插入、selection overlay、列宽拖拽、switch 语义/状态、保存请求、content API、reload 和删除。剩余表格对齐、fit、数据表格入口,以及 Wolai 选项菜单图标/toolbar 视觉细节另拆后续 |
| E24 | PARTIAL | Image / Media 能力包 | 已完成图片 `/tp` 最小真源闭环与图片 toolbar 小闭环:slash `媒体与附件` 分组入口调用 `leptos-tiptap` 官方 `set_image(TiptapImageResource)`,启用 `TiptapExtension::Image`,保存链新增 `EditorBlockType::Image` / `TiptapNode::Image` 并保留 `props.tiptapImage``mnote-web` reload 恢复 `<img>`;点击图片后出现 `image-floating-toolbar`,删除入口本切片保持禁用,左/中/右对齐通过官方 Image 扩展 `addAttributes("data-align")` + `updateAttributes("image")` 持久化,同源下载通过隐藏 `<a download>` 触发且不改文档;`task152-e24-image-smoke.js` 覆盖入口、实际图片加载、toolbar、同源下载、删除入口禁用态、居中对齐、保存请求、content API 和刷新恢复;剩余上传、最近上传、caption、resize、replace、跨源下载 fallback、删除保存链、移动端 toolbar、失败态 |
| E25 | PARTIAL | TOC 能力包 | 已完成 `/toc` 最小真源闭环:`leptos-tiptap` 新增真实 `tocNode` schema/commandslash `页面目录 /toc` 插入真实节点,NodeView 从当前 headings 派生目录项,支持点击定位、标题显示开关、保存请求、content API 与 reload 恢复;`task153-e25-toc-smoke.js` 覆盖入口、动态 heading 更新、hash 定位和 `props.tiptapTocNode`。剩余 TOC sidebar、active heading 高亮、完整块菜单入口、官方 TableOfContents v3 数据源评估、移动端与像素级视觉 |
| E26 | TODO | UniqueID / Anchor 能力包 | 参考 UniqueID/anchor/copy anchor link 相关实现;每个块需要稳定 id,复制链接应指向可恢复的页面/块锚点,刷新后仍可定位 |
| E27 | TODO | AI 编辑能力包 | 参考 `ai-context`、AI icons、Improve/Ask AI/continue/regenerate/summarize/translate;先接 mnote 现有 AI command/tool 真源,再做 Wolai 菜单入口和流式状态 |
| E28 | TODO | Mention / Emoji 能力包 | 参考 mention trigger 与 emoji menu;实现 `@`、emoji 搜索、键盘导航、插入后的 Tiptap JSON mnote 保存链,不作为纯文本占位 |
| E29 | TODO | Comment / History 能力包 | 参考评论入口、块历史入口的菜单形态,结合 mnote 后端能力决定先做 smoke 占位还是真实存储;`Ctrl/Cmd+Alt+M`、评论气泡数量、块历史恢复需单列对标 |
| E30 | TODO | Menu / Floating 状态机能力包 | 参考 `use-floating-element``use-menu-navigation``use-floating-toolbar-visibility`;统一 Esc外部点击、鼠标移出、方向键、Enter、层级菜单收起逻辑,避免每个菜单各写一套状态机 |
| E26 | PARTIAL | UniqueID / Anchor 能力包 | 已完成最小真源闭环:Rust `EditorBlock.block_id` -> Tiptap `attrs.blockId` -> DOM `data-block-id` + `id` -> 复制链接 hash -> `page.body.save` / `/api/documents/content` -> reload 后 `:target` 定位;`task154-e26-anchor-smoke.js` 覆盖保存、复制、reload/hash 和截图。Tiptap `UniqueID` 仍只作为参考/辅助口径,不作为正式块 id;剩余更多块类型 anchor 覆盖、Wolai 视觉细节、引用预览和移动端行为。 |
| E27 | PARTIAL | AI 编辑能力包 | 已完成块菜单 `AI 助理` 主路径与最小写入闭环:点击后发起 `/api/ai-agent/run`payload 使用 `scope=document``stream=true``options.ai.provider=online`,携带 `documentId``workspaceId`、Rust `blockId`、selection、selected text、Tiptap 快照和 `action=ask_ai`;历史基线里 `Hermes` 只作为 `/api/ai-agent/run` 内部 fallback。2026-05-05 起长期口径改为:`/api/ai-agent/run` 后续应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter`Hermes``Codex``openai-agents-python` 仅为可插拔外置 agent`task155-e27-ai-edit-smoke.js``task156-e27-ai-writeback-smoke.js` 保留为过渡主链行为基线。Auth 验收必须复用 Convex Auth 测试账号,真实 token / actor 优先,`dev identity` 仅为本地 fallback。当前主线程已于 2026-05-03 先切 E28E27 暂停在 online smoke 的 `20128 /v1/responses` 模型网关排障与真实 actor 收口。剩余 slash AI/selection toolbar AI、Improve/continue/regenerate/summarize/translate、Wolai 视觉基线、SSE 分段 UI、`doc_insert_blocks` 多块插入与标题写入。 |
| E28 | PAUSED | Mention / Emoji 能力包 | 2026-05-03 暂停;Wolai baseline 已纠偏:正文 `@` 当前是提醒/会议/成员候选入口,不是页面引用搜索,恢复时先重定 mention/emoji 口径,再决定 Tiptap JSON mnote 保存链 |
| E29 | PAUSED | Comment / History 能力包 | 2026-05-03 暂停;评论/历史入口仍需后端边界和 Wolai 基线复核,待 E30 统一菜单状态机后再接入,避免继续复制独立弹层逻辑 |
| E30 | PARTIAL | Menu / Floating 状态机能力包 | 2026-05-03 第一刀已完成本地统一状态机 smoke:`task158` 覆盖 slash、块菜单/二级菜单、selection toolbar/color panel、image floating toolbar、table toolbar/options 的互斥打开、Esc 统一关闭、方向键 active,以及打开块菜单时关闭 image toolbarWolai 只读基线已确认 selection/type menu 和 table popper 的 Esc/外部点击/URL 不变,slash 与块菜单二级菜单在只读条件下不稳定,后续仍需 editable-test 复核 Enter、外部点击和更多层级收起 |
| E31 | PARTIAL | Tiptap 参考对标 smoke 矩阵 | `task137-wolai-phase-e-smoke.js` 已覆盖 E13 分割线的 DOM `<hr>``/api/documents/content` `divider`、刷新恢复和页面转换 `_self` 打开;后续每完成一个能力包仍必须同时断言 Tiptap JSON/schema、mnote 真源保存、Wolai 视觉/交互截图、刷新恢复;截图差异未复核不得标 DONE |
## 9. Phase FPage Aggregate 和命令边界
@@ -10,6 +10,7 @@
> 状态说明:
> - 本稿对应 `Phase 7 v2` 的“文档页 AI 最小闭环”已完成,故迁入 `done/`
> - 本稿完成不等于整个 `Phase 7 v2` 已完成;结构化知识写链仍以后续阶段继续推进
> - 2026-05-05 追加说明:本稿记录的是 `openai-agents-python` sidecar 作为过渡主链的完成状态,不代表当前长期方向;长期口径已由 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 收口为 `mnote-cli` 是唯一长期 agent 执行面
---
@@ -9,6 +9,11 @@
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md`
>
> 2026-05-05 追加说明:
> - 本稿的对象模型、artifact 写链与 kernel 边界仍然有效
> - 但触发与执行口径已被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖
> - 当前凡是提到“页面 AI 面板触发”的位置,都应理解为“页面 AI 面板作为 `mnote-cli` host 触发”,而不是独立内置编排主线
---
@@ -38,7 +43,7 @@
- `ai_note node`
- `reference edge`
2. 只允许围绕**当前文档页**创建,不做跨页、跨工作区写链
3. 触发方式不是自然语言,不做自动推断,只允许页面 AI 面板里的固定按钮触发
3. 触发方式不是自然语言,不做自动推断,只允许页面 AI 面板里的固定按钮触发;该面板长期应只是 `mnote-cli` host
4. 点击按钮后直接创建,不走“先预览再确认”的两阶段流
5. `summary node` 默认单例覆盖更新;`ai_note node` 每次新建
6. 两者都落成**可编辑的页面型节点**
@@ -91,7 +96,7 @@
- 它依赖更稳定的摄取边界
- 它会引入更重的异步任务与检索协议
- 它不是当前页 AI 面板的最小邻接能力
- 它不是当前页 AI host 面板的最小邻接能力
所以本稿故意不把阶段 5 全写成一个“大包”,而是只切出:
@@ -283,7 +288,7 @@
- “看起来像摘要就自动生成”
- “只要语义明确就直接落库”
只支持页面 AI 面板里的两个固定按钮:
只支持页面 AI host 面板里的两个固定按钮:
- `创建 Summary`
- `创建 AI Note`
@@ -22,6 +22,10 @@
> - 2026-04-23 验证补充:
> - `cd /mnt/Data1T/mnote/wolai-backend && ./.venv/bin/python -m pytest -q tests/test_ai_document_agent.py` -> `15 passed`
> - `cd /mnt/Data1T/mnote/wolai-frontend && pnpm test -- --runInBand src/app/api/ai-agent/run/route.test.ts src/app/api/ai-agent/document/config/route.test.ts src/components/editor/DocumentAiAgentPanel.runtime.test.tsx src/components/ai-agent/panelShared.test.ts` -> `202 passed`
> - 2026-05-05 口径修正:
> - 本稿记录的 `openai-agents-python` 文档页主链收口,当前仅作为过渡阶段基线
> - 长期方向已被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖
> - 当前长期口径固定为:`mnote-cli` 是唯一长期 agent 执行面;`openai-agents-python`、Hermes、Codex 都视为可插拔外置 agent
---
@@ -41,7 +45,7 @@
1. 如果现在开始做 `Phase 7`,第一交付物到底是什么
2. `Phase 7` 应该接在哪条现有主线之后,而不是另起一套理想结构
3. `openai-agents-python` 到底如何接入当前文档页 AI 主链
3. 过渡期 `openai-agents-python` 适配链到底如何接入当前文档页 AI 主链
4. 哪些 `v1` 里的目标继续保留,哪些必须降级到后续阶段
一句话收口:
@@ -54,7 +58,9 @@
当前推荐口径固定如下:
> **`mnote` 的长期 AI 主线仍然采用“Rust kernel / Rust runtime 做唯一事实源与真执行面,`openai-agents-python` 作为 `mnote` 专用编排层,`Hermes` 退回过渡与实验平台”的结构。**
> **本稿原始判断是“Rust kernel / Rust runtime 做唯一事实源与真执行面,`openai-agents-python` 作为 `mnote` 专用编排层,`Hermes` 退回过渡与实验平台”。**
>
> **该判断现已退化为历史过渡口径;当前长期口径改为:`Rust kernel / Rust runtime` 做唯一事实源与真执行面,`mnote-cli` 做唯一长期 agent 执行面,`openai-agents-python` 与 `Hermes` 都作为可插拔外置 agent。**
但如果按真实执行顺序来排,当前 `Phase 7` 必须服从下面这个前置条件:
@@ -153,7 +159,7 @@
- 已有一个可工作的文档页 AI 过渡链
- 这条链已经开始向 `page aggregate command family` 回接
- `Phase 7` 的第一任务,应是把这条链从 Hermes 兼容桥迁到正式 `openai-agents-python` 编排层
- `Phase 7` 的第一任务,应是把这条链从 Hermes 兼容桥收口到统一 `mnote-cli` host / adapter`openai-agents-python`、Hermes、Codex 只作为可插拔外置 agent
---
@@ -212,7 +218,7 @@
1. 明确它是过渡链
2. 冻结它的保留边界
3.`openai-agents-python` 替换它的编排层,而不是推翻整条文档页 AI 回写链
3.`mnote-cli` 统一承接长期 agent 执行面,而不是把 `openai-agents-python` 或 Hermes 继续写成长期主编排
---
@@ -226,13 +232,13 @@ Document Page / Read View / AI Panel
v
AI Gateway / SSE Event Adapter
|
+---- current fallback: Hermes bridge
|
+---- target mainline: openai-agents-python
+---- long-term execution surface: mnote-cli
| |
| +---- provider: OpenAI Responses API
| +---- mnote page tools
| +---- mnote tree tools
| +---- external agent adapter: openai-agents-python
| +---- external agent adapter: Hermes
| +---- external agent adapter: Codex
|
+---- current fallback: Hermes bridge
|
v
Rust runtime / mnote-web / bridge-runtime
@@ -267,14 +273,14 @@ Rust kernel truth
- 通用 agent 编排框架
- 通用聊天产品壳
#### `openai-agents-python`
#### `mnote-cli`
负责:
- 文档页 AI 会话编排
- tools / handoffs / tracing / guardrails / HITL
- 调用 OpenAI `Responses API`
- 组织 `mnote` 的文档页 AI 工具合同
- 外置 agent adapter 调度
- mnote page tools / tree tools 统一入口
- CLI host / Web AI host 之间的执行契约
不负责:
@@ -282,6 +288,22 @@ Rust kernel truth
- 绕过 Rust runtime 直接写产品数据
- 自己持有第二套页面真相
#### `openai-agents-python`
负责:
- 作为可插拔外置 agent adapter / 对照实现
- tools / handoffs / tracing / guardrails / HITL
- 调用 OpenAI `Responses API`
- 通过 `mnote-cli` 暴露的工具合同接入 `mnote`
不负责:
- 保存产品真相
- 绕过 Rust runtime 直接写产品数据
- 自己持有第二套页面真相
- 文档页 AI 长期默认主编排
#### Hermes
负责:
@@ -530,9 +552,9 @@ AI 第一批正式写能力只冻结为:
> **`Phase 7` 不删除过渡工具面,但也不把过渡工具名继续写成长期产品契约。**
### 8.3 `openai-agents-python` 的接入原则
### 8.3 外置 agent adapter 的接入原则
`openai-agents-python` 接入时,优先接的不是抽象理想工具名,而是
`openai-agents-python`、Hermes、Codex 作为外置 adapter 接入时,优先接的不是抽象理想工具名,而是 `mnote-cli` 暴露的统一工具契约
1. 当前页最小闭环所需的正式命令语义
2. 当前已真实存在的过渡运行时工具能力
@@ -574,7 +596,7 @@ AI 第一批正式写能力只冻结为:
- [x] 明确当前第一闭环只覆盖文档页 AI 主写链
- [x] 明确 `summary / ai_note / reference / index` 降为后续扩展
- [x] 明确 `Hermes` 只保留为 fallback / 对照链
- [x] 明确 `openai-agents-python` 为长期推荐编排层
- [x] 明确 `openai-agents-python` 仅作为过渡期可插拔外置 agent / adapter 示例
- [x] 明确页面 AI 模型配置真源是 `model_key`,不是 combo 名或 provider model
- [x] 明确 `profile` 前端可设、后端持有真源
- [x] 明确 tool registry 必须前端可见
@@ -645,11 +667,11 @@ AI 第一批正式写能力只冻结为:
---
## 阶段 3引入 `openai-agents-python` 文档页编排层
## 阶段 3整理并保留 `openai-agents-python` 过渡适配链
### 目标
在不推翻现有文档页 AI 回写链的前提下, `openai-agents-python` 替换 Hermes 作为长期文档页编排层。
在不推翻现有文档页 AI 回写链的前提下,保留 `openai-agents-python` 这条已跑通的过渡外置 adapter,用它作为 `mnote-cli`-first 迁移期间的对照与行为基线,而不是把它定义成长期默认文档页编排层。
### 需要完成
@@ -669,19 +691,20 @@ AI 第一批正式写能力只冻结为:
### 完成判定
- [x] 在不依赖 Hermes 主编排的情况下,文档页 AI 已能跑通第一闭环
- [x] 新编排层已具备“模型 / profile / 工具”统一能力面出口
- [x] 当前过渡 adapter 已具备“模型 / profile / 工具”统一能力面出口
---
## 阶段 4:切文档页 AI 默认路径
## 阶段 4:切文档页 AI 默认执行路径
### 目标
让文档页 AI 在产品可见行为上真正从 Hermes 过渡到新编排层
让文档页 AI 在产品可见行为上从 Hermes 兼容链过渡到 `mnote-cli` host / adapter;当前已跑通的 `openai-agents-python` 链只保留为外置 adapter 行为基线
### 需要完成
- [x] 文档页 AI panel 默认走 `openai-agents-python`
- [x] 历史基线中,文档页 AI panel 默认走 `openai-agents-python`
- [x] 长期口径中,文档页 AI panel 默认应走 `mnote-cli` host / adapter
- [x] 标题改名沿 `page.head.updateTitle` 回写
- [x] 正文改写沿 `page.body.save` 回写
- [x] 主编辑区 island 稳定回显 AI 结果
@@ -726,7 +749,7 @@ AI 第一批正式写能力只冻结为:
### 10.1 继续保留的价值
- 承接现有文档页 AI 运行链
- 作为 `openai-agents-python` 的回归对照
- 作为 `mnote-cli` host 与其他外置 adapter 的回归对照
- 作为 fallback 开关
- 承接非主链实验场景
@@ -741,8 +764,8 @@ AI 第一批正式写能力只冻结为:
最终可接受状态有两种:
1. Hermes 退化为非主链实验平台
2. Hermes 完全下线,只保留 `openai-agents-python + Rust runtime`
1. Hermes 退化为非主链实验平台 / 外置 agent adapter
2. Hermes 完全下线,只保留 `mnote-cli` + Rust runtime,并按需接入 `openai-agents-python` / Codex 等外置 agent adapter
---
@@ -766,11 +789,11 @@ AI 第一批正式写能力只冻结为:
当前冻结如下:
> **`Phase 7` 仍然以 `openai-agents-python` 作为长期推荐编排层,Rust runtime 作为唯一事实源与真执行面。**
> **本稿原始判断里,`openai-agents-python` 曾被写成长期推荐编排层;该口径现已失效。当前长期口径应以 `v4` 为准:`mnote-cli` 是唯一长期 agent 执行面Rust runtime 仍是唯一事实源与真执行面`openai-agents-python`、Hermes、Codex 都只是可插拔外置 agent。**
> **但当前 `Phase 7` 的第一交付物不再定义为“完整 AI 平台”或“完整结构化知识写链”,而是“文档页 AI 直接进入主编辑区,并沿 `page aggregate command family` 正式回写”。**
> **`Hermes` 可以继续作为过渡平台与实验平台存在,但不再作为文档页 AI 的长期语义中心。**
> **`Hermes` 可以继续作为过渡平台与实验平台存在,但不再作为文档页 AI 的长期语义中心。`openai-agents-python` 也只保留为外置 adapter。**
> **页面 AI 的模型真源固定为 `model_key``resolved_combo / resolved_runtime_model` 只作为运行时调试信息;`profile` 前端可设但由后端持有真源;tool registry 必须前端可见。**
@@ -0,0 +1,339 @@
# 7 [process] mnote Kernel Phase 7 AI 与 CLI 外置 Agent 边界实施方案 v3
> 更新时间:2026-05-05
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.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`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md`
>
> 本稿与 `v2` 的关系:
> - `v2` 记录的是文档页 AI 最小闭环与 `openai-agents-python` sidecar 的主线收口
> - 本稿记录的是从“双中心并存”过渡到 `CLI-first` 之前的边界判断,重点保留当时为何要把页面内 AI 与外置 agent 拆层
> - 若未来继续演进,长期口径以 `v4` 为准;本稿不再作为当前长期判断依据
>
> 2026-05-05 追加说明:
> - 本稿已被 `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v4.md` 覆盖
> - `v3` 只保留用于记录“双中心并存”的过渡判断
> - 当前长期口径不再采用本稿,而采用 `v4``CLI-first`
> - 当前统一口径是:`mnote-cli` 是唯一长期 agent 执行面,`openai-agents-python`、Hermes、Codex 都只是可插拔外置 agent
---
## 1. 文档目的
这份稿只回答一个问题:
> **`mnote` 的 AI 能力到底应该以“内置 agent”为中心,还是以“CLI 兼容外置 agent”为中心。**
当前长期结论已被 `v4` 修正为单一执行面:
1. Web 文档页 AI 面板继续保留,但只作为页面内交互壳 / CLI host
2. `mnote-cli` 是唯一长期 agent 执行面
3. Hermes、Codex、`openai-agents-python` 这类外置 agent 不再直接依赖产品内编排细节,而是统一通过 CLI / 稳定工具面进入系统
4. Rust kernel 继续作为唯一事实源与真执行面
一句话收口:
> **本稿原始判断是“不要退回成只有 CLI”;该口径现已被 `v4` 覆盖。当前长期口径是:CLI 作为唯一执行面,Web AI 只保留为页面内交互壳。**
---
## 2. 当前完成情况
### 2.1 文档页 AI 最小闭环已完成
`7-1` 已经完成,说明当前文档页 AI 至少已经跑通:
- 当前页 page aggregate 上下文读取
- `openai-agents-python` sidecar 编排
- 标题改名与正文写回
- `Hermes` fallback / 对照链
- `model_key / profile / tool registry` 的可见配置面
这意味着当前系统已经不是“要不要有 AI”,而是“AI 的长期边界该放在哪一层”。
### 2.2 `mnote-cli` 已经不是空壳
当前 Rust 侧已有 `mnote-cli`,并且已经覆盖:
- `page`
- `block`
- `mindmap`
- `search`
- `sidebar`
- `editor`
- `tool`
同时它已经有较清晰的执行参数面:
- `--json`
- `--execute`
- `--session-id`
- `--reason`
- `--idempotency-key`
- `--validate-only`
- `--dry-run`
这说明 CLI 已经具备成为“外置 agent 统一兼容层”的基础,而不是只做调试壳。
### 2.3 内置 AI 现在的定位已经足够清晰
当前 `wolai-backend/app/services/ai_document_agent.py` 这一层已经把文档页 AI 约束成:
- 只处理当前文档页
- 只使用有限工具面
- 通过 page aggregate 组织上下文
- 通过 `slash_run / doc_insert_blocks / doc_replace_range` 这类写链回写
所以内置 AI 的价值不是“全能 agent 平台”,而是“产品内即时编辑体验”。
---
## 3. 核心判断
### 3.1 `v3` 当时对 CLI-first 的顾虑
本节保留的是 `v3` 当时尚未接受 `CLI-first` 时的顾虑:
1. 担心产品内即时体验变差
2. 担心页面上下文、审核、回写被拆散
3. 担心外置 agent 变强但产品主链变弱
这些顾虑在 `v4` 中的处理方式不是退回“双中心”,而是明确:
- Web AI 面板继续保留
- 但它只作为 `mnote-cli` 的页面内 host / client
- 执行面不再由产品内 AI 单独持有
### 3.2 也不建议把内置 AI 变成唯一中心
相反,如果继续把内置 AI 当唯一中心,另一个问题会变得更严重:
- agent 更新能力受限
- 记忆层容易变成产品私有状态
- 能力面会偏向单一页面操作
- Hermes / CLI 这类外置 agent 很难稳定接入
所以正确做法不是“只保留内置 AI”,而是**把 `mnote-cli` 收口为唯一长期 agent 执行面**。
### 3.3 当前长期分层应理解为三层
1. **Web AI 面板 / 页面内交互壳**
- 负责文档页内交互、上下文读取、局部回写、审核辅助
2. **`mnote-cli`**
- 负责唯一长期 agent 执行面,以及外置 agent、脚本、批处理、离线任务、可观测执行
3. **Rust kernel**
- 负责所有事实源、命令、查询、投影、审计与回放
这三层之间不应该互相抢语义中心。
---
## 4. 架构边界
### 4.1 Rust kernel
负责:
- 页面、树、边、投影、查询、命令
- 结构化写入的最终事实源
- 审计、trace、回放、恢复
不负责:
- 直接承担 UI 体验
- 直接承担通用 agent 编排
### 4.2 Web AI 面板(产品内交互壳)
负责:
- 当前文档页的即时编辑
- 选中上下文的解释与改写
- 通过正式命令面回写
- 审核与继续编辑的体验闭环
不负责:
- 成为全局 agent 平台
- 取代 CLI 的批处理入口
- 持有独立长期记忆真源
- 成为第二个长期执行面
### 4.3 `mnote-cli`
负责:
- 作为唯一长期 agent 执行面与统一命令面
- 作为人类与脚本的可执行入口
- 作为 Hermes、Codex、批处理、定时任务的兼容层
应该提供:
- 稳定的 JSON 输出
- 明确的 plan / execute 分层
- 会话与 trace 参数
- 幂等键
- 校验模式与 dry-run
- 可发现的 capability manifest
### 4.4 Hermes / Codex / `openai-agents-python`
负责:
- 可插拔外置 agent 场景
- 过渡平台
- 回归对照
不负责:
- 成为产品内部独立长期主编排
- 直接绕过 Rust runtime 写业务数据
---
## 5. CLI 应该补什么
### 5.1 统一能力发现
CLI 需要先变成“可发现能力集合”,而不是一堆散命令。
最少要能回答:
- 当前可写什么
- 当前可读什么
- 当前哪些命令是计划态
- 当前哪些命令可直接执行
- 当前命令属于哪个 scope
### 5.2 统一执行元数据
外置 agent 真正缺的不是命令本身,而是执行元数据一致:
- `session_id`
- `actor_id`
- `actor_type`
- `reason`
- `idempotency_key`
- `validate_only`
- `dry_run`
- trace / audit id
这些都应该在 CLI 层成为一等参数。
### 5.3 统一记忆入口
这里的“记忆”不应该是产品壳里的隐式状态,而应该拆成三层:
1. 会话记忆
- 某次 agent 运行的上下文与结果
2. 配置记忆
- profile、工具注册、运行时偏好
3. kernel 记忆
- 真实业务对象、projection、历史变更
CLI 负责把这三层显式化,外置 agent 通过它读取或写入,不再自己猜。
### 5.4 统一机器可读输出
如果 Hermes、Codex、脚本都要接入,CLI 输出必须稳定机器可读。
最低要求:
- `--json` 必须完整
- 错误结构必须稳定
- 成功结构必须能直接喂给上层 agent
- 人类输出和机器输出要分开
---
## 6. Web AI 面板继续保留什么
### 6.1 继续保留
- 当前页正文改写
- 当前页标题改名
- 当前页上下文读取
- 页面内审核与继续编辑
- 与 page aggregate 对齐的最小闭环
### 6.2 不继续扩写
- 通用多 agent 平台壳
- 全局记忆产品壳
- 独立于页面主链的第二份真相
- 把 CLI 功能重复做一遍
### 6.3 设计原则
Web AI 面板只做“最近的一层”,不要做“全部层”。
因为页面内 AI 交互壳的目标是让用户快,不是让系统像一个独立平台那样完整。
---
## 7. 迁移策略
### 阶段 1:收口 CLI 兼容面
- 把 `mnote-cli` 明确成唯一长期 agent 执行面
- 保持现有命令树不乱长
- 先统一 JSON / plan / execute / trace / idempotency
### 阶段 2:补稳定能力发现
- 输出 capability manifest
- 区分 read / write / job
- 区分 document / tree / workspace scope
### 阶段 3:外置 agent 接入
- Hermes 通过 CLI 进入系统
- 后续其他 agent 也通过同一 CLI 进入
- 不再为每个 agent 单独做一套产品内桥接逻辑
### 阶段 4:保留页面内 AI host
- 文档页 AI 继续保留当前页面内主路径
- 只做 UI 内最小闭环
- 不回退到全局壳
- 不再把页面内 host 叙述成独立长期执行面
---
## 8. 非目标
本稿不做:
- 重新实现一个新的通用 AI 平台
- 把 Hermes 直接升级成产品内主编排中心
- 把内置 AI 删除掉
- 把所有页面内交互体验都删除并强制改成手工 CLI 操作
- 在 CLI 里重复一套产品 UI
---
## 9. 完成判定
当下面几项成立时,才算这个方向真正站稳:
- `mnote-cli` 是唯一长期 agent 执行面,而不是纯调试壳
- Web AI 面板只负责页面内最小闭环
- `openai-agents-python`、Hermes、Codex 等外置 agent 通过 CLI 进入系统
- Rust kernel 仍然是唯一事实源
- `model_key / profile / tool registry` 这些产品语义不被页面内 host 私有化
一句话收口:
> **本稿保留的是“产品内 AI + CLI 兼容层”并存的过渡判断;当前长期口径已经改为:`mnote-cli` 是唯一执行面,Web AI 只是页面内交互壳,Rust kernel 继续负责真相。**
@@ -0,0 +1,698 @@
# 7 [process] mnote Kernel Phase 7 CLI-First 外置 Agent 统一执行面方案 v4
> 更新时间:2026-05-05
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.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`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-1-phase7-document-ai-minimum-loop-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v2.md`
> - `/mnt/Data1T/mnote/design/07-ai/process/7-phase7-ai-kernel-projection-plan-v3.md`
>
> 本稿与前稿的关系:
> - `v2` 的价值仍然成立:它记录了文档页 AI 最小闭环已经真实完成
> - `v3` 把 CLI 抬到了重要位置,但仍保留了“产品内 AI 主链”和“CLI 外置层”双中心
> - 本稿进一步收口,明确长期只保留一个执行方向:`CLI-first`
>
> 当前判断修正:
> - 文档页 AI 面板不再被视为独立 AI 编排主线
> - `openai-agents-python` 不再被视为长期默认主编排,而是一个可插拔外置 agent
> - Hermes、Codex、后续其他 agent,应统一通过 `mnote-cli` 接入
---
## 1. 文档目的
这份稿只回答一个问题:
> **`mnote` 的长期 AI 执行面,到底要不要收口成一个唯一方向。**
当前答案是:
> **要。长期只保留一个 AI 执行方向:`Rust kernel + mnote-cli`。**
这里的关键不是“要不要有 AI UI”,而是:
- UI 可以存在
- 外置 agent 可以很多
- 但执行面只能有一个
一句话收口:
> **`mnote` 的长期主线不是“内置 AI + CLI 两套长期并存”,而是“CLI 作为唯一 agent 执行面,Web 面板只是其中一个客户端”。**
---
## 2. 为什么要改成 CLI-first
当前如果继续保留两套长期方向:
1. 产品内一套 AI 编排
2. 外置 agent 一套 CLI / 脚本 / Hermes / Codex 编排
那么后面一定会出现这些重复维护成本:
- 两套工具注册表
- 两套上下文装配逻辑
- 两套 session / trace / audit 语义
- 两套能力开关和权限边界
- 两套“哪些命令是真执行、哪些只是兼容层”的判断
这不是“体验和平台分层”,而是“长期双轨维护”。
如果改成 CLI-first
- Web AI 面板调用 CLI
- Hermes 调用 CLI
- Codex 调用 CLI
- 批处理脚本调用 CLI
- 定时任务调用 CLI
那么系统只维护:
1. 一个真执行方向:`mnote-cli`
2. 多个调用方:UI / agent / script / job
这才是真正减少长期维护成本。
---
## 3. 当前完成情况如何解释
### 3.1 `7-1` 已完成,但不代表它必须成为长期方向
当前文档页 AI 最小闭环已经完成,这个事实不变:
- page aggregate 上下文读取
- 标题改名与正文改写
- `openai-agents-python` sidecar
- `Hermes` fallback / 对照链
但这个完成状态只能说明:
> **我们已经验证过一条可工作的过渡主链。**
不能自动推出:
> **这条链就应该变成长期唯一主线。**
所以 `v2` 记录的工作不是白做,而是:
- 它证明了产品内写链需求真实存在
- 它帮助我们冻结了最小工具面和页面语义面
- 它可以作为 CLI-first 迁移时的行为基线
### 3.2 `mnote-cli` 已经足够接近长期入口
当前 `mnote-cli` 已经不是空壳,已有:
- `page`
- `block`
- `mindmap`
- `search`
- `sidebar`
- `editor`
- `tool`
同时还有这些关键参数:
- `--json`
- `--execute`
- `--session-id`
- `--reason`
- `--idempotency-key`
- `--validate-only`
- `--dry-run`
这说明真正应该继续投入的,不是再扩一条新的 Web 内 AI 主编排,而是把 CLI 补成稳定统一入口。
---
## 4. 新的长期架构边界
### 4.1 Rust kernel / Rust runtime
负责:
- 唯一事实源
- query / command / projection
- page / tree / graph / artifact 的正式语义
- 审计、回放、恢复、trace 基线
不负责:
- 直接承担 agent 编排
- 直接承担产品聊天壳
### 4.2 `mnote-cli`
负责:
- 唯一长期 agent 执行入口
- 人类、脚本、外置 agent、Web 面板的统一命令面
- 统一能力发现、统一参数面、统一输出结构
必须成为:
- CLI for humans
- CLI for agents
- CLI for jobs
- CLI for UI host
### 4.3 Web AI 面板
负责:
- 当前页面里的交互壳
- 流式展示
- 输入与审核
- 把页面上下文转成 CLI 可消费的标准输入
- 接收 CLI 事件并映射到 UI
不负责:
- 自己维护独立 agent 编排层
- 自己维护独立工具注册表
- 自己定义长期 AI 契约
### 4.4 外置 agent
包括但不限于:
- Hermes
- Codex
- `openai-agents-python`
- 后续任何别的 agent runtime
它们都负责:
- 决策
- 推理
- 工具调用策略
- 会话层 orchestration
但它们不应再直接拥有产品内部执行面。它们都应该统一调用 `mnote-cli`
---
## 5. `openai-agents-python` 的新定位
### 5.1 不再是默认主线
从本稿开始,`openai-agents-python` 不再被定义为:
- `mnote` 的长期默认主编排
- 产品内文档页 AI 的唯一推荐底座
### 5.2 改为可插拔外置 agent
它的新定位是:
> **一个可插拔外置 agent runtime。**
也就是说,它和 Hermes、Codex 的区别不再是“谁是产品内主线”,而只是:
- 不同的 agent 实现
- 不同的模型接法
- 不同的 orchestration 风格
是否继续保留它,取决于后续真实效果,而不是当前历史惯性。
### 5.3 可接受的长期状态
未来允许的状态有三种:
1. `openai-agents-python` 继续保留,作为可选外置 agent
2. `openai-agents-python` 仅保留在测试 / 回归 / 对照场景
3. `openai-agents-python` 完全移除
这三种都不影响长期主架构,只要唯一执行面还是 `mnote-cli`
---
## 6. CLI-first 下的产品口径
### 6.1 UI 仍然可以有 AI 面板
CLI-first 不等于没有产品内 AI UI。
允许继续有:
- 文档页 AI 面板
- mindmap AI 面板
- OnlyOffice AI 面板
但这些都只是:
> **CLI 的图形客户端。**
### 6.2 页面语义仍然保留
虽然主执行面转到 CLI,但下面这些产品语义仍要保留:
- `model_key`
- `profile`
- `tool registry`
- `page aggregate`
- `page.head.updateTitle`
- `page.body.save`
- `tree.*`
区别只在于:
- 这些不再由 Web 内独立 AI service 主导
- 而是由 CLI 和 Rust runtime 共同定义
### 6.3 Web 不应再拥有第二套工具面
当前过渡工具面:
- `doc_get`
- `doc_find`
- `doc_insert_blocks`
- `doc_replace_range`
- `slash_run`
如果继续保留,后续也应通过 CLI 暴露,而不是继续作为 Web 内 service 的私有 tool surface。
---
## 7. CLI 必须补齐的能力
CLI-first 成立的前提,不是“已有命令很多”,而是这些能力必须补齐。
### 7.1 能力发现
至少要能稳定输出:
- 当前有哪些 command / query / job
- 每个能力属于哪个 scope
- 哪些可读,哪些可写
- 哪些是 plan-only
- 哪些是真执行
建议提供:
- `mnote-cli capabilities --json`
### 7.2 标准上下文输入
CLI 需要能接住标准化页面上下文,而不是让每个 agent 自己拼:
- `documentId`
- `blocks`
- `pageOptions`
- `editorRuntimePageOptions`
- `subtree`
- `outline`
- `evidence`
也就是说,页面上下文装配逻辑要从 Web 内 agent service 迁到 CLI 兼容输入协议。
### 7.3 标准事件输出
如果 Web 面板要流式显示,CLI 需要输出标准事件流,例如:
- `assistant_message`
- `tool_call`
- `tool_result`
- `completion`
- `error`
否则 UI 最后还是会倒逼出第二套 Web 内编排逻辑。
### 7.4 标准 session / trace / audit
CLI 必须把这些变成一等能力:
- `session_id`
- `trace_id`
- `request_id`
- `actor_id`
- `actor_type`
- `reason`
- `idempotency_key`
### 7.5 权限与 scope
CLI 要能明确限制:
- 当前只能改当前页
- 当前允许跨页还是不允许
- 当前允许树命令还是只允许页面命令
- 当前是只读、写入还是后台 job
---
## 8. 顺序执行 checklist
本节是后续实施时的主 checklist。执行时从上到下推进,完成一项后把对应 `- [ ]` 改成 `- [x]`,并在该项下补充实际命令输出摘要或截图路径。
### 8.1 冻结 CLI 基座
- [x] 确认 `mnote-cli` 顶层命令存在。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --help
```
- 通过标准:输出包含 `page``block``mindmap``search``sidebar``editor``tool`
- [x] 确认 agent 必需的全局参数存在。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --help
```
- 通过标准:输出包含 `--json``--execute``--session-id``--reason``--idempotency-key``--validate-only``--dry-run`
- [x] 确认 `mnote-cli` 当前单测全通过。
- 命令:
```bash
cargo test --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli
```
- 通过标准:所有 `mnote-cli` 测试通过;当前基线应覆盖 CLI JSON contract、tool registry、page/save/sidebar 等最小面。
### 8.2 冻结页面命令面
- [x] 验证页面读取命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page get --page-id page_demo --workspace-id ws_demo
```
- 通过标准:输出包含 `domain: page``action: get``context.requestId``context.traceId`,并且 `operation.transport.functionName` 指向页面查询。
- [x] 验证页面标题更新命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page title --page-id page_demo --workspace-id ws_demo --title "新标题"
```
- 通过标准:输出包含 `domain: page``action: title``operation.kind: command`,并且写入仍通过 Rust runtime / transport 计划表达。
- [x] 验证页面正文保存命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json page save --page-id page_demo --workspace-id ws_demo --content-json '[{"id":"block_1"}]'
```
- 通过标准:输出包含 `domain: page``action: save``operation.kind: command`,并且能看到正文写回 payload。
### 8.3 冻结块命令面
- [x] 验证块插入命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json block insert --page-id page_demo --workspace-id ws_demo --block-type paragraph --content "hello"
```
- 通过标准:输出包含 `domain: block``action: insert``operation.kind: command``operation.name: insert_block`
- [x] 验证块 patch 命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json block patch --page-id page_demo --workspace-id ws_demo --block-id block_demo --snapshot-json '{"id":"block_demo","type":"paragraph","text":"updated"}'
```
- 通过标准:输出包含 `domain: block``action: patch``operation.kind: command``operation.name: blocks.patch`
- [x] 第 1 步:固定块写链仍然存在的入口证据。
- 结果:`/api/ai-agent/run` 已不再直接恢复 `slash_run` / `doc_insert_blocks` / `doc_replace_range``DocumentAiAgentPanel.runtime.tsx` 也已不再声明 Web 内独立 agent owner。
- 验证命令:
```bash
rg -n "doc_insert_blocks|doc_replace_range|slash_run|startHermesRun|startDocumentAiOrchestratorRun|runCodexBridge" \
/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts \
/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx
```
- [x] 第 2 步:把块写链收口为 `mnote-cli` host / adapter。
- 结果:`/api/ai-agent/run` 已收口为薄 host route,块写链不再由 Web 私有 service 直接拥有。
- 依赖检查:`DocumentAiAgentPanel.runtime.tsx``/api/ai-agent/run/route.ts` 的 contract 说明已同步改成 `mnote-cli`
- [x] 第 3 步:补块写链回归测试。
- 结果:`pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx``pnpm vitest run src/app/api/ai-agent/run/route.test.ts` 通过。
- 验证命令:
```bash
pnpm vitest run /mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx
pnpm vitest run /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.test.ts
```
- 通过标准:测试明确约束块写链不再暴露 Web 私有 tool surface,并且 route 只保留 CLI host / adapter 语义。
### 8.4 冻结搜索与侧栏投影
- [x] 验证文档搜索命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json search documents --workspace-id ws_demo --query "Hermes"
```
- 通过标准:输出包含 `domain: search``action: documents`,并且查询计划可由 Web host 消费。
- [x] 验证块搜索命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json search blocks --page-id page_demo --query "Hermes"
```
- 通过标准:输出包含 `domain: search``action: blocks``operation.name: search_blocks`
- [x] 验证侧栏数据集命令。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json sidebar dataset --workspace-id ws_demo
```
- 通过标准:输出包含 `domain: sidebar``action: dataset``operation.name: sidebar.dataset.list`
### 8.5 冻结 tool runtime 与安全模式
- [x] 验证 `tool run` 的只读 explain-plan。
- 命令:
```bash
cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --json --validate-only --dry-run --session-id ai-checklist-smoke --reason phase7-cli-first --idempotency-key phase7-cli-first-001 tool run --tool-name doc_get --kind query --mode explain-plan --args-json '{"pageId":"page_demo"}'
```
- 通过标准:输出包含 `domain: tool``action: run``context.sessionId: ai-checklist-smoke``context.idempotencyKey: phase7-cli-first-001``context.validateOnly: true``context.dryRun: true`
- [x] 验证 tool registry 会拒绝未知工具。
- 命令:
```bash
cargo test --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli tool_run_rejects_unknown_tool
```
- 通过标准:测试通过,未知 tool 不会绕过 registry 进入执行链。
- [x] 确认 `--reason` 进入后续 audit / trace 输出规划。
- 当前状态:CLI 参数面已接住 `--reason`,并已在标准 JSON 输出的 `context.reason` 中展开。
- 通过标准:`cargo test --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli output_context_keeps_reason_for_audit_trace` 通过,断言 `reason` 出现在标准 audit / trace 输出上下文里。
### 8.6 将 Web AI 面板降为 CLI host
- [x] 检查 Rust Web checklist 的 Phase 5 口径。
- 文件:`/mnt/Data1T/mnote/design/03-rust-web/process/3-1-rust-web-long-term-checklist-v2.md`
- 通过标准:AI 面板只能写成 `mnote-cli` host/client 收口,不得写成 Hermes 或 `openai-agents-python` 长期主编排。
- 已核对:该文件明确写入“长期执行面应收口到 `mnote-cli`Web 只作为 CLI host / client`openai-agents-python` / Hermes / Codex 只作为可插拔外置 agent”。
- [x] 检查 legacy Next retirement gate 的 AI route 口径。
- 文件:`/mnt/Data1T/mnote/design/03-rust-web/process/3-11-rust-web-legacy-next-retirement-gates-v1.md`
- 通过标准:`/api/ai-agent/run` 只能写成 CLI host / adapter 兼容层,结构化写入必须经 Rust runtime。
- 已核对:该文件明确写入 `/api/ai-agent/run` 长期应降为 `mnote-cli` host / adapter,结构化写入必须经 Rust runtime,外置 agent 不得拥有第二执行面。
- [x] 检查 Wolai-aline E27 的 AI 编辑口径。
- 文件:`/mnt/Data1T/mnote/design/05-editor-mainline/process/5-9-wolai-aline-continuous-checklist-v1.md`
- 通过标准:E27 只能把 `openai-agents-python` sidecar / Hermes fallback 当作过渡基线,不得写成长期主线。
- 已核对:E27 明确写入 `/api/ai-agent/run` 后续应收口为 `mnote-cli` 唯一长期 agent 执行面上的 host / adapter`Hermes``Codex``openai-agents-python` 仅为可插拔外置 agent;当前 sidecar / fallback 只保留为过渡行为基线。
### 8.7 外置 agent 接入顺序
- [x] 第 1 步:冻结 Codex 接入方式。
- 结果:`/api/ai-agent/run` 已不再按 `provider=codex` 直入独立 bridge,统一走 `mnote-cli` host。
- 验证命令:
```bash
rg -n "provider === \"codex\"|runCodexBridge" /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts
```
- [x] 第 2 步:冻结 Hermes 接入方式。
- 结果:`/api/ai-agent/run` 已不再直接调用 `startHermesRun` / `streamHermesRunEvents`Hermes 不再是长期主链入口。
- 验证命令:
```bash
rg -n "startHermesRun|streamHermesRunEvents|/api/hermes/bridge" /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts
```
- [x] 第 3 步:冻结 `openai-agents-python` 接入方式。
- 结果:`DocumentAiAgentPanel.runtime.tsx` 已不再声明 `bridgeOwner: "openai-agents-python"``provider=online` 也不再直连 orchestrator。
- 验证命令:
```bash
rg -n "bridgeOwner: \"openai-agents-python\"|startDocumentAiOrchestratorRun|/api/v1/ai-agent/document/run" /mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts
```
- [x] 第 4 步:统一复核外置 agent 只剩 CLI host / adapter 口径。
- 结果:已通过全局复核,`/api/ai-agent/run` 与面板 contract 都只保留 `mnote-cli` host / adapter 口径。
- 验证命令:
```bash
rg -n "provider === \"codex\"|runCodexBridge|startHermesRun|streamHermesRunEvents|bridgeOwner: \"openai-agents-python\"|startDocumentAiOrchestratorRun|/api/v1/ai-agent/document/run" \
/mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts \
/mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx
```
### 8.8 完成判定
- [x] `mnote-cli` 的最小命令面在仓库内可复跑。
- 已验证:`cargo run --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli -- --help``cargo test --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli` 可复跑。
- [x] `--json``--execute``--validate-only``--dry-run` 的输出语义稳定。
- 已验证:`--help` 暴露这些全局参数;`tool run --validate-only --dry-run --json` 输出包含 `context.validateOnly: true``context.dryRun: true``context.reason``context.sessionId``context.idempotencyKey`
- [x] `page / block / search / sidebar / tool` 都能用 CLI 直接验证。
- 已验证:8.2 到 8.5 已分别覆盖 `page get/title/save``block insert/patch``search documents/blocks``sidebar dataset``tool run`
- [x] 第 1 步:确认 Web AI 面板的 contract 已收口为 `mnote-cli` host / client。
- 结果:面板 contract 已改为 `bridgeOwner: "mnote-cli"``runtimeRole: "mnote_cli_host_client"`
- 验证命令:
```bash
rg -n "bridgeOwner: \"openai-agents-python\"|startDocumentAiOrchestratorRun|startHermesRun|runCodexBridge" /mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts
```
- [x] 第 2 步:确认三类外置 agent 都只作为可插拔 runtime。
- 结果:`route.ts` 已收口为统一 host route,不再按 provider 分叉到三套执行实现。
- 验证命令:
```bash
rg -n "provider === \"codex\"|runCodexBridge|startHermesRun|startDocumentAiOrchestratorRun|bridgeOwner: \"openai-agents-python\"" /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.ts /mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx
```
- [x] 第 3 步:补完成判定的最终回归。
- 结果:`cargo test --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli``pnpm vitest run src/components/editor/DocumentAiAgentPanel.runtime.test.tsx``pnpm vitest run src/app/api/ai-agent/run/route.test.ts` 均通过。
- 验证命令:
```bash
cargo test --manifest-path /mnt/Data1T/mnote/rust/Cargo.toml -p mnote-cli
pnpm vitest run /mnt/Data1T/mnote/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.test.tsx
pnpm vitest run /mnt/Data1T/mnote/wolai-frontend/src/app/api/ai-agent/run/route.test.ts
```
- [x] 结构化写入仍然只能回到 Rust runtime / Rust kernel。
- 已核对:本稿 4.1、6.2、6.3 明确 Rust kernel / Rust runtime 负责事实源、query / command / projection 与结构化写入语义;`3-11` 与 Wolai-aline E27 均要求结构化写入经 Rust runtime 或 Rust tool / `page.body.save`
### 8.9 当前测试账号写入验收
- [x] 确认当前测试账号默认允许 CLI 新建与编辑页面。
- 命令:
```bash
cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/server/mnote-cli-agent-host.test.ts
```
- 通过标准:`mnote-cli` host 默认下发 `MNOTE_CLI_ALLOW_CREATE_PAGE=1``MNOTE_CLI_ALLOW_EDIT=1`,并把当前 Web 用户身份透传给 CLI 子进程。
- [x] 确认当前测试账号的真实 workspace。
- 当前测试账号:
- `userId`: `a4b72c17-49e3-46d3-8456-24a0a7044d64`
- `email`: `dev@mnote.local`
- `name`: `开发用户`
- 当前真实 workspace
- `workspaceId`: `ws_req_1778035004501_15`
- `workspaceName`: `开发用户 的空间`
- 首个页面:`tree_1778036320856_1` / `新页面`
- 通过标准:`/api/sidebar?workspaceId=ws_req_1778035004501_15` 返回的 `documents` 至少包含 `tree_1778036320856_1`
- [x] 确认旧的 CLI 直连 Convex identity 不能作为长期用户写入面。
- 事实:曾经写入 `tree_1777430834634_3` / `anonymous 的空间` 的页面不会出现在当前测试账号的 `ws_req_1778035004501_15`
- 结论:CLI 不能长期依赖伪造 `DEV_USER_ID` 直连 Convex 来代表用户写入;需要由 Web host / mnote-web 持有当前会话、当前 workspace 与 capability 后再执行写入。
- [x] 确认 CLI host 会把当前页面 workspaceId 传入 CLI 输入。
- 命令:
```bash
cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/server/mnote-cli-agent-host.test.ts
```
- 通过标准:`--args-json` 包含 `workspaceId: "ws_req_1778035004501_15"`,避免 agent 在缺省 workspace 或错误 workspace 下创建页面。
- [x] 用当前浏览器会话创建当前账号可见页面。
- 执行动作:在已登录测试账号的浏览器会话中调用 `/api/tree/commands` 创建:
- `CLI 可见测试 A 1778057564`
- `CLI 可见测试 B 1778057564`
- 通过标准:创建响应的 `result.workspaceId` 必须是 `ws_req_1778035004501_15`
- 当前验证结果:
- `tree_1778048047020_3` / `CLI 可见测试 A 1778057564`
- `tree_1778048047047_4` / `CLI 可见测试 B 1778057564`
- [x] 用当前浏览器会话读取 sidebar 确认可见。
- 命令等价动作:
```js
await fetch('/api/sidebar?workspaceId=ws_req_1778035004501_15', { credentials: 'include' })
```
- 通过标准:返回的 `documents``kernelSidebarProjection.items``kernelSidebarTree` 都包含 `CLI 可见测试 A 1778057564``CLI 可见测试 B 1778057564`
- 当前验证结果:已在浏览器会话 API 返回中看到上述两个页面;同一返回还显示早先创建的 `cli_visible_ws_req_a_17780471``cli_visible_ws_req_b_17780471`
- [ ] 后续权限收口:补“禁止/允许 CLI 编辑、允许 CLI 新建页面”的 capability 开关。
- 目标:默认测试账号允许 CLI 新建与编辑;后续真实用户可在设置中关闭关键页面的 CLI 编辑或新建权限。
- 通过标准:CLI 写操作必须能读到当前用户、当前 workspace、当前页面 capability;未授权时返回明确拒绝,不得 fallback 到 anonymous / dev identity 写入。
- [x] 确认 Web host 会把当前会话用户透传给 `mnote-cli` 作为默认执行身份。
- 命令:
```bash
cd /mnt/Data1T/mnote/wolai-frontend && pnpm vitest run src/lib/server/mnote-cli-agent-host.test.ts src/app/api/ai-agent/run/route.test.ts
```
- 通过标准:`spawn("cargo", ...)` 的环境变量包含 `DEV_USER_ID``DEV_USER_EMAIL``DEV_USER_NAME`,并默认下发 `MNOTE_CLI_ALLOW_CREATE_PAGE=1``MNOTE_CLI_ALLOW_EDIT=1`
---
## 9. 迁移策略
### 阶段 0:口径冻结
先冻结新的长期判断:
- CLI 是唯一长期执行面
- Web 面板只是 CLI 客户端
- `openai-agents-python` 是可插拔外置 agent
- Hermes / Codex / 后续 agent 都统一走 CLI
### 阶段 1CLI 能力补齐
优先补:
- capability manifest
- 标准 JSON 输出
- 事件流输出
- 标准上下文输入
- 标准 trace / session 参数
### 阶段 2Web 面板降为 CLI host
把当前文档页 AI 面板从:
- “调用内置 sidecar 主编排”
改成:
- “调用 CLI host / CLI adapter”
### 阶段 3:外置 agent 接入统一化
统一支持:
- Hermes -> `mnote-cli`
- Codex -> `mnote-cli`
- `openai-agents-python` -> `mnote-cli`
### 阶段 4:裁剪过渡层
当 CLI-first 成熟后,逐步评估:
- 是否保留 `openai-agents-python`
- 是否保留当前 Web 内 sidecar
- 是否完全移除旧过渡桥接
---
## 10. 非目标
本稿不做:
- 再造一套新的 agent 平台
- 让 Web 直接失去 AI UI
- 把所有 UX 都退回终端
- 让 Hermes 或 `openai-agents-python` 重新变成产品内部唯一中心
---
## 11. 完成判定
满足下面几项时,才算 CLI-first 真正成立:
- `mnote-cli` 是唯一长期 agent 执行入口
- Web AI 面板只是 CLI host,不再持有独立主编排
- Hermes / Codex / `openai-agents-python` 都通过 CLI 接入
- Rust kernel 仍然是唯一事实源
- 是否保留 `openai-agents-python` 不影响整体架构
一句话收口:
> **`mnote` 的长期 AI 方向应当收口成“Rust kernel 负责真相,`mnote-cli` 负责唯一执行面,外置 agent 全部可插拔,Web 只做交互壳”。**
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["simple-mind-map"],
};
export default nextConfig;
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+29 -1
View File
@@ -1865,12 +1865,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
@@ -2116,7 +2118,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"wasm-streams 0.5.0",
"web-sys",
"xxhash-rust",
]
@@ -2489,6 +2491,19 @@ dependencies = [
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
@@ -2887,6 +2902,19 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
+41 -29
View File
@@ -583,7 +583,6 @@ struct DocumentSaveCommandPayload {
#[serde(rename_all = "camelCase")]
struct DocumentOptionsCommandPayload {
document_id: String,
workspace_id: Option<String>,
options: DocumentOptionsPatchPayload,
}
@@ -7993,6 +7992,46 @@ fn execute_command(
_ => "documents.options.update",
};
let options = payload.options;
let mut options_json = serde_json::Map::new();
if let Some(value) = options.wide_layout {
options_json.insert("wideLayout".into(), json!(value));
}
if let Some(value) = options.small_text {
options_json.insert("smallText".into(), json!(value));
}
if let Some(value) = options.show_heading_numbers {
options_json.insert("showHeadingNumbers".into(), json!(value));
}
if let Some(value) = options.show_toc {
options_json.insert("showToc".into(), json!(value));
}
if let Some(value) = options.show_structure {
options_json.insert("showStructure".into(), json!(value));
}
if let Some(value) = options.protect_editing {
options_json.insert("protectEditing".into(), json!(value));
}
if let Some(value) = options.show_word_count {
options_json.insert("showWordCount".into(), json!(value));
}
if let Some(value) = options.collapse_backlinks {
options_json.insert("collapseBacklinks".into(), json!(value));
}
if let Some(value) = options.page_font.clone() {
options_json.insert("pageFont".into(), json!(value));
}
if let Some(value) = options.layout_density.clone() {
options_json.insert("layoutDensity".into(), json!(value));
}
if let Some(value) = options.hide_child_pages {
options_json.insert("hideChildPages".into(), json!(value));
}
if let Some(value) = options.show_block_ref_count {
options_json.insert("showBlockRefCount".into(), json!(value));
}
if let Some(value) = options.embed_default_block_id.clone() {
options_json.insert("embedDefaultBlockId".into(), json!(value));
}
let command = CommandEnvelope {
name: command_name.into(),
command_id: command_wire.command_id.clone(),
@@ -8034,22 +8073,7 @@ fn execute_command(
payload_json: request.payload_json,
args_json: json!({
"id": payload.document_id,
"workspaceId": payload.workspace_id,
"options": {
"wideLayout": options.wide_layout,
"smallText": options.small_text,
"showHeadingNumbers": options.show_heading_numbers,
"showToc": options.show_toc,
"showStructure": options.show_structure,
"protectEditing": options.protect_editing,
"showWordCount": options.show_word_count,
"collapseBacklinks": options.collapse_backlinks,
"pageFont": options.page_font,
"layoutDensity": options.layout_density,
"hideChildPages": options.hide_child_pages,
"showBlockRefCount": options.show_block_ref_count,
"embedDefaultBlockId": options.embed_default_block_id,
},
"options": Value::Object(options_json),
}),
}))
}
@@ -13395,21 +13419,9 @@ mod tests {
plan.args_json,
json!({
"id": "doc_1",
"workspaceId": "ws_1",
"options": {
"wideLayout": Value::Null,
"smallText": Value::Null,
"showHeadingNumbers": Value::Null,
"showToc": true,
"showStructure": Value::Null,
"protectEditing": Value::Null,
"showWordCount": Value::Null,
"collapseBacklinks": Value::Null,
"pageFont": Value::Null,
"layoutDensity": "compact",
"hideChildPages": Value::Null,
"showBlockRefCount": Value::Null,
"embedDefaultBlockId": Value::Null,
},
})
);
+86 -35
View File
@@ -90,6 +90,7 @@ pub struct CliOutputContext {
pub actor_id: String,
pub actor_type: String,
pub session_id: Option<String>,
pub reason: Option<String>,
pub workspace_id: Option<String>,
pub idempotency_key: Option<String>,
pub validate_only: bool,
@@ -566,6 +567,7 @@ pub fn plan_page_get(
"page",
"get",
&bridge,
ctx,
query.name,
json!({
"pageId": page_id,
@@ -614,6 +616,7 @@ pub fn plan_page_title(
"page",
"title",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -671,6 +674,7 @@ pub fn plan_page_save(
"page",
"save",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -724,6 +728,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul
"page",
"create",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -778,6 +783,7 @@ pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<Cl
"page",
"move",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -825,6 +831,7 @@ pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResul
"page",
"delete",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -868,6 +875,7 @@ pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliRes
"page",
"restore",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -923,6 +931,7 @@ pub fn plan_block_insert(
"block",
"insert",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -988,6 +997,7 @@ pub fn plan_block_patch(
"block",
"patch",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -1042,6 +1052,7 @@ pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult<
"block",
"move",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -1091,6 +1102,7 @@ pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResul
"block",
"embed",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -1147,6 +1159,7 @@ pub fn plan_search_documents(
"search",
"documents",
&bridge,
ctx,
envelope.name,
json!({
"query": query,
@@ -1209,6 +1222,7 @@ pub fn plan_search_blocks(
"search",
"blocks",
&bridge,
ctx,
envelope.name,
json!({
"query": query,
@@ -1245,6 +1259,7 @@ pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult<C
"sidebar",
"dataset",
&bridge,
ctx,
envelope.name,
json!({
"workspaceId": workspace_id,
@@ -1286,6 +1301,7 @@ pub fn plan_mindmap_get(
"mindmap",
"get",
&bridge,
ctx,
envelope.name,
json!({
"workspaceId": workspace_id,
@@ -1351,6 +1367,7 @@ pub fn plan_mindmap_put(
"mindmap",
"put",
&bridge,
ctx,
command.name,
command.command_id,
json!({
@@ -2148,7 +2165,7 @@ fn execute_convex_query_raw(
args_json: Value,
cli_ctx: &CliContext,
) -> CliResult<Value> {
let env = ConvexCliEnv::load()?;
let env = ConvexCliEnv::load(cli_ctx)?;
let payload = json!({
"path": function_name,
"format": "convex_encoded_json",
@@ -2171,7 +2188,7 @@ fn execute_convex_mutation_raw(
args_json: Value,
cli_ctx: &CliContext,
) -> CliResult<Value> {
let env = ConvexCliEnv::load()?;
let env = ConvexCliEnv::load(cli_ctx)?;
let payload = json!({
"path": function_name,
"format": "convex_encoded_json",
@@ -2225,7 +2242,7 @@ struct ConvexCliEnv {
}
impl ConvexCliEnv {
fn load() -> CliResult<Self> {
fn load(cli_ctx: &CliContext) -> CliResult<Self> {
let url = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL")?
.or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok())
.ok_or_else(|| {
@@ -2233,18 +2250,14 @@ impl ConvexCliEnv {
})?;
let admin_key = read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY")?
.ok_or_else(|| CliError::validation("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?;
let dev_user_id = read_env_or_dotenv("DEV_USER_ID")?.unwrap_or_else(|| "dev-user".into());
let dev_user_id = normalize_actor_for_convex_identity(&cli_ctx.actor_id)
.or(read_env_or_dotenv("DEV_USER_ID")?)
.unwrap_or_else(|| "dev-user".into());
let dev_user_name =
read_env_or_dotenv("DEV_USER_NAME")?.unwrap_or_else(|| "开发用户".into());
let dev_user_email =
read_env_or_dotenv("DEV_USER_EMAIL")?.unwrap_or_else(|| "dev@mnote.local".into());
let identity = json!({
"subject": dev_user_id,
"issuer": "https://mnote.local/dev-auth",
"tokenIdentifier": format!("dev-user|{}", dev_user_id),
"name": dev_user_name,
"email": dev_user_email,
});
let identity = build_convex_dev_identity(&dev_user_id, &dev_user_name, &dev_user_email);
let encoded = base64::engine::general_purpose::STANDARD
.encode(serde_json::to_string(&identity).map_err(|error| {
CliError::transport(format!("开发用户身份序列化失败: {error}"))
@@ -2262,6 +2275,25 @@ impl ConvexCliEnv {
}
}
fn normalize_actor_for_convex_identity(actor_id: &str) -> Option<String> {
let trimmed = actor_id.trim();
if trimmed.is_empty() || trimmed == "anonymous" || trimmed == "cli_user" {
None
} else {
Some(trimmed.to_string())
}
}
fn build_convex_dev_identity(user_id: &str, name: &str, email: &str) -> Value {
json!({
"subject": user_id,
"issuer": "https://mnote.local/dev-auth",
"tokenIdentifier": format!("dev-user|{}", user_id),
"name": name,
"email": email,
})
}
fn read_env_or_dotenv(key: &str) -> CliResult<Option<String>> {
if let Ok(value) = env::var(key) {
let trimmed = value.trim().to_string();
@@ -2457,6 +2489,7 @@ fn build_query_output(
domain: &str,
action: &str,
bridge: &BridgeContext,
ctx: &CliContext,
name: String,
normalized_input: Value,
transport: CliTransportPlan,
@@ -2466,18 +2499,7 @@ fn build_query_output(
entrypoint: "mnote-cli",
domain: domain.into(),
action: action.into(),
context: build_output_context(
bridge,
&CliContext {
actor_id: bridge.actor_id.clone(),
actor_type: bridge.actor_type.clone(),
session_id: bridge.session_id.clone(),
reason: None,
idempotency_key: bridge.idempotency_key.clone(),
validate_only: bridge.validate_only,
dry_run: bridge.dry_run,
},
),
context: build_output_context(bridge, ctx),
operation: CliOperationOutput::Query {
name,
normalized_input,
@@ -2491,6 +2513,7 @@ fn build_command_output(
domain: &str,
action: &str,
bridge: &BridgeContext,
ctx: &CliContext,
name: String,
command_id: String,
normalized_input: Value,
@@ -2501,18 +2524,7 @@ fn build_command_output(
entrypoint: "mnote-cli",
domain: domain.into(),
action: action.into(),
context: build_output_context(
bridge,
&CliContext {
actor_id: bridge.actor_id.clone(),
actor_type: bridge.actor_type.clone(),
session_id: bridge.session_id.clone(),
reason: None,
idempotency_key: bridge.idempotency_key.clone(),
validate_only: bridge.validate_only,
dry_run: bridge.dry_run,
},
),
context: build_output_context(bridge, ctx),
operation: CliOperationOutput::Command {
name,
command_id,
@@ -2530,6 +2542,7 @@ fn build_output_context(bridge: &BridgeContext, ctx: &CliContext) -> CliOutputCo
actor_id: bridge.actor_id.clone(),
actor_type: bridge.actor_type.clone(),
session_id: bridge.session_id.clone(),
reason: ctx.reason.clone(),
workspace_id: bridge.workspace_id.clone(),
idempotency_key: ctx.idempotency_key.clone(),
validate_only: bridge.validate_only,
@@ -2786,6 +2799,18 @@ mod tests {
}
}
#[test]
fn output_context_keeps_reason_for_audit_trace() {
let ctx = CliContext {
reason: Some("phase7-cli-first".into()),
..CliContext::default()
};
let output = plan_page_save(&ctx, "page_1", Some("ws_1"), None, "[]", None)
.expect("page save plan should build");
assert_eq!(output.context.reason.as_deref(), Some("phase7-cli-first"));
}
#[test]
fn page_create_json_contract_uses_documents_create() {
let output = plan_page_create(
@@ -2817,6 +2842,32 @@ mod tests {
}
}
#[test]
fn convex_dev_identity_prefers_explicit_cli_actor() {
let ctx = CliContext {
actor_id: "nn7bhmt782sykdrecah0rbe2nx867ks1".into(),
..CliContext::default()
};
let user_id = normalize_actor_for_convex_identity(&ctx.actor_id)
.expect("显式 CLI actor 应成为 Convex 写入身份");
let identity = build_convex_dev_identity(&user_id, "开发用户", "dev@mnote.local");
assert_eq!(
identity["subject"],
json!("nn7bhmt782sykdrecah0rbe2nx867ks1")
);
assert_eq!(
identity["tokenIdentifier"],
json!("dev-user|nn7bhmt782sykdrecah0rbe2nx867ks1")
);
}
#[test]
fn convex_dev_identity_ignores_legacy_default_cli_actor() {
assert_eq!(normalize_actor_for_convex_identity("cli_user"), None);
assert_eq!(normalize_actor_for_convex_identity(" anonymous "), None);
}
#[test]
fn page_move_json_contract_uses_documents_move() {
let output = plan_page_move(
+1 -1
View File
@@ -12,7 +12,7 @@ core-protocol = { path = "../core-protocol" }
futures-util = "0.3"
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
mnote-editor-core = { path = "../mnote-editor-core" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
storage-convex-bridge = { path = "../storage-convex-bridge" }
+668 -26
View File
@@ -3,26 +3,21 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id;
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use axum::body::{Body, Bytes};
use axum::extract::Query;
use axum::extract::{Extension, State};
use axum::http::StatusCode;
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use core_protocol::KernelProjectionKind;
use futures_util::{StreamExt, TryStreamExt};
use serde::Deserialize;
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompatBoundaryResponse {
pub ok: bool,
pub boundary: &'static str,
pub compatibility: &'static str,
pub request_id: String,
pub trace_id: String,
pub target: String,
pub notes: Vec<&'static str>,
}
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -33,19 +28,548 @@ pub struct CompatSidebarQuery {
pub async fn next_ai_agent_run(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Json<CompatBoundaryResponse> {
Json(CompatBoundaryResponse {
ok: true,
boundary: "next_route_compat",
compatibility: "placeholder",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
target: format!("{}/bridge", state.config().hermes_base_path),
notes: vec![
"/api/ai-agent/run 是 legacy compat endpointcanonical route 是 /api/hermes/bridge。",
"结构化写入必须通过 Hermes/Rust bridge 再落到 page/tree/edge command。",
],
request: Request<Body>,
) -> Result<Response, WebError> {
let (parts, body) = request.into_parts();
let body = axum::body::to_bytes(body, 10 * 1024 * 1024)
.await
.map_err(|error| WebError::internal(format!("读取 AI 请求体失败: {error}")))?;
let payload: Value = serde_json::from_slice(&body).map_err(|error| {
WebError::bad_request_code(
"ai_agent_bad_request",
format!("AI 请求体不是合法 JSON: {error}"),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
})?;
if state.config().enable_legacy_next_compat {
if let Some(base_url) = state.config().legacy_next_base_url.as_deref() {
if let Ok(response) =
proxy_ai_agent_run_to_next(base_url, &context, &parts.headers, body.clone()).await
{
return Ok(response);
}
}
}
if let Ok(response) = run_local_mnote_cli_ai_host(&state, &context, &payload).await {
return Ok(response);
}
let Some(backend_url) = resolve_ai_orchestrator_backend_url() else {
return Err(WebError::bad_gateway_code(
"ai_orchestrator_unavailable",
"未配置 BACKEND_URL,无法连接 document AI orchestrator。",
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web"));
};
let upstream_url = reqwest::Url::parse(&format!(
"{}/api/v1/ai-agent/document/run",
backend_url.trim_end_matches('/')
))
.map_err(|error| WebError::internal(format!("AI orchestrator URL 非法: {error}")))?;
let forward_payload = build_document_ai_orchestrator_payload(&state, &context, &payload);
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("AI orchestrator HTTP 客户端创建失败: {error}"))
})?;
let mut upstream_request = client.post(upstream_url).json(&forward_payload);
upstream_request = apply_ai_forward_headers(upstream_request, &parts.headers, &context);
if let Some(api_key) = read_env_or_dotenv("MNOTE_AI_ORCHESTRATOR_API_KEY") {
upstream_request = upstream_request.header("x-mnote-ai-key", api_key);
}
let upstream_response = upstream_request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"ai_orchestrator_proxy_error",
format!("document AI orchestrator 请求失败: {error}"),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "document-ai-orchestrator")
})?;
if !upstream_response.status().is_success() {
let status = upstream_response.status();
let body = upstream_response
.text()
.await
.unwrap_or_else(|error| format!("读取 upstream 错误响应失败: {error}"));
return Err(WebError::bad_gateway_code(
"ai_orchestrator_upstream_error",
format!(
"document AI orchestrator 返回 HTTP {}: {}",
status.as_u16(),
body
),
)
.with_context(&context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "document-ai-orchestrator"));
}
build_ai_sse_proxy_response(upstream_response, &context)
}
async fn proxy_ai_agent_run_to_next(
base_url: &str,
context: &RequestContext,
headers: &HeaderMap,
body_bytes: Bytes,
) -> Result<Response, WebError> {
let upstream_url = reqwest::Url::parse(&format!(
"{}/api/ai-agent/run",
base_url.trim_end_matches('/')
))
.map_err(|error| WebError::internal(format!("Next AI route URL 非法: {error}")))?;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| WebError::internal(format!("Next AI HTTP 客户端创建失败: {error}")))?;
let mut upstream_request = client.post(upstream_url).body(body_bytes.clone());
for (name, value) in headers.iter() {
if is_hop_by_hop_header(name.as_str())
|| name == header::HOST
|| name == header::CONTENT_LENGTH
|| name == header::COOKIE
|| name == header::AUTHORIZATION
{
continue;
}
upstream_request = upstream_request.header(name.as_str(), value.as_bytes());
}
if let Some(cookie) = context.auth.cookie_header.as_deref() {
upstream_request = upstream_request.header(header::COOKIE, cookie);
}
if let Some(authorization) = context.auth.authorization.as_deref() {
upstream_request = upstream_request.header(header::AUTHORIZATION, authorization);
}
upstream_request = upstream_request.header("x-request-id", context.trace.request_id.as_str());
upstream_request = upstream_request.header("x-trace-id", context.trace.trace_id.as_str());
upstream_request = upstream_request.header("x-mnote-source-channel", "rust_web_route");
upstream_request = upstream_request.header("x-mnote-source-client", "mnote-web");
upstream_request = upstream_request.header("x-mnote-actor-id", context.auth.actor_id.as_str());
upstream_request =
upstream_request.header("x-mnote-actor-type", context.auth.actor_type.as_str());
if let Some(workspace_id) = context.workspace.workspace_id.as_deref() {
upstream_request = upstream_request.header("x-mnote-workspace-id", workspace_id);
}
if let Some(session_id) = context.auth.session_id.as_deref() {
upstream_request = upstream_request.header("x-mnote-session-id", session_id);
}
let upstream_response = upstream_request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"next_ai_proxy_error",
format!("Next /api/ai-agent/run 请求失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "next-ai-route")
})?;
if !upstream_response.status().is_success() {
let status = upstream_response.status();
let body = upstream_response
.text()
.await
.unwrap_or_else(|error| format!("读取 Next AI 错误响应失败: {error}"));
return Err(WebError::bad_gateway_code(
"next_ai_upstream_error",
format!(
"Next /api/ai-agent/run 返回 HTTP {}: {}",
status.as_u16(),
body
),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
.with_header("x-upstream-service", "next-ai-route"));
}
build_ai_sse_proxy_response(upstream_response, context)
}
fn resolve_repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
}
fn build_mnote_cli_args(context: &RequestContext, payload: &Value) -> Vec<String> {
let ai = payload
.get("options")
.and_then(|value| value.get("ai"))
.cloned()
.unwrap_or(Value::Null);
let runtime_context = payload.get("context").cloned().unwrap_or(Value::Null);
let document_id = runtime_context
.get("documentId")
.and_then(Value::as_str)
.unwrap_or("current");
let workspace_id = runtime_context
.get("workspaceId")
.and_then(Value::as_str)
.or(context.workspace.workspace_id.as_deref());
let session_id = ai
.get("sessionId")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("ai-{}", context.trace.request_id));
let args_json = json!({
"pageId": document_id,
"documentId": document_id,
"workspaceId": workspace_id,
"provider": ai.get("provider").cloned().unwrap_or(Value::Null),
"modelKey": ai.get("modelKey").cloned().unwrap_or(Value::Null),
"profileId": ai.get("profileId").cloned().unwrap_or(Value::Null),
"selectedUids": runtime_context.get("selectedUids").cloned().unwrap_or(Value::Null),
"pageOptions": runtime_context.get("pageOptions").cloned().unwrap_or(Value::Null),
})
.to_string();
vec![
"run".into(),
"--quiet".into(),
"--manifest-path".into(),
resolve_repo_root()
.join("rust")
.join("Cargo.toml")
.to_string_lossy()
.to_string(),
"-p".into(),
"mnote-cli".into(),
"--".into(),
"--json".into(),
"--validate-only".into(),
"--dry-run".into(),
"--actor-id".into(),
context.auth.actor_id.clone(),
"--actor-type".into(),
context.auth.actor_type.clone(),
"--session-id".into(),
session_id,
"--reason".into(),
"ai-agent-run:mnote-web-rust-host".into(),
"tool".into(),
"run".into(),
"--tool-name".into(),
"doc_get".into(),
"--kind".into(),
"query".into(),
"--mode".into(),
"explain-plan".into(),
"--args-json".into(),
args_json,
]
}
async fn run_local_mnote_cli_ai_host(
state: &AppState,
context: &RequestContext,
payload: &Value,
) -> Result<Response, WebError> {
let stream = payload
.get("stream")
.and_then(Value::as_bool)
.unwrap_or(true);
let args = build_mnote_cli_args(context, payload);
let repo_root = resolve_repo_root();
let actor_id =
if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" {
state.config().dev_user_id.clone()
} else {
context.auth.actor_id.clone()
};
let actor_type =
if context.auth.actor_type.trim().is_empty() || context.auth.actor_type == "anonymous" {
"user".to_string()
} else {
context.auth.actor_type.clone()
};
let dev_email = state.config().dev_user_email.clone();
let dev_name = state.config().dev_user_name.clone();
let output = tokio::task::spawn_blocking(move || {
Command::new("cargo")
.args(args)
.current_dir(repo_root)
.env("CARGO_TERM_COLOR", "never")
.env(
"RUSTUP_TOOLCHAIN",
env::var("RUSTUP_TOOLCHAIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "1.89.0".into()),
)
.env("DEV_USER_ID", actor_id)
.env("DEV_USER_EMAIL", dev_email)
.env("DEV_USER_NAME", dev_name)
.env("MNOTE_CLI_ALLOW_CREATE_PAGE", "1")
.env("MNOTE_CLI_ALLOW_EDIT", "1")
.env("MNOTE_ACTOR_TYPE", actor_type)
.output()
})
.await
.map_err(|error| {
WebError::bad_gateway_code(
"mnote_cli_host_join_error",
format!("mnote-cli host join 失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
})?
.map_err(|error| {
WebError::bad_gateway_code(
"mnote_cli_host_spawn_error",
format!("mnote-cli host 启动失败: {error}"),
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web")
})?;
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stream {
if !output.status.success() {
return Err(WebError::bad_gateway_code(
"mnote_cli_host_failed",
if stderr.is_empty() {
"mnote-cli 执行失败".into()
} else {
stderr
},
)
.with_context(context)
.with_header("x-mnote-web-owner", "mnote-web"));
}
let mut response = Json(json!({
"ok": true,
"bridgeOwner": "mnote-cli",
"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout },
}))
.into_response();
stamp_owner_header(response.headers_mut());
response.headers_mut().insert(
HeaderName::from_static("x-mnote-ai-execution-owner"),
HeaderValue::from_static("mnote-cli"),
);
return Ok(response);
}
let body = if output.status.success() {
format!(
"event: ready\ndata: {}\n\nevent: assistant_message\ndata: {}\n\nevent: completion\ndata: {}\n\n",
json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(),
json!({"text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }}).to_string(),
json!({"ok": true, "text": if stdout.is_empty() { "mnote-cli 无输出" } else { &stdout }, "steps": 1}).to_string(),
)
} else {
format!(
"event: ready\ndata: {}\n\nevent: error\ndata: {}\n\n",
json!({"ok": true, "bridgeOwner": "mnote-cli"}).to_string(),
json!({"ok": false, "message": if stderr.is_empty() { "mnote-cli 执行失败" } else { &stderr }}).to_string(),
)
};
let mut response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header(header::CONNECTION, "keep-alive")
.header("x-accel-buffering", "no")
.header("x-mnote-ai-execution-owner", "mnote-cli")
.body(Body::from(body))
.map_err(|error| WebError::internal(format!("mnote-cli SSE 响应构造失败: {error}")))?;
stamp_owner_header(response.headers_mut());
Ok(response)
}
fn build_document_ai_orchestrator_payload(
state: &AppState,
context: &RequestContext,
payload: &Value,
) -> Value {
let ai_options = payload.get("options").and_then(|value| value.get("ai"));
let user_id = if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous"
{
state.config().dev_user_id.clone()
} else {
context.auth.actor_id.clone()
};
json!({
"userId": user_id,
"sessionId": ai_options
.and_then(|value| value.get("sessionId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty()),
"model": ai_options
.and_then(|value| value.get("model"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty()),
"modelKey": ai_options
.and_then(|value| value.get("modelKey"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty()),
"profileId": ai_options
.and_then(|value| value.get("profileId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty()),
"maxSteps": payload.get("maxSteps").filter(|value| !value.is_null()).cloned().unwrap_or_else(|| json!(10)),
"messages": payload.get("messages").cloned().unwrap_or_else(|| json!([])),
"context": build_document_ai_context(payload.get("context")),
})
}
fn build_document_ai_context(context: Option<&Value>) -> Value {
let get = |key: &str| {
context
.and_then(|value| value.get(key))
.cloned()
.unwrap_or(Value::Null)
};
json!({
"source": get("source"),
"action": get("action"),
"documentId": get("documentId"),
"workspaceId": get("workspaceId"),
"selectedBlockId": get("selectedBlockId"),
"selectedBlockIndex": get("selectedBlockIndex"),
"selectedUids": get("selectedUids"),
"selectedText": get("selectedText"),
"selection": get("selection"),
"tiptapDocument": get("tiptapDocument"),
"documentBlocks": get("documentBlocks"),
"node": get("node"),
"subtree": get("subtree"),
"outline": get("outline"),
"evidence": get("evidence"),
"pageOptions": get("pageOptions"),
})
}
fn apply_ai_forward_headers(
mut request: reqwest::RequestBuilder,
headers: &HeaderMap,
context: &RequestContext,
) -> reqwest::RequestBuilder {
for (name, value) in headers.iter() {
if is_hop_by_hop_header(name.as_str())
|| name == header::HOST
|| name == header::CONTENT_LENGTH
|| name == header::CONTENT_TYPE
{
continue;
}
request = request.header(name.as_str(), value.as_bytes());
}
request = request.header(header::CONTENT_TYPE.as_str(), "application/json");
request = request.header("x-request-id", context.trace.request_id.as_str());
request = request.header("x-trace-id", context.trace.trace_id.as_str());
request = request.header("x-mnote-source-channel", "rust_web_route");
request = request.header("x-mnote-source-client", "mnote-web");
request = request.header("x-mnote-actor-id", context.auth.actor_id.as_str());
request.header("x-mnote-actor-type", context.auth.actor_type.as_str())
}
fn build_ai_sse_proxy_response(
upstream_response: reqwest::Response,
context: &RequestContext,
) -> Result<Response, WebError> {
let status =
StatusCode::from_u16(upstream_response.status().as_u16()).unwrap_or(StatusCode::OK);
let ready = Bytes::from(format!(
"event: ready\ndata: {}\n\n",
json!({"ok": true, "requestId": context.trace.request_id}).to_string()
));
let upstream_stream = upstream_response
.bytes_stream()
.map_err(std::io::Error::other);
let body_stream = futures_util::stream::once(async move { Ok::<Bytes, std::io::Error>(ready) })
.chain(upstream_stream);
let mut response = Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header(header::CONNECTION, "keep-alive")
.header("x-accel-buffering", "no")
.body(Body::from_stream(body_stream))
.map_err(|error| WebError::internal(format!("AI SSE 响应构造失败: {error}")))?;
stamp_owner_header(response.headers_mut());
Ok(response)
}
fn stamp_owner_header(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web"));
}
}
fn resolve_ai_orchestrator_backend_url() -> Option<String> {
read_env_or_dotenv("BACKEND_INTERNAL_URL")
.or_else(|| read_env_or_dotenv("BACKEND_URL"))
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
fn 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"
)
}
pub async fn next_sidebar(
@@ -97,6 +621,8 @@ mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use tokio::net::TcpListener;
use tower::util::ServiceExt;
fn app() -> axum::Router {
@@ -135,4 +661,120 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn direct_ai_agent_run_is_owned_by_rust_web_when_next_compat_disabled() {
let response = 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: false,
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(),
}))
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai-agent/run")
.header("content-type", "application/json")
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"online"}}}"#))
.expect("request"),
)
.await
.expect("response");
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(!text.contains("legacy_next_compat_disabled"));
}
#[tokio::test]
async fn ai_agent_run_proxies_to_next_ai_route_when_legacy_next_compat_enabled() {
let next_app = axum::Router::new().route(
"/api/ai-agent/run",
axum::routing::post(|| async move {
(
[(
axum::http::header::CONTENT_TYPE,
"text/event-stream; charset=utf-8",
)],
"event: assistant_message\ndata: {\"text\":\"hello from next ai\"}\n\n",
)
.into_response()
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind next");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
axum::serve(listener, next_app).await.expect("serve next");
});
let response = 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(format!("http://{}", addr)),
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(),
}))
.oneshot(
Request::builder()
.method("POST")
.uri("/api/ai-agent/run")
.header("content-type", "application/json")
.body(Body::from(r#"{"stream":true,"scope":"document","messages":[{"role":"user","content":"ping"}],"context":{"documentId":"doc-1"},"options":{"ai":{"provider":"hermes"}}}"#))
.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 = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("assistant_message"));
assert!(text.contains("hello from next ai"));
server.abort();
}
}
@@ -65,6 +65,12 @@ pub struct DocumentOptionsRequest {
pub command_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentPurgeRequest {
pub document_id: String,
}
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";
@@ -552,6 +558,50 @@ pub async fn save(
Ok(ok_response(&context, result))
}
pub async fn purge(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentPurgeRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let command = RuntimeCommandEnvelopeWire {
name: "documents.purge".into(),
command_id: format!("document_purge_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(RuntimeTargetWire {
workspace_id: None,
page_id: Some(document_id.to_string()),
block_id: None,
}),
payload: json!({
"documentId": document_id,
}),
preflight_data: None,
reason: Some("mnote-web documents purge compat".into()),
refs: vec!["mnote-web-documents-compat".into()],
dry_run: false,
validate_only: false,
};
let result =
execute_runtime_command_via_convex(state.config(), &context, None, command).await?;
Ok(ok_response(&context, result))
}
pub async fn title(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
+428 -18
View File
@@ -19,6 +19,10 @@ 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";
const COOKIE_CONVEX_AUTH_JWT: &str = "__convexAuthJWT";
const COOKIE_CONVEX_AUTH_REFRESH_TOKEN: &str = "__convexAuthRefreshToken";
const COOKIE_MNOTE_WEB_CONVEX_TOKEN: &str = "mnote_web_convex_token";
const COOKIE_MNOTE_WEB_DEV_SESSION: &str = "mnote_web_dev_session";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -55,7 +59,16 @@ pub async fn gateway_health(State(state): State<AppState>) -> Response {
response
}
pub async fn auth_entry(
pub async fn favicon() -> Response {
let mut response = Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response());
stamp_gateway_headers(response.headers_mut(), false);
response
}
pub async fn auth_api(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
request: Request<Body>,
@@ -64,6 +77,49 @@ pub async fn auth_entry(
return legacy_next_proxy(State(state), Extension(context), request).await;
}
let body = axum::body::to_bytes(request.into_body(), 256 * 1024)
.await
.map_err(|error| WebError::bad_request(format!("读取登录请求失败: {error}")))?;
let payload: serde_json::Value = serde_json::from_slice(&body).map_err(|error| {
WebError::bad_request_code(
"auth_bad_request",
format!("登录请求不是合法 JSON: {error}"),
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
})?;
let action = payload
.get("action")
.and_then(|value| value.as_str())
.unwrap_or_default();
if action != "auth:signIn" && action != "auth:signOut" {
return Err(WebError::bad_request_code(
"auth_action_unsupported",
"Rust gateway 当前仅支持 Convex Auth 登录与登出动作。",
)
.with_context(&context)
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
let convex_response = run_convex_auth_action(&state, &context, &payload).await?;
Ok(build_auth_proxy_response(&convex_response, &context))
}
pub async fn auth_entry(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
_request: Request<Body>,
) -> Result<Response, WebError> {
if has_real_auth_context(&context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let content = crate::ssr::render_view(crate::ssr::pages::auth::AuthPage());
let mut response = Html(format!(
r#"<!doctype html>
@@ -90,6 +146,16 @@ pub async fn root_entry(
Extension(context): Extension<RequestContext>,
Query(query): Query<RootEntryQuery>,
) -> Result<Response, WebError> {
if !has_real_auth_context(&context) {
let mut response = Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/auth")
.body(Body::empty())
.map_err(|error| WebError::internal(format!("认证入口跳转响应构造失败: {error}")))?;
stamp_gateway_headers(response.headers_mut(), false);
return Ok(response);
}
let workspace_id =
resolve_root_workspace_id(&state, &context, query.workspace_id.as_deref()).await?;
let requested_page_id = normalize_optional_id(query.page_id.as_deref());
@@ -365,6 +431,195 @@ fn normalize_optional_id(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn has_real_auth_context(context: &RequestContext) -> bool {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return true;
}
extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT).is_some()
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
}
async fn run_convex_auth_action(
state: &AppState,
context: &RequestContext,
payload: &serde_json::Value,
) -> Result<serde_json::Value, WebError> {
let convex_url = state
.config()
.convex_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::service_unavailable_code(
"convex_config_missing",
"缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL,无法执行 Convex Auth。",
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_url")
.with_header("x-upstream-service", "convex")
})?;
let action = payload
.get("action")
.and_then(|value| value.as_str())
.unwrap_or_default();
let mut args = payload.get("args").cloned().unwrap_or_else(|| json!({}));
if action == "auth:signIn"
&& args
.get("refreshToken")
.map(|value| !value.is_null())
.unwrap_or(false)
{
if let Some(refresh_token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_REFRESH_TOKEN)
{
args["refreshToken"] = serde_json::Value::String(refresh_token);
}
}
let request_body = json!({
"path": action,
"format": "convex_encoded_json",
"args": [args],
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| {
WebError::internal(format!("Convex Auth HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "convex_auth_client")
.with_header("x-upstream-service", "convex")
})?;
let mut request = client
.post(format!("{}/api/action", convex_url.trim_end_matches('/')))
.header("Content-Type", "application/json")
.header("Convex-Client", "mnote-web")
.json(&request_body);
if let Some(token) = extract_cookie_value(context, COOKIE_CONVEX_AUTH_JWT) {
request = request.header(header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"convex_auth_proxy_error",
format!("Convex Auth 请求失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_action")
.with_header("x-upstream-service", "convex")
})?;
let status = response.status();
let value: serde_json::Value = response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"convex_auth_response_invalid",
format!("Convex Auth 响应不是合法 JSON: {error}"),
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_decode")
.with_header("x-upstream-service", "convex")
})?;
if !status.is_success() && status.as_u16() != 560 {
return Err(WebError::bad_gateway_code(
"convex_auth_upstream_error",
format!("Convex Auth 返回 HTTP {}: {}", status.as_u16(), value),
)
.with_context(context)
.with_header("x-error-phase", "convex_auth_status")
.with_header("x-upstream-service", "convex"));
}
Ok(value)
}
fn build_auth_proxy_response(
convex_response: &serde_json::Value,
context: &RequestContext,
) -> Response {
if convex_response
.get("status")
.and_then(|value| value.as_str())
!= Some("success")
{
let message = convex_response
.get("errorMessage")
.and_then(|value| value.as_str())
.unwrap_or("Convex Auth 登录失败。");
let mut response = axum::Json(json!({ "error": message })).into_response();
*response.status_mut() = StatusCode::BAD_REQUEST;
clear_auth_cookies(response.headers_mut());
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
return response;
}
let value = convex_response
.get("value")
.cloned()
.unwrap_or_else(|| json!({}));
let tokens = value.get("tokens");
let mut response_body = value.clone();
if let Some(tokens) = tokens {
if tokens.is_null() {
response_body["tokens"] = serde_json::Value::Null;
} else if let Some(token) = tokens.get("token").and_then(|value| value.as_str()) {
response_body["tokens"] = json!({
"token": token,
"refreshToken": "dummy",
});
}
}
let mut response = axum::Json(response_body).into_response();
if let Some(tokens) = tokens {
if tokens.is_null() {
clear_auth_cookies(response.headers_mut());
} else {
set_auth_cookie_from_value(
response.headers_mut(),
COOKIE_CONVEX_AUTH_JWT,
tokens.get("token"),
);
set_auth_cookie_from_value(
response.headers_mut(),
COOKIE_CONVEX_AUTH_REFRESH_TOKEN,
tokens.get("refreshToken"),
);
expire_cookie(response.headers_mut(), COOKIE_MNOTE_WEB_DEV_SESSION);
}
}
stamp_gateway_headers(response.headers_mut(), false);
context.apply_response_headers(response.headers_mut());
response
}
fn set_auth_cookie_from_value(
headers: &mut axum::http::HeaderMap,
name: &'static str,
value: Option<&serde_json::Value>,
) {
let Some(value) = value.and_then(|value| value.as_str()) else {
return;
};
let cookie = format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn clear_auth_cookies(headers: &mut axum::http::HeaderMap) {
expire_cookie(headers, COOKIE_CONVEX_AUTH_JWT);
expire_cookie(headers, COOKIE_CONVEX_AUTH_REFRESH_TOKEN);
expire_cookie(headers, COOKIE_MNOTE_WEB_DEV_SESSION);
}
fn expire_cookie(headers: &mut axum::http::HeaderMap, name: &'static str) {
let cookie = format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
fn choose_root_entry_active_page_id(
requested_page_id: Option<&str>,
recent_page_id: Option<&str>,
@@ -466,6 +721,14 @@ mod tests {
fn app_with_config(
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
) -> axum::Router {
app_with_config_and_convex_url(legacy_next_base_url, enable_legacy_next_compat, None)
}
fn app_with_config_and_convex_url(
legacy_next_base_url: String,
enable_legacy_next_compat: bool,
convex_url: Option<String>,
) -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
@@ -477,7 +740,7 @@ mod tests {
enable_debug_shell_routes: false,
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_url,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: None,
@@ -488,6 +751,33 @@ mod tests {
}))
}
async fn spawn_convex_auth_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("convex auth listener");
let addr = listener.local_addr().expect("convex auth addr");
let app = axum::Router::new().route(
"/api/action",
post(|| async {
axum::Json(serde_json::json!({
"status": "success",
"value": {
"tokens": {
"token": "jwt-demo",
"refreshToken": "refresh-demo"
}
}
}))
}),
);
tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("convex auth server");
});
format!("http://{addr}")
}
async fn spawn_legacy_auth_upstream() -> String {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -598,6 +888,8 @@ mod tests {
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
@@ -674,6 +966,8 @@ mod tests {
.uri("/")
.header("cookie", "mnote_recent_page_id=page_child")
.header("x-mnote-workspace-id", "ws_demo")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
@@ -691,6 +985,57 @@ mod tests {
assert!(html.contains(r#"data-root-active-page-id="page_child""#));
}
#[tokio::test]
async fn root_entry_redirects_anonymous_viewer_to_auth() {
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::SEE_OTHER);
assert_eq!(
response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("/auth")
);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
}
#[tokio::test]
async fn root_entry_allows_forwarded_actor_to_enter_workspace() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.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-mnote-shell="workspace""#));
}
#[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)
@@ -720,7 +1065,77 @@ mod tests {
}
#[tokio::test]
async fn auth_entry_uses_legacy_next_login_ui_when_compat_enabled() {
async fn favicon_is_handled_by_gateway_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.uri("/favicon.ico")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::NO_CONTENT);
assert_eq!(
response
.headers()
.get("x-mnote-web-owner")
.and_then(|value| value.to_str().ok()),
Some("mnote-web")
);
}
#[tokio::test]
async fn auth_api_sets_convex_auth_cookies_when_compat_disabled() {
let convex_url = spawn_convex_auth_upstream().await;
let response = app_with_config_and_convex_url(
"http://127.0.0.1:3100".into(),
false,
Some(convex_url),
)
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth")
.header("content-type", "application/json")
.body(Body::from(
r#"{"action":"auth:signIn","args":{"provider":"password","params":{"email":"mnote.e2e@example.com","password":"MnoteE2E123!","flow":"signIn"}}}"#,
))
.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 cookies = response.headers().get_all(header::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")));
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["tokens"]["token"], "jwt-demo");
assert_eq!(payload["tokens"]["refreshToken"], "dummy");
}
#[tokio::test]
async fn auth_entry_uses_mnote_web_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(
@@ -738,44 +1153,39 @@ mod tests {
.headers()
.get("x-mnote-legacy-upstream")
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
None
);
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-mnote-shell="auth""#));
assert!(html.contains("邮箱登录"));
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)
async fn auth_entry_redirects_authenticated_viewer_to_root() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
.method("POST")
.uri("/auth")
.header("origin", "http://127.0.0.1:3000")
.header("referer", "http://127.0.0.1:3000/auth")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response
.headers()
.get("x-mnote-legacy-upstream")
.get(header::LOCATION)
.and_then(|value| value.to_str().ok()),
Some("next-app-router")
Some("/")
);
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]
+7
View File
@@ -30,6 +30,7 @@ pub fn build_router(state: AppState) -> Router {
let mut router = Router::new()
.route("/health", get(health::health))
.route("/", get(gateway::root_entry))
.route("/favicon.ico", get(gateway::favicon))
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route(
@@ -59,10 +60,16 @@ pub fn build_router(state: AppState) -> Router {
.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", post(gateway::auth_api))
.route("/api/auth/session", get(session::session))
.route("/api/auth/whoami", get(session::session))
.route("/api/auth/mnote-web-token", get(session::session))
.route("/api/auth/session/refresh", post(session::refresh_session))
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/page", get(web_shell::documents_page_compat))
.route("/api/documents/purge", post(documents::purge))
.route("/api/documents/title", post(documents::title))
.route("/api/documents/options", post(documents::options))
.route("/api/documents/save", post(documents::save))
+138 -20
View File
@@ -28,6 +28,7 @@ pub struct SessionResponse {
pub email: String,
pub name: String,
pub actor_type: String,
pub auth_mode: &'static str,
pub request_id: String,
pub trace_id: String,
}
@@ -47,36 +48,57 @@ 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,
}))
owner_json(Json(build_session_response(&state, context)))
}
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,
}));
let mut response = owner_json(Json(build_session_response(&state, context)));
*response.status_mut() = StatusCode::OK;
response
}
fn build_session_response(state: &AppState, context: RequestContext) -> SessionResponse {
let actor_id = context.auth.actor_id.trim();
let has_forwarded_actor = !actor_id.is_empty() && actor_id != "anonymous";
let user_id = if has_forwarded_actor {
actor_id.to_string()
} else {
state.config().dev_user_id.clone()
};
let actor_type = if has_forwarded_actor {
context.auth.actor_type
} else {
"devFallback".to_string()
};
SessionResponse {
ok: true,
owner: "mnote-web",
user_id,
email: if has_forwarded_actor {
String::new()
} else {
state.config().dev_user_email.clone()
},
name: if has_forwarded_actor {
actor_id.to_string()
} else {
state.config().dev_user_name.clone()
},
actor_type,
auth_mode: if has_forwarded_actor {
"forwardedActor"
} else {
"devFallback"
},
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
}
}
fn owner_json<T>(payload: Json<T>) -> Response
where
T: Serialize,
@@ -172,4 +194,100 @@ mod tests {
assert_eq!(payload["userId"], "dev-user");
assert!(payload.get("convexAdminKey").is_none());
}
#[tokio::test]
async fn session_prefers_forwarded_actor_identity_over_dev_fallback() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/auth/session")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.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"], "user_real");
assert_eq!(payload["actorType"], "user");
assert_eq!(payload["authMode"], "forwardedActor");
assert!(payload.get("convexAdminKey").is_none());
}
#[tokio::test]
async fn legacy_whoami_alias_prefers_forwarded_actor_identity() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/auth/whoami")
.header("x-mnote-actor-id", "user_real")
.header("x-mnote-actor-type", "user")
.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"], "user_real");
assert_eq!(payload["actorType"], "user");
assert_eq!(payload["authMode"], "forwardedActor");
assert_ne!(payload["code"], "legacy_next_compat_disabled");
}
#[tokio::test]
async fn legacy_whoami_alias_returns_dev_identity() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/auth/whoami")
.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_ne!(payload["code"], "legacy_next_compat_disabled");
}
#[tokio::test]
async fn legacy_mnote_web_token_alias_returns_dev_identity() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/auth/mnote-web-token")
.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_ne!(payload["code"], "legacy_next_compat_disabled");
}
}
+11 -13
View File
@@ -295,11 +295,7 @@ pub(crate) fn collect_filetree_render_rows(
let selected_ids = active_document_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|document_id| {
[format!("doc:{document_id}"), format!("index:{document_id}")]
.into_iter()
.collect::<BTreeSet<_>>()
})
.map(|document_id| BTreeSet::from([format!("index:{document_id}")]))
.unwrap_or_default();
projection
@@ -497,10 +493,7 @@ fn build_tree_shell_renderer_input(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|document_id| {
FileTreeSelectionState::from_selected(&[
format!("doc:{document_id}"),
format!("index:{document_id}"),
])
FileTreeSelectionState::from_selected(&[format!("index:{document_id}")])
})
.unwrap_or_default();
TreeShellRendererInput::filetree(FileTreeRendererInput {
@@ -1512,10 +1505,10 @@ fn build_tree_shell_html(
let selectedFileTreeRowIds = new Set(
rendererSelectedFileTreeRowIds.length > 0
? rendererSelectedFileTreeRowIds
: currentActiveDocumentId ? [`doc:${currentActiveDocumentId}`, `index:${currentActiveDocumentId}`] : []
: currentActiveDocumentId ? [`index:${currentActiveDocumentId}`] : []
);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `doc:${currentActiveDocumentId}` : null);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? `index:${currentActiveDocumentId}` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? `index:${currentActiveDocumentId}` : null);
let visibleFileTreeRowIds = [];
let draggingPageNodeId = "";
let activePageDropNodeId = null;
@@ -4964,7 +4957,12 @@ mod tests {
assert!(filetree_html.contains("\"rendererInput\""));
assert!(filetree_html.contains("\"mode\":\"fileTree\""));
assert!(filetree_html.contains("\"filetreeSelection\""));
assert!(filetree_html.contains("\"selectedRowIds\""));
assert!(filetree_html.contains("\"selectedRowIds\":[\"index:page_root\"]"));
assert!(!filetree_html.contains("\"selectedRowIds\":[\"doc:page_root\""));
assert!(filetree_html.contains("\"focusedRowId\":\"index:page_root\""));
assert!(filetree_html.contains("data-row-id=\"doc:page_root\""));
assert!(filetree_html.contains("data-row-id=\"index:page_root\""));
assert!(filetree_html.contains("data-row-id=\"index:page_root\" data-row-kind=\"index\""));
assert!(filetree_html.contains("\"commandDispatcher\""));
assert!(filetree_html.contains("\"runtimeArtifact\""));
assert!(filetree_html.contains("\"contractName\":\"rust_tree_shell_runtime_artifact_v1\""));
+189 -17
View File
@@ -41,6 +41,13 @@ pub struct DocumentShellQuery {
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentPageCompatQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
pub async fn document_page_shell(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -82,6 +89,8 @@ pub async fn document_page_shell(
let workspace_name = workspace_projection.workspace_name.clone();
let page_subtree_json =
serde_json::to_string(&aggregate.tree.page_subtree).unwrap_or_else(|_| "null".to_string());
let page_options_json = serde_json::to_string(&aggregate.layout.page_options)
.unwrap_or_else(|_| "null".to_string());
let snapshot_json = serde_json::to_string(&aggregate).unwrap_or_else(|_| "null".to_string());
let bootstrap_json = build_editor_bootstrap_json(&aggregate, &context);
let body_content = crate::ssr::render_view(leptos::view! {
@@ -93,6 +102,7 @@ pub async fn document_page_shell(
workspace_name={workspace_name}
workspace_sidebar_html={workspace_sidebar_html}
page_subtree_json={page_subtree_json}
page_options_json={page_options_json}
/>
});
let html = format!(
@@ -198,9 +208,9 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
}
if (!documentId) return;
const escapedId = cssEscape(documentId);
setText(`.tree-row[data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-document-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-doc-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
const escapedDocRowId = cssEscape(`doc:${documentId}`);
setText(`.tree-row[data-shell-mode="page"][data-node-id="${escapedId}"] > .tree-link > .tree-link-title`, title);
setText(`.tree-row[data-shell-mode="filetree"][data-row-id="${escapedDocRowId}"] > .tree-link > .tree-link-title`, title);
setText(`.wolai-page-row[data-node-id="${escapedId}"] > .wolai-row-title`, title);
setText(`a[href="/documents/${escapedId}"] > .wolai-row-title`, title);
setText(`a[href^="/documents/${escapedId}?"] > .wolai-row-title`, title);
@@ -307,8 +317,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return '';
};
const legacyBlockToTiptap = (block) => {
const legacyBlockToTiptap = (block, index = 0) => {
const type = typeof block?.type === 'string' ? block.type : 'paragraph';
const blockId = typeof block?.id === 'string' && block.id.trim()
? block.id.trim()
: typeof block?.blockId === 'string' && block.blockId.trim()
? block.blockId.trim()
: `block-${index + 1}`;
const text = flattenText(block?.content);
const content = text ? [{ type: 'text', text }] : [];
const textAlign = typeof block?.props?.textAlign === 'string'
@@ -318,15 +333,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
: undefined;
const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs;
const nestedChildren = Array.isArray(block?.children)
? block.children.map(legacyBlockToTiptap).filter(Boolean)
? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean)
: [];
const withListChildren = (itemType, listType, attrs = {}) => ({
type: listType,
attrs: { blockId, ...attrs },
content: [{
type: itemType,
attrs,
attrs: { blockId },
content: [
{ type: 'paragraph', content },
{ type: 'paragraph', attrs: { blockId }, content },
...nestedChildren,
],
}],
@@ -334,7 +350,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (type === 'heading') {
const level = Number(block?.props?.level || block?.level || 1) || 1;
const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {};
return { type: 'heading', attrs: withTextAlign({ level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content };
}
if (type === 'bulletListItem' || type === 'bullet_list_item') {
return withListChildren('listItem', 'bulletList');
@@ -346,13 +362,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) });
}
if (type === 'quote' || type === 'blockquote') {
return { type: 'blockquote', attrs: withTextAlign(), content: [{ type: 'paragraph', attrs: withTextAlign(), content }] };
return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] };
}
if (type === 'codeBlock' || type === 'code_block') {
return { type: 'codeBlock', attrs: withTextAlign({ language: block?.props?.language || null }), content };
return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content };
}
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') {
return { type: 'horizontalRule' };
return { type: 'horizontalRule', attrs: { blockId } };
}
if (type === 'table') {
const tableSnapshot = block?.props?.tiptapTable;
@@ -395,9 +411,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
alt: block?.props?.alt || block?.alt || null,
title: block?.props?.title || block?.title || null,
};
return attrs.src ? { type: 'image', attrs } : null;
return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null;
}
return { type: 'paragraph', attrs: withTextAlign(), content };
return { type: 'paragraph', attrs: withTextAlign({ blockId }), content };
};
const textToTiptapDocument = (text) => ({
@@ -436,6 +452,102 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return textToTiptapDocument(fallbackText);
};
const inlineTextNodes = (node) => {
if (!node || typeof node !== 'object') return [];
if (Array.isArray(node.content)) {
return node.content.flatMap((child) => {
if (child?.type === 'text') {
const text = typeof child.text === 'string' ? child.text : '';
return text ? [{ type: 'text', text }] : [];
}
if (child?.type === 'hardBreak') {
return [{ type: 'text', text: '\n' }];
}
return inlineTextNodes(child);
});
}
return [];
};
const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null;
const blockIdOf = (node, index) => {
const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : '';
return raw || `block-${index + 1}`;
};
const tiptapNodeToEditorBlock = (node, index) => {
const blockId = blockIdOf(node, index);
if (node?.type === 'paragraph') {
return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] };
}
if (node?.type === 'heading') {
const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1));
return { blockId, blockType: 'heading', props: { headingLevel: level }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
}
if (node?.type === 'bulletList') {
return { blockId, blockType: 'bullet_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] };
}
if (node?.type === 'orderedList') {
return { blockId, blockType: 'numbered_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] };
}
if (node?.type === 'taskList') {
const taskItem = firstChild(node);
return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] };
}
if (node?.type === 'blockquote') {
return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] };
}
if (node?.type === 'codeBlock') {
return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
}
if (node?.type === 'horizontalRule') {
return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] };
}
if (node?.type === 'image') {
return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] };
}
if (node?.type === 'tocNode') {
return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] };
}
if (node?.type === 'table') {
return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] };
}
return null;
};
const editorDocumentFromTiptapDocument = (tiptapDocument) => {
const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : [];
const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean);
return {
documentId: bootstrap.documentId,
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
};
const legacyBlocksFromEditorDocument = (editorDocument) => (
Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : []
).map((block) => ({
id: block.blockId,
type: block.blockType,
props: block.blockType === 'heading'
? { level: block.props?.headingLevel || 1 }
: block.blockType === 'todo'
? { checked: Boolean(block.props?.checked) }
: block.blockType === 'code_block'
? { language: block.props?.language || null }
: block.blockType === 'image'
? { ...(block.props || {}) }
: block.blockType === 'toc'
? { ...(block.props || {}) }
: block.blockType === 'table'
? { ...(block.props || {}) }
: undefined,
content: Array.isArray(block.contentNodes)
? block.contentNodes.map((node) => typeof node?.text === 'string' ? node.text : '').join('')
: '',
}));
const pageBody = aggregate.body || {};
const permissions = aggregate.head?.permissions || {};
const conflictDetectionKey = typeof pageBody.conflictDetectionKey === 'string'
@@ -502,6 +614,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
setStatus('saved');
return;
}
const editorDocument = editorDocumentFromTiptapDocument(tiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument);
setStatus('saving');
const response = await fetch(bootstrap.saveEndpoint || '/api/documents/save', {
method: 'POST',
@@ -511,9 +625,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
workspaceId: bootstrap.workspaceId,
revision: editorMeta.revision,
conflictDetectionKey: editorMeta.conflictDetectionKey,
content: [],
editorDocument,
content,
tiptapDocument,
blockCount: null,
blockCount: editorDocument.blocks.length,
}),
});
const result = await response.json().catch(() => null);
@@ -526,6 +641,15 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (typeof saved.conflict_detection_key === 'string') editorMeta.conflictDetectionKey = saved.conflict_detection_key;
if (typeof saved.conflictDetectionKey === 'string') editorMeta.conflictDetectionKey = saved.conflictDetectionKey;
lastSavedSerialized = serialized;
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
window.__mnoteRecordPageHistorySnapshot('save', {
wordCount: content.filter((block) => typeof block?.content === 'string' && block.content.trim()).length,
characterCount: currentEditorText().replace(/\s/g, '').length,
blockCount: editorDocument.blocks.length,
todoTotal: editorDocument.blocks.filter((block) => block.blockType === 'todo').length,
todoDone: editorDocument.blocks.filter((block) => block.blockType === 'todo' && block.props?.checked).length,
});
}
setStatus('saved');
};
@@ -566,6 +690,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const mountId = runtime.mount(root, mountOptions);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_island');
if (typeof window.__mnoteApplyPageOptionsToShell === 'function') {
window.__mnoteApplyPageOptionsToShell();
}
setStatus('ready');
};
@@ -668,6 +795,47 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
Ok(response)
}
pub async fn documents_page_compat(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentPageCompatQuery>,
) -> Result<Response, WebError> {
let document_id = query.document_id.trim().to_string();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let aggregate = build_page_aggregate_snapshot(
&state,
&context,
&document_id,
query.workspace_id.as_deref(),
)
.await?;
let projection_owner = aggregate.source_label();
let mut response = (
StatusCode::OK,
Json(json!({
"ok": true,
"owner": "mnote-web",
"schema": "mnote.documents_page_compat.v1",
"page": aggregate,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.into_response();
stamp_shell_headers(response.headers_mut(), "documents-page-compat");
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") {
if let Ok(value) = HeaderValue::from_str(projection_owner) {
response.headers_mut().insert(name, value);
}
}
Ok(response)
}
pub async fn page_aggregate(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1059,9 +1227,13 @@ mod tests {
assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1"));
assert!(html
.contains(".tree-row[data-node-id=\"${escapedId}\"] > .tree-link > .tree-link-title"));
assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(!html.contains("[data-node-id=\"${escapedId}\"] .tree-link-title"));
assert!(html.contains("data-testid=\"wolai-page-settings-trigger\""));
assert!(html.contains("data-mnote-action=\"open-page-settings\""));
assert!(html.contains("data-mnote-action=\"open-page-ai\""));
assert!(html.contains("\"titleEndpoint\":\"/api/documents/title\""));
assert!(html.contains("__MNOTE_TREE_LIVE_BOOTSTRAP__"));
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
+209 -10
View File
@@ -1,21 +1,220 @@
//! MNOTE 登录页面组件
use super::layout::PageLayout;
use leptos::prelude::*;
const TEST_ACCOUNT_EMAIL: &str = "mnote.e2e@example.com";
const TEST_ACCOUNT_PASSWORD: &str = "MnoteE2E123!";
const TEST_ACCOUNT_NAME: &str = "mnote-e2e";
/// 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>
<main class="mnote-auth" data-testid="mnote-auth-page">
<a class="mnote-auth-brand" href="/auth" aria-label="MNOTE">
<span class="mnote-auth-brand-mark" aria-hidden="true">"M"</span>
<span>"MNOTE"</span>
</a>
<section class="mnote-auth-panel" aria-labelledby="mnote-auth-title">
<div class="mnote-auth-heading">
<h1 id="mnote-auth-title">"邮箱登录"</h1>
<p>"登录后进入你的工作区"</p>
</div>
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="convex-password">
<input type="hidden" name="action" value="auth:signIn" />
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
<label class="mnote-auth-field">
<span>"邮箱"</span>
<input
id="email"
name="email"
type="email"
autocomplete="email"
placeholder="请输入邮箱"
required
/>
</label>
<label class="mnote-auth-field mnote-auth-username" hidden>
<span>"用户名"</span>
<input
id="username"
name="username"
type="text"
autocomplete="username"
placeholder="用于显示的用户名"
/>
</label>
<label class="mnote-auth-field">
<span>"密码"</span>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
placeholder="请输入密码"
minlength="8"
required
/>
</label>
<p class="mnote-auth-message" role="status" aria-live="polite" data-auth-message></p>
<button class="mnote-auth-submit" type="submit" data-auth-submit>"登录"</button>
<button
class="mnote-auth-quick-login"
type="button"
data-auth-test-login
data-test-email=TEST_ACCOUNT_EMAIL
data-test-password=TEST_ACCOUNT_PASSWORD
data-test-name=TEST_ACCOUNT_NAME
>
"测试账号快速登录"
</button>
</form>
<button class="mnote-auth-switch" type="button" data-auth-switch data-flow="signIn">
"没有账号?注册"
</button>
</section>
</PageLayout>
<script>{AUTH_SCRIPT}</script>
</main>
}
}
const AUTH_SCRIPT: &str = r#"
(function () {
var root = document.querySelector('[data-testid="mnote-auth-page"]');
if (!root) return;
var form = root.querySelector('form[data-auth-mode="convex-password"]');
var flowInput = root.querySelector('[data-auth-flow]');
var usernameField = root.querySelector('.mnote-auth-username');
var usernameInput = root.querySelector('#username');
var title = root.querySelector('#mnote-auth-title');
var subtitle = root.querySelector('.mnote-auth-heading p');
var submit = root.querySelector('[data-auth-submit]');
var quickLogin = root.querySelector('[data-auth-test-login]');
var switcher = root.querySelector('[data-auth-switch]');
var message = root.querySelector('[data-auth-message]');
if (!form || !flowInput || !submit || !quickLogin || !switcher || !message) return;
function setMessage(text, type) {
message.textContent = text || '';
message.dataset.type = type || '';
}
function setBusy(isBusy) {
submit.disabled = !!isBusy;
quickLogin.disabled = !!isBusy;
switcher.disabled = !!isBusy;
}
function setFlow(nextFlow) {
var isSignUp = nextFlow === 'signUp';
flowInput.value = nextFlow;
switcher.dataset.flow = nextFlow;
if (title) title.textContent = isSignUp ? '' : '';
if (subtitle) subtitle.textContent = isSignUp ? '' : '';
submit.textContent = isSignUp ? '' : '';
switcher.textContent = isSignUp ? '' : '';
if (usernameField) usernameField.hidden = !isSignUp;
if (usernameInput) usernameInput.required = isSignUp;
quickLogin.hidden = isSignUp;
var passwordInput = root.querySelector('#password');
if (passwordInput) passwordInput.autocomplete = isSignUp ? 'new-password' : 'current-password';
setMessage('', '');
}
function buildPayload(flow, email, password, username) {
var payload = {
action: 'auth:signIn',
args: {
provider: 'password',
params: {
email: email,
password: password,
flow: flow
}
}
};
if (flow === 'signUp' && username) {
payload.args.params.name = username;
}
return payload;
}
async function requestAuth(flow, email, password, username) {
var response = await fetch('/api/auth', {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'include',
body: JSON.stringify(buildPayload(flow, email, password, username))
});
var data = await response.json().catch(function () { return {}; });
if (!response.ok || data.error) {
throw new Error(data.error || '');
}
return data;
}
switcher.addEventListener('click', function () {
setFlow(flowInput.value === 'signIn' ? 'signUp' : 'signIn');
});
form.addEventListener('submit', async function (event) {
event.preventDefault();
var email = String((root.querySelector('#email') || {}).value || '').trim();
var password = String((root.querySelector('#password') || {}).value || '');
var username = String((usernameInput || {}).value || '').trim();
var flow = flowInput.value === 'signUp' ? 'signUp' : 'signIn';
if (!email || !password || (flow === 'signUp' && !username)) {
setMessage('', 'error');
return;
}
setBusy(true);
setMessage(flow === 'signUp' ? '...' : '...', 'info');
try {
await requestAuth(flow, email, password, username);
setMessage('...', 'success');
window.location.assign('/');
} catch (error) {
setMessage(error && error.message ? error.message : '', 'error');
setBusy(false);
}
});
quickLogin.addEventListener('click', async function () {
var email = String(quickLogin.dataset.testEmail || '').trim();
var password = String(quickLogin.dataset.testPassword || '');
var username = String(quickLogin.dataset.testName || '').trim();
if (!email || !password || !username) {
setMessage('', 'error');
return;
}
var emailInput = root.querySelector('#email');
var passwordInput = root.querySelector('#password');
if (emailInput) emailInput.value = email;
if (passwordInput) passwordInput.value = password;
setBusy(true);
setMessage('...', 'info');
try {
await requestAuth('signIn', email, password, username);
setMessage('...', 'success');
window.location.assign('/');
return;
} catch (_signInError) {
setMessage('...', 'info');
}
try {
await requestAuth('signUp', email, password, username);
setMessage('...', 'success');
window.location.assign('/');
} catch (error) {
setMessage(error && error.message ? error.message : '', 'error');
setBusy(false);
}
});
setFlow('signIn');
})();
"#;
@@ -5,6 +5,7 @@
use crate::ssr::pages::layout::PageLayout;
use leptos::prelude::*;
use serde_json::Value;
/// MNOTE 文档页面
///
@@ -33,6 +34,9 @@ pub fn DocumentPage(
/// Page Aggregate 子树 JSON(可选)
#[prop(optional)]
page_subtree_json: Option<String>,
/// Page Aggregate 页面选项 JSON(可选)
#[prop(optional)]
page_options_json: Option<String>,
) -> impl IntoView {
let has_page_subtree = page_subtree_json
.as_deref()
@@ -44,9 +48,45 @@ pub fn DocumentPage(
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "个人空间".to_string());
let page_options = page_options_json
.as_deref()
.and_then(|value| serde_json::from_str::<Value>(value).ok())
.unwrap_or(Value::Null);
let page_wide_layout = page_options
.get("wideLayout")
.and_then(Value::as_bool)
.unwrap_or(false);
let page_small_text = page_options
.get("smallText")
.and_then(Value::as_bool)
.unwrap_or(false);
let page_layout_density = page_options
.get("layoutDensity")
.and_then(Value::as_str)
.unwrap_or("normal")
.to_string();
let page_font = page_options
.get("pageFont")
.and_then(Value::as_str)
.unwrap_or("default")
.to_string();
let page_show_heading_numbers = page_options
.get("showHeadingNumbers")
.and_then(Value::as_bool)
.unwrap_or(true);
view! {
<PageLayout current_nav="documents" 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()} topbar_title={title.clone()}>
<main class="document-shell" data-editor-host="leptos_tiptap_island" data-document-id={document_id.clone()} data-workspace-id={workspace_id.clone()}>
<main
class="document-shell"
data-editor-host="leptos_tiptap_island"
data-document-id={document_id.clone()}
data-workspace-id={workspace_id.clone()}
data-page-wide-layout={page_wide_layout.to_string()}
data-page-small-text={page_small_text.to_string()}
data-layout-density={page_layout_density.clone()}
data-page-font={page_font.clone()}
data-page-show-heading-numbers={page_show_heading_numbers.to_string()}
>
<header class="document-shell-header">
<div class="document-page-icon" aria-hidden="true">
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
+859 -12
View File
@@ -17,6 +17,15 @@ const SIDEBAR_TREE_JS: &str = r##"
var activeFileTreeDropRow = null;
var projectionRefreshTimer = 0;
var activeTreeContextMenu = null;
var pageUiState = {
pageOptions: null,
historySnapshots: [],
pageSettingsOpen: false,
pageAiOpen: false,
pageAiBusy: false,
pageAiMessages: [],
pageAiSuggestionIndex: 0
};
function closestAction(target, selector) {
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
@@ -36,11 +45,178 @@ const SIDEBAR_TREE_JS: &str = r##"
return String(value).replace(/["\\]/g, '\\$&');
}
function parseJsonScript(id) {
var node = document.getElementById(id);
if (!node) return null;
try {
return JSON.parse(node.textContent || 'null');
} catch (_) {
return null;
}
}
function currentDocumentId() {
var match = window.location.pathname.match(/^\/documents\/([^\/]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function currentPageAggregate() {
return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {};
}
function defaultPageOptions() {
return {
wideLayout: false,
smallText: false,
layoutDensity: 'normal',
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: 'default',
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null
};
}
function currentPageOptions() {
if (pageUiState.pageOptions) return pageUiState.pageOptions;
var aggregate = currentPageAggregate();
var current = aggregate && aggregate.layout && aggregate.layout.pageOptions && typeof aggregate.layout.pageOptions === 'object'
? aggregate.layout.pageOptions
: null;
if (!current) {
return defaultPageOptions();
}
pageUiState.pageOptions = Object.assign(defaultPageOptions(), current);
return pageUiState.pageOptions;
}
function pageOptionIsSupported(name) {
return name === 'wideLayout'
|| name === 'smallText'
|| name === 'layoutDensity'
|| name === 'pageFont'
|| name === 'showHeadingNumbers';
}
function pageOptionDescription(name) {
if (name === 'wideLayout') return '';
if (name === 'smallText') return '';
if (name === 'showToc') return '';
if (name === 'showHeadingNumbers') return '';
if (name === 'protectEditing') return '';
if (name === 'collapseBacklinks') return '';
if (name === 'hideChildPages') return '';
if (name === 'showBlockRefCount') return '';
return name;
}
function pageOptionHint(name) {
if (name === 'wideLayout') return '';
if (name === 'smallText') return '';
if (name === 'showHeadingNumbers') return '';
if (name === 'layoutDensity') return '';
if (name === 'pageFont') return '';
if (name === 'showToc') return '线 Rust ';
if (name === 'protectEditing') return '线';
if (name === 'collapseBacklinks') return '线 Rust ';
if (name === 'hideChildPages') return '线';
if (name === 'showBlockRefCount') return '线';
return '线';
}
function ensureHistorySnapshotsSeeded() {
if (pageUiState.historySnapshots.length > 0) return;
var aggregate = currentPageAggregate();
var stats = aggregate && aggregate.stats && typeof aggregate.stats === 'object' ? aggregate.stats : {};
pageUiState.historySnapshots = [{
id: 'snapshot-initial',
timestamp: Date.now(),
stats: {
wordCount: Number(stats.wordCount || 0),
characterCount: Number(stats.characterCount || 0),
blockCount: Number(stats.blockCount || 0),
todoTotal: Number(stats.todoTotal || 0),
todoDone: Number(stats.todoDone || 0)
}
}];
}
function computeLivePageStats() {
var aggregate = currentPageAggregate();
var fallbackStats = aggregate && aggregate.stats && typeof aggregate.stats === 'object' ? aggregate.stats : {};
var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) {
return {
wordCount: Number(fallbackStats.wordCount || 0),
characterCount: Number(fallbackStats.characterCount || 0),
blockCount: Number(fallbackStats.blockCount || 0),
todoTotal: Number(fallbackStats.todoTotal || 0),
todoDone: Number(fallbackStats.todoDone || 0)
};
}
var text = (editor.textContent || '').trim();
var compact = text.replace(/\s+/g, ' ').trim();
var wordCount = compact ? compact.split(' ').filter(Boolean).length : 0;
var characterCount = text.replace(/\s/g, '').length;
var blockCount = editor.querySelectorAll(':scope > *').length;
var todos = Array.from(editor.querySelectorAll('input[type="checkbox"]'));
return {
wordCount: wordCount || Number(fallbackStats.wordCount || 0),
characterCount: characterCount || Number(fallbackStats.characterCount || 0),
blockCount: blockCount || Number(fallbackStats.blockCount || 0),
todoTotal: todos.length || Number(fallbackStats.todoTotal || 0),
todoDone: todos.filter(function(node){ return node.checked; }).length || Number(fallbackStats.todoDone || 0)
};
}
function recordPageHistorySnapshot(reason, stats) {
ensureHistorySnapshotsSeeded();
pageUiState.historySnapshots = [{
id: 'snapshot-' + Date.now(),
timestamp: Date.now(),
reason: reason || 'save',
stats: stats || computeLivePageStats()
}].concat(pageUiState.historySnapshots).slice(0, 15);
renderPageHistoryDrawer();
}
function applyPageOptionsToShell() {
var options = currentPageOptions();
var shell = document.querySelector('.document-shell');
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
if (shell instanceof HTMLElement) {
shell.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
shell.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
shell.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
shell.setAttribute('data-page-font', String(options.pageFont || 'default'));
shell.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
shell.style.width = '100%';
shell.style.maxWidth = options.wideLayout ? '980px' : '760px';
}
if (editorRoot instanceof HTMLElement) {
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
editorRoot.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
}
if (editorSurface instanceof HTMLElement) {
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
editorSurface.setAttribute('data-page-font', String(options.pageFont || 'default'));
editorSurface.setAttribute('data-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
}
document.documentElement.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
document.documentElement.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
document.documentElement.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default'));
document.documentElement.setAttribute('data-page-show-heading-numbers', String(Boolean(options.showHeadingNumbers)));
}
function normalizeSidebarTreeMode(value) {
var mode = String(value || '').trim();
return mode === 'filetree' ? 'filetree' : 'page';
@@ -131,8 +307,11 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.querySelectorAll('.tree-row[data-node-id="' + cssEscape(nodeId) + '"], .tree-row[data-document-id="' + cssEscape(nodeId) + '"], .tree-row[data-doc-id="' + cssEscape(nodeId) + '"]').forEach(function(row) {
if (row instanceof HTMLElement) {
if (row.getAttribute('data-shell-mode') === 'filetree') row.setAttribute('data-selected', 'true');
else row.setAttribute('data-active', 'true');
if (row.getAttribute('data-shell-mode') === 'filetree') {
row.setAttribute('data-selected', String(row.getAttribute('data-row-id') === 'index:' + nodeId));
} else {
row.setAttribute('data-active', 'true');
}
}
});
return;
@@ -195,10 +374,10 @@ const SIDEBAR_TREE_JS: &str = r##"
function updateTitleEverywhere(documentId, title) {
if (!documentId) return;
var escaped = cssEscape(documentId);
var escapedDocRowId = cssEscape('doc:' + documentId);
var selectors = [
'.tree-row[data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-doc-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title',
'.tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title',
'.wolai-page-row[data-node-id="' + escaped + '"] > .wolai-row-title',
'a[href="/documents/' + escaped + '"] > .wolai-row-title',
'a[href^="/documents/' + escaped + '?"] > .wolai-row-title'
@@ -322,7 +501,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false;
var selected = rowId === 'doc:' + activeId || rowId === 'index:' + activeId || documentId === activeId;
var selected = rowId === 'index:' + activeId;
var testId = rowKind === 'document' ? 'filetree-doc-row' : rowKind === 'index' ? 'filetree-index-row' : 'filetree-asset-row';
var toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="filetree-toggle" data-rust-action="toggle" data-row-id="' + escapeHtml(rowId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '">' + (expanded ? '▾' : '') + '</button>'
@@ -846,10 +1025,624 @@ const SIDEBAR_TREE_JS: &str = r##"
else openSearchModal();
}
function updatePageSettingsTriggerState() {
var trigger = document.querySelector('[data-testid="wolai-page-settings-trigger"]');
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('data-state', pageUiState.pageSettingsOpen ? 'open' : 'closed');
trigger.setAttribute('aria-expanded', pageUiState.pageSettingsOpen ? 'true' : 'false');
}
function updatePageAiTriggerState() {
var trigger = document.querySelector('[data-testid="wolai-floating-ai"]');
if (!(trigger instanceof HTMLElement)) return;
trigger.setAttribute('data-state', pageUiState.pageAiOpen ? 'open' : 'closed');
trigger.setAttribute('aria-expanded', pageUiState.pageAiOpen ? 'true' : 'false');
}
function createPageOptionRow(key, type) {
var inputType = type || 'checkbox';
var supported = pageOptionIsSupported(key);
if (inputType === 'checkbox') {
return '' +
'<label class="wolai-page-setting-row' + (supported ? '' : ' is-pending') + '" data-page-setting-row="' + key + '">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">' + escapeHtml(pageOptionDescription(key)) + '</span>' +
'<span class="wolai-page-setting-hint">' + escapeHtml(pageOptionHint(key)) + '</span>' +
'</span>' +
'<input type="checkbox" class="wolai-page-setting-checkbox" data-page-option-checkbox="' + key + '"' + (supported ? '' : ' data-setting-pending="true"') + ' />' +
'</label>';
}
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="' + key + '">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">' + escapeHtml(pageOptionDescription(key)) + '</span>' +
'<span class="wolai-page-setting-hint">' + escapeHtml(pageOptionHint(key)) + '</span>' +
'</span>' +
'<select class="wolai-page-setting-select" data-page-option-select="' + key + '">' +
'<option value="compact"></option>' +
'<option value="normal"></option>' +
'<option value="spacious"></option>' +
'</select>' +
'</label>';
}
function createPageFontRow() {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="pageFont">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label"></span>' +
'<span class="wolai-page-setting-hint"></span>' +
'</span>' +
'<select class="wolai-page-setting-select" data-page-option-select="pageFont">' +
'<option value="default"></option>' +
'<option value="song"></option>' +
'<option value="kai"></option>' +
'</select>' +
'</label>';
}
function ensurePageHistoryDrawer() {
var existing = document.querySelector('[data-testid="wolai-page-history-drawer"]');
if (existing instanceof HTMLElement) return existing;
var drawer = document.createElement('aside');
drawer.className = 'wolai-page-history-drawer';
drawer.setAttribute('data-testid', 'wolai-page-history-drawer');
drawer.setAttribute('data-mnote-surface', 'page-history');
drawer.hidden = true;
drawer.innerHTML = '' +
'<div class="wolai-page-history-panel">' +
'<div class="wolai-page-history-header">' +
'<div><div class="wolai-page-history-title"></div><div class="wolai-page-history-subtitle"> 15 </div></div>' +
'<button type="button" class="wolai-surface-close" data-page-history-action="close" aria-label="关闭页面历史">×</button>' +
'</div>' +
'<div class="wolai-page-history-list" data-page-history-list></div>' +
'</div>';
drawer.addEventListener('click', function(event) {
if (event.target === drawer) closePageHistoryDrawer();
});
document.body.appendChild(drawer);
return drawer;
}
function renderPageHistoryDrawer() {
var drawer = ensurePageHistoryDrawer();
var list = drawer.querySelector('[data-page-history-list]');
if (!(list instanceof HTMLElement)) return;
ensureHistorySnapshotsSeeded();
list.innerHTML = pageUiState.historySnapshots.length
? pageUiState.historySnapshots.map(function(snapshot) {
var stats = snapshot.stats || {};
var label = new Date(snapshot.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
return '' +
'<div class="wolai-page-history-item">' +
'<div class="wolai-page-history-item-copy">' +
'<div class="wolai-page-history-item-title">' + escapeHtml(label) + '</div>' +
'<div class="wolai-page-history-item-meta"> ' + Number(stats.wordCount || 0) + ' · ' + Number(stats.characterCount || 0) + ' · ' + Number(stats.blockCount || 0) + '</div>' +
'</div>' +
'<button type="button" class="wolai-page-history-item-ghost" data-page-history-action="noop"></button>' +
'</div>';
}).join('')
: '<div class="wolai-page-history-empty"></div>';
}
function openPageHistoryDrawer() {
renderPageHistoryDrawer();
var drawer = ensurePageHistoryDrawer();
drawer.hidden = false;
document.documentElement.setAttribute('data-mnote-page-history-open', 'true');
}
function closePageHistoryDrawer() {
var drawer = document.querySelector('[data-testid="wolai-page-history-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = true;
document.documentElement.removeAttribute('data-mnote-page-history-open');
}
function ensurePageShareDialog() {
var existing = document.querySelector('[data-testid="wolai-page-share-dialog"]');
if (existing instanceof HTMLElement) return existing;
var dialog = document.createElement('div');
dialog.className = 'wolai-page-share-dialog';
dialog.setAttribute('data-testid', 'wolai-page-share-dialog');
dialog.setAttribute('data-mnote-surface', 'page-share');
dialog.hidden = true;
dialog.innerHTML = '' +
'<div class="wolai-page-share-card" role="dialog" aria-modal="true">' +
'<div class="wolai-page-share-header">' +
'<div><div class="wolai-page-share-title"></div><div class="wolai-page-share-subtitle"> 3000 mnote-web </div></div>' +
'<button type="button" class="wolai-surface-close" data-page-share-action="close" aria-label="关闭公开分享页面">×</button>' +
'</div>' +
'<div class="wolai-page-share-state">' +
'<span class="wolai-public-pill wolai-public-pill--inline"></span>' +
'<span class="wolai-page-share-copy">访</span>' +
'</div>' +
'<div class="wolai-page-share-url-row">' +
'<input class="wolai-page-share-url" type="text" readonly data-page-share-url value="" />' +
'<button type="button" class="wolai-page-share-copy-button" data-page-share-action="copy-link"></button>' +
'</div>' +
'<div class="wolai-page-share-footer">线</div>' +
'</div>';
dialog.addEventListener('click', function(event) {
if (event.target === dialog) closePageShareDialog();
});
document.body.appendChild(dialog);
return dialog;
}
function openPageShareDialog() {
var dialog = ensurePageShareDialog();
var input = dialog.querySelector('[data-page-share-url]');
if (input instanceof HTMLInputElement) input.value = window.location.href;
dialog.hidden = false;
document.documentElement.setAttribute('data-mnote-page-share-open', 'true');
}
function closePageShareDialog() {
var dialog = document.querySelector('[data-testid="wolai-page-share-dialog"]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
document.documentElement.removeAttribute('data-mnote-page-share-open');
}
function pageAiSuggestions() {
var title = searchText(document.querySelector('[data-page-title-current="true"]')?.textContent) || '';
return [
'' + title + '',
'',
'',
''
];
}
function ensurePageAiDrawer() {
var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
if (existing instanceof HTMLElement) return existing;
var drawer = document.createElement('aside');
drawer.className = 'wolai-page-ai-drawer';
drawer.setAttribute('data-testid', 'wolai-page-ai-drawer');
drawer.setAttribute('data-mnote-surface', 'page-ai');
drawer.hidden = true;
drawer.innerHTML = '' +
'<div class="wolai-page-ai-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-header">' +
'<div class="wolai-page-ai-header-copy">' +
'<h2 class="wolai-page-ai-title" data-title="page-ai-title"></h2>' +
'<div class="wolai-page-ai-subtitle"> · mnote-cli</div>' +
'</div>' +
'<button type="button" class="wolai-surface-close" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' +
'<div class="wolai-page-ai-body">' +
'<div class="wolai-page-ai-suggestions">' +
'<div class="wolai-page-ai-suggestions-header">' +
'<span></span>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="rotate"></button>' +
'</div>' +
'<div class="wolai-page-ai-suggestion-list" data-page-ai-suggestion-list></div>' +
'</div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</div>' +
'<div class="wolai-page-ai-footer">' +
'<div class="wolai-page-ai-toolbar">' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-action="new-session" aria-label="当前已是新会话"></button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-action="history"></button>' +
'<button type="button" class="wolai-page-ai-model-chip" data-page-ai-action="model">mnote-cli</button>' +
'</div>' +
'<div class="wolai-page-ai-input-row">' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="问我你想知道的"></textarea>' +
'<button type="button" class="wolai-page-ai-send" data-page-ai-action="send" aria-label="发送"></button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(drawer);
return drawer;
}
function renderPageAiSuggestions() {
var drawer = ensurePageAiDrawer();
var list = drawer.querySelector('[data-page-ai-suggestion-list]');
if (!(list instanceof HTMLElement)) return;
var suggestions = pageAiSuggestions();
var offset = pageUiState.pageAiSuggestionIndex % suggestions.length;
var ordered = suggestions.slice(offset).concat(suggestions.slice(0, offset)).slice(0, 3);
list.innerHTML = ordered.map(function(text) {
return '<button type="button" class="wolai-page-ai-suggestion" data-page-ai-suggestion="' + escapeHtml(text) + '">' + escapeHtml(text) + '</button>';
}).join('');
}
function renderPageAiConversation() {
var drawer = ensurePageAiDrawer();
var conversation = drawer.querySelector('[data-page-ai-conversation]');
if (!(conversation instanceof HTMLElement)) return;
if (!pageUiState.pageAiMessages.length) {
conversation.innerHTML = '<div class="wolai-page-ai-empty">使</div>';
return;
}
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '">' +
'<div class="wolai-page-ai-message-role">' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '</div>' +
'<div class="wolai-page-ai-message-text">' + escapeHtml(item.content || '') + '</div>' +
'</div>';
}).join('');
conversation.scrollTop = conversation.scrollHeight;
}
function openPageAiDrawer() {
renderPageAiSuggestions();
renderPageAiConversation();
var drawer = ensurePageAiDrawer();
drawer.hidden = false;
pageUiState.pageAiOpen = true;
updatePageAiTriggerState();
}
function closePageAiDrawer() {
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
if (drawer instanceof HTMLElement) drawer.hidden = true;
pageUiState.pageAiOpen = false;
updatePageAiTriggerState();
}
function isPageAiDrawerOpen() {
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
return drawer instanceof HTMLElement && !drawer.hidden;
}
async function streamPageAiResponse(response, onEvent) {
if (!response.body || typeof response.body.getReader !== 'function') return;
var reader = response.body.getReader();
var decoder = new TextDecoder();
var buffer = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
var frames = buffer.split('\n\n');
buffer = frames.pop() || '';
frames.forEach(function(frame) {
var eventName = '';
var dataLines = [];
frame.split('\n').forEach(function(line) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
if (eventName) onEvent(eventName, dataLines.join('\n'));
});
}
}
async function sendPageAiMessage(text) {
if (pageUiState.pageAiBusy) return;
var prompt = searchText(text);
if (!prompt) return;
var aggregate = currentPageAggregate();
var body = aggregate.body || {};
var subtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null;
var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiBusy = true;
pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
renderPageAiConversation();
try {
var response = await fetch('/api/ai-agent/run', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
stream: true,
maxSteps: 8,
scope: 'document',
messages: [{ role: 'user', content: prompt }],
toolChoice: {
mode: 'auto',
toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
},
context: {
documentId: currentDocumentId(),
documentBlocks: body.content || null,
node: {
documentId: currentDocumentId(),
title: aggregate.head && aggregate.head.title ? aggregate.head.title : ''
},
subtree: subtree,
outline: outline,
evidence: null,
pageOptions: currentPageOptions()
},
options: {
searxng: true,
ai: { provider: 'hermes' }
}
})
});
if (!response.ok) {
throw new Error('page_ai_failed_' + response.status);
}
var assistantText = '';
await streamPageAiResponse(response, function(eventName, payloadText) {
if (eventName === 'assistant_message') {
try {
var payload = JSON.parse(payloadText || 'null');
assistantText = searchText(payload && payload.text);
} catch (_) {}
}
});
pageUiState.pageAiMessages.push({
role: 'assistant',
content: assistantText || ' AI `mnote-cli`'
});
} catch (error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
content: ' AI `mnote-cli` ' + (error instanceof Error ? error.message : String(error))
});
} finally {
pageUiState.pageAiBusy = false;
renderPageAiConversation();
}
}
function ensurePageSettingsPopover() {
var existing = document.querySelector('[data-testid="wolai-page-settings-popover"]');
if (existing instanceof HTMLElement) return existing;
var popover = document.createElement('div');
popover.className = 'wolai-page-settings-popover';
popover.setAttribute('data-testid', 'wolai-page-settings-popover');
popover.setAttribute('data-mnote-surface', 'page-settings');
popover.hidden = true;
popover.innerHTML = '' +
'<div class="wolai-page-settings-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-settings-tabs" data-testid="wolai-page-settings-tabs" role="tablist" aria-label="页面设置分组">' +
'<button type="button" class="wolai-page-settings-tab is-active" role="tab" aria-selected="true" data-page-settings-tab="page"></button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="custom"></button>' +
'<button type="button" class="wolai-page-settings-tab" role="tab" aria-selected="false" data-page-settings-tab="global"></button>' +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="page">' +
createPageOptionRow('wideLayout', 'checkbox') +
createPageOptionRow('smallText', 'checkbox') +
createPageOptionRow('showToc', 'checkbox') +
createPageOptionRow('showHeadingNumbers', 'checkbox') +
createPageOptionRow('protectEditing', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="custom" hidden>' +
createPageFontRow() +
createPageOptionRow('layoutDensity', 'select') +
createPageOptionRow('collapseBacklinks', 'checkbox') +
createPageOptionRow('hideChildPages', 'checkbox') +
createPageOptionRow('showBlockRefCount', 'checkbox') +
'</div>' +
'<div class="wolai-page-settings-section" data-page-settings-panel="global" hidden>' +
'<div class="wolai-page-settings-global-note"> Rust Web </div>' +
'</div>' +
'<div class="wolai-page-settings-actions">' +
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="history">...</button>' +
'<button type="button" class="wolai-page-settings-action" data-page-settings-action="share">...</button>' +
'<button type="button" class="wolai-page-settings-action is-disabled" data-page-settings-action="move" disabled>...</button>' +
'<button type="button" class="wolai-page-settings-action is-disabled" data-page-settings-action="embed" disabled>...</button>' +
'<button type="button" class="wolai-page-settings-action is-danger is-disabled" data-page-settings-action="delete" disabled></button>' +
'</div>' +
'<div class="wolai-page-settings-stats" data-testid="wolai-page-settings-stats"></div>' +
'</div>';
document.body.appendChild(popover);
return popover;
}
function renderPageSettingsPopover() {
var popover = ensurePageSettingsPopover();
var options = currentPageOptions();
popover.querySelectorAll('[data-page-option-checkbox]').forEach(function(input) {
var key = input.getAttribute('data-page-option-checkbox');
input.checked = Boolean(options[key]);
input.disabled = !pageOptionIsSupported(key);
});
popover.querySelectorAll('[data-page-option-select]').forEach(function(select) {
var key = select.getAttribute('data-page-option-select');
var value = key === 'layoutDensity' ? String(options.layoutDensity || 'normal') : String(options.pageFont || 'default');
select.value = value;
});
var statsNode = popover.querySelector('[data-testid="wolai-page-settings-stats"]');
if (statsNode instanceof HTMLElement) {
var stats = computeLivePageStats();
statsNode.innerHTML = '' +
'<span> ' + Number(stats.wordCount || 0) + '</span>' +
'<span> ' + Number(stats.characterCount || 0) + '</span>' +
'<span> ' + Number(stats.blockCount || 0) + '</span>' +
'<span> ' + Number(stats.todoDone || 0) + '/' + Number(stats.todoTotal || 0) + '</span>';
}
}
function setActivePageSettingsTab(tabName) {
var popover = ensurePageSettingsPopover();
popover.querySelectorAll('[data-page-settings-tab]').forEach(function(tab) {
var active = tab.getAttribute('data-page-settings-tab') === tabName;
tab.classList.toggle('is-active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
popover.querySelectorAll('[data-page-settings-panel]').forEach(function(panel) {
panel.hidden = panel.getAttribute('data-page-settings-panel') !== tabName;
});
}
async function persistPageOptionsPatch(patch) {
var previous = Object.assign({}, currentPageOptions());
pageUiState.pageOptions = Object.assign({}, previous, patch);
var nextOptions = Object.assign({}, pageUiState.pageOptions);
applyPageOptionsToShell();
renderPageSettingsPopover();
try {
var response = await fetch('/api/documents/options', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
options: nextOptions,
commandName: 'page.layout.updateOptions'
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'page_settings_save_failed_' + response.status);
}
document.documentElement.setAttribute('data-mnote-page-options-saved', 'true');
} catch (error) {
pageUiState.pageOptions = previous;
applyPageOptionsToShell();
renderPageSettingsPopover();
document.documentElement.setAttribute('data-mnote-page-options-error', error instanceof Error ? error.message : String(error));
}
}
function isPageSettingsOpen() {
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function openPageSettingsPopover() {
if (!currentDocumentId()) return;
var popover = ensurePageSettingsPopover();
renderPageSettingsPopover();
setActivePageSettingsTab('page');
popover.hidden = false;
pageUiState.pageSettingsOpen = true;
updatePageSettingsTriggerState();
}
function closePageSettingsPopover() {
var popover = document.querySelector('[data-testid="wolai-page-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
pageUiState.pageSettingsOpen = false;
updatePageSettingsTriggerState();
}
function togglePageSettingsPopover() {
if (isPageSettingsOpen()) closePageSettingsPopover();
else openPageSettingsPopover();
}
document.addEventListener('click', function(e) {
if (activeTreeContextMenu && activeTreeContextMenu.contains(e.target)) return;
if (activeTreeContextMenu) closeTreeContextMenu();
var historyClose = closestAction(e.target, '[data-page-history-action="close"]');
if (historyClose) {
e.preventDefault();
closePageHistoryDrawer();
return;
}
var shareClose = closestAction(e.target, '[data-page-share-action="close"]');
if (shareClose) {
e.preventDefault();
closePageShareDialog();
return;
}
var shareCopy = closestAction(e.target, '[data-page-share-action="copy-link"]');
if (shareCopy) {
e.preventDefault();
void copyTreeContextValue(window.location.href, 'page-share-link');
return;
}
var pageHistoryTrigger = closestAction(e.target, '[data-mnote-action="open-page-history"]');
if (pageHistoryTrigger) {
e.preventDefault();
openPageHistoryDrawer();
return;
}
var pageSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-page-settings"]');
if (pageSettingsTrigger) {
e.preventDefault();
togglePageSettingsPopover();
return;
}
var pageSettingsPanel = closestAction(e.target, '.wolai-page-settings-panel');
if (isPageSettingsOpen() && !pageSettingsPanel) {
closePageSettingsPopover();
}
var pageSettingsTab = closestAction(e.target, '[data-page-settings-tab]');
if (pageSettingsTab) {
e.preventDefault();
setActivePageSettingsTab(pageSettingsTab.getAttribute('data-page-settings-tab') || 'page');
return;
}
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
if (pageSettingsAction) {
e.preventDefault();
var actionName = pageSettingsAction.getAttribute('data-page-settings-action') || '';
if (actionName === 'history') {
openPageHistoryDrawer();
return;
}
if (actionName === 'share') {
openPageShareDialog();
return;
}
return;
}
var pageAiTrigger = closestAction(e.target, '[data-mnote-action="open-page-ai"]');
if (pageAiTrigger) {
e.preventDefault();
openPageAiDrawer();
return;
}
var pageAiClose = closestAction(e.target, '[data-page-ai-action="close"]');
if (pageAiClose) {
e.preventDefault();
closePageAiDrawer();
return;
}
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
if (pageAiRotate) {
e.preventDefault();
pageUiState.pageAiSuggestionIndex += 1;
renderPageAiSuggestions();
return;
}
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
if (pageAiSuggestion) {
e.preventDefault();
var text = pageAiSuggestion.getAttribute('data-page-ai-suggestion') || '';
var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (inputNode instanceof HTMLTextAreaElement) {
inputNode.value = text;
inputNode.focus();
}
return;
}
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
if (pageAiNewSession) {
e.preventDefault();
pageUiState.pageAiMessages = [];
renderPageAiConversation();
return;
}
var pageAiSend = closestAction(e.target, '[data-page-ai-action="send"]');
if (pageAiSend) {
e.preventDefault();
var input = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (input instanceof HTMLTextAreaElement) {
var message = input.value;
input.value = '';
void sendPageAiMessage(message);
}
return;
}
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
if (searchTrigger) {
e.preventDefault();
@@ -973,6 +1766,11 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && isPageSettingsOpen()) {
event.preventDefault();
closePageSettingsPopover();
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key && event.key.toLowerCase() === 'p') {
event.preventDefault();
toggleSearchModal();
@@ -982,8 +1780,51 @@ const SIDEBAR_TREE_JS: &str = r##"
closeSearchModal();
closeTreeContextMenu();
}
if (event.key === 'Enter' && !event.shiftKey) {
var aiInput = closestAction(event.target, '[data-page-ai-input]');
if (aiInput instanceof HTMLTextAreaElement) {
event.preventDefault();
var text = aiInput.value;
aiInput.value = '';
void sendPageAiMessage(text);
}
}
});
document.addEventListener('change', function(event) {
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
if (checkbox instanceof HTMLInputElement) {
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
if (!pageOptionIsSupported(key)) return;
var patch = {};
patch[key] = checkbox.checked;
void persistPageOptionsPatch(patch);
return;
}
var select = closestAction(event.target, '[data-page-option-select]');
if (select instanceof HTMLSelectElement) {
var selectKey = select.getAttribute('data-page-option-select') || '';
var next = {};
if (selectKey === 'layoutDensity') next.layoutDensity = select.value;
if (selectKey === 'pageFont') next.pageFont = select.value;
if (Object.keys(next).length) {
void persistPageOptionsPatch(next);
}
}
});
function initializePageUiSurfaces() {
pageUiState.pageOptions = null;
applyPageOptionsToShell();
updatePageSettingsTriggerState();
updatePageAiTriggerState();
ensureHistorySnapshotsSeeded();
}
window.__mnoteRecordPageHistorySnapshot = recordPageHistorySnapshot;
window.__mnoteApplyPageOptionsToShell = applyPageOptionsToShell;
setTimeout(initializePageUiSurfaces, 0);
function readPageDragNodeId(event) {
var fromTransfer = event.dataTransfer ? event.dataTransfer.getData(PAGE_DRAG_MIME) || event.dataTransfer.getData('text/plain') : '';
return (fromTransfer || draggingPageNodeId || '').trim();
@@ -1403,8 +2244,8 @@ pub fn PageLayout(
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="历史" aria-label="历史"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="更多" aria-label="更多"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="页面历史" aria-label="历史" data-mnote-action="open-page-history"><span class="material-symbols-outlined" data-icon="history" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="页面选项和全局选项" aria-label="更多" data-testid="wolai-page-settings-trigger" data-mnote-action="open-page-settings"><span class="material-symbols-outlined" data-icon="more_horiz" aria-hidden="true"></span></button>
</div>
</header>
<article class="mnote-content">
@@ -1412,7 +2253,7 @@ pub fn PageLayout(
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button wolai-floating-button--help" aria-label="帮助"><span class="material-symbols-outlined" data-icon="help" aria-hidden="true"></span></button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
</div>
</div>
</div>
@@ -1446,13 +2287,19 @@ mod tests {
fn sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates() {
assert!(SIDEBAR_TREE_JS.contains("openTreeContextMenu"));
assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu"));
assert!(SIDEBAR_TREE_JS.contains("复制访问链接(带标题)"));
assert!(SIDEBAR_TREE_JS.contains("复制访问链接"));
assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶"));
assert!(SIDEBAR_TREE_JS.contains(
".tree-row[data-node-id=\"' + escaped + '\"] > .tree-link > .tree-link-title"
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(SIDEBAR_TREE_JS.contains(
r#".tree-row[data-shell-mode="filetree"][data-row-id="' + escapedDocRowId + '"] > .tree-link > .tree-link-title"#
));
assert!(!SIDEBAR_TREE_JS.contains(
r#".tree-row[data-document-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
assert!(!SIDEBAR_TREE_JS
.contains("[data-node-id=\"' + cssEscape(documentId) + '\"] .tree-link-title"));
.contains(r#"[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title"#));
}
#[test]
+700 -18
View File
@@ -877,32 +877,190 @@ a:hover {
/* ===== 认证页面 ===== */
.mnote-auth {
display: flex;
height: 100vh;
position: relative;
display: grid;
min-height: 100vh;
place-items: center;
padding: 48px 20px;
background: #FFFFFF;
}
.mnote-auth-brand {
position: fixed;
top: 28px;
left: 34px;
display: inline-flex;
align-items: center;
gap: 10px;
color: #37352F;
font-size: 18px;
font-weight: 650;
letter-spacing: 0;
}
.mnote-auth-brand:hover {
color: #37352F;
}
.mnote-auth-brand-mark {
display: inline-flex;
width: 32px;
height: 32px;
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;
border-radius: 6px;
background: #37352F;
color: #FFF;
font-size: 18px;
font-weight: 700;
color: var(--wolai-text-primary);
margin-bottom: 8px;
line-height: 1;
}
.mnote-auth-card p {
color: var(--wolai-text-secondary);
font-size: 15px;
.mnote-auth-panel {
width: min(100%, 360px);
color: #37352F;
}
.mnote-auth-heading {
margin-bottom: 30px;
text-align: center;
}
.mnote-auth-heading h1 {
margin: 0 0 8px;
color: #37352F;
font-size: 28px;
font-weight: 650;
line-height: 1.25;
letter-spacing: 0;
}
.mnote-auth-heading p {
color: #9B9A97;
font-size: 14px;
line-height: 1.5;
}
.mnote-auth-form {
display: grid;
gap: 12px;
}
.mnote-auth-field {
display: grid;
gap: 7px;
color: #6D6D69;
font-size: 13px;
line-height: 1.3;
}
.mnote-auth-field input {
width: 100%;
height: 42px;
border: 1px solid #DCDAD6;
border-radius: 4px;
background: #FFF;
color: #37352F;
font: 400 15px/1.4 var(--wolai-font-sans);
letter-spacing: 0;
outline: none;
padding: 0 12px;
}
.mnote-auth-field input::placeholder {
color: #B5B1AA;
}
.mnote-auth-field input:focus {
border-color: #A8A39A;
box-shadow: 0 0 0 2px rgba(55, 53, 47, 0.08);
}
.mnote-auth-message {
min-height: 20px;
color: #9B9A97;
font-size: 13px;
line-height: 20px;
text-align: center;
}
.mnote-auth-message[data-type="error"] {
color: #D14343;
}
.mnote-auth-message[data-type="success"] {
color: #23834D;
}
.mnote-auth-submit {
width: 100%;
height: 42px;
border: 0;
border-radius: 4px;
background: #37352F;
color: #FFF;
cursor: pointer;
font: 600 15px/1 var(--wolai-font-sans);
letter-spacing: 0;
}
.mnote-auth-submit:hover {
background: #2F2D29;
}
.mnote-auth-submit:disabled {
cursor: default;
opacity: .58;
}
.mnote-auth-quick-login {
width: 100%;
height: 42px;
border: 1px solid #DCDAD6;
border-radius: 4px;
background: #FFF;
color: #37352F;
cursor: pointer;
font: 500 14px/1 var(--wolai-font-sans);
letter-spacing: 0;
}
.mnote-auth-quick-login:hover {
background: #F7F7F5;
}
.mnote-auth-quick-login:disabled {
cursor: default;
opacity: .58;
}
.mnote-auth-switch {
display: block;
margin: 18px auto 0;
border: 0;
background: transparent;
color: #6D6D69;
cursor: pointer;
font: 400 14px/1.4 var(--wolai-font-sans);
letter-spacing: 0;
}
.mnote-auth-switch:hover {
color: #37352F;
}
@media (max-width: 640px) {
.mnote-auth {
align-items: start;
padding-top: 132px;
}
.mnote-auth-brand {
top: 24px;
left: 22px;
}
}
/* ===== ProseMirror 兼容 ===== */
.ProseMirror {
white-space: pre-wrap;
@@ -1875,6 +2033,515 @@ body {
font-size: 18px;
}
.document-shell[data-page-wide-layout="true"] {
width: min(100%, 980px);
}
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror,
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror li {
font-size: 15px;
line-height: 22px;
}
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol,
.document-shell[data-layout-density="compact"] #mnote-leptos-tiptap-island-editor-root .ProseMirror blockquote {
margin-bottom: 4px;
}
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ul,
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror ol,
.document-shell[data-layout-density="spacious"] #mnote-leptos-tiptap-island-editor-root .ProseMirror blockquote {
margin-bottom: 14px;
}
.document-shell[data-page-font="song"] .document-title-input,
.document-shell[data-page-font="song"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
font-family: "Noto Serif SC", "Songti SC", serif;
}
.document-shell[data-page-font="kai"] .document-title-input,
.document-shell[data-page-font="kai"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
font-family: "STKaiti", "KaiTi", serif;
}
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror {
counter-reset: mnote-heading;
}
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h1::before,
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h2::before,
.document-shell[data-page-show-heading-numbers="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror h3::before {
counter-increment: mnote-heading;
content: counter(mnote-heading) ". ";
color: #8B8782;
font-weight: 500;
}
.wolai-page-settings-popover {
position: fixed;
top: 52px;
right: 18px;
z-index: 90;
}
.wolai-page-settings-panel {
width: min(360px, calc(100vw - 24px));
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 48px rgba(27, 28, 28, 0.12);
}
.wolai-page-settings-tabs {
display: flex;
gap: 6px;
}
.wolai-page-settings-tab {
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: #6D6A65;
font-size: 13px;
cursor: pointer;
}
.wolai-page-settings-tab.is-active {
background: #F4F3F3;
color: #1B1C1C;
font-weight: 600;
}
.wolai-page-settings-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.wolai-page-settings-section[hidden] {
display: none !important;
}
.wolai-page-setting-row {
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.wolai-page-setting-row.is-pending {
opacity: 0.72;
}
.wolai-page-setting-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.wolai-page-setting-label {
color: #1B1C1C;
font-size: 14px;
line-height: 20px;
}
.wolai-page-setting-hint,
.wolai-page-settings-global-note {
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.wolai-page-setting-checkbox {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.wolai-page-setting-select {
min-width: 96px;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 8px;
background: #FFF;
color: #1B1C1C;
font-size: 13px;
}
.wolai-page-settings-actions {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
border-top: 1px solid rgba(27, 28, 28, 0.06);
}
.wolai-page-settings-action {
min-height: 34px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: #1B1C1C;
font-size: 13px;
cursor: pointer;
}
.wolai-page-settings-action:hover {
background: #F4F3F3;
}
.wolai-page-settings-action.is-disabled {
color: #A19D97;
cursor: not-allowed;
}
.wolai-page-settings-action.is-danger {
color: #C44B55;
}
.wolai-page-settings-stats {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
padding-top: 4px;
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.wolai-page-history-drawer {
position: fixed;
inset: 0;
z-index: 88;
background: rgba(27, 28, 28, 0.04);
}
.wolai-page-history-drawer[hidden] {
display: none !important;
}
.wolai-page-history-panel {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
width: min(380px, calc(100vw - 24px));
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border-left: 1px solid rgba(27, 28, 28, 0.08);
background: #FFF;
box-shadow: -18px 0 42px rgba(27, 28, 28, 0.12);
}
.wolai-page-history-header,
.wolai-page-share-header,
.wolai-page-ai-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.wolai-page-history-title,
.wolai-page-share-title,
.wolai-page-ai-title {
color: #1B1C1C;
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.wolai-page-history-subtitle,
.wolai-page-share-subtitle,
.wolai-page-ai-subtitle {
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.wolai-surface-close {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #5A5A5A;
cursor: pointer;
}
.wolai-surface-close:hover {
background: #F4F3F3;
}
.wolai-page-history-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
overflow: auto;
}
.wolai-page-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 10px;
background: #FFF;
}
.wolai-page-history-item-title {
color: #1B1C1C;
font-size: 13px;
font-weight: 500;
line-height: 20px;
}
.wolai-page-history-item-meta,
.wolai-page-history-empty,
.wolai-page-share-copy,
.wolai-page-share-footer,
.wolai-page-ai-empty,
.wolai-page-ai-message-role {
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.wolai-page-history-item-ghost,
.wolai-page-ai-ghost,
.wolai-page-ai-tab,
.wolai-page-ai-model-chip,
.wolai-page-share-copy-button {
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: #F4F3F3;
color: #1B1C1C;
font-size: 12px;
cursor: pointer;
}
.wolai-page-share-dialog {
position: fixed;
inset: 0;
z-index: 92;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(27, 28, 28, 0.12);
}
.wolai-page-share-dialog[hidden] {
display: none !important;
}
.wolai-page-share-card {
width: min(460px, calc(100vw - 24px));
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
border-radius: 16px;
background: #FFF;
box-shadow: 0 18px 48px rgba(27, 28, 28, 0.18);
}
.wolai-page-share-state {
display: flex;
flex-direction: column;
gap: 8px;
}
.wolai-public-pill--inline {
width: fit-content;
}
.wolai-page-share-url-row {
display: flex;
gap: 8px;
}
.wolai-page-share-url {
flex: 1 1 auto;
height: 34px;
padding: 0 10px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 8px;
background: #FFF;
color: #1B1C1C;
font-size: 13px;
}
.wolai-page-ai-drawer {
position: fixed;
top: 12px;
right: 12px;
bottom: 12px;
z-index: 89;
}
.wolai-page-ai-drawer[hidden] {
display: none !important;
}
.wolai-page-ai-panel {
width: min(440px, calc(100vw - 24px));
height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.98);
box-shadow: -18px 0 42px rgba(27, 28, 28, 0.14);
}
.wolai-page-ai-header-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.wolai-page-ai-body {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
}
.wolai-page-ai-suggestions {
display: flex;
flex-direction: column;
gap: 10px;
}
.wolai-page-ai-suggestions-header,
.wolai-page-ai-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wolai-page-ai-suggestion-list,
.wolai-page-ai-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wolai-page-ai-suggestion {
min-height: 32px;
padding: 8px 10px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 10px;
background: #FFF;
color: #1B1C1C;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.wolai-page-ai-conversation {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
overflow: auto;
}
.wolai-page-ai-message {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
background: #F7F7F7;
}
.wolai-page-ai-message--user {
background: #F4F8FF;
}
.wolai-page-ai-message-text {
color: #1B1C1C;
font-size: 13px;
line-height: 20px;
white-space: pre-wrap;
}
.wolai-page-ai-footer {
display: flex;
flex-direction: column;
gap: 10px;
}
.wolai-page-ai-tab.is-active {
background: #1B1C1C;
color: #FFF;
}
.wolai-page-ai-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.wolai-page-ai-input {
flex: 1 1 auto;
min-height: 88px;
padding: 10px 12px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 12px;
background: #FFF;
color: #1B1C1C;
font-size: 13px;
line-height: 20px;
resize: none;
}
.wolai-page-ai-send {
width: 36px;
height: 36px;
border: 0;
border-radius: 10px;
background: #1B1C1C;
color: #FFF;
font-size: 16px;
cursor: pointer;
}
@media (max-width: 768px) {
.mnote-sidebar,
.wolai-sidebar {
@@ -1885,6 +2552,15 @@ body {
padding: 0 12px;
}
.wolai-page-settings-popover {
right: 12px;
}
.wolai-page-history-panel,
.wolai-page-ai-panel {
width: min(100vw - 24px, 420px);
}
.wolai-public-pill,
.wolai-breadcrumb-root,
.wolai-breadcrumb-separator {
@@ -1930,6 +2606,12 @@ body {
.wolai-topbar-actions {
gap: 2px;
}
.wolai-page-settings-panel,
.wolai-page-share-card,
.wolai-page-ai-panel {
width: calc(100vw - 24px);
}
}
"##;
@@ -66,6 +66,7 @@ export interface InitOutput {
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
readonly wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
@@ -33,10 +33,12 @@ import { register_text_style } from './snippets/leptos-tiptap-c355e6ec24c3df4c/s
import { register_toc_node } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_toc_node.js';
import { register_underline } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_underline.js';
import { register_youtube } from './snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/tiptap_youtube.js';
import { find_mnote_block_anchor, post_mnote_document_save, write_mnote_text_to_clipboard } from './snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js';
import * as import1 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import2 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import3 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import3 from "./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js"
import * as import4 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
import * as import5 from "./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js"
export class IntoUnderlyingByteSource {
@@ -475,6 +477,10 @@ function __wbg_get_imports() {
const ret = arg0.fetch(getStringFromWasm0(arg1, arg2), arg3);
return ret;
},
__wbg_find_mnote_block_anchor_451c3d968e5e0147: function(arg0, arg1) {
const ret = find_mnote_block_anchor(getStringFromWasm0(arg0, arg1));
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_firstElementChild_f67647a589d437a2: function(arg0) {
const ret = arg0.firstElementChild;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -529,6 +535,13 @@ function __wbg_get_imports() {
const ret = arg0[arg1 >>> 0];
return ret;
},
__wbg_get_aa7ea1c497b45090: function() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg1.get(getStringFromWasm0(arg2, arg3));
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_get_unchecked_17f53dad852b9588: function(arg0, arg1) {
const ret = arg0[arg1 >>> 0];
return ret;
@@ -537,6 +550,17 @@ function __wbg_get_imports() {
const ret = arg0[arg1];
return ret;
},
__wbg_hash_6b96fb5ff20f84b3: function() { return handleError(function (arg0, arg1) {
const ret = arg1.hash;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_headers_6022deb4e576fb8e: function(arg0) {
const ret = arg0.headers;
return ret;
},
__wbg_height_cc0f4b9ec7073c11: function(arg0) {
const ret = arg0.height;
return ret;
@@ -726,6 +750,10 @@ function __wbg_get_imports() {
const ret = arg0.metaKey;
return ret;
},
__wbg_new_0_4d657201ced14de3: function() {
const ret = new Date();
return ret;
},
__wbg_new_0c7403db6e782f19: function(arg0) {
const ret = new Uint8Array(arg0);
return ret;
@@ -788,6 +816,13 @@ function __wbg_get_imports() {
const ret = arg0.ok;
return ret;
},
__wbg_origin_1f038926109a2a37: function() { return handleError(function (arg0, arg1) {
const ret = arg1.origin;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_parentElement_d1271cca94202d1f: function(arg0) {
const ret = arg0.parentElement;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
@@ -796,6 +831,25 @@ function __wbg_get_imports() {
const ret = arg0.parentNode;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_pathname_94c34ca840abb4f4: function() { return handleError(function (arg0, arg1) {
const ret = arg1.pathname;
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
}, arguments); },
__wbg_post_mnote_document_save_94a0f8ecea85d168: function() { return handleError(function (arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
const ret = post_mnote_document_save(getStringFromWasm0(arg0, arg1));
return ret;
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}, arguments); },
__wbg_preventDefault_f55c01cb5fd2bcc0: function(arg0) {
arg0.preventDefault();
},
@@ -982,6 +1036,9 @@ function __wbg_get_imports() {
const ret = arg0.scrollHeight;
return ret;
},
__wbg_scrollIntoView_7725227126cff177: function(arg0, arg1) {
arg0.scrollIntoView(arg1 !== 0);
},
__wbg_scrollTop_f548101d48000fe9: function(arg0) {
const ret = arg0.scrollTop;
return ret;
@@ -1005,6 +1062,10 @@ function __wbg_get_imports() {
__wbg_setProperty_0d903d23a71dfe70: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) {
arg0.setProperty(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4));
}, arguments); },
__wbg_setTimeout_d8786dd31f90da0f: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.setTimeout(arg1, arg2);
return ret;
}, arguments); },
__wbg_set_022bee52d0b05b19: function() { return handleError(function (arg0, arg1, arg2) {
const ret = Reflect.set(arg0, arg1, arg2);
return ret;
@@ -1096,6 +1157,10 @@ function __wbg_get_imports() {
const ret = arg0.target;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_text_595ef75535aa25c1: function() { return handleError(function (arg0) {
const ret = arg0.text();
return ret;
}, arguments); },
__wbg_then_792e0c862b060889: function(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
@@ -1104,6 +1169,14 @@ function __wbg_get_imports() {
const ret = arg0.then(arg1);
return ret;
},
__wbg_toISOString_07c00b3614e865a1: function(arg0) {
const ret = arg0.toISOString();
return ret;
},
__wbg_toString_306ed0b9f320c1ca: function(arg0) {
const ret = arg0.toString();
return ret;
},
__wbg_top_158f7c4dd1427771: function(arg0) {
const ret = arg0.top;
return ret;
@@ -1133,57 +1206,74 @@ function __wbg_get_imports() {
const ret = arg0.width;
return ret;
},
__wbg_write_mnote_text_to_clipboard_d233faf092e4e4c0: function() { return handleError(function (arg0, arg1) {
let deferred0_0;
let deferred0_1;
try {
deferred0_0 = arg0;
deferred0_1 = arg1;
const ret = write_mnote_text_to_clipboard(getStringFromWasm0(arg0, arg1));
return ret;
} finally {
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
}
}, arguments); },
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1456, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1476, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1686, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1715, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1777, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1806, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1611, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1632, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1688, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1717, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1624, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1631, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1687, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1653, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1716, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0) {
__wbindgen_cast_0000000000000009: function(arg0) {
// Cast intrinsic for `F64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0) {
__wbindgen_cast_000000000000000a: function(arg0) {
// Cast intrinsic for `I64 -> Externref`.
const ret = arg0;
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0) {
__wbindgen_cast_000000000000000c: function(arg0) {
// Cast intrinsic for `U64 -> Externref`.
const ret = BigInt.asUintN(64, arg0);
return ret;
@@ -1203,11 +1293,16 @@ function __wbg_get_imports() {
"./mnote-leptos-tiptap-spike-island_bg.js": import0,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import1,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import2,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import3,
"./snippets/mnote-leptos-tiptap-spike-fdb5779a1690afd2/inline0.js": import3,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import4,
"./snippets/leptos-tiptap-c355e6ec24c3df4c/src/js/generated/bridge_runtime.js": import5,
};
}
function wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1);
}
@@ -23,6 +23,7 @@ export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a:
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
export const wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
@@ -1 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=n.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var x=e.CommandManager,m=e.Editor,k=e.Extension,b=e.InputRule,y=e.Mark,r=e.Node,h=e.NodePos,f=e.NodeView,E=e.PasteRule,R=e.Tracker,P=e.callOrReturn,C=e.canInsertNode,T=e.combineTransactionSteps,A=e.createChainableState,S=e.createDocument,B=e.createNodeFromContent,w=e.createStyleTag,N=e.defaultBlockAt,O=e.deleteProps,v=e.elementFromString,M=e.escapeForRegEx,I=e.extensions,q=e.findChildren,F=e.findChildrenInRange,D=e.findDuplicates,j=e.findParentNode,L=e.findParentNodeClosestToPos,K=e.fromString,H=e.generateHTML,G=e.generateJSON,z=e.generateText,J=e.getAttributes,U=e.getAttributesFromExtensions,V=e.getChangedRanges,Y=e.getDebugJSON,W=e.getExtensionField,$=e.getHTMLFromFragment,Q=e.getMarkAttributes,X=e.getMarkRange,Z=e.getMarkType,ee=e.getMarksBetween,te=e.getNodeAtPosition,ne=e.getNodeAttributes,oe=e.getNodeType,ie=e.getRenderedAttributes,re=e.getSchema,se=e.getSchemaByResolvedExtensions,le=e.getSchemaTypeByName,ae=e.getSchemaTypeNameByName,de=e.getSplittedAttributes,pe=e.getText,ce=e.getTextBetween,ue=e.getTextContentFromNodes,ge=e.getTextSerializersFromSchema,_e=e.injectExtensionAttributesToParseRule,xe=e.inputRulesPlugin,me=e.isActive,ke=e.isAtEndOfNode,be=e.isAtStartOfNode,ye=e.isEmptyObject,he=e.isExtensionRulesEnabled,fe=e.isFunction,Ee=e.isList,Re=e.isMacOS,Pe=e.isMarkActive,Ce=e.isNodeActive,Te=e.isNodeEmpty,Ae=e.isNodeSelection,Se=e.isNumber,Be=e.isPlainObject,we=e.isRegExp,Ne=e.isSafari,Oe=e.isString,ve=e.isTextSelection,Me=e.isiOS,Ie=e.markInputRule,qe=e.markPasteRule,s=e.mergeAttributes,Fe=e.mergeDeep,De=e.minMax,je=e.nodeInputRule,Le=e.nodePasteRule,Ke=e.objectIncludes,He=e.pasteRulesPlugin,Ge=e.posToDOMRect,ze=e.removeDuplicates,Je=e.resolveFocusPosition,Ue=e.rewriteUnknownContent,Ve=e.selectionToInsertionEnd,Ye=e.splitExtensions,We=e.textInputRule,$e=e.textPasteRule,Qe=e.textblockTypeInputRule,l=e.wrappingInputRule;var u=/^\s*>\s$/,a=r.create({name:"blockquote",addOptions(){return{HTMLAttributes:{}}},content:"block+",group:"block",defining:!0,parseHTML(){return[{tag:"blockquote"}]},renderHTML({HTMLAttributes:t}){return["blockquote",s(this.options.HTMLAttributes,t),0]},addCommands(){return{setBlockquote:()=>({commands:t})=>t.wrapIn(this.name),toggleBlockquote:()=>({commands:t})=>t.toggleWrap(this.name),unsetBlockquote:()=>({commands:t})=>t.lift(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-b":()=>this.editor.commands.toggleBlockquote()}},addInputRules(){return[l({find:u,type:this.type})]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function g(){let t=globalThis,o=t[d];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[d]=i,i}function p(){return g()}function c(t){p().registerExtension(t)}var _={name:"blockquote",create:()=>a,commands:{set_blockquote:t=>t.chain().focus().setBlockquote().run(),toggle_blockquote:t=>t.chain().focus().toggleBlockquote().run(),unset_blockquote:t=>t.chain().focus().unsetBlockquote().run()},selection_keys:["blockquote"],selection_state:t=>({blockquote:t.isActive("blockquote")})};function st(){c(_)}export{st as register_blockquote};
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var k=t.CommandManager,b=t.Editor,y=t.Extension,h=t.InputRule,f=t.Mark,r=t.Node,E=t.NodePos,R=t.NodeView,P=t.PasteRule,T=t.Tracker,A=t.callOrReturn,C=t.canInsertNode,S=t.combineTransactionSteps,B=t.createChainableState,w=t.createDocument,N=t.createNodeFromContent,O=t.createStyleTag,M=t.defaultBlockAt,v=t.deleteProps,I=t.elementFromString,q=t.escapeForRegEx,F=t.extensions,D=t.findChildren,j=t.findChildrenInRange,L=t.findDuplicates,H=t.findParentNode,K=t.findParentNodeClosestToPos,G=t.fromString,z=t.generateHTML,J=t.generateJSON,U=t.generateText,V=t.getAttributes,Y=t.getAttributesFromExtensions,W=t.getChangedRanges,$=t.getDebugJSON,Q=t.getExtensionField,X=t.getHTMLFromFragment,Z=t.getMarkAttributes,tt=t.getMarkRange,et=t.getMarkType,nt=t.getMarksBetween,ot=t.getNodeAtPosition,it=t.getNodeAttributes,rt=t.getNodeType,st=t.getRenderedAttributes,lt=t.getSchema,at=t.getSchemaByResolvedExtensions,dt=t.getSchemaTypeByName,pt=t.getSchemaTypeNameByName,ut=t.getSplittedAttributes,ct=t.getText,gt=t.getTextBetween,_t=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,mt=t.injectExtensionAttributesToParseRule,kt=t.inputRulesPlugin,bt=t.isActive,yt=t.isAtEndOfNode,ht=t.isAtStartOfNode,ft=t.isEmptyObject,Et=t.isExtensionRulesEnabled,Rt=t.isFunction,Pt=t.isList,Tt=t.isMacOS,At=t.isMarkActive,Ct=t.isNodeActive,St=t.isNodeEmpty,Bt=t.isNodeSelection,wt=t.isNumber,Nt=t.isPlainObject,Ot=t.isRegExp,Mt=t.isSafari,vt=t.isString,It=t.isTextSelection,qt=t.isiOS,Ft=t.markInputRule,Dt=t.markPasteRule,s=t.mergeAttributes,jt=t.mergeDeep,Lt=t.minMax,Ht=t.nodeInputRule,Kt=t.nodePasteRule,Gt=t.objectIncludes,zt=t.pasteRulesPlugin,Jt=t.posToDOMRect,Ut=t.removeDuplicates,Vt=t.resolveFocusPosition,Yt=t.rewriteUnknownContent,Wt=t.selectionToInsertionEnd,$t=t.splitExtensions,Qt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,l=t.wrappingInputRule;var c=/^\s*>\s$/,a=r.create({name:"blockquote",addOptions(){return{HTMLAttributes:{}}},content:"block+",group:"block",defining:!0,parseHTML(){return[{tag:"blockquote"}]},renderHTML({HTMLAttributes:e}){return["blockquote",s(this.options.HTMLAttributes,e),0]},addCommands(){return{setBlockquote:()=>({commands:e})=>e.wrapIn(this.name),toggleBlockquote:()=>({commands:e})=>e.toggleWrap(this.name),unsetBlockquote:()=>({commands:e})=>e.lift(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-b":()=>this.editor.commands.toggleBlockquote()}},addInputRules(){return[l({find:c,type:this.type})]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function g(){let e=globalThis,o=e[d];if(o!=null)return o;let i={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[d]=i,i}function p(){return g()}function u(e){p().registerExtension(e)}function _(e){return typeof e=="string"&&e.length>0?{"data-block-id":e,id:e}:{}}var x=a.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:e=>e.getAttribute("data-block-id"),renderHTML:e=>_(e.blockId)}}}}),m={name:"blockquote",create:()=>x,commands:{set_blockquote:e=>e.chain().focus().setBlockquote().run(),toggle_blockquote:e=>e.chain().focus().toggleBlockquote().run(),unset_blockquote:e=>e.chain().focus().unsetBlockquote().run()},selection_keys:["blockquote"],selection_state:e=>({blockquote:e.isActive("blockquote")})};function ae(){u(m)}export{ae as register_blockquote};
@@ -1,4 +1,4 @@
var p=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(p==null||p.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=p.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var N=e.CommandManager,B=e.Editor,O=e.Extension,v=e.InputRule,M=e.Mark,_=e.Node,D=e.NodePos,I=e.NodeView,F=e.PasteRule,j=e.Tracker,L=e.callOrReturn,K=e.canInsertNode,H=e.combineTransactionSteps,G=e.createChainableState,q=e.createDocument,z=e.createNodeFromContent,$=e.createStyleTag,J=e.defaultBlockAt,W=e.deleteProps,V=e.elementFromString,U=e.escapeForRegEx,Y=e.extensions,Q=e.findChildren,X=e.findChildrenInRange,Z=e.findDuplicates,ee=e.findParentNode,te=e.findParentNodeClosestToPos,ne=e.fromString,oe=e.generateHTML,re=e.generateJSON,ie=e.generateText,se=e.getAttributes,le=e.getAttributesFromExtensions,ae=e.getChangedRanges,de=e.getDebugJSON,pe=e.getExtensionField,ce=e.getHTMLFromFragment,ue=e.getMarkAttributes,ge=e.getMarkRange,_e=e.getMarkType,xe=e.getMarksBetween,me=e.getNodeAtPosition,ke=e.getNodeAttributes,be=e.getNodeType,ye=e.getRenderedAttributes,fe=e.getSchema,he=e.getSchemaByResolvedExtensions,Ee=e.getSchemaTypeByName,Se=e.getSchemaTypeNameByName,Pe=e.getSplittedAttributes,Ce=e.getText,Ae=e.getTextBetween,Re=e.getTextContentFromNodes,Te=e.getTextSerializersFromSchema,we=e.injectExtensionAttributesToParseRule,Ne=e.inputRulesPlugin,Be=e.isActive,Oe=e.isAtEndOfNode,ve=e.isAtStartOfNode,Me=e.isEmptyObject,De=e.isExtensionRulesEnabled,Ie=e.isFunction,Fe=e.isList,je=e.isMacOS,Le=e.isMarkActive,Ke=e.isNodeActive,He=e.isNodeEmpty,Ge=e.isNodeSelection,qe=e.isNumber,ze=e.isPlainObject,$e=e.isRegExp,Je=e.isSafari,We=e.isString,Ve=e.isTextSelection,Ue=e.isiOS,Ye=e.markInputRule,Qe=e.markPasteRule,x=e.mergeAttributes,Xe=e.mergeDeep,Ze=e.minMax,et=e.nodeInputRule,tt=e.nodePasteRule,nt=e.objectIncludes,ot=e.pasteRulesPlugin,rt=e.posToDOMRect,it=e.removeDuplicates,st=e.resolveFocusPosition,lt=e.rewriteUnknownContent,at=e.selectionToInsertionEnd,dt=e.splitExtensions,pt=e.textInputRule,ct=e.textPasteRule,c=e.textblockTypeInputRule,ut=e.wrappingInputRule;var u=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(u==null||u.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var l=u.modules["@tiptap/pm/state"];if(l==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/state" is unavailable');var _t=l.AllSelection,xt=l.EditorState,mt=l.NodeSelection,m=l.Plugin,k=l.PluginKey,b=l.Selection,kt=l.SelectionRange,y=l.TextSelection,bt=l.Transaction;var A=/^```([a-z]+)?[\s\n]$/,R=/^~~~([a-z]+)?[\s\n]$/,f=_.create({name:"codeBlock",addOptions(){return{languageClassPrefix:"language-",exitOnTripleEnter:!0,exitOnArrowDown:!0,defaultLanguage:null,HTMLAttributes:{}}},content:"text*",marks:"",group:"block",code:!0,defining:!0,addAttributes(){return{language:{default:this.options.defaultLanguage,parseHTML:t=>{var n;let{languageClassPrefix:o}=this.options,a=[...((n=t.firstElementChild)===null||n===void 0?void 0:n.classList)||[]].filter(r=>r.startsWith(o)).map(r=>r.replace(o,""))[0];return a||null},rendered:!1}}},parseHTML(){return[{tag:"pre",preserveWhitespace:"full"}]},renderHTML({node:t,HTMLAttributes:n}){return["pre",x(this.options.HTMLAttributes,n),["code",{class:t.attrs.language?this.options.languageClassPrefix+t.attrs.language:null},0]]},addCommands(){return{setCodeBlock:t=>({commands:n})=>n.setNode(this.name,t),toggleCodeBlock:t=>({commands:n})=>n.toggleNode(this.name,"paragraph",t)}},addKeyboardShortcuts(){return{"Mod-Alt-c":()=>this.editor.commands.toggleCodeBlock(),Backspace:()=>{let{empty:t,$anchor:n}=this.editor.state.selection,o=n.pos===1;return!t||n.parent.type.name!==this.name?!1:o||!n.parent.textContent.length?this.editor.commands.clearNodes():!1},Enter:({editor:t})=>{if(!this.options.exitOnTripleEnter)return!1;let{state:n}=t,{selection:o}=n,{$from:i,empty:s}=o;if(!s||i.parent.type!==this.type)return!1;let a=i.parentOffset===i.parent.nodeSize-2,r=i.parent.textContent.endsWith(`
var p=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(p==null||p.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=p.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var O=e.CommandManager,v=e.Editor,M=e.Extension,D=e.InputRule,I=e.Mark,_=e.Node,F=e.NodePos,L=e.NodeView,j=e.PasteRule,H=e.Tracker,K=e.callOrReturn,G=e.canInsertNode,q=e.combineTransactionSteps,z=e.createChainableState,$=e.createDocument,J=e.createNodeFromContent,W=e.createStyleTag,V=e.defaultBlockAt,U=e.deleteProps,Y=e.elementFromString,Q=e.escapeForRegEx,X=e.extensions,Z=e.findChildren,ee=e.findChildrenInRange,te=e.findDuplicates,ne=e.findParentNode,oe=e.findParentNodeClosestToPos,re=e.fromString,ie=e.generateHTML,se=e.generateJSON,le=e.generateText,ae=e.getAttributes,de=e.getAttributesFromExtensions,pe=e.getChangedRanges,ce=e.getDebugJSON,ue=e.getExtensionField,ge=e.getHTMLFromFragment,_e=e.getMarkAttributes,xe=e.getMarkRange,me=e.getMarkType,ke=e.getMarksBetween,be=e.getNodeAtPosition,ye=e.getNodeAttributes,fe=e.getNodeType,he=e.getRenderedAttributes,Ee=e.getSchema,Ae=e.getSchemaByResolvedExtensions,Se=e.getSchemaTypeByName,Pe=e.getSchemaTypeNameByName,Ce=e.getSplittedAttributes,Re=e.getText,Te=e.getTextBetween,we=e.getTextContentFromNodes,Be=e.getTextSerializersFromSchema,Ne=e.injectExtensionAttributesToParseRule,Oe=e.inputRulesPlugin,ve=e.isActive,Me=e.isAtEndOfNode,De=e.isAtStartOfNode,Ie=e.isEmptyObject,Fe=e.isExtensionRulesEnabled,Le=e.isFunction,je=e.isList,He=e.isMacOS,Ke=e.isMarkActive,Ge=e.isNodeActive,qe=e.isNodeEmpty,ze=e.isNodeSelection,$e=e.isNumber,Je=e.isPlainObject,We=e.isRegExp,Ve=e.isSafari,Ue=e.isString,Ye=e.isTextSelection,Qe=e.isiOS,Xe=e.markInputRule,Ze=e.markPasteRule,x=e.mergeAttributes,et=e.mergeDeep,tt=e.minMax,nt=e.nodeInputRule,ot=e.nodePasteRule,rt=e.objectIncludes,it=e.pasteRulesPlugin,st=e.posToDOMRect,lt=e.removeDuplicates,at=e.resolveFocusPosition,dt=e.rewriteUnknownContent,pt=e.selectionToInsertionEnd,ct=e.splitExtensions,ut=e.textInputRule,gt=e.textPasteRule,c=e.textblockTypeInputRule,_t=e.wrappingInputRule;var u=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(u==null||u.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var l=u.modules["@tiptap/pm/state"];if(l==null)throw new Error('leptos-tiptap bridge module "@tiptap/pm/state" is unavailable');var mt=l.AllSelection,kt=l.EditorState,bt=l.NodeSelection,m=l.Plugin,k=l.PluginKey,b=l.Selection,yt=l.SelectionRange,y=l.TextSelection,ft=l.Transaction;var C=/^```([a-z]+)?[\s\n]$/,R=/^~~~([a-z]+)?[\s\n]$/,f=_.create({name:"codeBlock",addOptions(){return{languageClassPrefix:"language-",exitOnTripleEnter:!0,exitOnArrowDown:!0,defaultLanguage:null,HTMLAttributes:{}}},content:"text*",marks:"",group:"block",code:!0,defining:!0,addAttributes(){return{language:{default:this.options.defaultLanguage,parseHTML:t=>{var n;let{languageClassPrefix:o}=this.options,a=[...((n=t.firstElementChild)===null||n===void 0?void 0:n.classList)||[]].filter(r=>r.startsWith(o)).map(r=>r.replace(o,""))[0];return a||null},rendered:!1}}},parseHTML(){return[{tag:"pre",preserveWhitespace:"full"}]},renderHTML({node:t,HTMLAttributes:n}){return["pre",x(this.options.HTMLAttributes,n),["code",{class:t.attrs.language?this.options.languageClassPrefix+t.attrs.language:null},0]]},addCommands(){return{setCodeBlock:t=>({commands:n})=>n.setNode(this.name,t),toggleCodeBlock:t=>({commands:n})=>n.toggleNode(this.name,"paragraph",t)}},addKeyboardShortcuts(){return{"Mod-Alt-c":()=>this.editor.commands.toggleCodeBlock(),Backspace:()=>{let{empty:t,$anchor:n}=this.editor.state.selection,o=n.pos===1;return!t||n.parent.type.name!==this.name?!1:o||!n.parent.textContent.length?this.editor.commands.clearNodes():!1},Enter:({editor:t})=>{if(!this.options.exitOnTripleEnter)return!1;let{state:n}=t,{selection:o}=n,{$from:i,empty:s}=o;if(!s||i.parent.type!==this.type)return!1;let a=i.parentOffset===i.parent.nodeSize-2,r=i.parent.textContent.endsWith(`
`);return!a||!r?!1:t.chain().command(({tr:d})=>(d.delete(i.pos-2,i.pos),!0)).exitCode().run()},ArrowDown:({editor:t})=>{if(!this.options.exitOnArrowDown)return!1;let{state:n}=t,{selection:o,doc:i}=n,{$from:s,empty:a}=o;if(!a||s.parent.type!==this.type||!(s.parentOffset===s.parent.nodeSize-2))return!1;let d=s.after();return d===void 0?!1:i.nodeAt(d)?t.commands.command(({tr:C})=>(C.setSelection(b.near(i.resolve(d))),!0)):t.commands.exitCode()}}},addInputRules(){return[c({find:A,type:this.type,getAttributes:t=>({language:t[1]})}),c({find:R,type:this.type,getAttributes:t=>({language:t[1]})})]},addProseMirrorPlugins(){return[new m({key:new k("codeBlockVSCodeHandler"),props:{handlePaste:(t,n)=>{if(!n.clipboardData||this.editor.isActive(this.type.name))return!1;let o=n.clipboardData.getData("text/plain"),i=n.clipboardData.getData("vscode-editor-data"),s=i?JSON.parse(i):void 0,a=s?.mode;if(!o||!a)return!1;let{tr:r,schema:d}=t.state,g=d.text(o.replace(/\r\n?/g,`
`));return r.replaceSelectionWith(this.type.create({language:a},g)),r.selection.$from.parent.type!==this.type&&r.setSelection(y.near(r.doc.resolve(Math.max(0,r.selection.from-2)))),r.setMeta("paste",!0),t.dispatch(r),!0}}})]}});var h="__LEPTOS_TIPTAP_BRIDGE__";function T(){let t=globalThis,n=t[h];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[h]=o,o}function E(){return T()}function S(t){E().registerExtension(t)}function P(t){if(t?.language!=null)return{language:t.language}}var w={name:"code_block",create:()=>f,commands:{set_code_block:(t,n)=>n.kind==="set_code_block"?t.chain().focus().setCodeBlock(P(n.attributes)).run():!1,toggle_code_block:(t,n)=>n.kind==="toggle_code_block"?t.chain().focus().toggleCodeBlock(P(n.attributes)).run():!1}};function Tt(){S(w)}export{Tt as register_code_block};
`);return!a||!r?!1:t.chain().command(({tr:d})=>(d.delete(i.pos-2,i.pos),!0)).exitCode().run()},ArrowDown:({editor:t})=>{if(!this.options.exitOnArrowDown)return!1;let{state:n}=t,{selection:o,doc:i}=n,{$from:s,empty:a}=o;if(!a||s.parent.type!==this.type||!(s.parentOffset===s.parent.nodeSize-2))return!1;let d=s.after();return d===void 0?!1:i.nodeAt(d)?t.commands.command(({tr:P})=>(P.setSelection(b.near(i.resolve(d))),!0)):t.commands.exitCode()}}},addInputRules(){return[c({find:C,type:this.type,getAttributes:t=>({language:t[1]})}),c({find:R,type:this.type,getAttributes:t=>({language:t[1]})})]},addProseMirrorPlugins(){return[new m({key:new k("codeBlockVSCodeHandler"),props:{handlePaste:(t,n)=>{if(!n.clipboardData||this.editor.isActive(this.type.name))return!1;let o=n.clipboardData.getData("text/plain"),i=n.clipboardData.getData("vscode-editor-data"),s=i?JSON.parse(i):void 0,a=s?.mode;if(!o||!a)return!1;let{tr:r,schema:d}=t.state,g=d.text(o.replace(/\r\n?/g,`
`));return r.replaceSelectionWith(this.type.create({language:a},g)),r.selection.$from.parent.type!==this.type&&r.setSelection(y.near(r.doc.resolve(Math.max(0,r.selection.from-2)))),r.setMeta("paste",!0),t.dispatch(r),!0}}})]}});var h="__LEPTOS_TIPTAP_BRIDGE__";function T(){let t=globalThis,n=t[h];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[h]=o,o}function E(){return T()}function A(t){E().registerExtension(t)}function w(t){return typeof t=="string"&&t.length>0?{"data-block-id":t,id:t}:{}}function S(t){if(t?.language!=null)return{language:t.language}}var B=f.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:t=>t.getAttribute("data-block-id"),renderHTML:t=>w(t.blockId)}}}}),N={name:"code_block",create:()=>B,commands:{set_code_block:(t,n)=>n.kind==="set_code_block"?t.chain().focus().setCodeBlock(S(n.attributes)).run():!1,toggle_code_block:(t,n)=>n.kind==="toggle_code_block"?t.chain().focus().toggleCodeBlock(S(n.attributes)).run():!1}};function Bt(){A(N)}export{Bt as register_code_block};
@@ -1 +1 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=i.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var x=e.CommandManager,m=e.Editor,k=e.Extension,b=e.InputRule,y=e.Mark,r=e.Node,h=e.NodePos,f=e.NodeView,E=e.PasteRule,R=e.Tracker,A=e.callOrReturn,P=e.canInsertNode,T=e.combineTransactionSteps,v=e.createChainableState,C=e.createDocument,S=e.createNodeFromContent,w=e.createStyleTag,N=e.defaultBlockAt,O=e.deleteProps,B=e.elementFromString,M=e.escapeForRegEx,I=e.extensions,F=e.findChildren,D=e.findChildrenInRange,j=e.findDuplicates,H=e.findParentNode,L=e.findParentNodeClosestToPos,K=e.fromString,q=e.generateHTML,G=e.generateJSON,$=e.generateText,z=e.getAttributes,J=e.getAttributesFromExtensions,U=e.getChangedRanges,V=e.getDebugJSON,Y=e.getExtensionField,Q=e.getHTMLFromFragment,W=e.getMarkAttributes,X=e.getMarkRange,Z=e.getMarkType,ee=e.getMarksBetween,te=e.getNodeAtPosition,ne=e.getNodeAttributes,oe=e.getNodeType,ie=e.getRenderedAttributes,re=e.getSchema,se=e.getSchemaByResolvedExtensions,le=e.getSchemaTypeByName,ae=e.getSchemaTypeNameByName,de=e.getSplittedAttributes,pe=e.getText,ue=e.getTextBetween,ce=e.getTextContentFromNodes,ge=e.getTextSerializersFromSchema,_e=e.injectExtensionAttributesToParseRule,xe=e.inputRulesPlugin,me=e.isActive,ke=e.isAtEndOfNode,be=e.isAtStartOfNode,ye=e.isEmptyObject,he=e.isExtensionRulesEnabled,fe=e.isFunction,Ee=e.isList,Re=e.isMacOS,Ae=e.isMarkActive,Pe=e.isNodeActive,Te=e.isNodeEmpty,ve=e.isNodeSelection,Ce=e.isNumber,Se=e.isPlainObject,we=e.isRegExp,Ne=e.isSafari,Oe=e.isString,Be=e.isTextSelection,Me=e.isiOS,Ie=e.markInputRule,Fe=e.markPasteRule,s=e.mergeAttributes,De=e.mergeDeep,je=e.minMax,He=e.nodeInputRule,Le=e.nodePasteRule,Ke=e.objectIncludes,qe=e.pasteRulesPlugin,Ge=e.posToDOMRect,$e=e.removeDuplicates,ze=e.resolveFocusPosition,Je=e.rewriteUnknownContent,Ue=e.selectionToInsertionEnd,Ve=e.splitExtensions,Ye=e.textInputRule,Qe=e.textPasteRule,l=e.textblockTypeInputRule,We=e.wrappingInputRule;var a=r.create({name:"heading",addOptions(){return{levels:[1,2,3,4,5,6],HTMLAttributes:{}}},content:"inline*",group:"block",defining:!0,addAttributes(){return{level:{default:1,rendered:!1}}},parseHTML(){return this.options.levels.map(t=>({tag:`h${t}`,attrs:{level:t}}))},renderHTML({node:t,HTMLAttributes:n}){return[`h${this.options.levels.includes(t.attrs.level)?t.attrs.level:this.options.levels[0]}`,s(this.options.HTMLAttributes,n),0]},addCommands(){return{setHeading:t=>({commands:n})=>this.options.levels.includes(t.level)?n.setNode(this.name,t):!1,toggleHeading:t=>({commands:n})=>this.options.levels.includes(t.level)?n.toggleNode(this.name,"paragraph",t):!1}},addKeyboardShortcuts(){return this.options.levels.reduce((t,n)=>({...t,[`Mod-Alt-${n}`]:()=>this.editor.commands.toggleHeading({level:n})}),{})},addInputRules(){return this.options.levels.map(t=>l({find:new RegExp(`^(#{${Math.min(...this.options.levels)},${t}})\\s$`),type:this.type,getAttributes:{level:t}}))}});var d="__LEPTOS_TIPTAP_BRIDGE__";function c(){let t=globalThis,n=t[d];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[d]=o,o}function p(){return c()}function u(t){p().registerExtension(t)}var g={name:"heading",create:()=>a.extend({addAttributes(){return{...this.parent?.(),collapsed:{default:null,parseHTML:t=>{let n=t.getAttribute("data-collapsed");return n==="true"?!0:n==="false"?!1:null},renderHTML:t=>t.collapsed===!0?{"data-collapsed":"true"}:{}}}}}),commands:{set_heading:(t,n)=>n.kind==="set_heading"?t.chain().focus().setHeading({level:n.level,...n.collapsed==null?{}:{collapsed:n.collapsed}}).run():!1,toggle_heading:(t,n)=>n.kind==="toggle_heading"?t.chain().focus().toggleHeading({level:n.level,...n.collapsed==null?{}:{collapsed:n.collapsed}}).run():!1},selection_keys:["h1","h2","h3","h4","h5","h6"],selection_state:t=>({h1:t.isActive("heading",{level:1}),h2:t.isActive("heading",{level:2}),h3:t.isActive("heading",{level:3}),h4:t.isActive("heading",{level:4}),h5:t.isActive("heading",{level:5}),h6:t.isActive("heading",{level:6})})};function st(){u(g)}export{st as register_heading};
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=i.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var m=e.CommandManager,k=e.Editor,b=e.Extension,y=e.InputRule,h=e.Mark,r=e.Node,f=e.NodePos,E=e.NodeView,R=e.PasteRule,A=e.Tracker,T=e.callOrReturn,P=e.canInsertNode,v=e.combineTransactionSteps,C=e.createChainableState,S=e.createDocument,w=e.createNodeFromContent,N=e.createStyleTag,O=e.defaultBlockAt,B=e.deleteProps,M=e.elementFromString,I=e.escapeForRegEx,F=e.extensions,D=e.findChildren,H=e.findChildrenInRange,L=e.findDuplicates,j=e.findParentNode,K=e.findParentNodeClosestToPos,q=e.fromString,G=e.generateHTML,$=e.generateJSON,z=e.generateText,J=e.getAttributes,U=e.getAttributesFromExtensions,V=e.getChangedRanges,Y=e.getDebugJSON,Q=e.getExtensionField,W=e.getHTMLFromFragment,X=e.getMarkAttributes,Z=e.getMarkRange,ee=e.getMarkType,te=e.getMarksBetween,ne=e.getNodeAtPosition,oe=e.getNodeAttributes,ie=e.getNodeType,re=e.getRenderedAttributes,se=e.getSchema,le=e.getSchemaByResolvedExtensions,ae=e.getSchemaTypeByName,de=e.getSchemaTypeNameByName,pe=e.getSplittedAttributes,ue=e.getText,ce=e.getTextBetween,ge=e.getTextContentFromNodes,_e=e.getTextSerializersFromSchema,xe=e.injectExtensionAttributesToParseRule,me=e.inputRulesPlugin,ke=e.isActive,be=e.isAtEndOfNode,ye=e.isAtStartOfNode,he=e.isEmptyObject,fe=e.isExtensionRulesEnabled,Ee=e.isFunction,Re=e.isList,Ae=e.isMacOS,Te=e.isMarkActive,Pe=e.isNodeActive,ve=e.isNodeEmpty,Ce=e.isNodeSelection,Se=e.isNumber,we=e.isPlainObject,Ne=e.isRegExp,Oe=e.isSafari,Be=e.isString,Me=e.isTextSelection,Ie=e.isiOS,Fe=e.markInputRule,De=e.markPasteRule,s=e.mergeAttributes,He=e.mergeDeep,Le=e.minMax,je=e.nodeInputRule,Ke=e.nodePasteRule,qe=e.objectIncludes,Ge=e.pasteRulesPlugin,$e=e.posToDOMRect,ze=e.removeDuplicates,Je=e.resolveFocusPosition,Ue=e.rewriteUnknownContent,Ve=e.selectionToInsertionEnd,Ye=e.splitExtensions,Qe=e.textInputRule,We=e.textPasteRule,l=e.textblockTypeInputRule,Xe=e.wrappingInputRule;var a=r.create({name:"heading",addOptions(){return{levels:[1,2,3,4,5,6],HTMLAttributes:{}}},content:"inline*",group:"block",defining:!0,addAttributes(){return{level:{default:1,rendered:!1}}},parseHTML(){return this.options.levels.map(t=>({tag:`h${t}`,attrs:{level:t}}))},renderHTML({node:t,HTMLAttributes:n}){return[`h${this.options.levels.includes(t.attrs.level)?t.attrs.level:this.options.levels[0]}`,s(this.options.HTMLAttributes,n),0]},addCommands(){return{setHeading:t=>({commands:n})=>this.options.levels.includes(t.level)?n.setNode(this.name,t):!1,toggleHeading:t=>({commands:n})=>this.options.levels.includes(t.level)?n.toggleNode(this.name,"paragraph",t):!1}},addKeyboardShortcuts(){return this.options.levels.reduce((t,n)=>({...t,[`Mod-Alt-${n}`]:()=>this.editor.commands.toggleHeading({level:n})}),{})},addInputRules(){return this.options.levels.map(t=>l({find:new RegExp(`^(#{${Math.min(...this.options.levels)},${t}})\\s$`),type:this.type,getAttributes:{level:t}}))}});var d="__LEPTOS_TIPTAP_BRIDGE__";function c(){let t=globalThis,n=t[d];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[d]=o,o}function p(){return c()}function u(t){p().registerExtension(t)}function g(t){return typeof t=="string"&&t.length>0?{"data-block-id":t,id:t}:{}}var _={name:"heading",create:()=>a.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:t=>t.getAttribute("data-block-id"),renderHTML:t=>g(t.blockId)},collapsed:{default:null,parseHTML:t=>{let n=t.getAttribute("data-collapsed");return n==="true"?!0:n==="false"?!1:null},renderHTML:t=>t.collapsed===!0?{"data-collapsed":"true"}:{}}}}}),commands:{set_heading:(t,n)=>n.kind==="set_heading"?t.chain().focus().setHeading({level:n.level,...n.collapsed==null?{}:{collapsed:n.collapsed}}).run():!1,toggle_heading:(t,n)=>n.kind==="toggle_heading"?t.chain().focus().toggleHeading({level:n.level,...n.collapsed==null?{}:{collapsed:n.collapsed}}).run():!1},selection_keys:["h1","h2","h3","h4","h5","h6"],selection_state:t=>({h1:t.isActive("heading",{level:1}),h2:t.isActive("heading",{level:2}),h3:t.isActive("heading",{level:3}),h4:t.isActive("heading",{level:4}),h5:t.isActive("heading",{level:5}),h6:t.isActive("heading",{level:6})})};function lt(){u(_)}export{lt as register_heading};
@@ -1 +1 @@
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=i.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var b=t.CommandManager,y=t.Editor,f=t.Extension,h=t.InputRule,E=t.Mark,r=t.Node,R=t.NodePos,P=t.NodeView,T=t.PasteRule,A=t.Tracker,C=t.callOrReturn,S=t.canInsertNode,w=t.combineTransactionSteps,N=t.createChainableState,O=t.createDocument,B=t.createNodeFromContent,I=t.createStyleTag,M=t.defaultBlockAt,v=t.deleteProps,F=t.elementFromString,D=t.escapeForRegEx,j=t.extensions,L=t.findChildren,H=t.findChildrenInRange,K=t.findDuplicates,q=t.findParentNode,G=t.findParentNodeClosestToPos,z=t.fromString,J=t.generateHTML,U=t.generateJSON,V=t.generateText,Y=t.getAttributes,$=t.getAttributesFromExtensions,Q=t.getChangedRanges,W=t.getDebugJSON,X=t.getExtensionField,Z=t.getHTMLFromFragment,tt=t.getMarkAttributes,et=t.getMarkRange,nt=t.getMarkType,ot=t.getMarksBetween,it=t.getNodeAtPosition,rt=t.getNodeAttributes,st=t.getNodeType,lt=t.getRenderedAttributes,at=t.getSchema,dt=t.getSchemaByResolvedExtensions,pt=t.getSchemaTypeByName,ut=t.getSchemaTypeNameByName,ct=t.getSplittedAttributes,gt=t.getText,_t=t.getTextBetween,mt=t.getTextContentFromNodes,xt=t.getTextSerializersFromSchema,kt=t.injectExtensionAttributesToParseRule,bt=t.inputRulesPlugin,yt=t.isActive,ft=t.isAtEndOfNode,ht=t.isAtStartOfNode,Et=t.isEmptyObject,Rt=t.isExtensionRulesEnabled,Pt=t.isFunction,Tt=t.isList,At=t.isMacOS,Ct=t.isMarkActive,St=t.isNodeActive,wt=t.isNodeEmpty,Nt=t.isNodeSelection,Ot=t.isNumber,Bt=t.isPlainObject,It=t.isRegExp,Mt=t.isSafari,vt=t.isString,Ft=t.isTextSelection,Dt=t.isiOS,jt=t.markInputRule,Lt=t.markPasteRule,s=t.mergeAttributes,Ht=t.mergeDeep,Kt=t.minMax,l=t.nodeInputRule,qt=t.nodePasteRule,Gt=t.objectIncludes,zt=t.pasteRulesPlugin,Jt=t.posToDOMRect,Ut=t.removeDuplicates,Vt=t.resolveFocusPosition,Yt=t.rewriteUnknownContent,$t=t.selectionToInsertionEnd,Qt=t.splitExtensions,Wt=t.textInputRule,Xt=t.textPasteRule,Zt=t.textblockTypeInputRule,te=t.wrappingInputRule;var g=/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/,a=r.create({name:"image",addOptions(){return{inline:!1,allowBase64:!1,HTMLAttributes:{}}},inline(){return this.options.inline},group(){return this.options.inline?"inline":"block"},draggable:!0,addAttributes(){return{src:{default:null},alt:{default:null},title:{default:null}}},parseHTML(){return[{tag:this.options.allowBase64?"img[src]":'img[src]:not([src^="data:"])'}]},renderHTML({HTMLAttributes:e}){return["img",s(this.options.HTMLAttributes,e)]},addCommands(){return{setImage:e=>({commands:n})=>n.insertContent({type:this.name,attrs:e})}},addInputRules(){return[l({find:g,type:this.type,getAttributes:e=>{let[,,n,o,c]=e;return{src:o,alt:n,title:c}}})]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function _(){let e=globalThis,n=e[d];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[d]=o,o}function p(){return _()}function u(e){p().registerExtension(e)}function m(e){return{src:e.src,alt:e.alt??void 0,title:e.title??void 0}}var x=a.extend({addAttributes(){return{...this.parent?.(),"data-align":{default:null,parseHTML:e=>e.getAttribute("data-align"),renderHTML:e=>{let n=e["data-align"];return typeof n=="string"&&n.length>0?{"data-align":n}:{}}}}}}),k={name:"image",create:()=>x,commands:{set_image:(e,n)=>n.kind==="set_image"?e.chain().focus().setImage(m(n)).run():!1}};function de(){u(k)}export{de as register_image};
var i=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(i==null||i.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=i.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var y=t.CommandManager,f=t.Editor,h=t.Extension,E=t.InputRule,R=t.Mark,r=t.Node,T=t.NodePos,A=t.NodeView,P=t.PasteRule,C=t.Tracker,S=t.callOrReturn,w=t.canInsertNode,N=t.combineTransactionSteps,B=t.createChainableState,O=t.createDocument,M=t.createNodeFromContent,I=t.createStyleTag,v=t.defaultBlockAt,F=t.deleteProps,D=t.elementFromString,L=t.escapeForRegEx,j=t.extensions,H=t.findChildren,K=t.findChildrenInRange,q=t.findDuplicates,G=t.findParentNode,z=t.findParentNodeClosestToPos,J=t.fromString,U=t.generateHTML,V=t.generateJSON,Y=t.generateText,$=t.getAttributes,Q=t.getAttributesFromExtensions,W=t.getChangedRanges,X=t.getDebugJSON,Z=t.getExtensionField,tt=t.getHTMLFromFragment,et=t.getMarkAttributes,nt=t.getMarkRange,ot=t.getMarkType,it=t.getMarksBetween,rt=t.getNodeAtPosition,st=t.getNodeAttributes,lt=t.getNodeType,at=t.getRenderedAttributes,dt=t.getSchema,pt=t.getSchemaByResolvedExtensions,ut=t.getSchemaTypeByName,ct=t.getSchemaTypeNameByName,gt=t.getSplittedAttributes,_t=t.getText,mt=t.getTextBetween,xt=t.getTextContentFromNodes,kt=t.getTextSerializersFromSchema,bt=t.injectExtensionAttributesToParseRule,yt=t.inputRulesPlugin,ft=t.isActive,ht=t.isAtEndOfNode,Et=t.isAtStartOfNode,Rt=t.isEmptyObject,Tt=t.isExtensionRulesEnabled,At=t.isFunction,Pt=t.isList,Ct=t.isMacOS,St=t.isMarkActive,wt=t.isNodeActive,Nt=t.isNodeEmpty,Bt=t.isNodeSelection,Ot=t.isNumber,Mt=t.isPlainObject,It=t.isRegExp,vt=t.isSafari,Ft=t.isString,Dt=t.isTextSelection,Lt=t.isiOS,jt=t.markInputRule,Ht=t.markPasteRule,s=t.mergeAttributes,Kt=t.mergeDeep,qt=t.minMax,l=t.nodeInputRule,Gt=t.nodePasteRule,zt=t.objectIncludes,Jt=t.pasteRulesPlugin,Ut=t.posToDOMRect,Vt=t.removeDuplicates,Yt=t.resolveFocusPosition,$t=t.rewriteUnknownContent,Qt=t.selectionToInsertionEnd,Wt=t.splitExtensions,Xt=t.textInputRule,Zt=t.textPasteRule,te=t.textblockTypeInputRule,ee=t.wrappingInputRule;var g=/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/,a=r.create({name:"image",addOptions(){return{inline:!1,allowBase64:!1,HTMLAttributes:{}}},inline(){return this.options.inline},group(){return this.options.inline?"inline":"block"},draggable:!0,addAttributes(){return{src:{default:null},alt:{default:null},title:{default:null}}},parseHTML(){return[{tag:this.options.allowBase64?"img[src]":'img[src]:not([src^="data:"])'}]},renderHTML({HTMLAttributes:e}){return["img",s(this.options.HTMLAttributes,e)]},addCommands(){return{setImage:e=>({commands:n})=>n.insertContent({type:this.name,attrs:e})}},addInputRules(){return[l({find:g,type:this.type,getAttributes:e=>{let[,,n,o,c]=e;return{src:o,alt:n,title:c}}})]}});var d="__LEPTOS_TIPTAP_BRIDGE__";function _(){let e=globalThis,n=e[d];if(n!=null)return n;let o={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[d]=o,o}function p(){return _()}function u(e){p().registerExtension(e)}function m(e){return typeof e=="string"&&e.length>0?{"data-block-id":e,id:e}:{}}function x(e){return{src:e.src,alt:e.alt??void 0,title:e.title??void 0}}var k=a.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:e=>e.getAttribute("data-block-id"),renderHTML:e=>m(e.blockId)},"data-align":{default:null,parseHTML:e=>e.getAttribute("data-align"),renderHTML:e=>{let n=e["data-align"];return typeof n=="string"&&n.length>0?{"data-align":n}:{}}}}}}),b={name:"image",create:()=>k,commands:{set_image:(e,n)=>n.kind==="set_image"?e.chain().focus().setImage(x(n)).run():!1}};function pe(){u(b)}export{pe as register_image};
@@ -1 +1 @@
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var e=n.modules["@tiptap/core"];if(e==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var g=e.CommandManager,_=e.Editor,x=e.Extension,m=e.InputRule,k=e.Mark,i=e.Node,b=e.NodePos,y=e.NodeView,h=e.PasteRule,E=e.Tracker,f=e.callOrReturn,P=e.canInsertNode,R=e.combineTransactionSteps,C=e.createChainableState,T=e.createDocument,A=e.createNodeFromContent,S=e.createStyleTag,w=e.defaultBlockAt,N=e.deleteProps,O=e.elementFromString,B=e.escapeForRegEx,v=e.extensions,M=e.findChildren,I=e.findChildrenInRange,F=e.findDuplicates,D=e.findParentNode,j=e.findParentNodeClosestToPos,L=e.fromString,K=e.generateHTML,H=e.generateJSON,q=e.generateText,G=e.getAttributes,z=e.getAttributesFromExtensions,J=e.getChangedRanges,U=e.getDebugJSON,V=e.getExtensionField,Y=e.getHTMLFromFragment,Q=e.getMarkAttributes,W=e.getMarkRange,X=e.getMarkType,Z=e.getMarksBetween,$=e.getNodeAtPosition,ee=e.getNodeAttributes,te=e.getNodeType,ne=e.getRenderedAttributes,oe=e.getSchema,re=e.getSchemaByResolvedExtensions,ie=e.getSchemaTypeByName,se=e.getSchemaTypeNameByName,ae=e.getSplittedAttributes,le=e.getText,de=e.getTextBetween,pe=e.getTextContentFromNodes,ce=e.getTextSerializersFromSchema,ue=e.injectExtensionAttributesToParseRule,ge=e.inputRulesPlugin,_e=e.isActive,xe=e.isAtEndOfNode,me=e.isAtStartOfNode,ke=e.isEmptyObject,be=e.isExtensionRulesEnabled,ye=e.isFunction,he=e.isList,Ee=e.isMacOS,fe=e.isMarkActive,Pe=e.isNodeActive,Re=e.isNodeEmpty,Ce=e.isNodeSelection,Te=e.isNumber,Ae=e.isPlainObject,Se=e.isRegExp,we=e.isSafari,Ne=e.isString,Oe=e.isTextSelection,Be=e.isiOS,ve=e.markInputRule,Me=e.markPasteRule,s=e.mergeAttributes,Ie=e.mergeDeep,Fe=e.minMax,De=e.nodeInputRule,je=e.nodePasteRule,Le=e.objectIncludes,Ke=e.pasteRulesPlugin,He=e.posToDOMRect,qe=e.removeDuplicates,Ge=e.resolveFocusPosition,ze=e.rewriteUnknownContent,Je=e.selectionToInsertionEnd,Ue=e.splitExtensions,Ve=e.textInputRule,Ye=e.textPasteRule,Qe=e.textblockTypeInputRule,We=e.wrappingInputRule;var a=i.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:t}){return["p",s(this.options.HTMLAttributes,t),0]},addCommands(){return{setParagraph:()=>({commands:t})=>t.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var l="__LEPTOS_TIPTAP_BRIDGE__";function c(){let t=globalThis,o=t[l];if(o!=null)return o;let r={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return t[l]=r,r}function d(){return c()}function p(t){d().registerExtension(t)}var u={name:"paragraph",create:()=>a,commands:{set_paragraph:t=>t.chain().focus().setParagraph().run()},selection_keys:["paragraph"],selection_state:t=>({paragraph:t.isActive("paragraph")})};function it(){p(u)}export{it as register_paragraph};
var n=globalThis.__LEPTOS_TIPTAP_BRIDGE__;if(n==null||n.modules==null)throw new Error("leptos-tiptap bridge bindings are unavailable");var t=n.modules["@tiptap/core"];if(t==null)throw new Error('leptos-tiptap bridge module "@tiptap/core" is unavailable');var x=t.CommandManager,m=t.Editor,k=t.Extension,b=t.InputRule,y=t.Mark,i=t.Node,h=t.NodePos,E=t.NodeView,f=t.PasteRule,R=t.Tracker,P=t.callOrReturn,A=t.canInsertNode,T=t.combineTransactionSteps,C=t.createChainableState,S=t.createDocument,w=t.createNodeFromContent,N=t.createStyleTag,O=t.defaultBlockAt,B=t.deleteProps,M=t.elementFromString,v=t.escapeForRegEx,F=t.extensions,I=t.findChildren,D=t.findChildrenInRange,j=t.findDuplicates,L=t.findParentNode,H=t.findParentNodeClosestToPos,K=t.fromString,q=t.generateHTML,G=t.generateJSON,z=t.generateText,J=t.getAttributes,U=t.getAttributesFromExtensions,V=t.getChangedRanges,Y=t.getDebugJSON,Q=t.getExtensionField,W=t.getHTMLFromFragment,X=t.getMarkAttributes,Z=t.getMarkRange,$=t.getMarkType,tt=t.getMarksBetween,et=t.getNodeAtPosition,nt=t.getNodeAttributes,ot=t.getNodeType,rt=t.getRenderedAttributes,it=t.getSchema,st=t.getSchemaByResolvedExtensions,at=t.getSchemaTypeByName,lt=t.getSchemaTypeNameByName,dt=t.getSplittedAttributes,pt=t.getText,ct=t.getTextBetween,ut=t.getTextContentFromNodes,gt=t.getTextSerializersFromSchema,_t=t.injectExtensionAttributesToParseRule,xt=t.inputRulesPlugin,mt=t.isActive,kt=t.isAtEndOfNode,bt=t.isAtStartOfNode,yt=t.isEmptyObject,ht=t.isExtensionRulesEnabled,Et=t.isFunction,ft=t.isList,Rt=t.isMacOS,Pt=t.isMarkActive,At=t.isNodeActive,Tt=t.isNodeEmpty,Ct=t.isNodeSelection,St=t.isNumber,wt=t.isPlainObject,Nt=t.isRegExp,Ot=t.isSafari,Bt=t.isString,Mt=t.isTextSelection,vt=t.isiOS,Ft=t.markInputRule,It=t.markPasteRule,s=t.mergeAttributes,Dt=t.mergeDeep,jt=t.minMax,Lt=t.nodeInputRule,Ht=t.nodePasteRule,Kt=t.objectIncludes,qt=t.pasteRulesPlugin,Gt=t.posToDOMRect,zt=t.removeDuplicates,Jt=t.resolveFocusPosition,Ut=t.rewriteUnknownContent,Vt=t.selectionToInsertionEnd,Yt=t.splitExtensions,Qt=t.textInputRule,Wt=t.textPasteRule,Xt=t.textblockTypeInputRule,Zt=t.wrappingInputRule;var a=i.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:e}){return["p",s(this.options.HTMLAttributes,e),0]},addCommands(){return{setParagraph:()=>({commands:e})=>e.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var l="__LEPTOS_TIPTAP_BRIDGE__";function c(){let e=globalThis,o=e[l];if(o!=null)return o;let r={modules:{},registerExtension:()=>{throw new Error("leptos-tiptap bridge runtime is not initialized")}};return e[l]=r,r}function d(){return c()}function p(e){d().registerExtension(e)}function u(e){return typeof e=="string"&&e.length>0?{"data-block-id":e,id:e}:{}}var g=a.extend({addAttributes(){return{...this.parent?.(),blockId:{default:null,parseHTML:e=>e.getAttribute("data-block-id"),renderHTML:e=>u(e.blockId)}}}}),_={name:"paragraph",create:()=>g,commands:{set_paragraph:e=>e.chain().focus().setParagraph().run()},selection_keys:["paragraph"],selection_state:e=>({paragraph:e.isActive("paragraph")})};function ae(){p(_)}export{ae as register_paragraph};
@@ -1,4 +1,21 @@
export function write_mnote_text_to_clipboard(text) {
try {
if (navigator?.clipboard?.writeText) {
return navigator.clipboard.writeText(String(text)).then(() => true, () => false);
}
} catch (_error) {}
return Promise.resolve(false);
}
export function find_mnote_block_anchor(blockId) {
const wanted = String(blockId || '');
if (!wanted) return null;
const root = document.querySelector('.editor-surface .ProseMirror');
if (!root) return null;
return Array.from(root.querySelectorAll('[data-block-id]')).find((node) => node.getAttribute('data-block-id') === wanted) || null;
}
export function select_mnote_image_node(image) {
try {
const root = image?.closest?.('.ProseMirror');
@@ -56,3 +73,26 @@ export function download_mnote_selected_image() {
return false;
}
}
export function bump_mnote_e27_ai_bridge_request_count() {
try {
const key = '__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__';
window[key] = Number(window[key] || 0) + 1;
return window[key];
} catch (_error) {
return 0;
}
}
export async function post_mnote_document_save(body) {
const response = await fetch('/api/documents/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: String(body || '{}'),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`document save failed: HTTP ${response.status}; ${text.slice(0, 1200)}`);
}
return text || '{}';
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":9228011546279038255,"outputs":{"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":""},"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"}},"successes":{}}
{"rustc_fingerprint":9228011546279038255,"outputs":{"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":{}}
+54 -35
View File
@@ -1,19 +1,21 @@
#!/usr/bin/env node
/**
* 同时热启动前端FastAPI Celery
* 同时热启动前端FastAPI以及按需启用的 Celery
* 可使用以下环境变量调整行为
* - FRONTEND_CMD覆盖前端启动命令默认为 "pnpm dev"
* - FRONTEND_CMD覆盖历史 Next 启动命令仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
* - BACKEND_CMD覆盖 FastAPI 启动命令默认为 "python -m uvicorn app.main:app --reload --port 8000"
* - CELERY_CMD覆盖 Celery 启动命令默认为 "celery -A app.workers.celery_app worker --loglevel=info"
* - ENABLE_CELERY设为 "1" or "true" 时启用默认 Celery worker
* - CELERY_CMD覆盖 Celery 启动命令设置后即视为显式启用 Celery
* - CELERY_POOL只在 CELERY_CMD 未覆盖时生效设置 Celery worker poolWindows 默认 "solo"其他平台默认使用 Celery 自身默认值
* - PYTHON_BIN只在 BACKEND_CMD 未覆盖时设置 Python 可执行文件默认 "python"
* - CELERY_BIN只在 CELERY_CMD 未覆盖时设置 Celery 可执行文件默认 "celery"
* - REDIS_URL仅用于探测 Redis 是否就绪默认 "redis://localhost:6379/0"
* - SKIP_CELERY设为 "1" or "true" 可跳过 Celery
* - SKIP_CELERY设为 "1" or "true" 强制跳过 Celery
* - MNOTE_WEB_SKIP_GATEWAY设为 "1" or "true" 临时恢复旧 Next 3000 入口
* - NEXT_LEGACY_PORTRust gateway 模式下 Next legacy upstream 端口默认 3100
* - SKIP_NEXT_LEGACY设为 "1" or "true" 时只启动 Rust gateway不启动 Next legacy upstream
* - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT设为 "1" or "true" 时才启动 Next legacy upstream
* - NEXT_LEGACY_PORT显式启用 legacy compat 时的 Next upstream 端口默认 3100
* - SKIP_NEXT_LEGACY兼容旧环境变量设为 "1" or "true" 时强制只启动 Rust gateway
*/
const { spawn, execSync } = require("child_process");
@@ -55,29 +57,34 @@ function resolveBackendExecutable(envName, fallbackName) {
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
const celeryPoolFromEnv = (process.env.CELERY_POOL || "").trim();
const skipCelery =
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
const celeryCmdFromEnv = process.env.CELERY_CMD;
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000);
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 shouldStartCelery(env = process.env) {
if (isEnabledEnv(env.SKIP_CELERY)) return false;
if (String(env.CELERY_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_CELERY);
}
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 legacyCompatRequested =
!skipGateway &&
isEnabledEnv(env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT) &&
!isEnabledEnv(env.SKIP_NEXT_LEGACY);
const skipNextLegacy = !skipGateway && !legacyCompatRequested;
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";
const legacyCompatEnabled = legacyCompatRequested ? "1" : "0";
return {
skipGateway,
@@ -489,16 +496,18 @@ async function checkRedisReachable(urlString, timeoutMs = 2000) {
}
async function main() {
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。
// 这里在启动前做一次“安全清理”,避免重启后仍卡住
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
try {
if (fs.existsSync(nextDevLockPath)) {
fs.rmSync(nextDevLockPath, { force: true });
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
if (runtimePlan.frontendTaskName) {
// 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败
// 只有显式启动历史 Next 时才清理该 lock,避免默认热启动继续触碰旧前端目录。
const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock");
try {
if (fs.existsSync(nextDevLockPath)) {
fs.rmSync(nextDevLockPath, { force: true });
logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`);
}
} catch (error) {
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
}
} catch (error) {
logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`);
}
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
@@ -514,7 +523,7 @@ async function main() {
const frontendUrl = `http://localhost:${frontendPort}`;
let nextLegacyPort = null;
if (!skipMnoteWebGateway) {
if (runtimePlan.frontendTaskName === "next-legacy") {
nextLegacyPort = runtimePlan.legacyPort;
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
if (!nextLegacyPortOk) {
@@ -523,20 +532,25 @@ async function main() {
}
}
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
const frontendTask = skipMnoteWebGateway ? findTask("frontend") : findTask("next-legacy");
if (!frontendTask) {
throw new Error("缺少前端任务配置");
const frontendTask = runtimePlan.frontendTaskName ? findTask(runtimePlan.frontendTaskName) : null;
if (runtimePlan.frontendTaskName && !frontendTask) {
throw new Error(`缺少前端任务配置:${runtimePlan.frontendTaskName}`);
}
if (frontendTask) {
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
frontendTask.command = runtimePlan.frontendCommand;
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
}
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}`);
if (runtimePlan.frontendTaskName === "next-legacy") {
logPrefix("next-legacy", `Next legacy upstream${runtimePlan.legacyUrl}`);
} else {
logPrefix("next-legacy", "默认不启动历史 Next upstream;如需临时兼容请设置 MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=1。");
}
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
if (gatewayTask) {
gatewayTask.command = runtimePlan.mnoteWebCommand;
@@ -558,7 +572,7 @@ async function main() {
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
}
if (!skipCelery) {
if (shouldStartCelery(process.env)) {
const defaultCeleryCommand = buildDefaultCeleryCommand();
const celeryTask = {
name: "celery",
@@ -578,9 +592,13 @@ async function main() {
// Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。
logPrefix(
"celery",
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis 或设置 SKIP_CELERY=1 显式跳过`,
`检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis,或继续保持当前默认关闭策略`,
);
}
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1)。");
} else {
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
}
if (tasks.length === 0) {
@@ -607,5 +625,6 @@ module.exports = {
isPortFree,
resolveRuntimePlan,
resolveBackendExecutable,
shouldStartCelery,
terminatePid,
};
+29 -12
View File
@@ -7,6 +7,7 @@ const {
ensurePortFree,
isPortFree,
resolveRuntimePlan,
shouldStartCelery,
} = require("./desktop-hot.js");
function findFreePort() {
@@ -114,15 +115,34 @@ test("ensurePortFree 在非 Windows 平台能释放后端监听进程", async (t
await waitForExit(child);
});
test("默认热启动计划使用 mnote-web 作为 3000 ownerNext 仅作为 legacy upstream", () => {
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.skipNextLegacy, true);
assert.equal(plan.publicPort, 3000);
assert.equal(plan.legacyPort, 3100);
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",
});
});
test("显式开启 legacy compat 时才启动 Next legacy upstream", () => {
const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
});
assert.equal(plan.skipGateway, false);
assert.equal(plan.skipNextLegacy, false);
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");
@@ -134,17 +154,6 @@ test("默认热启动计划使用 mnote-web 作为 3000 ownerNext 仅作为 l
});
});
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",
@@ -176,3 +185,11 @@ test("SKIP_NEXT_LEGACY 保持 Rust gateway 为 3000 owner,但不启动 Next le
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "0",
});
});
test("默认跳过 Celery,只有显式开启时才启动", () => {
assert.equal(shouldStartCelery({}), false);
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1" }), true);
assert.equal(shouldStartCelery({ ENABLE_CELERY: "true" }), true);
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
});
+30 -11
View File
@@ -13,7 +13,9 @@
* - INGEST_PORT默认 8779
* - PYTHON_BIN默认 python
* - CELERY_BIN默认 celery
* - SKIP_CELERY=1跳过 Celery worker
* - ENABLE_CELERY=1启用默认 Celery worker
* - CELERY_CMD覆盖 Celery 启动命令设置后即视为显式启用 Celery
* - SKIP_CELERY=1强制跳过 Celery worker
* - REDIS_URL用于探测 Redis默认 redis://127.0.0.1:6379/0
*/
@@ -57,9 +59,7 @@ function resolveBackendExecutable(envName, fallbackName) {
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery");
const skipCelery =
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0";
const frontendPort = Number(process.env.FRONTEND_PORT || 3000);
@@ -74,6 +74,17 @@ function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
function shouldStartCelery(env = process.env) {
if (isEnabledEnv(env.SKIP_CELERY)) return false;
if (String(env.CELERY_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_CELERY);
}
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, "utf8");
@@ -299,20 +310,22 @@ async function main() {
});
// Celery(可选)
if (!skipCelery) {
if (shouldStartCelery(process.env)) {
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
if (ok) {
spawnTask({
name: "celery",
command: `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
cwd: backendDir,
env: envBackend,
});
} else {
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
}
} else {
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1");
} else {
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
}
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
@@ -337,7 +350,13 @@ async function main() {
);
}
main().catch((error) => {
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
if (require.main === module) {
main().catch((error) => {
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
}
module.exports = {
shouldStartCelery,
};
+12
View File
@@ -0,0 +1,12 @@
const assert = require("node:assert");
const { test } = require("node:test");
const { shouldStartCelery } = require("./desktop-prod.js");
test("desktop-prod 默认跳过 Celery,只有显式开启时才启动", () => {
assert.equal(shouldStartCelery({}), false);
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1" }), true);
assert.equal(shouldStartCelery({ ENABLE_CELERY: "true" }), true);
assert.equal(shouldStartCelery({ CELERY_CMD: "custom-celery" }), true);
assert.equal(shouldStartCelery({ ENABLE_CELERY: "1", SKIP_CELERY: "1" }), false);
});
@@ -110,18 +110,28 @@ async function validateGateway(baseUrl) {
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"/);
}
assert.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应再代理 3100");
assert.match(authText, /data-mnote-shell="auth"/);
assert.match(authText, /邮箱登录/);
assert.match(authText, /测试账号快速登录/);
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
const root = await fetchWithTimeout(`${baseUrl}/`);
const rootText = await root.text();
assert.equal(root.status, 200, `/ 失败: ${root.status}`);
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 120)}`);
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(rootText, /data-mnote-shell="workspace"/);
assert.equal(root.headers.get("location"), "/auth");
const authedRoot = await fetchWithTimeout(`${baseUrl}/`, {
headers: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const authedRootText = await authedRoot.text();
assert.equal(authedRoot.status, 200, `/ 已登录入口失败: ${authedRoot.status}`);
assert.equal(authedRoot.headers.get("x-mnote-web-owner"), "mnote-web");
assert.match(authedRootText, /data-mnote-shell="workspace"/);
}
async function main() {
+16 -5
View File
@@ -91,6 +91,17 @@ function startRetiredNextGateway(port) {
});
}
function authedInit(init = {}) {
return {
...init,
headers: {
...(init.headers || {}),
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
};
}
async function readText(baseUrl, path, init) {
const response = await fetchWithTimeout(`${baseUrl}${path}`, init);
const text = await response.text();
@@ -121,7 +132,7 @@ async function validateSkipNextRuntimePlan() {
}
async function validateCoreShellsWithoutNext(baseUrl) {
const root = await readText(baseUrl, "/?workspaceId=ws_demo");
const root = await readText(baseUrl, "/?workspaceId=ws_demo", authedInit());
assert.match(root.text, /data-mnote-shell="workspace"/);
assert.doesNotMatch(root.text, /next-app-router/i);
@@ -129,22 +140,22 @@ async function validateCoreShellsWithoutNext(baseUrl) {
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");
const document = await readText(baseUrl, "/documents/doc_1?workspaceId=ws_demo", authedInit());
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 treeEvents = await fetchWithTimeout(`${baseUrl}/api/tree/events?workspaceId=ws_demo&maxPolls=0`, authedInit());
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");
const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust", authedInit());
assert.equal(search.response.headers.get("x-mnote-web-shell"), "search");
assert.match(search.text, /mnote\.search_shell\.v1/);
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1");
const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1", authedInit());
assert.equal(mindmap.response.headers.get("x-mnote-web-shell"), "mindmap");
assert.match(mindmap.text, /mnote\.mindmap_shell\.v1/);
assert.doesNotMatch(mindmap.text, /next-app-router/i);
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
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(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task154-e26-anchor-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const CONVERTER_SOURCE = "/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.ts";
const TARGET_BLOCK_ID = "e26-anchor-target";
const SECOND_BLOCK_ID = "e26-anchor-second";
function assertAnchorSourceBoundary() {
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(!spike.includes('Some(format!("top-level-{index}"))'), "E26 不能继续把 top-level 序号当块锚点 id");
assert(spike.includes("data-block-id"), "E26 runtime DOM 必须暴露 data-block-id");
assert(spike.includes("navigator.clipboard.writeText"), "E26 复制链接必须真实写入剪贴板");
assert(spike.includes("scroll_mnote_block_anchor_from_hash"), "E26 必须处理 reload/hash 定位");
const converter = fs.readFileSync(CONVERTER_SOURCE, "utf8");
assert(converter.includes('const BLOCK_ID_ATTR = "blockId"'), "TS 转换层必须继续以 Rust blockId 作为 Tiptap attrs 真源");
assert(converter.includes("editorBlockDocumentFromTiptapDoc"), "保存链必须继续回到 EditorBlockDocument");
}
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 postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
assert(payload?.result, `${label} 缺少 result`);
return payload.result;
}
async function createTempDocument() {
const title = `task154-e26-anchor-${Date.now().toString(36)}`;
const result = await postTreeCommand({ action: "create", title }, "创建 E26 临时文档");
assert(result.documentId, "创建 E26 临时文档缺少 documentId");
assert(result.workspaceId, "创建 E26 临时文档缺少 workspaceId");
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempDocument(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E26 临时文档");
}
async function loadDocumentContent(target, label) {
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await fetchWithTimeout(url, { method: "GET" });
return readJsonResponse(response, label);
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
}, null, { timeout: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
function collectBlockIds(value, ids = []) {
if (!value || typeof value !== "object") return ids;
if (Array.isArray(value)) {
for (const item of value) collectBlockIds(item, ids);
return ids;
}
if (typeof value.blockId === "string") ids.push(value.blockId);
if (typeof value.id === "string") ids.push(value.id);
if (value.attrs && typeof value.attrs.blockId === "string") ids.push(value.attrs.blockId);
collectBlockIds(value.content, ids);
collectBlockIds(value.children, ids);
collectBlockIds(value.blocks, ids);
collectBlockIds(value.editorDocument, ids);
return ids;
}
async function setAnchorFixture(page) {
await page.evaluate(({ targetBlockId, secondBlockId }) => {
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
if (!editor) throw new Error('找不到 Tiptap editor');
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { blockId: targetBlockId },
content: [{ type: 'text', text: 'E26 anchor target paragraph' }],
},
{
type: 'heading',
attrs: { blockId: secondBlockId, level: 2 },
content: [{ type: 'text', text: 'E26 second heading' }],
},
],
}, true);
editor.commands.focus('start');
}, { targetBlockId: TARGET_BLOCK_ID, secondBlockId: SECOND_BLOCK_ID });
try {
await page.waitForFunction(({ targetBlockId, secondBlockId }) => {
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement
&& document.querySelector(`[data-block-id="${secondBlockId}"]`) instanceof HTMLElement;
}, { targetBlockId: TARGET_BLOCK_ID, secondBlockId: SECOND_BLOCK_ID }, { timeout: UI_TIMEOUT_MS });
} catch (error) {
const diagnostics = await page.evaluate(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editor = document.querySelector('.editor-surface .ProseMirror');
return {
status: host?.getAttribute('data-runtime-editor-status') || null,
html: editor?.innerHTML || null,
json: editor?.editor?.getJSON?.() || null,
};
}).catch((evalError) => ({ evalError: String(evalError) }));
throw new Error(`E26 fixture 未渲染 data-block-id: ${JSON.stringify(diagnostics).slice(0, 2400)}; cause=${error.message}`);
}
}
async function waitForSavedDocument(page, expectedBlockIds) {
await page.waitForFunction(({ expectedBlockIds }) => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
if (host?.getAttribute('data-runtime-editor-status') !== 'saved') return false;
const editor = host?.querySelector('.editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) return false;
return expectedBlockIds.every((blockId) => editor.querySelector(`[data-block-id="${blockId}"]`) instanceof HTMLElement);
}, { expectedBlockIds }, { timeout: UI_TIMEOUT_MS });
}
async function openBlockMenuForTarget(page) {
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await target.hover({ timeout: UI_TIMEOUT_MS });
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await handle.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
async function main() {
assertAnchorSourceBoundary();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, permissions: ["clipboard-read", "clipboard-write"] });
const page = await context.newPage();
const saveRequests = [];
page.on("request", (request) => {
if (!request.url().includes("/api/documents/save")) return;
const body = request.postData();
if (!body) return;
try {
saveRequests.push(JSON.parse(body));
} catch {
saveRequests.push({ raw: body });
}
});
let target = null;
try {
target = await createTempDocument();
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
await waitForRuntimeIsland(page);
await setAnchorFixture(page);
await waitForSavedDocument(page, [TARGET_BLOCK_ID, SECOND_BLOCK_ID]);
await screenshot(page, "01-anchor-blocks-with-data-block-id");
const persistedByRequest = [...saveRequests].reverse().find((request) => collectBlockIds(request).includes(TARGET_BLOCK_ID));
assert(persistedByRequest, `保存请求必须包含 Rust blockId 派生的 EditorBlockDocument/TiptapDocument: ${JSON.stringify(saveRequests.slice(-3)).slice(0, 2000)}`);
const contentAfterSave = await loadDocumentContent(target, "读取 E26 保存后的正文");
assert(collectBlockIds(contentAfterSave?.result ?? contentAfterSave).includes(TARGET_BLOCK_ID), `/api/documents/content 必须保留目标 blockId: ${JSON.stringify(contentAfterSave).slice(0, 1600)}`);
const menu = await openBlockMenuForTarget(page);
await screenshot(page, "02-block-menu-copy-link-entry");
await menu.locator('[data-testid="block-drag-menu-item-copy-link"]').first().click({ timeout: UI_TIMEOUT_MS });
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
assert(clipboardText.includes(`/documents/${target.documentId}`), `复制链接必须指向当前页面: ${clipboardText}`);
assert(clipboardText.endsWith(`#${TARGET_BLOCK_ID}`), `复制链接必须使用 Rust blockId 作为 hash: ${clipboardText}`);
assert(!clipboardText.includes("top-level-"), `复制链接不能使用前端序号 id: ${clipboardText}`);
const hashUrl = new URL(clipboardText);
assert.equal(hashUrl.hash, `#${TARGET_BLOCK_ID}`, `复制链接 hash 异常: ${clipboardText}`);
await page.goto(hashUrl.href, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
await page.waitForFunction((targetBlockId) => {
const block = document.querySelector(`[data-block-id="${targetBlockId}"]`);
if (!(block instanceof HTMLElement)) return false;
const rect = block.getBoundingClientRect();
const anchorMatched = block.id === targetBlockId && block.matches(":target");
const highlighted = anchorMatched
|| block.getAttribute("data-anchor-highlight") === "true"
|| block.classList.contains("mnote-block-anchor-highlight");
return rect.top >= 0 && rect.top < window.innerHeight * 0.75 && highlighted;
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "03-after-hash-reload-anchor-highlight");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, blockId: TARGET_BLOCK_ID, clipboardText, screenshotDir: SCREENSHOT_DIR }, null, 2));
} finally {
if (target) await purgeTempDocument(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.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);
});
}
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
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(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task155-e27-ai-edit-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const TARGET_BLOCK_ID = "e27-ai-target";
function assertAiSourceBoundary() {
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
}
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 postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
assert(payload?.result, `${label} 缺少 result`);
return payload.result;
}
async function createTempDocument() {
const title = `task155-e27-ai-${Date.now().toString(36)}`;
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempDocument(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
}, null, { timeout: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setAiFixture(page) {
await page.evaluate((targetBlockId) => {
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
if (!editor) throw new Error('找不到 Tiptap editor');
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { blockId: targetBlockId },
content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }],
},
],
}, true);
editor.commands.focus('start');
}, TARGET_BLOCK_ID);
await page.waitForFunction((targetBlockId) => {
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
}
async function openBlockMenuForTarget(page) {
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await target.hover({ timeout: UI_TIMEOUT_MS });
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await handle.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
function assertAiBridgePayload(payload, target) {
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
}
async function main() {
assertAiSourceBoundary();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const aiBridgeRequests = [];
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
const request = route.request();
const body = request.postData() || "{}";
let payload = null;
try {
payload = JSON.parse(body);
} catch {
payload = { raw: body };
}
aiBridgeRequests.push({ url: request.url(), payload });
if (request.url().includes("/api/ai-agent/run")) {
await route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
},
body: [
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
'event: tool_result\n' + 'data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"e27-ai-target","type":"paragraph","content":"E27 AI rewritten paragraph"}]}}\n\n',
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
].join(""),
});
return;
}
await route.fulfill({
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"x-mnote-ai-bridge-owner": "rust-web-hermes",
},
body: JSON.stringify({
ok: true,
bridge: "e27-smoke-hermes-bridge",
canonicalRoute: "/api/hermes/bridge",
contract: {
schema: "mnote.ai_bridge.v1",
structuredWriteOwner: "rust-web-hermes",
},
}),
});
});
let target = null;
try {
target = await createTempDocument();
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
await waitForRuntimeIsland(page);
await setAiFixture(page);
const menu = await openBlockMenuForTarget(page);
await screenshot(page, "01-block-menu-ai-entry");
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
const first = aiBridgeRequests[0];
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
assertAiBridgePayload(first.payload, target);
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
}, null, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "02-ai-bridge-ready-state");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url }, null, 2));
} finally {
if (target) await purgeTempDocument(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.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);
});
}
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
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(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task156-e27-ai-writeback-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const TARGET_BLOCK_ID = "e27-ai-target";
const AI_REWRITTEN_TEXT = "E27 AI rewritten paragraph";
function assertAiSourceBoundary() {
const spike = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(spike.includes("/api/ai-agent/run"), "E27 AI 入口必须调用 /api/ai-agent/run 在线主路径,Hermes 只能作为后端 fallback");
assert(spike.includes('"provider": "online"'), "E27 AI 请求必须使用 provider=online 进入 openai-agents-python 主路径");
assert(spike.includes('"stream": true'), "E27 AI 请求必须开启 stream=true,确保 /api/ai-agent/run 路由进入 agents sidecar");
assert(spike.includes("mnote-leptos-tiptap-ai-status"), "E27 必须暴露 AI bridge 状态,供 smoke 和后续流式状态复用");
assert(spike.includes("selectedBlockId"), "E27 AI 请求必须携带 Rust blockId 作为 selectedBlockId");
assert(spike.includes("tiptapDocument"), "E27 AI 请求必须携带当前 Tiptap JSON 快照");
}
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 postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
assert(payload?.result, `${label} 缺少 result`);
return payload.result;
}
async function createTempDocument() {
const title = `task156-e27-ai-writeback-${Date.now().toString(36)}`;
const result = await postTreeCommand({ action: "create", title }, "创建 E27 临时文档");
assert(result.documentId, "创建 E27 临时文档缺少 documentId");
assert(result.workspaceId, "创建 E27 临时文档缺少 workspaceId");
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempDocument(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E27 临时文档");
}
async function loadDocumentContent(target, label) {
const url = `${BASE_URL}/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await fetchWithTimeout(url, { method: "GET" });
return readJsonResponse(response, label);
}
function rawIncludes(value, text) {
return JSON.stringify(value ?? null).includes(text);
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
}, null, { timeout: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setAiFixture(page) {
await page.evaluate((targetBlockId) => {
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
if (!editor) throw new Error('找不到 Tiptap editor');
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { blockId: targetBlockId },
content: [{ type: 'text', text: 'E27 Ask AI source paragraph' }],
},
],
}, true);
editor.commands.focus('start');
}, TARGET_BLOCK_ID);
await page.waitForFunction((targetBlockId) => {
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement;
}, TARGET_BLOCK_ID, { timeout: UI_TIMEOUT_MS });
}
async function openBlockMenuForTarget(page) {
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
await target.hover({ timeout: UI_TIMEOUT_MS });
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await handle.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
function assertAiBridgePayload(payload, target) {
assert.equal(payload.scope, "document", `AI 请求 scope 必须是 document: ${JSON.stringify(payload).slice(0, 1600)}`);
assert.equal(payload.stream, true, "AI 请求必须开启 stream=true,避免退回 Hermes 非流式兼容路径");
assert.equal(payload.options?.ai?.provider, "online", "AI 请求必须使用 provider=online,由 /api/ai-agent/run 分流到 agents sidecar");
assert.equal(payload.context?.documentId, target.documentId, "AI 请求必须携带当前 documentId");
assert.equal(payload.context?.workspaceId, target.workspaceId, "AI 请求必须携带当前 workspaceId");
assert.equal(payload.context?.selectedBlockId, TARGET_BLOCK_ID, "AI 请求必须携带 Rust blockId");
assert(Array.isArray(payload.context?.selectedUids) && payload.context.selectedUids.includes(TARGET_BLOCK_ID), "AI 请求 selectedUids 必须包含 Rust blockId");
assert(payload.context?.selection?.currentBlockId === TARGET_BLOCK_ID, "AI 请求 selection 必须指向当前块");
assert(payload.context?.tiptapDocument?.type === "doc", "AI 请求必须携带 tiptapDocument 快照");
assert(JSON.stringify(payload.context.tiptapDocument).includes("E27 Ask AI source paragraph"), "AI 请求快照必须包含当前块正文");
assert.equal(payload.context?.source, "leptos-tiptap-island", "AI 请求必须标记来源 island");
assert.equal(payload.context?.action, "ask_ai", "块菜单 AI 入口必须使用 ask_ai action");
}
async function main() {
assertAiSourceBoundary();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const aiBridgeRequests = [];
const saveRequests = [];
await page.addInitScript(() => {
window.__MNOTE_E27_SAVE_REQUESTS__ = [];
});
page.on("request", async (request) => {
if (!request.url().includes("/api/documents/save")) return;
const body = request.postData();
if (!body) return;
let payload;
try {
payload = JSON.parse(body);
} catch {
payload = { raw: body };
}
saveRequests.push(payload);
await page.evaluate((item) => {
window.__MNOTE_E27_SAVE_REQUESTS__ = Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__) ? window.__MNOTE_E27_SAVE_REQUESTS__ : [];
window.__MNOTE_E27_SAVE_REQUESTS__.push(item);
}, payload).catch(() => undefined);
});
await page.route(/\/api\/(hermes\/bridge|ai-agent\/run)$/, async (route) => {
const request = route.request();
const body = request.postData() || "{}";
let payload = null;
try {
payload = JSON.parse(body);
} catch {
payload = { raw: body };
}
aiBridgeRequests.push({ url: request.url(), payload });
if (request.url().includes("/api/ai-agent/run")) {
await route.fulfill({
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
},
body: [
'event: ready\n' + 'data: {"ok":true,"requestId":"task155-e27"}\n\n',
'event: tool_result\n' + `data: {"tool":"doc_replace_range","ok":true,"result":{"data":[{"id":"${TARGET_BLOCK_ID}","type":"paragraph","content":"${AI_REWRITTEN_TEXT}"}]}}\n\n`,
'event: completion\n' + 'data: {"ok":true,"text":"done","steps":1}\n\n',
].join(""),
});
return;
}
await route.fulfill({
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"x-mnote-ai-bridge-owner": "rust-web-hermes",
},
body: JSON.stringify({
ok: true,
bridge: "e27-smoke-hermes-bridge",
canonicalRoute: "/api/hermes/bridge",
contract: {
schema: "mnote.ai_bridge.v1",
structuredWriteOwner: "rust-web-hermes",
},
}),
});
});
let target = null;
try {
target = await createTempDocument();
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
await waitForRuntimeIsland(page);
await setAiFixture(page);
const menu = await openBlockMenuForTarget(page);
await screenshot(page, "01-block-menu-ai-entry");
await menu.locator('[data-testid="block-drag-menu-item-ai"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && ["pending", "ready"].includes(status.getAttribute("data-state") || "");
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => window.__MNOTE_E27_AI_BRIDGE_REQUEST_COUNT__ > 0, null, { timeout: UI_TIMEOUT_MS });
assert(aiBridgeRequests.length > 0, "点击 AI 入口后必须发起 /api/ai-agent/run 请求");
const first = aiBridgeRequests[0];
assert(first.url.includes("/api/ai-agent/run"), `AI 请求必须走 /api/ai-agent/run 在线主路径,实际: ${first.url}`);
assertAiBridgePayload(first.payload, target);
await page.waitForFunction(() => {
const status = document.querySelector('[data-testid="mnote-leptos-tiptap-ai-status"]');
return status instanceof HTMLElement && status.getAttribute("data-state") === "ready";
}, null, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((expectedText) => {
const editor = document.querySelector('.editor-surface .ProseMirror');
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
await page.waitForFunction((expectedText) => {
return Array.isArray(window.__MNOTE_E27_SAVE_REQUESTS__)
&& window.__MNOTE_E27_SAVE_REQUESTS__.some((request) => JSON.stringify(request ?? null).includes(expectedText));
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "02-ai-writeback-editor-saved");
const saveHit = saveRequests.some((request) => rawIncludes(request, AI_REWRITTEN_TEXT));
assert(saveHit, `AI 写入必须触发 /api/documents/save 且保存 payload 包含改写正文: ${JSON.stringify(saveRequests.slice(-4)).slice(0, 2400)}`);
const contentAfterWrite = await loadDocumentContent(target, "读取 E27 AI 写入后的正文");
assert(rawIncludes(contentAfterWrite, AI_REWRITTEN_TEXT), `/api/documents/content 必须能读回 AI 写入正文: ${JSON.stringify(contentAfterWrite).slice(0, 2400)}`);
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
await page.waitForFunction((expectedText) => {
const editor = document.querySelector('.editor-surface .ProseMirror');
return editor instanceof HTMLElement && editor.innerText.includes(expectedText);
}, AI_REWRITTEN_TEXT, { timeout: UI_TIMEOUT_MS });
await screenshot(page, "03-ai-writeback-reload-readback");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR, aiBridgeUrl: first.url, wroteText: AI_REWRITTEN_TEXT }, null, 2));
} finally {
if (target) await purgeTempDocument(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.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);
});
}
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
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(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const SCREENSHOT_DIR = process.env.MNOTE_PHASE_E_SCREENSHOT_DIR || "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task158-e30-menu-state-local-smoke";
const LEPTOS_SPIKE_SOURCE = "/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/src/lib.rs";
const IMAGE_SRC = "/api/editor/image-placeholder.svg";
const TARGET_BLOCK_ID = "e30-menu-state-target";
function assertMenuStateSourceBoundary() {
const source = fs.readFileSync(LEPTOS_SPIKE_SOURCE, "utf8");
assert(
source.includes("close_editor_floating_overlays"),
"E30 必须把菜单/浮层关闭逻辑收口到 close_editor_floating_overlays,而不是每个菜单散写 set(false)",
);
assert(
source.includes("close_editor_floating_overlays_if_escape"),
"E30 Escape 必须通过统一函数关闭 slash/block/toolbar/image/table 浮层",
);
assert(
source.includes("open_slash_menu_overlay"),
"E30 slash 打开必须走统一互斥入口,避免和其他浮层叠开",
);
assert(
source.includes("open_image_toolbar_overlay"),
"E30 image toolbar 打开必须走统一互斥入口",
);
assert(
source.includes("open_block_menu_overlay"),
"E30 block menu 打开必须走统一互斥入口",
);
}
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 postTreeCommand(body, label) {
const response = await fetchWithTimeout(`${BASE_URL}/api/tree/commands`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const payload = await readJsonResponse(response, label);
assert(payload?.result, `${label} 缺少 result`);
return payload.result;
}
async function createTempDocument() {
const title = `task158-e30-menu-state-${Date.now().toString(36)}`;
const result = await postTreeCommand({ action: "create", title }, "创建 E30 临时文档");
assert(result.documentId, "创建 E30 临时文档缺少 documentId");
assert(result.workspaceId, "创建 E30 临时文档缺少 workspaceId");
return { documentId: result.documentId, workspaceId: result.workspaceId, title };
}
async function purgeTempDocument(target) {
if (!target?.documentId || !target?.workspaceId) return;
await postTreeCommand({ action: "purge", workspaceId: target.workspaceId, documentId: target.documentId }, "清理 E30 临时文档");
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').first();
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const editor = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]').first();
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const editorNode = host?.querySelector('.editor-surface .ProseMirror[contenteditable="true"]');
return host?.getAttribute("data-runtime-editor-status") !== "error" && editorNode instanceof HTMLElement && editorNode.isContentEditable;
}, null, { timeout: UI_TIMEOUT_MS });
return editor;
}
async function screenshot(page, name) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png`, fullPage: true });
}
async function setMenuStateFixture(page) {
await page.evaluate(({ targetBlockId, imageSrc }) => {
const editor = document.querySelector('.editor-surface .ProseMirror')?.editor;
if (!editor) throw new Error('找不到 Tiptap editor');
editor.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
attrs: { blockId: targetBlockId },
content: [{ type: 'text', text: 'E30 menu state target paragraph' }],
},
{
type: 'paragraph',
attrs: { blockId: 'e30-selection-target' },
content: [{ type: 'text', text: 'E30 selection toolbar target text' }],
},
{
type: 'image',
attrs: { src: imageSrc, alt: 'E30 图片占位', title: 'E30 图片', 'data-align': 'left' },
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] }] },
],
},
{
type: 'tableRow',
content: [
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] }] },
{ type: 'tableCell', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] }] },
],
},
],
},
],
}, true);
editor.commands.focus('start');
}, { targetBlockId: TARGET_BLOCK_ID, imageSrc: IMAGE_SRC });
await page.waitForFunction(({ targetBlockId, imageSrc }) => {
return document.querySelector(`[data-block-id="${targetBlockId}"]`) instanceof HTMLElement
&& document.querySelector(`.editor-surface .ProseMirror img[src="${imageSrc}"]`) instanceof HTMLImageElement
&& document.querySelector('.editor-surface .ProseMirror table') instanceof HTMLTableElement;
}, { targetBlockId: TARGET_BLOCK_ID, imageSrc: IMAGE_SRC }, { timeout: UI_TIMEOUT_MS });
}
async function openBlockMenuForTarget(page) {
const target = page.locator(`[data-block-id="${TARGET_BLOCK_ID}"]`).first();
await target.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
const targetBox = await target.boundingBox();
assert(targetBox, "E30 目标块缺少可 hover 区域");
await page.mouse.move(targetBox.x + 8, targetBox.y + Math.min(10, Math.max(4, targetBox.height / 2)));
const handle = page.locator('[data-testid="block-drag-handle-trigger"]').first();
await handle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await handle.click({ timeout: UI_TIMEOUT_MS });
const menu = page.locator('[data-testid="block-drag-menu"]').first();
await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return menu;
}
async function openSelectionToolbar(page) {
await page.evaluate(() => {
const textNode = Array.from(document.querySelectorAll('.editor-surface .ProseMirror p'))
.find((node) => (node.textContent || '').includes('E30 selection toolbar target text'))
?.firstChild;
if (!textNode) throw new Error('找不到 E30 选区文本节点');
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(12, textNode.textContent.length));
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await page.mouse.up();
const toolbar = page.locator('[data-testid="mnote-leptos-tiptap-toolbar"]').first();
await toolbar.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return toolbar;
}
async function assertNoFloatingOverlay(page, label) {
const state = await page.evaluate(() => ({
slash: Boolean(document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')),
block: Boolean(document.querySelector('[data-testid="block-drag-menu"]')),
toolbar: Boolean(document.querySelector('[data-testid="mnote-leptos-tiptap-toolbar"]')),
turnInto: Boolean(document.querySelector('[data-testid="turn-into-panel"]')) && getComputedStyle(document.querySelector('[data-testid="turn-into-panel"]')).display !== 'none',
color: Boolean(document.querySelector('[data-testid="toolbar-color-panel"]')) && getComputedStyle(document.querySelector('[data-testid="toolbar-color-panel"]')).display !== 'none',
more: Boolean(document.querySelector('[data-testid="toolbar-more-panel"]')) && getComputedStyle(document.querySelector('[data-testid="toolbar-more-panel"]')).display !== 'none',
image: Boolean(document.querySelector('[data-testid="image-floating-toolbar"]')),
tableToolbar: Boolean(document.querySelector('[data-testid="mnote-leptos-tiptap-table-toolbar"]')),
tableOptions: Boolean(document.querySelector('[data-testid="table-toolbar-options-menu"]')),
}));
assert.deepEqual(state, {
slash: false,
block: false,
toolbar: false,
turnInto: false,
color: false,
more: false,
image: false,
tableToolbar: false,
tableOptions: false,
}, `${label} 后仍有浮层残留: ${JSON.stringify(state)}`);
}
async function main() {
assertMenuStateSourceBoundary();
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
let target = null;
try {
target = await createTempDocument();
const url = `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "文档页没有返回响应");
assert.equal(response.status(), 200, `文档页状态码异常: ${response.status()}`);
const editor = await waitForRuntimeIsland(page);
await setMenuStateFixture(page);
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
await page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.keyboard.press("ArrowDown");
const selectedAfterArrow = await page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"] .slash-item[data-active="true"]').first().innerText();
assert(selectedAfterArrow.trim().length > 0, "slash menu 方向键后必须有 active 项");
await screenshot(page, "01-slash-arrow-active");
await page.keyboard.press("Escape");
await assertNoFloatingOverlay(page, "Slash Escape");
await openBlockMenuForTarget(page);
await page.locator('[data-testid="block-drag-menu-item-turn-into"]').first().hover({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="block-turn-into-menu"], [data-e30-testid="block-turn-into-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await screenshot(page, "02-block-submenu-open");
await page.keyboard.press("Escape");
await assertNoFloatingOverlay(page, "Block menu Escape");
const toolbar = await openSelectionToolbar(page);
await toolbar.locator('[data-testid="toolbar-color"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="toolbar-color-panel"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await screenshot(page, "03-color-panel-open");
await page.keyboard.press("Escape");
await assertNoFloatingOverlay(page, "Selection color Escape");
const image = page.locator('.editor-surface .ProseMirror img[src]').first();
await image.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="image-floating-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await screenshot(page, "04-image-toolbar-open");
await page.keyboard.press("Escape");
await assertNoFloatingOverlay(page, "Image toolbar Escape");
await page.locator('.editor-surface .ProseMirror table td, .editor-surface .ProseMirror table th').first().click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="mnote-leptos-tiptap-table-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="table-toolbar-options"]').first().click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="table-toolbar-options-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await screenshot(page, "05-table-options-open");
await page.keyboard.press("Escape");
await assertNoFloatingOverlay(page, "Table options Escape");
await openSelectionToolbar(page);
await page.locator('[data-testid="mnote-leptos-tiptap-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await editor.click({ timeout: UI_TIMEOUT_MS });
await page.keyboard.type("/");
await page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.equal(await page.locator('[data-testid="mnote-leptos-tiptap-toolbar"]').count(), 0, "打开 slash 时 selection toolbar 必须关闭");
await page.keyboard.press("Escape");
await image.click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-testid="image-floating-toolbar"]').first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await openBlockMenuForTarget(page);
assert.equal(await page.locator('[data-testid="image-floating-toolbar"]').count(), 0, "打开块菜单时 image toolbar 必须关闭");
await screenshot(page, "06-block-menu-after-image-mutual-exclusion");
console.log(JSON.stringify({ ok: true, baseUrl: BASE_URL, documentId: target.documentId, screenshotDir: SCREENSHOT_DIR }, null, 2));
} finally {
if (target) await purgeTempDocument(target).catch(() => undefined);
await page.close().catch(() => undefined);
await context.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);
});
}
+134
View File
@@ -0,0 +1,134 @@
#!/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");
function buildFixtureEnv(port) {
return {
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_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
"documents:getMeta": {
id: "doc_auth",
workspace_id: "ws_demo",
title: "认证验收页面",
updated_at: "2026-05-04T00:00:00Z",
can_edit: true,
word_count: 3,
character_count: 12,
block_count: 1,
},
"documents:getContent": {
content: [{ id: "block_auth_1", type: "paragraph", content: [] }],
revision: 1,
conflict_detection_key: "doc_auth:1",
pageSubtree: { rootNodeId: "doc_auth", outline: [] },
},
}),
MNOTE_WEB_MUTATION_FIXTURES_JSON: JSON.stringify({
"workspaces:ensureDefaultWorkspace": {
workspaces: [{ id: "ws_demo", name: "我的空间", type: "personal", memberCount: 1, isDefault: true }],
activeWorkspaceId: "ws_demo",
},
}),
};
}
function startGateway(port) {
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: "/mnt/Data1T/mnote/rust",
env: { ...process.env, ...buildFixtureEnv(port) },
stdio: ["ignore", "pipe", "pipe"],
});
}
async function validateAuthEntry(baseUrl) {
const root = await fetchWithTimeout(`${baseUrl}/`);
const rootText = await root.text();
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 160)}`);
assert.equal(root.headers.get("location"), "/auth");
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
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.equal(auth.headers.get("x-mnote-legacy-upstream"), null, "/auth 不应代理到 3100");
assert.match(authText, /data-mnote-shell="auth"/);
assert.match(authText, /data-testid="mnote-auth-page"/);
assert.match(authText, /邮箱登录/);
assert.match(authText, /name="email"[^>]*type="email"|type="email"[^>]*name="email"/);
assert.match(authText, /name="password"[^>]*type="password"|type="password"[^>]*name="password"/);
assert.match(authText, /data-auth-mode="convex-password"/);
assert.match(authText, /没有账号?注册/);
assert.match(authText, /测试账号快速登录/);
assert.doesNotMatch(authText, /隐私政策|使用\s*Google|使用\s*GitHub|第三方快捷/iu);
const authedAuth = await fetchWithTimeout(`${baseUrl}/auth`, {
headers: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
assert.equal(authedAuth.status, 303, `/auth 已登录应跳转 /: ${authedAuth.status}`);
assert.equal(authedAuth.headers.get("location"), "/");
const authedRoot = await fetchWithTimeout(`${baseUrl}/`, {
headers: {
"x-mnote-actor-id": "user_real",
"x-mnote-actor-type": "user",
},
});
const authedRootText = await authedRoot.text();
assert.equal(authedRoot.status, 200, `/ 已登录根入口失败: ${authedRoot.status}`);
assert.match(authedRootText, /data-mnote-shell="workspace"/);
}
async function main() {
const external = (process.env.MNOTE_UI_BASE_URL || "").replace(/\/+$/, "");
let gateway = null;
let baseUrl = external;
if (!baseUrl) {
const port = await findFreePort();
baseUrl = `http://127.0.0.1:${port}`;
gateway = startGateway(port);
await waitForGateway(baseUrl);
}
let stderr = "";
if (gateway) {
gateway.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
}
try {
await validateAuthEntry(baseUrl);
console.log(JSON.stringify({ ok: true, baseUrl, task: "task159-auth-entry" }, null, 2));
} finally {
if (gateway) {
gateway.kill("SIGTERM");
setTimeout(() => gateway.kill("SIGKILL"), 2_000).unref();
if (stderr.trim()) {
process.stderr.write(stderr.slice(-2000));
}
}
}
}
if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
+3 -3
View File
@@ -10,9 +10,9 @@ 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 UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
const TEST_USERNAME_PREFIX = "测试用户";
const TEST_EMAIL = "test@example.com";
const TEST_PASSWORD = "Test123456";
const TEST_USERNAME_PREFIX = "mnote-e2e-";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
function assert(condition, message) {
if (!condition) {
-35
View File
@@ -1,35 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": ["vitest/globals"],
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
});
+2 -2
View File
@@ -46,7 +46,7 @@
"convex": "^1.35.1",
"jszip": "3.10.1",
"lucide-react": "^0.554.0",
"next": "16.0.3",
"next": "16.2.4",
"pdfjs-dist": "^4.10.38",
"react": "19.2.0",
"react-dom": "19.2.0",
@@ -69,7 +69,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"eslint-config-next": "16.2.4",
"jose": "^6.1.3",
"jsdom": "^27.2.0",
"tailwindcss": "^4",
+76 -76
View File
@@ -16,10 +16,10 @@ importers:
version: 0.42.0(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)
'@blocknote/mantine':
specifier: 0.42.0
version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@blocknote/react':
specifier: ^0.42.0
version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
version: 0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@convex-dev/auth':
specifier: ^0.0.91
version: 0.0.91(@auth/core@0.37.0)(convex@1.35.1(react@19.2.0))(react@19.2.0)
@@ -114,8 +114,8 @@ importers:
specifier: ^0.554.0
version: 0.554.0(react@19.2.0)
next:
specifier: 16.0.3
version: 16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
specifier: 16.2.4
version: 16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
pdfjs-dist:
specifier: ^4.10.38
version: 4.10.38
@@ -161,7 +161,7 @@ importers:
devDependencies:
'@cloudflare/next-on-pages':
specifier: ^1.13.16
version: 1.13.16(next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)
version: 1.13.16(next@16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.17
@@ -178,8 +178,8 @@ importers:
specifier: ^9
version: 9.39.1(jiti@2.6.1)
eslint-config-next:
specifier: 16.0.3
version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
specifier: 16.2.4
version: 16.2.4(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
jose:
specifier: ^6.1.3
version: 6.1.3
@@ -487,9 +487,6 @@ packages:
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
@@ -1424,56 +1421,56 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/env@16.0.3':
resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==}
'@next/env@16.2.4':
resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==}
'@next/eslint-plugin-next@16.0.3':
resolution: {integrity: sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==}
'@next/eslint-plugin-next@16.2.4':
resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==}
'@next/swc-darwin-arm64@16.0.3':
resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==}
'@next/swc-darwin-arm64@16.2.4':
resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@16.0.3':
resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==}
'@next/swc-darwin-x64@16.2.4':
resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@16.0.3':
resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==}
'@next/swc-linux-arm64-gnu@16.2.4':
resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@16.0.3':
resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==}
'@next/swc-linux-arm64-musl@16.2.4':
resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@16.0.3':
resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==}
'@next/swc-linux-x64-gnu@16.2.4':
resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@16.0.3':
resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==}
'@next/swc-linux-x64-musl@16.2.4':
resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@16.0.3':
resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==}
'@next/swc-win32-arm64-msvc@16.2.4':
resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@16.0.3':
resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==}
'@next/swc-win32-x64-msvc@16.2.4':
resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2758,6 +2755,11 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
engines: {node: '>=6.0.0'}
hasBin: true
baseline-browser-mapping@2.8.29:
resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==}
hasBin: true
@@ -3292,8 +3294,8 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-next@16.0.3:
resolution: {integrity: sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==}
eslint-config-next@16.2.4:
resolution: {integrity: sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==}
peerDependencies:
eslint: '>=9.0.0'
typescript: '>=3.3.1'
@@ -4326,8 +4328,8 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next@16.0.3:
resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==}
next@16.2.4:
resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -5794,10 +5796,10 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/mantine@0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@blocknote/mantine@0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@mantine/core@8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.8(react@19.2.0))(@mantine/utils@6.0.22(react@19.2.0))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@blocknote/core': 0.42.0(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)
'@blocknote/react': 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@blocknote/react': 0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@mantine/core': 8.3.8(@mantine/hooks@8.3.8(react@19.2.0))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@mantine/hooks': 8.3.8(react@19.2.0)
'@mantine/utils': 6.0.22(react@19.2.0)
@@ -5817,14 +5819,14 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/react@0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@blocknote/react@0.42.0(@floating-ui/dom@1.7.6)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@blocknote/core': 0.42.0(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/pm': 3.10.7
'@tiptap/react': 3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tiptap/react': 3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
emoji-mart: 5.6.0
lodash.merge: 4.6.2
react: 19.2.0
@@ -5847,7 +5849,7 @@ snapshots:
dependencies:
mime: 3.0.0
'@cloudflare/next-on-pages@1.13.16(next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)':
'@cloudflare/next-on-pages@1.13.16(next@16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(wrangler@4.54.0)':
dependencies:
acorn: 8.15.0
ast-types: 0.14.2
@@ -5858,7 +5860,7 @@ snapshots:
esbuild: 0.15.18
js-yaml: 4.1.1
miniflare: 3.20250718.3
next: 16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
next: 16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
package-manager-manager: 0.2.0
pcre-to-regexp: 1.1.0
semver: 7.7.3
@@ -5977,11 +5979,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.7.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
@@ -6513,7 +6510,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.9.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
@@ -6630,38 +6627,38 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.9.2
'@tybys/wasm-util': 0.10.1
optional: true
'@next/env@16.0.3': {}
'@next/env@16.2.4': {}
'@next/eslint-plugin-next@16.0.3':
'@next/eslint-plugin-next@16.2.4':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@16.0.3':
'@next/swc-darwin-arm64@16.2.4':
optional: true
'@next/swc-darwin-x64@16.0.3':
'@next/swc-darwin-x64@16.2.4':
optional: true
'@next/swc-linux-arm64-gnu@16.0.3':
'@next/swc-linux-arm64-gnu@16.2.4':
optional: true
'@next/swc-linux-arm64-musl@16.0.3':
'@next/swc-linux-arm64-musl@16.2.4':
optional: true
'@next/swc-linux-x64-gnu@16.0.3':
'@next/swc-linux-x64-gnu@16.2.4':
optional: true
'@next/swc-linux-x64-musl@16.0.3':
'@next/swc-linux-x64-musl@16.2.4':
optional: true
'@next/swc-win32-arm64-msvc@16.0.3':
'@next/swc-win32-arm64-msvc@16.2.4':
optional: true
'@next/swc-win32-x64-msvc@16.0.3':
'@next/swc-win32-x64-msvc@16.2.4':
optional: true
'@nodelib/fs.scandir@2.1.5':
@@ -7375,9 +7372,9 @@ snapshots:
dependencies:
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/extension-floating-menu@3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)':
'@tiptap/extension-floating-menu@3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)':
dependencies:
'@floating-ui/dom': 1.7.4
'@floating-ui/dom': 1.7.6
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/pm': 3.10.7
optional: true
@@ -7447,7 +7444,7 @@ snapshots:
prosemirror-transform: 1.10.5
prosemirror-view: 1.41.3
'@tiptap/react@3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
'@tiptap/react@3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@tiptap/core': 3.10.7(@tiptap/pm@3.10.7)
'@tiptap/pm': 3.10.7
@@ -7460,7 +7457,7 @@ snapshots:
use-sync-external-store: 1.6.0(react@19.2.0)
optionalDependencies:
'@tiptap/extension-bubble-menu': 3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)
'@tiptap/extension-floating-menu': 3.10.7(@floating-ui/dom@1.7.4)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)
'@tiptap/extension-floating-menu': 3.10.7(@floating-ui/dom@1.7.6)(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7)
transitivePeerDependencies:
- '@floating-ui/dom'
@@ -7627,7 +7624,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.3
semver: 7.7.4
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -7913,6 +7910,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.8.29: {}
bidi-js@1.0.3:
@@ -8515,9 +8514,9 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-next@16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
eslint-config-next@16.2.4(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 16.0.3
'@next/eslint-plugin-next': 16.2.4
eslint: 9.39.1(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1))
@@ -9121,7 +9120,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
semver: 7.7.3
semver: 7.7.4
is-callable@1.2.7: {}
@@ -9970,24 +9969,25 @@ snapshots:
natural-compare@1.4.0: {}
next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
next@16.2.4(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@next/env': 16.0.3
'@next/env': 16.2.4
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001755
postcss: 8.4.31
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.0.3
'@next/swc-darwin-x64': 16.0.3
'@next/swc-linux-arm64-gnu': 16.0.3
'@next/swc-linux-arm64-musl': 16.0.3
'@next/swc-linux-x64-gnu': 16.0.3
'@next/swc-linux-x64-musl': 16.0.3
'@next/swc-win32-arm64-msvc': 16.0.3
'@next/swc-win32-x64-msvc': 16.0.3
'@next/swc-darwin-arm64': 16.2.4
'@next/swc-darwin-x64': 16.2.4
'@next/swc-linux-arm64-gnu': 16.2.4
'@next/swc-linux-arm64-musl': 16.2.4
'@next/swc-linux-x64-gnu': 16.2.4
'@next/swc-linux-x64-musl': 16.2.4
'@next/swc-win32-arm64-msvc': 16.2.4
'@next/swc-win32-x64-msvc': 16.2.4
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -10648,7 +10648,7 @@ snapshots:
dependencies:
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.3
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
+15
View File
@@ -15,6 +15,21 @@
const http = require("http");
const net = require("net");
const path = require("path");
// 说明:Next 当前 vendored 的 browserslist/baseline 提示会在进程启动早期输出,
// 即使项目侧依赖已经升级也未必消失。这里默认静音这类“当前不可操作”的噪音;
// 如需排查真实数据新鲜度,可在启动前显式传入 `false` 恢复原始警告。
if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === "false") {
delete process.env.BROWSERSLIST_IGNORE_OLD_DATA;
} else if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === undefined) {
process.env.BROWSERSLIST_IGNORE_OLD_DATA = "true";
}
if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === "false") {
delete process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA;
} else if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === undefined) {
process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA = "true";
}
const next = require("next");
const { parse: parseUrl } = require("url");
const { buildTreeShellRuntime } = require("./build-tree-shell-runtime");
+20 -5
View File
@@ -13,11 +13,26 @@
* - CONVEX_INTERNAL_URL默认 http://127.0.0.1:3210
*/
const http = require("http");
const net = require("net");
const path = require("path");
const next = require("next");
const { parse: parseUrl } = require("url");
const http = require("http");
const net = require("net");
const path = require("path");
// 说明:Next 当前 vendored 的 browserslist/baseline 提示会在进程启动早期输出,
// 即使项目侧依赖已经升级也未必消失。这里默认静音这类“当前不可操作”的噪音;
// 如需排查真实数据新鲜度,可在启动前显式传入 `false` 恢复原始警告。
if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === "false") {
delete process.env.BROWSERSLIST_IGNORE_OLD_DATA;
} else if (process.env.BROWSERSLIST_IGNORE_OLD_DATA === undefined) {
process.env.BROWSERSLIST_IGNORE_OLD_DATA = "true";
}
if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === "false") {
delete process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA;
} else if (process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA === undefined) {
process.env.BASELINE_BROWSER_MAPPING_IGNORE_OLD_DATA = "true";
}
const next = require("next");
const { parse: parseUrl } = require("url");
const ONLYOFFICE_PREFIX = "/onlyoffice-server";
const CONVEX_PREFIX = "/convex";
+49 -38
View File
@@ -5,53 +5,64 @@
* 运行node scripts/register-test-user.js
*/
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
name: "测试用户",
};
const TEST_CREDENTIALS = {
email: "mnote.e2e@example.com",
password: "MnoteE2E123!",
name: "mnote-e2e",
};
async function registerTestUser() {
const baseUrl = "http://localhost:3000";
console.log("正在注册测试账号...");
console.log(`邮箱: ${TEST_CREDENTIALS.email}`);
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
}),
});
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "auth:signIn",
args: {
provider: "password",
params: {
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
},
},
}),
});
const result = await response.json();
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else if (response.status === 501) {
console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
} else {
console.error("✗ 注册失败:", result.error || result.message);
}
} catch (error) {
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else {
const message = result?.error || result?.message || "";
if (/already exists|已存在|已被使用|已被占用/i.test(message)) {
console.log("ℹ 测试账号已存在,可直接使用:");
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
return;
}
console.log("ℹ 自动注册失败,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"测试账号快速登录"或"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
}
} catch (error) {
console.error("✗ 请求失败:", error.message);
console.log("\n请确保开发服务器正在运行 (pnpm dev)");
}
+4 -4
View File
@@ -11,10 +11,10 @@ import { getUserFacingErrorMessage } from "@/lib/auth/errors";
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
const TEST_CREDENTIALS = {
email: "mnote.e2e@example.com",
password: "MnoteE2E123!",
} as const;
/**
* Convex Auth /
@@ -4,11 +4,23 @@ const mockSafeGetJsonBody = vi.fn();
const mockValidateRequestBody = vi.fn();
const mockIsConvexEnabled = vi.fn();
const mockGetAuthedConvexClient = vi.fn();
const mockStartHermesRun = vi.fn();
const mockStreamHermesRunEvents = vi.fn();
const mockBuildDocumentBridgeContext = vi.fn();
const mockExecuteRustBridgeTool = vi.fn();
const mockStartDocumentAiOrchestratorRun = vi.fn();
const mockStartMnoteCliAgentHostRun = vi.fn();
const mockRequireAuthContext = vi.fn();
const mockGetConvexAuthedHttpClient = vi.fn();
vi.mock("next/server", () => ({
NextResponse: class NextResponse extends Response {
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
}
},
}));
vi.mock("@/lib/api-utils", async () => {
const actual = await vi.importActual<typeof import("@/lib/api-utils")>("@/lib/api-utils");
@@ -27,21 +39,16 @@ vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: mockGetAuthedConvexClient,
}));
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
startHermesRun: mockStartHermesRun,
streamHermesRunEvents: mockStreamHermesRunEvents,
vi.mock("@/lib/auth/authContext", () => ({
requireAuthContext: mockRequireAuthContext,
}));
vi.mock("@/lib/documents/bridge", () => ({
buildDocumentBridgeContext: mockBuildDocumentBridgeContext,
vi.mock("@/lib/convex/server", () => ({
getConvexAuthedHttpClient: mockGetConvexAuthedHttpClient,
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
executeRustBridgeTool: mockExecuteRustBridgeTool,
}));
vi.mock("@/lib/server/document-ai-orchestrator", () => ({
startDocumentAiOrchestratorRun: mockStartDocumentAiOrchestratorRun,
vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
}));
describe("/api/ai-agent/run route", () => {
@@ -50,241 +57,23 @@ describe("/api/ai-agent/run route", () => {
mockValidateRequestBody.mockReset();
mockIsConvexEnabled.mockReset();
mockGetAuthedConvexClient.mockReset();
mockStartHermesRun.mockReset();
mockStreamHermesRunEvents.mockReset();
mockBuildDocumentBridgeContext.mockReset();
mockExecuteRustBridgeTool.mockReset();
mockStartDocumentAiOrchestratorRun.mockReset();
mockStartMnoteCliAgentHostRun.mockReset();
mockRequireAuthContext.mockReset();
mockGetConvexAuthedHttpClient.mockReset();
});
it("应把 Hermes slash_run 完成事件恢复成结构化 tool_result", async () => {
it("文档页在线请求应只进入 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成 AI 标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
client: { query: vi.fn(), mutation: vi.fn() },
});
mockBuildDocumentBridgeContext.mockResolvedValue({
deploymentId: null,
projectId: null,
workspaceId: null,
requestId: "req-1",
traceId: "trace-1",
actor: {
actorType: "user",
actorId: "user-1",
sessionId: null,
},
source: {
channel: "next-route",
client: "wolai-frontend",
},
tenantId: null,
authToken: null,
idempotencyKey: null,
validateOnly: false,
dryRun: false,
});
mockStartHermesRun.mockResolvedValue({
runId: "run-1",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "tool.started",
tool: "slash_run",
preview: '{"text":"/rename doc-1 AI 标题"}',
});
await onEvent({
event: "tool.completed",
tool: "slash_run",
duration: 0.12,
error: false,
});
await onEvent({
event: "run.completed",
output: "已完成",
});
});
mockExecuteRustBridgeTool.mockResolvedValue({
plan: {
kind: "tool",
toolName: "slash_run",
},
result: {
ok: true,
parsed: {
command: "rename_doc",
params: {
documentId: "doc-1",
title: "AI 标题",
},
},
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockExecuteRustBridgeTool).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "slash_run",
args: {
text: "/rename doc-1 AI 标题",
},
data: {
source: "ai-agent-route",
},
}),
);
expect(text).toContain("event: tool_result");
expect(text).toContain('"tool":"slash_run"');
expect(text).toContain('"command":"rename_doc"');
expect(text).toContain('"title":"AI 标题"');
});
it("文档页 AI 请求应把 pageOptions 带入 Hermes instructions", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "根据当前页面设置调整内容" }],
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
pageOptions: {
wideLayout: true,
smallText: true,
layoutDensity: "compact",
},
},
options: {
ai: {
provider: "hermes",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({
runId: "run-2",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "已完成",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("pageOptions="),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('"wideLayout":true'),
}),
);
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining("editorRuntimePageOptions="),
}),
);
});
it("文档页在线流式请求应优先走 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
}),
);
expect(mockStartHermesRun).not.toHaveBeenCalled();
expect(text).toContain("event: ready");
expect(text).toContain("来自 orchestrator");
});
it("文档页在线请求应把 modelKey、profileId 和 sessionId 透传到 orchestrator", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
documentBlocks: [],
},
options: {
ai: {
provider: "online",
@@ -301,73 +90,19 @@ describe("/api/ai-agent/run route", () => {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockResolvedValue(
new Response(
'event: assistant_message\ndata: {"text":"来自 orchestrator"}\n\n',
{
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
},
mockRequireAuthContext.mockResolvedValue({
userId: "user-1",
});
mockGetConvexAuthedHttpClient.mockResolvedValue({});
mockStartMnoteCliAgentHostRun.mockResolvedValue(
new Response('event: completion\ndata: {"ok":true,"text":"mnote-cli"}\n\n', {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"x-mnote-ai-execution-owner": "mnote-cli",
},
),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
});
it("orchestrator 失败时应自动回退 Hermes", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "把标题改成回退标题" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "online",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartDocumentAiOrchestratorRun.mockRejectedValue(new Error("sidecar down"));
mockStartHermesRun.mockResolvedValue({
runId: "run-fallback",
});
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({
event: "run.completed",
output: "Hermes fallback",
});
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
@@ -377,8 +112,112 @@ describe("/api/ai-agent/run route", () => {
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartDocumentAiOrchestratorRun).toHaveBeenCalledTimes(1);
expect(mockStartHermesRun).toHaveBeenCalledTimes(1);
expect(text).toContain("Hermes fallback");
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
payload: expect.objectContaining({
scope: "document",
context: expect.objectContaining({
documentId: "doc-1",
pageOptions: expect.objectContaining({
wideLayout: true,
}),
}),
options: expect.objectContaining({
ai: expect.objectContaining({
provider: "online",
sessionId: "doc_ai:doc-1:session-1",
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
}),
}),
}),
}),
);
expect(text).toContain("mnote-cli");
});
it.each(["codex", "hermes", "local", "ollama"] as const)(
"provider=%s 也必须统一进入 mnote-cli host",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "处理当前页面" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider,
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockRequireAuthContext.mockResolvedValue({
userId: "user-1",
});
mockGetConvexAuthedHttpClient.mockResolvedValue({});
mockStartMnoteCliAgentHostRun.mockResolvedValue(
new Response('event: completion\ndata: {"ok":true,"text":"mnote-cli"}\n\n', {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"x-mnote-ai-execution-owner": "mnote-cli",
},
}),
);
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledTimes(1);
expect(mockStartMnoteCliAgentHostRun).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
options: expect.objectContaining({
ai: expect.objectContaining({
provider,
}),
}),
}),
}),
);
},
);
it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
messages: [{ role: "user", content: "总结当前页面" }],
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "",
},
});
mockRequireAuthContext.mockRejectedValue(new Error("未登录,请先登录"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://127.0.0.1:3000/api/ai-agent/run", {
method: "POST",
}),
);
expect(response.status).toBe(401);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
});
});
+15 -767
View File
@@ -1,684 +1,15 @@
import { NextResponse } from "next/server";
import { safeGetJsonBody, errorResponses, validateRequestBody } from "@/lib/api-utils";
import {
DEFAULT_AGENT_MAX_STEPS,
MAX_AGENT_STEPS,
MIN_AGENT_STEPS,
} from "@/lib/constants";
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
codexMessagesToPrompt,
findWorkspaceRoot,
startCodexJsonRun,
} from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents, type HermesRunEvent } from "@/lib/ai-agent/hermes/bridge";
import {
readHermesToolArgsFromEvent,
readHermesToolResultFromEvent,
} from "@/lib/ai-agent/hermes/tool-result-recovery";
import { startDocumentAiOrchestratorRun } from "@/lib/server/document-ai-orchestrator";
import { buildDocumentBridgeContext } from "@/lib/documents/bridge";
import { executeRustBridgeTool } from "@/lib/documents/rust-runtime";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
startMnoteCliAgentHostRun,
type MnoteCliAgentRunPayload,
} from "@/lib/server/mnote-cli-agent-host";
export const dynamic = "force-dynamic";
type AgentMessage = { role: "user" | "assistant"; content: string };
type AgentScope = "global" | "mindmap" | "document" | "onlyoffice";
type RawAiProvider = "online" | "local" | "ollama" | "codex" | "hermes";
type RuntimeProvider = "agents" | "hermes" | "codex";
type CodexMode = "chat" | "test" | "dev";
type RequestPayload = {
stream?: boolean;
maxSteps?: number;
scope?: AgentScope;
messages: AgentMessage[];
attachments?: Array<{
id: string;
title: string;
fileUrl: string;
mimeType?: string | null;
}>;
toolChoice?: {
mode: "auto" | "manual";
toolSets?: string[];
tools?: string[];
};
context?: {
documentId?: string;
mindmapId?: string;
selectedUids?: string[];
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
};
options?: {
searxng?: boolean;
ai?: {
provider?: RawAiProvider;
model?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
};
};
};
type LegacyStreamEvent =
| { type: "assistant_message"; data: { text: string } }
| { type: "tool_call"; data: { id: string; tool: string; args: Record<string, unknown> } }
| { type: "tool_result"; data: { id: string; tool: string; ok: boolean; ms: number; result: unknown } }
| { type: "completion"; data: { ok: true; text: string; steps: number } }
| { type: "error"; data: { ok: false; message: string } }
| { type: "codex_session"; data: { sessionId: string } };
const sseHeaders = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
} as const;
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const makeRunId = () => {
try {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch {
// ignore
}
return `run_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
};
const serializeContextSnapshot = (label: string, value: unknown, limit: number) => {
if (value === undefined) {
return null;
}
try {
const text = JSON.stringify(value);
return `${label}=${text.slice(0, limit)}`;
} catch {
return `${label}=provided`;
}
};
const clampSteps = (raw: unknown) => {
const parsed = Number(raw ?? DEFAULT_AGENT_MAX_STEPS);
if (!Number.isFinite(parsed)) return DEFAULT_AGENT_MAX_STEPS;
return Math.max(MIN_AGENT_STEPS, Math.min(MAX_AGENT_STEPS, Math.floor(parsed)));
};
const normalizeProvider = (
raw: unknown,
scope: AgentScope,
stream: boolean,
): RuntimeProvider => {
const value = String(raw ?? "").trim().toLowerCase();
if (value === "online" && scope === "document" && stream) {
return "agents";
}
return value === "codex" ? "codex" : "hermes";
};
const proxySseResponse = (upstream: Response) => {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, requestId: makeRunId() })));
const reader = upstream.body?.getReader();
if (!reader) {
controller.close();
return;
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
controller.enqueue(value);
}
}
} finally {
controller.close();
}
},
});
return new Response(body, { headers: sseHeaders });
};
const normalizeScope = (payload: RequestPayload): AgentScope => {
const raw = String(payload.scope ?? "").trim();
if (raw === "global" || raw === "mindmap" || raw === "document" || raw === "onlyoffice") {
return raw;
}
return payload.context?.mindmapId ? "mindmap" : payload.context?.documentId ? "document" : "global";
};
const stripCodexModePrefix = (text: string): { mode: CodexMode | null; text: string } => {
const value = String(text ?? "");
const match = value.match(/^\s*#(chat|test|dev)\b[\s:\-–—]*/i);
if (!match) return { mode: null, text: value };
const mode = String(match[1] ?? "").toLowerCase() as CodexMode;
return { mode, text: value.slice(match[0].length).trimStart() };
};
const extractCodexModeFromMessages = (messages: AgentMessage[]) => {
const lastUser = [...messages].reverse().find((item) => item.role === "user")?.content ?? "";
const picked = stripCodexModePrefix(lastUser);
const cleanedMessages = messages.map((item) => {
if (item.role !== "user") return item;
const cleaned = stripCodexModePrefix(item.content);
return { ...item, content: cleaned.text };
});
return {
mode: picked.mode ?? "chat",
cleanedMessages,
};
};
const buildHermesInstructions = (
payload: RequestPayload,
userId: string,
scope: AgentScope,
maxSteps: number,
) => {
const attachments = Array.isArray(payload.attachments) ? payload.attachments.slice(0, 12) : [];
const selectedUids = Array.isArray(payload.context?.selectedUids)
? payload.context?.selectedUids.map((item) => String(item)).filter(Boolean).slice(0, 12)
: [];
const lines: string[] = [
"你当前运行在 mnote Web 前端的 Hermes bridge 后面。请始终使用简体中文。",
"当前前端已经收口为轻桥接层,不要再假设存在旧的前端 builtin registry、toolset 静态映射或 create*ServerTools 编排逻辑。",
"除非工具结果明确表明已完成写入,否则不要声称已经修改页面、思维导图或 OnlyOffice 文档。",
"不要把本机终端、文件系统或其他 Hermes 默认工具当成 mnote 业务真执行面。mnote 业务写入应视为独立桥能力。",
`当前调用用户:${userId}`,
`当前 scope${scope}`,
`本轮最大步数提示:${maxSteps}`,
];
if (payload.context?.documentId) {
lines.push(`documentId=${String(payload.context.documentId).trim()}`);
}
if (payload.context?.mindmapId) {
lines.push(`mindmapId=${String(payload.context.mindmapId).trim()}`);
}
if (selectedUids.length > 0) {
lines.push(`selectedUids=${selectedUids.join(",")}`);
}
if (payload.context?.documentBlocks !== undefined) {
lines.push(serializeContextSnapshot("documentBlocksSnapshot", payload.context.documentBlocks, 4000) ?? "documentBlocksSnapshot=provided");
}
if (payload.context?.node !== undefined) {
lines.push(serializeContextSnapshot("kernelNode", payload.context.node, 1800) ?? "kernelNode=provided");
}
if (payload.context?.subtree !== undefined) {
lines.push(serializeContextSnapshot("kernelSubtree", payload.context.subtree, 5000) ?? "kernelSubtree=provided");
}
if (payload.context?.outline !== undefined) {
lines.push(serializeContextSnapshot("kernelOutline", payload.context.outline, 2500) ?? "kernelOutline=provided");
}
if (payload.context?.evidence !== undefined) {
lines.push(serializeContextSnapshot("kernelEvidence", payload.context.evidence, 2500) ?? "kernelEvidence=provided");
}
if (payload.context?.pageOptions !== undefined) {
lines.push(
serializeContextSnapshot(
"pageOptions",
payload.context.pageOptions,
2000,
) ?? "pageOptions=provided",
);
lines.push(
serializeContextSnapshot(
"editorRuntimePageOptions",
pickLeptosTiptapRuntimePageOptions(payload.context.pageOptions),
1200,
) ?? "editorRuntimePageOptions=provided",
);
}
if (attachments.length > 0) {
lines.push(
[
"attachments:",
...attachments.map(
(item, index) =>
`${index + 1}. id=${String(item.id)} title=${String(item.title)} mime=${String(item.mimeType ?? "")} url=${String(item.fileUrl)}`,
),
].join("\n"),
);
}
if (payload.toolChoice?.mode === "manual") {
const tools = Array.isArray(payload.toolChoice.tools) ? payload.toolChoice.tools.filter(Boolean) : [];
const toolSets = Array.isArray(payload.toolChoice.toolSets) ? payload.toolChoice.toolSets.filter(Boolean) : [];
if (tools.length > 0 || toolSets.length > 0) {
lines.push(
`前端兼容提示:manual toolChoice tools=[${tools.join(", ")}] toolSets=[${toolSets.join(", ")}]. 这些只是旧前端兼容字段,不代表当前 Hermes 一定具备同名工具。`,
);
}
}
if (scope === "onlyoffice") {
lines.push("OnlyOffice 浏览器专属 client capability 仍在单独桥接;若当前后端没有明确写入结果,请直接说明限制,不要伪造选区修改。\n");
}
return lines.join("\n\n").trim();
};
const buildHermesInput = (messages: AgentMessage[]) => {
return messages
.slice(-50)
.filter((item) => item.role === "user" || item.role === "assistant")
.map((item) => ({ role: item.role, content: String(item.content ?? "") }));
};
type PendingHermesToolCall = {
preview: string;
argsJson: Record<string, unknown> | null;
};
const recoverStructuredHermesToolResult = async (input: {
request: Request;
payload: RequestPayload;
userId: string;
client: ReturnType<typeof getAuthedConvexClient> extends Promise<infer T>
? T["client"]
: never;
tool: string;
argsJson: Record<string, unknown> | null;
fallbackRequestId: string;
fallbackTraceId: string;
}): Promise<unknown | null> => {
if (!input.argsJson) {
return null;
}
if (
input.tool !== "slash_run" &&
input.tool !== "doc_insert_blocks" &&
input.tool !== "doc_replace_range"
) {
return null;
}
const documentId = String(input.payload.context?.documentId ?? "").trim() || null;
const data =
input.tool === "slash_run"
? { source: "ai-agent-route" }
: input.payload.context?.documentBlocks ?? null;
if ((input.tool === "doc_insert_blocks" || input.tool === "doc_replace_range") && data == null) {
return null;
}
return buildDocumentBridgeContext({
request: input.request,
workspaceId: null,
})
.then((context) =>
executeRustBridgeTool({
context: {
...context,
requestId: context.requestId || input.fallbackRequestId,
traceId: context.traceId || input.fallbackTraceId,
actor: {
...context.actor,
actorId: input.userId,
},
},
toolName: input.tool,
invocationKind: "command",
args: input.argsJson,
data: (data && typeof data === "object" && !Array.isArray(data) ? data : { data }) as Record<
string,
unknown
>,
target: documentId
? {
pageId: documentId,
blockId:
input.tool === "doc_replace_range"
? String(input.argsJson.blockId ?? "").trim() || null
: null,
}
: null,
}),
)
.then((result) => result.result)
.catch(() => null);
};
const streamHermesLegacyEvents = async ({
messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent,
}: {
messages: AgentMessage[];
instructions: string;
sessionId: string | null;
request: Request;
payload: RequestPayload;
userId: string;
onEvent: (event: LegacyStreamEvent) => Promise<void> | void;
}) => {
const input = buildHermesInput(messages);
const { runId } = await startHermesRun({
input,
instructions,
...(sessionId ? { session_id: sessionId } : {}),
});
const pendingToolIds = new Map<string, string[]>();
const pendingToolCalls = new Map<string, PendingHermesToolCall>();
let toolCount = 0;
let assistantBuffer = "";
let failureMessage = "";
let completed = false;
await streamHermesRunEvents(runId, async (event: HermesRunEvent) => {
if (event.event === "tool.started") {
const tool = String(event.tool ?? "").trim() || "unknown_tool";
const id = `hermes_${runId}_${++toolCount}`;
const queue = pendingToolIds.get(tool) ?? [];
queue.push(id);
pendingToolIds.set(tool, queue);
const preview = typeof event.preview === "string" ? event.preview : "";
pendingToolCalls.set(id, {
preview,
argsJson: readHermesToolArgsFromEvent(event, tool),
});
await onEvent({
type: "tool_call",
data: {
id,
tool,
args: preview ? { preview } : {},
},
});
return;
}
if (event.event === "tool.completed") {
const tool = String(event.tool ?? "").trim() || "unknown_tool";
const queue = pendingToolIds.get(tool) ?? [];
const id = queue.shift() ?? `hermes_${runId}_${toolCount}`;
pendingToolIds.set(tool, queue);
const pendingToolCall = pendingToolCalls.get(id) ?? null;
pendingToolCalls.delete(id);
const preview = pendingToolCall?.preview ?? "";
const duration = Number(event.duration ?? 0);
const structuredResultFromEvent = !Boolean(event.error) ? readHermesToolResultFromEvent(event) : null;
const recoveredResult =
structuredResultFromEvent ??
(await recoverStructuredHermesToolResult({
request,
payload,
userId,
tool,
argsJson: pendingToolCall?.argsJson ?? null,
fallbackRequestId: makeRunId(),
fallbackTraceId: makeRunId(),
}));
await onEvent({
type: "tool_result",
data: {
id,
tool,
ok: !Boolean(event.error),
ms: Number.isFinite(duration) ? Math.max(0, Math.round(duration * 1000)) : 0,
result:
recoveredResult ??
(preview ? { preview, error: Boolean(event.error) } : { error: Boolean(event.error) }),
},
});
return;
}
if (event.event === "message.delta") {
assistantBuffer += typeof event.delta === "string" ? event.delta : "";
return;
}
if (event.event === "run.failed") {
failureMessage = String(event.error ?? "Hermes run 失败");
await onEvent({ type: "error", data: { ok: false, message: failureMessage } });
completed = true;
return;
}
if (event.event === "run.completed") {
const fallbackOutput = typeof event.output === "string" ? event.output : "";
const finalText = (assistantBuffer || fallbackOutput || "(无输出)").trim();
if (finalText) {
await onEvent({ type: "assistant_message", data: { text: finalText } });
}
await onEvent({
type: "completion",
data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) },
});
completed = true;
}
});
if (!completed && !failureMessage) {
const finalText = (assistantBuffer || "(无输出)").trim();
if (finalText) {
await onEvent({ type: "assistant_message", data: { text: finalText } });
}
await onEvent({
type: "completion",
data: { ok: true, text: finalText, steps: Math.max(1, toolCount || 1) },
});
}
};
const runCodexBridge = async ({
payload,
request,
stream,
}: {
payload: RequestPayload;
request: Request;
stream: boolean;
}) => {
const { mode, cleanedMessages } = extractCodexModeFromMessages(payload.messages.slice(0, 50));
const workspaceRoot = await findWorkspaceRoot(process.cwd());
const sessionIdRaw = String(payload.options?.ai?.sessionId ?? "").trim();
const requestMode = mode;
const sys =
requestMode === "dev"
? "你当前处于 #dev 模式:可在工作区内读取/修改文件并执行命令,但只能影响当前工作区。请用简体中文输出。"
: requestMode === "test"
? "你当前处于 #test 模式:只做分析与回答,不要执行命令,不要修改文件,不要伪造工具执行。请用简体中文输出。"
: "你当前处于 #chat 模式:只聊天,不要执行命令,不要修改文件,不要输出 diff。请用简体中文输出。";
const buildPrompt = () => {
if (!sessionIdRaw) {
return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]);
}
const lastUser = [...cleanedMessages].reverse().find((item) => item.role === "user")?.content ?? "";
const nextUserText = String(lastUser || "").trim();
if (!nextUserText) {
return codexMessagesToPrompt([{ role: "system", content: sys }, ...cleanedMessages]);
}
return codexMessagesToPrompt([
{ role: "system", content: sys },
{ role: "user", content: nextUserText },
]);
};
if (!stream) {
const run = startCodexJsonRun({
cwd: workspaceRoot,
sandbox: "workspace-write",
prompt: buildPrompt(),
model: null,
sessionId: sessionIdRaw || null,
});
const result = await run.done;
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: 500 });
}
return NextResponse.json({
text: result.text,
steps: 1,
events: [],
sessionId: result.threadId || sessionIdRaw || null,
});
}
const encoder = new TextEncoder();
let killActiveRun: (() => void) | null = null;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(toSseFrame(event, data)));
};
const requestId = makeRunId();
send("ready", { ok: true, requestId });
let sessionSent = false;
let assistantSent = false;
const toolStartAt = new Map<string, number>();
const stopRun = () => {
try {
killActiveRun?.();
} catch {
// ignore
}
};
const onAbort = () => {
stopRun();
};
try {
request.signal?.addEventListener("abort", onAbort, { once: true });
} catch {
// ignore
}
(async () => {
const run = startCodexJsonRun({
cwd: workspaceRoot,
sandbox: "workspace-write",
prompt: buildPrompt(),
model: null,
sessionId: sessionIdRaw || null,
onJsonLine: (line) => {
if (line.type === "thread.started") {
const sid = String((line as { thread_id?: string }).thread_id ?? "").trim();
if (sid && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: sid });
}
return;
}
if (line.type === "item.started" && (line as { item?: { type?: string; id?: string; command?: string } }).item?.type === "command_execution") {
const item = (line as { item?: { type?: string; id?: string; command?: string } }).item;
const id = String(item?.id ?? "").trim();
const command = String(item?.command ?? "");
if (!id) return;
toolStartAt.set(id, Date.now());
send("tool_call", { id, tool: "codex_command", args: { command } });
return;
}
if (line.type === "item.completed" && (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item?.type === "command_execution") {
const item = (line as { item?: { type?: string; id?: string; exit_code?: number; aggregated_output?: string } }).item;
const id = String(item?.id ?? "").trim();
if (!id) return;
const startedAt = toolStartAt.get(id) ?? Date.now();
const ms = Math.max(0, Date.now() - startedAt);
const exitCode = Number(item?.exit_code ?? 0);
send("tool_result", {
id,
tool: "codex_command",
ok: exitCode === 0,
ms,
result: {
exitCode,
output: String(item?.aggregated_output ?? ""),
},
});
return;
}
if (line.type === "item.completed" && (line as { item?: { type?: string; text?: string } }).item?.type === "agent_message") {
const item = (line as { item?: { type?: string; text?: string } }).item;
const text = String(item?.text ?? "").trim();
if (text) {
assistantSent = true;
send("assistant_message", { text });
}
}
},
});
killActiveRun = run.kill;
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error });
return;
}
if (result.threadId && !sessionSent) {
sessionSent = true;
send("codex_session", { sessionId: result.threadId });
}
if (!assistantSent && result.text) {
assistantSent = true;
send("assistant_message", { text: result.text });
}
send("completion", { ok: true, text: result.text, steps: 1 });
})()
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
send("error", { ok: false, message });
})
.finally(() => {
try {
request.signal?.removeEventListener("abort", onAbort);
} catch {
// ignore
}
stopRun();
controller.close();
});
},
cancel() {
try {
killActiveRun?.();
} catch {
// ignore
}
},
});
return new Response(body, { headers: sseHeaders });
};
export async function POST(request: Request) {
const payload = await safeGetJsonBody<RequestPayload>(request);
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
if (!payload) {
return errorResponses.badRequest("请求体不能为空");
}
@@ -689,106 +20,23 @@ export async function POST(request: Request) {
}
let userId = "";
let userEmail: string | undefined;
let userName: string | undefined;
if (isConvexEnabled()) {
const { auth } = await getAuthedConvexClient();
userId = auth.userId ?? "";
userEmail = auth.email;
userName = auth.name;
}
if (!userId) {
return errorResponses.unauthorized();
}
const stream = payload.stream !== false;
const scope = normalizeScope(payload);
const provider = normalizeProvider(payload.options?.ai?.provider, scope, stream);
if (provider === "codex") {
return await runCodexBridge({ payload, request, stream });
}
if (provider === "agents") {
try {
const upstream = await startDocumentAiOrchestratorRun({
request,
userId,
payload,
});
return proxySseResponse(upstream);
} catch (error) {
console.warn(
"document ai orchestrator 不可用,回退 Hermes",
error instanceof Error ? error.message : String(error),
);
}
}
const maxSteps = clampSteps(payload.maxSteps);
const instructions = buildHermesInstructions(payload, userId, scope, maxSteps);
const sessionId = String(payload.options?.ai?.sessionId ?? "").trim() || null;
if (!stream) {
const events: LegacyStreamEvent[] = [];
await streamHermesLegacyEvents({
messages: payload.messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent: (event) => {
events.push(event);
},
}).catch((error) => {
throw error instanceof Error ? error : new Error(String(error));
});
const completion = [...events].reverse().find((event) => event.type === "completion") as Extract<LegacyStreamEvent, { type: "completion" }> | undefined;
const assistant = [...events].reverse().find((event) => event.type === "assistant_message") as Extract<LegacyStreamEvent, { type: "assistant_message" }> | undefined;
return NextResponse.json({
text: completion?.data.text ?? assistant?.data.text ?? "",
steps: completion?.data.steps ?? Math.max(1, events.filter((event) => event.type === "tool_call").length || 1),
events,
});
}
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(toSseFrame(event, data)));
};
send("ready", { ok: true, requestId: makeRunId() });
const ping = setInterval(() => {
try {
controller.enqueue(encoder.encode(`: ping ${Date.now()}\n\n`));
} catch {
// ignore
}
}, 15_000);
(async () => {
await streamHermesLegacyEvents({
messages: payload.messages,
instructions,
sessionId,
request,
payload,
userId,
onEvent: (event) => {
send(event.type, event.data ?? null);
},
});
})()
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
send("error", { ok: false, message });
})
.finally(() => {
clearInterval(ping);
controller.close();
});
},
return startMnoteCliAgentHostRun({
request,
userId,
userEmail,
userName,
payload,
});
return new Response(body, { headers: sseHeaders });
}
@@ -264,13 +264,13 @@ describe("extractCurrentPageTitleFromSlashToolResult", () => {
describe("DocumentAiAgentPanel.runtime island contract", () => {
it("AI bridge runtime 固定为 Rust Web/Hermes owned island", () => {
it("AI bridge runtime 固定为 mnote-cli host/client 主路径", () => {
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",
bridgeOwner: "mnote-cli",
runtimeRole: "mnote_cli_host_client",
runEndpoint: "/api/ai-agent/run",
legacyCompatEndpoint: null,
});
});
});
@@ -29,10 +29,10 @@ 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",
bridgeOwner: "mnote-cli",
runtimeRole: "mnote_cli_host_client",
runEndpoint: "/api/ai-agent/run",
legacyCompatEndpoint: null,
} as const;
const extractCodexMode = (text: string): CodexMode => {
@@ -149,10 +149,10 @@ export function buildTreeShellDomFiletreeSelection(activeDocumentId?: string | n
focusedRowId: null as string | null,
};
}
const documentRowId = `doc:${documentId}`;
const indexRowId = `index:${documentId}`;
return {
selectedRowIds: [documentRowId, `index:${documentId}`],
anchorRowId: documentRowId,
focusedRowId: documentRowId,
selectedRowIds: [indexRowId],
anchorRowId: indexRowId,
focusedRowId: indexRowId,
};
}
@@ -512,7 +512,8 @@ describe("tree-shell-iframe-host", () => {
expect(iframe?.getAttribute("srcdoc")).toContain('"expandedIds":["doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain("const rendererExpandedIds = normalizeStringArray(rendererInput.expandedIds);");
expect(iframe?.getAttribute("srcdoc")).toContain('"filetreeSelection"');
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["doc:doc_a","index:doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"selectedRowIds":["index:doc_a"]');
expect(iframe?.getAttribute("srcdoc")).toContain('"focusedRowId":"index:doc_a"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_filetree_selection_reducer_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"contractName":"rust_tree_shell_runtime_artifact_v1"');
expect(iframe?.getAttribute("srcdoc")).toContain('"family":"rust_family"');
@@ -849,10 +849,10 @@ const LOCAL_TREE_SHELL_TEMPLATE = `<!doctype html>
let selectedFileTreeRowIds = new Set(
rendererSelectedFileTreeRowIds.length > 0
? rendererSelectedFileTreeRowIds
: currentActiveDocumentId ? [\`doc:\${currentActiveDocumentId}\`, \`index:\${currentActiveDocumentId}\`] : [],
: currentActiveDocumentId ? [\`index:\${currentActiveDocumentId}\`] : [],
);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? \`doc:\${currentActiveDocumentId}\` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? \`doc:\${currentActiveDocumentId}\` : null);
let fileTreeAnchorRowId = rendererAnchorRowId || (currentActiveDocumentId ? \`index:\${currentActiveDocumentId}\` : null);
let fileTreeFocusedRowId = rendererFocusedRowId || (currentActiveDocumentId ? \`index:\${currentActiveDocumentId}\` : null);
let visibleFileTreeRowIds = [];
const focusRowElement = (nodeId) => {
@@ -75,4 +75,33 @@ describe("page subtree response helpers", () => {
expect(normalized.conflictDetectionKey).toBe("doc_1:3");
expect(normalized.pageSubtree).toBeNull();
});
it("兼容 mnote-web transport 返回的 result 包装", () => {
const normalized = normalizeDocumentContentResponse({
documentId: "doc_1",
payload: {
result: {
content: [
{
id: "paragraph_1",
type: "paragraph",
content: [{ type: "text", text: "CLI 写入后应可编辑" }],
},
],
revision: 4,
conflictDetectionKey: "doc_1:4",
},
},
});
expect(normalized.content).toEqual([
{
id: "paragraph_1",
type: "paragraph",
content: [{ type: "text", text: "CLI 写入后应可编辑" }],
},
]);
expect(normalized.revision).toBe(4);
expect(normalized.conflictDetectionKey).toBe("doc_1:4");
});
});
@@ -10,6 +10,7 @@ export type DocumentContentResponseLike = {
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
result?: DocumentContentResponseLike | null;
};
export type NormalizedDocumentContentResponse = {
@@ -24,7 +25,8 @@ export function normalizeDocumentContentResponse(input: {
title?: string | null;
payload?: DocumentContentResponseLike | null;
}): NormalizedDocumentContentResponse {
const payload = input.payload ?? null;
const rawPayload = input.payload ?? null;
const payload = rawPayload?.result ?? rawPayload;
const content = payload?.content ?? null;
const revision =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
@@ -23,6 +23,27 @@ describe("tiptap-content-converter", () => {
]);
});
it("把 BlockNote inline text 数组转换成 TipTap 可编辑文本节点", () => {
expect(
tiptapDocFromBlocks([
{
id: "cli_visible_b_b2",
type: "paragraph",
content: [{ type: "text", text: "第二个 CLI 页面,可直接编辑。" }],
},
] as never),
).toEqual({
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "cli_visible_b_b2" },
content: [{ type: "text", text: "第二个 CLI 页面,可直接编辑。" }],
},
],
});
});
it("round-trips p0.5 block families through editor block document", () => {
const document = editorBlockDocumentFromContent([
{ id: "p1", type: "paragraph", content: "段落" },
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockBuildForwardHeaders } = vi.hoisted(() => ({
mockBuildForwardHeaders: vi.fn(async () => new Headers({ "x-forwarded-for": "127.0.0.1" })),
}));
vi.mock("@/lib/runtime-config", () => ({
getMnoteRuntimeConfig: () => ({ backendUrl: "http://backend.test" }),
}));
vi.mock("@/lib/server/forward-headers", () => ({
buildForwardHeaders: mockBuildForwardHeaders,
}));
import { startDocumentAiOrchestratorRun } from "./document-ai-orchestrator";
describe("startDocumentAiOrchestratorRun", () => {
beforeEach(() => {
vi.restoreAllMocks();
mockBuildForwardHeaders.mockClear();
delete process.env.BACKEND_URL;
delete process.env.BACKEND_INTERNAL_URL;
delete process.env.MNOTE_AI_ORCHESTRATOR_API_KEY;
});
it("应把 leptos-tiptap 的 block 级 AI 上下文透传给 sidecar", async () => {
const fetchMock = vi.fn(async () =>
new Response('event: ready\ndata: {"ok":true}\n\n', {
headers: { "Content-Type": "text/event-stream; charset=utf-8" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await startDocumentAiOrchestratorRun({
userId: "user-1",
payload: {
maxSteps: 8,
messages: [{ role: "user", content: "改写当前块" }],
context: {
source: "leptos-tiptap-island",
action: "ask_ai",
documentId: "doc-1",
workspaceId: "ws-1",
selectedBlockId: "block-1",
selectedUids: ["block-1"],
selectedText: "旧段落",
selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
},
options: {
ai: {
modelKey: "gpt-5.3-codex",
profileId: "page_writer_polish",
sessionId: "doc_ai:doc-1:session-1",
},
},
},
});
expect(fetchMock).toHaveBeenCalledWith(
"http://backend.test/api/v1/ai-agent/document/run",
expect.objectContaining({ method: "POST" }),
);
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
expect(body.context).toMatchObject({
source: "leptos-tiptap-island",
action: "ask_ai",
documentId: "doc-1",
workspaceId: "ws-1",
selectedBlockId: "block-1",
selectedUids: ["block-1"],
selectedText: "旧段落",
selection: { currentBlockId: "block-1", state: { from: 1, to: 4 } },
tiptapDocument: { type: "doc", content: [{ type: "paragraph" }] },
documentBlocks: { type: "doc", content: [{ type: "paragraph" }] },
});
});
});
@@ -10,7 +10,16 @@ type RequestPayload = {
maxSteps?: number;
messages: AgentMessage[];
context?: {
source?: string;
action?: string;
documentId?: string;
workspaceId?: string;
selectedBlockId?: string;
selectedBlockIndex?: number;
selectedUids?: string[];
selectedText?: string;
selection?: unknown;
tiptapDocument?: unknown;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
@@ -77,7 +86,16 @@ export async function startDocumentAiOrchestratorRun(input: {
maxSteps: input.payload.maxSteps,
messages: input.payload.messages,
context: {
source: input.payload.context?.source ?? null,
action: input.payload.context?.action ?? null,
documentId: input.payload.context?.documentId ?? null,
workspaceId: input.payload.context?.workspaceId ?? null,
selectedBlockId: input.payload.context?.selectedBlockId ?? null,
selectedBlockIndex: input.payload.context?.selectedBlockIndex ?? null,
selectedUids: input.payload.context?.selectedUids ?? null,
selectedText: input.payload.context?.selectedText ?? null,
selection: input.payload.context?.selection ?? null,
tiptapDocument: input.payload.context?.tiptapDocument ?? null,
documentBlocks: input.payload.context?.documentBlocks ?? null,
node: input.payload.context?.node ?? null,
subtree: input.payload.context?.subtree ?? null,
@@ -0,0 +1,119 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
default: {
spawn: mockSpawn,
},
spawn: mockSpawn,
}));
vi.mock("next/server", () => ({
NextResponse: class NextResponse extends Response {
static json(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
}
},
}));
import { startMnoteCliAgentHostRun } from "./mnote-cli-agent-host";
function createMockChild(stdoutText: string) {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn();
setTimeout(() => {
child.stdout.end(stdoutText);
child.stderr.end("");
child.emit("close", 0);
}, 0);
return child;
}
describe("startMnoteCliAgentHostRun", () => {
beforeEach(() => {
mockSpawn.mockReset();
delete process.env.DEV_USER_ID;
delete process.env.DEV_USER_EMAIL;
delete process.env.DEV_USER_NAME;
});
it("启动 mnote-cli 时应把当前 Web 用户作为默认 CLI 写入身份下发", async () => {
mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));
const response = await startMnoteCliAgentHostRun({
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
userEmail: "dev@mnote.local",
userName: "开发用户",
payload: {
stream: false,
messages: [{ role: "user", content: "写入测试页" }],
context: {
documentId: "doc-1",
workspaceId: "ws_req_1778035004501_15",
},
},
});
expect(response.status).toBe(200);
expect(mockSpawn).toHaveBeenCalledWith(
"cargo",
expect.arrayContaining([
"--args-json",
expect.stringContaining('"workspaceId":"ws_req_1778035004501_15"'),
]),
expect.objectContaining({
env: expect.objectContaining({
DEV_USER_ID: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
DEV_USER_EMAIL: "dev@mnote.local",
DEV_USER_NAME: "开发用户",
MNOTE_CLI_ALLOW_CREATE_PAGE: "1",
MNOTE_CLI_ALLOW_EDIT: "1",
}),
}),
);
});
it("应把当前页面 workspaceId 传入 CLI argsJson,避免 agent 写入错误工作空间", async () => {
mockSpawn.mockImplementation(() => createMockChild('{"ok":true}\n'));
await startMnoteCliAgentHostRun({
request: new Request("http://127.0.0.1:3000/api/ai-agent/run"),
userId: "a4b72c17-49e3-46d3-8456-24a0a7044d64",
userEmail: "dev@mnote.local",
userName: "开发用户",
payload: {
stream: false,
messages: [{ role: "user", content: "在当前空间写测试页" }],
context: {
documentId: "tree_1778036320856_1",
workspaceId: "ws_req_1778035004501_15",
},
},
});
const args = mockSpawn.mock.calls[0]?.[1] as string[];
const argsJson = args[args.indexOf("--args-json") + 1];
expect(JSON.parse(argsJson)).toMatchObject({
documentId: "tree_1778036320856_1",
workspaceId: "ws_req_1778035004501_15",
});
});
});
@@ -0,0 +1,239 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import type { PageOptionsState } from "@/types/page-options";
import { pickLeptosTiptapRuntimePageOptions } from "@/lib/documents/page-option-semantics";
type AgentMessage = { role: "user" | "assistant"; content: string };
export type MnoteCliAgentRunPayload = {
stream?: boolean;
maxSteps?: number;
scope?: string;
messages: AgentMessage[];
context?: {
documentId?: string;
workspaceId?: string;
documentBlocks?: unknown;
pageOptions?: PageOptionsState;
node?: unknown;
subtree?: unknown;
outline?: unknown;
evidence?: unknown;
selectedUids?: string[];
};
options?: {
ai?: {
provider?: string;
sessionId?: string;
modelKey?: string;
profileId?: string;
model?: string;
};
};
};
type CliRunResult = {
code: number | null;
stdout: string;
stderr: string;
};
const CLI_HOST_TIMEOUT_MS = 30_000;
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
async function pathExists(targetPath: string) {
try {
await access(targetPath);
return true;
} catch {
return false;
}
}
async function resolveRepoRoot() {
let dir = path.resolve(process.cwd());
for (let i = 0; i < 8; i += 1) {
if (await pathExists(path.join(dir, "rust", "Cargo.toml"))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve(process.cwd(), "..");
}
function buildCliArgs(input: { repoRoot: string; userId: string; payload: MnoteCliAgentRunPayload }) {
const sessionId = String(input.payload.options?.ai?.sessionId ?? "").trim() || `ai-${Date.now()}`;
const documentId = String(input.payload.context?.documentId ?? "").trim() || "current";
const workspaceId = String(input.payload.context?.workspaceId ?? "").trim() || null;
const argsJson = JSON.stringify({
pageId: documentId,
documentId,
workspaceId,
provider: input.payload.options?.ai?.provider ?? null,
modelKey: input.payload.options?.ai?.modelKey ?? null,
profileId: input.payload.options?.ai?.profileId ?? null,
selectedUids: input.payload.context?.selectedUids ?? null,
pageOptions: input.payload.context?.pageOptions ?? null,
editorRuntimePageOptions: input.payload.context?.pageOptions
? pickLeptosTiptapRuntimePageOptions(input.payload.context.pageOptions)
: null,
});
return [
"run",
"--quiet",
"--manifest-path",
path.join(input.repoRoot, "rust", "Cargo.toml"),
"-p",
"mnote-cli",
"--",
"--json",
"--validate-only",
"--dry-run",
"--actor-id",
input.userId,
"--actor-type",
"user",
"--session-id",
sessionId,
"--reason",
"ai-agent-run:mnote-cli-host",
"tool",
"run",
"--tool-name",
"doc_get",
"--kind",
"query",
"--mode",
"explain-plan",
"--args-json",
argsJson,
];
}
async function runMnoteCli(input: {
userId: string;
userEmail?: string;
userName?: string;
payload: MnoteCliAgentRunPayload;
}): Promise<CliRunResult> {
const repoRoot = await resolveRepoRoot();
const args = buildCliArgs({ repoRoot, userId: input.userId, payload: input.payload });
return new Promise<CliRunResult>((resolve, reject) => {
const child = spawn("cargo", args, {
cwd: repoRoot,
env: {
...process.env,
CARGO_TERM_COLOR: "never",
RUSTUP_TOOLCHAIN: process.env.RUSTUP_TOOLCHAIN?.trim() || "1.89.0",
DEV_USER_ID: input.userId,
DEV_USER_EMAIL: input.userEmail?.trim() || process.env.DEV_USER_EMAIL || "dev@mnote.local",
DEV_USER_NAME: input.userName?.trim() || process.env.DEV_USER_NAME || "开发用户",
MNOTE_CLI_ALLOW_CREATE_PAGE: process.env.MNOTE_CLI_ALLOW_CREATE_PAGE || "1",
MNOTE_CLI_ALLOW_EDIT: process.env.MNOTE_CLI_ALLOW_EDIT || "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, CLI_HOST_TIMEOUT_MS);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (timedOut) {
reject(new Error("mnote-cli host 执行超时"));
return;
}
resolve({ code, stdout, stderr });
});
});
}
export async function startMnoteCliAgentHostRun(input: {
request: Request;
userId: string;
userEmail?: string;
userName?: string;
payload: MnoteCliAgentRunPayload;
}): Promise<Response> {
void input.request;
const run = runMnoteCli({
userId: input.userId,
userEmail: input.userEmail,
userName: input.userName,
payload: input.payload,
});
if (!input.payload.stream) {
const result = await run;
if (result.code !== 0) {
return NextResponse.json({ error: result.stderr || "mnote-cli 执行失败" }, { status: 500 });
}
return NextResponse.json({
ok: true,
bridgeOwner: "mnote-cli",
text: result.stdout.trim(),
});
}
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(toSseFrame("ready", { ok: true, bridgeOwner: "mnote-cli" })));
try {
const result = await run;
if (result.code !== 0) {
controller.enqueue(
encoder.encode(toSseFrame("error", { ok: false, message: result.stderr || "mnote-cli 执行失败" })),
);
} else {
const text = result.stdout.trim() || "mnote-cli 无输出";
controller.enqueue(encoder.encode(toSseFrame("assistant_message", { text })));
controller.enqueue(encoder.encode(toSseFrame("completion", { ok: true, text, steps: 1 })));
}
} catch (error) {
controller.enqueue(
encoder.encode(toSseFrame("error", { ok: false, message: error instanceof Error ? error.message : String(error) })),
);
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "mnote-cli",
},
});
}
@@ -31,11 +31,11 @@ const isPublicRoute = createRouteMatcher([
]);
export default convexAuthNextjsMiddleware(async (request, ctx) => {
// 公共路由不做拦截(但 middleware 仍会处理 token 刷新/代理等)。
// 公共路由不做拦截(但 proxy 仍会处理 token 刷新/代理等)。
if (isPublicRoute(request)) return;
// 只在启用 Convex 模式时做鉴权拦截;否则走 Supabase 逻辑(页面内部自行处理)。
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
// 注意:proxy 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
// 开发用户模式下允许“免登录”访问(主要用于迁移/联调与 E2E 回归)。
@@ -49,6 +49,6 @@ export default convexAuthNextjsMiddleware(async (request, ctx) => {
});
export const config = {
// 说明:排除静态资源,避免无意义的中间件开销。
// 说明:排除静态资源,避免无意义的 proxy 开销。
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};