chore(tree): remove legacy iframe tree shell

Remove the active TreeShellIframeHost path and legacy env flag so rust_family only mounts the Rust/WASM DOM shell host.

Move legacy iframe host files to ignored recycle storage, clear tracked recycle cache entries, and update tree-domain design wording to the current no-iframe position.

Align filetree default active rows with doc:<documentId> and update the renderer artifact bridge contract to dom_wasm.
This commit is contained in:
lix-2026
2026-05-16 08:57:40 +08:00
parent 384da4e44c
commit f9336257a5
52 changed files with 145 additions and 13538 deletions
-347
View File
@@ -1,347 +0,0 @@
# AI Agent 平台 v1(把 Wolai 当成“Web 版 VSCode + Cline”来做)
> 目标:不只在思维导图里,而是在**主编辑区(BlockNote**、**OnlyOffice**、**思维导图**三处都能使用同一套“会用工具做事”的 AI。
>
> 我们把当前项目类比为 VSCode:
> - UI 里有一个统一的 Chat 面板(像 VSCode Chat / Cline Webview
> - AI 不是“输出一段文本”,而是能调用工具(像 Cline 的工具 + VSCode 的 languageModelTools
> - 工具调用可追溯、可控(权限/确认/日志),且可扩展(MCP / 内置工具 / 用户自定义)
---
## 0. 现状与痛点(为什么现在“不智能”)
当前项目的 AI 主要集中在 mindmap 相关 Next Route + 面板:
- `wolai-frontend/src/app/api/mindmap-ai/*`:PDF 大纲生成导图、补完节点、agent 路由等。
- `wolai-frontend/src/components/editor/blocks/MindmapAiAgentPanel.tsx`:对话 UI + 在线/本地模型切换 +(有限)工具选择。
痛点(用户反馈的核心):
1) AI 常变成“对话 + 兜底链接”,看起来没真正**调用工具**完成“写入/修改/跳转/补全”。
2) 缺少跨区域能力:主编辑区、OnlyOffice 没有统一的 AI 工具体系。
3) 缺少可观测性:用户看不到 AI 做了哪些步骤、调用了哪些工具、产生了哪些改动。
4) 工具调用协议不稳定:不同模型/网关对 JSON tool-calling 的支持差异大,容易进入循环/步数耗尽。
---
## 1. 设计目标(对齐 Cline / VSCode Chat 的能力)
### 1.1 统一入口:一个 AI 面板,多处可用
- 右侧/浮层 AI 面板:所有区域共用(思维导图、BlockNote、OnlyOffice)。
- 支持“快速对话”与“任务模式”(像 Cline:可以多步执行、展示步骤与工具日志)。
- 支持 `@` 选择文件树资产/上传文件/引用当前选区。
### 1.2 统一协议:工具调用必须稳定、可解析、可追溯
参考 cankao/cline-main
- 使用**XML-like 工具标签协议**(而不是依赖模型原生 function-calling):
- 优点:更跨模型、可流式解析、可用“严格 parser”保证可靠性。
- 核心点:模型输出 `<tool_name>...</tool_name>`,服务端解析后执行工具,再把 `<tool_result>...</tool_result>` 回喂模型。
### 1.3 统一工具模型:Tool / ToolSet / 权限 / 确认
参考 cankao/vscode 的 chat tools
- Tool 定义包含:id、展示名、modelDescription、inputSchema、来源、是否需要确认、是否可在 prompt 中被引用等。
- ToolSet:工具集合(方便“手动勾选一组工具”或“按场景启用”)。
- 权限策略:读操作默认允许;写操作可配置为“自动/需确认/禁止”。
### 1.4 统一上下文模型:Attachments / Variables@ 引用)
参考 VSCode chat 的 attachments / variables
- `@file`/`@folder`/`@selection`/`@mindmap`/`@onlyoffice` 统一变成“上下文条目”。
- 每个条目可计算 token 预算,必要时“省略/压缩/摘要”。
---
## 2. 总体架构(分层 + 运行时)
### 2.1 模块拆分(建议新增)
`wolai-frontend/src/lib` 下新增统一平台包(后续逐步迁移 mindmap AI 逻辑):
- `src/lib/ai-agent/protocol/`
- 工具标签协议(解析/序列化/流式切分)
- 参考:`cankao/cline-main/src/core/assistant-message/*`
- `src/lib/ai-agent/tools/`
- 工具注册表(Tool、ToolSet、schema
- 参考:VSCode `languageModelTools*`
- `src/lib/ai-agent/runtime/`
- Agent 循环(step budget、工具执行、结果回喂、压缩/续写)
- 参考:Cline “tool use + new_task + context management”
- `src/lib/ai-agent/context/`
- Attachments/Variables@ 引用)
- token 预算与省略策略
- `src/lib/ai-agent/providers/`
- 在线/本地模型统一适配(复用现有 `openaiCompatibleChat`
### 2.2 服务端编排(主入口 API)
新增统一入口(示例):
- `POST /api/ai-agent/run`
- 输入:会话信息 + 用户输入 + attachments + 工具允许集(auto 或手动)
- 输出:SSE 流(推荐)或 JSON:
- assistant 文本流
- tool_call / tool_result 事件流
- 最终 completion(包含“做了什么/改了什么/引用来源/失败原因”)
为什么需要 SSE
- 让 UI 像 Cline 一样逐步显示:正在检索/正在读取文档/正在写入导图/已保存等。
### 2.3 客户端工具宿主(可选但很关键)
很多能力必须在浏览器侧执行(例如读取当前选区、定位 OnlyOffice 页码、获取思维导图当前选中节点)。
设计为两类工具:
1) **Server Tools**(在 Next Route 执行)
- searxng 检索、LightRAG 查询、读写 mindmap JSON、生成导图 ops、读写 BlockNote 文档存储等。
2) **Client Tools**(在浏览器执行)
- 读取当前编辑器选区、读取 OnlyOffice 当前页/选中区域、读当前 mindmap 选中节点等。
执行方式(v1 推荐):
- `/api/ai-agent/run` 只执行 Server Tools
- Client Tools 先不做“由 AI 主动触发”,而是通过 attachments 提前把必要上下文注入(@selection@currentFile@mindmapSelection)。
执行方式(v2 目标):
- 引入“前端工具宿主”:当服务端需要执行 Client Tool 时,通过 SSE 向前端发起请求,前端执行后再回传结果(类似 VSCode extension host / Cline host provider)。
- 现状:已在 OnlyOffice 场景落地(SSE 事件 `client_tool_call` → 前端调用 OnlyOffice 插件 API → 回调 `/api/ai-agent/client-tool-result`)。
- 后续:把该机制抽象成通用 Client Tool Host,复用到 BlockNote/思维导图的“选区/选中节点”等能力。
---
## 3. 协议:工具调用(建议采用 Cline 风格 XML 标签)
### 3.1 为什么不用纯 JSON tool-calling
- 不同在线网关/模型对 `response_format=json_object`、function-calling 支持不一致。
- 一旦输出不符合 schema,容易进入循环(步数耗尽),用户只看到“兜底链接”。
### 3.2 v1 工具标签协议(最小可用)
模型输出只能出现以下块之一:
- `<tool_name>...</tool_name>`:请求执行工具
- `<attempt_completion>...</attempt_completion>`:结束并输出最终结果
工具参数采用子标签:
```xml
<search_web>
<query>gemini 3 tokens price</query>
<count>5</count>
</search_web>
```
工具结果回喂:
```xml
<tool_result>
<tool_name>search_web</tool_name>
<result>{"results":[...]}</result>
</tool_result>
```
解析器参考:
- `cankao/cline-main/src/core/assistant-message/parse-assistant-message.ts`
---
## 4. 工具体系(跨 Mindmap / BlockNote / OnlyOffice
### 4.1 核心内置工具(v1 必做)
#### A. 检索与证据
- `search_web`:通过 searxng 搜索,返回 title/url/snippet(必须可追溯)
- `rag_query`:通过 LightRAG 查询(文档内检索)
#### B. 思维导图(Mindmap
(复用并升级 `design/mindmap-ai-agent-api.md` 的 ops 思路)
- `mindmap_get`:读取某个 mindmap JSONdocumentId + mindmapId
- `mindmap_apply_ops`:应用 ops 并落盘(增量修改,禁止全量覆盖)
- `mindmap_expand_node`:检索→生成 ops→apply(服务端组合工具)
#### C. 主编辑区(BlockNote 文档)
> 目标:让 AI 像“写代码”一样能对文档做结构化编辑,而不是生成一段文本让用户复制粘贴。
- `doc_get_selection`v1 先做成 attachment 注入;v2 再做成 client tool
- `doc_insert_blocks`:在某个 block 前/后插入 blocks
- `doc_replace_range`:替换某个选区(或某 block 的内容)
- `doc_find`:在文档内查找某段文本/标题定位位置
#### D. OnlyOffice(文档阅读/编辑)
v1(先做最基础的增/删/改/查 + 可落地的“文档驱动导图”):
- `oo_get_selection`:读取当前选区(查)
- `oo_replace_selection`:替换当前选区(改/删;删=传空字符串)
- `oo_insert_text` / `oo_insert_html`:插入内容(增)
- `oo_insert_image`:插入图片(增)
- `asset_extract_outline`:附件(PDF 优先)→结构化大纲(含页码)
- `asset_to_mindmap`:附件大纲→思维导图落盘(含 refs/hyperlink
v2 再做:
- `onlyoffice_jump`:跳转到某页/某段(依赖 OnlyOffice API 能力)
- `onlyoffice_insert_comment`:插入批注/引用锚点
- `onlyoffice_forcesave`:强制保存/落盘(用于“编辑中→解析/索引→生成”闭环)
### 4.2 ToolSet(便于 UI 一键勾选)
建议默认提供:
- `toolset.readonly`search_web、rag_query、asset_get、mindmap_get(只读)
- `toolset.mindmap_write`mindmap_apply_ops、mindmap_expand_node
- `toolset.doc_write`doc_insert_blocks、doc_replace_range
- `toolset.onlyoffice_editor`oo_get_selection、oo_replace_selection、oo_insert_*(选区增删改查)
- `toolset.onlyoffice_read`/`toolset.onlyoffice_write`asset_extract_outline / asset_to_mindmap(文档驱动导图)
---
## 5. UI/交互(对齐“像 Cline 一样智能”)
### 5.1 一个统一的 AI 面板(简洁)
参考你给的 CherryStudio 截图:保持简洁,但必须有三块:
1) 顶部:会话标题 + 模型选择(在线/本地 + model)
2) 中部:对话历史 + 工具执行日志(可折叠)
3) 底部:输入框 + `@` 选择附件 + 工具选择(自动/手动、多选 ToolSet/Tool
关键交互规则:
- 焦点在输入框:Enter = 发送;Shift+Enter = 换行;不触发思维导图/编辑器快捷键。
- 焦点不在输入框:Enter/Tab 等交由当前编辑器(mindmap 节点快捷键、BlockNote 等)。
### 5.2 可观测性:必须展示“AI 做了什么”
每条消息可附:
- 计划(可选)
- 调用过的工具列表(工具名 + 输入 + 输出摘要 + 耗时 + 是否保存)
- 改动摘要(例如:对 mindmap 增加了 7 个子节点;对文档插入了 3 个段落)
### 5.3 “自动 vs 手动”的真正含义
- 自动:AI 在允许的 ToolSet 范围内自行调用工具。
- 手动:只允许用户勾选的工具/ToolSet;AI 只能在这个集合内调用。
---
## 6. 里程碑计划(慢慢实现,但每一步都可验收)
### 完成进度(截至 2026-01-12
> 说明:本节用于记录“已经落地到代码”的进度,避免只停留在规划层。
> ✅=已完成;🟡=部分完成/已打通但仍需扩展;⬜=未开始
| 里程碑 | 条目 | 状态 | 备注(对应实现) |
| --- | --- | --- | --- |
| M0 | `POST /api/ai-agent/run` | ✅ | `wolai-frontend/src/app/api/ai-agent/run/route.ts` |
| M0 | XML-like 工具标签协议解析器 | ✅ | `wolai-frontend/src/lib/ai-agent/protocol/toolTagProtocol.ts` |
| M0 | Tool RegistryTool/ToolSet | ✅ | `wolai-frontend/src/lib/ai-agent/tools/registry.ts` + `wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts` |
| M0 | Agent Runtimestep budget + 工具日志) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/runAgent.ts` |
| M0 | SSE 输出(assistant/tool_call/tool_result/completion | ✅ | `/api/ai-agent/run` 已支持 SSE |
| Infra | 公共环境文件(服务域名统一配置) | ✅ | `wolai-frontend/public/mnote-env.json`Supabase/Backend/OnlyOffice Web/Desktop |
| Infra | 桌面端默认远程客户端行为(启动不再走 127.0.0.1 登录) | ✅ | `desktop-electron/main.js`networkMode=remote-client + 强制 Supabase/Backend 走 Tunnel |
| Infra | OnlyOffice 构建兼容(useSearchParams + Suspense | ✅ | `wolai-frontend/src/app/onlyoffice/page.tsx` + `wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx` |
| M0 | 前端 AI 面板(可复用) | 🟡 | 已在 dev + 思维导图 + OnlyOffice 接入;BlockNote 仍需继续推进 |
| M1 | mindmap 读写工具(增量 ops + 细粒度工具) | ✅ | `wolai-frontend/src/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools.ts` |
| M1 | `mindmap_expand_node`(检索→生成→落盘) | ✅ | 同上(支持可选 `search_web` 证据) |
| M1 | UI:选中节点上下文注入(给 AI) | ✅ | 思维导图面板通过 `context.selectedUids` 注入;并对用户显示纯文本节点内容 |
| M1 | UI:最大步数可配置(不再固定 6) | ✅ | 思维导图面板/Dev 面板支持 1~24,默认 10 |
| M2 | ToolSet 按位置隔离(避免工具混淆) | ✅ | `/api/ai-agent/run``scope` 强制过滤;各面板按场景传不同 toolSets |
| M2 | BlockNote 文档工具(doc_* | 🟡 | 已实现 doc_get/doc_find/doc_insert_blocks/doc_replace_range + 文档侧边 AI 面板;`@selection`/更丰富块类型仍需扩展 |
| M2 | LightRAG 检索工具(rag_* | ✅ | `rag_lightrag_query`(调用 `LIGHTRAG_URL``/query` |
| M2 | 跨页面文档工具(docs_* | ✅ | `docs_search` + `docs_read`(按 title/raw_text |
| M2 | 图片读取工具(OCR) | ✅ | `image_read`(读取 `media_assets.ocr_text` |
| M2 | 斜杠命令工具 | ✅ | `slash_run`/new 创建文档、/rename 重命名) |
| M2 | UICline 风格工具栏 + 侧边栏内切页 | ✅ | 文档/思维导图 AI 面板统一顶部工具栏(工具/历史/账户/设置),不再使用居中弹窗 |
| M2 | UI:工具日志可折叠 | ✅ | tool_call/tool_result 支持折叠展开,便于长日志查看 |
| M3 | OnlyOffice 作用域(scope=onlyoffice+ ToolSet | ✅ | `/api/ai-agent/run/route.ts` 支持 onlyoffice scope,并允许 `toolset.onlyoffice_*`(含 `toolset.onlyoffice_editor` |
| M3 | OnlyOffice 客户端工具桥接(SSE↔回调) | ✅ | `wolai-frontend/src/lib/ai-agent/runtime/clientToolBridge.ts` + `/api/ai-agent/client-tool-result` + SSE `client_tool_call` |
| M3 | oo_* 工具(选区增/删/改/查) | ✅ | `registryBuiltins.ts` 注册 + OnlyOffice 插件 `wolai-frontend/public/onlyoffice/plugins/agent-tools/*` |
| M3 | OnlyOffice AI 面板接入(浮层) | ✅ | `wolai-frontend/src/app/onlyoffice/page.tsx` + `wolai-frontend/src/components/onlyoffice/OnlyOfficeAiAgentPanel.tsx` |
| M3 | `asset_extract_outline`PDF→大纲,含页码) | 🟡 | `wolai-frontend/src/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools.ts`MinerU content_list |
| M3 | `asset_to_mindmap`(附件→导图落盘) | 🟡 | 同上(把大纲转成 mindmap ops + refs + hyperlink |
### M0(基础设施):统一 Agent Runtime + 工具协议 + 日志
- [x] 新增 `POST /api/ai-agent/run`(先不替换现有 mindmap-ai,做并行)
- [x] 实现 XML-like 工具协议解析器(参考 cline 的 parse-assistant-message
- [x] 实现 Tool RegistryTool/ToolSet/inputSchema/权限标记)
- [ ] 前端统一 AI 面板(可嵌入 mindmap/sidebar、主编辑区、OnlyOffice 页面)(已在 dev + mindmap 接入,BlockNote/OnlyOffice 待推进)
- [x] SSE 输出:assistant 文本 + tool_call + tool_result + completion
验收(pw-tests):
- 输入“请给出 gemini-3 tokens 价格”,能看到工具日志(至少 search_web),并返回带来源的答案。
### M1(思维导图):把 mindmap AI 从“对话”升级为“增量 ops 写入”
- [x] 复用 `design/mindmap-ai-agent-api.md`:完善 `mindmap_get``mindmap_apply_ops`
- [x] `mindmap_expand_node`:服务端检索→生成 ops→apply,并强制 refs
- [x] UI:在 mindmap 中选择节点后,@mindmapSelection 自动注入上下文(通过 `context.selectedUids` 注入)
验收(pw-tests):
- 选中节点“补完”,新增子节点≥3,刷新仍存在,且每个新增节点带 refs/可跳转链接。
### M2(主编辑区 BlockNote):让 AI 能“读选区并结构化改写/插入”
- [x] 定义 BlockNote 文档操作 API(只允许增量,禁止覆盖整篇)(当前以“块级插入/替换”为最小可用)
- [x] 工具:`doc_insert_blocks``doc_replace_range``doc_find`(另含 `doc_get`
- [ ] UI:输入框支持 `@selection`(或“引用当前选区”按钮)
验收(pw-tests):
- 选中一段文本,让 AI “改写成更简洁版本并保留要点”,结果直接落入文档(不是生成一段文本)。
### M3OnlyOffice):插件端实时改文档 + 文档驱动导图
- [x] OnlyOffice 插件注入(autostart + pluginsData + CORS
- [x] oo_* 工具(选区增/删/改/查):`oo_get_selection` / `oo_replace_selection` / `oo_insert_*`
- [x] 前端工具宿主(OnlyOffice 版):SSE `client_tool_call` ↔ 回调 `/api/ai-agent/client-tool-result`
- [x] `asset_extract_outline`PDF 优先(基于 MinerU 的 content_list 提取 text_level + page_idx
- [x] `asset_to_mindmap`:从文档大纲生成 mindmap(章→节→要点)并落盘
- [ ] 引用:节点 refspage/slide+ hyperlink`#page=`)在 OnlyOffice/PDF 预览器中可稳定跳转(仍需实测与兼容)
补充说明(OnlyOffice “能否穿透拿到信息”):
1) **常规集成(不做插件)**OnlyOffice Docs 集成侧主要通过 `editorConfig.callbackUrl` 回传保存/状态;外部并不会天然得到“当前文档全文/选区文本”。
2) **要拿到编辑器内信息**:需要(A)保证文档已落盘(callback + 保存/forcesave)后从存储侧读取,或(B)开发 OnlyOffice 插件/宏(在编辑器内执行 Office API,再把结果回传到宿主)。
3) **M3 v1 策略(双通道)**
- **实时编辑(Word/PPT/Excel 的基础增删改查)**:走 OnlyOffice 插件(编辑器内执行)→ 通过前端工具宿主把结果回传给服务端 Agent。
- **结构化理解/导图生成**:走“附件/存储→MinerU 结构化解析→导图写入”,不依赖编辑器能直接吐出全文/层级信息。
- 后续再补:`onlyoffice_forcesave`(保证拿到最新落盘版本)+ 更强的“书签/页码/定位”跳转能力。
验收(pw-tests):
- 对指定 PDF 生成“章→节→内容”层级导图,点击节点跳到对应页(至少 URL 含 `#page=`)。
### M4(v2):前端工具宿主 + MCP 工具 + 类“技能”系统
- [ ] 前端工具宿主(通用版):允许 AI 请求 client tool(读取 BlockNote 选区、思维导图当前选中节点、OnlyOffice 当前页等)
- [ ] MCP:把 supabase_local / searxng / 未来自定义 MCP 作为工具源(像 cline)
- [ ] skills:把“工作流说明”做成可加载的技能文件,AI 可按需激活(类似你现在的 Codex skills
验收:
- AI 能在一个任务里自行组合:检索→读文档→写导图→在主编辑区插入总结→生成可跳转引用。
---
## 7. 风险与策略
1) **安全/误操作**:默认只允许增量 ops;涉及删除/覆盖需要二次确认或显式指令。
2) **上下文过长**:引入“attachments 省略/摘要”,并在会话接近上限时自动 compact(参考 opencode/crush)。
3) **模型差异**:工具协议用 XML-like,避免强依赖 function-calling;必要时提供 provider 特化 prompt。
4) **性能/长任务**:PDF 解析/大纲生成走后端 job(可加队列/轮询),前端只显示进度与结果。
---
## 8. 与现有 design 的关系(不推倒重来)
- `design/mindmap-ai-v2.md`:保留“PDF/小结/RAG 补完”的业务目标与验收用例。
- `design/mindmap-ai-agent-api.md`:保留“mindmap ops 增量协议”,作为 v1 中 Mindmap 工具的核心。
- 本文档新增的是:**把 mindmap 的思路抽象成全站统一的 AI Agent 平台**,并且对齐 Cline/VSCode 的“工具 + 上下文 + 日志 + 权限”工程化形态。
-364
View File
@@ -1,364 +0,0 @@
可以,按你选的 1A/2B/3B,我建议的落地方案是:
- Convex:自托管 Docker Compose(官方)
- 对象存储:MinIO(S3 兼容)
- 鉴权:固定“开发用户”(仅开发态),所有权限校验先按这个用户走
- 后端:不再以 FastAPI/Celery 为主链路,长任务改为 Convex action + scheduler;如后续需要 OCR/RAG 重计算,再让 Convex
action 去调用现有 services/* HTTP 服务
下面是我建议的“可执行迁移流程设计”(按阶段推进,每一阶段都能跑通):
———
## 一、部署方案(Windows 本机)
### A. 运行组件
1. convex-backend(端口:3210 + 3211 actions
2. convex-dashboard(端口:6791
3. minio(端口:9000 S3 API9001 Console
4. minio-init(启动时自动建 bucket
### B. 目录规划(建议)
- infra/convex/
- docker-compose.ymlConvex backend + dashboard + MinIO
- .env(只放本机,不提交;或提交 .env.example
- README.md(写清启动端口与初始化)
### C. 关键环境变量(Convex backend 使用 MinIO 作为 S3 存储)
Convex 自托管支持把导出/导入/模块/用户文件/搜索索引都放到 S3,并允许通过 S3_ENDPOINT_URL 连接 MinIO。你将配置这些
bucket(示例名):
- convex-snapshot-exports
- convex-snapshot-imports
- convex-modules
- convex-user-files
- convex-search-indexes
并在 backend 容器里设置(示例):
- AWS_REGION=us-east-1
- AWS_ACCESS_KEY_ID=<minio key>
- AWS_SECRET_ACCESS_KEY=<minio secret>
- S3_ENDPOINT_URL=http://minio:9000
- S3_STORAGE_EXPORTS_BUCKET=...(以及其余 4 个)
> 这样后续你在 Convex 里用文件能力(上传/存储/取 URL)会自然落到 MinIO,不需要再引入 Supabase Storage 或自写一套 presigned > 上传逻辑。
———
## 二、迁移总体策略(符合“先跑通但都换掉”)
你的项目目前最大的问题不是“后端”,而是数据访问散落在 Next Route Handlers + Supabase。迁移应以 “把 Supabase 数据面替换成
Convex” 为主线,同时把鉴权先简化为固定用户。
我建议采用“门面不变、内核替换”的方式:
- 保留现有 wolai-frontend/src/app/api/**/route.ts 路由不变(前端 UI 不用立刻大改)
- 逐个把这些 route handler 内部从 supabase.* 改为 convex query/mutation/action
- 用一个总开关 USE_CONVEX=1 控制,方便随时切回 Supabase 对照(你没有用户数据,回退成本也接近 0)
———
## 三、阶段化详细流程(每阶段验收点明确)
### 阶段 0:盘点与冻结范围(半天内)
目标:明确“哪些模块先迁、哪些先不动”,避免一次改爆。
- 以 CODE_INDEX.md 为基准,确认实际运行入口是 wolai-frontend/(不是根目录 src/)。
- 先只迁:documents / workspaces / workspace_members / mindmap / background_tasks(足够跑主流程)
- 暂不迁或延后:luckysheet_ws(你现在也没实际测试/用户)
验收:列出一张迁移模块清单(我可以直接在仓库里生成 infra/convex/MIGRATION.md,你确认后再动代码)。
———
### 阶段 1Convex + MinIO 跑起来(不改业务代码)
目标:本机基础设施 ready。
- 在 infra/convex/ 放置 composedocker compose up -d
- 生成 admin keydocker compose exec backend ./generate_admin_key.sh
- 在 wolai-frontend/.env.local 配:
- CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210
- CONVEX_SELF_HOSTED_ADMIN_KEY=...
- USE_CONVEX=1
验收:
- Dashboard 能打开:http://localhost:6791
- MinIO Console 能打开:http://localhost:9001
- Convex CLI 能连上自托管(下一阶段会做)
———
### 阶段 2:在 wolai-frontend/ 初始化 Convex 项目(最小 demo
目标:让前端工程具备 convex/ 目录与生成类型的能力。
- 安装 convex 依赖
- 初始化 wolai-frontend/convex/schema + demo function
- 跑一次 npx convex dev(自托管模式,指向 CONVEX_SELF_HOSTED_URL
验收:写一个 ping queryNext.js 页面/route 能调用到并返回结果。
———
### 阶段 3:引入“固定开发用户”与权限骨架(替代 Supabase Auth/RLS
目标:所有数据访问都通过同一套“开发用户上下文”注入,避免到处散落假逻辑。
建议实现方式:
- wolai-frontend/src/lib/auth/devUser.ts
- getDevUser() 固定返回 { userId, email, name }(从 .env.local 读取,默认常量)
- Convex functions 不接受“任意 userId 参数”,而是由 route handler 统一注入(现在固定,未来替换为真实 auth)
权限策略(先最小化):
- 所有写操作:要求 workspace_members 存在(你可以先自动把 dev 用户加入默认 workspace
- 所有读操作:同上
- 未来接入真实 auth 时,只需要把 getDevUser() 替换为 getAuthedUser()Convex 侧的权限函数不变
验收:不依赖 Supabase token,也能跑通“创建默认 workspace + 创建文档”。
———
### 阶段 4:迁移核心数据模型与最小 CRUDdocuments/workspaces
目标:主流程跑通(侧边栏、打开文档、保存)。
- 在 Convex schema 中创建对应集合(建议保留你现有 UUID 作为业务 id 字段,并建立唯一索引,避免 URL/引用大改)
- 实现 queries/mutations
- workspaces.getOrCreateDefaultForUser
- documents.listByWorkspace
- documents.getById
- documents.create
- documents.updateContent(含 raw_text/index_status 等你现在写回字段)
然后改 wolai-frontend/src/app/api/documents/**/route.ts 内部实现:
- 先只改:create/content/save/title/options/list 等最常用路径
- 暂时保留 Supabase 版本分支(USE_CONVEX 开关)
验收:你打开应用后能创建/读取/保存文档(数据落在 Convex),且不再访问 Supabase 表。
———
### 阶段 5:迁移 mindmap 与任务(替换 Celery/trigger 的思路)
目标:把“异步/任务/进度”统一到 Convex。
- background_tasks:做成 Convex 表
- startOcrmutation 创建任务记录,然后 scheduler.runAfter() 触发 action
- ocrPipelineaction 模拟 OCR(对齐你现在 wolai-backend/app/workers/tasks.py 的行为:更新进度、回写文档内容、任务
completed
验收:
- 前端调用“OCR/任务”相关 API 能返回 taskId
- 任务状态能轮询/订阅(先轮询即可)
- 不再需要 wolai-backend 的 Celery 才能完成基本链路
———
### 阶段 6:文件存储(MinIO + Convex file storage
目标:后续做媒体/附件时不会再回到 Supabase Storage。
- 保持 Convex backend 已配置 S3MinIO
- 在 Convex 里实现:
- files.generateUploadUrl
- files.saveMeta(把文件与 document/workspace 关联)
- files.getUrl(用于展示/下载)
验收:能上传一个文件到 Convex file storage(底层落 MinIO),并在页面拿到可访问 URL。
———
到目前为止,你已经跑通了“文档/附件 + Convex 自部署”这条主链路;接下来要做的事,本质上是:把
USE_CONVEX=1 时仍在走 Supabase 的那些模块逐步“替换或下线”,直到项目真的只依赖 Convex。
- 先确认“还剩哪些 Supabase 依赖”
- wolai-frontend/src/ 下仍有大量 Supabase 路由/工具在用:mindmap、referencesRPC)、onlyoffice、
luckysheet、ai-agent、sidebar、search(recent) 等(目前大概率是“Supabase 分支/兜底分支”)。
- services/ingest_service/ 仍通过 Supabase REST 做任务/索引/清理(属于后续 RAG/索引链路)。
- 根目录 src/ 也还有 Supabase 用法(更像历史/备用 Next 工程,若不参与桌面构建可先不动)。
- 按你“尽量一体化绑死 Convex”的优先级,建议下一步这样排
1. 鉴权/权限从“固定用户”升级为可扩展的真实方案:否则后面所有“按用户/工作区隔离”都只能靠约定。
Convex Auth 是官方路线之一,但对 Next.js server 侧支持仍在演进中,需要你接受一定不稳定/适配成
本。citeturn0search1
2. 把仍依赖 Supabase Storage 的功能全部切到 Convex Files:你已经验证 Dashboard Files 可见;下一步
是把 luckysheet/onlyoffice 等涉及上传/签名 URL 的地方也迁掉(或临时 501 下线),保证
USE_CONVEX=1 时不会再触发 Supabase Storage。citeturn0search5
3. 把“搜索/索引/推荐”从 Postgres/RPC 思路迁到 Convex:文档搜索走 Convex 全文检索;RAG/embedding 走 Vector Search(注意向量检索需要在 action 里跑)。citeturn0search2turn0search4
4. 把 services/ingest_service 的“队列/任务状态机”迁出 Supabase:要么先停用该服务;要么改成 Convex
内部的任务表 + actions + scheduler(这样系统更一体化)。
5. 做运维闭环(自部署必需):明确 Docker 卷备份/恢复策略(Convex 也在推进更易用的数据备份/恢复能
力,但你仍应以卷级备份为底线)。citeturn0search3
- 关于“Convex 组件”与你项目的适配结论
- Convex “Components”适合把通用能力(比如协作编辑、鉴权、工作流)做成可插拔模块,并且支持隔离/复
用;你的项目属于“练手快速迭代”,很适合用组件化方式逐块替换 Supabase 逻辑。citeturn1search0
- Files 这一块:Convex 的 Files 更像“一个大池子/大桶”,不强调文件夹;你要的“按用户/工作区区分、路
径/目录视图”,仍建议用业务表字段(workspace_id、user_id、path)来实现管理与展示,这是最贴合你“一 体化 + 不引入外部对象存储”的路线。citeturn0search5
checklist:
- M1(已完成):documents/workspaces/media/search 全链路 Convex 化 + 冒烟回归
- M2(已完成):Mindmap 数据与接口迁移(/api/mindmap/**、/api/mindmap-trash/empty),落到 Convex(优先复用
documents.mindmap_data
- M3(已完成):References(页面引用/反链)迁移(/api/references/record、/api/references/backlinks),补齐目前的 占位实现
- M4(已完成):AI Agent/api/ai-agent/run、/api/ai-agent/client-tool-result)去 Supabase 化(鉴权/读写文档/工
具回调)
- M5(待继续):Online Table / Luckysheet/api/tables/**、/api/luckysheet/**)迁移或在 Convex 模式下先禁用(给
出明确 UI 提示)
- M6(待继续):OnlyOffice/api/onlyoffice/*)迁移或在 Convex 模式下先禁用(同上)
- M7(待继续):服务侧(services/ingest_service 等)去 Supabase 化:任务表/状态机迁到 Convex jobs/actions(或先
停用该链路)
- M8(未开始):鉴权从“固定用户”升级为可扩展方案(仍保持 Convex 一体化)
- M9(未开始):运维闭环:Convex 数据/Files 卷备份恢复、日志与健康检查、启动脚本收敛
我刚做完的(你现在的代码状态)
- 补齐 documents 的最后缺口:wolai-frontend/src/app/api/documents/embed/route.ts:1 已支持 Convex。
- 抽了一个路由侧通用 helperwolai-frontend/src/lib/convex/route.ts:1(统一拿 auth+client)。
- M7 进展:已把“自动入库/LightRAG 触发”迁到 Convex jobs/actions 的最小骨架(支持 document/mindmap/media 三类入库任务),并在 Convex 模式下默认关闭 services/ingest_service 的 Supabase 轮询入库(避免继续依赖 Supabase)。
- 触发入库(开发用):POST wolai-frontend/src/app/api/dev/ingest/enqueue/route.ts:1
- 查询任务:GET wolai-frontend/src/app/api/dev/jobs/demo/route.ts:1(传 id
- Convex action 需要环境变量:LIGHTRAG_URL / LIGHTRAG_API_KEY(运行在 Convex 侧)
- 自动触发(Convex 侧):保存页面/导图会 debounce enqueue 入库任务(wolai-frontend/convex/_utils/ingestJobs.ts:1
- 已移除 ingest_service 内与 Supabase 相关代码(不再包含 Supabase 客户端/配置/轮询入库实现)
- “最近访问”已落库到 Convexwolai-frontend/convex/schema.ts:1 新增 user_recent_pageswolai-frontend/ convex/recents.ts:1 + 接入 wolai-frontend/src/app/api/search/recent/route.ts:1、wolai-frontend/src/
app/api/search/documents/route.ts:1。
- 回归脚本已扩展并跑通:pw-tests/scripts/e2e_convex_smoke.py:1;最新产物 pw-tests/artifacts/convex-
smoke-20260116-194415.png:1、pw-tests/artifacts/convex-smoke-console-20260116-194415.log:1。
接下来我建议从 M2(Mindmap)开始做:它目前是 Convex 模式下仍“纯 Supabase 路由”的最大功能块。你希望优先
做 Mindmap,还是先做 AI Agent/OnlyOffice/表格?
———
## 补充规格:编辑器“移动/嵌入到...”按 wolai 机制复刻(同页嵌入先禁止)
### 0. 结论(本次明确的产品语义)
- “嵌入到...” = **块引用(同步编辑)**:目标页面插入“嵌入引用块”,引用源块;源块仍留在原位置。
- “移动到...” = **移动块本体**:源块(含子树)从当前页面移到目标页面;块 ID 保持不变。
- **同页嵌入先禁止**:当目标页面 = 源块所在页面时,禁止“嵌入到...”(避免递归/复杂边界)。
> 说明:当前代码里(CustomSideMenu/sidebar)做的是“把块变成子页面 + 插入 pageReference”,这不等价于 wolai 的块引用/块移动,需要整体重做。
### 1. 术语
- 源块(sourceBlock):用户在编辑器里选中的那个块(BlockNote block),有稳定 `blockId`。
- 源页面(sourceDoc):包含源块的页面(document)。
- 目标页面(targetDoc):用户在弹窗里选择的页面。
- 块引用(blockReference):一种特殊块,指向 `targetBlockId`,显示/编辑代理到源块。
### 2. 行为规格(可直接转成 pw-tests 验收点)
#### 2.1 “移动到...”(Move
- 触发:块左侧拖拽菜单 → “移动/嵌入到...” → 选“移动到” → 选目标页面。
- 结果:
- 源页面:源块(含 children 子树)消失。
- 目标页面:插入源块子树(MVP 先插在末尾)。
- 不变量:
- 源块 `blockId` 不变(未来引用/链接仍指向同一块)。
- 子树结构保持不变(children 仍挂在源块下)。
- 限制:
- 选择目标页面为当前页面:视为 no-op(提示“已在当前页面”或直接关闭)。
- 无权限/不存在:失败提示。
#### 2.2 “嵌入到...”(Embed
- 触发:块左侧拖拽菜单 → “移动/嵌入到...” → 切换“嵌入到” → 选目标页面。
- 结果:
- 源页面:源块保持原位。
- 目标页面:新增一个 `blockReference`display=embed),`targetBlockId = 源块.blockId`MVP 先插在末尾)。
- 同步编辑:
- 在目标页面的嵌入引用中编辑内容,实际修改的是源块(刷新源页面可见变化)。
- 删除语义:
- 删除目标页面里的引用块,只移除“引用”,不删除源块本体。
- 跳转语义(先对齐 wolai 思路,后续可微调):
- 嵌入引用块本体不强制“点击跳转”;但在块菜单提供“跳转到原块”动作。
- 限制:
- **同页嵌入禁止**`targetDocId === sourceDocId` 时直接禁止(UI 禁用 + API 双重校验)。
- 无权限/不存在:失败提示。
### 3. 数据结构设计(MVP,兼容你当前“整页 content JSON”)
#### 3.1 新增块类型:blockReference(区分于 pageReference
- `type: "blockReference"`
- `props`(建议):
- `targetBlockId: string`(必填)
- `display: "inline" | "embed"`MVP 用 embed
- `alias?: string`(行内引用别名,后续再做)
#### 3.2 块索引(block_index)——用于从 blockId 反查所在页面
因为块仍存放在 `documents.content` 内(整页 JSON),为了实现:
- “跳转到原块”
- “嵌入引用渲染/编辑时找到源块”
需要一个索引集合(Convex 表):
- `block_index { blockId, documentId, workspaceId, updatedAt }`
维护方式(MVP):
- 每次保存页面 content 时(documents.save / documents.updateContent),解析 blocks,批量 upsert 索引。
- Move 操作需要同时更新源/目标页的索引(或依赖后续 save 再修正,但建议 move 立刻修正)。
#### 3.3 引用边(可选,但很有用)
- `reference_edges { sourceDocumentId, targetBlockId, createdAt }`
- 用途:反链面板(Backlinks)、统计、权限校验辅助。
### 4. API 设计(建议新增 blocks 维度接口,避免滥用 documents/embed
> 目标:把“块移动/块引用”从“创建子页面 + pageReference”的错误语义中解耦出来。
#### 4.1 `POST /api/blocks/move`
- 入参:`{ sourceDocumentId, blockId, targetDocumentId, position?: "end" }`
- 行为:从 sourceDoc content 移除 block 子树,追加到 targetDoc content;更新索引。
#### 4.2 `POST /api/blocks/embed`
- 入参:`{ sourceDocumentId, blockId, targetDocumentId, position?: "end" }`
- 行为:校验非同页;在 targetDoc content 追加 `blockReference(targetBlockId=blockId, display="embed")`。
#### 4.3 `GET /api/blocks/get?blockId=...`
- 返回:`{ documentId, block, path? }`
- 用途:渲染引用块、跳转到原块、hover 预览(后续)。
#### 4.4 `POST /api/blocks/patch`(嵌入引用的同步编辑)
- 入参:`{ blockId, patch }`MVP 可先做 `replaceBlock`:提交完整 block JSON
- 行为:定位源块所在文档,修改该块内容并保存;广播刷新(后续可用 Convex 订阅优化)。
### 5. UI / 交互设计(与现有 MoveEmbedPickerDialog 的对接)
- 仍复用现有 `MoveEmbedPickerDialog` 做“选页面”能力。
- 在“嵌入到”模式下:
- Picker 直接排除当前页面(`excludeIds=[currentDocumentId]`),并在选中时二次校验。
- 行为完成后的反馈:
- Movetoast “已移动到 XXX”
- Embed:toast “已在目标页面末尾插入引用块”
### 6. pw-tests 验收用例(最小集合,锁定行为不跑偏)
- `move_basic`A 页面块 → Move 到 B;断言 A 不存在、B 存在,且块 `blockId` 未变化。
- `embed_basic`A 页面块 → Embed 到 B;断言 A 仍存在、B 出现 `blockReference(targetBlockId=...)`。
- `embed_edit_sync`:在 B 的嵌入引用中编辑 → 刷新 A → 断言源块同步变化。
- `embed_same_page_forbidden`Embed 目标选择当前页 → UI 提示/不可选,且服务端拒绝。
- `embed_delete_only_reference`:删除 B 的引用块 → A 源块仍存在。
### 7. 迁移实施顺序(避免一次性大爆炸)
1) 先落地 `blockReference` 块类型与只读渲染(不做同步编辑)。
2) 上 `block_index` 并在保存 content 时维护;补齐 `GET /api/blocks/get`。
3) 实现 `POST /api/blocks/embed` + UI 接入(替换旧 documents/embed 的错误语义)。
4) 实现 `POST /api/blocks/move`(跨文档移动块子树)。
5) 最后做 `POST /api/blocks/patch`,实现嵌入引用的同步编辑与限制(删除语义/跳转)。
@@ -1,167 +0,0 @@
# 文件树(类 VSCode Explorer)实现顺序 + 对应文件 + 必测用例
> 目标:在「文件模式」页面树中实现接近 VSCode Explorer 的体验:点击行高亮选择(支持多选/范围选)、`Ctrl+C/Ctrl+V` 复制粘贴(真实文件/文件夹复制)、拖拽移动/按修饰键拖拽复制。
>
> 非目标:键盘导航(↑↓←→)与编辑器正文内复制粘贴行为。
## 0. 参考代码位置(VSCode
说明:VSCode Explorer 不是独立 React 组件,依赖 Workbench 服务体系;这里用于“行为/结构参考”,不直接搬运实现。
- [x] VSCode 参考仓库:`cankao/vscode`
- [x] 关键入口与文件(详见 `design/vscodetree`):
- [x] `cankao/vscode/src/vs/workbench/contrib/files/browser/files.contribution.ts`
- [x] `cankao/vscode/src/vs/workbench/contrib/files/browser/views/explorerView.ts`
- [x] `cankao/vscode/src/vs/workbench/contrib/files/common/explorerModel.ts`
- [x] `cankao/vscode/src/vs/base/common/resourceTree.ts`
- [x] `cankao/vscode/src/vs/workbench/contrib/files/browser/fileCommands.ts`
## 1. MNOTE 当前实现位置(改造落点)
- [x] 文件树(文件模式):`wolai-frontend/src/components/sidebar/file-tree.tsx`
- [x] 侧边栏状态/数据/选择/复制/拖拽集成:`wolai-frontend/src/components/sidebar/sidebar.tsx`
- [x] 文档树扁平化:`wolai-frontend/src/lib/sidebar-tree.ts`(已有 `flattenDocumentTree`
- [x] 复制页面(保留单页复制):`wolai-frontend/src/app/api/documents/duplicate/route.ts`
- [x] 递归复制入口:`wolai-frontend/src/app/api/documents/copy-tree/route.ts`
- [x] 附件 copy/move/delete/rename`wolai-frontend/src/app/api/media/batch/route.ts`
## 2. 实现顺序(建议小步提交,每步能回归)
### Step 0:建立“可见行模型”(TreeRow)——多选/复制/拖拽的共同基础
**要做**
- [x] 抽象 `visibleRows: FileTreeRow[]`(按展开状态 flattendoc + index.md + assets + 子 doc
- [x] `rowId` 全局唯一:`doc:<id>` / `index:<docId>` / `asset:<assetId>`
**新增/改造文件**
- [x] `wolai-frontend/src/lib/file-tree/types.ts`
- [x] `wolai-frontend/src/lib/file-tree/rows.ts`
**必测用例(Vitest:纯函数)**
- [x] `buildVisibleRows` 顺序稳定(展开/折叠后符合预期)
- [x] `rowId` 唯一且可逆解析
---
### Step 1:选择模型(Selection Model)——对齐 VSCode 的点击/多选/范围选/右键语义
**要做**
- [x] 去除复选框,改为“点击行高亮选择”
- [x] 多选覆盖 doc/index.md/asset 同一集合
- [x] 状态字段:
- [x] `selectedRowIds: Set<string>`
- [x] `anchorRowId: string | null`Shift 范围起点)
- [x] `focusedRowId: string | null`(最后交互行,用于粘贴目标推断)
**新增文件**
- [x] `wolai-frontend/src/lib/file-tree/selection.ts`(把交互写成纯函数,便于测)
**必测用例(Vitest:纯函数)**
- [x] 单击:清空并仅选中当前;更新 anchor/focus
- [x] Ctrl/Cmd+单击:切换选中;不清空其他
- [x] Shift+单击:按 `visibleRows` 做区间选择(覆盖式)
- [x] 右键:未选中项右键 → 先切为单选再弹菜单;已选中项 → 保持多选集合
- [x] 空白处单击清空选择
---
### Step 2`Ctrl+C / Ctrl+V`(仅文件树区域生效)
**要做**
- [x] 只在“文件树区域激活”时响应 `Ctrl+C/V`,不影响编辑器输入框/正文
- [x] 复制时写入自定义剪贴板 payload(同时提供内存 fallback
- [x] 粘贴目标推断:
- [x] focused 为 doc → 贴入其下
- [x] focused 为 index/asset → 贴入其所属 doc
- [x] 无 focused → fallback activeDocId
**新增文件**
- [x] `wolai-frontend/src/lib/file-tree/clipboard.ts`
**必测用例(Vitest**
- [x] payload 版本/字段校验(`type/version/action/rowIds`
- [x] 目标推断 3 分支覆盖(doc / index|asset / null
- [x]`input/textarea/contenteditable` 内不拦截 `Ctrl+C/V`
---
### Step 3:“真实文件/文件夹复制”后端能力(对齐 VSCode 语义)
**要做**
- [x] doc(文件夹/页面)复制:支持递归复制其内容(子页面 + 正文 + 附件)
- [x] asset(真实文件)复制:复制存储对象并创建新记录(`media/batch copy` + 重名策略)
- [x] 命名冲突:自动生成不冲突名称(文件夹与文件分别处理)
- [x] 虚拟附件(mindmap.json 等)不参与复制/拖拽(仅保留打开/下载逻辑)
**新增/改造 API**
- [x] 新增:`wolai-frontend/src/app/api/documents/copy-tree/route.ts`(递归复制入口,返回新旧 id 映射)
- [x] 保持:`wolai-frontend/src/app/api/documents/duplicate/route.ts`(现有单页复制不破坏)
- [x] 补齐:`wolai-frontend/src/app/api/media/upload/route.ts`(写入 `bucket/storage_path`,便于后续 copy/move
- [x] 补齐:`wolai-frontend/src/app/api/media/batch/route.ts`copy/move 支持 `storage_path` 或可解析的 `file_url`;重名策略)
**纯函数工具与测试**
- [x] 命名:`wolai-frontend/src/lib/file-tree/naming.ts`
- [x] 测试:`wolai-frontend/src/lib/file-tree/naming.test.ts`
---
### Step 4:拖拽移动/复制(含多选拖拽)
**要做**
- [x] 拖拽默认移动;按修饰键(`Alt`)为复制(浏览器层面 dropEffect 已设置;Alt-copy 建议人工补测一次)
- [x] 起拖行在选择集中 → 拖整个选择集;否则仅拖当前行并先切为单选
- [ ] 拖拽悬停反馈(drop feedback):
- [ ] 悬停到“收起的 doc(文件夹)行”时,仅该行变灰
- [ ] 悬停到“展开的 doc(文件夹)行”时,该 doc 的可见子节点范围一起变灰(类似 VSCode Explorer
- [x] drop 目标:
- [x] drop 到 doc 行:贴入其下
- [x] drop 到 index/asset 行:等同贴入其所属 doc
- [x] 禁止把 doc 拖到自身或后代(含多选去重/去后代)
- [x] 与“拖拽上传文件到页面”的 drop 行为区分(payload 不同)
**新增文件**
- [x] `wolai-frontend/src/lib/file-tree/dnd.ts`
- [x] `wolai-frontend/src/lib/file-tree/asset.ts`(判断“真实文件”附件)
**必测用例(Vitest:纯函数)**
- [x] `inferDropTargetDocId`doc / index|asset / null 推断正确(`wolai-frontend/src/lib/file-tree/dnd.test.ts`
- [x] `isInvalidDocDrop`:自拖/后代拖拦截(`wolai-frontend/src/lib/file-tree/dnd.test.ts`
- [x] `isRealFileAsset`:排除 mindmap 等虚拟附件(`wolai-frontend/src/lib/file-tree/asset.test.ts`
## 3. 手工验收(每步至少跑一遍)
- [x] 点击行高亮;active(当前打开页)与 selected(选中)同时可识别
- [x] `Ctrl/Cmd+单击` 多选;`Shift+单击` 连续范围选;右键语义正确
- [ ] `Ctrl+C/Ctrl+V`:仅树区域生效;编辑器正文不受影响(MCP 按键模拟不稳定,建议人工复核一次)
- [x] 复制 doc`/api/documents/copy-tree` 已验证返回 200(页面端 `fetch`
- [ ] 复制 asset:生成新的存储对象(不是引用),新文件可独立下载(需要至少 1 个真实附件用例)
- [x] 拖拽移动 doc:已通过页面拖拽触发 `/api/documents/move` 并验证 200
- [x] 从系统拖拽文件到文件树:落点为目标页面(doc 行 / index 行 / asset 行),触发 `/api/media/upload` 上传并作为“真实文件”附件挂到该页面下
- [ ] 拖拽悬停反馈:收起 doc 行仅该行变灰;展开 doc 行则其可见范围一起变灰
- [ ] 从系统拖拽单个文件:只创建 1 份附件记录(不应出现两个同名文件)
- [ ] 从系统拖拽文件到“当前打开的页面”:主编辑区自动插入 1 个附件/媒体块(可在页面正文中看到)
- [ ] Alt+拖拽复制:逻辑已接入(`event.altKey`),需要人工补测一次(MCP 暂无法“按住 Alt 拖拽”)
---
### Step 5:多选删除(右键删除作用于选择集)
**要做**
- [x] 右键删除:若右键对象在 `selectedRowIds` 内,则对整个选择集执行删除
- [x] index 行视作其所属页面(去重后再删)
- [x] 页面删除:移动到垃圾桶(复用 `/api/documents/delete`
- [x] 附件删除:沿用当前逻辑(`/api/media/batch delete`mindmap 走 `/api/mindmap/:docId`
- [x] 若页面被删除,则跳过其页面内已选附件删除(避免重复/无效操作)
**新增/改造文件**
- [x] `wolai-frontend/src/lib/file-tree/delete.ts`
- [x] `wolai-frontend/src/components/sidebar/sidebar.tsx`
**必测用例(Vitest:纯函数)**
- [x] 选中父/子页面时,仅删除父页面(去后代)
- [x] index 行与 doc 行同时选中时,页面 id 去重
- [x] 页面被删时,其下已选附件不参与附件删除
**手工验收**
- [x] 多选后右键任一已选行 → 点击“删除到垃圾桶”,应删除整个选择集(而不是仅最后右键项)
- [ ] 多选包含「页面 + 附件」时:无论最后右键落在页面行还是附件行,“删除”都应按选择集执行
-38
View File
@@ -1,38 +0,0 @@
# Luckysheet 全屏首次加载异常(问题清单与修复 Checklist)
## 现象(用户反馈)
- 全屏打开在线表格时,前 1~2 次渲染不完整:Luckysheet 工具栏/公式栏缺失或布局异常;刷新后再打开才恢复正常。
- DevTools 的 Sources/页面树里看不到预期的 `/luckysheet/*` 静态资源(怀疑资源未加载或加载顺序异常)。
## 目标
- 全屏首次打开就稳定显示完整 Luckysheet UI(工具栏、公式栏、sheetbar 等)。
- 资源加载可观测(能确认 JS/CSS 确实加载完成)。
- 不破坏现有 Convex 数据读写链路(`/api/tables/*` -> Convex query/mutation)。
## Checklist(按优先级)
### A. 资源加载与初始化时序(高优先级)
- [ ] 确认 Luckysheet CSS 与 JS 都已加载完成,再允许 `create()`(避免“JS ready 但 CSS 未 ready”导致的高度/布局计算错误)。
- [ ] 首次创建后触发一次 `resize`(部分情况下 Luckysheet 需要通过 resize 重新计算 UI 布局)。
- [ ] 记录/暴露资源清单(仅 dev):便于确认当前页面是否完成注入与版本号是否一致。
### B. 全屏容器可见性与布局(高优先级)
- [ ] Luckysheet `create()` 时容器必须“已占位且可计算尺寸”(不要在 `display:none` 的容器里初始化)。
- [ ] Loading/Error 采用绝对定位遮罩,避免通过隐藏容器实现 loading(防止尺寸为 0 导致布局计算异常)。
### C. 与 Convex 数据链路的时序(中优先级)
- [ ] 表格数据(snapshot/schema/title)获取完成后再创建实例,避免多次 create/destroy 抖动。
- [ ] 保存/同步触发频率可控(debounce),避免高频 mutation 导致卡顿(尤其在外网高延迟场景)。
### D. 观测与回归(中优先级)
- [ ] 在控制台输出关键阶段日志(仅 dev):`资源加载完成` / `create` / `destroy` / `resize`
- [ ] 复现用例:首次全屏打开(冷启动)、关闭后再开、刷新后再开、嵌入 iframe 与全屏互不影响。
## 已实施修复(对应 A/B
- `wolai-frontend/src/components/online-table/useLuckysheetLoader.ts`CSS 注入改为等待 `onload`,并与 JS 一起作为 ready 条件;dev 下暴露 `window.__wolaiLuckysheetResources` 便于排查。
- `wolai-frontend/src/components/online-table/FullScreenTableEditor.tsx`:容器始终占位 + overlay 遮罩;`create()` 后触发 `resize`(含兜底调用 `luckysheet.resize?.()`)。
## 验证步骤(建议)
1. 冷启动后打开含在线表格的文档,点击“全屏编辑”,观察工具栏/公式栏是否一次成功显示。
2. 打开 DevTools -> Network / Sources,确认首次打开会请求 `/luckysheet/*`,且 CSS/JS 不报错。
3. 控制台查看 `window.__wolaiLuckysheetResources`(dev 模式)是否存在,以及资源路径版本号是否一致。
-144
View File
@@ -1,144 +0,0 @@
# Mindmap AI Agent API 设计(读写思维导图,类似 CLI 工具)
## 目标
让 AI 不再“只输出一段文本”,而是像 CLI 一样:
1. **读**:获取当前页面中某个 Mindmap 的完整数据(包含节点、链接、引用)。
2. **改**:以结构化的 `ops`(操作序列)形式修改导图(新增/删除/改名/加链接/加引用/加备注)。
3. **可追溯**:每次 AI 修改都能记录“为什么这样改/引用来自哪里”(refs 作为第一公民)。
4. **安全**:必须校验登录态 + document 权限;只能操作该 document 下的 mindmap 文件。
## 现状(简述)
- 目前 Mindmap 的保存/加载已经存在(按文件树保存,支持同页多个独立 mindmap)。
- AI v2 的 `/api/mindmap-ai/outline-to-mindmap` 能把 PDF 转成 mindmapData(节点带 `hyperlink``refs`)。
- 仍缺少:AI 直接对“已有导图”做增量修改的能力(例如“补完此节点”“根据搜索扩展子节点”)。
## 设计原则
- **客户端只负责 UI/交互**:选择节点、展示进度、应用结果。
- **服务端负责权限与落盘**:验证用户、读写 mindmap JSON、生成 ops、应用 ops。
- **AI 输出必须是 ops**:避免 AI 直接吐一棵全量树造成覆盖/丢数据;也利于审计与回滚。
- **引用强制**:默认每个新增节点都要给 `refs`(至少页码/URL),没有引用则标记为 `待核验`
## 核心数据结构
### 1) Mindmap 节点引用 `NodeRef`(已在实现中使用)
```ts
export type NodeRef = {
kind: "pdf" | "docx" | "pptx" | "url";
assetId?: string;
fileUrl?: string;
page?: number; // 1-based
slide?: number; // 1-based
title?: string;
snippet?: string;
};
```
### 2) 操作协议 `MindmapOp`
> 说明:这里的 `uid` 指 simple-mind-map 节点 `data.uid`(当前实现已使用)。
```ts
export type MindmapOp =
| { op: "addChild"; parentUid: string; node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string } }
| { op: "addSiblingAfter"; targetUid: string; node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string } }
| { op: "updateText"; uid: string; text: string }
| { op: "setHyperlink"; uid: string; hyperlink: string | null }
| { op: "setRefs"; uid: string; refs: NodeRef[] }
| { op: "appendNote"; uid: string; markdown: string }
| { op: "deleteNode"; uid: string };
```
### 3) 服务端应用 ops(纯 JSON 层)
- 以“树遍历 + uid 索引”应用变更,保证:
- 不依赖浏览器端实例;
- 不依赖 simple-mind-map 内部状态;
- 可在服务端记录变更日志。
## API 设计(建议新增)
### 1) 读取导图
`GET /api/mindmap/[documentId]/[mindmapId]`
返回:
```json
{
"mindmapId": "xxx",
"documentId": "xxx",
"data": { "data": { "text": "...", "uid": "..." }, "children": [] },
"updatedAt": "2026-01-09T00:00:00.000Z"
}
```
### 2) 应用 ops(增量修改 + 自动保存)
`POST /api/mindmap/[documentId]/[mindmapId]/ops`
入参:
```json
{
"ops": [ { "op": "addChild", "parentUid": "...", "node": { "text": "...", "hyperlink": "...", "refs": [] } } ],
"actor": { "kind": "ai", "provider": "online", "model": "gemini-2.5-flash" },
"reason": "补完该节点:……(可选)"
}
```
出参:
```json
{
"ok": true,
"applied": 7,
"data": { "data": { "text": "...", "uid": "..." }, "children": [] }
}
```
### 3) AI 补完(服务端编排:检索 → 生成 ops → 应用 ops)
`POST /api/mindmap-ai/expand-node`
入参(建议):
```json
{
"documentId": "xxx",
"mindmapId": "xxx",
"targetUid": "xxx",
"instruction": "请补完该节点,要求每条必须带引用",
"sources": { "rag": true, "searxng": true }
}
```
输出:
```json
{
"ok": true,
"providerUsed": "online",
"ops": [ ... ],
"applied": 7
}
```
> 注意:该接口内部会调用 `/api/mindmap/[...]/ops` 做落盘,避免重复代码。
## 与 SearxNG / RAG 的关系
- 对“搜索补完”:服务端先走检索(LightRAG + 可选 SearxNG),把证据(标题/URL/snippet)整理成短上下文,再让在线 AI 输出 `MindmapOp[]`
- 必须要求:每个新增节点都附带 refs(url 或 pdf page),否则标记为“待核验”并限制输出量。
## 验收建议(后续 pw-tests
1. 选中某节点,点击“AI 补完”,等待生成。
2. 导图新增 ≥ 3 个子节点。
3. 每个新增节点包含可点击 `hyperlink``refs`
4. 刷新页面后仍存在(说明落盘成功)。
-294
View File
@@ -1,294 +0,0 @@
# Mindmap AI v2 实施计划(文档大纲 / 小结带引用 / 搜索补全)
> 目标:把当前“仅能对话并返回 Markdown 列表”的 AI,升级为**可读可用**的“文档驱动思维导图”能力:
> 1) PDF/Word/PPT 按目录与大纲生成思维导图,并且节点可跳转到对应页/幻灯片;
> 2) 基于文档内容生成小结/知识点,并附带可点击引用(页码/链接);
> 3) 支持在节点上“搜索/查询后补完内容”(RAG + 可选联网检索)。
---
## 0. 现状与问题
### 0.1 当前实现(代码落点)
- `wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx`:AI 面板目前是“本地 Ollama /api/chat 流式输出 + Markdown 解析为节点”。
- `services/ingest_service`:已具备 **Supabase -> MinerU -> LightRAG** 的自动入库链路(用于全文检索/RAG)。
- `services/rag_gateway`:封装了对 LightRAG 的 HTTP 调用(`/query``/query/data`),可作为统一 RAG 网关。
- `wolai-frontend/src/app/onlyoffice/page.tsx`OnlyOffice 文档查看/编辑入口(目前用于 docx/pptx/xlsx 等)。
- `wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx`:节点超链接通过 `SET_NODE_HYPERLINK` 直接写入 URL。
### 0.2 为什么“离想象差很远”
当前 AI 只有“生成文本 -> 解析成节点”的能力,缺少:
- **与文档绑定**:没有把 PDF/Word/PPT 的结构(目录/标题层级)变成导图;
- **可验证的引用**:没有“这句话来自第几页/哪一段”,因此难以沉浸式阅读与回溯;
- **补全的检索依据**:没有把“补完节点”变成“基于检索结果 + 引用”输出,容易胡编;
- **工程化形态**:没有任务、进度、缓存、失败重试、可复用数据结构。
---
## 1. 用户故事与验收标准(以你的测试文件为准)
### 1.1 文档按目录/大纲生成导图(PDF/Word/PPT
**用户故事**
- 我上传(或选中)一个文档(PDF/Word/PPT),点击“按大纲生成思维导图”,自动生成多级节点。
- 我点击任意节点,可以跳转到该文档对应页(PDF)或对应位置(Word/PPT 至少能定位页/提示页码,理想是直接跳转)。
**验收**
-`wolai-frontend/test/卤化反应原理_1-9.pdf`
- 能生成至少 2 级结构(中心主题 -> 章节 -> 小节)。
- 至少章节级节点带可点击引用(页码/链接)。
- 点击节点后打开的 URL 包含 `#page=<n>`PDF 以浏览器内建查看器为准)。
### 1.2 小结/知识点生成(带链接引用)
**用户故事**
- 我选中“生成小结”,AI 输出“关键结论/反应机理/注意点”,每条结论附带引用(页码链接或文档片段来源)。
**验收**
-`wolai-frontend/test/卤化反应原理测试.pdf`
- 生成不少于 N(建议 8)条要点;
- 每条要点至少 1 个引用(页码/链接);
- 引用能点击打开对应 PDF 页。
### 1.3 搜索/查询后补完节点内容
**用户故事**
- 我选中一个/多个节点,输入“补完方向/问题”,AI 基于检索结果补充子节点或备注,附带引用。
**验收**
- 对同一 PDF 文档:
- “补完”后的内容至少包含 3 个子节点或 1 段结构化备注;
- 结论带引用(页码/链接);
- 不允许“无引用的长篇自由发挥”(默认强制引用)。
---
## 2. 总体方案(推荐架构)
核心原则:**先结构化,再生成**;**先检索证据,再写结论**;**引用是第一公民**。
### 2.1 三层能力拆分
1) **文档解析层(Document -> Outline/Chunks**
- 输出:`DocOutline`(层级标题 + 页码/位置)与 `DocChunks`(可引用文本块)。
2) **RAG/生成层(Outline/Chunks -> Mindmap/Summary/Expansion**
- 输出:思维导图节点树(含引用)、小结节点(含引用)、补全子节点(含引用)。
3) **渲染/交互层(Mindmap UI**
- 节点点击:打开引用的文档页/位置;支持“查看引用”“展开更多证据”。
### 2.2 数据来源优先级(PDF
按可靠性排序:
1) PDF 自带书签/目录(`pypdf` outline)→ **最靠谱**(本次 `卤化反应原理_1-9.pdf` 没有 outline
2) MinerU 解析结果(建议开启 `return_content_list`/`return_middle_json`)→ 可拿到更结构化的块,并可能带页信息
3) `pypdf` 分页提取文本 + 标题检测(规则 + LLM 辅助)→ 兜底方案
---
## 3. 关键数据结构(建议新增/统一)
### 3.1 引用结构 `NodeRef`
用于节点的“可点击跳转”和“可追溯引用”:
```ts
export type NodeRef = {
kind: "pdf" | "docx" | "pptx" | "url";
assetId?: string; // 优先用 assetId,避免 URL 过期
fileUrl?: string; // public URL 或 signed URL(兜底)
page?: number; // PDF 页码(1-based
slide?: number; // PPT 页码/幻灯片序号(1-based
title?: string; // 引用标题(例如“2.1 卤化机理”)
snippet?: string; // 可选:引用片段
};
```
### 3.2 节点存储方式
- **短链**(推荐):`node.data.refs: NodeRef[]`(自定义字段)
- **展示用超链接**:仍使用 `SET_NODE_HYPERLINK` 写入一个可点击 URL(例如 PDF `...#page=3`
- **引用详情**:写入 `node.data.note``node.data.data.note`(保持兼容)为 Markdown
- `- [p3] 证据片段...`
- `- [p5] ...`
说明:simple-mind-map 对 `data` 的自定义字段容忍度较高,但要确保序列化/反序列化后不丢字段。
---
## 4. 具体功能方案
### 4.1 “按目录/大纲生成思维导图”
#### 4.1.1 API(建议新增)
新增 Next Route(前端同域,避免跨域/鉴权麻烦):
- `POST /api/mindmap-ai/outline-to-mindmap`
- 入参:`{ assetId, documentId?, prefer: "bookmark" | "mineru" | "heuristic" }`
- 出参:`{ mindmapData, outline, refsSummary }`
后台实现可以优先走 `services/ingest_service` 或直接复用其逻辑(后续可沉到 `wolai-backend`)。
#### 4.1.2 解析策略(对测试 PDF 友好)
因为 `卤化反应原理_1-9.pdf` 没有书签:
1) 使用 `pypdf` 逐页提取文本(已有代码可参考 `services/ingest_service/app/services/auto_indexer.py`);
2) 对每页文本做标题候选抽取(规则:编号标题如 `1.` `1.1` `(一)` 等 + 行长/标点密度);
3) 用 LLM(可走 Ollama)把候选标题整理为层级结构,并返回 `{title, level, page}`
4) 转换为 mindmap:中心主题=文件名/第一页大标题,子节点=章节;每个章节节点写入 hyperlink `fileUrl#page=<page>`
#### 4.1.3 Word/PPT
阶段 1 先保证:
- Word/PPT 也能“生成大纲导图”,但跳转能力允许降级:
- 若 OnlyOffice 支持跳转 API:实现真实跳转;
- 若不支持:点击节点打开文档,并弹出“建议跳转页码/幻灯片序号”的提示(至少可用)。
(后续再把 Word/PPT 的“位置”提升为可跳转锚点)
---
### 4.2 “小结/知识点生成(带引用)”
#### 4.2.1 证据来源:优先 LightRAG,其次本地分页文本
优先走 `services/rag_gateway`
- `POST /rag/query`:拿到 `response + references`LightRAG 会返回引用列表)
- `POST /rag/graph`:拿结构化分块/引用(用于“点开看证据/更多片段”)
如果 LightRAG 返回引用信息不足以映射到页码,需要补齐“页码映射”:
- 方案 A(推荐):在入库时把“每页”作为 chunk,并把 `file_source + page` 写入可回传字段(需要改 ingest_service 装饰文本或分块入库方式)
- 方案 B(兜底):在本地用 `pypdf` 重新做“分页文本”,对引用片段做模糊匹配定位页码
#### 4.2.2 输出形态
在 Mindmap 里提供两种落地方式:
- 生成到“备注”(适合长文本 + 引用列表)
- 生成到“子节点”(每条要点一个子节点,子节点携带 `refs` 和 hyperlink
默认要求:**每条要点至少 1 个引用**(无引用则标记为“待核验”,并提示用户继续检索)。
---
### 4.3 “搜索/查询后补完节点内容”
#### 4.3.1 两种检索源(可配置)
1) **本地文档库检索**LightRAG(默认)
2) **联网检索**SearxNG(本仓库已存在 Docker 服务,可直接用)
##### 4.3.1.1 SearxNG 接入约定(基于仓库现状)
仓库已存在 `services/searxng-docker/.env`,其中包含:
- `SEARXNG_BASE_URL=http://127.0.0.1:8889`
- `SEARXNG_API_TOKEN=...`
建议接入方式:
- 前端**不要**直连 searxng(避免 token 暴露),通过 Next Route 代理:
- `POST /api/search/searxng`
- 入参:`{ q: string, count?: number, lang?: string }`
- 出参:`{ results: Array<{ title: string, url: string, snippet?: string, engine?: string }>} `
SearxNG 查询接口(推荐 JSON):
- `GET ${SEARXNG_BASE_URL}/search?q=<query>&format=json&language=zh-CN&categories=general&safesearch=1`
鉴权策略(需要实际跑通后确定,做成可配置):
- 方案 A:不加鉴权(本地内网服务)
- 方案 B:携带 token(例如 `X-API-Key` / `Authorization: Bearer` 之一;以你当前 searxng 配置为准)
> 注意:SearxNG 返回结果字段不同版本略有差异,后端代理层要做一次“结果归一化”和去重(按 url)。
#### 4.3.2 交互与输出
- 输入:用户选中节点 + “补完问题/方向”
- 系统 prompt 固定:强制输出结构化(Markdown 列表)+ 引用
- 输出策略:
- 子节点补全:把回答拆成 3~8 个子节点追加
- 备注补全:写入 note,并附引用列表
---
## 5. 前端 UI 改造建议(MindmapSidebar 的 AI 面板)
把当前“模型/地址/系统提示”改为“面向功能的工作流”,保留“高级设置”折叠:
1) **从文档生成**
- 选择文档资产(assetId)+ 生成按钮 + 进度(解析中/生成中/完成)
2) **生成小结**
- 可选:作用范围(整篇/选中节点对应章节)+ 要点数量 + 输出到(子节点/备注)
3) **补完节点**
- 输入框 + 检索源选择 + 输出到(子节点/备注)
同时在节点右键/工具栏增加:
- “打开引用”
- “查看引用(弹窗列出页码/片段)”
---
## 6. 工程落地步骤(里程碑)
> 建议按“先可用,再做强”的顺序推进。
### M1(1~2 天):PDF 大纲导图(可跳页)
- [ ] 新增 `/api/mindmap-ai/outline-to-mindmap`(仅支持 PDF
- [ ] 实现 “pypdf 分页文本 + 标题候选 + LLM 整理层级”
- [ ] MindmapSidebar 增加“从 PDF 生成导图”入口(基于 assetId)
- [ ] 节点写入 hyperlink`publicFileUrl#page=<n>`
- [ ] pw-tests:导入 `卤化反应原理_1-9.pdf`,断言生成节点数与 `#page=` 链接存在,并截图
### M2(2~3 天):小结生成(强制引用)
- [ ] 接入 `services/rag_gateway``/rag/query`(前端通过 Next Route 代理)
- [ ] 让小结输出“要点 + 引用”
- [ ] 引用映射到 `#page=`(先用本地分页匹配兜底)
- [ ] pw-tests:对 `卤化反应原理测试.pdf` 生成 N 条小结并截图
### M3(2~4 天):搜索补全节点(RAG)
- [ ] AI 面板增加“补完节点”模式
- [ ] 选中节点作为上下文,拼接检索 query:LightRAG(文档内)+ SearxNG(联网)
- [ ] 新增 `POST /api/search/searxng` 作为代理(隐藏 token + 统一返回格式)
- [ ] 将 searxng 的 `title/url/snippet` 作为“外部证据”,与 LightRAG 引用一起喂给模型生成
- [ ] 防胡编:无引用则提示“需要更多证据/换关键词”
- [ ] pw-tests:选中某节点补完,校验新增子节点与引用
### M4(可选):Word/PPT 真跳转
- [ ] 调研 OnlyOffice 是否支持跳页/跳幻灯片 API(不支持则维持降级)
- [ ] 若支持:在 `/onlyoffice` 页面接收 `page/slide` 参数并调用 DocsAPI 跳转
---
## 7. 风险与对策
- **PDF 无书签(当前测试文件就是)**:必须做“文本标题抽取 + LLM 结构化”的兜底。
- **页码引用难**:短期先用“本地分页匹配”;中期把“页级分块”纳入入库,引用直接带页码。
- **URL 过期/权限**:优先存 `assetId`,点击时再换取可用 URL(必要时扩展 `/api/media/signed-url` 支持 assetId)。
- **性能**:解析/生成尽量放到后端(Next Route 或 wolai-backend),前端只跑轻量 UI;长任务使用 job 表/轮询。
---
## 8. 与 BlockNote 的集成点(后续)
- Slash Menu 可加入口(例如 `/mind ai``/mind from pdf`),插入/更新块可用 BlockNote 的 `insertOrUpdateBlockForSlashMenu``editor.updateBlock`(见 BlockNote 官方文档的 Suggestion Menus 示例)。
-50
View File
@@ -1,50 +0,0 @@
# 思维导图官方功能落地 Checklist(阶段推进)
在的实现并没有“重写”核心,而是直接用 simple-mind-map 做壳。wolai-frontend/package.json 里依赖的是
simple-mind-map@0.14.0-fix.1UI 层在 wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx
里一次性注册了 Painter、AssociativeLine、OuterFrame、Export、Formula、RichText、MiniMap、Select、
Drag、KeyboardNavigation、NodeImgAdjust 等插件,并做了 BlockNote 的数据/自动保存封装,样式配置也直
接拷自官方 mind-map/src/config/zh.js(见 mindmapOptions.ts 的注释)。
- cankao/mind-map-0.16.0 文件夹其实内置的库版本号还是 0.14.0-fix.1cankao/mind-map-0.16.0/simple-
mind-map/package.json),只是包含了官方 Vue Web 端与完整资源/插件集合;如果把它“直接引入”,要么嵌套 一套 Vue 应用,要么自己重新封装一层,成本高且会破坏现有 React/BlockNote 流程。
- 真正的差距不在“是否用官方”,而在“插件/功能覆盖度与版本”。目前缺口:未启用的官方插件(Search、
Scrollbar、TouchEvent、RainbowLines、Watermark、Demonstrate、Cooperate、MindMapLayoutPro 等)、导入 仅支持 JSON(官方还支持 markdown/xmind)、导出只暴露 JSON/PNG(官方支持 SVG/PDF/TXT/Markdown/
XMind)、布局 Pro、快捷键设置等也没露出 UI。
- 因此不建议把整套 mind-map-0.16.0/web 直接搬进来(框架不兼容、打包体积大、BlockNote 协同逻辑要重
写)。更可行的路径:
1. 升级核心库到官方最新编译产物(用 cankao/mind-map-0.16.0/simple-mind-map/dist 或重新 build),
保持 React 外壳不变;
2. 对齐官方插件清单,按需 MindMap.usePlugin 并在我们的工具栏/侧栏补入口;
3. 补齐导入/导出选项(markdown/xmind/svg/pdf/txt)、水印/彩虹线/滚动条等开关;
4. 用 simple-mind-map 的默认配置常量和主题列表替换手动拷贝,减少跟进成本;
5. 升级后回归测试:基础操作、导入导出、缩放/拖拽、快捷键、BlockNote 同步。
- 结论:现有方向没有“走弯路”,因为核心逻辑本就用的官方库;没必要嵌 Vue 端。重点是版本升级+插件/功能补
全,把 React 外壳和 BlockNote 适配保留,同时享受官方迭代。需要我按上面步骤先做库升级和插件补齐吗?
思维导图的要求:请参考cankao\kmind-plugin,思维导图应该是像当前luckysheet一样,能够在主页面中生成,并且可以使用斜杆菜单生成。当前主页面中的思维导图生成时会保存内容文件至当前的文件目录下。
思维导图布局:
- [x] 阶段1:顶栏工具组
- [x] 回退/前进/格式刷(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`
- [x] 同级/子节点插入、删除(同上 `ToolbarNodeBtnList.vue` 插入/删除按钮)
- [x] 资源操作:图片、图标、超链接、备注、标签、概要、关联线、公式、外框、AI(AI 先占位)(同上文件 image/icon/link/note/tag/summary/associativeLine/formula/attachment/outerFrame 按钮)
- [x] 目录/新建/打开/另存为/导入/导出(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Toolbar.vue` 导入/导出/更多)
- [x] 阶段2:右侧侧栏 Tabs
- [x] 节点样式、基础样式、连线、主题、结构(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/BaseStyle.vue``Style.vue``Theme.vue``Structure.vue`
- [x] 大纲视图与编辑、设置(滚轮行为/自由拖拽/AI等)、图标/贴纸、公式、备注、AI 对话(参考:`OutlineSidebar.vue``SidebarTrigger.vue`;图标/贴纸 `NodeIconSidebar.vue`;公式 `FormulaSidebar.vue`
- [] 阶段2附加:组件化与文件化
- [] Mindmap 组件可嵌入笔记,创建时自动生成子文件并通过斜杠菜单插入
- [ ] 阶段3:浮动导航与 MiniMap
- [ ] 右下导航条(回到根、适配画布、缩放、只读切换、mini map 开关、搜索、全屏 、语言/滚轮模式等)(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/NavigatorToolbar.vue`
- [ ] MiniMap 视窗拖动(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Navigator.vue`MiniMap 插件在 `Edit.vue` 注册)
- [ ] 阶段4:状态栏
- [ ] 左下字数/节点统计(监听 data_change)(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Count.vue`
- [ ] 阶段5:清理与同步
- [ ] 移除旧自研画布/入口,保留新版实现
- [ ] 同步 `src``wolai-frontend` 两套前端
- [ ] 阶段6:测试验证
- [ ] desktop:hot 本地跑通,手测核心按钮/侧栏/mini map/统计栏
- [ ] 补充必要的快捷键/命令映射用例(人工验证)
> 完成每个子项后请及时勾选,便于阶段推进与回溯。
-40
View File
@@ -1,40 +0,0 @@
在 VS Code 源码中,资源管理器(Explorer)相关实现分散在多个目录/文件中。以下是关键文件和目录的定位(用于对照实现与阅读)。
## 1) 入口注册(Explorer Contributions
- `src/vs/workbench/contrib/files/browser/files.contribution.ts`
- 注册资源管理器相关视图、命令、菜单等贡献点。
## 2) 视图实现(Explorer View
- `src/vs/workbench/contrib/files/browser/views/explorerView.ts`
- Explorer 视图的渲染、布局与交互(树展示、展开折叠、选中、右键、拖拽等)。
## 3) 数据模型(Explorer Model
- `src/vs/workbench/contrib/files/common/explorerModel.ts`
- Explorer 的数据模型(树数据构建、过滤/排序、与文件服务交互)。
## 4) 通用资源树结构(ResourceTree
- `src/vs/base/common/resourceTree.ts`
- 基于 URI 的通用树结构实现,Explorer 使用的重要基础。
## 5) 文件相关命令(File Commands
- `src/vs/workbench/contrib/files/browser/fileCommands.ts`
- 浏览器端通用文件命令(新建/删除/重命名/复制粘贴等)。
- `src/vs/workbench/contrib/files/electron-browser/fileCommands.ts`
- 桌面端特有命令(如在系统文件管理器中显示等)。
## 6) 文件服务接口(File Service
- `src/vs/platform/files/common/files.ts`
- 文件服务核心接口(`IFileService`),提供文件系统读写与监听能力。
## 7) 辅助工具(URI/过滤等)
- `src/vs/base/common/resources.ts`URI/路径工具
- `src/vs/workbench/common/resources.ts`glob 匹配/过滤(排除文件等)
- `src/vs/workbench/contrib/files/common/files.ts`:文件资源通用类型
@@ -1,133 +0,0 @@
# Wolai 帮助中心对标:页面组成 / 页面操作缺陷项(MNOTE)
本文目的:基于 Wolai 帮助中心公开页面(含图片)做对标,把“页面组成、页面操作”相关能力整理成清单,并与当前仓库实现逐项比对,输出可落地的缺陷项与优先级。
## 1. 抓取与语料位置(可复现)
### 1.1 语料目录
- 语料根目录(最近一次抓取):`artifacts/wolai-help-center-v5/`
- 页面(Markdown + JSON blocks):`artifacts/wolai-help-center-v5/pages/`
- 图片(本地落盘):`artifacts/wolai-help-center-v5/images/`
- 关键词命中:`artifacts/wolai-help-center-v5/analysis/keyword_hits.json`
- “页面相关帮助页”大纲:`artifacts/wolai-help-center-v5/analysis/outlines_页面.md`
### 1.2 复现命令
1) 导出 Wolai blocks(文本/结构化语料):
```powershell
python scripts/wolai_help_center/export_wolai_help_center.py `
--seed-file scripts/wolai_help_center/seeds.txt `
--out artifacts/wolai-help-center-v5 `
--max-pages 200 `
--download-images
```
说明:若遇到图片 403(wostatic 缺少 `auth_key`),可使用 Playwright 兜底下载:
```powershell
node scripts/wolai_help_center/download_images_playwright.js --out artifacts/wolai-help-center-v5
```
## 2. Wolai(帮助中心)关于“页面组成 / 页面操作”的要点清单
说明:以下条目根据 `artifacts/wolai-help-center-v4/analysis/outlines_页面.md` 汇总(每项后附对标页)。
### 2.1 页面操作(Wolai:页面选项)
对标页:`pages/4TsNd1GmGB3RYbahUZy1bz__页面选项.md`
- 布局与显示:自适应宽度、小字体、标题目录(含快捷键/目录菜单/目录宽度自适应)、标题自动编号
- 安全与回退:编辑保护、撤回、页面历史
- 页面管理:删除页面、移动到…、嵌入到…
- 模板与链接:添加为空间公共模板、复制页面链接、复制页面链接(带标题)
- 引用:行内页面引用、嵌入页面引用
- 导出与统计:导出页面、字数统计、待办列表项统计
- 自定义页面:字体、布局、显示块引用数字、折叠引用页面列表、隐藏子页面、页面“嵌入到”默认位置
- 全局选项:显示块结构、拼写检查、Good Night 模式/跟随系统、导入…、飞行模式
### 2.2 页面基础与创建
对标页:`pages/iLJdcXJp8nByXA8KyWDCmN__页面.md`
- 创建页面
- 页面选项与自定义页面(入口与配置关系)
### 2.3 页面引用(双向链接)
对标页:`pages/doMqLaba4V76PByjJXacSc__页面引用.md`
- 创建页面引用入口:`[[``#`、文本样式工具条、快捷键
- 预引用 & 创建新页面
- 引用时创建页面的默认位置
- 复制页面引用链接
- 引用预览与别名
- 双向链接概念与常见问题
### 2.4 页面历史
对标页:`pages/6aAAjNB2E3oXiCQcCn7grX__页面历史.md`
### 2.5 页面权限与协作访客
对标页:
- `pages/rMnSw1JTt6EhRq5U6xmxYz__页面权限.md`
- `pages/8XtDuVgByhv7SDKAykmJDj__为页面添加协作访客进行协作.md`
- 权限:对象、类别、优先规则、最小规则、继承与覆盖、设定自己的权限
- 协作访客:添加/人数上限/权限调整/管理/移除/升级为空间成员/退出空间
### 2.6 页面关系图
对标页:`pages/ipygGPjLvTLMYYPyW2RDw6__页面关系图.md`
- 关系图说明、选项(布局方式/关系/最佳视图)
- 关系图操作(触控板/鼠标)
- 3D 关系图与操作
- 快捷键、单页面关系图
### 2.7 评论(页面/块)
对标页:`pages/urDEXF1q5AiBdqiqV6SYbX__对页面或块进行评论.md`
- 页面评论、块评论、选中文字评论、回复评论、页面评论聚合
## 3. 当前项目实现对照(基于仓库代码扫描)
### 3.1 已覆盖/已有雏形
- 页面选项(部分):`wolai-frontend/src/components/editor/page-options-sidebar.tsx`
- - 页面选项:自适应宽度、小字体、标题目录、标题编号、编辑保护、字数提示
- - 页面操作:撤回/重做、移动/嵌入、添加为模板、复制页面链接(含标题)、行内/嵌入页面引用、删除页面、导出、历史、评论入口
- 标题目录:快捷键 `Ctrl/Cmd + Shift + L`;目录菜单(层级/完整标题/关闭):`wolai-frontend/src/components/editor/document-toc.tsx`
- 待办统计:统计 `todoTotal/todoDone` 并展示:`wolai-frontend/src/components/editor/blocknote-editor.tsx``wolai-frontend/src/components/editor/page-options-sidebar.tsx`
- 自定义页面(部分):折叠反向引用:`wolai-frontend/src/components/editor/page-backlinks-panel.tsx`
- 全局选项(个人环境):显示块结构/拼写检查/主题/飞行模式:`wolai-frontend/src/store/app-preferences.ts``wolai-frontend/src/components/providers/app-preferences-hydrator.tsx`
- 评论(页面/块):`wolai-frontend/convex/comments.ts``wolai-frontend/src/components/editor/document-comments-drawer.tsx`
- 页面历史(临时快照,非 Wolai“5 分钟服务器快照”):`wolai-frontend/src/components/editor/document-history-drawer.tsx`
- 页面引用/反向链接(基础):`wolai-frontend/src/components/editor/page-backlinks-panel.tsx``wolai-frontend/src/components/editor/blocks/PageReferenceBlock.tsx`
- 共享/权限(基础):`wolai-frontend/src/components/sharing/document-share-dialog.tsx`
- 公开/共享、只读/可编辑、禁止下载/禁止复制(存在“后端未更新”的提示分支)
- 删除/恢复/清空垃圾桶等:`wolai-frontend/src/app/api/documents/*``wolai-frontend/src/components/sidebar/sidebar.tsx`
### 3.2 缺陷项(与 Wolai 帮助中心对标)
#### P0:影响核心闭环(协作/数据安全/关键入口缺失)
- 评论(已新增模块,但能力未对齐):Wolai 支持页面/块/选中文字评论、未解决数量气泡、关闭/重新打开、编辑/删除、评论聚合筛选;当前实现已覆盖“页面/块评论 + 回复 + 解决/未解决”,但缺少“选中文字评论/高亮样式/气泡计数/编辑删除/聚合筛选/快捷键 `Ctrl/Cmd + Alt/Opt + M`”。
- 页面关系图:Wolai 帮助中心定义了完整的 2D/3D 关系图与交互;按你的 P0 指令“暂不需要页面关系”,当前可保持缺口(建议仅保留入口一致性,避免误导)。
- 权限规则:按你的 P0 指令“暂不改变”,当前不动;但后续若统一 mindmap / luckysheet / 文件树共享,建议补齐“继承/覆盖/优先/最小规则”的产品说明与 UI(避免割裂)。
#### P1:对标体验差(入口分散/选项不全/统计缺口)
- “页面选项”仍有细节缺口:
- “添加为空间公共模板”:当前为 `is_template`(模板区可见),但未区分“多人空间公共模板/权限限制/取消模板”。
- “导出页面”:当前导出为 JSON 快照;缺少多格式导出(与“空间安全设置禁用导出”联动)。
- “页面历史”:当前为前端临时快照;缺少 Wolai“每 5 分钟生成服务器快照 + 版本浏览 + 恢复此版本”。
- “字数统计”:当前只有字/字符/块/待办;缺少悬浮展开的分类统计。
- “自定义页面”不完整:Wolai 自定义页面包含字体/布局/隐藏子页面/显示块引用数字/嵌入默认位置等;当前仅实现“折叠反向引用”。
- “标题目录”宽度自适应:当前已增加基础自适应宽度,但缺少 Wolai 的“极窄圆点竖线”样式与目录项层级折叠按钮。
- “全局选项”缺口:Wolai 全局含“导入…”入口;当前未对齐(功能可能在其它入口,需确认是否纳入 P1)。
#### P2:增强项(可后置,但建议纳入路线图)
- 协作访客:Wolai 有“协作访客”这一权限对象;当前未看到对应模型与入口(可能需要与群组/邀请体系结合重做)。
- 拼写检查 / Good Night / 跟随系统 / 飞行模式:已实现基础能力与快捷键(主题/块结构),但仍需补齐更多 Wolai 式入口与文案(例如:空间层默认开关)。
## 4. 对 mindmap / luckysheet / VSCode 文件树共享的统一建议(避免割裂)
当前项目已加入 mindmap、luckysheet 与 VSCode 文件树/共享能力;为了让“页面操作”一致,建议把以下能力抽象为跨类型统一的“文档资源操作层”(页面/表格/思维导图/文件 都走同一套):
- 权限与共享:继承/覆盖规则与 UI 一致;“只读/可编辑/禁止复制/禁止下载”对所有资源类型都可用且可验证。
- 历史与回滚:页面、表格、思维导图、文件都能产生“可恢复历史”(至少保存点 + 恢复确认 + 只读保护)。
- 关系图与评论:关系图节点不仅是“页面”,也能包含表格/思维导图/文件;评论可挂载到“资源”或“块/节点/单元格”。
---
如果你希望我下一步直接把这些 P0/P1 缺陷项拆成可执行任务(每项:入口、数据模型、接口、UI、验收用例),我可以继续基于本报告细化成一份实现清单(并标注建议改哪些文件/组件)。
-136
View File
@@ -1,136 +0,0 @@
# Wolai 深度复刻计划 (Wolai Deep Replication Plan)
**目标**:深度复刻 Wolai 的核心体验,重点解决左侧树状侧边栏的 Bug,并完善右侧编辑器的块交互(特别是「转为页面」功能)。
**基准**:严格遵循 `design/ui_react.md``design/PHASE_1.5_TURN_TO_PAGE_EXACT_CLONE.md`
## 1. 现状分析 & 问题定位
当前项目已具备基础骨架(Next.js + Supabase + BlockNote),但在精细度和交互上仍有差距:
* **侧边栏 (Sidebar)**
* 存在 Bug:拖拽抖动、状态同步延迟、无限嵌套渲染可能出错。
* 缺失细节:右键菜单(重命名/删除)、多选操作、精确的 Hover/Active 样式。
* **编辑器 (Editor)**
* 缺失核心功能:「转为页面」互转逻辑。
* 样式细节:BlockNote 默认样式需定制为 Wolai 的蓝色系 (#2563eb)。
## 2. 实施路线图
### 阶段 A: 侧边栏重构与修复 (Sidebar Overhaul)
**目标**:打造丝滑、无 Bug 的无限嵌套文件树。
1. **数据层优化**
## 3. 技术规范 (Strict Guidelines)
* **CSS 框架**Tailwind CSS (v4)。
* **图标库**`lucide-react` (通用), `react-icons` (特定品牌)。
* **状态管理**`zustand` (全局 UI 状态), `swr` / `react-query` (数据获取)。
* **拖拽库**`@dnd-kit/core`, `@dnd-kit/sortable` (推荐,比 react-dnd 更现代且易于控制样式)。
## 4. 验证计划
### 自动化测试
* (暂无,依赖手动验证)
# Wolai 深度复刻计划 (Wolai Deep Replication Plan)
**目标**:深度复刻 Wolai 的核心体验,重点解决左侧树状侧边栏的 Bug,并完善右侧编辑器的块交互(特别是「转为页面」功能)。
**基准**:严格遵循 `design/ui_react.md``design/PHASE_1.5_TURN_TO_PAGE_EXACT_CLONE.md`
## 1. 现状分析 & 问题定位
当前项目已具备基础骨架(Next.js + Supabase + BlockNote),但在精细度和交互上仍有差距:
* **侧边栏 (Sidebar)**
* 存在 Bug:拖拽抖动、状态同步延迟、无限嵌套渲染可能出错。
* 缺失细节:右键菜单(重命名/删除)、多选操作、精确的 Hover/Active 样式。
* **编辑器 (Editor)**
* 缺失核心功能:「转为页面」互转逻辑。
* 样式细节:BlockNote 默认样式需定制为 Wolai 的蓝色系 (#2563eb)。
## 2. 实施路线图
### 阶段 A: 侧边栏重构与修复 (Sidebar Overhaul)
**目标**:打造丝滑、无 Bug 的无限嵌套文件树。
1. **数据层优化**
## 3. 技术规范 (Strict Guidelines)
* **CSS 框架**Tailwind CSS (v4)。
* **图标库**`lucide-react` (通用), `react-icons` (特定品牌)。
* **状态管理**`zustand` (全局 UI 状态), `swr` / `react-query` (数据获取)。
* **拖拽库**`@dnd-kit/core`, `@dnd-kit/sortable` (推荐,比 react-dnd 更现代且易于控制样式)。
## 4. 验证计划
### 自动化测试
* (暂无,依赖手动验证)
### 官方操作策略 (Official Operational Strategies)
* **块操作逻辑**
* **转换 (Turn into)**:核心逻辑是“保留内容,改变形式”。通过 `::` 菜单、快捷键或 `/` 菜单触发。
* **层级 (Hierarchy)**
* **缩进**:使用 `Tab` / `Shift+Tab` 控制缩进,建立父子关系。
* **嵌套**:拖拽块到另一个块的**内部**(下方缩进位)可创建嵌套结构。
* **布局 (Layout)**:通过拖拽块到左右边缘(显示垂直蓝线)创建分栏。支持 `/2` 等快捷指令。
* **多选 (Multi-select)**:支持鼠标框选或 `Shift+Click` 连续选择。`Esc` 选中当前块,`Shift+Arrow` 多选。
* **Markdown 自动格式化**:输入 `* ` 自动变列表,`# ` 变标题,`[] ` 变待办,`()` 变高级待办,`+[]` 变折叠待办。
* **引用系统 (Reference System)**
* **块引用 (Block Ref)**
* **行内引用**`[[``# ` 触发。虚线下划线,不可编辑,点击跳转。快捷键 `Esc` -> `H`
* **嵌入引用**:独立块,左侧虚线边框,可编辑(有限制)。快捷键 `Esc` -> `Q`
* **页面引用 (Page Ref)**
* **双向链接 (Backlinks)**:页面底部显示“反向引用”列表。
* **预引用**:引用不存在的页面时,可创建“预引用”占位符或直接新建页面。
* **聚合应用**:如 `[[TODO]]` 标签聚合。
* **页面选项 (Page Options)**
* **宽屏模式**:切换定宽 (760px) / 自适应宽度。
* **小字体**16px -> 14px 全局缩放。
* **标题目录 (TOC)**:右侧悬浮,层级 H1-H4 + 子页面。
* **统计**:字数统计、块数统计。
* **待办列表 (Todo List)**
* **进度条联动**:父块为进度条时,自动计算子待办块的完成百分比。
* **高级待办**:4种状态(未做、进行中、完成、取消)。`Alt+Click` 切换取消。
* **图片 (Image)**
* **插入**:命令、拖拽、复制、URL。
* **OCR**:需手动在菜单中点击“文字识别”以建立索引。
* **编辑**:支持裁剪、马赛克、绘图。
### 手动验证清单
* **隐式 OCR**:官方文档未强调 OCR 功能,印证了其作为后台搜索索引服务的定位,而非前台核心编辑功能。
* **文件预览**:依赖第三方服务或浏览器原生能力,保持轻量化。
### 手动验证清单
1. **侧边栏测试**
* [ ] 创建 3 层嵌套页面,确认缩进正确。
* [ ] 拖拽页面 A 到页面 B 内部,确认无刷新实时更新。
- **底部**:结果计数("共 N 条匹配结果"+ 快捷键提示("Ctrl+Enter 新窗口打开", "Alt+Enter 右侧边栏打开")。
- **OCR 策略 (Hidden OCR)**Live Site 验证确认):
- **机制**:图片上传后,后端异步进行 OCR 识别。
- **搜索**:识别的文本会被索引,支持通过关键词搜索到包含该文本的图片页面。
- **展示**:默认情况下,OCR 文本**不直接显示**在页面上(保持页面整洁),而是作为图片的元数据存在。
- **高亮**:在搜索结果摘要中,会高亮显示匹配的 OCR 文本。
- **图片块操作 (Image Block Operations)**Live Site 捕捉确认):
- **菜单项**
- **图片边框** (Toggle)。
- **设置图片超链接...**
- **评论**
- **对齐** (左/中/右)。
- **查看原图** / **全屏查看**
- **编辑图片** (裁剪/旋转等)。
- **文字识别 (OCR)** (手动触发入口)。
- **添加说明 (Caption)** / **说明文字居中**
- **下载**
- **恢复默认大小**
- **替换图片...**
- **文件预览 (File View)**Live Site 捕捉确认):
- **Office 文档 (.docx, .xlsx, .pptx)**
- **策略**:不自行开发渲染器,调用 **Microsoft Office Online****Google Docs Viewer** 服务。
- **实现**:在新标签页打开,或使用 iframe 嵌入 Office Online 预览链接。
- **组件推荐**`react-doc-viewer` (支持多种格式,自动降级) 或直接构建 Office Online URL。
- **PDF 文档**
- **策略**:使用 `react-pdf` 在应用内直接渲染,提供更好的阅读体验。
- **功能**:翻页、缩放、下载。
## 5. 下一步行动 (Next Steps)
建议优先执行 **阶段 A (侧边栏)**,因为它是整个应用的导航骨架,Bug 最影响体验。
然后执行 **阶段 B**,打通「块 <-> 页面」的流转。
-79
View File
@@ -1,79 +0,0 @@
# Wolai 深度复刻计划 (Wolai Deep Replication Plan)
**目标**:深度复刻 Wolai 的核心体验,重点解决左侧树状侧边栏的 Bug,并完善右侧编辑器的块交互(特别是「转为页面」功能)。
**基准**:严格遵循 `design/ui_react.md``design/PHASE_1.5_TURN_TO_PAGE_EXACT_CLONE.md`
## 1. 现状分析 & 问题定位
当前项目已具备基础骨架(Next.js + Supabase + BlockNote),但在精细度和交互上仍有差距:
* **侧边栏 (Sidebar)**
* 存在 Bug:拖拽抖动、状态同步延迟、无限嵌套渲染可能出错。
* 缺失细节:右键菜单(重命名/删除)、多选操作、精确的 Hover/Active 样式。
* **编辑器 (Editor)**
* 缺失核心功能:「转为页面」互转逻辑。
* 样式细节:BlockNote 默认样式需定制为 Wolai 的蓝色系 (#2563eb)。
## 2. 实施路线图
### 阶段 A: 侧边栏重构与修复 (Sidebar Overhaul)
**目标**:打造丝滑、无 Bug 的无限嵌套文件树。
1. **数据层优化**
* 确保 `documents` 表的 `parent_id` 递归查询高效(使用 Supabase recursive CTE)。
* 实现 `useSidebar` Hook,统一管理展开/折叠状态(持久化到 localStorage)。
2. **交互层重写 (dnd-kit)**
* 使用 `@dnd-kit/core` 替换现有的拖拽逻辑(如果现有不稳定)。
* **解决抖动**:实现 `DragOverlay`,确保拖拽时有清晰的半透明快照。
* **精确落点**:实现 "Drop Indicator"(蓝色横线),明确显示是「插入中间」还是「变为子节点」。
3. **UI 细节复刻**
* **样式**:行高 28px,字体 14px InterHover 背景 `#f5f5f5`,选中背景 `#eef2ff` + 左侧蓝条。
* **图标**:使用 `lucide-react`,文件夹折叠/展开箭头动画(90度旋转)。
* **右键菜单**:实现自定义 Context Menu,包含:重命名、删除、在下方新建页面、复制链接。
### 阶段 B: 编辑器深度定制 (Editor Refinement)
**目标**:实现 Wolai 的「块即页面」核心体验。
1. **样式定制**
* 覆盖 BlockNote 默认 CSS 变量,使用 Wolai 蓝色 `#2563eb` 作为主色(光标、选区、链接)。
* 调整块间距(margin-bottom: 8px)和内边距。
2. **功能:转为页面 (Turn to Page)**
* **后端 API**:实现 `POST /api/documents/create-child`,接收块内容,创建子文档。
* **自定义块**:注册 `PageReference` 块,渲染为蓝色带图标的链接块。
* **菜单集成**
* **Side Menu (:::)**:添加「转换为 -> 页面」选项。
* **Slash Menu (/)**:添加「页面」选项。
* **双向同步**:确保在编辑器中创建子页面后,左侧侧边栏实时更新显示。
### 阶段 C: 全局交互与细节
1. **面包屑导航**:确保点击面包屑能正确跳转,并与侧边栏选中状态同步。
2. **Loading 状态**:页面切换时的顶部进度条(NProgress 风格,蓝色)。
3. **空状态**:新页面显示「无标题」占位符和「按 / 输入命令」提示。
## 3. 技术规范 (Strict Guidelines)
* **CSS 框架**Tailwind CSS (v4)。
* **图标库**`lucide-react` (通用), `react-icons` (特定品牌)。
* **状态管理**`zustand` (全局 UI 状态), `swr` / `react-query` (数据获取)。
* **拖拽库**`@dnd-kit/core`, `@dnd-kit/sortable` (推荐,比 react-dnd 更现代且易于控制样式)。
## 4. 验证计划
### 自动化测试
* (暂无,依赖手动验证)
### 手动验证清单
1. **侧边栏测试**
* [ ] 创建 3 层嵌套页面,确认缩进正确。
* [ ] 拖拽页面 A 到页面 B 内部,确认无刷新实时更新。
* [ ] 快速展开/折叠多个节点,确认无卡顿。
* [ ] 右键重命名页面,确认编辑器顶部标题同步更新。
2. **编辑器测试**
* [ ] 输入文本,点击左侧 `:::`,选择「转换为页面」。
* [ ] 确认原文本块变为蓝色链接块。
* [ ] 点击链接块,跳转到新页面,内容已迁移。
* [ ] 检查左侧侧边栏,确认新页面出现在原页面下方。
## 5. 下一步行动 (Next Steps)
建议优先执行 **阶段 A (侧边栏)**,因为它是整个应用的导航骨架,Bug 最影响体验。
然后执行 **阶段 B**,打通「块 <-> 页面」的流转。
-155
View File
@@ -1,155 +0,0 @@
# Wolai 深度复刻计划
**目标**:复刻 Wolai 在线文档的核心体验,首先补齐左侧树状侧边栏的交互稳定性,并在右侧 BlockNote 编辑器内实现官方文档列举的全部块能力、引用链路与媒体处理。
**基准**`design/ui_react.md``design/PHASE_1.5_TURN_TO_PAGE_EXACT_CLONE.md`,以及 `scraped_docs` 目录内的官方说明。
**范围**Next.js + Supabase + BlockNote 技术栈,聚焦 Web 前端与与 Supabase 表结构;暂不包含移动端或第三方 IM 集成。
## 0. 对标资料与能力映射
| 模块 | 对标文档 | 关键能力 | 当前差距 |
| --- | --- | --- | --- |
| 工作空间壳层/侧边栏 | `scraped_docs/功能概览.md``scraped_docs/页面定义.md``scraped_docs/垃圾桶.md` | 左侧树支持星标置顶/公共/共享/私有/模板/垃圾桶分区、宽度拖拽、工作空间切换、顶部功能按钮;垃圾桶需支持恢复/彻底删除及“操作确认” | 仅有简单树结构,缺分区、星标、垃圾桶操作、右键菜单及拖拽稳定性 |
| 页面配置 | `scraped_docs/页面选项.md` | 自适应宽度、小字体、标题目录、标题自动编号、编辑保护、导出/移动/嵌入/历史/统计、自定义页面默认布局 | 目前只有部分菜单;标题目录/编号、编辑保护、字数统计未实现 |
| 标题体系 | `scraped_docs/标题.md` | H1-H5、折叠标题、快捷键/Markdown/斜杠创建、标题目录关联、自动编号、居中、级别提示 | BlockNote 默认仅支持 H1-H3,无折叠、无自动编号、无快捷键转换 |
| 块编辑基础 | `scraped_docs/基本编辑.md` | 块是第一等公民:拖动、分栏、块结构显示、块转换、块级复制/粘贴/缩进、文本样式/颜色/链接/引用/显示块结构快捷键 | 现有 BlockNote 能力未进行 Wolai 样式与交互定制,缺块结构视图、块插入按钮等细节 |
| 斜杠菜单 | `scraped_docs/斜杠菜单.md` | `/` 唤起可搜索命令,覆盖基础块、进阶块、转换、行内元素、媒体、引用、快速功能、颜色/背景 | 仅有默认 BlockNote slash menu,命令内容/分组/拼音过滤未对齐 |
| 列表与待办 | `scraped_docs/数字列表.md``scraped_docs/todolist.md` | 多级编号格式可自定义、Markdown/快捷命令、缩进结构、进度条联动、高级待办四态及 Alt 切换取消 | 数字列表格式固定,缺高级待办、进度条联动逻辑 |
| 引用体系 | `scraped_docs/页面引用.md``scraped_docs/块引用.md` | [[/ # 快速引用、预引用、引用别名、行内/嵌入块引用、拖拽引用、反向链接、嵌入到...、快捷键 H/Q/Ctrl+Shift+R | 目前仅有基本页面链接,无预引用、别名、嵌入块引用、反向引用列表 |
| 搜索 | `scraped_docs/搜索选项.md` | `Ctrl/Cmd+P` 全局搜索、最近访问列表、仅标题/精确/时间范围过滤、当前页面范围过滤、图片 OCR 搜索、快捷键多窗口打开 | 只有简单页面检索,无过滤器、图片 OCR、快捷开窗 |
| 媒体/图片 | `scraped_docs/图片.md` | 上传/最近上传/链接/动态图标四入口、拖拽/粘贴、大小限制提示、对齐/边框/说明/说明居中/链接、全屏/查看原图/下载、裁剪/马赛克/绘制编辑、手动 OCR | 仅能上传图片,缺菜单操作、编辑器、OCR 入口、动态图标 |
## 1. 现状分析与问题定位
### 1.1 导航与工作空间壳层
- 树状结构拖拽存在抖动与状态不同步,缺少 Wolai 官方的分区/折叠/右键菜单/星标/垃圾桶功能。
- 顶部功能按钮(搜索、页面关系图、导入、成员等)未复刻,空间切换流程也不符合 `功能概览.md` 描述。
### 1.2 编辑器交互
- BlockNote 默认样式与 Wolai 蓝色系(#2563eb)相差较大,行距、列布局、块菜单体验不足。
- H5-H9、折叠标题、标题目录/编号、块转换、显示块结构等官方能力缺失。
- 待办、进度条、数字列表等块尚未实现 Wolai 规则,`转为页面` 功能仍未闭环。
### 1.3 引用、搜索与上下文
- 目前无 [[ 预引用、别名、嵌入引用等;页面引用未形成反向链接、也没有“嵌入到...”工作流。
- 全局搜索停留在基础 Title 匹配层面,缺时间范围过滤、图片 OCR 建索引、快捷键打开多窗。
### 1.4 媒体与垃圾桶
- 图片块只支持最小上传,暂无编辑、说明、链接、OCR 和动态图标;无法区分最近上传。
- 垃圾桶界面缺“操作确认”开关、搜索与恢复/彻底删除功能,风险不可控。
## 2. 分阶段路线图
### 阶段 A:侧边栏与空间壳层重构(T+3 天)
- [x] 新建 `SidebarTree` 数据层,按工作空间/公共/共享/私有/星标/模板分类读取 Supabase;对齐 `功能概览.md` 的折叠状态与宽度拖拽。
- [x] 引入 `@dnd-kit` 实现虚拟化拖拽,保证无限嵌套无抖动;补齐右键菜单(重命名/移动/删除/转为子页面)。
- [x] 实现顶部功能按钮占位与事件(搜索入口、页面关系图、导入、成员、消息箱、今日速记、手气不错、更多)。
- [x] 垃圾桶列表、恢复/彻底删除、操作确认、关键字搜索全部落地。
### 阶段 B:编辑器块体系与标题/列表(T+5 天)
- [x]`scraped_docs/基本编辑.md` 为准对 BlockNote 主题进行定制:块拖拽手柄、上下插入按钮、列布局、显示块结构。
- [x] 完成 H1-H5、折叠标题、标题居中/快捷键/自动编号、标题目录与右侧 TOC。
- [x] 实现数字列表格式层级选择、Markdown 快捷输入、进度条块与高级待办四态以及进度联动。
- [x] 补齐 `/` 命令面板、拼音过滤、命令分组、快速功能入口以及颜色/背景命令。
### 阶段 C:页面/块引用与搜索体系(T+4 天)
- [x] [[ / # 预引用流水线与引用面板基础版:BlockNote 双输入触发搜索面板,`useReferenceComposer` 负责调用 `/api/references/record`,并将行内/嵌入引用写入新建的 `page_refs` 表;底部提供 `PageBacklinksPanel` 读取 `list_backlinks` RPC 并展示反向引用列表。
- [x] 服务端:创建 `page_refs` 表 + 触发器/策略,提供 `record_page_ref``list_backlinks` RPC,并通过 API 路由对齐工作空间鉴权。
- [x] 前端:`SearchPalette` 引入 `useReferenceComposer`,自动写入引用并回填 blockId`DocumentContent` 新增反链面板。
- [x] 进阶:`PageHoverCard` 引用预览、拖拽引用卡片、`嵌入到...``复制块引用` 快捷入口与引用筛选折叠仍待实现。
- [x] 搜索弹窗(Ctrl/Cmd+P)与 OCR 过滤:复刻 Wolai 快捷搜索,支持最近访问缓存、仅标题 / 精确匹配 / 编辑时间 / 创建时间 / 当前页面范围 / 图片 OCR 多过滤器,并提供 Ctrl/Cmd+Enter 新开窗口、Alt+Enter 右侧打开。
- [x] 数据:落地 `user_recent_pages` 表 + `/api/search/recent``/api/search/documents` 同时返回最近访问并在前端展示。
- [x] UI:提供最近/全部 Tab、编辑/创建时间切换、自定义日期选择及 OCR 过滤提示。
- [x] 验证:补充 vitest 针对过滤组合(仅标题+时间范围)、快捷键映射(Ctrl/Cmd+Enter、Alt+Enter)以及 OCR 结果高亮。
- [x] 页面选项与统计:自适应宽度/小字体/标题目录/编辑保护/导出/历史/字数统计等全部可在右上角菜单内切换,并同步到 Supabase。
- Schema`pages` 表新增 `is_narrow_width`, `use_small_font`, `toc_mode`, `is_locked`, `stats_word_count`, `stats_todo` 字段;新增 `page_audit_logs` 记录导出/历史访问。
- 交互:`PageOptionsDrawer` 组件统一渲染切换项,部分操作(导出、历史)通过 Server Action 调用 Supabase Edge Function;字数/待办统计依赖块结构 diff 即时刷新并展示在面板底部。
- 安全:编辑保护开启后 BlockNote 进入只读状态,界面显示锁标识并要求拥有者解锁,所有写入请求需校验 `pages.is_locked`
### 阶段 D:媒体与增值体验(T+3 天)
- [x] 图片块:上传/最近上传/链接/动态图标四入口,尺寸限制校验,菜单中支持边框/说明/说明居中/超链接/全屏/查看原图/下载/替换/编辑/OCR。
- [x] 图片编辑器集成裁剪、矩形、马赛克、自由绘制;OCR 操作触发 Supabase Edge Function 建索引供搜索使用。
- [ ] 模板中心/帮助入口、空间功能按钮埋点、Good Night 模式跟随系统等 UI 细节收尾。
## 3. 关键模块实现要点
### 3.1 侧边栏与工作空间(来源:`功能概览.md`、`页面定义.md`、`垃圾桶.md`
- 列表以顶层页面为根,支持无限展开+懒加载;同级采用虚拟滚动避免渲染开销。
- 星标置顶、公共/共享/私有/模板中心入口需可切换并记忆折叠状态;垃圾桶内可按标题搜索并展示“操作确认”开关。
- 工作空间下拉展示所有空间,允许创建/加入并一键切换;顶部功能按钮根据空间类型(团队/个人)切换文案。
### 3.2 页面容器与选项(来源:`页面选项.md`)
- 页面右上角菜单包含:自适应宽度、小字体、标题目录/编号、编辑保护、撤回、删除、移动、嵌入、模板、复制链接(含标题)、行内/嵌入引用、导出、页面历史、字数统计、待办统计。
- 自定义页面弹窗可调整字体、布局、显示块引用数字、折叠反向引用、隐藏子页面、嵌入默认位置。
- 标题目录宽度需根据视口自适应,提供完整/精简/折叠菜单和 `Ctrl/Cmd+Shift+L` 快捷键。
### 3.3 标题系统(来源:`标题.md`)
- 支持 `/h1`-`/h5``+##` 折叠标题、`Ctrl/Cmd+Shift+1~4` 快捷键、`+` 转折叠,标题悬浮显示等级圆点。
- 自动编号校验层级并以红色问号提示错误;编号点击可切换样式或关闭。
- 标题可居中(块菜单/`C` 键),并与标题目录联动展示子页面。
### 3.4 块编辑与斜杠菜单(来源:`基本编辑.md`、`斜杠菜单.md`)
- 左侧 `::` 手柄支持拖动、上下/左右插入(+ 按钮、A/B 快捷键),`Esc` 选中块,`Ctrl/Cmd+A` 二段选择,`Tab/Shift+Tab` 缩进。
- 块结构视图通过 `Ctrl/Cmd+Shift+U` 开关灰色虚线框;块菜单含“转换为”并列出所有文本类块。
- 斜杠命令按官方分组(基础块/进阶块/转换/行内元素/媒体/引用/快速输入/分栏/快速功能/颜色/背景),支持拼音模糊与英文过滤;命令项需展示快捷键或描述。
### 3.5 列表与待办(来源:`数字列表.md`、`todolist.md`
- 数字列表默认三层(1/a/i)循环,并允许通过层级首项菜单切换样式;处理跨块断裂和图片缩进造成的编号断层。
- 待办列表支持 Markdown `[]`、高级待办 `()`、折叠变体 `+[]``+()`,进度条块读取同级待办完成度,高级待办 Alt/Opt 点击直接切换为取消。
- 待办完成度同步页面选项中的统计信息。
### 3.6 页面/块引用(来源:`页面引用.md`、`块引用.md`)
- 输入 `[[` / `#` / 文本样式 / `Ctrl/Cmd+Shift+R` 统一本地化搜索;若无命中提供“预引用”和“创建页面”。
- 行内/嵌入引用可通过 `复制块引用链接` (H/Q 快捷键) 或拖拽到右侧边栏创建;行内引用可设置别名并悬浮预览。
- 页面底部展示反向引用列表并支持折叠(配合页面选项中的折叠引用设置)。
### 3.7 搜索与工作流(来源:`搜索选项.md`)
- 搜索弹窗默认显示最近 20 个页面,可清空记录;输入时支持模糊匹配标题、分词匹配内容,英文/数字前缀匹配。
- 过滤器:仅匹配标题、精确匹配、编辑时间、创建时间、当前页面范围、图片 OCR;回车打开,`Ctrl/Cmd+Enter` 新窗口,`Alt/Opt+Enter` 右侧边栏。
- 选中文本 `Ctrl/Cmd+P` 直接填充搜索词。
### 3.8 媒体/图片(来源:`图片.md`)
- `/tp` 插入图片后提供上传/最近上传/链接/动态图标;支持多文件拖拽上传、粘贴图片/链接。
- 图片块菜单包含边框、默认大小、全屏、查看原图、下载、更换、说明、说明居中 (`C`)、添加链接。
- 图片编辑器提供裁剪/矩形/马赛克/自由绘制,OCR 操作会触发后台任务并有状态提示;说明区可输入文本并选择对齐方式。
- 动态图标能力需可选择日期/百分比/字符等变体。
### 3.9 垃圾桶与安全(来源:`垃圾桶.md`)
- 左侧底部固定“垃圾桶”入口,支持搜索、恢复、彻底删除;提供“操作确认”开关及 180 天恢复提示。
- 恢复页面默认回到“我的页面”,彻底删除需在 Supabase 端调用软删除 / 标记以便后台恢复。
## 4. 技术规范
- **技术栈**Next.js 15 App Router、React 18.3、SupabaseAuth + Postgres + Storage)、BlockNote + Tiptap 扩展、Tailwind CSS v4、`lucide-react`/`react-icons`
- **状态管理**`zustand` 统一管理全局 UISidebar/Dialogs/Theme),`@tanstack/react-query` 管理数据 fetching 与缓存;搜索/引用使用 React Server Actions + Supabase RPC 提升性能。
- **拖拽与虚拟化**`@dnd-kit/core` + `@dnd-kit/sortable`,虚拟树使用 `@tanstack/react-virtual`,结合稳定 ID 解决嵌套节点闪动。
- **样式要求**:全局遵循 Wolai 配色与排版,标题、待办、列表等组件以 CSS 变量驱动,支持 Good Night 模式与系统跟随。
- **编码规范**:所有字符串/文件 UTF-8、中文注释;公共逻辑拆入 `services/``src/lib/`,组件使用 `src/components/wolai/*` 组织。
- **Supabase 表设计**
- `pages`(层级/排序/图标/题头图/保护状态);
- `blocks`(类型/属性 JSON/父子顺序/引用 ID);
- `media_assets`(文件/最近上传/动态图标配置/OCR 文本);
- `page_refs`(引用关系、别名、行内/嵌入标记)。
## 5. 验证计划
### 自动化测试
- `supabase/functions`:添加单元测试验证 OCR 索引写入、垃圾桶恢复逻辑。
- 前端 `vitest`
- Sidebar 拖拽排序、星标过滤、垃圾桶操作确认。
- Block schema:标题转换/折叠、待办进度联动、转为页面数据写入。
- Slash menu:命令过滤(拼音/英文)、命令执行回调。
- E2EPlaywright):
1. 新建页面 -> 输入 `/h5` -> 自动编号 -> 打开标题目录。
2. 创建高级待办 + 进度条 -> Alt 点击取消 -> 进度更新。
3. 复制块引用 -> 粘贴为嵌入 -> 搜索引用的别名。
4. 上传图片 -> 编辑 -> 启动 OCR -> 通过搜索定位。
### 手动验证清单
- 侧边栏:三层嵌套创建/拖拽/移动到垃圾桶/恢复;星标页面固定在顶部。
- 编辑器:`转为页面` 保留嵌套内容;折叠标题展开/折叠一致;显示块结构/Good Night 切换。
- Slash menu`/tp``/dblb``/zhwlb``/grsz` 等命令可被拼音缩写过滤。
- 搜索:键入中文模糊、英文前缀、仅标题、编辑时间过滤;`Ctrl+Enter` 新窗口、`Alt+Enter` 右侧打开。
- 引用:`[[` 预引用 -> 创建新页面 -> 切换别名 -> 页面底部显示反向引用;`嵌入到...` 将块嵌入其他页面。
- 图片:上传 + 最近上传选择 + 链接插入 + 动态图标,OCR 后在搜索中出现高亮摘要。
- 垃圾桶:打开操作确认、恢复/彻底删除操作均符合提示。
## 6. 下一步行动
1. 输出 Sidebar 数据结构与拖拽方案设计稿,确定 Supabase 表/索引需求。
2. 基于 `scraped_docs/基本编辑.md` 构建 BlockNote 自定义 schema(标题/待办/进度条/转页面),并封装 `/` 命令扩展。
3. 设计全局搜索 & OCR 索引流程(图片上传 -> Edge Function OCR -> Supabase `media_assets` 更新 -> 搜索服务),并评估性能预算。
4. 规划垃圾桶与页面历史数据清理脚本,确保 180 天内可恢复。
@@ -1,234 +0,0 @@
# MNOTE 全局 AI 面板改版设计(参考 AiChem,按现有工具适配)
## 1. 目标
本次改版目标不是把 `AiChem` 的全局 AI 面板原样搬过来,而是:
1. 参考 `F:\SOFT\AiChem\aichem-frontend\src\components\assistant\AssistantWidget.vue` 的整体视觉气质与布局节奏。
2. 参考 `cankao\image.png` 的暗色面板、顶部工具栏、聊天气泡、底部输入区样式。
3. 结合 MNOTE 当前真实能力,保留并强化:
- SSE 流式对话
- 工具调用日志
- 最大步数控制
- 当前全局 AI 默认可用工具集
4. 明确避免照搬 AiChem 中 MNOTE 当前并不存在的能力,例如:
- 会话历史抽屉
- 用户资料页入口
- 设置面板
- 会话附件池
- Plan / Act 真切换逻辑
---
## 2. 当前现状
MNOTE 当前全局 AI 入口:
- 组件:`wolai-frontend/src/components/ai-agent/AiAgentPanel.tsx`
- 页面:`wolai-frontend/src/app/dev/ai-agent/page.tsx`
- 桌面镜像:`desktop-electron/desktop-next/src/components/ai-agent/AiAgentPanel.tsx`
当前问题:
1. 视觉上仍是开发态 demo,缺少“正式全局 AI”界面感。
2. 对话区与工具日志区只是普通双栏卡片,缺少层次。
3. 没有明确呈现“当前全局 AI 能做什么”。
4. 输入区缺少模型/工具/运行状态等辅助信息的组织。
5. 工具日志的可读性较弱,不利于观察一次任务中的调用链。
---
## 3. 参考来源拆解
### 3.1 AiChem 借鉴点
从 AiChem 当前实现中,适合借鉴的点:
1. **整体暗色外壳**
- 深色背景
- 细边框
- 低对比度分区
2. **顶部栏**
- 左侧品牌 + 当前会话标题
- 右侧一排紧凑图标按钮
3. **消息区**
- 左右分布的气泡
- AI / 我 的短标签头像
- 空状态时直接展示 AI 欢迎卡片
4. **底部输入区**
- 工具/状态信息在输入框上方
- 输入框独立成块
- 发送按钮悬浮在右下角
### 3.2 本次不能直接照搬的点
AiChem 中以下能力当前 MNOTE 全局 AI 没有,不应伪装成已支持:
1. 会话历史侧栏
2. 会话附件池与附件管理
3. 用户中心入口
4. 设置面板与 provider 切换菜单
5. Plan / Act 实际模式切换
---
## 4. MNOTE 当前真实工具能力
根据 `wolai-frontend/src/app/api/ai-agent/run/route.ts``wolai-frontend/src/lib/ai-agent/tools/builtins/registryBuiltins.ts`,当前全局 AI 默认允许:
1. `toolset.readonly`
- `search_web`
2. `toolset.rag_read`
- `rag_lightrag_query`
3. `toolset.docs_read`
- `docs_search`
- `docs_read`
4. `toolset.media_read`
- `image_read`
5. `toolset.slash_write`
- `slash_run`
因此本次 UI 呈现要围绕这些真实能力组织文案与标签:
1. 联网检索
2. LightRAG 检索
3. 跨页面文档搜索/读取
4. 图片 OCR
5. 斜杠命令写入
---
## 5. 改版方案
## 5.1 整体布局
由“两个普通 Card 横排”改成“一个暗色 shell + 主对话区 + 右侧活动区”的结构:
1. 最外层:全屏高度暗色容器。
2. 顶部:统一标题栏。
3. 中部:
- 左侧:聊天主区域
- 右侧:工具活动面板
4. 底部:输入区
这样既能保留 MNOTE 的“工具日志可观测性”,又能接近 AiChem 的整体观感。
## 5.2 顶部栏
顶部栏信息改为:
1. 左侧品牌:`MNOTE`
2. 主标题:`全局 AI`
3. 副标题:显示当前运行模式说明,例如“自动工具编排”
4. 右侧操作:
- `工具面板显隐`
- `恢复示例问题`
- `清空对话`
- `停止`(仅运行中可用)
说明:
1. 不使用 AiChem 的“历史 / 我的 / 设置”等按钮。
2. 只放当前组件真实可执行的动作。
## 5.3 空状态
当没有消息时,展示一张 AI 欢迎卡片,文案基于 MNOTE 当前工具能力:
1. 我可以联网查资料并给出来源
2. 我可以调用 LightRAG 进行知识检索
3. 我可以搜索和读取工作区文档
4. 我可以读取图片 OCR
5. 我可以执行受控斜杠命令
这部分会直接替代目前“暂无消息”的空白状态。
## 5.4 消息气泡
消息区采用更接近参考图的气泡样式:
1. AI 气泡偏中性深灰
2. 用户气泡偏蓝色强调
3. 头像使用短标签:
- AI`AI`
- 用户:`我`
4. 消息文本保留 `whitespace-pre-wrap`
## 5.5 工具活动面板
右侧保留工具日志,但增强结构化:
1. 顶部显示“本轮活动”
2. 显示总数、当前状态
3. 日志按卡片区分:
- 工具调用
- 工具结果
- 错误
4. 每项显示更明确的标签:
- 工具名称
- 请求编号
- 成功/失败
- 耗时
说明:
1. AiChem 的日志多以内联折叠存在于消息中。
2. MNOTE 当前组件已有独立日志流,因此本次保留右侧活动面板,不强行改成内联。
## 5.6 输入区
底部输入区分三层:
1. 第一层:能力标签区
- 当前启用工具集 pill
2. 第二层:运行控制区
- `最大步数`
- 当前状态(待命 / 运行中)
3. 第三层:输入框主体
- 多行输入
- Enter 发送、Shift+Enter 换行
- 右下角发送按钮
- 运行中显示停止按钮
## 5.7 页面外层
`/dev/ai-agent` 页面同步调整为:
1. 去掉开发态说明块的割裂感
2. 保留必要说明,但收敛成顶部一小行 meta 文案
3. 让主体尽可能接近“正式全局 AI 面板”
---
## 6. 本次不做
以下内容明确不纳入本次改版,避免范围失控:
1. 新增会话持久化
2. 新增模型切换
3. 新增工具手动多选
4. 新增历史记录
5. 改造后端接口结构
6. 把工具日志写回消息气泡
---
## 7. 验收标准
完成后应满足:
1. 全局 AI 页面整体视觉明显接近参考图的暗色正式面板。
2. 欢迎态能够正确展示 MNOTE 当前真实工具能力。
3. 输入、发送、停止、清空、恢复示例问题均可正常工作。
4. SSE 消息流与工具日志仍按原逻辑工作。
5. 工具日志面板可以显隐,不影响对话主流程。
6. Web 与桌面镜像保持一致。
---
## 8. 实施顺序
1. 先为 `AiAgentPanel` 补一组针对结构/状态的测试。
2. 再改造 `AiAgentPanel.tsx` 的布局与样式。
3. 同步改造 `desktop-electron/desktop-next` 镜像文件。
4. 最后微调 `app/dev/ai-agent/page.tsx` 外层页面。
-372
View File
@@ -1,372 +0,0 @@
"use strict";
// 说明:
// - 这是 Sidebar / PageTree Rust Web tree shell 的真实网页 smoke 脚本。
// - 目标覆盖:主页面已挂载 iframe tree shell、展开/折叠、在 shell 内创建子页面、重命名、移动、导航。
// - 脚本会创建临时页面并在结束后清理,避免污染现有数据。
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
const MNOTE_WEB_BASE_URL = process.env.MNOTE_WEB_SMOKE_BASE_URL || "";
const REQUEST_TIMEOUT_MS = 20_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function requireMnoteWebSmokeBaseUrl() {
assert(
Boolean(MNOTE_WEB_BASE_URL),
"当前 smoke 仅用于 legacy mnote-web tree shell,对应端口已默认退役;如需执行,请显式设置 MNOTE_WEB_SMOKE_BASE_URL。",
);
return MNOTE_WEB_BASE_URL;
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers()["content-type"] || "";
if (!contentType.includes("application/json")) {
const snippet = typeof payload === "string" ? payload.slice(0, 200) : JSON.stringify(payload).slice(0, 200);
throw new Error(`${path} 返回了非 JSON 内容:${snippet}`);
}
return payload;
}
async function createTempDocument(requestContext, parentId = null) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return {
documentId: payload.id,
workspaceId: payload.workspace_id,
};
}
async function renameDocument(requestContext, workspaceId, documentId, title) {
await requestJson(requestContext, "/api/documents/title", {
method: "POST",
data: {
workspaceId,
documentId,
title,
commandName: "page.head.updateTitle",
},
});
}
async function purgeDocument(requestContext, documentId) {
await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function getViewerIdentity(requestContext) {
const payload = await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
assert(payload && typeof payload.userId === "string" && payload.userId, "获取当前用户失败:缺少 userId");
return payload;
}
async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "networkidle", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
});
}
return await getViewerIdentity(requestContext);
}
async function runTreeShellRegression(page, requestContext, viewer, target) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
const uniqueSuffix = Date.now().toString();
const rootTitle = `task091-root-${uniqueSuffix}`;
const childTitle = `task091-child-${uniqueSuffix}`;
const createdTitle = `task091-created-${uniqueSuffix}`;
const renamedTitle = `task091-renamed-${uniqueSuffix}`;
await renameDocument(requestContext, target.workspaceId, target.parentId, rootTitle);
await renameDocument(requestContext, target.workspaceId, target.childId, childTitle);
const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const groupButton = page.getByRole("button", { name: "分组" });
await groupButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await groupButton.click({ timeout: UI_TIMEOUT_MS });
const sidebarPanel = page.getByText("页面树");
await sidebarPanel.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframe = page.locator('iframe[title="mnote-web tree shell"]');
await iframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframeSrc = await iframe.getAttribute("src");
assert(iframeSrc && iframeSrc.includes(`${runtimeBaseUrl}/tree`), `Sidebar 未挂载 mnote-web tree shell${iframeSrc}`);
assert(iframeSrc.includes(`actorId=${encodeURIComponent(viewer.userId)}`), `tree shell 未透传当前用户 actorId${iframeSrc}`);
const getTreeFrame = () => page.frameLocator('iframe[title="mnote-web tree shell"]');
const waitForTreeReady = async () => {
const frame = getTreeFrame();
await frame.locator('[data-testid="tree-create-root"]').waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
return frame;
};
const getFirstChildId = async (parentId) => {
const frame = await waitForTreeReady();
return await frame
.locator(`.tree-node[data-node-id="${parentId}"] > .tree-children > .tree-node`)
.first()
.getAttribute("data-node-id");
};
const waitForFirstChildId = async (parentId, expectedId) => {
const deadline = Date.now() + UI_TIMEOUT_MS;
while (Date.now() < deadline) {
const actualId = await getFirstChildId(parentId);
if (actualId === expectedId) {
return actualId;
}
await page.waitForTimeout(250);
}
return await getFirstChildId(parentId);
};
let treeFrame = await waitForTreeReady();
const parentRow = treeFrame.locator(`.tree-row[data-node-id="${target.parentId}"]`);
const childRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`);
await parentRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const toggleButton = parentRow.locator('[data-testid="tree-node-toggle"]');
await toggleButton.click({ timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
await toggleButton.click({ timeout: UI_TIMEOUT_MS });
await childRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const createResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"create"'),
{ timeout: UI_TIMEOUT_MS },
);
await parentRow.locator('[data-testid="tree-action-create"]').click({ timeout: UI_TIMEOUT_MS });
const createResponse = await createResponsePromise;
const createPayload = await createResponse.json();
const createdDocumentId = createPayload?.result?.documentId;
assert(typeof createdDocumentId === "string" && createdDocumentId, "tree shell 创建子页面失败:缺少 documentId");
await page.waitForURL((url) => url.toString().includes(`/documents/${createdDocumentId}`), {
timeout: UI_TIMEOUT_MS,
});
target.createdIds.push(createdDocumentId);
treeFrame = await waitForTreeReady();
const createdRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`);
await createdRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
page.once("dialog", (dialog) => dialog.accept(renamedTitle));
const renameResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"rename"'),
{ timeout: UI_TIMEOUT_MS },
);
await createdRow.locator('[data-testid="tree-action-rename"]').click({ timeout: UI_TIMEOUT_MS });
await renameResponsePromise;
treeFrame = await waitForTreeReady();
const renamedRow = treeFrame.locator(`.tree-row[data-node-id="${createdDocumentId}"]`);
await renamedRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const childOrderBeforeMove = await getFirstChildId(target.parentId);
assert(
childOrderBeforeMove === target.childId,
`移动前的首个子节点异常:期望 ${target.childId},实际 ${childOrderBeforeMove}`,
);
const moveResponsePromise = page.waitForResponse(
(response) =>
response.url().includes(`${runtimeBaseUrl}/api/tree/commands`) &&
response.request().method() === "POST" &&
response.status() === 200 &&
(response.request().postData() || "").includes('"action":"move"'),
{ timeout: UI_TIMEOUT_MS },
);
await renamedRow.locator('[data-testid="tree-action-move-up"]').click({ timeout: UI_TIMEOUT_MS });
await moveResponsePromise;
const firstChildAfterMove = await waitForFirstChildId(target.parentId, createdDocumentId);
assert(
firstChildAfterMove === createdDocumentId,
`移动后排序未生效:期望首个子节点为 ${createdDocumentId},实际 ${firstChildAfterMove}`,
);
treeFrame = await waitForTreeReady();
const latestChildRow = treeFrame.locator(`.tree-row[data-node-id="${target.childId}"]`);
await latestChildRow.locator('[data-testid="tree-node-open"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => url.toString().includes(`/documents/${target.childId}`), {
timeout: UI_TIMEOUT_MS,
});
return {
documentUrl,
rootTitle,
childTitle,
createdTitle,
renamedTitle,
createdDocumentId,
};
}
async function runPickerRegression(page, target) {
const documentUrl = `${BASE_URL}/documents/${target.parentId}?workspaceId=${encodeURIComponent(target.workspaceId)}`;
await page.goto(documentUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
const inspectorToggle = page.getByRole("button", { name: /显示页面选项|隐藏页面选项/ });
await inspectorToggle.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const currentLabel = (await inspectorToggle.getAttribute("aria-label")) || "";
if (currentLabel.includes("显示")) {
await inspectorToggle.click({ timeout: UI_TIMEOUT_MS });
}
const moveEmbedButton = page.getByRole("button", { name: "移动/嵌入到..." });
await moveEmbedButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await moveEmbedButton.click({ timeout: UI_TIMEOUT_MS });
const dialog = page.getByRole("dialog");
await dialog.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const pickerIframe = dialog.locator('iframe[title="mnote-web tree shell"]');
await pickerIframe.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const iframeSrc = await pickerIframe.getAttribute("src");
assert(iframeSrc && iframeSrc.includes("mode=picker"), `picker 未以 tree shell 轻量模式挂载:${iframeSrc}`);
assert(iframeSrc.includes("allowRootPick=1"), `picker 未透传 allowRootPick${iframeSrc}`);
assert(
iframeSrc.includes(`excludeIds=${encodeURIComponent(target.parentId)}`),
`picker 未透传 excludeIds${iframeSrc}`,
);
const frame = dialog.frameLocator('iframe[title="mnote-web tree shell"]');
const rootPick = frame.locator('[data-testid="tree-picker-root"]');
await rootPick.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await rootPick.click({ timeout: UI_TIMEOUT_MS });
await dialog.waitFor({ state: "hidden", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let result = null;
const createdIds = [];
try {
const viewer = await ensureAuthenticated(page, context.request);
const parent = await createTempDocument(context.request, null);
createdIds.push(parent.documentId);
const child = await createTempDocument(context.request, parent.documentId);
createdIds.push(child.documentId);
result = await runTreeShellRegression(page, context.request, viewer, {
workspaceId: parent.workspaceId,
parentId: parent.documentId,
childId: child.documentId,
createdIds,
});
await runPickerRegression(page, {
workspaceId: parent.workspaceId,
parentId: parent.documentId,
});
console.log(
JSON.stringify(
{
ok: true,
workspaceId: parent.workspaceId,
viewerUserId: viewer.userId,
parentId: parent.documentId,
childId: child.documentId,
...result,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
for (const documentId of [...createdIds].reverse()) {
try {
await purgeDocument(context.request, documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,116 +0,0 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
openDocument,
prepareTempTreeFixture,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function runDefaultShellPath(page, fixture) {
await openDocument(page, fixture.workspaceId, fixture.parentId);
await page.getByRole("button", { name: "进入编辑" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
(await page.locator(`iframe[title="mnote-web-document-shell-${fixture.parentId}"]`).count()) === 0,
"默认文档页不应继续挂载 mnote-web 文档壳 iframe",
);
await page.getByRole("button", { name: "进入编辑" }).click({ timeout: UI_TIMEOUT_MS });
await page.getByLabel("页面标题").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(".wolai-editor").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return {
compatReady: true,
};
}
async function runRuntimeDebugPath(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("#block-editor-list").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-slash").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return {
debugUrl: page.url(),
};
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let fixture = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
const primary = await runDefaultShellPath(page, fixture);
const debug = await runRuntimeDebugPath(page, fixture);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
parentId: fixture.parentId,
childId: fixture.childId,
debugRuntimeBaseUrl: MNOTE_WEB_BASE_URL || null,
primary,
debug,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${
cleanupError instanceof Error
? cleanupError.stack || cleanupError.message
: String(cleanupError)
}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,101 +0,0 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const textarea = page.locator("textarea[data-block-input-id]").first();
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await textarea.click({ timeout: UI_TIMEOUT_MS });
await textarea.fill("你好 runtime input", { timeout: UI_TIMEOUT_MS });
await page.locator("#editor-save-status").getByText("saved").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const value = await textarea.inputValue();
assert(value === "你好 runtime input", `输入值不符合预期:${value}`);
const selection = await page.locator("#editor-selection-id").textContent();
assert(selection && selection.includes("_block_1"), `selection 未更新到真实输入块:${selection}`);
const eventLog = (await page.locator("#editor-event-log").textContent()) || "";
assert(
eventLog.includes("human_editor_input.beforeinput"),
`未记录 beforeinput runtime 标记:${eventLog}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
selection,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,122 +0,0 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const firstTextarea = page.locator("textarea[data-block-input-id]").first();
await firstTextarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await firstTextarea.fill("AlphaBeta", { timeout: UI_TIMEOUT_MS });
await firstTextarea.evaluate((node) => {
node.setSelectionRange(5, 5);
});
await firstTextarea.press("Enter", { timeout: UI_TIMEOUT_MS });
const textareas = page.locator("textarea[data-block-input-id]");
await textareas.nth(1).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert((await textareas.count()) === 2, "Enter 拆块后未出现第二个输入块");
assert((await textareas.nth(0).inputValue()) === "Alpha", "拆块后首块文本不正确");
assert((await textareas.nth(1).inputValue()) === "Beta", "拆块后次块文本不正确");
await textareas.nth(1).press("Tab", { timeout: UI_TIMEOUT_MS });
await page.locator(".editor-row-meta").nth(1).getByText("depth=1").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await textareas.nth(1).press("Shift+Tab", { timeout: UI_TIMEOUT_MS });
await page.locator(".editor-row-meta").nth(1).getByText("depth=0").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await textareas.nth(1).evaluate((node) => {
node.setSelectionRange(0, 0);
});
await textareas.nth(1).press("Backspace", { timeout: UI_TIMEOUT_MS });
assert((await page.locator("textarea[data-block-input-id]").count()) === 1, "合并后块数量未回到 1");
assert(
(await page.locator("textarea[data-block-input-id]").first().inputValue()) === "AlphaBeta",
"Backspace 合并后文本不正确",
);
const saveStatus = ((await page.locator("#editor-save-status").textContent()) || "").trim().toLowerCase();
assert(
saveStatus === "saved" || saveStatus === "saving" || saveStatus === "idle",
`结构事务后保存状态异常:${saveStatus}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
saveStatus,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,102 +0,0 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const textarea = page.locator("textarea[data-block-input-id]").first();
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await textarea.fill("Command", { timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-slash").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-slash-action="heading"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-page-ref").click({ timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-block-ref").click({ timeout: UI_TIMEOUT_MS });
await page.locator("#editor-save-status").getByText("saved").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const value = await textarea.inputValue();
assert(
value.includes("[[page]]") && value.includes("((block))"),
`引用 token 未写入真实输入器:${value}`,
);
const title = (await page.locator(".editor-row-title").first().textContent()) || "";
assert(title.includes("Heading"), `slash 切块后未变成 heading${title}`);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
value,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,108 +0,0 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
requireMnoteWebSmokeBaseUrl,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
const runtimeBaseUrl = requireMnoteWebSmokeBaseUrl();
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${runtimeBaseUrl}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const textarea = page.locator("textarea[data-block-input-id]").first();
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await textarea.fill("persist runtime content", { timeout: UI_TIMEOUT_MS });
await page.locator("#editor-save-status").getByText("saved").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
const reloadedTextarea = page.locator("textarea[data-block-input-id]").first();
await reloadedTextarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
(await reloadedTextarea.inputValue()) === "persist runtime content",
"刷新后未回放最近一次保存内容",
);
await page.goto(
`${BASE_URL}/documents/${fixture.parentId}?workspaceId=${encodeURIComponent(fixture.workspaceId)}`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByRole("button", { name: "进入编辑" }).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert(
(await page.locator(`iframe[title="mnote-web-document-shell-${fixture.parentId}"]`).count()) === 0,
"默认文档页不应继续挂载 runtime debug iframe",
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,237 +0,0 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
UI_TIMEOUT_MS,
assert,
createTempDocument,
ensureAuthenticated,
purgeDocument,
} = require("./tree-shell-smoke-helpers");
function readDocumentIdFromUrl(url) {
try {
const parsed = new URL(url);
const match = parsed.pathname.match(/^\/documents\/([^/]+)$/);
return match ? match[1] : null;
} catch {
return null;
}
}
async function collectRuntimeIslandDiagnostics(page) {
return page.evaluate(() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = document.querySelector("[data-editor-host-observability]");
const editor = host?.querySelector(".editor-surface .ProseMirror");
const textareaCount = host?.querySelectorAll("textarea").length ?? 0;
const contenteditableCount = host?.querySelectorAll(".editor-surface .ProseMirror[contenteditable]").length ?? 0;
return {
hostKind: host?.getAttribute("data-editor-host-kind") ?? null,
runtimeStatus: host?.getAttribute("data-runtime-editor-status") ?? null,
activeHostKind: observability?.getAttribute("data-editor-host-active") ?? null,
observability: observability?.getAttribute("data-editor-host-observability") ?? null,
editorTagName: editor instanceof HTMLElement ? editor.tagName : null,
editorIsContentEditable: editor instanceof HTMLElement ? editor.isContentEditable : false,
editorContentEditableAttr: editor instanceof HTMLElement ? editor.getAttribute("contenteditable") : null,
editorCount: host?.querySelectorAll(".editor-surface .ProseMirror").length ?? 0,
textareaCount,
contenteditableCount,
hostHTML: host?.innerHTML?.slice(0, 2000) ?? null,
};
});
}
async function waitForRuntimeIsland(page) {
const root = page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
await root.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
try {
await page.waitForFunction(
() => {
const host = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = document.querySelector("[data-editor-host-observability]");
const editor = host?.querySelector(".editor-surface .ProseMirror");
return (
host?.getAttribute("data-editor-host-kind") === "leptos_tiptap_island" &&
observability?.getAttribute("data-editor-host-active") === "leptos_tiptap_island" &&
host?.getAttribute("data-runtime-editor-status") !== "error" &&
editor instanceof HTMLElement &&
editor.isContentEditable === true
);
},
null,
{ timeout: UI_TIMEOUT_MS },
);
} catch (error) {
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostics, null, 2)}`,
);
}
const editorCount = await root.locator(".editor-surface .ProseMirror").count();
const textareaCount = await root.locator("textarea").count();
const contenteditableCount = await root.locator(".editor-surface .ProseMirror[contenteditable]").count();
if (editorCount === 0 || contenteditableCount === 0 || textareaCount > 0) {
const diagnostics = await collectRuntimeIslandDiagnostics(page).catch(() => null);
throw new Error(
[
editorCount === 0 ? "island 主编辑器根节点内缺少 `.editor-surface .ProseMirror` surface" : null,
contenteditableCount === 0 ? "island 主编辑器 surface 未暴露真实 contenteditable" : null,
textareaCount > 0 ? "island 主编辑器根节点内不应回退为 textarea" : null,
]
.filter(Boolean)
.join("") +
`\n${JSON.stringify(diagnostics, null, 2)}`,
);
}
}
async function waitForSaved(page) {
await page.waitForFunction(
() =>
document
.querySelector('[data-editor-host-kind="leptos_tiptap_island"]')
?.getAttribute("data-runtime-editor-status") === "saved",
null,
{ timeout: UI_TIMEOUT_MS },
);
}
async function readEditorText(page) {
return page.evaluate(() => {
const editor = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror',
);
return editor?.textContent ?? "";
});
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let createdDocumentId = null;
let createdWorkspaceId = null;
try {
await ensureAuthenticated(page, context.request);
const created = await createTempDocument(context.request, null);
createdDocumentId = created.documentId;
createdWorkspaceId = created.workspaceId;
await page.goto(
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}`,
{ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
);
assert(
page.url().includes(`/documents/${createdDocumentId}`),
`未进入新建页面:${page.url()}`,
);
await waitForRuntimeIsland(page);
const editor = page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
.first();
const text = `task108-island-${Date.now().toString().slice(-6)}`;
await editor.evaluate((el) => {
if (el instanceof HTMLElement) {
el.focus();
}
});
await page.keyboard.type(text, { delay: 30 });
await waitForSaved(page);
assert((await readEditorText(page)).includes(text), "默认 runtime island 未写入文本");
await page.keyboard.press("Control+z");
await page.waitForFunction(
(expected) => {
const editorNode = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
);
return !(editorNode?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
await page.keyboard.press("Control+y");
await page.waitForFunction(
(expected) => {
const editorNode = document.querySelector(
'[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]',
);
return (editorNode?.textContent ?? "").includes(expected);
},
text,
{ timeout: UI_TIMEOUT_MS },
);
await waitForSaved(page);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForRuntimeIsland(page);
assert((await readEditorText(page)).includes(text), "刷新后未回填 runtime island 保存内容");
await page.goto(
`${BASE_URL}/documents/${createdDocumentId}?workspaceId=${encodeURIComponent(createdWorkspaceId)}&editorHost=blocknote`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.waitForFunction(
() =>
document
.querySelector("[data-editor-host-observability]")
?.getAttribute("data-editor-host-active") === "blocknote",
null,
{ timeout: UI_TIMEOUT_MS },
);
const runtimeIslandCount = await page
.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
.count();
assert(runtimeIslandCount === 0, "显式 blocknote 回退下不应继续挂载 island 主编辑器");
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
documentId: createdDocumentId,
workspaceId: createdWorkspaceId,
text,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (createdDocumentId) {
try {
await purgeDocument(context.request, createdDocumentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -1,18 +0,0 @@
import { AiAgentPanel } from "@/components/ai-agent/AiAgentPanel";
export default function DevAiAgentPage() {
return (
<div className="min-h-screen bg-[#04070f] px-4 py-6 text-white">
<div className="mx-auto flex max-w-[1600px] flex-col gap-4">
<div className="px-1">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-sky-200/70">MNOTE · Global AI Lab</div>
<div className="mt-2 text-sm text-white/55">
<code className="rounded bg-white/10 px-1.5 py-0.5 text-white">/api/ai-agent/run</code> 使 SSE
</div>
</div>
<AiAgentPanel />
</div>
</div>
);
}
@@ -1,30 +0,0 @@
"use client";
import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock";
import type { BlockNoteEditor } from "@blocknote/core";
import type { CustomBlockSchema } from "@/components/editor/schema";
const stubBlock = {
id: "dev-mindmap",
type: "mindmap",
props: {
docId: "dev",
data: defaultMindmapData,
},
content: [],
children: [],
} as any;
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
@@ -1,133 +0,0 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
import { cn } from "@/lib/utils";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
{ ssr: false },
);
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = {
wordCount: 0,
characterCount: 0,
blockCount: 0,
todoTotal: 0,
todoDone: 0,
};
export default function PageOptionsPlaygroundPage() {
const editorBridge = useEditorBridgeStore((s) => s.bridge);
const [options, setOptions] = useState<PageOptionsState>(defaultOptions);
const [stats, setStats] = useState<DocumentStats>(defaultStats);
const documentId = "dev-page-options";
const workspaceId = "dev-workspace";
const initialContent = useMemo(
() => [
{
id: "h1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "标题一" }],
},
{
id: "p1",
type: "paragraph",
props: {},
content: [{ type: "text", text: "这是用于回归测试页面选项的示例段落。" }],
},
],
[],
);
const toggleOption = useCallback((key: BooleanPageOptionKey) => {
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const setPageFont = useCallback((font: PageFont) => setOptions((prev) => ({ ...prev, pageFont: font })), []);
const setLayoutDensity = useCallback(
(density: PageLayoutDensity) => setOptions((prev) => ({ ...prev, layoutDensity: density })),
[],
);
const pageRootClass = cn(
"flex h-[calc(100vh-64px)] overflow-hidden bg-wolai-bg",
options.pageFont === "song" && "wolai-page-font-song",
options.pageFont === "kai" && "wolai-page-font-kai",
options.layoutDensity === "compact" && "wolai-page-density-compact",
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
return (
<div className="p-6">
<div className="mb-4 text-sm text-gray-500">
Dev Playground Playwright
</div>
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className={pageRootClass}>
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
<div className="text-3xl font-semibold text-wolai-text-primary"></div>
<p className="mt-1 text-sm text-wolai-text-secondary"></p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={initialContent as unknown}
pageOptions={options}
readOnly={false}
onStatsChange={setStats}
/>
</div>
</div>
<PageOptionsSidebar
documentId={documentId}
options={options}
stats={stats}
onToggle={toggleOption}
onSetPageFont={setPageFont}
onSetLayoutDensity={setLayoutDensity}
onSetEmbedDefaultToCursor={() => window.alert("该页面为 Dev Playground,不写入后端")}
onClearEmbedDefault={() => setOptions((prev) => ({ ...prev, embedDefaultBlockId: null }))}
onExport={() => window.alert("该页面为 Dev Playground,不提供导出")}
onOpenHistory={() => window.alert("该页面为 Dev Playground,不提供历史")}
onOpenComments={() => window.alert("该页面为 Dev Playground,不提供评论")}
onUndo={() => editorBridge?.undo?.()}
onRedo={() => editorBridge?.redo?.()}
onDeletePage={() => window.alert("该页面为 Dev Playground,不提供删除")}
onOpenMoveEmbedPicker={() => window.alert("该页面为 Dev Playground,不提供移动/嵌入")}
onCopyPageLink={() => window.alert("该页面为 Dev Playground,不提供复制链接")}
onCopyPageReference={() => window.alert("该页面为 Dev Playground,不提供引用")}
onAddToTemplates={() => window.alert("该页面为 Dev Playground,不提供模板")}
/>
</div>
</ImagePickerProvider>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,56 +0,0 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
const TODO_STATES = ["todo", "doing", "done", "cancelled"] as const;
const STATUS_LABELS: Record<(typeof TODO_STATES)[number], string> = {
todo: "未开始",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
};
export const advancedTodoBlock = createReactBlockSpec(
{
type: "advancedTodo",
propSchema: {
status: {
default: "todo",
values: TODO_STATES,
},
},
content: "inline",
},
{
render: ({ block, editor }) => {
const updateStatus = (next: (typeof TODO_STATES)[number]) => {
editor.updateBlock(block, { props: { status: next } });
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const current = block.props.status as (typeof TODO_STATES)[number];
if (event.altKey) {
updateStatus("cancelled");
return;
}
const index = TODO_STATES.indexOf(current);
const nextState = TODO_STATES[(index + 1) % TODO_STATES.length];
updateStatus(nextState);
};
return (
<div className="wolai-advanced-todo">
<button
type="button"
className={`wolai-advanced-todo__status status-${block.props.status}`}
onClick={handleClick}
>
{STATUS_LABELS[block.props.status as (typeof TODO_STATES)[number]]}
</button>
<div className="wolai-advanced-todo__content" />
</div>
);
},
},
)();
@@ -1,166 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { extractBlockText } from "@/lib/blocks";
type RemoteBlock = {
id: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: unknown;
};
const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading";
export const blockReferenceBlock = createReactBlockSpec(
{
type: "blockReference",
propSchema: {
sourceDocumentId: { default: "" },
targetBlockId: { default: "" },
display: { default: "embed" },
},
content: "none",
},
() => ({
render: ({ block }) => <BlockReferenceContent block={block as any} />,
}),
)();
function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) {
const router = useRouter();
const sourceDocumentId = block.props.sourceDocumentId;
const targetBlockId = block.props.targetBlockId;
const [remote, setRemote] = useState<RemoteBlock | null>(null);
const [textDraft, setTextDraft] = useState<string>("");
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [error, setError] = useState<string>("");
const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]);
useEffect(() => {
if (!sourceDocumentId || !targetBlockId) {
setStatus("error");
setError("引用信息不完整");
return;
}
let cancelled = false;
setStatus("loading");
setError("");
setRemote(null);
const run = async () => {
try {
const res = await fetch(
`/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`,
{ method: "GET", credentials: "include" },
);
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.error ?? "获取引用块失败");
}
const json = await res.json();
const next = (json?.block ?? null) as RemoteBlock | null;
if (!cancelled) {
setRemote(next);
if (next && isTextBlock(next)) {
setTextDraft(extractBlockText(next as any));
}
setStatus("idle");
}
} catch (e) {
if (!cancelled) {
setStatus("error");
setError(e instanceof Error ? e.message : "获取引用块失败");
}
}
};
void run();
return () => {
cancelled = true;
};
}, [sourceDocumentId, targetBlockId]);
const openSource = useCallback(() => {
if (sourceDocumentId) {
router.push(`/documents/${sourceDocumentId}`);
}
}, [router, sourceDocumentId]);
const saveText = useCallback(async () => {
if (!remote || !canEdit) return;
const nextBlock: RemoteBlock = {
...remote,
id: remote.id,
content: [{ type: "text", text: textDraft }],
};
const res = await fetch("/api/blocks/patch", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
const msg = payload?.error ?? "同步编辑失败";
if (typeof window !== "undefined") window.alert(msg);
return;
}
if (typeof window !== "undefined") window.alert("已同步编辑到原块");
}, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]);
return (
<div
className="mt-2 rounded-md border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3"
onMouseDown={(e) => {
// 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。
e.stopPropagation();
}}
>
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500">
<span></span>
<span className="ml-auto flex items-center gap-2">
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={openSource}>
</Button>
</span>
</div>
{status === "loading" ? (
<div className="text-sm text-gray-400">...</div>
) : status === "error" ? (
<div className="text-sm text-red-600">{error}</div>
) : !remote ? (
<div className="text-sm text-gray-400"></div>
) : canEdit ? (
<div className="space-y-2">
<textarea
className="w-full resize-y rounded-md border border-[#e2e8f0] bg-white p-2 text-sm text-gray-900 outline-none"
rows={3}
value={textDraft}
onChange={(e) => setTextDraft(e.target.value)}
placeholder="在这里编辑会同步到原块(MVP:仅支持段落/标题纯文本)"
/>
<div className="flex items-center gap-2">
<Button type="button" size="sm" className="h-8 px-3 text-xs" onClick={() => void saveText()}>
</Button>
<span className="text-[11px] text-gray-400">MVP/</span>
</div>
</div>
) : (
<div className="text-sm text-gray-700">
<div className="mb-1 text-xs text-gray-400">{remote.type ?? "unknown"}</div>
<div className="text-sm text-gray-800">{extractBlockText(remote as any) || "(内容为空或暂不支持渲染)"}</div>
</div>
)}
</div>
);
}
@@ -1,789 +0,0 @@
"use client";
import {
useEffect,
useRef,
useState,
useMemo,
useCallback,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useCurrentDocumentStore } from "@/store/current-document";
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { CustomBlockSchema } from "../schema";
type MediaAlign = "left" | "center" | "right";
type MediaBlockRenderProps = {
block: Block<CustomBlockSchema> & { props: any };
editor: BlockNoteEditor<CustomBlockSchema>;
};
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
image: "图片",
video: "视频",
audio: "音频",
file: "文件",
};
const deriveFileName = (value?: string) => {
if (!value) {
return "未命名资源";
}
try {
const url = new URL(value);
const last = url.pathname.split("/").filter(Boolean).pop();
if (last) {
return decodeURIComponent(last);
}
} catch {
const segments = value.split("?")[0]?.split("/") ?? [];
const last = segments.pop();
if (last) {
return decodeURIComponent(last);
}
}
return "未命名资源";
};
const formatFileSize = (size?: number | null) => {
if (!size || size <= 0) {
return "未知大小";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let idx = 0;
let current = size;
while (current >= 1024 && idx < units.length - 1) {
current /= 1024;
idx += 1;
}
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
};
const MediaBlockContent = ({ block, editor }: any) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
const browserFileUrl = useMemo(() => toBrowserAccessibleUrl(fileUrl) ?? fileUrl, [fileUrl]);
const rawThumbUrl = (block.props.thumbnailUrl as string | undefined) || "";
const browserThumbUrl = useMemo(
() => (rawThumbUrl ? toBrowserAccessibleUrl(rawThumbUrl) ?? rawThumbUrl : ""),
[rawThumbUrl],
);
const rawAssetType = (block.props.assetType as string) || "image";
const assetType: MediaKind =
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
? (rawAssetType as MediaKind)
: "image";
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
const canAlign = assetType === "image" || assetType === "video";
const canToggleBorder = assetType === "image";
const canTriggerOcr = assetType === "image";
const canResize = assetType === "image" || assetType === "video";
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
const mediaRef = useRef<HTMLDivElement | null>(null);
const latestWidthRef = useRef(localWidth);
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
const captionRef = useRef<HTMLInputElement | null>(null);
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
const resolveDocumentId = useCallback(() => {
if (typeof window !== "undefined") {
const [, tail] = window.location.pathname.split("/documents/");
if (tail) {
const id = tail.split(/[/?#]/)[0];
if (id) return id;
}
}
return (block.props as { documentId?: string })?.documentId || "";
}, [block.props]);
const extension = useMemo(() => {
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
const match = /\.([a-z0-9]+)$/.exec(name);
return match?.[1] ?? "";
}, [block.props.fileName, fileUrl]);
const isOfficeDoc = useMemo(
() =>
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
extension,
),
[extension],
);
const handleChoose = () => {
openPicker({
defaultTab: fileUrl ? "recent" : "upload",
mediaType: assetType,
onSelect: (selection) => {
editor.updateBlock(block, {
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? rawAssetType,
fileName: selection.fileName ?? block.props.fileName ?? "",
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
documentId: resolveDocumentId(),
},
});
},
});
};
const toggleBorder = () => {
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
};
const setAlign = (align: MediaAlign) => {
editor.updateBlock(block, { props: { captionAlign: align } });
};
const handleCaptionChange = (value: string) => {
editor.updateBlock(block, { props: { caption: value } });
};
const enableCaptionEdit = () => {
setCaptionEditing(true);
setTimeout(() => captionRef.current?.focus(), 0);
};
useEffect(() => {
if (!shouldShowCaption && captionEditing) {
setCaptionEditing(false);
}
}, [captionEditing, shouldShowCaption]);
useEffect(() => {
if (!dragging) {
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
}
}, [block.props.width, dragging]);
useEffect(() => {
latestWidthRef.current = localWidth;
}, [localWidth]);
const resolvedWidth = useMemo(() => {
if (!canResize) return 0;
if (localWidth > 0) return clampWidth(localWidth);
if (block.props.width && Number(block.props.width) > 0) {
return clampWidth(Number(block.props.width));
}
return 0;
}, [block.props.width, canResize, localWidth]);
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
if (!canResize) return;
event.preventDefault();
event.stopPropagation();
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
if (!canvasWidth) {
return;
}
setDragging({
side,
startX: event.clientX,
startWidth: canvasWidth,
});
};
useEffect(() => {
if (!dragging) {
return undefined;
}
const handleMove = (event: MouseEvent) => {
event.preventDefault();
const delta = event.clientX - dragging.startX;
const adjusted = dragging.side === "left" ? -delta : delta;
const next = clampWidth(dragging.startWidth + adjusted);
setLocalWidth(next);
};
const handleUp = () => {
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
setDragging(null);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragging, editor, block]);
const handleLink = () => {
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
if (next === null) return;
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
};
const resolveAssetId = async () => {
const direct = (block.props as { assetId?: string })?.assetId;
if (direct) return direct;
// 说明:历史数据/迁移场景下,media block 可能丢失 assetId,导致:
// - PDF 打开拿到的不是原文件(旧链接过期/返回 HTML)
// - OnlyOffice callback 缺少 assetId,进而“不能保存”
// 这里尝试通过 documentId + fileName 在 media_assets 中反查 assetId。
const docId = (block.props as { documentId?: string })?.documentId || resolveDocumentId();
const name = String(block.props.fileName || block.props.caption || "").trim();
if (!docId || !name) return "";
try {
const res = await fetch(
`/api/media/by-document?documentId=${encodeURIComponent(docId)}&limit=500`,
);
if (!res.ok) return "";
const payload = (await res.json().catch(() => null)) as { items?: Array<{ id?: string; file_name?: string | null }> } | null;
const items = Array.isArray(payload?.items) ? payload!.items! : [];
const hit = items.find((it) => String(it.file_name || "") === name);
return hit?.id ? String(hit.id) : "";
} catch {
return "";
}
};
const resolveLatestFileUrl = async () => {
if (!fileUrl) return "";
const assetId = await resolveAssetId();
if (!assetId) return fileUrl;
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
if (!res.ok) return fileUrl;
const payload = (await res.json().catch(() => null)) as { signedUrl?: string } | null;
return payload?.signedUrl || fileUrl;
} catch {
return fileUrl;
}
};
const viewOriginal = async () => {
const url = await resolveLatestFileUrl();
if (!url) return;
window.open(url, "_blank", "noopener,noreferrer");
};
const openWithOnlyOffice = async () => {
if (!fileUrl) return;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
try {
const assetId = await resolveAssetId();
const res = assetId
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
: await fetch(
`/api/media/signed-url?fileUrl=${encodeURIComponent(fileUrl)}&fileName=${encodeURIComponent(
displayFileName,
)}&for=onlyoffice`,
);
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
const target = new URL("/onlyoffice", window.location.origin);
target.searchParams.set("fileUrl", signedUrl);
target.searchParams.set("fileName", displayFileName);
target.searchParams.set("fileType", extension || "docx");
const docId = resolveDocumentId();
if (docId) {
target.searchParams.set("documentId", docId);
}
if (assetId) {
target.searchParams.set("assetId", assetId);
}
target.searchParams.set("mode", currentDocReadOnly ? "view" : "edit");
window.open(target.toString(), "_blank", "noopener,noreferrer");
} catch (error) {
window.alert((error as Error).message);
}
};
const currentDocumentId = useCurrentDocumentStore((state) => state.documentId);
const currentDisableDownload = useCurrentDocumentStore((state) => state.disableDownload);
const resolvedDocIdForRestriction = resolveDocumentId();
const downloadDisabled =
Boolean(currentDisableDownload) &&
Boolean(resolvedDocIdForRestriction) &&
String(currentDocumentId ?? "") === String(resolvedDocIdForRestriction);
const downloadAsset = async () => {
if (downloadDisabled) {
window.alert("该页面已禁止下载");
return;
}
const url = await resolveLatestFileUrl();
if (!url) return;
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = block.props.fileName || block.props.caption || typeLabel;
anchor.click();
};
const handleDeleteAsset = async () => {
const assetId = (block.props as { assetId?: string })?.assetId;
if (!assetId) {
editor.removeBlocks([block.id]);
return;
}
const docId = resolveDocumentId();
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除附件失败");
return;
}
editor.removeBlocks([block.id]);
emitAssetsChanged(docId);
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
return;
}
setBusy(true);
try {
const response = await fetch("/api/media/ocr", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: block.props.assetId }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "触发 OCR 失败");
}
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
} catch (error) {
window.alert((error as Error).message);
} finally {
setBusy(false);
}
};
if (!fileUrl) {
return (
<div className="wolai-media wolai-media--empty">
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
<ImageIcon className="h-4 w-4" />
{typeLabel}
</Button>
<p className="text-xs text-gray-500"></p>
</div>
);
}
const renderPreviewContent = () => {
if (assetType === "video") {
return (
<video
controls
className="max-h-[420px] w-full rounded-2xl bg-black"
poster={browserThumbUrl || undefined}
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
>
<source src={browserFileUrl} type={block.props.mimeType || "video/mp4"} />
</video>
);
}
if (assetType === "audio") {
return (
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
<audio controls className="w-full">
<source src={browserFileUrl} type={block.props.mimeType || "audio/mpeg"} />
</audio>
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
</div>
);
}
if (assetType === "file") {
// 根据文件扩展名确定图标颜色
const getIconColor = () => {
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
if (ext === "pdf") return "text-red-500";
if (["doc", "docx"].includes(ext)) return "text-blue-600";
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
return "text-[#9B9A97]";
};
return (
<div
role="button"
tabIndex={0}
onClick={() => {
if (isOfficeDoc) {
void openWithOnlyOffice();
} else {
void downloadAsset();
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (isOfficeDoc) {
void openWithOnlyOffice();
} else {
void downloadAsset();
}
}
}}
data-testid="wolai-media-file-row"
className="group flex h-[26px] items-center gap-2 rounded-[4px] border border-transparent bg-transparent px-2 outline-none transition-colors duration-200 cursor-pointer select-none hover:border-[#E9E9E8] hover:bg-[#F7F7F5] focus:outline-none focus-visible:outline-none"
>
<span className={cn("w-5 h-5 flex-none flex items-center justify-center", getIconColor())}>
<Paperclip className="h-4 w-4" />
</span>
<div className="flex min-w-0 flex-1 flex-row items-center gap-2 overflow-hidden">
<span className="min-w-0 flex-1 truncate text-[14px] text-[#37352F] font-normal">
{displayFileName}
</span>
{block.props.fileSize ? (
<span className="shrink-0 text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</span>
) : null}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
data-testid="wolai-media-file-download"
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
aria-label={downloadDisabled ? "已禁止下载" : "下载"}
title={downloadDisabled ? "已禁止下载" : "下载"}
disabled={downloadDisabled}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void downloadAsset();
}}
>
<Download className="h-4 w-4" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-testid="wolai-media-file-more"
className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
aria-label="更多操作"
title="更多操作"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleChoose}></DropdownMenuItem>
{!shouldShowCaption && <DropdownMenuItem onClick={enableCaptionEdit}></DropdownMenuItem>}
<DropdownMenuItem onClick={handleLink}>{block.props.linkUrl ? "编辑链接" : "添加链接"}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
void viewOriginal();
}}
>
</DropdownMenuItem>
{isOfficeDoc && <DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使 ONLYOFFICE </DropdownMenuItem>}
<DropdownMenuItem
disabled={downloadDisabled}
onClick={() => {
void downloadAsset();
}}
>
{downloadDisabled ? "已禁止下载" : "下载到本地"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDeleteAsset}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
return (
<img
src={browserThumbUrl || browserFileUrl}
alt={block.props.caption || typeLabel}
style={inlineStyle}
/>
);
};
const figure = (
<figure
className={cn(
"wolai-media__figure",
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
)}
>
<div className="wolai-media__preview">{renderPreviewContent()}</div>
{shouldShowCaption && (
<figcaption>
<input
ref={captionRef}
value={block.props.caption ?? ""}
onChange={(event) => handleCaptionChange(event.target.value)}
onBlur={() => setCaptionEditing(false)}
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
/>
</figcaption>
)}
</figure>
);
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
const quickActions: QuickAction[] = [
{
key: "replace",
label: `替换${typeLabel}`,
icon: <RefreshCcw className="h-4 w-4" />,
onClick: handleChoose,
},
canToggleBorder
? {
key: "border",
label: block.props.hasBorder ? "取消边框" : "显示边框",
icon: <ImageIcon className="h-4 w-4" />,
onClick: toggleBorder,
}
: null,
!shouldShowCaption
? {
key: "caption",
label: "添加说明",
icon: <Type className="h-4 w-4" />,
onClick: enableCaptionEdit,
}
: null,
{
key: "link",
label: block.props.linkUrl ? "编辑链接" : "添加链接",
icon: <LinkIcon className="h-4 w-4" />,
onClick: handleLink,
},
!downloadDisabled
? {
key: "download",
label: `下载${typeLabel}`,
icon: <Download className="h-4 w-4" />,
onClick: () => {
void downloadAsset();
},
}
: null,
{
key: "delete",
label: `删除${typeLabel}`,
icon: <Trash className="h-4 w-4" />,
onClick: handleDeleteAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
return (
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
<div
className="wolai-media__canvas"
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
onDoubleClick={() => {
if (assetType === "file" && isOfficeDoc) {
void openWithOnlyOffice();
} else if (assetType === "file") {
void viewOriginal();
}
}}
>
{block.props.linkUrl ? (
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
{figure}
</a>
) : (
figure
)}
{assetType !== "file" && (
<div className="wolai-media__quickbar">
{quickActions.map((action) => (
<button
key={action.key}
type="button"
className="wolai-media__quickbutton"
onClick={action.onClick}
title={action.label}
aria-label={action.label}
>
{action.icon}
</button>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={handleChoose}></DropdownMenuItem>
{!shouldShowCaption && (
<DropdownMenuItem onClick={enableCaptionEdit}></DropdownMenuItem>
)}
{canToggleBorder && (
<DropdownMenuItem onClick={toggleBorder}>
{block.props.hasBorder ? "取消边框" : "显示边框"}
</DropdownMenuItem>
)}
{canAlign && (
<>
<DropdownMenuLabel className="text-xs text-gray-400"></DropdownMenuLabel>
<DropdownMenuItem onClick={() => setAlign("left")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("center")}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setAlign("right")}></DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}></DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
void viewOriginal();
}}
>
</DropdownMenuItem>
<DropdownMenuItem
disabled={downloadDisabled}
onClick={() => {
void downloadAsset();
}}
>
{downloadDisabled ? "已禁止下载" : "下载到本地"}
</DropdownMenuItem>
{canTriggerOcr && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{canResize && (
<>
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
</>
)}
</div>
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
</div>
);
};
export const mediaBlock = createReactBlockSpec(
{
type: "media",
propSchema: {
fileUrl: { default: "", type: "string" },
thumbnailUrl: { default: "", type: "string" },
caption: { default: "", type: "string" },
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
hasBorder: { default: true, type: "boolean" },
linkUrl: { default: "", type: "string" },
assetId: { default: "", type: "string" },
assetType: { default: "image", type: "string" },
fileName: { default: "", type: "string" },
fileSize: { default: 0, type: "number" },
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
documentId: { default: "", type: "string" },
},
content: "none",
},
{
render: (props) => <MediaBlockContent {...props} />,
},
)();
const handleCopyLink = async (targetUrl: string | null) => {
if (!targetUrl) return;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(targetUrl);
window.alert("链接已复制");
} else {
throw new Error("no clipboard");
}
} catch {
window.prompt("请复制以下链接", targetUrl);
}
};
const ResizeHandle = ({
side,
onMouseDown,
dragging,
}: {
side: "left" | "right";
dragging: boolean;
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
}) => (
<span
role="separator"
tabIndex={0}
aria-orientation="horizontal"
onMouseDown={onMouseDown}
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
/>
);
const clampWidth = (value: number) => {
const min = 240;
const max = 960;
if (Number.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
};
@@ -1,256 +0,0 @@
"use client";
import { BlockNoteEditor, Block } from "@blocknote/core";
import { createReactBlockSpec } from "@blocknote/react";
import React, { useCallback, useMemo, useState } from "react";
import type { CustomBlockSchema } from "../schema";
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
import { useEditorBridgeStore } from "@/store/editor-bridge";
const DEFAULT_WIDTH = 960;
const DEFAULT_HEIGHT = 520;
const MIN_WIDTH = 420;
const MAX_WIDTH = 1400;
const MIN_HEIGHT = 320;
const MAX_HEIGHT = 900;
type ResizeHandle =
| "left"
| "right"
| "top"
| "bottom"
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
const handleMapping: Record<
ResizeHandle,
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
> = {
left: { horizontal: "left" },
right: { horizontal: "right" },
top: { vertical: "top" },
bottom: { vertical: "bottom" },
"top-left": { horizontal: "left", vertical: "top" },
"top-right": { horizontal: "right", vertical: "top" },
"bottom-left": { horizontal: "left", vertical: "bottom" },
"bottom-right": { horizontal: "right", vertical: "bottom" },
};
// 占位符组件:在紧凑模式下渲染表格块
const OnlineTableBlockComponent = ({
block,
editor,
}: any) => {
const { tableId } = block.props;
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
const [draftSize, setDraftSize] = useState({
width: storedWidth ?? DEFAULT_WIDTH,
height: storedHeight ?? DEFAULT_HEIGHT,
});
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const committedSize = useMemo(
() => ({
width: clamp(storedWidth ?? DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH),
height: clamp(storedHeight ?? DEFAULT_HEIGHT, MIN_HEIGHT, MAX_HEIGHT),
}),
[storedHeight, storedWidth],
);
const commitSize = useCallback(
(next: { width: number; height: number }) => {
setDraftSize(next);
editor.updateBlock(block, {
props: {
...block.props,
width: next.width,
height: next.height,
},
});
},
[block, editor],
);
// 阶段三:实现双击/按钮进入全屏编辑
const handleFullScreen = () => {
if (openTableFullScreen) {
openTableFullScreen(tableId);
} else {
console.error("Editor bridge not ready or openTableFullScreen missing.");
}
};
const handleDelete = useCallback(() => {
editor.removeBlocks([block.id]);
}, [block.id, editor]);
const startResize = useCallback(
(handle: ResizeHandle) => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = committedSize.width;
const startHeight = committedSize.height;
let nextWidth = startWidth;
let nextHeight = startHeight;
const axes = handleMapping[handle];
setActiveHandle(handle);
setDraftSize({ width: startWidth, height: startHeight });
document.body.style.userSelect = "none";
const cursor =
axes.horizontal && axes.vertical
? axes.horizontal === "left"
? axes.vertical === "top"
? "nwse-resize"
: "nesw-resize"
: axes.vertical === "top"
? "nesw-resize"
: "nwse-resize"
: axes.horizontal
? "ew-resize"
: "ns-resize";
document.body.style.cursor = cursor;
const handleMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaY = moveEvent.clientY - startY;
if (axes.horizontal === "left") {
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
} else if (axes.horizontal === "right") {
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
} else {
nextWidth = startWidth;
}
if (axes.vertical === "top") {
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else if (axes.vertical === "bottom") {
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
} else {
nextHeight = startHeight;
}
setDraftSize({
width: nextWidth,
height: nextHeight,
});
};
const handleUp = () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
setActiveHandle(null);
commitSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
});
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[commitSize, committedSize.height, committedSize.width],
);
const size = useMemo(() => {
const src = activeHandle ? draftSize : committedSize;
return {
width: clamp(src.width, MIN_WIDTH, MAX_WIDTH),
height: clamp(src.height, MIN_HEIGHT, MAX_HEIGHT),
};
}, [activeHandle, committedSize, draftSize]);
const handleClass = (handle: ResizeHandle) =>
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
activeHandle === handle ? "is-dragging" : ""
}`;
return (
<div className="w-full overflow-auto" contentEditable={false}>
<div
className="online-table-block group relative mx-auto"
style={{ width: size.width, minWidth: MIN_WIDTH }}
>
<CompactTablePreview
tableId={tableId}
onFullScreen={handleFullScreen}
onDelete={handleDelete}
height={size.height}
/>
<button
type="button"
aria-label="向左拖拽以调整宽度"
className={handleClass("left")}
onMouseDown={startResize("left")}
/>
<button
type="button"
aria-label="向右拖拽以调整宽度"
className={handleClass("right")}
onMouseDown={startResize("right")}
/>
<button
type="button"
aria-label="向上拖拽以调整高度"
className={handleClass("top")}
onMouseDown={startResize("top")}
/>
<button
type="button"
aria-label="向下拖拽以调整高度"
className={handleClass("bottom")}
onMouseDown={startResize("bottom")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-left")}
onMouseDown={startResize("top-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("top-right")}
onMouseDown={startResize("top-right")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-left")}
onMouseDown={startResize("bottom-left")}
/>
<button
type="button"
aria-label="拖拽以调整宽高"
className={handleClass("bottom-right")}
onMouseDown={startResize("bottom-right")}
/>
</div>
</div>
);
};
// Block Spec 定义
export const onlineTableBlock = createReactBlockSpec(
{
type: "onlineTable",
propSchema: {
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
title: { default: "未命名表格" },
width: { default: DEFAULT_WIDTH },
height: { default: DEFAULT_HEIGHT },
},
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
},
{
render: (props) => <OnlineTableBlockComponent {...props} />,
}
);
@@ -1,77 +0,0 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
import { RiFileTextFill } from "react-icons/ri";
import { useRouter } from "next/navigation";
const normalizeTitle = (value?: string | null) => {
if (!value || !value.trim()) {
return "未命名页面";
}
return value;
};
const PageReferenceContent = ({
pageId,
title,
asChildPage,
}: {
pageId: string;
title: string;
asChildPage: boolean;
}) => {
const router = useRouter();
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
// 页面引用的标题会由编辑器同步更新 block.props.title。
const resolvedTitle = normalizeTitle(title);
const navigate = () => {
if (pageId) {
router.push(`/documents/${pageId}`);
}
};
return (
<div
data-child-page={asChildPage ? "true" : "false"}
role="button"
tabIndex={0}
onClick={navigate}
onKeyDown={(event) => {
if ((event.key === "Enter" || event.key === " ") && pageId) {
event.preventDefault();
navigate();
}
}}
className="group mt-2 inline-flex items-center gap-1.5 rounded-[3px] px-1.5 py-0.5 text-[#37352F] hover:bg-[rgba(55,53,47,0.08)] transition-colors duration-150"
style={{ fontFamily: "Inter, system-ui, sans-serif" }}
>
<RiFileTextFill className="w-4 h-4 text-[#9B9A97] group-hover:text-[#37352F] transition-colors" aria-hidden />
<span className="text-[15px] font-medium leading-normal">{resolvedTitle}</span>
<span className="text-[13px] text-[#9B9A97] opacity-0 group-hover:opacity-100 ml-2">
</span>
</div>
);
};
export const pageReferenceBlock = createReactBlockSpec(
{
type: "pageReference",
propSchema: {
pageId: { default: "" },
title: { default: "未命名页面" },
asChildPage: { default: false },
},
content: "none",
},
() => ({
render: ({ block }) => (
<PageReferenceContent
pageId={block.props.pageId}
title={block.props.title}
asChildPage={Boolean((block.props as any).asChildPage)}
/>
),
}),
)();
@@ -1,50 +0,0 @@
"use client";
import { createReactBlockSpec } from "@blocknote/react";
export const progressBlock = createReactBlockSpec(
{
type: "progressMeter",
propSchema: {
percent: { default: 0, type: "number" },
auto: { default: true, type: "boolean" },
summary: { default: "", type: "string" },
},
content: "inline",
},
{
render: ({ block, editor }) => {
const percent = block.props.percent ?? 0;
const handleToggle = () => {
editor.updateBlock(block, { props: { auto: !block.props.auto } });
};
const handleBarClick = () => {
if (block.props.auto) return;
const input = window.prompt("设置进度(0-100", percent.toString());
if (!input) return;
const value = Number.parseInt(input, 10);
if (Number.isNaN(value)) return;
editor.updateBlock(block, {
props: { percent: Math.min(100, Math.max(0, value)) },
});
};
return (
<div className="wolai-progress">
<div className="wolai-progress__header">
<span className="wolai-progress__summary">{block.props.summary || "暂无条目"}</span>
<button type="button" className="wolai-progress__mode" onClick={handleToggle}>
{block.props.auto ? "自动" : "手动"}
</button>
</div>
<div className="wolai-progress__bar" onClick={handleBarClick}>
<div className="wolai-progress__fill" style={{ width: `${percent}%` }} />
</div>
<span className="wolai-progress__percent">{percent}%</span>
<div className="wolai-progress__description" />
</div>
);
},
},
)();
@@ -1,753 +0,0 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import type { Block, PartialBlock } from "@blocknote/core";
import {
BlockColorsItem,
SideMenu,
TableColumnHeaderItem,
TableRowHeaderItem,
useBlockNoteEditor,
useComponentsContext,
type DragHandleMenuProps,
type SideMenuProps,
} from "@blocknote/react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import type { CustomBlockSchema } from "../schema";
import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { useCommentsUiStore } from "@/store/comments-ui";
import { createChildDocumentCommand, deleteDocumentCommand } from "@/lib/documents/tree-command-client";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
typeof TableRowHeaderItem
>[0]["block"];
type DraftBlock = PartialBlock<CustomBlockSchema> & { id?: string };
type ConvertOption = {
label: string;
type?: Block<CustomBlockSchema>["type"];
props?: Record<string, unknown>;
shortcut?: string;
action?: () => void;
};
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
};
const FourDotHandleIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" {...props}>
<circle cx="6" cy="6" r="1.2" />
<circle cx="10" cy="6" r="1.2" />
<circle cx="6" cy="10" r="1.2" />
<circle cx="10" cy="10" r="1.2" />
</svg>
);
const extractText = (block: Block<CustomBlockSchema>) => {
const inlineNodes = block.content as InlineNode[] | undefined;
const maybeText = inlineNodes?.[0]?.text;
if (typeof maybeText === "string" && maybeText.trim().length > 0) {
return maybeText.trim();
}
return "未命名页面";
};
const clearMindmapAutosaveCache = (targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const prefix = "wolai-mindmap-autosave-";
const targetPrefix = `${prefix}${targetDocumentId}`;
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i += 1) {
const k = window.localStorage.key(i);
if (!k) continue;
if (mindmapId) {
if (k === directKey) keys.push(k);
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
keys.push(k);
}
}
keys.forEach((k) => window.localStorage.removeItem(k));
} catch {
// ignore
}
};
const markMindmapDeleting = (targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const w = window as unknown as {
__wolaiMindmapDeletingKeys?: Set<string>;
};
if (!w.__wolaiMindmapDeletingKeys) {
w.__wolaiMindmapDeletingKeys = new Set<string>();
}
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
w.__wolaiMindmapDeletingKeys.add(key);
window.setTimeout(() => {
try {
w.__wolaiMindmapDeletingKeys?.delete(key);
} catch {
// ignore
}
}, 8000);
} catch {
// ignore
}
};
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
const Components = useComponentsContext()!;
const editor = useBlockNoteEditor<CustomBlockSchema>();
const router = useRouter();
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
const duplicateBlock = useCallback(() => {
const blockWithoutId: DraftBlock = { ...block };
delete blockWithoutId.id;
editor.insertBlocks([blockWithoutId], block, "after");
}, [block, editor]);
const removePageReference = useCallback(async () => {
if (block.type === "pageReference") {
const pageId = block.props.pageId;
if (pageId) {
await deleteDocumentCommand({
documentId: pageId,
workspaceId,
});
if (typeof window !== "undefined") {
emitDocumentsChanged(pageId);
}
}
}
editor.removeBlocks([block.id]);
router.refresh();
}, [block, editor, router, workspaceId]);
const handleDeleteBlock = useCallback(async () => {
if (block.type === "pageReference") {
void removePageReference();
return;
}
if (block.type === "onlineTable") {
const tableId = block.props.tableId as string | undefined;
if (tableId) {
try {
await deleteOnlineTable(tableId);
} catch (error) {
console.error("删除在线表格失败", error);
window.alert("删除在线表格失败,请稍后重试");
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
emitAssetsChanged(currentDocumentId);
}
}
editor.removeBlocks([block.id]);
return;
}
if (block.type === "media") {
const assetId = block.props.assetId as string | undefined;
if (assetId) {
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除附件失败");
return;
}
emitAssetsChanged(currentDocumentId);
}
editor.removeBlocks([block.id]);
return;
}
if (block.type === "mindmap") {
// 关键:必须先标记“删除中”,避免 MindmapBlock 卸载清理把数据 POST 回去导致“删除后复活”。
markMindmapDeleting(currentDocumentId, block.id);
clearMindmapAutosaveCache(currentDocumentId, block.id);
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
emitAssetsChanged(currentDocumentId, undefined, undefined, false, [block.id]);
// 侧边栏/全局删除监听也会尝试移除对应块,这里做 try/catch 避免重复删除导致报错
try {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
return;
}
editor.removeBlocks([block.id]);
}, [block, currentDocumentId, editor, removePageReference]);
const turnToPage = useCallback(async () => {
try {
const payload = await createChildDocumentCommand({
parentId: currentDocumentId,
title: extractText(block),
blocks: [block],
});
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId: payload.pageId, title: payload.title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
} catch (error) {
console.error("块转页面失败", error);
}
}, [block, currentDocumentId, editor, router]);
const handleMoveEmbedPick = useCallback(
async (mode: "move" | "embed", targetDocumentId: string | null) => {
if (!targetDocumentId) {
return;
}
if (mode === "embed" && targetDocumentId === currentDocumentId) {
if (typeof window !== "undefined") {
window.alert("禁止嵌入到当前页面");
}
return;
}
const endpoint = mode === "embed" ? "/api/blocks/embed" : "/api/blocks/move";
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceDocumentId: currentDocumentId,
blockId: block.id,
targetDocumentId,
position: "end",
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message =
payload?.error ?? (mode === "embed" ? "嵌入失败,请检查目标页面" : "移动失败,请检查目标页面");
if (typeof window !== "undefined") {
window.alert(message);
}
return;
}
if (mode === "move") {
// 说明:移动块本体:本地编辑器也要移除该块,避免等待刷新造成错觉。
try {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
router.refresh();
return;
}
if (typeof window !== "undefined") {
window.alert("已在目标页面末尾插入嵌入引用块");
}
},
[block.id, currentDocumentId, editor, router],
);
const moveOrEmbedBlock = useCallback(async () => {
// 说明:拖拽菜单点击后会立即卸载,必须使用全局 Host 承载弹窗。
openPicker({
workspaceId,
defaultMode: "move",
modes: ["move", "embed"],
allowRoot: false,
excludeIds: [currentDocumentId],
onPick: handleMoveEmbedPick,
});
return;
}, [currentDocumentId, handleMoveEmbedPick, openPicker, workspaceId]);
const convertOptions = useMemo<ConvertOption[]>(
() => [
{ label: "文本", type: "paragraph", shortcut: "Ctrl+Alt+0" },
{ label: "待办列表", type: "checkListItem", shortcut: "Ctrl+Shift+5" },
{ label: "高级待办列表", type: "advancedTodo" },
{ label: "主标题", type: "heading", props: { level: 1 }, shortcut: "Ctrl+Shift+1" },
{ label: "大标题", type: "heading", props: { level: 2 }, shortcut: "Ctrl+Shift+2" },
{ label: "中标题", type: "heading", props: { level: 3 }, shortcut: "Ctrl+Shift+3" },
{ label: "小标题", type: "heading", props: { level: 4 }, shortcut: "Ctrl+Shift+4" },
{ label: "页面", action: turnToPage },
{ label: "列表", type: "bulletListItem", shortcut: "Ctrl+Shift+6" },
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
{ label: "引述文字", type: "quote" },
{ label: "代码片段", type: "codeBlock" },
],
[turnToPage],
);
const convertBlock = useCallback(
(option: ConvertOption) => {
if (option.action) {
option.action();
return;
}
if (!option.type) return;
editor.updateBlock(block as any, {
type: option.type as any,
props: (option.props ?? {}) as any,
} as any);
},
[block, editor],
);
const copyBlockLink = useCallback(async () => {
if (typeof window === "undefined") {
return;
}
const url = `${window.location.origin}/documents/${currentDocumentId}#block-${block.id}`;
try {
await navigator.clipboard.writeText(url);
window.alert("块链接已复制");
} catch {
window.prompt("复制失败,请手动复制", url);
}
}, [block.id, currentDocumentId]);
const openOnRight = useCallback(() => {
if (typeof window === "undefined") {
return;
}
const url = `${window.location.origin}/documents/${currentDocumentId}?preview=sidebar&focus=${block.id}`;
window.open(url, "_blank", "noopener,noreferrer");
}, [block.id, currentDocumentId]);
const setAdvancedTodoStatus = useCallback(
(status: "todo" | "doing" | "done" | "cancelled") => {
if (block.type !== "advancedTodo") return;
editor.updateBlock(block, {
props: { status },
});
},
[block, editor],
);
const toggleProgressMode = useCallback(() => {
if (block.type !== "progressMeter") return;
editor.updateBlock(block, {
props: { auto: !block.props.auto },
});
}, [block, editor]);
const setManualProgress = useCallback(() => {
if (block.type !== "progressMeter") return;
const value = Number.parseInt(window.prompt("手动设置进度(0-100", String(block.props.percent ?? 0)) ?? "", 10);
if (Number.isNaN(value)) return;
const clamped = Math.min(100, Math.max(0, value));
editor.updateBlock(block, {
props: { percent: clamped },
});
}, [block, editor]);
return (
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
<Components.Generic.Menu.Item className="bn-menu-item" onClick={openOnRight}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Root sub>
<Components.Generic.Menu.Trigger sub>
<Components.Generic.Menu.Item className="bn-menu-item" subTrigger>
</Components.Generic.Menu.Item>
</Components.Generic.Menu.Trigger>
<Components.Generic.Menu.Dropdown sub className="bn-menu-dropdown">
{convertOptions.map((option) => (
<Components.Generic.Menu.Item
key={option.label}
className="bn-menu-item flex items-center justify-between gap-4"
onClick={() => convertBlock(option)}
>
<span>{option.label}</span>
{option.shortcut && <span className="text-[10px] text-gray-400">{option.shortcut}</span>}
</Components.Generic.Menu.Item>
))}
</Components.Generic.Menu.Dropdown>
</Components.Generic.Menu.Root>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={duplicateBlock}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={copyBlockLink}>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={moveOrEmbedBlock}>
/...
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => window.alert("块历史功能开发中,敬请期待")}
>
...
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => {
if (!workspaceId) {
window.alert("缺少 workspaceId,无法打开评论");
return;
}
openCommentsForBlock({ workspaceId, documentId: currentDocumentId, blockId: block.id });
}}
>
</Components.Generic.Menu.Item>
<Components.Generic.Menu.Item className="bn-menu-item" onClick={handleDeleteBlock}>
</Components.Generic.Menu.Item>
<BlockColorsItem block={block}></BlockColorsItem>
<TableRowHeaderItem block={block as TableMenuBlock}></TableRowHeaderItem>
<TableColumnHeaderItem block={block as TableMenuBlock}></TableColumnHeaderItem>
{block.type === "advancedTodo" && (
<>
<Components.Generic.Menu.Divider className="bn-menu-divider" />
{[
{ label: "设为未开始", status: "todo" as const },
{ label: "设为进行中", status: "doing" as const },
{ label: "设为已完成", status: "done" as const },
{ label: "设为取消", status: "cancelled" as const },
].map((item) => (
<Components.Generic.Menu.Item
key={item.status}
className="bn-menu-item"
onClick={() => setAdvancedTodoStatus(item.status)}
>
{item.label}
</Components.Generic.Menu.Item>
))}
</>
)}
{block.type === "progressMeter" && (
<>
<Components.Generic.Menu.Divider className="bn-menu-divider" />
<Components.Generic.Menu.Item className="bn-menu-item" onClick={toggleProgressMode}>
{block.props.auto ? "切换为手动进度" : "切换为自动进度"}
</Components.Generic.Menu.Item>
{!block.props.auto && (
<Components.Generic.Menu.Item className="bn-menu-item" onClick={setManualProgress}>
</Components.Generic.Menu.Item>
)}
</>
)}
</Components.Generic.Menu.Dropdown>
);
};
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
currentDocumentId: string;
workspaceId: string | null;
unresolvedCommentCountByBlockId?: Record<string, number>;
};
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
const Components = useComponentsContext()!;
const {
editor,
block,
blockDragStart,
blockDragEnd,
freezeMenu,
unfreezeMenu,
currentDocumentId,
workspaceId,
unresolvedCommentCountByBlockId,
} = props;
const [insertHovered, setInsertHovered] = useState<null | "top" | "bottom">(null);
const [menuOpen, setMenuOpen] = useState(false);
const [hovering, setHovering] = useState(false);
const [activeCursorBlockId, setActiveCursorBlockId] = useState<string | null>(null);
const hoverAreaRef = useRef<HTMLDivElement | null>(null);
const menuFrozenRef = useRef(false);
// 说明:Wolai 的附件(file)手柄是在行中线左侧居中显示。
// BlockNote 的 SideMenu 默认参考点更偏“块顶部”,因此这里通过扩大 hover 区域并把手柄定位到附件行中线来对齐。
const isFileAttachmentRow = block.type === "media" && (block as any)?.props?.assetType === "file";
const rowHeightPx = isFileAttachmentRow ? 26 : 24;
const hoverPadPx = 14;
const lineGapPx = 3;
const insertBtnSizePx = 16;
const handleBtnSizePx = 22;
const unresolvedCount = unresolvedCommentCountByBlockId?.[block.id] ?? 0;
useEffect(() => {
const update = () => {
const cursor = editor.getTextCursorPosition();
setActiveCursorBlockId(cursor?.block?.id ?? null);
};
update();
return editor.onSelectionChange(update);
}, [editor]);
const setFrozen = useCallback(
(next: boolean) => {
if (menuFrozenRef.current === next) return;
menuFrozenRef.current = next;
if (next) {
freezeMenu();
} else {
unfreezeMenu();
}
},
[freezeMenu, unfreezeMenu],
);
useEffect(() => {
// 说明:Win+Shift+S 截图会触发窗口失焦/可见性变化;若用右键取消,
// 有些环境下不会触发正常的 mouseleave,导致手柄状态卡死(看起来像“消失”)。
// 这里在失焦/隐藏时强制解除冻结并重置 hover 状态。
const reset = () => {
setHovering(false);
setMenuOpen(false);
setInsertHovered(null);
setFrozen(false);
};
const onBlur = () => reset();
const onVisibility = () => {
if (document.visibilityState === "hidden") {
reset();
}
};
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [setFrozen]);
const insertParagraph = useCallback(
(position: "before" | "after") => {
const inserted = editor.insertBlocks([{ type: "paragraph" } as any], block as any, position as any)?.[0];
if (inserted) {
editor.setTextCursorPosition(inserted as any);
editor.focus();
}
},
[block, editor],
);
const stop = (e: ReactMouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const paragraphPlainText = useMemo(() => {
if (block.type !== "paragraph") return null;
const content = Array.isArray(block.content) ? (block.content as any[]) : [];
const text = content
.map((node) =>
node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""
)
.join("");
return text;
}, [block]);
const isEmptyParagraph = block.type === "paragraph" && (paragraphPlainText ?? "").trim().length === 0;
const showEmptyPlus = isEmptyParagraph && (activeCursorBlockId === block.id || hovering || menuOpen);
const openSlashMenuFromEmptyPlus = (e: ReactMouseEvent) => {
stop(e);
if (!isEmptyParagraph) return;
try {
editor.setTextCursorPosition(block as any, "start");
} catch {
// ignore
}
editor.focus();
// 说明:按 Wolai 手感,点击“+”等同于在空行输入 “/” 打开斜杠菜单。
// deleteTriggerCharacter=true 会把 “/” 写入编辑器,并在选择条目后由插件清理掉。
editor.openSuggestionMenu("/", { deleteTriggerCharacter: true, ignoreQueryLength: true });
};
const forceShowInsertButtons = block.type === "mindmap" || block.type === "onlineTable";
// 说明:思维导图/在线表格等嵌入块内部可能接管鼠标事件,导致 hover 状态不稳定。
// 对这些块直接常驻显示插入控件,避免“看不到横杠/加号”。
const showInsertButtons = !showEmptyPlus && (forceShowInsertButtons || hovering || menuOpen);
const handleCenterY = hoverPadPx + rowHeightPx / 2;
// 说明:插入按钮的位置必须“跟着手柄走”,不能依赖容器上下边界。
// 否则对于思维导图/在线表格等高块,容器可能被撑高,导致按钮跑到块底部。
// 说明:插入按钮不能与六点手柄发生重叠,否则 hover/click 会被手柄拦截(表现为“看得见但点不到/hover 没反应”)。
// 这里用“手柄按钮尺寸 + 插入按钮尺寸 + 间距”计算中心距,确保永不重叠。
const insertDistPx = handleBtnSizePx / 2 + insertBtnSizePx / 2 + lineGapPx;
const insertBeforeTopPx = handleCenterY - insertDistPx - insertBtnSizePx / 2;
const insertAfterTopPx = handleCenterY + insertDistPx - insertBtnSizePx / 2;
return (
<Components.Generic.Menu.Root
onOpenChange={(open: boolean) => {
setMenuOpen(open);
setFrozen(open || hovering);
}}
position={"left"}
>
<div
ref={hoverAreaRef}
data-testid="wolai-handle-area"
className="relative w-7 overflow-visible"
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onPointerLeave={() => {
setHovering(false);
setInsertHovered(null);
setFrozen(menuOpen || false);
}}
>
<button
type="button"
data-testid="wolai-insert-before"
title="在上方插入块"
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: insertBeforeTopPx }}
onPointerEnter={() => setInsertHovered("top")}
onPointerLeave={() => {
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering) setInsertHovered(null);
});
}}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("before");
}}
>
{insertHovered === "top"
? <Plus className="h-3.5 w-3.5" />
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
<div
className="absolute left-1/2 z-[2147483647] -translate-x-1/2 -translate-y-1/2"
style={{ top: hoverPadPx + rowHeightPx / 2 }}
>
{showEmptyPlus ? (
<button
type="button"
data-testid="wolai-empty-plus"
aria-label="打开斜杠命令"
className="flex h-[22px] w-[22px] items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
onMouseDown={stop}
onClick={openSlashMenuFromEmptyPlus}
>
<Plus className="h-4 w-4" />
</button>
) : null}
{!showEmptyPlus ? (
<Components.Generic.Menu.Trigger>
<div
onPointerEnter={() => {
setHovering(true);
setFrozen(menuOpen || true);
}}
onPointerLeave={() => {
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave
// 这里用 :hover 兜底,避免插入按钮一闪而过。
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering && !menuOpen) {
setHovering(false);
setInsertHovered(null);
setFrozen(false);
}
});
}}
>
<Components.SideMenu.Button
label="块操作"
draggable={true}
onDragStart={(e) => blockDragStart(e, block)}
onDragEnd={blockDragEnd}
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
icon={
<span className="relative inline-flex">
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
{unresolvedCount > 0 ? (
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
{unresolvedCount > 9 ? "9+" : unresolvedCount}
</span>
) : null}
</span>
}
/>
</div>
</Components.Generic.Menu.Trigger>
) : null}
</div>
<button
type="button"
data-testid="wolai-insert-after"
title="在下方插入块"
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
style={{ top: insertAfterTopPx }}
onPointerEnter={() => setInsertHovered("bottom")}
onPointerLeave={() => {
queueMicrotask(() => {
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
if (!stillHovering) setInsertHovered(null);
});
}}
onMouseDown={stop}
onClick={(e) => {
stop(e);
insertParagraph("after");
}}
>
{insertHovered === "bottom"
? <Plus className="h-3.5 w-3.5" />
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
</button>
</div>
<CustomDragHandleMenu block={block} currentDocumentId={currentDocumentId} workspaceId={workspaceId} />
</Components.Generic.Menu.Root>
);
};
export const CustomSideMenu = (props: CustomSideMenuProps) => (
<SideMenu {...props}>
<WolaiDragHandleWithInsert {...props} />
</SideMenu>
);
@@ -1,419 +0,0 @@
"use client";
import { useCallback, useMemo } from "react";
import type { JSX } from "react";
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from "@blocknote/react";
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
import { useRouter } from "next/navigation";
import {
FileImage,
FilePlus2,
FileVideo,
ListTree,
Music,
Paperclip,
PilcrowSquare,
Play,
Spline,
Sparkles,
SquareCheckBig,
Table,
} from "lucide-react";
import type { CustomBlockSchema } from "../schema";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind, MediaSelection } from "@/types/media";
import { createOnlineTable } from "@/lib/online-table";
type Props = {
editor: BlockNoteEditor<CustomBlockSchema>;
currentDocumentId: string;
};
const matchKeywords = (query: string, aliases: string[]) => {
const lower = query.trim().toLowerCase();
if (!lower) return true;
return aliases.some((alias) => alias.toLowerCase().includes(lower));
};
function insertOrUpdateBlockForSlashMenuCompat(
editor: BlockNoteEditor<CustomBlockSchema>,
partialBlock: unknown,
) {
const cursor = editor.getTextCursorPosition();
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
if (!referenceBlock) {
return;
}
const nextType = (partialBlock as { type?: unknown })?.type;
const shouldAppendParagraph = nextType === "mindmap";
const content = Array.isArray(referenceBlock.content) ? referenceBlock.content : [];
const text = content
.map((node) => (node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""))
.join("")
.trim();
const looksLikeSlashCommand = text === "" || text.startsWith("/");
if (referenceBlock.type === "paragraph" && looksLikeSlashCommand) {
// 兼容默认 slash menu 行为:将当前段落“就地替换”为目标块类型,避免插入后又被 slash 菜单逻辑清理掉
editor.updateBlock(referenceBlock, partialBlock as never);
if (shouldAppendParagraph) {
const inserted = editor.insertBlocks(
[{ type: "paragraph" } as never],
referenceBlock,
"after",
);
const paragraph = inserted[0];
if (paragraph) {
try {
editor.setTextCursorPosition(paragraph, "start");
} catch {
// ignore
}
}
}
return;
}
if (shouldAppendParagraph) {
const inserted = editor.insertBlocks(
[partialBlock as never, { type: "paragraph" } as never],
referenceBlock,
"after",
);
const paragraph = inserted[1] ?? inserted[inserted.length - 1];
if (paragraph) {
try {
editor.setTextCursorPosition(paragraph, "start");
} catch {
// ignore
}
}
return;
}
editor.insertBlocks([partialBlock as never], referenceBlock, "after");
}
const GROUP_TRANSLATIONS: Record<string, string> = {
"Headings": "标题",
"Subheadings": "副标题",
"Basic blocks": "基础块",
"Advanced": "高级",
"Media": "媒体",
"Others": "其他",
};
const DEFAULT_ITEM_TRANSLATIONS: Record<
string,
{ title?: string; subtext?: string; group?: string; aliases?: string[] }
> = {
"Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] },
"Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] },
"Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] },
"Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] },
"Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] },
"Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] },
"Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] },
"Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] },
"Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] },
"Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] },
"Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] },
"Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] },
"Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] },
"Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] },
"Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] },
"Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] },
"Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] },
"Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] },
"Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] },
"Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] },
"File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] },
"Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] },
"Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] },
"Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] },
};
const MEDIA_ICONS: Record<MediaKind, JSX.Element> = {
image: <FileImage className="h-4 w-4 text-[#2563eb]" />,
video: <FileVideo className="h-4 w-4 text-[#f97316]" />,
audio: <Music className="h-4 w-4 text-[#10b981]" />,
file: <Paperclip className="h-4 w-4 text-[#0f172a]" />,
};
const HEADING_PRESETS = [
{
level: 1,
title: "主标题",
subtext: "适合页面名称/顶层章节",
aliases: ["biaoti1", "h1", "level1"],
},
{
level: 2,
title: "大标题",
subtext: "用于章节逻辑层",
aliases: ["biaoti2", "h2", "level2"],
},
{
level: 3,
title: "中标题",
subtext: "用于小节和段落",
aliases: ["biaoti3", "h3", "level3"],
},
{
level: 4,
title: "小标题",
subtext: "更细的结构说明",
aliases: ["biaoti4", "h4", "level4"],
},
{
level: 5,
title: "极小标题",
subtext: "适合脚注/补充说明",
aliases: ["biaoti5", "h5", "level5"],
},
];
const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => {
const maybeKey = (item as { key?: string }).key ?? "";
if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) {
return true;
}
const title = item.title ?? "";
return title.includes("标题");
};
export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]);
const router = useRouter();
const { openPicker } = useImagePicker();
const getItems = useCallback(
async (query: string) => {
// 注意:不要把 cursor/referenceBlock 在 getItems 阶段“捕获”后长期复用。
// Slash 菜单打开后,BlockNote 会持续更新光标与块对象;若使用陈旧引用,
// 可能出现插入块“瞬间出现又消失/不落库”的现象(尤其是插入自定义块时)。
const createTableItem: DefaultReactSuggestionItem = {
title: "在线表格",
group: "高级",
aliases: ["online table", "bg", "表格"],
icon: <Table className="h-4 w-4 text-[#0f172a]" />,
onItemClick: async () => {
const documentId = currentDocumentId;
try {
const newTable = await createOnlineTable(documentId);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "onlineTable",
props: { tableId: newTable.id, title: newTable.title },
content: [],
});
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: newTable.id } }));
} catch (error) {
console.error("Failed to create table:", error);
// TODO: 插入错误提示块
}
},
};
const createMindmapItem: DefaultReactSuggestionItem = {
title: "思维导图",
group: "高级",
subtext: "插入可编辑导图(带顶栏工具)",
aliases: ["mindmap", "swdt", "导图"],
icon: <Spline className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "mindmap",
props: { docId: currentDocumentId },
content: [],
});
},
};
const createPageItem: DefaultReactSuggestionItem = {
title: "嵌入页面",
group: "嵌入",
aliases: ["page", "ym", "子页面", "嵌入页面块"],
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
onItemClick: async () => {
const cursor = editor.getTextCursorPosition();
const cursorBlock = cursor?.block as any;
const firstText =
Array.isArray(cursorBlock?.content) && cursorBlock.content.length > 0
? (cursorBlock.content[0] as any)?.text
: undefined;
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parentId: currentDocumentId,
title: typeof firstText === "string" && firstText.trim() ? firstText : "未命名页面",
blocks: cursorBlock ? [cursorBlock] : [],
}),
});
if (!response.ok) return;
const { pageId, title } = await response.json();
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "pageReference",
props: { pageId, title, asChildPage: true },
content: [],
});
router.refresh();
},
};
const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({
title: preset.title,
group: "标题",
subtext: preset.subtext,
aliases: preset.aliases,
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "heading",
props: { level: preset.level },
content: [],
});
},
}));
const foldHeading: DefaultReactSuggestionItem = {
title: "折叠标题",
group: "标题",
aliases: ["toggle", "zd", "fold"],
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "heading",
props: { level: 2, isToggleable: true },
content: [],
});
},
};
const advancedTodo: DefaultReactSuggestionItem = {
title: "高级待办",
group: "待办",
subtext: "四态状态 · Alt 直接取消",
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "advancedTodo",
props: { status: "todo" },
content: [],
});
},
};
const progressMeter: DefaultReactSuggestionItem = {
title: "进度条",
group: "进度",
subtext: "自动读取下方待办完成度",
aliases: ["jdt", "progress", "jindu"],
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "progressMeter",
props: { percent: 0, auto: true },
content: [],
});
},
};
const foldAdvancedTodo: DefaultReactSuggestionItem = {
title: "折叠高级待办",
group: "待办",
aliases: ["zdgjdb", "foldtodo"],
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
onItemClick: () => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "advancedTodo",
props: { status: "todo" },
content: [],
});
},
};
const customItems = [
...headingItems,
foldHeading,
createPageItem,
createTableItem,
createMindmapItem,
advancedTodo,
foldAdvancedTodo,
progressMeter,
].filter((item) => matchKeywords(query, item.aliases ?? []));
const insertMediaSelection = (selection: MediaSelection) => {
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "media",
props: {
fileUrl: selection.fileUrl,
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
assetId: selection.assetId,
assetType: selection.assetType ?? "image",
fileName: selection.fileName ?? "",
fileSize: selection.fileSize ?? null,
mimeType: selection.mimeType ?? "",
ocrStatus: "idle",
},
content: [],
});
};
const handleMediaPick = (mediaType: MediaKind) => {
openPicker({
mediaType,
multiple: true,
onSelect: (selection) => {
insertMediaSelection({
...selection,
assetType: selection.assetType ?? mediaType,
});
},
});
};
const localizedDefaults = defaultItems.map((item) => {
const translation = DEFAULT_ITEM_TRANSLATIONS[item.title];
const next: DefaultReactSuggestionItem = { ...item };
if (translation?.title) next.title = translation.title;
if (translation?.subtext) next.subtext = translation.subtext;
if (translation?.aliases) next.aliases = translation.aliases;
if (translation?.group) {
next.group = translation.group;
} else if (item.group && GROUP_TRANSLATIONS[item.group]) {
next.group = GROUP_TRANSLATIONS[item.group];
}
if (["Image", "Video", "Audio", "File"].includes(item.title)) {
const mediaType = item.title.toLowerCase() as MediaKind;
next.icon = MEDIA_ICONS[mediaType];
next.group = translation?.group ?? "媒体";
next.subtext = translation?.subtext ?? next.subtext;
next.aliases = translation?.aliases ?? next.aliases;
next.onItemClick = () => handleMediaPick(mediaType);
}
return next;
});
const sanitizedDefaults = localizedDefaults.filter(
(item) => !isHeadingDefaultItem(item) && item.title !== "表格",
);
const merged = [...customItems, ...sanitizedDefaults];
return filterSuggestionItems(merged, query);
},
[currentDocumentId, defaultItems, editor, openPicker, router],
);
return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
}
@@ -1,156 +0,0 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { PreferredSidebarSnapshotProvider } from "@/components/sidebar/preferred-sidebar-snapshot-context";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { buildSidebarInitialData } from "@/lib/sidebar-data";
import type { DocumentRecord } from "@/lib/documents";
import { usePageHeadTitle } from "./use-page-head-title";
function buildDocument(overrides: Partial<DocumentRecord> = {}): DocumentRecord {
return {
access_scope: "private",
id: "doc-1",
workspace_id: "ws-1",
title: "标题 A",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
...overrides,
};
}
function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
return buildSidebarInitialData({
activeWorkspaceId: "ws-1",
workspaces: [],
documents,
trashedDocuments: [],
mindmaps: [],
mediaAssets: [],
trashedMediaAssets: [],
tables: [],
});
}
function HookProbe(props: { documentId: string; fallbackTitle: string }) {
const state = usePageHeadTitle(props);
return (
<>
<div
data-testid="page-head-title"
data-display-title={state.displayTitle}
data-committed-title={state.committedTitle}
data-has-draft={state.hasDraft ? "1" : "0"}
/>
<button type="button" data-testid="set-draft" onClick={() => state.setDraftTitle(" 新标题 ")}>
稿
</button>
<button type="button" data-testid="commit-persisted" onClick={() => state.commitPersistedTitle("新标题")}>
</button>
</>
);
}
describe("usePageHeadTitle", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("应优先使用 live sidebar snapshot 中的标题作为 committed title", async () => {
const snapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "树标题",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={snapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
const probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-committed-title")).toBe("树标题");
expect(probe?.getAttribute("data-display-title")).toBe("树标题");
expect(probe?.getAttribute("data-has-draft")).toBe("0");
});
it("应只把本地输入保留为短暂 draft,并在 live 标题追平后自动清空", async () => {
const initialSnapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "旧标题",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={initialSnapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
const setDraftButton = container.querySelector<HTMLButtonElement>("[data-testid='set-draft']");
act(() => {
setDraftButton?.click();
});
let probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe(" 新标题 ");
expect(probe?.getAttribute("data-committed-title")).toBe("旧标题");
expect(probe?.getAttribute("data-has-draft")).toBe("1");
const commitPersistedButton = container.querySelector<HTMLButtonElement>("[data-testid='commit-persisted']");
act(() => {
commitPersistedButton?.click();
});
probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
expect(probe?.getAttribute("data-has-draft")).toBe("1");
const syncedSnapshot = buildSidebarData([
buildDocument({
id: "doc-1",
title: "新标题",
updated_at: "2026-04-21T00:00:02.000Z",
}),
]);
await act(async () => {
root.render(
<PreferredSidebarSnapshotProvider data={syncedSnapshot}>
<HookProbe documentId="doc-1" fallbackTitle="SSR 标题" />
</PreferredSidebarSnapshotProvider>,
);
});
probe = container.querySelector("[data-testid='page-head-title']");
expect(probe?.getAttribute("data-display-title")).toBe("新标题");
expect(probe?.getAttribute("data-committed-title")).toBe("新标题");
expect(probe?.getAttribute("data-has-draft")).toBe("0");
});
});
@@ -1,44 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { usePreferredSidebarDocumentTitle } from "@/components/sidebar/preferred-sidebar-snapshot-context";
function normalizePageHeadTitle(title: string | null | undefined): string {
const normalized = String(title ?? "").trim();
return normalized || "无标题";
}
export function usePageHeadTitle(input: { documentId: string; fallbackTitle: string }) {
const liveSidebarTitle = usePreferredSidebarDocumentTitle(input.documentId);
const committedTitle = useMemo(
() => normalizePageHeadTitle(liveSidebarTitle ?? input.fallbackTitle),
[input.fallbackTitle, liveSidebarTitle],
);
const [draftState, setDraftState] = useState<{
documentId: string;
title: string | null;
}>({
documentId: input.documentId,
title: null,
});
const draftTitle = draftState.documentId === input.documentId ? draftState.title : null;
const hasDraft = draftTitle != null && normalizePageHeadTitle(draftTitle) !== committedTitle;
return {
displayTitle: hasDraft ? draftTitle ?? committedTitle : committedTitle,
committedTitle,
hasDraft,
setDraftTitle: (title: string) => {
setDraftState({
documentId: input.documentId,
title,
});
},
commitPersistedTitle: (title: string) => {
setDraftState({
documentId: input.documentId,
title: normalizePageHeadTitle(title),
});
},
};
}