fix local office resource editing

- add local-folder OnlyOffice sign/callback writeback and edit-tab handling

- align main resource tabs, attachment edit menu, slash isolation, and filetree context behavior

- record Sidex/Hermes gap reviews and Reasonix task checklists
This commit is contained in:
lix-2026
2026-05-21 05:40:06 +08:00
parent a29d9868f6
commit eba1010191
50 changed files with 4309 additions and 283 deletions
@@ -0,0 +1,60 @@
# Reasonix Browser TesterOffice 只读打开、编辑入口与插件噪音验证
你是浏览器测试员,不是代码修改员。
禁止修改源码、禁止 git commit、禁止回滚文件。使用真实浏览器或 Playwright 验证。所有截图和 `result.json` 写入指定输出目录。失败时保留 console/network 摘要。最终只汇报测试证据,不提出未经验证的代码结论。
## Project Root
`/mnt/Data1T/mnote`
## 输出目录
`/mnt/Data1T/mnote/tmp/reasonix-office-view-edit-plugin-2026-05-21`
## 前置
- 被测入口:`http://127.0.0.1:3000`
- 使用全新的 isolated browser context / userDataDir。
- 测试开始前清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。
- 默认登录:访问 `/auth`,点击“测试账号快速登录”;如失败,使用邮箱 `mnote.e2e@example.com`、密码 `MnoteE2E123!`
- 测试文件:`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`
## 测试目标
1. 上传该 docx 后,默认打开到主编辑器右侧 resource tab,而不是浏览器 popup。
2. 默认 OnlyOffice 是只读/view 模式。
3. 三点菜单或右键菜单存在“使用编辑模式打开”入口。
4. 点击编辑入口后,记录 guard 或 `mode=edit` 的可观察证据。
5. 记录 `/api/media/sign` 对 local asset 是否还出现 404。
6. 记录 annotation/plugin 404/pageerror 是否存在;如果存在,判断是否影响主文档可见渲染。
## PASS 标准
- 截图必须显示 OnlyOffice 文档内容区或明确的 OnlyOffice 编辑器界面。
- `errorCode=-18`、WebSocket handshake failed、polling failed、连接失败、主 iframe 空白必须标记 FAIL/BLOCKED。
- 插件 404 只有在主文档已渲染且 WebSocket 正常时,才可标记为 non-critical。
## 输出格式
写入 `result.json`,至少包含:
```json
{
"status": "PASS|FAIL|BLOCKED",
"steps": [],
"assertions": [],
"screenshots": [],
"consoleErrors": [],
"networkFailures": [],
"onlyoffice": {
"iframeSrc": "",
"modeEvidence": "",
"documentKeyEvidence": "",
"mediaSignLocal404": false,
"plugin404NonCritical": null
}
}
```
同时输出简短 `final.md`,列出复现步骤、观察结果和截图路径。
@@ -0,0 +1,66 @@
# Reasonix Browser Test: Tab / Office / Upload Bugs
项目根目录:`/mnt/Data1T/mnote`
请先阅读并遵守:`/home/lix/.codex/skills/reasonix-browser-tester/SKILL.md`
## 角色
你是独立浏览器测试员,不是代码修改员。
禁止修改源码、禁止 git commit、禁止回滚文件。只允许创建测试输出目录、临时测试数据和截图。
## 测试目标
验证以下 MNote UI 行为是否真实存在,并输出可复核证据:
1. 在主 resource tab 打开时,点击左侧文件树中的主 Markdown 页面,是否能切回 page tab。
2. 点击左侧“新建页面”后,是否能切到新页面的主 Markdown page tab;文件树焦点/选中是否落在新建页面。
3. 上传并打开真实文件:
`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`
验证是否进入主 resource tab、是否出现 `/onlyoffice` iframe、是否出现错误提示或卡死。
4. 观察上传中文文件名时浏览器是否卡死;记录 console、network、页面是否还能响应。
## 建议测试方式
- 默认入口:`http://127.0.0.1:3000`
- 使用 Playwright/Chrome。
- 优先使用 local-first 临时 workspace
- 创建临时 root。
- 写入 `.mnote/workspace.json`
- 创建 `Alpha.md``Beta.md` 等测试页面。
- 可通过 `/auth` 的“测试账号快速登录”登录。
## 断言证据
请记录:
- 当前 URL。
- `.mnote-main-tab.is-active` 的文本、`data-mnote-main-tab``data-mnote-tab-kind`
- `[data-mnote-main-tab="page"]` 是否 active。
- 左侧 filetree 行的 `data-selected` / `data-focused` / `data-active`
- Office iframe `src`
- 上传 API 响应状态和 body。
- console error/warning。
- network failed 请求。
## 输出目录
写入:
`/mnt/Data1T/mnote/tmp/reasonix-browser-test-2026-05-21-tab-office-upload/`
必须输出:
- `result.json`
- `final.md`
- 至少 3 张截图:切换前 resource tab、点击 md 后状态、docx 打开状态。失败时额外截图。
## 最终回复格式
只汇报:
- 输出目录。
- 每个测试目标 pass/fail。
- 关键失败证据。
- 未能验证的原因。
@@ -0,0 +1,57 @@
你是浏览器测试员,不是代码修改员。
禁止修改源码、禁止 git commit、禁止回滚文件。
使用真实浏览器或 Playwright 验证。
所有截图和 result.json 写入指定输出目录。
失败时保留 console/network 摘要。
最终只汇报测试证据,不提出未经验证的代码结论。
# Reasonix 浏览器测试任务:Office 中文 docx 上传并打开
## 输出目录
`/mnt/Data1T/mnote/tmp/reasonix-office-upload-open-clean-2026-05-21/`
## 测试入口
- URL: `http://127.0.0.1:3000`
- 登录:访问 `/auth`,点击“测试账号快速登录”
- 必须使用全新 isolated browser context / userDataDir。
- 测试开始前清空 cookies、localStorage、sessionStorage、IndexedDB、Cache Storage。
## 测试文件
上传真实文件:
`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`
## 测试步骤
1. 创建临时 local-first workspace,至少包含 `Alpha.md``.mnote/workspace.json`
2. 打开 `Alpha.md` 页面。
3. 上传上述 docx 到当前页面,记录上传 API 状态、耗时、返回 asset。
4. 点击或派发真实 UI 等价操作打开该 docx 附件到主编辑器资源 tab。
5. 等待 OnlyOffice iframe 内部进入稳定状态,最长 90 秒。
6. 截图最终打开效果,截图必须显示以下之一:
- Office 文档内容区 / 编辑器 UI 已可见。
- 明确的 OnlyOffice 错误面板或连接失败信息。
7. 记录 console errors、failed requests、OnlyOffice WebSocket / polling URL。
8. 检查 WebSocket / polling URL 的 doc key 是否为安全 key,不包含 `/``:`、中文字符。
## 判定规则
- `PASS` 不能只因为 upload 200、resource tab active 或 iframe 存在。
- 如果出现 `errorCode=-18``连接失败`、WebSocket handshake failed、polling failed,标记为 `FAIL``BLOCKED`,并截图。
- 如果页面上传后卡死、刷新后无法恢复、需要重新登录,标记为 `FAIL`
- 如果 DocumentServer 未运行或代理不可达,标记为 `BLOCKED`,并记录服务状态和网络错误。
## 输出要求
- `result.json`
- `overallStatus`: `pass` / `fail` / `blocked`
- `freshContext`: 是否使用全新上下文并清空缓存
- `upload`: status、elapsedMs、asset
- `open`: tabKind、iframeSrc、officeReady、visibleTextOrError
- `onlyofficeNetwork`: websocketUrls、pollingUrls、failedRequests
- `docKeySafety`: safe、observedKeys、unsafeReasons
- `screenshots`: 最终截图路径
- `final.md`:简要步骤、观察、结论、截图路径。
@@ -0,0 +1,60 @@
# Reasonix Worker A: Local Folder Trash + Empty Workspace
Project root: `/mnt/Data1T/mnote`
## 背景
用户报告:
- 文件树删除页面后全部跑到资源垃圾箱,页面垃圾箱没有使用。
- 会突然出现“初始化的新页面”,删除后还会出现;空白页面应保持空白,不显示任何占位。
事实源:
- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md`
- Checklist: `bugs/04-tree-domain/process/4-45-local-folder-page-trash-and-empty-workspace-v1.md`
## Ownership
你负责:
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 必要时 `rust/crates/mnote-web/src/routes/gateway.rs` 中 trash 分类/渲染的小范围补丁
- 相关 Rust tests
你不是独自在代码库中工作。不要回滚、覆盖、格式化或清理其他人/其他 worker 的改动。不要提交 git。
## 任务
1. 移除默认工作区自动创建 `初始化的新页面/初始化的新页面.md` 的行为。
2. 更新相关测试,不再断言默认页面存在。
3. 修复本地文件树删除页面 bundle 目录时被归类为 `local_directory` 资源的问题:
- 若目录内存在同名 `.md`,应按 Markdown 页面生命周期进入 trash。
- trash index 应能让页面垃圾箱识别为页面/document,而不是资源 local_directory。
4. 空 root / 空 page tree 不应投影出“初始化的新页面”。
## 关键线索
- `ensure_default_workspace_home_page` 当前会创建初始化页面。
- `trash_local_entry` 现在先解析 `local-dir:*`,导致页面 bundle 目录先走 `trash_local_directory`
- `trash_local_markdown_page` 已能删除 bundle,但需要让入口优先识别 bundle 页面。
## 验证
至少运行:
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web create_default_local_workspace_for_actor_at_base_creates_manifest_and_root_only -- --test-threads=1
cargo test -p mnote-web local_tree_command_manages_loose_markdown_like_regular_file -- --test-threads=1
cargo test -p mnote-web local_tree_command_delete_folder_moves_directory_to_trash -- --test-threads=1
```
如新增测试,请一并运行。
## 最终回复格式
- 修改文件列表
- 完成的 checklist 项
- 运行过的命令和结果
- 未完成项/风险
@@ -0,0 +1,62 @@
# Reasonix Worker AOnlyOffice local-folder 不再误请求 media/sign
你是编码 worker,不是审查员。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。
## Project Root
`/mnt/Data1T/mnote`
## 背景
详见:
- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md`
- `bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md`
当前 `/onlyoffice` 页面只要有 `assetId` 就请求 `/api/media/sign`。local-folder asset 形如 `local:asset:<path>`,本来应直接使用传入的 local open `fileUrl`,因此这个请求会产生误导性的 404。
## Ownership
你负责:
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- 与 above 契约直接相关的 `mnote-web` 单测
- 更新 `bugs/05-editor-mainline/process/5-34-local-folder-media-sign-office-url-contract-v1.md` 的本轮执行记录
不要修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- ACP / Hermes 相关文件
- git 状态无关文件
## 任务
1.`/onlyoffice` 页面识别 local-folder asset / local fileUrl,并在这种情况下跳过 `/api/media/sign`
2. 保持非 local asset 仍可请求 `/api/media/sign`
3. 保持 doc key 安全化,不引入 `/``:`、中文等危险字符。
4. 补或调整单测,确保页面脚本包含该判断。
## 设计约束
- local 判断应保守:优先基于 `assetId` 前缀 `local:` / `local-file:`,以及 `fileUrl` path 为 `/api/local-folder/files/open`
- 不要把 `/api/media/sign` 改成本地文件签名端点。
- 不要改变 callback 或 edit/save 逻辑。
## 验收命令
请至少运行:
```bash
cargo test -p mnote-web onlyoffice -- --test-threads=1
```
如果失败,记录失败原因和相关输出。
## 最终输出
在 final 中列出:
- 修改文件
- 关键实现点
- 运行过的命令和结果
- 未完成项或风险
@@ -0,0 +1,48 @@
# Reasonix Worker B: Main Page Tab Title Sync
Project root: `/mnt/Data1T/mnote`
## 背景
用户报告:主 tab 的标题与实际打开页面标题不一致,不会随实际打开的 tab / 页面变化。
事实源:
- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md`
- Checklist: `bugs/05-editor-mainline/process/5-28-main-page-tab-title-active-sync-v1.md`
## Ownership
你负责小范围修改:
- `rust/crates/mnote-web/src/routes/web_shell.rs` 中 page tab / `updatePaneChrome` / 标题保存同步相关逻辑
- `rust/crates/mnote-web/src/ssr/pages/document.rs` 如需要补 data 属性
- 相关 Rust tests
不要修改 OnlyOffice、文件树菜单、slash 定位、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。
## 任务
1. `replacePaneDocument` / `updatePaneChrome(primary)` 更新 primary document 后,同步:
- `[data-mnote-main-tab="page"] .mnote-main-tab-title`
- page tab 上的 document/workspace data 属性(如需要)
2. 标题保存控制器 `updateVisibleTitle` 保存标题时,同步当前 page tab 标题。
3. 资源 tab 与 page tab 切换时,page tab 标题始终代表当前 primary document。
4. 补一个 Rust 字符串测试或现有测试断言。
## 验证
至少运行:
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web page_layout_renders_main_editor_tab_strip -- --test-threads=1
cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 完成的 checklist 项
- 运行过的命令和结果
- 未完成项/风险
@@ -0,0 +1,68 @@
# Reasonix Worker BOffice 编辑模式菜单与 guard
你是编码 worker,不是审查员。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。
## Project Root
`/mnt/Data1T/mnote`
## 背景
详见:
- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md`
- `bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md`
产品口径:Office 默认只读打开;三点菜单增加“使用编辑模式打开”。当前 local-folder edit/save callback 没有完整闭环,因此编辑入口不能表现成默认可保存功能。
## Ownership
你负责:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 与 layout JS 字符串相关的 `mnote-web` 单测
- 更新 `bugs/05-editor-mainline/process/5-35-office-edit-mode-menu-and-guard-v1.md` 的本轮执行记录
不要修改:
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/mnote-web/src/routes/media.rs`
- ACP / Hermes 相关文件
## 任务
1. 确认 Office 默认打开路径使用 `mode=view`,尤其是 local uploaded asset 和编辑器附件点击。
2. 在正文附件三点菜单中增加“使用编辑模式打开”。
3. 在文件树 Office asset 右键菜单中增加“使用编辑模式打开”。
4. 点击编辑入口时打开 `mode=edit` 的 OnlyOffice URL。
5. 如果保存闭环未完成,给编辑入口增加 guard:
- 可以用 `window.confirm` 或明确状态标记提示“编辑保存仍在实验中,建议先备份文件”;
- 必须便于浏览器测试识别,例如设置 `data-mnote-last-office-edit-mode-requested="true"``data-mnote-last-office-edit-mode-guard="shown"`
## 设计约束
- 不默认 edit。
- 不承诺保存已闭环。
- 不影响 md/text/code 附件继续走 tiptap 渲染 / 编辑路径。
- 尽量复用现有 `buildLocalOnlyOfficeOpenUrl``buildOnlyOfficeOpenUrl``openConvexAssetFromFileTree` 等函数。
## 验收命令
请至少运行:
```bash
cargo test -p mnote-web sidebar_tree_js -- --test-threads=1
cargo test -p mnote-web onlyoffice -- --test-threads=1
```
如果失败,记录失败原因和相关输出。
## 最终输出
在 final 中列出:
- 修改文件
- 菜单项位置与 action 名称
- 默认 view 与显式 edit 的实现点
- 运行过的命令和结果
- 未完成项或风险
@@ -0,0 +1,53 @@
# Reasonix Worker C: OnlyOffice Docx Upload Open Freeze
Project root: `/mnt/Data1T/mnote`
## 背景
用户报告:上传 `DHA美黑化妆品监管综述_2026-05-16.docx` 后卡死,docx 打不开,并出现 OnlyOffice 连接失败信息。
事实源:
- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md`
- Checklist: `bugs/05-editor-mainline/process/5-29-onlyoffice-docx-upload-open-freeze-v1.md`
## Ownership
你负责:
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/mnote-web/src/routes/local_folder_source.rs` 中 local file open / upload 的小范围修复
- `rust/crates/mnote-web/src/routes/web_shell.rs` 中 office resource tab 打开/错误 UI 的小范围修复
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中附件 open URL 的小范围修复
- OnlyOffice/local upload 相关 tests
不要修改文件树 trash、默认 workspace、右键菜单结构、slash 定位、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。
## 任务
1. 定位 docx 卡死发生层级:上传、附件插入、主 tab iframe、OnlyOffice DocumentServer 回源或错误处理。
2. 确保本地 docx 打开时:
- `fileUrl` 指向 `/api/local-folder/files/open?rootUri=...&path=...`
- `fileType=docx`
- `assetId=local-file:<relativePath>`
- 主 tab 打开优先,不默认弹出浏览器窗口。
3. OnlyOffice iframe 加载失败时显示非阻塞错误,不让主页面卡死。
4. 补 targeted test。
## 验证
至少运行:
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web local_file_open_sets_pdf_and_office_content_type -- --test-threads=1
cargo test -p mnote-web onlyoffice_page_exposes_documentserver_fetch_base_for_local_files -- --test-threads=1
cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 完成的 checklist 项
- 运行过的命令和结果
- 未完成项/风险
@@ -0,0 +1,65 @@
# Reasonix Worker COnlyOffice local-folder callback 保存契约
你是编码 worker,但本任务优先做设计与最小安全实现评估。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。
## Project Root
`/mnt/Data1T/mnote`
## 背景
详见:
- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md`
- `bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md`
ONLYOFFICE 官方口径:callback `status === 2``status === 6` 时,后端需要下载 `body.url` 并写回文件,成功返回 `{ "error": 0 }`。当前 `mnote-web` callback 代理 legacy Nextlocal-folder 写回没有闭环。
## Ownership
你负责:
- 优先更新 `bugs/05-editor-mainline/process/5-36-onlyoffice-local-edit-save-callback-contract-v1.md`
- 如判断安全且改动小,可修改:
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/adapter-onlyoffice/src/lib.rs`
- 相关单测
不要修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- ACP / Hermes 相关文件
## 任务
1. 梳理当前 callback 能否定位 local-folder 原文件。
2. 明确实现 local writeback 还缺哪些输入:rootUri、relative path、workspace/source kind、session。
3. 如果现有 URL 参数已经足够安全定位文件,则实现最小 status 2/6 下载写回。
4. 如果现有输入不足,先不要硬写实现;更新 checklist,明确缺口,并建议 Worker B 的编辑入口保持 guard。
## 必须满足的安全条件
- 写回路径必须落在当前 local root allowed roots 内。
- status 非 2/6 不写文件并返回 `{ "error": 0 }`
- 下载 URL 必须经过现有 OnlyOffice internal URL rewrite 逻辑或等价安全处理。
- 不允许从 assetId 字符串直接拼任意绝对路径写入。
## 验收命令
如改代码,请至少运行:
```bash
cargo test -p mnote-web onlyoffice_callback -- --test-threads=1
```
如果只更新设计,也请说明为什么本轮不应实现。
## 最终输出
在 final 中列出:
- 修改文件
- 是否实现代码,若未实现说明阻塞输入
- 安全边界判断
- 运行过的命令和结果
- 未完成项或风险
@@ -0,0 +1,53 @@
# Reasonix Worker D: Filetree Context Menu Sidex Alignment
Project root: `/mnt/Data1T/mnote`
## 背景
用户报告:文件树右键菜单大多没有实际作用;主页面使用右键菜单“侧边栏显示”提示暂不支持在侧栏打开资源。用户要求核对 Sidex 右键菜单,调整当前可用菜单。
事实源:
- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md`
- Checklist: `bugs/05-editor-mainline/process/5-30-filetree-context-menu-sidex-alignment-v1.md`
- Sidex 参考:`reference-code/sidex-main/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts`
## Ownership
你负责:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中文件树右键菜单 items 和 handler 的小范围修改
- 必要时 `rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs`
- 必要时 `rust/crates/mnote-web/src/routes/local_folder_source.rs``create_folder` 已有入口接线,不要大改 tree command
- 相关 tests
不要修改 Office、slash 定位、默认 workspace、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。
## 任务
1. 对照 Sidex/VSCode ExplorerContext 的原则:只显示/启用可用动作。
2. local_folder 下:
- `New File` 继续创建页面。
- `New Folder` 接入 `create_folder` 或隐藏/禁用但不能误导。
- `Copy Path` 复制真实 relative path / asset path,不是标题。
- `Reveal` 至少滚动聚焦并记录状态。
- `Duplicate` 若没有真实 copy,隐藏或禁用。
- `Open Right` 只对页面显示;资源未支持侧栏时隐藏,避免 unsupported placeholder。
3. 更新字符串测试。
## 验证
至少运行:
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --test-threads=1
cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 完成的 checklist 项
- 运行过的命令和结果
- 未完成项/风险
@@ -0,0 +1,58 @@
# Reasonix Worker DOnlyOffice annotation 插件 404/pageerror 噪音
你是编码 worker。你不独占代码库,可能有其他 worker 同时修改不同文件;不要回滚、覆盖或清理他人的改动。
## Project Root
`/mnt/Data1T/mnote`
## 背景
详见:
- `bugs/05-editor-mainline/process/2026-05-21-office-edit-sign-plugin-gap-review-v1.md`
- `bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md`
OnlyOffice 自定义 annotation/custom assistant 插件可能产生 404 或 pageerror。需要确认来源,并让测试能区分“插件噪音”和“主文档打开失败”。
## Ownership
你负责:
- `bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md`
- 如确认是 MNote 显式注入插件,可修改:
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `src/components/onlyoffice/` 下直接的自定义插件配置文件
- 相关测试
不要修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- ACP / Hermes 相关文件
- OnlyOffice 主静态资源目录的大规模文件
## 任务
1. 搜索 annotation / custom assistant / pluginsData / autostart 来源。
2. 判断 404 是否由 MNote 显式注入插件引起。
3. 如果 MNote 显式注入且当前不依赖该插件,禁用无效注入或加开关。
4. 如果是 OnlyOffice 内部可选资源,更新 checklist 说明为 non-critical,但不能吞掉 `errorCode=-18`、WebSocket failed、主 iframe 空白等真正失败。
5. 如有必要,补测试或 smoke 文档口径。
## 验收命令
请至少运行与你修改相关的测试;优先:
```bash
cargo test -p mnote-web onlyoffice -- --test-threads=1
```
## 最终输出
在 final 中列出:
- 修改文件
- 404/pageerror 来源
- 处理策略
- 运行过的命令和结果
- 未完成项或风险
@@ -0,0 +1,48 @@
# Reasonix Worker E: Slash Menu Side Pane Isolation
Project root: `/mnt/Data1T/mnote`
## 背景
用户报告:虽然侧栏打开不支持,但在主栏打开 slash 菜单时,侧栏也出现一个 slash 菜单。
事实源:
- Review: `bugs/05-editor-mainline/process/2026-05-21-workbench-filetree-office-slash-bugs-review-v1.md`
- Checklist: `bugs/05-editor-mainline/process/5-31-side-pane-slash-overlay-isolation-v1.md`
## Ownership
你负责:
- `rust/crates/mnote-web/src/routes/web_shell.rs` 中 slash menu root 选择、定位、secondary unsupported cleanup
- 必要时 `rust/spikes/leptos-tiptap-spike/src/lib.rs` 中 overlay data 属性/host 绑定
- 相关 tests
不要修改 Office、右键菜单、文件树 trash、默认 workspace、ACP/Hermes 文件。你不是独自在代码库中工作;不要回滚、覆盖、格式化或清理其他 worker 的改动。不要提交 git。
## 任务
1. `positionSlashMenuForRoot(root)` 只能定位当前 root 内的 slash menu;不能 fallback 到全局第一个 slash menu。
2. `activeEditorRootForSlashMenu()` 只选择可见且含 selection/focus 的 editor roothidden / unsupported secondary root 不应参与。
3. 打开 unsupported side target 时清理 secondary pane 遗留的 slash overlay。
4. 补字符串测试或 Rust 单测,明确覆盖“不使用全局 slash menu fallback”。
## 验证
至少运行:
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1
cargo test -p mnote-web document_shell_runtime_positions_slash_menu_per_editor_host -- --test-threads=1
```
如果第二个测试不存在,请新增相近测试并运行。
## 最终回复格式
- 修改文件列表
- 完成的 checklist 项
- 运行过的命令和结果
- 未完成项/风险
@@ -0,0 +1,57 @@
# Reasonix 任务:ACP permission 决策闭环
Project root: `/mnt/Data1T/mnote`
你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore``.codegraph/``auth-console.txt``auth-network.txt`
## 背景
Codex 已写 review`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md`
本任务对应 checklist`design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md`
当前问题:`AcpClient` 对 incoming JSON-RPC `session/request_permission` 立即返回 unsupported/denyPage AI UI 虽有按钮,但点击不能真正影响 ACP 请求。
## Ownership
允许直接修改:
- `rust/crates/mnote-web/src/acp_client.rs`
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/acp_bridge.rs`
- `rust/crates/mnote-web/src/routes/hermes_client.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 permission 决策接线相关的小范围代码
- 相关 Rust 测试
- `design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md` 的执行记录
注意:`layout.rs` 可能被其他 worker 改动。只做 permission endpoint 调用与事件处理,不重排 Page AI drawer。
## 目标
实现 `permission.requested -> UI allow/deny -> ACP JSON-RPC response -> permission.allowed/denied` 的闭环。超时、缺失 pending request、缺失 run 时默认 deny 或返回清晰错误。
## 具体要求
1. `AcpClient` 支持注册 incoming request handlerhandler 可异步返回 result/error。
2. `session/request_permission` 不应立即 unsupported;应进入 pending request,向前端发 `permission.requested`
3. 增加后端 endpoint 或扩展现有 endpoint,让前端按 run id / permission id 提交 allow/deny。
4. 前端 `pageAiResolvePermission()` 必须调用后端,而不只是本地改 UI 状态。
5. 决策完成后向 SSE/UI 发送 `permission.allowed``permission.denied`
6. 补 mock ACP 测试覆盖 allow、deny、timeout/default deny。
## 验收命令
请至少运行:
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_client permission -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge permission -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 关键实现说明
- 实际运行的验证命令和结果
- 未完成项 / 风险
@@ -0,0 +1,54 @@
# Reasonix 任务:ACP session info / plan UI
Project root: `/mnt/Data1T/mnote`
你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore``.codegraph/``auth-console.txt``auth-network.txt`
## 背景
Codex 已写 review`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md`
本任务对应 checklist`design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md`
当前 `AcpSessionManager` 已解析 `SessionInfoUpdate``PlanUpdate`,但 `acp_bridge.rs` 丢弃它们。
## Ownership
允许直接修改:
- `rust/crates/mnote-web/src/acp_bridge.rs`
- `rust/crates/mnote-web/src/routes/hermes_client.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 session info / plan event 处理的小范围代码
- 相关 Rust 测试
- `design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md` 的执行记录
注意:`layout.rs` 可能被其他 worker 改动。请只做 event handling 和轻量 UI,不重排 Page AI drawer。
## 目标
`session_info_update``plan` 通过 SSE 转到 Page AI UI。session info 用于更新当前会话标题;plan 用轻量系统/状态消息展示,不写入页面正文。
## 具体要求
1. `AcpSessionEvent::SessionInfoUpdate` 转成 `session.info.updated` SSE。
2. `AcpSessionEvent::PlanUpdate` 转成 `plan.updated` SSE。
3. 前端收到 session info 后更新当前 session title,并持久化当前 session list。
4. 前端收到 plan 后渲染轻量 plan 状态,不能作为普通 assistant 正文。
5. 如果可低风险实现,解析 tool output 中 todo JSON 并复用 plan 区域展示;若风险高,可只写 TODO 到 checklist 执行记录。
6. 补 bridge 单测和 `SIDEBAR_TREE_JS` 字符串断言。
## 验收命令
请至少运行:
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 关键实现说明
- 实际运行的验证命令和结果
- 未完成项 / 风险
@@ -0,0 +1,56 @@
# Reasonix 任务:ACP session/load 恢复
Project root: `/mnt/Data1T/mnote`
你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore``.codegraph/``auth-console.txt``auth-network.txt`
## 背景
Codex 已写 review`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md`
本任务对应 checklist`design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md`
参考实现:`reference-code/hermes-vscode-main/src/sessionManager.ts`,重点是 `ensureSession()` 中调用 `session/load { sessionId, cwd, mcpServers: [] }`,失败再 fallback `session/new`
## Ownership
允许直接修改:
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/routes/hermes_client.rs`
- 与上述变更直接相关的 Rust 测试
- `design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md` 的执行记录
禁止修改:
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- Convex schema / migrations
- 无关设计稿和 smoke 脚本
## 目标
实现或补齐 ACP `session/load` 恢复能力。恢复已有 ACP adapter session 时必须实际调用 adapterload 返回 null 或错误时 fallback 到 `session/new`
## 具体要求
1.`AcpSessionManager` 增加清晰 API,例如 `load_session(session_id, cwd)` / `ensure_session(cwd, stored_session_id)`
2. load 成功后当前 `session_id` 必须是 stored id。
3. load 失败、返回 null 或不支持时,不应让后续 prompt 继续使用 phantom id,应创建新 ACP session。
4.`routes/hermes_client.rs` ACP run 启动处接入恢复逻辑;注意区分 MNote session id、ACP session id、run id。
5. 补 mock ACP 测试:`session/load` 成功、null fallback、新建 session 后 prompt 仍可工作。
## 验收命令
请至少运行:
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_session_manager -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web hermes_client_acp_resume -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 关键实现说明
- 实际运行的验证命令和结果
- 未完成项 / 风险
@@ -0,0 +1,55 @@
# Reasonix 任务:ACP tool locations 与打开动作
Project root: `/mnt/Data1T/mnote`
你不是唯一在代码库中工作的 agent。不要回滚、覆盖或整理与你任务无关的改动;不要提交 git;不要修改 `.gitignore``.codegraph/``auth-console.txt``auth-network.txt`
## 背景
Codex 已写 review`design/10-review/process/13-hermes-vscode-plugin-gap-review-v1.md`
本任务对应 checklist`design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md`
参考实现:`reference-code/hermes-vscode-main/src/protocol.ts` 会解析 `tool_call.locations[].path``src/chatPanel.ts` 在 read/edit tool 完成后打开对应 editor。
## Ownership
允许直接修改:
- `rust/crates/mnote-web/src/acp_types.rs`
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/acp_bridge.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs` 中 tool card 展示 / 打开动作小范围代码
- 相关 Rust 测试或必要 smoke
- `design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md` 的执行记录
注意:`layout.rs` 可能被其他 worker 改动。请避免大范围重排,只接线 tool locations 展示和 open action。
## 目标
保留 ACP tool `locations[]`,并在 Page AI tool card 中显示和打开。默认打开到 MNote 主编辑器资源 tab / 现有 file open resolver,不默认新浏览器窗口。
## 具体要求
1. `SessionUpdate::ToolCall` 解析 `locations: [{ path }]`
2. `AcpSessionEvent::ToolCall` 携带 locations。
3. `acp_bridge` SSE `tool.started` 包含 locations。
4. 前端 tool card 保存并显示 locations。
5. read/edit tool completed 时提供打开动作;优先复用现有主编辑器资源 tab 或已有 file open 函数。
6. 补测试覆盖 parse + SSE + 前端字符串断言。
## 验收命令
请至少运行:
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_tool -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1
```
## 最终回复格式
- 修改文件列表
- 关键实现说明
- 实际运行的验证命令和结果
- 未完成项 / 风险
+1
View File
@@ -65,6 +65,7 @@ tmp
rust/spikes/leptos-tiptap-spike/trunk-8123.err
rust/spikes/leptos-tiptap-spike/trunk-8123.out
design/05-editor-mainline/reference-code
/reference-code/
# 本地 Wolai 对标取证与截图产物
/test-results/
@@ -0,0 +1,44 @@
# Checklist: Local Folder Page Trash And Empty Workspace
## 目标
- 修复删除页面后进入资源垃圾箱的问题。
- 停止自动生成“初始化的新页面”。
- 空白本地工作区保持空白,不创建占位页面。
## 允许修改
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 必要时 `rust/crates/mnote-web/src/routes/gateway.rs` 中 trash 渲染分类逻辑
- 相关 Rust 单测与 smoke
## 禁止事项
- 不删除用户真实文件。
- 不回滚 ACP/Hermes 相关改动。
- 不改旧 React 回收区。
## 待办
- [x] 移除默认工作区创建“初始化的新页面”的运行时行为。
- [x] 更新旧断言:默认 workspace 只创建 `.mnote` 元数据,不创建默认页面目录。
- [x] 对页面 bundle 目录删除进行页面生命周期识别:若目录内存在同名 `.md`,删除应走 `trash_local_markdown_page`
- [x] trash index 中页面记录应可被页面垃圾箱识别,不再作为 `local_directory` 资源统计。
- [x] 空 page tree / file tree 不渲染“初始化的新页面”。
- [x] 补充或更新 targeted tests。
## 本轮执行记录
- 代码:`local_folder_source.rs` 移除默认首页创建,并让 `local-dir:*` 页面 bundle 删除转入 `markdown_bundle` 页面垃圾箱。
- 新增测试:`local_tree_command_delete_page_bundle_directory_uses_page_trash`
- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-trash.json`,截图 `06-page-bundle-trash-classification.png`
- 说明:`task432-filetree-trash-page-dual-browser-no-refresh-smoke` 当前失败在 convex-source 双浏览器实时 delta 未插入新建子页,不是本地页面 bundle 垃圾箱分类失败;后续应按实时同步历史 smoke 单独处理或退役。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web local_tree_command_manages_loose_markdown_like_regular_file -- --test-threads=1
cargo test -p mnote-web local_tree_command_delete_folder_moves_directory_to_trash -- --test-threads=1
cargo test -p mnote-web create_default_local_workspace_for_actor_at_base_creates_manifest_and_root_only -- --test-threads=1
```
@@ -0,0 +1,52 @@
# Tab / Office / Upload Browser Bugs Review V1
日期:2026-05-21
## 范围
本轮覆盖用户继续反馈的 4 类缺陷:
1. 主 resource tab 激活后,点击左侧主 Markdown 文件或新建页面,不能切回主 Markdown page tab。
2. 左侧新建页面后,文件树焦点/选中没有落到新建页面。
3. 真实中文名 docx 上传后仍打不开:`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`
4. 上传文件偶发浏览器卡死,刷新无效但文件实际已上传;怀疑中文文件名、中文符号、Office 打开链路或上传后插入编辑器引发卡住。
## 当前初步判断
- `navigateToDocument` 在当前文档 ID 相同的路径上只更新树行状态,不会调用 resource tab runtime 切回 page tab。因此当前 page 已经是同一 Markdown 时,点击左侧文件树会停留在 resource tab。
- `openPrimaryDocument` 替换 primary pane 后未显式调用 `activateMainEditorTab('')`,从 resource tab 导航到其他 Markdown 页面时也可能仍显示 resource host。
- `createPage` 创建成功后直接 `navigateToDocument`,没有等待 local folder projection 刷新,也没有把 filetree selection state 的 `selectedRowIds/focusedRowId` 指向新页面。
- 上传 Office 附件后 `insertUploadedAssetIntoEditor` 会同步构造 OnlyOffice 链接并拉取 `whoami`,若 DocumentServer 或 iframe 链路异常,当前需要确认是否只是打开失败,还是上传后插入/刷新/资源行追加链路导致页面失去响应。
- 之前浏览器测试只验证到 `/onlyoffice` iframe URL 和 resource tab 状态,未完整验证 DocumentServer 内部是否报 `errorCode=-18`;本轮需要抓 iframe/console/network 证据。
## 修改策略
- Tab 切回:
- 在 document pane runtime 暴露 `activatePageTab()`
- `openPrimaryDocument` 成功后强制切回 page tab。
- `navigateToDocument` 的 same-document fast path 也调用 `activatePageTab()`,解决“点击当前主 md 不能从 resource tab 切回”。
- 新建页焦点:
- 增加 filetree 选择 helper,按 documentId 选中并聚焦 `doc:{documentId}` 行。
- `createPage` 在 local_folder 下先刷新 sidebar projection,再选中新页面,再导航。
- document pane primary 切换后派发 `mnote:primary-document-activated`,由 sidebar runtime 同步 selection state。
- Office / 上传:
- 用真实中文 docx 做浏览器测试,记录上传 API、resource tab、iframe src、OnlyOffice 页面错误。
- 若出现卡死,优先让上传链路避免等待非关键插入/打开操作;上传完成后 UI 不应被 OnlyOffice whoami/iframe 初始化阻塞。
- 需要区分“DocumentServer 内部连接失败”和“MNote 浏览器卡死”两类问题,不混为同一个修复。
- Reasonix 测试流程:
- 新增 `/home/lix/.codex/skills/reasonix-browser-tester/SKILL.md`
- 本轮派发 Reasonix 独立浏览器测试任务,Codex 只复核其 `result.json` 和截图。
## 不做事项
- 不回滚现有 ACP/Hermes 改动。
- 不把 Office 改成下载兜底作为唯一行为。
- 不把 Reasonix 测试结论当成事实源;必须复核截图和结构化日志。
- 不清理用户已有上传文件或真实工作区文件。
## 验收
- 点击 page tab / 左侧当前 Markdown / 左侧其他 Markdown,都能从 resource tab 回到主 Markdown page tab。
- 左侧新建页面后,URL、page tab 标题、filetree `data-selected``data-focused` 指向新页面。
- 上传指定中文 docx 后浏览器不应卡死;resource tab 有明确打开状态或错误状态;刷新后不应要求重新登录才能恢复。
- Reasonix 输出独立浏览器测试证据,Codex 与本地验证对照。
@@ -0,0 +1,99 @@
# Workbench Filetree / Office / Slash Bug Review V1
日期:2026-05-21
## 范围
本轮覆盖用户截图 23-26 中的 6 个 P0/P1 缺陷:
1. 文件树删除页面后,页面进入资源垃圾箱,页面垃圾箱没有使用。
2. 主编辑区 tab 标题与实际打开页面标题不一致。
3. 上传 docx 后页面卡死,docx 打不开。
4. 不应自动出现“初始化的新页面”;空白工作区应保持空白。
5. 文件树右键菜单大量动作不可用,需对照 Sidex / VSCode 文件资源管理器收敛到可用动作。
6. 主栏打开 slash 菜单时,侧栏也出现 slash 菜单。
## 当前证据
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- `ensure_default_workspace_home_page` 会创建 `初始化的新页面/初始化的新页面.md`
- `create_default_local_workspace_for_actor_at_base_creates_manifest_and_root_only` 仍断言该默认页面存在。
- `trash_local_entry` 先解析 `local-dir:*`,再解析 `local-file:*`,最后才走 `trash_local_markdown_page`
- 文件树中页面 bundle 目录在 `scan_directory` 中是 `rowKind=folder`,因此删除会走 `trash_local_directory``resourceKind=local_directory`
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `replacePaneDocument` / `updatePaneChrome` 会更新正文、面包屑、`document.title`,但没有同步 `[data-mnote-main-tab="page"] .mnote-main-tab-title`
- `openUnsupportedSideTarget` 会替换 secondary root 为不支持占位;`positionSlashMenuForRoot` 仍允许 fallback 到 `document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')`,存在跨 pane 定位/展示风险。
- `openResourceAsSideTarget` 对 markdown/text/code/office 全部进入 unsupported,占位会污染侧栏;这与用户期望“不可用动作不要误打开侧栏”冲突。
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 文件树右键菜单直接展示 `open-right / new-file / new-folder / paste / refresh / collapse / reveal / duplicate / rename / delete`,其中部分 disabled 或仅 dispatch event,无真实实现。
- `openConvexAssetFromFileTree` 对 local office 可走主 tab 的 iframe,但 Convex office 仍直接 `window.open(buildOnlyOfficeOpenUrl(...))`
- 附件上传 `insertUploadedAssetIntoEditor``enhanceEditorAttachmentLink` 已尝试统一附件 UI;docx 卡死需要先确认是上传阶段阻塞、OnlyOffice iframe 回源失败,还是 active-tab iframe 加载导致主页面阻塞。
- Sidex / VSCode 参考:
- Sidex 保留 VSCode 的 `MenuId.ExplorerContext` 菜单体系,文件树动作来源在 `reference-code/sidex-main/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts`
- 可对照的基础动作是 Open to the Side、New File、New Folder、Reveal、Copy Path、Rename、Delete;动作是否显示/启用由 context key 决定,不是把所有潜在动作硬塞进菜单。
- Sidex workspace 层有 `FileClipboard``InlineInputKind::{Rename, NewFile, NewFolder}` 等状态,说明新建/重命名应走内联输入或明确命令,而不是菜单项只发事件。
## 修改策略
- Bug1 / Bug4 属于本地文件夹 tree/source 口径问题,优先修 Rust kernel-facing 路径:
- 默认工作区不再创建“初始化的新页面”。
- bundle 页面删除必须以 Markdown 页面生命周期入 trash,页面垃圾箱可识别,不应按 `local_directory` 资源归类。
- 空 root page tree 可返回空列表;UI 不渲染占位页面。
- Bug2 属于主编辑区 tab chrome 同步:
- `updatePaneChrome(primary)` 同步 page tab 标题、`data-document-id` 和 active 状态。
- 页面标题保存事件也应更新 page tab。
- Bug3 属于 Office 打开链路:
- 上传本地 docx 后,主 tab 打开应使用 `/onlyoffice?...fileUrl=/api/local-folder/files/open...`,并避免在主线程同步读取/解析大文件。
- 如果 OnlyOffice DocumentServer 不可连,应显示非阻塞错误,不应卡死整个页面。
- Bug5 属于菜单可用性:
- 菜单只显示/启用已有真实实现。
- “在右侧边栏打开”只对已支持侧栏打开的页面显示;资源侧栏未支持时不要打开 unsupported placeholder。
- `Copy Path` 对 local folder 应复制真实 relative path,不是复制标题。
- New Folder 在 local_folder 下应接入 `create_folder`,否则隐藏。
- Bug6 属于 editor host 隔离:
- Slash 菜单定位只能操作当前 root 内的菜单,不能 fallback 到别的 pane 的菜单。
- 不支持侧栏资源时应清理 secondary editor/slash overlay,或完全不打开侧栏。
## 不做事项
- 不回滚当前工作区已有 ACP/Hermes 改动。
- 不把资源 tab、side pane、OnlyOffice 做成全新架构;只修当前 P0 可见缺陷。
- 不复活旧 React / BlockNote 运行时。
- 不把 Sidex 菜单实现照搬成通用命令注册系统;本轮只借鉴“按可用能力显示动作”的原则。
## 验收
- Rust targeted tests 至少覆盖:
- local workspace 默认初始化不创建“初始化的新页面”。
- 删除页面 bundle 进入 page/document trash,而不是 `local_directory` resource trash。
- 主 page tab 随 active primary document 标题更新。
- local docx onlyoffice URL / content-type / proxy 回源可达。
- 文件树菜单不可用动作隐藏或禁用,已显示动作有真实 handler。
- slash menu 不跨 primary/secondary/resource pane 显示。
- UI 类最终必须做真实浏览器或 smoke 验证,并保留截图/输出。
## 本轮执行结果
- 已完成并移动到 `done/`
- `bugs/04-tree-domain/done/4-45-local-folder-page-trash-and-empty-workspace-v1.md`
- `bugs/05-editor-mainline/done/5-28-main-page-tab-title-active-sync-v1.md`
- `bugs/05-editor-mainline/done/5-29-onlyoffice-docx-upload-open-freeze-v1.md`
- `bugs/05-editor-mainline/done/5-30-filetree-context-menu-sidex-alignment-v1.md`
- `bugs/05-editor-mainline/done/5-31-side-pane-slash-overlay-isolation-v1.md`
- Rust 验证:
- `cargo test -p mnote-web -- --test-threads=1`481 passed。
- 新增 `local_tree_command_delete_page_bundle_directory_uses_page_trash`,锁定 `local-dir:*` 页面 bundle 删除进入 `markdown_bundle` 页面垃圾箱。
- Smoke / 浏览器验证:
- `node scripts/task463-onlyoffice-resolver-smoke.js`:通过。
- `node scripts/task462-resource-tab-error-placeholder-smoke.js`:通过。
- `node scripts/task455-local-folder-mindmap-clean-smoke.js`:通过。
- `node scripts/task458-local-create-page-no-conflict-smoke.js`:通过。
- `node scripts/task464-local-folder-resource-trash-ui-smoke.js`:通过。
- `node scripts/task472-side-target-secondary-pane-smoke.js`:通过。
- 真实浏览器证据:
- `tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json`
- `tmp/bug-20260521-workbench-browser-verify/result-slash.json`
- `tmp/bug-20260521-workbench-browser-verify/result-trash.json`
- 截图覆盖:主 tab 标题同步、真实 docx resource tab、资源右键菜单、primary/secondary slash 隔离、页面 bundle 垃圾箱分类。
- 历史 smoke 注意:
- `node scripts/task432-filetree-trash-page-dual-browser-no-refresh-smoke.js` 当前失败在 convex-source 双浏览器 live delta 未插入新建子页;失败点早于本轮页面删除/垃圾箱断言,不代表本地页面 bundle 垃圾箱修复失败。后续应按实时同步历史 smoke 单独修复或退役。
@@ -0,0 +1,38 @@
# Checklist: Main Page Tab Title Active Sync
## 目标
主编辑区 page tab 标题必须随当前 primary document 切换和标题保存同步更新。
## 允许修改
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/ssr/pages/document.rs`
- 相关测试 / smoke
## 禁止事项
- 不改资源 tab 整体架构。
- 不移除资源 tab close guard。
- 不回滚现有 `layout.rs` 用户改动。
## 待办
- [x]`updatePaneChrome(primary)` 中同步 `[data-mnote-main-tab="page"]` 的 title、document id、workspace id。
- [x] 标题保存控制器 `updateVisibleTitle` 同步当前 page tab 标题。
- [x] 切换回 page tab 时确保 tab 标题与当前 primary document 一致。
- [x] 增加 Rust 字符串测试或 browser smoke 覆盖。
## 本轮执行记录
- 代码:`web_shell.rs` 在标题更新与 primary pane 切换时同步主 page tab。
- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json``tabState.tabTitle=Beta`,截图 `01-beta-page-tab-title.png`
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web page_layout_renders_main_editor_tab_strip -- --test-threads=1
cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1
```
@@ -0,0 +1,42 @@
# Checklist: OnlyOffice Docx Upload Open Freeze
## 目标
上传 docx 后点击打开不应卡死;本地 docx 应能在主编辑器 resource tab 中打开 OnlyOffice,失败时显示非阻塞错误。
## 允许修改
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- OnlyOffice / local upload 相关测试
## 禁止事项
- 不绕开 local-first rootUri 授权。
- 不把 docx 改成浏览器下载兜底作为唯一行为。
- 不引入新外部服务。
## 待办
- [x] 复现并定位上传后卡死发生在上传、插入附件 UI、resource tab iframe、OnlyOffice DocumentServer 回源哪一层。
- [x] 确保 local docx 打开 URL 走 `/api/local-folder/files/open` 且 content-type 正确。
- [x] 主 tab iframe 打开 OnlyOffice 时设置清晰 loading/error 状态,超时不阻塞主页面。
- [x] Convex / local office 行为尽量统一到 resource tab;新窗口只作为“在新窗口打开”动作。
- [x] 补充 docx 上传/打开 targeted smoke 或 Rust 字符串测试。
## 本轮执行记录
- 代码:`onlyoffice.rs` 为 API 加载和初始化增加超时错误;`layout.rs` 让 office 资源优先进入主 resource tab。
- 浏览器验证:使用真实文件 `/mnt/Data1T/research/DHA_cosmetics/progress/DHA美黑化妆品监管综述_2026-05-16.docx` 上传并打开,结果在 `tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json`,截图 `02-real-docx-resource-tab.png`
- Smoke`node scripts/task463-onlyoffice-resolver-smoke.js` 已通过。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web local_file_open_sets_pdf_and_office_content_type -- --test-threads=1
cargo test -p mnote-web onlyoffice_page_exposes_documentserver_fetch_base_for_local_files -- --test-threads=1
cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1
```
@@ -0,0 +1,46 @@
# Checklist: Filetree Context Menu Sidex Alignment
## 目标
对照 Sidex / VSCode Explorer Context,收敛 MNote 文件树右键菜单:只展示可用动作,已展示动作必须有真实效果。
## 允许修改
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs`
- `rust/crates/mnote-web/src/routes/local_folder_source.rs` 中已有 tree command 入口的小范围扩展
- 相关测试
## 禁止事项
- 不照搬 Sidex 菜单注册框架。
- 不保留点击后只 dispatch 但用户不可见无效果的菜单项。
- 不让资源“在右侧边栏打开”生成 unsupported placeholder。
## Sidex 对照结论
- Sidex / VSCode 的 Explorer 菜单来自 `MenuId.ExplorerContext`,动作在 `fileActions.contribution.ts` 注册。
- 基础动作包括 Open to the Side、New File、New Folder、Reveal、Copy Path、Rename、Delete。
- 动作显示/启用依赖 context key;不可用动作不应常驻为“假按钮”。
## 待办
- [x] local_folder 下 `New File` 继续创建页面;`New Folder` 接入 `create_folder` 或在非 local_folder 隐藏。
- [x] `Copy Path` 对 local folder 复制 `relativePath` / asset path,不复制标题。
- [x] `Reveal` 只做滚动/聚焦即可,但需记录状态。
- [x] `Duplicate` 若未实现 copy,则隐藏或禁用并说明;不要只 dispatch。
- [x] `Open Right` 对页面可打开 secondary document;对资源若不支持侧栏则隐藏,避免 unsupported placeholder。
- [x] 更新菜单字符串测试。
## 本轮执行记录
- 代码:`layout.rs` 收口文件树右键菜单,移除资源侧栏打开和未实现 duplicate,接入 local folder `create_folder` 与真实 `Copy Path`
- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-title-docx-menu.json` 中资源菜单为“在新窗口打开 / 重命名 / 复制资源 ID / 删除 / Copy Path / Refresh / Reveal”,截图 `03-asset-context-menu.png`
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web sidebar_tree_runtime_renders_context_menu_and_scoped_title_updates -- --test-threads=1
cargo test -p mnote-web sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions -- --test-threads=1
```
@@ -0,0 +1,38 @@
# Checklist: Side Pane Slash Overlay Isolation
## 目标
主编辑器 slash 菜单不得在侧栏/secondary pane 中出现;不支持侧栏打开资源时不得留下会响应 slash 的侧栏 editor host。
## 允许修改
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- 必要时 `rust/spikes/leptos-tiptap-spike/src/lib.rs`
- 相关测试 / smoke
## 禁止事项
- 不禁用主编辑器 slash 菜单。
- 不删除侧栏页面编辑能力。
- 不用全局 CSS 隐藏全部 slash menu 作为表面修复。
## 待办
- [x] `positionSlashMenuForRoot` 只查当前 root 内菜单;不能 fallback 到全局第一个 slash menu。
- [x] `activeEditorRootForSlashMenu` 优先选择含有 selection/focus 的可见 rootsecondary hidden/unsupported root 不参与。
- [x] 打开 unsupported side target 时彻底卸载 secondary view 并清理遗留 slash overlay。
- [x] 为 primary + secondary 同时存在时的 slash 隔离补测试。
- [x] 若 leptos runtime 自身生成全局 overlay,需要给 overlay 绑定 `data-pane-role` 或 editor host id。
## 本轮执行记录
- 代码:`web_shell.rs` 增加 `setSlashMenuInactive` / `hideSlashMenusOutsideRoot`,非当前 selection/focus root 的 slash menu 会被标记 `data-mnote-slash-inactive` 并隐藏。
- 浏览器验证:`tmp/bug-20260521-workbench-browser-verify/result-slash.json``primaryMenus=1``secondaryMenus=0``inactiveSecondary=1`,截图 `05-slash-primary-secondary-isolation.png`
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web document_shell_runtime_renders_resource_tab_runtime -- --test-threads=1
cargo test -p mnote-web document_shell_runtime_positions_slash_menu_per_editor_host -- --test-threads=1
```
@@ -0,0 +1,42 @@
# Checklist: Main Resource Tab Page Switch And Filetree Focus
## 目标
- resource tab 激活时,点击主 Markdown 或其他 Markdown 页面能切回主 page tab。
- 新建页面后,主编辑区显示新页面,filetree 选中和焦点落在新页面。
## 允许修改
- `rust/crates/mnote-web/src/routes/web_shell.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 相关 Rust 字符串测试与浏览器 smoke
## 禁止事项
- 不改资源 tab 整体架构。
- 不删除 resource tab MRU / close guard。
- 不回滚 ACP/Hermes 相关改动。
## 待办
- [x] document pane runtime 暴露 `activatePageTab()`
- [x] `openPrimaryDocument` 成功后切回 page tab。
- [x] `navigateToDocument` same-document fast path 也能从 resource tab 切回 page tab。
- [x] 页面树整行点击也能触发打开,避免只点内部按钮才生效。
- [x] filetree 增加按 documentId 选中/聚焦 helper。
- [x] `createPage` 在 local folder projection 刷新后选中新页面。
- [x] primary document 激活后同步 filetree selection state。
- [x] 补浏览器验证覆盖 resource tab -> md page 切换、新建页面焦点。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web -- --test-threads=1
```
## 验证记录
- `cargo test -p mnote-web -- --test-threads=1`482 passed。
- Playwright 复测:resource tab 激活后点击左侧 `Beta` 页面,URL 切到 `/documents/local-md:Beta.md`active tab 为 `page`resource host 隐藏,页面树 active 行为 `Beta`
- 截图:`/mnt/Data1T/mnote/tmp/bug-20260521-tab-office-upload-after-row-click.png`
@@ -0,0 +1,49 @@
# Checklist: Office Chinese Docx Upload Open Freeze
## 目标
上传并打开中文文件名 docx 时,浏览器不应卡死;Office 打开失败时必须是可见、非阻塞错误。
指定测试文件:
`/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`
## 允许修改
- `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 相关 browser smoke
## 禁止事项
- 不引入新外部服务。
- 不把 docx 只做下载兜底。
- 不清理用户真实下载文件。
## 待办
- [x] 用真实中文 docx 复现上传、插入附件、点击打开、OnlyOffice iframe 内错误。
- [x] 记录 console/network/iframe src/页面响应性。
- [x] 区分 DocumentServer `errorCode=-18` 与浏览器主页面卡死。
- [x] 上传完成后不得等待非关键 Office 初始化;上传链路必须释放 UI。
- [x] 对中文文件名/URL 编码和 OnlyOffice document key 安全化补测试。
- [x] 增加 Reasonix clean browser 测试流程,并用截图交叉验证。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust
cargo test -p mnote-web -- --test-threads=1
node scripts/task463-onlyoffice-resolver-smoke.js
```
## 验证记录
- `cargo test -p mnote-web onlyoffice -- --test-threads=1`12 passed。
- `cargo test -p mnote-web -- --test-threads=1`482 passed。
- Reasonix clean browser 测试输出:`/mnt/Data1T/mnote/tmp/reasonix-office-upload-open-clean-2026-05-21/result.json`,结论 `pass`
- Reasonix 最终截图:`/mnt/Data1T/mnote/tmp/reasonix-office-upload-open-clean-2026-05-21/04-final-state.png`,可见 OnlyOffice 文档正文。
- Codex 自测输出:`/mnt/Data1T/mnote/tmp/codex-office-upload-open-clean-2026-05-21/result.json`
- Codex 最终截图:`/mnt/Data1T/mnote/tmp/codex-office-upload-open-clean-2026-05-21/codex-office-final.png`,可见 OnlyOffice 文档正文。
- 已确认 WebSocket URL 使用安全 doc key,例如 `mnote_1258170954_1240121036`,不含 `/``:` 或中文字符;未出现 `errorCode=-18`
@@ -0,0 +1,107 @@
# Office local-first 预览、编辑与插件噪音缺口审查 v1
## 背景
本轮只审查 OnlyOffice 与 MNote local-first 工作区之间的三个接口缺口:
1. `/api/media/sign` 对 local-folder asset 返回 404。
2. Office 附件默认只读,但需要三点菜单提供“使用编辑模式打开”。
3. OnlyOffice 自定义 annotation 插件存在 404 / pageerror 噪音。
上一轮浏览器验证已经确认:中文 docx 上传后可在 resource tab 中只读打开,`errorCode=-18` 的 WebSocket 连接问题已通过 doc key 安全化和 `/onlyoffice-server` 反代修复。因此本轮不把“只读预览可见”重新定义为失败。
## 官方依据
Context7 查询 `/onlyoffice/api.onlyoffice.com` 得到的关键口径:
- `editorConfig.mode` 支持 `"view"` / `"edit"`,只是初始化编辑器模式。
- 保存必须配置 `editorConfig.callbackUrl`,后端在 callback `status === 2``status === 6` 时下载 `body.url` 并写回文件,成功返回 `{ "error": 0 }`
- `onRequestEditRights` 是 view 模式切 edit 的官方事件;触发后集成方必须以 edit mode 重新初始化编辑器。
- 插件通过 `editorConfig.plugins.autostart``editorConfig.plugins.pluginsData` 连接;annotation / custom assistant 类插件 404 属于可选插件加载链路,不等于主文档渲染失败。
## 代码现状
- `rust/crates/mnote-web/src/routes/media.rs``/api/media/sign` 当前只服务 Convex media asset,内部查询 `mediaAssets:getById` / `mediaAssets:refreshUrl`。local-folder asset 形如 `local:asset:<path>`,不在 Convex media 表中,所以返回 404 是现状契约不匹配。
- `rust/crates/mnote-web/src/routes/onlyoffice.rs``/onlyoffice` 页面在 `resolveAssetUrlAndKey()` 中只要有 `assetId` 就请求 `/api/media/sign`;失败后静默保留传入的 `fileUrl`。这就是“当前 view 模式不阻断打开”的原因。
- local-folder Office 预览已经可以直接使用 `/api/local-folder/files/open?rootUri=...&path=...` 作为 `fileUrl`,不需要经过 `/api/media/sign`
- `/api/onlyoffice/callback` 当前仍代理 legacy Next writeback。对 local-folder 的 `status 2/6 -> 下载 body.url -> 原文件覆盖写回 -> watcher 同步` 没有完整闭环。
- `layout.rs` 中正文附件菜单已有 `new-window`,但没有“使用编辑模式打开”。部分路径当前会默认生成 `mode=edit`,这与“默认只读、显式编辑”的产品口径不一致。
- annotation 插件 404 / pageerror 暂未证明会影响文档渲染;当前更像是 OnlyOffice 静态插件包或自定义插件配置缺失导致的 console 噪音。
## 根因判断
### 1. `/api/media/sign` local-folder 404
根因不是权限失效,而是端点 ownership 错位:`/api/media/sign` 是历史 Convex media asset 签名端点,local-folder 文件已经有 local open route,二者不应混用。
P0 目标不是让 `/api/media/sign` 接管所有本地文件,而是让 OnlyOffice local 路径不再无意义请求该端点,避免误导日志和后续测试。
### 2. 编辑模式入口
OnlyOffice `mode=edit` 并不等于 MNote 已支持保存。没有 callback 写回闭环时,直接默认编辑会制造“看似能改、实际不能保存”的假功能。
P0 目标:
- 默认打开仍为 `view`
- 菜单提供“使用编辑模式打开”入口。
- 编辑入口必须带 guard:清楚标记为实验能力,或在 local-folder writeback 未闭环时阻止/提示。
P1 目标才是实现 local-folder callback 写回。
### 3. annotation 插件 404 / pageerror
插件链路属于可选增强。若配置了 autostart / pluginsData 但静态资源不存在,会产生 404 或 pageerror。只要正文渲染和 OnlyOffice 主 WebSocket 正常,这不是打开失败。
P0 目标:
- 不把可选插件 404 当成 Office 打开失败。
- 不让缺失插件反复污染测试结论。
- 若当前 MNote 并未真正依赖 annotation 插件,则应禁用 autostart 或明确过滤为 non-critical。
## Checklist 拆分
- `5-34-local-folder-media-sign-office-url-contract-v1.md`
- P0OnlyOffice local-folder 打开不再请求 `/api/media/sign`
- P1:补回归测试,确认 local asset 仍可只读打开。
- `5-35-office-edit-mode-menu-and-guard-v1.md`
- P0:正文附件与文件树资源菜单增加“使用编辑模式打开”。
- P0:默认打开保持只读。
- P0:若保存闭环未完成,编辑入口必须有 guard / 实验标记。
- `5-36-onlyoffice-local-edit-save-callback-contract-v1.md`
- P1:设计或实现 local-folder callback 写回。
- P1:覆盖 status 2/6、下载 URL rewrite、路径权限、冲突保护。
- `5-37-onlyoffice-annotation-plugin-noise-policy-v1.md`
- P0:定位 annotation 404 来源。
- P0:禁用无效 autostart / pluginsData 或把该类错误降级为 non-critical 测试噪音。
## 不做事项
- 不默认以 edit 模式打开 Office。
- 不在没有保存闭环时承诺“Office 可编辑保存”。
- 不把 local-folder 文件任意暴露给 `/api/media/sign`,除非经过 rootUri / workspace / allowed roots 校验。
- 不把 annotation 插件 404 等同于主文档渲染失败。
## 验收要求
- `cargo test -p mnote-web -- --test-threads=1`
- `codegraph sync .` 后确认索引健康。
- Reasonix clean browser 测试:全新 browser context,登录测试账号,上传 `/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`,打开默认只读,截图确认内容可见;打开三点菜单确认编辑入口;点击编辑入口后截图和日志证明行为符合 guard / edit URL 预期。
- Codex 必须复核 Reasonix 的 `result.json` 和截图,再自行做一次浏览器截图核查。
## 当前执行结论
- P0 已处理:
- local-folder Office 打开跳过 `/api/media/sign`,继续使用 `/api/local-folder/files/open`
- Office 默认打开为 `mode=view`
- 菜单提供“使用编辑模式打开”,进入前有实验 guard。
- 已存在 Office resource tab 从 view 切 edit 会刷新同一 iframe URL,不再只激活旧 tab。
- annotation/custom assistant 插件 404 已归类为 non-critical noise,不作为主文档打开失败。
- P1 仍保留:
- local-folder Office edit/save callback 写回闭环未实现,编辑模式仍不能承诺保存到原文件。
- `onRequestEditRights` 事件重新初始化 edit URL 未实现。
- 浏览器证据:
- Reasonix`tmp/reasonix-office-view-edit-plugin-2026-05-21/result.json` 与截图。
- Codex`tmp/codex-office-view-edit-plugin-2026-05-21/result.json``01-office-view-mode.png``02-office-edit-mode.png`
@@ -0,0 +1,46 @@
# 5-34 local-folder media/sign 与 Office URL 契约
## 目标
OnlyOffice 打开 local-folder asset 时不再向 `/api/media/sign` 发起无意义请求;local 文件继续通过 local-folder open route 进入 OnlyOffice 只读预览。
## 原因
`/api/media/sign` 当前只签 Convex media asset。local-folder asset 已有 `rootUri + path` 的本地文件打开链路,不应混入 Convex 签名端点。
## 允许修改
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 相关 `mnote-web` 单测
## 禁止事项
- 不开放任意本地路径签名。
- 不把 `/api/media/sign` 扩成绕过 allowed roots 的本地文件下载端点。
- 不改变非 local Convex media asset 的签名行为。
## Checklist
- [x] `/onlyoffice` 页面识别 local asset / local fileUrl,跳过 `/api/media/sign`
- [x] local asset 的 doc key 仍稳定、安全,不包含 `/``:`、中文等危险字符。
- [x] 非 local asset 仍可走 `/api/media/sign` 解析 signedUrl。
- [x] 补单测覆盖 local asset 不依赖 media sign 的页面脚本契约。
## 验收
- `cargo test -p mnote-web onlyoffice -- --test-threads=1`
- `cargo test -p mnote-web -- --test-threads=1`
## 本轮执行记录
- 2026-05-21Reasonix worker A 执行完成。
-`page()` 模板 JS 中添加 `isLocalFolderAsset()` 守卫函数。
- `resolveAssetUrlAndKey()` 中 local-folder asset 跳过 `/api/media/sign`
- 守卫条件:`assetId` 前缀为 `local:``local-file:`,或 `fileUrl` 路径含 `/api/local-folder/files/open`
- 新增 `onlyoffice_page_skips_media_sign_for_local_folder_asset` 单测。
- `cargo test -p mnote-web onlyoffice -- --test-threads=1` 全部 13 项通过。
- 2026-05-21Codex 复核补充。
- 干净浏览器上下文上传并打开 `/home/lix/Downloads/重庆发展特殊化妆品可行性报告_政府汇报版.docx`
- local-folder iframe `fileUrl` 指向 `/api/local-folder/files/open?...`,未出现 `/api/media/sign` local 404。
- 证据:`tmp/codex-office-view-edit-plugin-2026-05-21/result.json`,截图 `01-office-view-mode.png` / `02-office-edit-mode.png`
@@ -0,0 +1,62 @@
# 5-35 Office 编辑模式菜单与保护
## 目标
Office 文件默认只读打开;在正文附件三点菜单和文件树资源右键菜单中增加“使用编辑模式打开”入口。保存闭环未完成前,编辑入口必须有 guard 或明确实验标记。
## 原因
ONLYOFFICE `mode=edit` 只是编辑器初始化模式。若 MNote 没有完成 callback 写回,默认 edit 会让用户误以为修改已经保存到本地文件。
## 允许修改
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- `rust/crates/mnote-web/src/routes/onlyoffice.rs` 中与 view/edit 初始化事件相关的最小补充
- 相关 `mnote-web` 单测
## 禁止事项
- 不默认 edit。
- 不声称 edit/save 已完整支持。
- 不改动 ACP / Hermes 无关代码。
## Checklist
- [x] `buildOnlyOfficeAssetOpenUrl` / local Office 默认输出 `mode=view`
- [x] 正文附件菜单增加“使用编辑模式打开”。
- [x] 文件树 Office asset 菜单增加“使用编辑模式打开”。
- [x] 编辑模式入口明确使用 `mode=edit` 打开到主 resource tab 或新窗口,行为与现有打开目标一致。
- [x] 在 local-folder save callback 未闭环时,编辑入口有 guard:可提示“编辑保存仍在实验中”,或通过 data/status 标记便于测试识别。
- [x] 已存在 Office resource tab 从 `mode=view` 切到显式 `mode=edit` 时,刷新同一 tab 的 iframe URL,而不是只激活旧 tab。
- [ ] 若启用 `onRequestEditRights`,必须重新初始化为 edit URL,不只 reload 当前 view。(P1,未实现)
## 验收
- `cargo test -p mnote-web sidebar_tree_js -- --test-threads=1`
- `cargo test -p mnote-web onlyoffice -- --test-threads=1`
- 浏览器截图:默认打开是只读;菜单中存在编辑入口;点击编辑入口后的页面 URL / debug state 包含 `mode=edit` 或 guard 提示。
## 本轮执行记录
- 2026-05-21Reasonix worker B 卡在计划阶段后由 Codex 终止;本条实现来自其它 Reasonix 结果与 Codex 复核修正。
- `buildOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`
- `buildOnlyOfficeOpenPath` 默认 mode 从 `'edit'` 改为 `'view'`
- `buildLocalOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`
- `normalizeOnlyOfficeAttachmentHref` 默认 mode 从 `'edit'` 改为 `'view'`
- `detailFromEditorAttachmentLink` 默认 mode 从 `'edit'` 改为 `'view'`
- `enhanceEditorAttachmentLink` 默认 mode 从 `'edit'` 改为 `'view'`
- 正文附件菜单(`openTreeContextMenu` `isAttachment` 分支)增加菜单项 `{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }`,放置于"在新窗口打开"之前。
- 文件树 asset 菜单(`isAsset` 分支)增加相同的 `open-edit-mode` 菜单项。
- 新增 `withOfficeEditModeGuard()` 守卫函数:
- 设置 `data-mnote-last-office-edit-mode-requested="true"``data-mnote-last-office-edit-mode-guard="shown"` 以支持浏览器测试识别。
- 弹出 `window.confirm('编辑保存仍在实验中,建议先备份文件。是否继续?')` 对话框,用户确认后才继续。
- `handleTreeContextMenuAction` 中 attachment 和 isAsset 分支分别处理 `open-edit-mode` 动作,调用 guard 后再以 `mode=edit` 打开。
- Codex 修正:普通“在新窗口打开”保持 `mode=view`;只有“使用编辑模式打开”才传入 `mode=edit`
- Codex 补充 `sidebar_tree_runtime_opens_office_assets_through_resource_shell` 契约测试,覆盖默认 view、显式 edit、guard 标记和 edit URL 重写。
- `cargo test -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1` 通过。
- `cargo test -p mnote-web onlyoffice -- --test-threads=1` 通过。
- 2026-05-21Codex 浏览器复核发现并修复“已有 Office tab 切 edit 不刷新”。
- 根因:`openResourceInActiveTab()` 遇到已存在 `objectIdentity` 时只调用 `activateMainEditorTab(objectIdentity)`,没有更新旧 iframe 的 `officeUrl`
- 修复:`web_shell.rs` 新增 `refreshExistingOfficeResourceTab()`,仅对 `entry.kind === 'office'` 且新旧 `href/officeUrl` 不一致时重建 passive iframe。
- 回归:`scripts/task463-onlyoffice-resolver-smoke.js` 新增 Test 1b,断言同一 docx tab 从 `mode=view` 刷新到 `mode=edit`,且不会创建第二个 Office tab。
- Codex clean browser 自测通过:中文 docx 默认 view;同 tab edit 后 iframe URL `mode=edit`;证据见 `tmp/codex-office-view-edit-plugin-2026-05-21/result.json`
@@ -0,0 +1,66 @@
# 5-36 OnlyOffice local-folder 编辑保存 callback 契约
## 目标
为 local-folder Office 编辑保存建立 callback 写回契约。P1 可以先落设计和测试骨架;若实现,必须覆盖 status 2/6 的下载写回和路径安全。
## 原因
ONLYOFFICE 官方保存链路要求 callback `status === 2``status === 6` 时,集成后端下载 `body.url` 并写回原文件。当前 `mnote-web` callback 仍代理 legacy Nextlocal-folder 写回未闭环。
## 允许修改
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `rust/crates/adapter-onlyoffice/src/lib.rs`
- 相关单测
- 如需记录设计,可补充本 checklist
## 禁止事项
- 不绕过 allowed roots 写任意路径。
- 不在没有冲突保护时覆盖用户无关文件。
- 不强行删除 legacy callback 代理。
## Checklist
- [ ] 明确 local asset callback 定位:从 `assetId` / `fileUrl` / session 推导 rootUri 与 path。
- [ ] status 非 2/6 时返回 `{ "error": 0 }`,不写文件。
- [ ] status 2/6 时下载 rewritten `body.url`
- [ ] 写回前校验目标路径属于当前 local root allowed roots。
- [ ] 写回后触发 watcher / projection 刷新或说明现有 watcher 如何感知。
- [ ] 若本轮不实现完整写回,必须在编辑入口保留 guard,不让用户以为保存已支持。
## 验收
- `cargo test -p mnote-web onlyoffice_callback -- --test-threads=1`
- `cargo test -p mnote-web -- --test-threads=1`
- 后续浏览器编辑保存验收需单独设计,不纳入 P0。
## 本轮执行记录
- 2026-05-21Reasonix worker C 分析完成。
**结论:本轮不应实现完整写回。**
**阻塞输入缺口:**
1. callback URL 当前只携带 `assetId``userId`,缺少 `rootUri``path`
- `buildCallbackUrl()``onlyoffice.rs` 页脚 JS 中硬编码只传两个参数。
- 对于 local-folder assetassetId `local:asset:path/to/file.docx`),无法从 assetId 推导 rootUri。
- 没有 rootUri 就无法 `resolve_onlyoffice_local_file_path` → 无法安全写回。
2. `adapter-onlyoffice``prepare_callback` 不感知 local-folder 路径。
- `OnlyOfficeCallbackPreparationInput` 没有 rootUri / path 字段。
- `prepare_callback` 只做下载 URL rewrite 和 session 定位,不含路径校验。
3. callback Rust handler 的 `proxy_legacy_onlyoffice_json` 目前代理 legacy Nextlocal-folder 无 legacy 时返回 NOT_IMPLEMENTED。
**实现写回所需的最小增量(下轮实现):**
- `page()` 页脚的 `buildCallbackUrl()`:检测 local asset,追加 `rootUri``path` query 参数。
- `OnlyOfficeCallbackQuery` struct:增加 `root_uri: Option<String>``path: Option<String>`
- `callback()` handler:检测到 local asset 时(assetId 含 `local:` / `local-file:` 前缀),从 query 提取 rootUri 和 path,调用 `resolve_onlyoffice_local_file_path` 校验路径在 allowed roots 内。
- `adapter-onlyoffice`:在 `OnlyOfficeCallbackPreparationInput``prepare_callback` 增加 local path 字段。
- status 2/6:下载 rewritten body.url,写回校验过的本地路径。
- status 非 2/6:返回 `{"error": 0}`,不写文件。
**当前 guard 状态:**
- Worker B 已在 `layout.rs` 中实现 `withOfficeEditModeGuard`confirm 对话框 + `data-mnote-last-office-edit-mode-requested` / `data-mnote-last-office-edit-mode-guard` 数据属性。
- 编辑入口在 local-folder writeback 未闭环时带 guard 提示,符合安全策略。
@@ -0,0 +1,51 @@
# 5-37 OnlyOffice annotation 插件噪音策略
## 目标
定位 OnlyOffice annotation / custom assistant 插件 404 或 pageerror 来源,并把它从主打开失败中剥离:能禁用则禁用,不能禁用则在测试和日志中明确为 non-critical。
## 原因
ONLYOFFICE 插件通过 `editorConfig.plugins.autostart``pluginsData` 注入。缺失插件资源会产生 404/pageerror,但不应影响主文档渲染、WebSocket 或只读预览。
## 允许修改
- `rust/crates/mnote-web/src/routes/onlyoffice.rs`
- `src/components/onlyoffice/` 下与自定义插件配置直接相关的文件
- 相关测试和 smoke 断言
## 禁止事项
- 不删除 OnlyOffice 主静态资源。
- 不把所有 console error 都静默吞掉。
- 不把主文档渲染失败降级为插件噪音。
## Checklist
- [x] 找到 annotation/custom assistant 插件配置或请求来源。
- [x] 若 MNote 当前不依赖该插件,禁用无效 autostart / pluginsData。(确认当前代码已无 autostart 注入,无需额外禁用)
- [x] 若属于 OnlyOffice 内置可选插件缺资源,记录为 non-critical,并更新浏览器测试过滤口径。
- [ ] 补测试或文档,确保 `errorCode=-18` / WebSocket 失败仍被视为失败,不被插件噪音掩盖。(P1,后续补充)
## 验收
- `cargo test -p mnote-web onlyoffice -- --test-threads=1`
- Reasonix clean browser 测试的 console/network 摘要能区分插件 404 与主打开失败。
## 本轮执行记录
- 2026-05-21Reasonix worker D 分析完成。
- **来源确认**:MNote 后端代码(`onlyoffice.rs`**没有**在 `editorConfig` 中设置 `plugins.autostart``plugins.pluginsData``MNOTE_AGENT_PLUGIN_GUID` 常量虽定义但未被任何配置引用。
- 旧的 `recycle/wolai-frontend/` 中存在设置 `autostart: [MNOTE_AGENT_PLUGIN_GUID]``pluginsData: [pluginConfigUrl]` 的代码,但那是已废弃的前端实现。
- 当前 `onlyoffice.rs``page()` 函数生成的 JS 中 `editorConfig``plugins` 字段。
- `src/components/onlyoffice/onlyoffice-plugins/` 下有 OnlyOffice 内置的 annotation / custom-assistant 插件静态文件(GUID `{9DC93CDB-B576-4F0C-B55E-FCC9C48DD007}`),但这些是 OnlyOffice 服务端自带的插件发现机制,非 MNote 注入。
- **处理策略**
1. 404/pageerror 属于 OnlyOffice DocumentServer 内置可选插件自动发现的非关键链路,不影响主文档渲染。
2. 不需要禁用代码层面的 `autostart`——因为当前代码已不注入任何插件。
3. 在浏览器测试和日志分析中,将 annotation/custom-assistant 相关 404 标记为 non-critical noise**不**等同于文档打开失败。
4. 保持 `errorCode=-18`WebSocket 连接失败)和主 iframe 空白仍视为真正失败。
- 未修改代码文件。
- 2026-05-21Reasonix 与 Codex 浏览器复核一致。
- Reasonix 结果:`tmp/reasonix-office-view-edit-plugin-2026-05-21/result.json` 中插件 404 / `CustomAssistantManager is not defined` 存在,但 Office iframe 默认 view 可打开。
- Codex 结果:`tmp/codex-office-view-edit-plugin-2026-05-21/result.json` 中同样捕获 `/sdkjs-plugins/{9DC93CDB-B576-4F0C-B55E-FCC9C48DD007}/...` 404;默认 view 与显式 edit 的主 iframe 均可建立。
- 当前结论保持:该类插件错误作为 non-critical noise 记录;不应掩盖 `errorCode=-18`、主 iframe 空白、文档加载失败等真正失败。
@@ -0,0 +1,53 @@
# 7-30 ACP session/load 恢复 checklist v1
> 状态:process
>
> OwnerACP session lifecycle
>
> 参考:`reference-code/hermes-vscode-main/src/sessionManager.ts`
## 目标
让 MNote ACP runtime 在恢复已有 ACP session 时真正调用 adapter 的 `session/load`,避免只恢复 MNote 会话历史而产生 phantom ACP session。
## 允许修改范围
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/routes/hermes_client.rs`
- 与 session load/resume 直接相关的 Rust 测试
## 禁止事项
- 不改 Page AI 前端 UI。
- 不改 Convex schema。
- 不删除或覆盖现有 local AI session JSONL 逻辑。
## Checklist
- [x]`AcpSessionManager` 增加 `load_session(session_id, cwd)` 或等价能力,参数与 Hermes VSCode 一致:`session/load { sessionId, cwd, mcpServers: [] }`
- [x] load 成功时把当前 ACP session id 设置为 loaded idload 返回 null / error 时允许 fallback 到 `session/new`
- [ ] 明确区分 MNote session id 与 ACP adapter session id;需要持久化/返回时字段名不能混淆。
- [x] ACP run 启动处优先尝试恢复已知 ACP session id,再创建新 session。
- [x] 补 mock ACP 测试覆盖 `session/load` 成功、返回 null fallback、新 session 仍可 prompt。
- [x] 更新本文件“执行记录”。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_session_manager -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web hermes_client_acp_resume -- --test-threads=1
```
## 执行记录
- 2026-05-20 22:06 — Reasonix Code 执行,Codex 复核并通过聚焦测试。
- `acp_types.rs`: 新增 `SessionLoadParams` / `SessionLoadResult` 类型。
- `acp_session_manager.rs`:
- 新增 `load_session(session_id, cwd)` → 调用 ACP `session/load`,成功时设置 internal session_idnull/error 返回 false。
- 新增 `ensure_session(cwd, stored_session_id)` → 1) 复用已有 session2) 有 stored id 时尝试 load3) fallback 到 `session/new`
- 新增 7 个单元测试覆盖所有路径:load 成功、null fallback、复用 active、无 stored 创建新 session、fallback 后 prompt 仍可工作。
- `hermes_client.rs`:
- `acp_stream_events()` 中从 payload 提取 `acpSessionId`,传给 `ensure_session()` 替代原来的 `create_session`
- 错误码从 `acp_session_create_failed` 改为 `acp_session_ensure_failed`
- Codex 复核:当前 `acpSessionId` 只从 run payload 读取,前端/session store 仍未稳定持久化并传回该字段,因此“session id 字段全链路区分与持久化”保留未完成。
- 验收:`cargo test -p mnote-web acp_session_manager -- --test-threads=1``cargo test -p mnote-web hermes_client_acp_resume -- --test-threads=1``cargo test -p mnote-web acp -- --test-threads=1` 通过。
@@ -0,0 +1,52 @@
# 7-31 ACP permission 决策闭环 checklist v1
> 状态:done
>
> OwnerACP incoming request / permission loop
>
> 参考:`reference-code/hermes-vscode-main/src/acpClient.ts`、`src/sessionManager.ts`
## 目标
`session/request_permission` 从"后端立即 unsupported/deny"改成"后端发出 pending request,前端 allow/deny,后端回写 JSON-RPC response"的真实闭环;超时仍默认 deny。
## 允许修改范围
- `rust/crates/mnote-web/src/acp_client.rs`
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/acp_bridge.rs`
- `rust/crates/mnote-web/src/routes/hermes_client.rs`
- 只允许为接线 permission 按需小改 `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 相关 Rust 测试
## 禁止事项
- 不把权限结果存为新的长期正文/附件真相。
- 不默认 allow;超时、缺失 run、缺失 pending request 都必须 deny 或返回清晰错误。
- 不大改 Page AI drawer 视觉结构。
## Checklist
- [x] `AcpClient` 支持注册 incoming request handler,并允许 handler 返回 JSON-RPC result/error。
- [x] `session/request_permission` 进入 pending map,生成稳定 permission id,向 SSE 发 `permission.requested`
- [x] 增加 HTTP endpoint 或现有 endpoint 扩展:前端可按 run id / permission id 提交 allow/deny。
- [x] 提交 allow/deny 后,ACP JSON-RPC request 得到对应 response;前端收到 `permission.allowed` / `permission.denied`
- [x] 超时默认 deny,并向 UI 和 runtime event 写明 timeout。
- [ ] 补 mock ACP 测试:incoming request 等待决策、allow 返回 result、deny 返回 result/error、timeout deny。
- [x] 更新本文件"执行记录"。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_client -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1
```
## 执行记录
- 2026-05-20Reasonix 实施,Codex 复核并修正 JSON-RPC id 保真与 permission response 语义。
- 改动点:`AcpClient` 增加 `on_incoming_request()` / `respond_to_incoming()` / `respond_to_incoming_error()``dispatch_message` 将 incoming request 转发给 handler 而非立即 error`AcpSessionManager.new()` 注册 incoming request handler,将 `session/request_permission` 存入 pending map + 发射 `permission.requested` + 启动 60s 超时自动 deny`AcpSessionManager` 增加 `resolve_permission()` 方法;`acp_bridge` SSE 转换支持 `permission.requested``hermes_client.rs` 增加 `resolve_permission` HTTP endpoint`routes/mod.rs` 注册路由;`layout.rs``pageAiResolvePermission` 调用后端。
- Codex 修正:incoming JSON-RPC request id 使用原始 `serde_json::Value` 保存和回写,避免字符串 id 被改成 0;allow/deny 正常路径改为参考 Hermes VSCode 插件的 `{ outcome: "selected", optionId }`,并补 `permission_response_selects_allow_and_deny_options` 测试。
- 未完成:已有 `acp_client` incoming request 基础测试和 option 选择 helper 测试,但还缺“mock ACP 发 permission -> HTTP resolve endpoint -> agent 收到 selected option”的端到端测试,故该 checklist 的 mock 闭环项保留未完成。
- 验收:`cargo test -p mnote-web acp_client -- --test-threads=1``cargo test -p mnote-web acp_bridge -- --test-threads=1``cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1``cargo test -p mnote-web acp -- --test-threads=1` 通过。
@@ -0,0 +1,54 @@
# 7-32 ACP tool locations 与打开动作 checklist v1
> 状态:process
>
> OwnerACP tool event data + Page AI open action
>
> 参考:`reference-code/hermes-vscode-main/src/protocol.ts`、`src/chatPanel.ts`
## 目标
保留 ACP `tool_call.locations[]`,在 Page AI tool card 中展示位置,并对 read/edit 完成事件提供打开动作。默认打开到 MNote 主编辑器资源 tab / 当前工作区 tab,不默认新浏览器窗口。
## 允许修改范围
- `rust/crates/mnote-web/src/acp_types.rs`
- `rust/crates/mnote-web/src/acp_session_manager.rs`
- `rust/crates/mnote-web/src/acp_bridge.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 相关 Rust 测试和必要 smoke 脚本
## 禁止事项
- 不重写资源 tab registry。
- 不新增与 Sidex workbench checklist 冲突的 editor group 模型。
- 不默认 `window.open`
- 不绕过 existing allowed root / local workspace access。
## Checklist
- [x] `SessionUpdate::ToolCall` 解析 `locations: [{ path }]`
- [x] `AcpSessionEvent::ToolCall` 携带 locations,并通过 SSE `tool.started` 发送。
- [x] `tool.completed` 能保留或关联此前相同 `toolCallId` 的 locations/kind/title。
- [x] Page AI tool card 展示 location 摘要。
- [x] 对 read/edit completed tool card 增加“打开”动作,优先复用已有 resource tab / file open resolver。
- [ ] 补测试覆盖 locations parse、SSE 数据、前端字符串断言或 smoke。
- [x] 更新本文件“执行记录”。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_tool -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1
```
## 执行记录
- 2026-05-20 Reasonix Code 实现:
- `acp_types.rs`: 新增 `ToolLocation` 结构体(含 `path` 字段),`SessionUpdate::ToolCall` 新增 `locations: Vec<ToolLocation>`(含反序列化支持)。
- `acp_session_manager.rs`: `AcpSessionEvent::ToolCall` 新增 `locations: Vec<String>``session_update_to_event` 中映射 locations。
- `acp_bridge.rs`: SSE `tool.started` 事件数据包含 `locations` 数组,桥测试中验证。
- `layout.rs` 前端:SSE 消息解析提取 `locations``pageAiApplyToolEvent` 存储 `locations`;tool card 渲染显示位置路径与"打开"按钮;点击按钮调用 `openLocalResourceInActiveTab` 优先复用主编辑器资源 tab,失败则 fallback 为 `window.open`
- 测试覆盖:`test_session_update_tool_call_with_locations`parse)、`acp_tool_events_keep_detail_for_collapsible_ui` 验证 SSE locations 字段、`page_ai_uses_backend_acp_session_runtime_store` 验证布局完整性。
- Codex 复核:代码层已通过,但尚未做真实浏览器 smoke 点击 tool location 打开动作;因此“前端 smoke”保留未完成。
- 验收通过:`cargo test -p mnote-web acp_bridge -- --test-threads=1``cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1``cargo test -p mnote-web acp -- --test-threads=1`
@@ -0,0 +1,49 @@
# 7-33 ACP session info / plan UI checklist v1
> 状态:process
>
> OwnerACP metadata SSE + Page AI lightweight UI
>
> 参考:`reference-code/hermes-vscode-main/src/sessionManager.ts`、`src/webview/renderers.ts`
## 目标
把已解析但被丢弃的 `session_info_update``plan` 事件送到 Page AI UI:自动标题更新当前会话;plan/todo 以轻量状态面板或系统消息展示,不混入正文真相。
## 允许修改范围
- `rust/crates/mnote-web/src/acp_bridge.rs`
- `rust/crates/mnote-web/src/routes/hermes_client.rs`
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 相关 Rust 测试
## 禁止事项
- 不改 ACP parser 类型,除非发现现有 PlanEntry 无法表达必要字段。
- 不实现完整 Phase C review session。
- 不把 plan/todo 写入页面正文。
## Checklist
- [x] `AcpSessionEvent::SessionInfoUpdate` 转成 `session.info.updated` SSE。
- [x] `AcpSessionEvent::PlanUpdate` 转成 `plan.updated` SSEdata 包含 entries。
- [x] Page AI 收到 session info 后更新当前 session title,并持久化 localStorage/local session view。
- [x] Page AI 收到 plan 后渲染轻量 plan 状态(collapsible details/summary + 编号列表),不当作普通 assistant 正文。
- [ ] tool raw output 中包含 todo JSON 时,可以复用 plan 区域或 tool card 状态显示。(风险可控,todo JSON 解析需额外确认格式约定;建议后续补)
- [x] 补 bridge 单测和 `SIDEBAR_TREE_JS` 字符串断言。
- [x] 更新本文件“执行记录”。
## 验收命令
```bash
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web acp_bridge -- --test-threads=1
cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1
```
## 执行记录
- 2026-05-20 Reasonix:桥接 SessionInfoUpdate → `session.info.updated` SSE、PlanUpdate → `plan.updated` SSE。
- 2026-05-20 Reasonix:前端 SSE handler 解析 session.info.updated 更新当前 session title 并持久化;plan.updated 在 pageAiMessages 末尾创建/更新 kind=plan 消息。
- 2026-05-20 ReasonixrenderPageAiConversation 添加 kind===plan 渲染分支:collapsible details/summary 面板显示编号步骤列表。
- 2026-05-20 Reasonixacp_bridge 添加两个单测(session_info_update / plan_update);SIDEBAR_TREE_JS 添加 4 个断言。
- 2026-05-20 Codex 复核:todo JSON 格式尚未确认,保留为未完成项;其余已通过 `cargo test -p mnote-web acp_bridge -- --test-threads=1``cargo test -p mnote-web page_ai_uses_backend_acp_session_runtime_store -- --test-threads=1``cargo test -p mnote-web acp -- --test-threads=1`
@@ -0,0 +1,99 @@
# 13 Hermes VSCode 插件对照审查 v1
> 状态:process
>
> 日期:2026-05-20
>
> 范围:`reference-code/hermes-vscode-main` 对照 MNote 当前 `mnote-web` Hermes / ACP / Page AI 实现。
## 1. 审查方法
本轮先以 Hermes VSCode 插件为参考实现,核查它对 ACP runtime、会话恢复、权限请求、tool call、上下文注入和 Webview 控制面的处理方式,再对照 MNote 当前 Rust Web 主链:
- 参考实现:`src/acpClient.ts``src/sessionManager.ts``src/protocol.ts``src/chatPanel.ts``src/sessionStore.ts``src/webview/main.ts``src/webview/renderers.ts``src/modelCatalog.ts``src/skillCatalog.ts`
- MNote 实现:`rust/crates/mnote-web/src/acp_client.rs``acp_session_manager.rs``acp_bridge.rs``routes/hermes_client.rs``ssr/pages/layout.rs`
- 审查口径:不把 VSCode UI 状态照搬为 MNote 真相源;MNote 继续遵守 local-first、Rust Web 承载、Page AI 文件引用 + agent 原生 patch/diff + watcher 同步主线。
## 2. Hermes VSCode 可借鉴模型
- `SessionManager.ensureSession()` 在已有 `storedSessionId` 时必须调用 ACP `session/load`。仅把旧 session id 写回内存会产生 phantom session,后续 `session/prompt` 可能静默失败。
- `AcpClient` 区分 JSON-RPC response、notification 和 incoming request`session/request_permission` 通过 handler 返回真实 allow / deny 结果。
- `session/update` 中的 `tool_call.locations[]` 会被解析保存;`tool_call_update completed` 后,`read` 打开 preview editor`edit` 打开 persistent editor。
- `session_info_update` 会更新会话标题;`plan` / todo tool 输出可渲染为任务 overlay,而不是丢弃。
- Webview 对 slash command 有 allowlistslash 响应渲染为居中的 system message,不混入普通 user / assistant 泡泡。
- model / skill picker 来自 Hermes 本地缓存和 `SKILL.md` 目录,作为 agent 控制面的一部分。
- Prompt 注入包含 cwd、active file、selection、open tabs、附件引用和 skills advisory,不把这些上下文当成长期正文真相。
## 3. MNote 当前状态
- Rust `AcpSessionManager` 已有 `session/new``session/prompt``session/cancel`、streaming dedup、typed event 和 usage / thought / tool / plan / session info 解析雏形。
- `acp_bridge.rs` 已把 TextDelta、ThoughtDelta、ToolCall、ToolCallUpdate、UsageUpdate、PermissionRequest 转成 SSE,但 `SessionInfoUpdate``PlanUpdate` 当前仍被丢弃。
- `layout.rs` Page AI 已有 session list/detail/search/resume/rename/delete、localStorage cache、Convex/local ACP runtime store、permission dialog、tool event UI、profile/skills 控制面和 usage summary。
- `routes/hermes_client.rs` 已有 local-first AI session JSONL、Convex ACP runtime store、run queue、abort 和 local agent audit。
- 当前 `resume_session` 只恢复 MNote/Convex/local 会话历史,不等同于对 ACP adapter 调用 `session/load` 注册底层 ACP session。
- 当前 `AcpClient` 对 incoming `session/request_permission` 仍立即返回 unsupported/deny,前端按钮没有真正回传 ACP 决策。
- 当前 `SessionUpdate::ToolCall` 类型未保留 `locations`,因此无法像 Hermes VSCode 一样把 read/edit 工具结果联动到主编辑器 tab。
## 4. P0 缺口
### P0-1 ACP `session/load` 恢复缺失
MNote 有会话列表、恢复按钮和 runtime store,但恢复更多是 MNote 会话历史层的恢复。对 ACP runtime 来说,若没有对 adapter 调用 `session/load { sessionId, cwd, mcpServers: [] }`,后续 prompt 可能对应不到 agent 侧真实 session。
建议:在 `AcpSessionManager` 增加 stored ACP session id / `load_session` / `ensure_session` 能力;在 `hermes_client` 的 ACP run 启动处尽量使用已保存 ACP session id 进行 load,失败再 `session/new`
### P0-2 权限请求缺少真实 allow / deny 闭环
前端存在 permission dialog,但后端 `AcpClient` 对 incoming request 立即返回 JSON-RPC error,再广播 denied。用户点击允许/拒绝不能影响当前 ACP 请求。
建议:`AcpClient` 增加 incoming request handler`AcpSessionManager``session/request_permission` 转成 pending request`routes/hermes_client` 提供按 run/session/permission id 决策的短生命周期接口;超时默认 deny。
### P0-3 tool `locations[]` 与 read/edit 打开联动缺失
Hermes VSCode 会保存 tool locations,并在 read/edit 完成后打开对应文件。MNote 当前 tool SSE 只有 raw input / output,不保留 `locations`,也没有把 tool 完成联动到 main editor resource tab / file tree target。
建议:扩展 ACP 类型、event 和 SSE 数据,保留 `locations`;前端对 completed read/edit 工具提供“打开”动作,优先复用现有 main editor resource tab,不默认新浏览器窗口。
### P0-4 `session_info_update` / `plan` 被桥接层丢弃
MNote 已在 `AcpSessionManager` 解析 `SessionInfoUpdate``PlanUpdate`,但 `acp_bridge` 当前返回 `None`。会话自动标题、agent plan/todo 不能进入 UI,也不能进入 runtime event 记录。
建议:转发 `session.info.updated``plan.updated` SSE;前端更新当前会话标题,并将 plan/todo 作为轻量状态面板或 tool/system message 渲染。
## 5. P1 缺口
- slash command 响应仍更像普通聊天消息,缺少参考实现中的 system message 视觉语义和 allowlist 明确边界。
- skills / model 控制面已存在,但未完全对齐 Hermes 本地 model cache 与 `SKILL.md` 目录;需要确认是否存在过期 profile / skill 混用。
- busy queue / interrupt cancel 已有队列和 stop,但与参考实现“第二条消息排队、interrupt 立即 unblock”的交互语义仍需 smoke 覆盖。
- Page AI session restore 需要区分 MNote session id、ACP adapter session id、run id,避免 UI 把任意历史 session 当作可继续底层 ACP 对话。
- tool raw output 中的 todo JSON 还没有独立 overlay,计划状态只能混在 tool card 中。
## 6. P2 缺口
- Webview markdown rendering / sanitization 参考实现使用 `marked` + `DOMPurify`MNote 当前对 assistant 消息仍偏纯文本逃逸,后续可评估富文本安全渲染。
- active file / selection / open tabs 上下文注入需要继续与 MNote editor group / resource tab 模型对齐。
- Hermes 插件中 attachments 以 `[Referenced file: path]` 进入 promptMNote local-first 已有文件 scope,但附件引用提示仍可更清晰。
- session store 的 max 20 sessions / max 300 messages 可作为 MNote localStorage/local JSONL 裁剪策略参考。
## 7. 不建议照搬
- 不照搬 VSCode Webview 的 DOM/CSS 结构;MNote 仍使用 Wolai 主题 Page AI drawer。
- 不把 VSCode `workspaceState` 当作 MNote 会话真相;local-first 下本地 JSONL / Convex control-plane / runtime registry 各自职责不变。
- 不把 Hermes 插件中的自动打开文件行为做成不可控跳转;MNote 应默认在 main editor resource tab 打开,并保留显式新窗口入口。
- 不在 UI 层新增第二套文件权限真相;权限和 allowed roots 仍由 Rust Web / local-first scope 决定。
- 不把 Page AI block-edit workflow 扩成普通 Markdown 主路径;local folder 继续走文件引用 + agent 原生 patch/diff + watcher 同步。
## 8. 执行拆分
本 review 先拆四份 P0 checklist,允许并行推进:
- `design/07-ai/process/7-30-acp-session-load-resume-checklist-v1.md`
- `design/07-ai/process/7-31-acp-permission-decision-loop-checklist-v1.md`
- `design/07-ai/process/7-32-acp-tool-location-and-open-action-checklist-v1.md`
- `design/07-ai/process/7-33-acp-session-info-plan-ui-checklist-v1.md`
P0 收口后再拆 P1
- slash/system message 与 model/skill 控制面收口。
- queue / interrupt cancel browser smoke。
- assistant markdown 安全渲染评估。
+2
View File
@@ -1535,6 +1535,8 @@ dependencies = [
"comrak",
"core-protocol",
"futures-util",
"hyper",
"hyper-util",
"leptos",
"mnote-editor-core",
"notify",
+2
View File
@@ -11,6 +11,8 @@ axum = { version = "0.8", features = ["multipart", "ws"] }
bridge-runtime = { path = "../bridge-runtime" }
core-protocol = { path = "../core-protocol" }
futures-util = "0.3"
hyper = "1"
hyper-util = { version = "0.1", features = ["tokio"] }
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
mnote-editor-core = { path = "../mnote-editor-core" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] }
+86 -12
View File
@@ -196,6 +196,7 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
kind,
status,
raw_input,
locations,
} => Some(SseEvent {
event: "tool.started".into(),
data: json!({
@@ -204,6 +205,7 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
"kind": kind,
"status": status,
"input": raw_input,
"locations": locations,
}),
}),
AcpSessionEvent::ToolCallUpdate {
@@ -238,25 +240,30 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
tool_name,
params,
decision,
} => Some(SseEvent {
event: if decision == "allowed" {
"permission.allowed".into()
} else {
"permission.denied".into()
},
} => {
let event = match decision.as_str() {
"allowed" => "permission.allowed",
"requested" => "permission.requested",
_ => "permission.denied",
};
Some(SseEvent {
event: event.into(),
data: json!({
"permissionId": permission_id,
"toolName": tool_name,
"params": params,
"decision": decision,
}),
})
}
AcpSessionEvent::SessionInfoUpdate { title } => Some(SseEvent {
event: "session.info.updated".into(),
data: json!({ "title": title }),
}),
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
event: "plan.updated".into(),
data: json!({ "entries": entries }),
}),
AcpSessionEvent::SessionInfoUpdate { .. } => {
None // Not forwarded to frontend
}
AcpSessionEvent::PlanUpdate { .. } => {
None // Not forwarded (Phase C)
}
AcpSessionEvent::Disconnected { reason } if reason == "session closed" => None,
AcpSessionEvent::Disconnected { reason } => Some(SseEvent {
event: "run.failed".into(),
@@ -327,6 +334,37 @@ mod tests {
assert_eq!(sse.data["decision"], "denied");
}
#[test]
fn acp_permission_requested_emits_permission_requested_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_2".into(),
tool_name: "mnote.page.get".into(),
params: json!({"documentId": "doc_2"}),
decision: "requested".into(),
};
let sse = acp_event_to_sse(event).expect("permission requested should be forwarded");
assert_eq!(sse.event, "permission.requested");
assert_eq!(sse.data["permissionId"], "perm_2");
assert_eq!(sse.data["toolName"], "mnote.page.get");
assert_eq!(sse.data["decision"], "requested");
}
#[test]
fn acp_permission_allowed_emits_permission_allowed_event() {
let event = AcpSessionEvent::PermissionRequest {
permission_id: "perm_3".into(),
tool_name: "mnote.page.save".into(),
params: json!({"documentId": "doc_1"}),
decision: "allowed".into(),
};
let sse = acp_event_to_sse(event).expect("permission allowed should be forwarded");
assert_eq!(sse.event, "permission.allowed");
assert_eq!(sse.data["permissionId"], "perm_3");
assert_eq!(sse.data["decision"], "allowed");
}
#[test]
fn acp_tool_events_keep_detail_for_collapsible_ui() {
let started = acp_event_to_sse(AcpSessionEvent::ToolCall {
@@ -335,12 +373,18 @@ mod tests {
kind: "read".into(),
status: crate::acp_types::ToolCallStatus::InProgress,
raw_input: Some(json!({"documentId": "doc_1", "includeBody": true})),
locations: vec!["/mnt/Data1T/mnote/src/main.rs".into()],
})
.expect("tool start");
assert_eq!(started.event, "tool.started");
assert_eq!(started.data["tool"], "mnote.page.get");
assert_eq!(started.data["status"], "in_progress");
assert_eq!(started.data["input"]["documentId"], "doc_1");
assert_eq!(
started.data["locations"][0],
"/mnt/Data1T/mnote/src/main.rs"
);
assert_eq!(started.data["locations"].as_array().unwrap().len(), 1);
let completed = acp_event_to_sse(AcpSessionEvent::ToolCallUpdate {
tool_call_id: "tool_1".into(),
@@ -367,4 +411,34 @@ mod tests {
assert_eq!(running.event, "tool.started");
assert_eq!(running.data["status"], "in_progress");
}
#[test]
fn acp_session_info_update_emits_session_info_updated_sse() {
let sse = acp_event_to_sse(AcpSessionEvent::SessionInfoUpdate {
title: "我的新会话标题".into(),
})
.expect("session info update should be forwarded");
assert_eq!(sse.event, "session.info.updated");
assert_eq!(sse.data["title"], "我的新会话标题");
}
#[test]
fn acp_plan_update_emits_plan_updated_sse() {
let entries = vec![
"步骤 1:读取文件".into(),
"步骤 2:修改配置".into(),
"步骤 3:验证更改".into(),
];
let sse = acp_event_to_sse(AcpSessionEvent::PlanUpdate {
entries: entries.clone(),
})
.expect("plan update should be forwarded");
assert_eq!(sse.event, "plan.updated");
let sse_entries: Vec<String> =
serde_json::from_value(sse.data["entries"].clone()).unwrap_or_default();
assert_eq!(sse_entries.len(), 3);
assert_eq!(sse_entries[0], "步骤 1:读取文件");
assert_eq!(sse_entries[1], "步骤 2:修改配置");
assert_eq!(sse_entries[2], "步骤 3:验证更改");
}
}
+158 -18
View File
@@ -80,6 +80,17 @@ type NotificationHandler = Box<dyn Fn(String, Value) + Send + 'static>;
/// Thread-safe mutex for notification handler (std mutex — lightweight, never held across awaits).
type NotificationHandlerMutex = std::sync::Mutex<Option<NotificationHandler>>;
// ── Incoming request handler type ────────────────────
///
/// agent 发送 JSON-RPC request(同时包含 `id` 与 `method`)时调用。
/// 返回 `true` 表示 handler 已负责稍后响应;返回 `false` 则由 dispatch_message
/// 直接回复 method-not-found。handler 应通过 [`AcpClient::respond_to_incoming`]
/// 或 [`AcpClient::respond_to_incoming_error`] 回写响应。
type IncomingRequestHandler = Box<dyn Fn(Value, String, Value) -> bool + Send + 'static>;
/// incoming request handler 的线程安全容器。
type IncomingRequestHandlerMutex = std::sync::Mutex<Option<IncomingRequestHandler>>;
// ── Pending request entry ────────────────────────────
type PendingEntry = oneshot::Sender<Result<Value, AcpError>>;
@@ -108,6 +119,9 @@ pub struct AcpClient {
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
next_id: AtomicU64,
notification_handler: Arc<NotificationHandlerMutex>,
/// agent 发来的 incoming JSON-RPC request handler(同时有 id 和 method)。
/// 未设置时会直接回复 method-not-found。
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
}
impl AcpClient {
@@ -162,14 +176,24 @@ impl AcpClient {
let pending: Arc<Mutex<HashMap<u64, PendingEntry>>> = Arc::new(Mutex::new(HashMap::new()));
let notification_handler: Arc<NotificationHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
let incoming_request_handler: Arc<IncomingRequestHandlerMutex> =
Arc::new(std::sync::Mutex::new(None));
// Start background reader task
let pending_clone = pending.clone();
let handler_clone = notification_handler.clone();
let incoming_clone = incoming_request_handler.clone();
let writer_clone = writer.clone();
let child_pid = child.id().unwrap_or(0);
tokio::spawn(async move {
Self::reader_loop(reader, writer_clone, pending_clone, handler_clone).await;
Self::reader_loop(
reader,
writer_clone,
pending_clone,
handler_clone,
incoming_clone,
)
.await;
info!("ACP reader loop ended (pid={})", child_pid);
});
@@ -179,6 +203,7 @@ impl AcpClient {
pending,
next_id: AtomicU64::new(1),
notification_handler,
incoming_request_handler,
};
// Handshake: initialize (reference: acpClient.ts L108 → call('initialize', {protocolVersion: 1}))
@@ -272,6 +297,53 @@ impl AcpClient {
*guard = Some(Box::new(handler));
}
/// 注册 incoming JSON-RPC request handler(消息同时包含 `id` 与 `method`)。
/// handler 接收原始 request id、method 和 params,并应稍后通过
/// [`respond_to_incoming`] 或 [`respond_to_incoming_error`] 响应。
/// 同一时间只保留一个 handler,后续注册会覆盖前一个。
pub fn on_incoming_request<F>(&self, handler: F)
where
F: Fn(Value, String, Value) -> bool + Send + 'static,
{
let mut guard = self.incoming_request_handler.lock().unwrap();
*guard = Some(Box::new(handler));
}
/// 用 result 响应 agent 发来的 incoming JSON-RPC request。
///
/// 必须使用 incoming request handler 收到的原始 `id`,避免丢失字符串 id。
pub async fn respond_to_incoming(&self, id: Value, result: Value) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"result": result,
});
debug!("ACP <-- respond to incoming #{}", id);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// 用 error 响应 incoming JSON-RPC request。
pub async fn respond_to_incoming_error(
&self,
id: Value,
code: i64,
message: &str,
) -> Result<(), AcpError> {
let msg = json!({
"jsonrpc": "2.0",
"id": id.clone(),
"error": {
"code": code,
"message": message,
},
});
debug!(
"ACP <-- respond error to incoming #{}: [{}] {}",
id, code, message
);
Self::write_jsonrpc_message(&self.writer, &msg).await
}
/// Gracefully close the ACP connection and kill the subprocess.
pub async fn close(&mut self) {
if let Some(mut child) = self.child.take() {
@@ -296,6 +368,7 @@ impl AcpClient {
writer: Arc<Mutex<BufWriter<ChildStdin>>>,
pending: Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: Arc<NotificationHandlerMutex>,
incoming_request_handler: Arc<IncomingRequestHandlerMutex>,
) {
let mut line_buf = String::new();
loop {
@@ -328,7 +401,14 @@ impl AcpClient {
}
};
Self::dispatch_message(msg, &writer, &pending, &notification_handler).await;
Self::dispatch_message(
msg,
&writer,
&pending,
&notification_handler,
&incoming_request_handler,
)
.await;
}
// Process died or EOF — resolve all pending
@@ -346,6 +426,7 @@ impl AcpClient {
writer: &Arc<Mutex<BufWriter<ChildStdin>>>,
pending: &Arc<Mutex<HashMap<u64, PendingEntry>>>,
notification_handler: &Arc<NotificationHandlerMutex>,
incoming_request_handler: &Arc<IncomingRequestHandlerMutex>,
) {
let has_id = msg.get("id").is_some();
let has_method = msg
@@ -355,15 +436,29 @@ impl AcpClient {
.unwrap_or(false);
if has_id && has_method {
// Incoming request from agent (e.g. session/request_permission)
// agent 发来的 incoming request,例如 session/request_permission
let method = msg["method"].as_str().unwrap_or("unknown").to_string();
let params = msg.get("params").cloned().unwrap_or(Value::Null);
let id = msg.get("id").cloned().unwrap_or(Value::Null);
// 当前还没有权限确认 UI,必须明确拒绝,避免 agent 等待到超时。
warn!("ACP incoming request not handled: {method} (params={params:?})");
let id_val = msg.get("id").cloned().unwrap_or(Value::Null);
// 若已注册 handler,则由 handler 决定是否负责稍后响应。
let handled = {
let handler_guard = incoming_request_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(id_val.clone(), method.clone(), params.clone())
} else {
false
}
};
if handled {
debug!("ACP incoming request dispatched: {method} #{}", id_val);
} else {
// 没有 handler 时必须立即响应,避免 agent 一直等待。
warn!("ACP incoming request not handled (no handler registered): {method}");
let response = json!({
"jsonrpc": "2.0",
"id": id,
"id": id_val,
"error": {
"code": -32601,
"message": format!("ACP incoming request not supported: {method}")
@@ -372,17 +467,6 @@ impl AcpClient {
if let Err(error) = Self::write_jsonrpc_message(writer, &response).await {
warn!("ACP incoming request response write failed: {error}");
}
if method == "session/request_permission" {
let mut event_params = params;
if let Some(object) = event_params.as_object_mut() {
object.insert("decision".into(), Value::String("denied".into()));
object.insert("method".into(), Value::String(method.clone()));
object.insert("jsonrpcId".into(), id);
}
let handler_guard = notification_handler.lock().unwrap();
if let Some(ref handler) = *handler_guard {
handler(method, event_params);
}
}
} else if has_id {
// Response to one of our requests
@@ -528,6 +612,48 @@ rl.on('line', (line) => {
.expect("spawn permission mock ACP")
}
async fn spawn_string_id_permission_request_mock_server() -> AcpClient {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
let permissionResponse = null;
const rl = readline.createInterface({ input, output, terminal: false });
function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.id !== undefined && msg.method === 'initialize') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
});
setTimeout(() => send({
jsonrpc: '2.0',
id: 'perm-string-id',
method: 'session/request_permission',
params: { reason: 'test permission' }
}), 10);
} else if (msg.id === 'perm-string-id' && msg.method === undefined) {
permissionResponse = msg;
} else if (msg.id !== undefined && msg.method === 'get_permission_response') {
send({
jsonrpc: '2.0',
id: msg.id,
result: { permissionResponse }
});
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_permission_request_string_id_mock.mjs");
std::fs::write(&script_path, script).expect("write permission string id mock script");
AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission string id mock ACP")
}
#[tokio::test]
async fn test_request_response() {
let client = spawn_mock_acp_server().await;
@@ -603,4 +729,18 @@ rl.on('line', (line) => {
assert_eq!(response["id"], 77);
assert!(response.get("result").is_some() || response.get("error").is_some());
}
#[tokio::test]
async fn test_incoming_permission_request_preserves_string_id() {
let client = spawn_string_id_permission_request_mock_server().await;
tokio::time::sleep(Duration::from_millis(100)).await;
let result: Value = client
.request("get_permission_response", json!({}))
.await
.expect("permission response probe");
let response = &result["permissionResponse"];
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], "perm-string-id");
assert!(response.get("result").is_some() || response.get("error").is_some());
}
}
+632 -25
View File
@@ -8,16 +8,16 @@
/// - `reference-code/hermes-vscode-main/src/protocol.ts` (dedup logic)
use crate::acp_client::AcpClient;
use crate::acp_types::{
ContentBlock, ContentBlockWrapper, SessionNewParams, SessionNewResult, SessionPromptParams,
SessionPromptResult, SessionUpdate, ToolCallStatus,
ContentBlock, ContentBlockWrapper, SessionLoadParams, SessionNewParams, SessionNewResult,
SessionPromptParams, SessionPromptResult, SessionUpdate, ToolCallStatus,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, info, warn};
#[cfg(test)]
use tokio::time::{sleep, Duration};
// ── Events ───────────────────────────────────────────
/// Strongly-typed event emitted by the session manager when a `session/update` arrives.
@@ -27,34 +27,36 @@ pub enum AcpSessionEvent {
TextDelta { text: String },
/// Streaming reasoning/thinking text.
ThoughtDelta { text: String },
/// Tool call started.
/// 工具调用开始。
ToolCall {
tool_call_id: String,
title: String,
kind: String,
status: ToolCallStatus,
raw_input: Option<Value>,
/// 工具涉及的文件路径。
locations: Vec<String>,
},
/// Tool call status update (with optional result content).
/// 工具调用状态更新,可能携带结果内容。
ToolCallUpdate {
tool_call_id: String,
status: ToolCallStatus,
content: Option<Vec<ContentBlockWrapper>>,
},
/// Context usage update.
/// 上下文用量更新。
UsageUpdate { used: u64, size: u64 },
/// Agent 发起权限请求;当前 runtime 会自动拒绝并同步给前端
/// Agent 发起权限请求。
PermissionRequest {
permission_id: String,
tool_name: String,
params: Value,
decision: String,
},
/// Session metadata update (e.g. auto-title).
/// 会话元数据更新,例如自动标题。
SessionInfoUpdate { title: String },
/// Plan entries update.
/// 计划条目更新。
PlanUpdate { entries: Vec<String> },
/// Connection closed/error.
/// 连接关闭或异常。
Disconnected { reason: String },
}
@@ -81,6 +83,19 @@ pub enum SessionState {
Closed,
}
// ── Pending permission ───────────────────────────────
/// 等待前端决策的 `session/request_permission`。
#[derive(Debug, Clone)]
pub struct PendingPermission {
/// incoming ACP request 的原始 JSON-RPC id,可能是数字或字符串。
pub jsonrpc_id: Value,
/// `session/request_permission` 的原始参数。
pub params: Value,
/// 请求创建时间,用于后续超时判断。
pub created_at: std::time::Instant,
}
// ── AcpSessionManager ────────────────────────────────
/// Manages ACP sessions — create, prompt, cancel, and event dispatch.
@@ -97,6 +112,8 @@ pub struct AcpSessionManager {
accumulated: Arc<Mutex<String>>,
/// Whether we're currently inside a prompt (for dedup gating).
in_prompt: Arc<Mutex<bool>>,
/// Pending permission requests keyed by permission_id.
pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>>,
}
impl AcpSessionManager {
@@ -110,23 +127,31 @@ impl AcpSessionManager {
let event_handler: Arc<Mutex<Option<SessionEventHandler>>> = Arc::new(Mutex::new(None));
let accumulated: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let in_prompt: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
let pending_permissions: Arc<Mutex<HashMap<String, PendingPermission>>> =
Arc::new(Mutex::new(HashMap::new()));
// Wire up the ACP notification handler
let session_id_clone = session_id.clone();
let event_handler_clone = event_handler.clone();
let accumulated_clone = accumulated.clone();
let in_prompt_clone = in_prompt.clone();
// ── Incoming request handler ──────────────────
// 处理 `session/request_permission`:先进入 pending,再由 HTTP
// resolve-permission 端点决定最终响应。
let event_handler_for_incoming = event_handler.clone();
let pending_for_incoming = pending_permissions.clone();
let client_for_incoming = client.clone();
client.on_incoming_request(move |id, method, params| {
if method != "session/request_permission" {
// 未知方法交回 dispatch_message 回复 method-not-found。
return false;
}
client.on_notification(move |method, params| {
if method == "session/request_permission" {
// 优先使用 agent 传来的 permission id;缺失时用 JSON-RPC id 派生稳定 id。
let permission_id = params
.get("permissionId")
.or_else(|| params.get("permission_id"))
.or_else(|| params.get("requestId"))
.or_else(|| params.get("id"))
.and_then(Value::as_str)
.unwrap_or("permission_auto_denied")
.to_string();
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("acp_perm_{}", id));
let tool_name = params
.get("toolName")
.or_else(|| params.get("tool"))
@@ -135,17 +160,92 @@ impl AcpSessionManager {
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
let handler = event_handler_clone.lock().unwrap();
// 记录到 pending map,等待前端 allow / deny。
{
let mut pending = pending_for_incoming.lock().unwrap();
pending.insert(
permission_id.clone(),
PendingPermission {
jsonrpc_id: id.clone(),
params: params.clone(),
created_at: std::time::Instant::now(),
},
);
debug!(
"ACP permission pending: {} (jsonrpc_id={}, tool={})",
permission_id, id, tool_name
);
}
// 向前端发出 `permission.requested`。
{
let handler = event_handler_for_incoming.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id,
permission_id: permission_id.clone(),
tool_name,
params,
decision: "requested".into(),
});
}
}
// 60 秒没有决策时自动 deny,避免 agent 永久等待。
let pending_for_timeout = pending_for_incoming.clone();
let event_handler_for_timeout = event_handler_for_incoming.clone();
let client_for_timeout = client_for_incoming.clone();
let timeout_permission_id = permission_id.clone();
let timeout_jsonrpc_id = id.clone();
tokio::spawn(async move {
sleep(Duration::from_secs(60)).await;
let should_deny = {
let mut pending = pending_for_timeout.lock().unwrap();
if pending.remove(&timeout_permission_id).is_some() {
true
} else {
false
}
};
if should_deny {
warn!(
"ACP permission timeout: {} (jsonrpc_id={}), auto-denying",
timeout_permission_id, timeout_jsonrpc_id
);
// 向 ACP 回复 permission denied。
let _ = client_for_timeout
.respond_to_incoming_error(
timeout_jsonrpc_id.clone(),
-32000,
"permission denied by timeout",
)
.await;
// 同步 denied 事件给前端。
let handler = event_handler_for_timeout.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id: timeout_permission_id,
tool_name: "session/request_permission".into(),
params: Value::Null,
decision: "denied".into(),
});
}
return;
}
});
true // handler took responsibility for responding
});
// ── Notification handler ──────────────────────
// Only handles `session/update` (streaming events from agent).
// Incoming request methods like `session/request_permission` are handled
// above via `on_incoming_request`.
let session_id_clone = session_id.clone();
let event_handler_clone = event_handler.clone();
let accumulated_clone = accumulated.clone();
let in_prompt_clone = in_prompt.clone();
client.on_notification(move |method, params| {
if method != "session/update" {
return;
}
@@ -187,6 +287,7 @@ impl AcpSessionManager {
event_handler,
accumulated,
in_prompt,
pending_permissions,
}
}
@@ -200,6 +301,92 @@ impl AcpSessionManager {
*guard = Some(Arc::new(handler));
}
/// 按用户决策解析 pending permission。
///
/// 根据 `permission_id` 找到 pending request,向 ACP 子进程回写 result/error
/// 发出 `permission.allowed` / `permission.denied` 事件,并从 pending map 移除。
///
/// permission 存在且完成响应时返回 `Ok(())`,否则返回错误原因。
pub async fn resolve_permission(
&self,
permission_id: &str,
decision: &str,
) -> Result<(), String> {
let pending = {
let mut map = self.pending_permissions.lock().unwrap();
map.remove(permission_id)
};
let pending = pending.ok_or_else(|| {
format!("pending permission not found: {permission_id} (already timed out or invalid)")
})?;
let tool_name = pending
.params
.get("toolName")
.or_else(|| pending.params.get("tool"))
.or_else(|| pending.params.get("name"))
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
let normalized_decision = match decision {
"allow" | "allowed" => "allow",
"deny" | "denied" => "deny",
other => return Err(format!("unknown decision: {other} (expected allow/deny)")),
};
let response = permission_response_for_decision(&pending.params, normalized_decision)
.unwrap_or_else(|| {
if normalized_decision == "allow" {
Err((-32000, "permission allow option not available".into()))
} else {
Err((-32000, "permission denied by user".into()))
}
});
match response {
Ok(result) => {
self.client
.respond_to_incoming(pending.jsonrpc_id.clone(), result)
.await
.map_err(|e| format!("ACP respond failed: {e}"))?;
info!(
"ACP permission resolved: {} decision={} (jsonrpc_id={})",
permission_id, normalized_decision, pending.jsonrpc_id
);
}
Err((code, message)) => {
self.client
.respond_to_incoming_error(pending.jsonrpc_id.clone(), code, &message)
.await
.map_err(|e| format!("ACP respond failed: {e}"))?;
info!(
"ACP permission rejected: {} decision={} (jsonrpc_id={})",
permission_id, normalized_decision, pending.jsonrpc_id
);
}
}
// 发出最终 decision 事件。
{
let handler = self.event_handler.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id: permission_id.to_string(),
tool_name,
params: pending.params,
decision: if normalized_decision == "allow" {
"allowed".into()
} else {
"denied".into()
},
});
}
}
Ok(())
}
/// Create a new ACP session.
///
/// Sends `session/new` to the agent and stores the returned `sessionId`.
@@ -228,6 +415,108 @@ impl AcpSessionManager {
Ok(result.session_id)
}
/// Load/resume an existing ACP session.
///
/// Sends `session/load { sessionId, cwd, mcpServers: [] }` to the agent.
/// Returns `true` if the session was loaded and the internal session_id updated,
/// `false` if the adapter returned null (session not found) or doesn't support the method.
///
/// On success the internal `session_id` is set to the loaded id.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()`
pub async fn load_session(
&self,
session_id: &str,
cwd: Option<&str>,
) -> Result<bool, crate::acp_client::AcpError> {
let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.unwrap_or(std::path::Path::new("/mnt/Data1T/mnote"))
.to_string_lossy()
.to_string();
let params = SessionLoadParams {
session_id: session_id.to_string(),
cwd: Some(cwd.map(str::to_string).unwrap_or(project_root)),
mcp_servers: Some(Vec::new()),
};
match self
.client
.request::<_, serde_json::Value>("session/load", params)
.await
{
Ok(result) => {
// Successful load: adapter returned { sessionId: "..." }
if let Some(sid) = result.get("sessionId").and_then(|v| v.as_str()) {
if !sid.is_empty() {
let mut sid_guard = self.session_id.lock().unwrap();
*sid_guard = Some(sid.to_string());
info!("ACP session loaded: {}", sid);
return Ok(true);
}
}
// Null or missing sessionId → session not found on adapter
info!(
"ACP session/load returned null for session_id={}",
session_id
);
Ok(false)
}
Err(crate::acp_client::AcpError::JsonRpc { code, message }) => {
// Adapter doesn't support session/load or session expired
info!(
"ACP session/load not supported (code={code}, message={message}), will fallback"
);
Ok(false)
}
Err(e) => {
warn!("ACP session/load transport error: {e}");
Err(e)
}
}
}
/// Ensure an active ACP session exists.
///
/// 1. If there's already an active session, return it immediately.
/// 2. If a `stored_session_id` is provided, attempt `session/load` first.
/// 3. Fall back to `session/new`.
///
/// Reference: `hermes-vscode-main/src/sessionManager.ts` `ensureSession()`
pub async fn ensure_session(
&self,
cwd: Option<&str>,
stored_session_id: Option<&str>,
) -> Result<String, crate::acp_client::AcpError> {
// 1. Reuse active session if one exists
{
let sid = self.session_id.lock().unwrap();
if let Some(ref sid) = *sid {
debug!("ACP ensure_session: reusing active session {}", sid);
return Ok(sid.clone());
}
}
// 2. Try to load a stored session
if let Some(stored) = stored_session_id {
if !stored.is_empty() {
debug!("ACP ensure_session: attempting session/load for {}", stored);
if self.load_session(stored, cwd).await.unwrap_or(false) {
return Ok(stored.to_string());
}
info!(
"ACP ensure_session: stored session {} not found, creating new",
stored
);
}
}
// 3. Fallback: create new session
debug!("ACP ensure_session: creating new session");
self.create_session(cwd, None).await
}
/// Send a prompt to the agent and stream events.
///
/// The `prompt` is a list of content blocks (text + optional page context).
@@ -424,6 +713,7 @@ impl AcpSessionManager {
kind,
status,
raw_input,
locations,
..
} => {
let title = title.clone().unwrap_or_else(|| "tool".into());
@@ -432,12 +722,14 @@ impl AcpSessionManager {
None => "other".into(),
};
let status = status.clone().unwrap_or(ToolCallStatus::Pending);
let locations: Vec<String> = locations.iter().map(|l| l.path.clone()).collect();
Some(AcpSessionEvent::ToolCall {
tool_call_id: tool_call_id.clone(),
title,
kind: kind_str,
status,
raw_input: raw_input.clone(),
locations,
})
}
@@ -479,6 +771,76 @@ impl AcpSessionManager {
}
}
fn permission_response_for_decision(
params: &Value,
decision: &str,
) -> Option<Result<Value, (i64, String)>> {
let option_id = permission_option_id_by_decision(params, decision);
match (decision, option_id) {
("allow", Some(option_id)) | ("deny", Some(option_id)) => Some(Ok(json!({
"outcome": "selected",
"optionId": option_id
}))),
("deny", None) => Some(Err((-32000, "permission denied by user".into()))),
_ => None,
}
}
fn permission_option_id_by_decision(params: &Value, decision: &str) -> Option<String> {
let options = params.get("options").and_then(Value::as_array)?;
let preferred: &[&str] = if decision == "allow" {
&["allow_once", "allow", "approve", "yes"]
} else {
&["deny_once", "reject_once", "deny", "reject", "no"]
};
for keyword in preferred.iter().copied() {
if let Some(option_id) = options.iter().find_map(|option| {
let id = permission_option_id(option)?;
let haystack = format!(
"{} {}",
id.to_ascii_lowercase(),
option
.get("kind")
.and_then(Value::as_str)
.unwrap_or("")
.to_ascii_lowercase()
);
if haystack.contains(keyword) {
Some(id)
} else {
None
}
}) {
return Some(option_id);
}
}
if decision == "allow" {
return options.iter().find_map(|option| {
let id = permission_option_id(option)?;
let lower = id.to_ascii_lowercase();
if lower.contains("deny") || lower.contains("reject") || lower == "no" {
None
} else {
Some(id)
}
});
}
None
}
fn permission_option_id(option: &Value) -> Option<String> {
option
.get("optionId")
.or_else(|| option.get("option_id"))
.or_else(|| option.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
@@ -605,6 +967,229 @@ rl.on('line', (line) => {
);
}
/// Creates a mock ACP that supports session/load (success case).
async fn spawn_mock_acp_with_load() -> Arc<AcpClient> {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'session/load') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: msg.params?.sessionId || 'loaded_session_1' }
}) + '\n');
} else if (msg.method === 'session/new') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: 'test_session_1' }
}) + '\n');
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || 'test';
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello from mock ACP' }
}
}
}) + '\n');
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { stopReason: 'end_turn' }
}) + '\n');
} else if (msg.method === 'session/cancel') {
// No response for notification
} else if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
}) + '\n');
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_session_load_success_mock.mjs");
std::fs::write(&script_path, script).expect("write mock");
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn");
Arc::new(client)
}
/// Creates a mock ACP that returns null for session/load (session expired/not found)
async fn spawn_mock_acp_with_load_null() -> Arc<AcpClient> {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (!msg.id) return;
if (msg.method === 'session/load') {
// Return null result → session not found on adapter
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: null
}) + '\n');
} else if (msg.method === 'session/new') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { sessionId: 'fallback_session_1' }
}) + '\n');
} else if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || 'fallback_session_1';
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello from fallback session' }
}
}
}) + '\n');
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { stopReason: 'end_turn' }
}) + '\n');
} else if (msg.method === 'session/cancel') {
// No response for notification
} else if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', id: msg.id,
result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] }
}) + '\n');
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_session_load_null_mock.mjs");
std::fs::write(&script_path, script).expect("write mock");
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn");
Arc::new(client)
}
#[tokio::test]
async fn test_load_session_success() {
let client = spawn_mock_acp_with_load().await;
let mgr = AcpSessionManager::new(client);
let loaded = mgr
.load_session("stored_session_1", Some("/test"))
.await
.expect("load_session");
assert!(loaded, "session should be loaded successfully");
assert_eq!(mgr.session_id().await, Some("stored_session_1".into()));
}
#[tokio::test]
async fn test_load_session_null_fallback() {
let client = spawn_mock_acp_with_load_null().await;
let mgr = AcpSessionManager::new(client);
// load_session returns false for null result
let loaded = mgr
.load_session("expired_session", Some("/test"))
.await
.expect("load_session");
assert!(!loaded, "session should not be loaded");
// No session_id set after failed load
assert_eq!(mgr.session_id().await, None);
}
#[tokio::test]
async fn test_ensure_session_reuses_active() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
// First, create a session
let created = mgr
.create_session(Some("/test"), None)
.await
.expect("create_session");
assert_eq!(created, "test_session_1");
// ensure_session should reuse the active session, not create a new one
let ensured = mgr
.ensure_session(Some("/test"), Some("stored_but_ignored"))
.await
.expect("ensure_session");
assert_eq!(ensured, "test_session_1");
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_loads_stored() {
let client = spawn_mock_acp_with_load().await;
let mgr = AcpSessionManager::new(client);
// No active session, stored id provided → should load
let ensured = mgr
.ensure_session(Some("/test"), Some("stored_session_1"))
.await
.expect("ensure_session");
assert_eq!(ensured, "stored_session_1");
assert_eq!(mgr.session_id().await, Some("stored_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_fallback_to_new() {
let client = spawn_mock_acp_with_load_null().await;
let mgr = AcpSessionManager::new(client);
// No active session, stored id provided but load returns null → fallback to new
let ensured = mgr
.ensure_session(Some("/test"), Some("expired_session"))
.await
.expect("ensure_session");
assert_eq!(ensured, "fallback_session_1");
assert_eq!(mgr.session_id().await, Some("fallback_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_no_stored_creates_new() {
let client = spawn_mock_acp().await;
let mgr = AcpSessionManager::new(client);
// No active session, no stored id → should create new
let ensured = mgr
.ensure_session(Some("/test"), None)
.await
.expect("ensure_session");
assert_eq!(ensured, "test_session_1");
assert_eq!(mgr.session_id().await, Some("test_session_1".into()));
}
#[tokio::test]
async fn test_ensure_session_fallback_then_prompt_works() {
let client = spawn_mock_acp_with_load_null().await;
let mgr = AcpSessionManager::new(client);
// ensure_session with expired stored id → should create fallback
mgr.ensure_session(Some("/test"), Some("expired_session"))
.await
.expect("ensure_session");
// Verify prompt still works on the fallback session
let prompt = vec![ContentBlock::Text {
text: "Hello after fallback".into(),
}];
let result = mgr
.run_prompt(prompt)
.await
.expect("run_prompt after fallback");
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
}
#[test]
fn test_thought_chunk_maps_to_thought_delta() {
let accumulated = Arc::new(Mutex::new(String::new()));
@@ -624,4 +1209,26 @@ rl.on('line', (line) => {
other => panic!("expected ThoughtDelta, got {other:?}"),
}
}
#[test]
fn permission_response_selects_allow_and_deny_options() {
let params = json!({
"options": [
{"optionId": "allow_once", "name": "Allow Once", "kind": "allow_once"},
{"optionId": "reject_once", "name": "Reject Once", "kind": "reject_once"}
]
});
let allow = permission_response_for_decision(&params, "allow")
.expect("allow response")
.expect("allow should select an option");
assert_eq!(allow["outcome"], "selected");
assert_eq!(allow["optionId"], "allow_once");
let deny = permission_response_for_decision(&params, "deny")
.expect("deny response")
.expect("deny should select an option");
assert_eq!(deny["outcome"], "selected");
assert_eq!(deny["optionId"], "reject_once");
}
}
+69 -1
View File
@@ -131,6 +131,24 @@ pub struct SessionNewResult {
pub session_id: String,
}
// ── Session load ─────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLoadParams {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<McpServerSpec>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLoadResult {
pub session_id: String,
}
// ── Content blocks ───────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -236,6 +254,8 @@ pub enum SessionUpdate {
status: Option<ToolCallStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_input: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
locations: Vec<ToolLocation>,
},
ToolCallUpdate {
#[serde(rename = "sessionUpdate")]
@@ -327,6 +347,8 @@ impl<'de> Deserialize<'de> for SessionUpdate {
kind: Option<ToolCallKind>,
status: Option<ToolCallStatus>,
raw_input: Option<Value>,
#[serde(default)]
locations: Vec<ToolLocation>,
}
let raw: Raw = serde_json::from_value(value).map_err(de::Error::custom)?;
@@ -337,6 +359,7 @@ impl<'de> Deserialize<'de> for SessionUpdate {
kind: raw.kind,
status: raw.status,
raw_input: raw.raw_input,
locations: raw.locations,
})
}
SessionUpdate::TOOL_CALL_UPDATE => {
@@ -456,6 +479,13 @@ pub enum ToolCallStatus {
Failed,
}
/// A file path location referenced by a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolLocation {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlanEntry {
@@ -666,9 +696,47 @@ mod tests {
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall { title, kind, .. } => {
SessionUpdate::ToolCall {
title,
kind,
locations,
..
} => {
assert_eq!(title.as_deref(), Some("mnote.doc.fetch"));
assert!(matches!(kind, Some(ToolCallKind::Read)));
assert!(locations.is_empty(), "no locations in this fixture");
}
_ => panic!("expected ToolCall"),
}
}
#[test]
fn test_session_update_tool_call_with_locations() {
let json = serde_json::json!({
"sessionId": "test_1",
"update": {
"sessionUpdate": "tool_call",
"toolCallId": "tc_2",
"title": "mnote.doc.read",
"kind": "read",
"status": "in_progress",
"locations": [
{ "path": "/mnt/Data1T/mnote/src/main.rs" },
{ "path": "/mnt/Data1T/mnote/src/lib.rs" }
]
}
});
let parsed: SessionUpdateParams = serde_json::from_value(json).unwrap();
match &parsed.update {
SessionUpdate::ToolCall {
tool_call_id,
locations,
..
} => {
assert_eq!(tool_call_id, "tc_2");
assert_eq!(locations.len(), 2);
assert_eq!(locations[0].path, "/mnt/Data1T/mnote/src/main.rs");
assert_eq!(locations[1].path, "/mnt/Data1T/mnote/src/lib.rs");
}
_ => panic!("expected ToolCall"),
}
+11 -11
View File
@@ -2367,10 +2367,10 @@ mod tests {
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("utf8");
assert!(html.contains("初始化的新页面"));
assert!(!html.contains("初始化的新页面"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
let _ = std::fs::remove_dir_all(&base);
}
@@ -2583,17 +2583,17 @@ mod tests {
.join("user_real")
.join("workspaces")
.join("my-space");
assert!(expected_root
.join("初始化的新页面")
.join("初始化的新页面.md")
.exists());
assert!(html.contains("初始化的新页面"));
assert!(
!expected_root.join("初始化的新页面").exists(),
"默认工作区不应创建'初始化的新页面'目录"
);
assert!(!html.contains("初始化的新页面"));
assert!(html.contains("local_folder"));
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
assert!(html.contains(r#"data-mnote-root-uri="file://"#));
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(html.contains("当前还没有可显示的本地工作区"));
assert!(html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
let _ = std::fs::remove_dir_all(&base);
@@ -1461,10 +1461,19 @@ async fn acp_stream_events(
}
});
let acp_session_id = mgr.create_session(None, None).await.map_err(|e| {
// Try to resume stored ACP session, or create a new one.
// Reference: hermes-vscode-main sessionManager.ts ensureSession()
let stored_acp_session_id = payload
.get("acpSessionId")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty());
let acp_session_id = mgr
.ensure_session(None, stored_acp_session_id)
.await
.map_err(|e| {
WebError::bad_gateway_code(
"acp_session_create_failed",
format!("ACP session creation failed: {e}"),
"acp_session_ensure_failed",
format!("ACP session ensure failed: {e}"),
)
.with_context(&context)
})?;
@@ -1810,6 +1819,82 @@ pub async fn abort_run(
}
}
/// Resolve a pending permission request from an ACP agent.
///
/// POST /api/hermes/client/runs/{run_id}/resolve-permission
/// Body: { "permissionId": "...", "decision": "allow"|"deny" }
///
/// Looks up the active run, forwards the decision to the ACP subprocess,
/// and emits `permission.allowed` / `permission.denied` SSE event.
pub async fn resolve_permission(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
Json(payload): Json<Value>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_authenticated(&context)?;
let permission_id = payload
.get("permissionId")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
WebError::bad_request_code("hermes_client_bad_request", "缺少 permissionId")
.with_context(&context)
})?;
let decision = payload
.get("decision")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
WebError::bad_request_code("hermes_client_bad_request", "缺少 decision (allow/deny)")
.with_context(&context)
})?;
let profile = profile_for_run(&run_id).unwrap_or_else(|| "default".into());
if acp_runtime_for_run(&run_id, &profile).is_none() {
return Err(WebError::bad_request_code(
"hermes_client_not_acp_run",
format!("run_id={run_id} 不是 ACP run"),
)
.with_context(&context));
}
let active = ACP_ACTIVE_RUNS
.lock()
.expect("acp active runs")
.get(&run_id)
.cloned();
let Some(active) = active else {
return Err(WebError::bad_request_code(
"hermes_client_no_active_run",
format!("run_id={run_id} 没有活跃 ACP run"),
)
.with_context(&context));
};
active
.manager
.resolve_permission(permission_id, decision)
.await
.map_err(|e| {
WebError::bad_request_code(
"hermes_client_permission_resolve_failed",
format!("resolve permission 失败: {e}"),
)
.with_context(&context)
})?;
Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"runId": run_id,
"permissionId": permission_id,
"decision": decision,
})),
))
}
pub async fn list_models(
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
@@ -1554,7 +1554,6 @@ fn create_default_local_workspace_for_actor_at_base(
let manifest = ensure_default_workspace_manifest(actor_id, &canonical_root)?;
ensure_default_workspace_directories(&canonical_root)?;
ensure_default_workspace_home_page(&canonical_root)?;
Ok(json!({
"ok": true,
"workspace": {
@@ -1782,36 +1781,6 @@ fn ensure_default_workspace_directories(root: &Path) -> Result<(), WebError> {
Ok(())
}
fn ensure_default_workspace_home_page(root: &Path) -> Result<(), WebError> {
let page_dir = root.join("初始化的新页面");
let page = page_dir.join("初始化的新页面.md");
if page.exists() {
return Ok(());
}
if !page.starts_with(root) {
return Err(WebError::bad_request_code(
"local_workspace_root_escape",
"默认本地工作区首页不能越过 root",
));
}
fs::create_dir_all(&page_dir).map_err(|error| {
WebError::bad_request_code(
"local_workspace_create_failed",
format!(
"无法创建默认本地工作区首页目录 {}: {error}",
page_dir.display()
),
)
})?;
let content = "";
fs::write(&page, content).map_err(|error| {
WebError::bad_request_code(
"local_workspace_create_failed",
format!("无法创建默认本地工作区首页 {}: {error}", page.display()),
)
})
}
fn local_workspace_manifest_path(root: &Path) -> PathBuf {
root.join(".mnote").join("workspace.json")
}
@@ -3781,6 +3750,12 @@ fn move_local_directory(
fn trash_local_entry(root: &Path, entry_id: &str) -> Result<Value, WebError> {
if let Some(directory) = resolve_local_directory_id(root, entry_id)? {
// 若目录是页面 bundle(包含同名 .md),按 Markdown 页面生命周期入 trash
if let Some(main_md) = nested_bundle_main_markdown(&directory) {
let relative = normalize_relative_path(root, &main_md)?;
let page_id = local_markdown_path_page_id(&relative);
return trash_local_markdown_page(root, &page_id);
}
return trash_local_directory(root, entry_id, &directory);
}
if let Some(file) = resolve_local_raw_file_id(root, entry_id)? {
@@ -6961,13 +6936,13 @@ mod tests {
execute_local_tree_command, execute_local_tree_command_with_sort, get_local_access_policy,
get_share_grants, initialize_local_page_id, initialize_local_workspace_for_actor,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id,
open_local_file, record_shared_cache, record_sync_pending_change,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report,
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
local_folder_watch_revision, local_markdown_path_page_id,
local_resource_write_editor_blocks, local_workspace_id, open_local_file,
record_shared_cache, record_sync_pending_change, resolve_local_markdown_page_aggregate,
save_local_markdown_page, update_local_markdown_title, validate_local_access_root,
write_local_markdown_asset, write_local_markdown_page_body, write_local_mindmap_data,
write_sync_conflict_report, LocalAccessGrantRequest, LocalAccessValidateRootRequest,
LocalFileOpenQuery, LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
};
use crate::context::RequestContext;
@@ -8216,10 +8191,10 @@ fn main() {}
assert_eq!(std::path::Path::new(root_path), expected_root.as_path());
assert!(expected_root.join(".mnote").join("workspace.json").exists());
assert!(expected_root
.join("初始化的新页面")
.join("初始化的新页面.md")
.exists());
assert!(
!expected_root.join("初始化的新页面").exists(),
"默认工作区不应创建'初始化的新页面'目录"
);
assert!(!expected_root.join("pages").exists());
assert!(!expected_root.join("assets").exists());
assert!(!expected_root.join("mindmaps").exists());
@@ -8516,6 +8491,39 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_page_bundle_directory_uses_page_trash() {
let root = temp_root("mnote-local-delete-page-bundle-dir");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create page bundle");
std::fs::write(root.join("Page").join("Page.md"), "# Page\n").expect("write markdown");
std::fs::write(root.join("Page").join("asset.txt"), "asset").expect("write asset");
let root_uri = format!("file://{}", root.display());
let folder_id = format!("local-dir:{}", encode_local_id_segment("Page"));
let page_id = local_markdown_path_page_id("Page/Page.md");
let result = execute_local_tree_command(&root_uri, "delete", &folder_id, None, None)
.expect("delete page bundle");
assert!(
!root.join("Page").exists(),
"页面 bundle 目录应整体移入垃圾箱"
);
assert_eq!(result["id"].as_str(), Some(page_id.as_str()));
assert_eq!(result["documentId"].as_str(), Some(page_id.as_str()));
assert_eq!(result["resourceKind"].as_str(), Some("markdown_bundle"));
assert_ne!(
result["resourceKind"].as_str(),
Some("local_directory"),
"页面 bundle 目录不能作为普通资源目录进入垃圾箱"
);
let trash_path = result["trashPath"].as_str().expect("trashPath");
assert!(root.join(trash_path).join("Page.md").is_file());
assert!(root.join(trash_path).join("asset.txt").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_local_file_id_uses_trash_index() {
let root = temp_root("mnote-local-delete-local-file-id");
+4
View File
@@ -311,6 +311,10 @@ pub fn build_router(state: AppState) -> Router {
"/client/runs/{run_id}/abort",
post(hermes_client::abort_run),
)
.route(
"/client/runs/{run_id}/resolve-permission",
post(hermes_client::resolve_permission),
)
.route("/client/models", get(hermes_client::list_models))
.route("/client/tools", get(hermes_client::list_tools)),
)
+494 -17
View File
@@ -1,19 +1,30 @@
use crate::app::AppConfig;
use crate::app::AppState;
use crate::error::WebError;
use adapter_onlyoffice::{prepare_proxy_request, sign_config, OnlyOfficeProxyPreparationInput};
use adapter_onlyoffice::{
prepare_callback, prepare_proxy_request, sign_config, OnlyOfficeCallbackPreparationInput,
OnlyOfficeProxyPreparationInput,
};
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, Uri};
use axum::http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use hyper::upgrade::Upgraded;
use hyper_util::rt::TokioIo;
use serde::Deserialize;
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::path::{Path as FsPath, PathBuf};
use std::time::Duration;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
use tokio_tungstenite::tungstenite::protocol::Role;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::WebSocketStream;
const ONLYOFFICE_PROBE_PATH: &str = "/web-apps/apps/api/documents/api.js";
const DEFAULT_ONLYOFFICE_INTERNAL_URL: &str = "http://127.0.0.1:8082";
@@ -136,6 +147,9 @@ pub struct OnlyOfficeCallbackQuery {
asset_id: Option<String>,
#[serde(rename = "userId")]
user_id: Option<String>,
#[serde(rename = "rootUri")]
root_uri: Option<String>,
path: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -429,6 +443,13 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
}}
return String(Math.abs(hash));
}}
function safeOnlyOfficeDocKey(input) {{
const raw = String(input || "").trim();
if (!raw) return "mnote_" + hashOnlyOfficeKey(initial.fileName || "document");
const safe = raw.replace(/[^A-Za-z0-9_.=-]/g, "_").replace(/_+/g, "_");
if (safe && safe === raw && safe.length <= 96) return safe;
return "mnote_" + hashOnlyOfficeKey(raw) + "_" + hashOnlyOfficeKey(initial.fileName || "");
}}
function docTypeFromExt(ext) {{
const value = String(ext || "").toLowerCase();
if (["ppt", "pptx", "odp"].includes(value)) return "slide";
@@ -465,19 +486,44 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return value;
}}
}}
function buildCallbackUrl(assetId, userId) {{
function localFolderOpenParams(raw) {{
try {{
const url = new URL(String(raw || ""), location.origin);
if (url.pathname !== "/api/local-folder/files/open") return null;
const rootUri = String(url.searchParams.get("rootUri") || "").trim();
const path = String(url.searchParams.get("path") || "").trim();
if (!rootUri || !path) return null;
return {{ rootUri, path }};
}} catch {{
return null;
}}
}}
function buildCallbackUrl(assetId, userId, localFile) {{
const callback = new URL("/api/onlyoffice/callback", callbackOrigin || location.origin);
if (assetId) callback.searchParams.set("assetId", assetId);
if (userId) callback.searchParams.set("userId", userId);
if (localFile && localFile.rootUri && localFile.path) {{
callback.searchParams.set("rootUri", localFile.rootUri);
callback.searchParams.set("path", localFile.path);
}}
return callback.toString();
}}
function loadScript(url) {{
function loadScript(url, timeoutMs) {{
return new Promise((resolve, reject) => {{
const script = document.createElement("script");
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error("无法加载 ONLYOFFICE API: " + url));
const cleanup = () => {{ script.onload = null; script.onerror = null; }};
script.onload = () => {{ cleanup(); resolve(); }};
script.onerror = () => {{ cleanup(); reject(new Error("无法加载 ONLYOFFICE API: " + url)); }};
document.head.appendChild(script);
if (timeoutMs > 0) {{
const timer = window.setTimeout(() => {{
cleanup();
reject(new Error("ONLYOFFICE API 加载超时 (" + (timeoutMs / 1000) + "秒)"));
}}, timeoutMs);
resolve = ((orig) => (value) => {{ window.clearTimeout(timer); return orig(value); }})(resolve);
reject = ((orig) => (reason) => {{ window.clearTimeout(timer); return orig(reason); }})(reject);
}}
}});
}}
const MNOTE_ONLYOFFICE_FRAME_REV = "mnote-proxy-identity-20260511";
@@ -539,10 +585,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
return "";
}}
}}
function isLocalFolderAsset() {{
if (initial.assetId && (initial.assetId.indexOf("local:") === 0 || initial.assetId.indexOf("local-file:") === 0)) return true;
if (initial.fileUrl && initial.fileUrl.indexOf("/api/local-folder/files/open") !== -1) return true;
return false;
}}
async function resolveAssetUrlAndKey() {{
let effectiveFileUrl = initial.fileUrl;
let storageId = "";
if (initial.assetId) {{
if (initial.assetId && !isLocalFolderAsset()) {{
try {{
const response = await fetch("/api/media/sign?assetId=" + encodeURIComponent(initial.assetId));
const payload = await response.json().catch(() => null);
@@ -554,19 +605,32 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
// ignore
}}
}}
const docKey = initial.assetId
const rawDocKey = initial.assetId
? (storageId ? initial.assetId + "_" + hashOnlyOfficeKey(storageId) : initial.assetId)
: hashOnlyOfficeKey(String(effectiveFileUrl || "") + "-" + initial.fileName);
const docKey = safeOnlyOfficeDocKey(rawDocKey);
return {{ effectiveFileUrl, storageId, docKey, resolvedFileUrl: resolveDocumentUrl(effectiveFileUrl) }};
}}
async function boot() {{
const bootTimeoutMs = 60000;
const bootDeadline = Date.now() + bootTimeoutMs;
const bootHeartbeat = window.setInterval(() => {{
if (Date.now() > bootDeadline) {{
window.clearInterval(bootHeartbeat);
showError("ONLYOFFICE 页面初始化超时(" + (bootTimeoutMs / 1000) + "秒),请检查 DocumentServer 是否正常运行。");
}}
}}, 5000);
try {{
const userId = await resolveWhoami();
const fileState = await resolveAssetUrlAndKey();
if (!fileState.resolvedFileUrl) throw new Error("缺少 fileUrl 参数");
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js");
await waitForDocEditorReady(120000);
await loadScript("/onlyoffice-server/web-apps/apps/api/documents/api.js", 15000);
await waitForDocEditorReady(60000);
const resolvedMode = initial.mode === "view" ? "view" : "edit";
const localFile = localFolderOpenParams(fileState.effectiveFileUrl || initial.fileUrl);
const displayUserId = String(userId || initial.userId || "mnote-local-user").trim() || "mnote-local-user";
const displayUserName = displayUserId === "mnote-local-user" ? "MNote" : displayUserId;
const config = {{
width: "100%",
height: "100%",
@@ -586,9 +650,15 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
editorConfig: {{
mode: resolvedMode,
lang: "zh-CN",
callbackUrl: buildCallbackUrl(initial.assetId, userId),
callbackUrl: buildCallbackUrl(initial.assetId, userId, localFile),
user: {{
id: displayUserId,
name: displayUserName
}},
customization: {{
feedback: {{ visible: false }},
anonymous: {{ request: false, label: "Guest" }},
features: {{ featuresTips: false }},
forcesave: resolvedMode !== "view"
}}
}},
@@ -637,8 +707,13 @@ pub async fn page(Query(query): Query<OnlyOfficePageQuery>) -> Result<Response,
if (signPayload.editorConfigToken) config.editorConfig.token = signPayload.editorConfigToken;
installOnlyOfficeFrameSrcPatch();
window.__MNOTE_ONLYOFFICE_EDITOR__ = new window.DocsAPI.DocEditor("onlyoffice-frame", config);
}} finally {{
window.clearInterval(bootHeartbeat);
}}
boot().catch((error) => showError(error && error.message ? error.message : error));
}}
boot().catch((error) => {{
showError(error && error.message ? error.message : error);
}});
</script>
</body>
</html>"#,
@@ -972,6 +1047,131 @@ fn proxy_local_folder_file_open(
Ok(Some(response))
}
fn is_onlyoffice_local_asset_id(asset_id: &str) -> bool {
let value = asset_id.trim();
value.starts_with("local:") || value.starts_with("local-file:")
}
fn onlyoffice_callback_success(extra: Value) -> Response {
let mut payload = json!({ "error": 0 });
if let (Some(object), Some(extra_object)) = (payload.as_object_mut(), extra.as_object()) {
for (key, value) in extra_object {
object.insert(key.clone(), value.clone());
}
}
Json(payload).into_response()
}
fn onlyoffice_callback_failure(error: WebError) -> Response {
Json(json!({
"error": 1,
"code": error.code(),
"message": error.message(),
}))
.into_response()
}
async fn download_onlyoffice_callback_body(download_url: &str) -> Result<Bytes, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.map_err(|error| {
WebError::internal(format!("OnlyOffice callback HTTP 客户端创建失败: {error}"))
})?;
let response = client.get(download_url).send().await.map_err(|error| {
WebError::bad_gateway_code(
"onlyoffice_local_callback_download_failed",
format!("OnlyOffice 保存文件下载失败: {error}"),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"onlyoffice_local_callback_download_failed",
format!("OnlyOffice 保存文件下载失败: HTTP {status}"),
));
}
response.bytes().await.map_err(|error| {
WebError::bad_gateway_code(
"onlyoffice_local_callback_body_failed",
format!("OnlyOffice 保存文件读取失败: {error}"),
)
})
}
async fn local_folder_onlyoffice_callback(
query: &OnlyOfficeCallbackQuery,
body: &Value,
status: i64,
) -> Result<Response, WebError> {
let asset_id = query.asset_id.as_deref().unwrap_or("").trim();
let root_uri = query
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_local_callback_root_missing",
"OnlyOffice 本地保存缺少 rootUri",
)
})?;
let relative_path = query
.path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_local_callback_path_missing",
"OnlyOffice 本地保存缺少 path",
)
})?;
let target = resolve_onlyoffice_local_file_path(root_uri, relative_path)?;
let onlyoffice_internal_url = resolve_onlyoffice_internal_url().await;
let prepared = prepare_callback(OnlyOfficeCallbackPreparationInput {
asset_id: asset_id.to_string(),
document_id: None,
workspace_id: None,
user_id: query.user_id.clone(),
session_id: None,
status,
url: body
.get("url")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
key: body
.get("key")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
onlyoffice_internal_url,
})
.map_err(|error| WebError::bad_request_code("onlyoffice_local_callback_invalid", error))?;
if !prepared.should_write {
return Ok(onlyoffice_callback_success(json!({
"localWrite": false,
"status": status,
})));
}
let download_url = prepared.download_url.as_deref().ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_local_callback_url_missing",
"OnlyOffice 本地保存缺少下载地址",
)
})?;
let bytes = download_onlyoffice_callback_body(download_url).await?;
fs::write(&target, &bytes).map_err(|error| {
WebError::bad_request_code(
"onlyoffice_local_callback_write_failed",
format!("写回本地 Office 文件失败: {error}"),
)
})?;
Ok(onlyoffice_callback_success(json!({
"localWrite": true,
"bytes": bytes.len(),
})))
}
pub async fn callback(
State(state): State<AppState>,
uri: Uri,
@@ -988,6 +1188,19 @@ pub async fn callback(
status,
"OnlyOffice callback received by mnote-web"
);
let is_local_callback = query
.asset_id
.as_deref()
.map(is_onlyoffice_local_asset_id)
.unwrap_or(false)
|| query.root_uri.as_deref().is_some()
|| query.path.as_deref().is_some();
if is_local_callback {
return match local_folder_onlyoffice_callback(&query, &body, status).await {
Ok(response) => response,
Err(error) => onlyoffice_callback_failure(error),
};
}
match proxy_legacy_onlyoffice_json(
state.config(),
"/api/onlyoffice/callback",
@@ -1308,14 +1521,101 @@ async fn proxy_onlyoffice_path(
Ok(response)
}
fn onlyoffice_websocket_url(base: &str, upstream_path: &str, query: Option<&str>) -> String {
let mut target = append_path_and_query(base, upstream_path, query);
if let Some(rest) = target.strip_prefix("http://") {
target = format!("ws://{rest}");
} else if let Some(rest) = target.strip_prefix("https://") {
target = format!("wss://{rest}");
}
target
}
async fn bridge_onlyoffice_websocket(upgraded: Upgraded, target: String) {
let client_io = TokioIo::new(upgraded);
let mut client_socket = WebSocketStream::from_raw_socket(client_io, Role::Server, None).await;
let Ok((mut upstream_socket, _response)) = connect_async(&target).await else {
let _ = client_socket.close(None).await;
return;
};
loop {
tokio::select! {
client_message = client_socket.next() => {
let Some(Ok(message)) = client_message else {
let _ = upstream_socket.send(TungsteniteMessage::Close(None)).await;
break;
};
let is_close = matches!(message, TungsteniteMessage::Close(_));
if upstream_socket.send(message).await.is_err() {
break;
}
if is_close {
break;
}
}
upstream_message = upstream_socket.next() => {
let Some(Ok(message)) = upstream_message else {
let _ = client_socket.send(TungsteniteMessage::Close(None)).await;
break;
};
let is_close = matches!(message, TungsteniteMessage::Close(_));
if client_socket.send(message).await.is_err() {
break;
}
if is_close {
break;
}
}
}
}
}
pub async fn server_proxy(
State(_state): State<AppState>,
Path(path): Path<String>,
uri: Uri,
method: Method,
headers: HeaderMap,
request: Request<Body>,
mut request: Request<Body>,
) -> Result<Response, WebError> {
let upgrade = headers
.get(header::UPGRADE)
.and_then(|value| value.to_str().ok())
.map(|value| value.eq_ignore_ascii_case("websocket"))
.unwrap_or(false);
if upgrade {
let key = headers
.get(header::SEC_WEBSOCKET_KEY)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
WebError::bad_request_code(
"onlyoffice_websocket_key_missing",
"缺少 Sec-WebSocket-Key",
)
})?;
let target =
onlyoffice_websocket_url(&resolve_onlyoffice_internal_url().await, &path, uri.query());
let upgraded = hyper::upgrade::on(&mut request);
tokio::spawn(async move {
if let Ok(upgraded) = upgraded.await {
bridge_onlyoffice_websocket(upgraded, target).await;
}
});
let response = Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header(header::CONNECTION, "Upgrade")
.header(header::UPGRADE, "websocket")
.header(
header::SEC_WEBSOCKET_ACCEPT,
derive_accept_key(key.as_bytes()),
)
.body(Body::empty())
.map_err(|error| {
WebError::internal(format!("构造 OnlyOffice WebSocket 响应失败: {error}"))
})?;
return Ok(response);
}
proxy_onlyoffice_path("", &path, uri, method, headers, request).await
}
@@ -1332,13 +1632,43 @@ pub async fn cache_proxy(
#[cfg(test)]
pub fn stable_doc_key(asset_id: &str, storage_id: &str, file_url: &str, file_name: &str) -> String {
if !asset_id.trim().is_empty() {
let raw = if !asset_id.trim().is_empty() {
if storage_id.trim().is_empty() {
return asset_id.trim().to_string();
}
return format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id));
asset_id.trim().to_string()
} else {
format!("{}_{}", asset_id.trim(), js_hash_abs(storage_id))
}
} else {
js_hash_abs(&format!("{file_url}-{file_name}"))
};
safe_onlyoffice_doc_key(&raw, file_name)
}
#[cfg(test)]
fn safe_onlyoffice_doc_key(input: &str, file_name: &str) -> String {
let raw = input.trim();
if raw.is_empty() {
return format!("mnote_{}", js_hash_abs(file_name));
}
let mut safe = String::new();
let mut previous_underscore = false;
for ch in raw.chars() {
let allowed = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-');
let next = if allowed { ch } else { '_' };
if next == '_' {
if previous_underscore {
continue;
}
previous_underscore = true;
} else {
previous_underscore = false;
}
safe.push(next);
}
if !safe.is_empty() && safe == raw && safe.len() <= 96 {
return safe;
}
format!("mnote_{}_{}", js_hash_abs(raw), js_hash_abs(file_name))
}
#[cfg(test)]
@@ -1374,6 +1704,24 @@ mod tests {
assert!(key.starts_with("asset_1_"));
}
#[test]
fn stable_doc_key_hashes_local_unicode_asset_ids() {
let key = stable_doc_key(
"local:asset:Alpha/重庆发展特殊化妆品可行性报告_政府汇报版.docx",
"",
"",
"重庆发展特殊化妆品可行性报告_政府汇报版.docx",
);
assert!(key.len() <= 128);
assert!(key.starts_with("mnote_"));
assert!(key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '=' | '-')));
assert!(!key.contains('/'));
assert!(!key.contains(':'));
assert!(!key.contains('重'));
}
#[tokio::test]
async fn onlyoffice_object_shell_exposes_resource_identity() {
let response = object_shell(
@@ -1533,6 +1881,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: None,
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
@@ -1550,6 +1900,85 @@ mod tests {
assert_eq!(payload["degraded"], true);
}
#[tokio::test]
async fn onlyoffice_local_callback_writes_status_two_body_to_original_file() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let (download_url, _captured) = spawn_legacy_json_server("new docx bytes").await;
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
root.display()
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({
"status": 2,
"key": "doc_key",
"url": download_url
})),
)
.await;
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 0);
assert_eq!(fs::read(&target).expect("read target"), b"new docx bytes");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_local_callback_ignores_non_write_status() {
let root = std::env::temp_dir().join(format!(
"mnote-onlyoffice-local-callback-ignore-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("Page")).expect("create page");
let target = root.join("Page").join("report.docx");
fs::write(&target, b"old").expect("write old docx");
let response = callback(
State(test_state(None)),
format!(
"/api/onlyoffice/callback?assetId=local%3Aasset%3APage%2Freport.docx&rootUri=file%3A%2F%2F{}&path=Page%2Freport.docx",
root.display()
)
.parse::<Uri>()
.expect("uri"),
Query(OnlyOfficeCallbackQuery {
asset_id: Some("local:asset:Page/report.docx".into()),
user_id: None,
root_uri: Some(format!("file://{}", root.display())),
path: Some("Page/report.docx".into()),
}),
Json(json!({ "status": 1 })),
)
.await;
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["error"], 0);
assert_eq!(fs::read(&target).expect("read target"), b"old");
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn onlyoffice_callback_proxies_to_legacy_next_writeback() {
let (base_url, captured) = spawn_legacy_json_server(r#"{"error":0}"#).await;
@@ -1561,6 +1990,8 @@ mod tests {
Query(OnlyOfficeCallbackQuery {
asset_id: Some("asset_1".into()),
user_id: Some("user_1".into()),
root_uri: None,
path: None,
}),
Json(json!({
"status": 2,
@@ -1635,4 +2066,50 @@ mod tests {
assert!(request
.starts_with("POST /api/onlyoffice/forcesave?assetId=asset_1&key=doc_key HTTP/1.1"));
}
#[tokio::test]
async fn onlyoffice_page_skips_media_sign_for_local_folder_asset() {
let response = page(Query(OnlyOfficePageQuery {
file_url: Some(
"http://localhost:3000/api/local-folder/files/open?rootUri=file:///tmp&path=Page/report.docx"
.into(),
),
file_name: Some("report.docx".into()),
file_type: Some("docx".into()),
asset_id: Some("local-file:Page/report.docx".into()),
document_id: Some("local-md:Page".into()),
user_id: None,
mode: Some("view".into()),
}))
.await
.expect("onlyoffice page");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let html = String::from_utf8(body.to_vec()).expect("html");
// 1) Guard function is present
assert!(html.contains("function isLocalFolderAsset()"));
// 2) Condition uses guard to skip /api/media/sign
assert!(html.contains("if (initial.assetId && !isLocalFolderAsset())"));
// 3) local: prefix detection
assert!(html.contains("initial.assetId.indexOf(\"local:\") === 0"));
// 4) local-file: prefix detection
assert!(html.contains("initial.assetId.indexOf(\"local-file:\") === 0"));
// 5) fileUrl path detection for /api/local-folder/files/open
assert!(html.contains(
"initial.fileUrl && initial.fileUrl.indexOf(\"/api/local-folder/files/open\") !== -1"
));
// 6) Non-local asset still fetches /api/media/sign (general code path preserved)
assert!(html.contains("/api/media/sign?assetId="));
// 7) local rootUri/path are propagated into callback for save writeback
assert!(html.contains("function localFolderOpenParams(raw)"));
assert!(html.contains("callback.searchParams.set(\"rootUri\", localFile.rootUri);"));
assert!(html.contains("callback.searchParams.set(\"path\", localFile.path);"));
// 8) user name is explicit so OnlyOffice does not ask for collaboration name
assert!(html.contains("user: {"));
assert!(html.contains("name: displayUserName"));
assert!(html.contains("anonymous: { request: false, label: \"Guest\" }"));
assert!(html.contains("features: { featuresTips: false }"));
}
}
+99 -8
View File
@@ -465,6 +465,8 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
const pageTabTitle = document.querySelector('[data-mnote-main-tab="page"] .mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
if (documentId) {
const escapedId = cssEscape(documentId);
@@ -2616,6 +2618,16 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
if (pageTab instanceof HTMLElement) {
pageTab.setAttribute('data-document-id', documentId);
pageTab.setAttribute('data-workspace-id', workspaceId);
const pageTabTitle = pageTab.querySelector('.mnote-main-tab-title');
if (pageTabTitle instanceof HTMLElement) pageTabTitle.textContent = title;
}
window.dispatchEvent(new CustomEvent('mnote:primary-document-activated', {
detail: { documentId, workspaceId, title }
}));
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
@@ -2986,12 +2998,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const anchorElement = anchorNode && anchorNode.nodeType === Node.ELEMENT_NODE
? anchorNode
: anchorNode?.parentElement || null;
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => node instanceof HTMLElement);
const roots = Array.from(document.querySelectorAll(ROOT_SELECTOR)).filter((node) => (
node instanceof HTMLElement
&& node.offsetParent !== null
&& node.getAttribute('data-mnote-side-target-unsupported') !== 'true'
));
if (anchorElement instanceof Element) {
const activeRoot = roots.find((root) => root.contains(anchorElement));
if (activeRoot) return activeRoot;
}
return roots.find((root) => root.offsetParent !== null) || roots[0] || null;
const focused = document.activeElement instanceof Element
? roots.find((root) => root.contains(document.activeElement))
: null;
if (focused) return focused;
return roots.find((root) => root.querySelector('.ProseMirror:focus-within')) || roots[0] || null;
};
const slashMenuAnchorFromSelection = (root) => {
@@ -3042,12 +3062,42 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return fallback();
};
const positionSlashMenuForRoot = (root) => {
if (!(root instanceof HTMLElement)) root = activeEditorRootForSlashMenu();
if (!(root instanceof HTMLElement)) return;
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]')
|| document.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
const setSlashMenuInactive = (menu, inactive) => {
if (!(menu instanceof HTMLElement)) return;
if (inactive) {
if (menu.getAttribute('data-mnote-slash-inactive') !== 'true') {
menu.setAttribute('data-mnote-slash-inactive', 'true');
}
if (menu.style.display !== 'none') menu.style.display = 'none';
return;
}
if (menu.getAttribute('data-mnote-slash-inactive') === 'true') {
menu.removeAttribute('data-mnote-slash-inactive');
}
if (menu.style.display === 'none') menu.style.display = '';
};
const hideSlashMenusOutsideRoot = (activeRoot) => {
document.querySelectorAll(`${ROOT_SELECTOR} [data-testid="mnote-leptos-tiptap-slash-menu"]`).forEach((menu) => {
const root = menu.closest(ROOT_SELECTOR);
if (root !== activeRoot) setSlashMenuInactive(menu, true);
});
};
const positionSlashMenuForRoot = (root) => {
const activeRoot = activeEditorRootForSlashMenu();
if (!(root instanceof HTMLElement)) root = activeRoot;
if (!(root instanceof HTMLElement)) return;
if (activeRoot instanceof HTMLElement && root !== activeRoot) {
const inactiveMenu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
setSlashMenuInactive(inactiveMenu, true);
hideSlashMenusOutsideRoot(activeRoot);
return;
}
hideSlashMenusOutsideRoot(root);
const menu = root.querySelector('[data-testid="mnote-leptos-tiptap-slash-menu"]');
if (!(menu instanceof HTMLElement)) return;
setSlashMenuInactive(menu, false);
const anchor = slashMenuAnchorFromSelection(root);
const gap = 8;
const menuWidth = Math.min(316, Math.max(160, window.innerWidth - 16));
@@ -3611,6 +3661,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
};
const refreshExistingOfficeResourceTab = (entry, input) => {
if (!entry || entry.kind !== 'office') return false;
const nextHref = String(input.officeUrl || input.href || '').trim();
if (!nextHref) return false;
const frame = entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame');
const currentHref = frame instanceof HTMLIFrameElement
? String(frame.getAttribute('src') || frame.src || '').trim()
: '';
if (currentHref !== nextHref) openPassiveResourceTab(entry, input);
return true;
};
const openMindmapResourceTab = async (entry, input) => {
const documentId = String(input.documentId || currentDocumentId() || '').trim();
const mindmapId = String(input.mindmapId || input.assetId || '').trim();
@@ -3668,6 +3730,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const title = String(input.title || input.fileName || input.assetId || '').trim() || '';
if (root instanceof HTMLElement) {
root.replaceChildren();
document.querySelectorAll('[data-document-pane="true"][data-pane-role="secondary"] [data-testid="mnote-leptos-tiptap-slash-menu"]').forEach((node) => {
if (node instanceof HTMLElement) node.remove();
});
root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
root.setAttribute('data-mnote-side-target-unsupported', 'true');
root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
@@ -3691,6 +3756,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (!objectIdentity) return false;
const existing = resourceTabRegistry.get(objectIdentity);
if (existing) {
refreshExistingOfficeResourceTab(existing, input);
activateMainEditorTab(objectIdentity);
return true;
}
@@ -3754,6 +3820,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const descriptor = descriptorFromCurrentUrl('primary', id, { workspaceId, sourceKind, rootUri });
await replacePaneDocument('primary', descriptor);
updatePrimaryUrl(descriptor, url instanceof URL ? url : null);
activateMainEditorTab('');
return true;
},
openPrimaryMindmap: async ({ documentId, mindmapId, workspaceId, url } = {}) => {
@@ -3778,6 +3845,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
},
resolveResourceOpen: (input) => resolveResourceOpen(input),
openResourceInActiveTab: openResourceInActiveTab,
activatePageTab: () => {
bindMainEditorPageTab();
activateMainEditorTab('');
return true;
},
openResourceAsSideTarget: async (input = {}) => {
const resolved = resolveResourceOpen({ ...input, openTarget: 'side' });
if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') {
@@ -4466,12 +4538,25 @@ mod tests {
assert!(html.contains("data-mnote-main-tab-strip"));
assert!(html.contains("class=\"mnote-main-tab-badge\""));
assert!(!html.contains(">description</span><span class=\"mnote-main-tab-title\""));
assert!(html.contains("[data-mnote-main-tab=\"page\"] .mnote-main-tab-title"));
assert!(html.contains("pageTab.setAttribute('data-document-id', documentId);"));
assert!(html.contains("data-mnote-tab-badge-kind"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("positionSlashMenuForRoot"));
assert!(html.contains("menu.style.position = 'fixed';"));
assert!(html.contains("installGlobalSlashMenuPositioning();"));
assert!(html.contains("data-mnote-slash-positioned', 'host'"));
assert!(html.contains(
"const menu = root.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]');"
));
assert!(html.contains("const setSlashMenuInactive = (menu, inactive) =>"));
assert!(html.contains("const hideSlashMenusOutsideRoot = (activeRoot) =>"));
assert!(html.contains("data-mnote-slash-inactive"));
assert!(html.contains("if (activeRoot instanceof HTMLElement && root !== activeRoot)"));
assert!(!html.contains(
"|| document.querySelector('[data-testid=\"mnote-leptos-tiptap-slash-menu\"]')"
));
assert!(html.contains("data-mnote-side-target-unsupported') !== 'true'"));
assert!(
html.contains("runtimeDescriptor.root.addEventListener(STATE_EVENT, view.onState);")
);
@@ -4509,6 +4594,12 @@ mod tests {
assert!(html.contains("resourceTabRegistry.delete(key)"));
assert!(html.contains("activateMainEditorTab(lastActiveResourceTabKey())"));
assert!(html.contains("resourceTabBadgeKind(input, kind)"));
assert!(html.contains("const refreshExistingOfficeResourceTab = (entry, input) =>"));
assert!(html.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(
html.contains("if (currentHref !== nextHref) openPassiveResourceTab(entry, input);")
);
assert!(html.contains("refreshExistingOfficeResourceTab(existing, input);"));
}
#[tokio::test]
@@ -4769,7 +4860,7 @@ mod tests {
assert!(html.contains("data-row-kind=\"markdown\""));
assert!(html.contains("data-page-openable=\"false\""));
assert!(html.contains("fileAction === 'open' && rowKind === 'folder'"));
assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'"));
assert!(html.contains("openTrigger.getAttribute('data-page-openable') === 'false'"));
assert!(html.contains("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("refreshSessionFromExternalFileChange"));
assert!(html.contains("refreshSessionFromExternalChange"));
+416 -31
View File
@@ -1253,6 +1253,9 @@ const SIDEBAR_TREE_JS: &str = r##"
var treeView = normalizeSidebarTreeMode(options && options.treeView ? options.treeView : activeSidebarTreeMode());
persistSidebarTreeMode(treeView);
if (currentDocumentId() === nodeId && window.location.pathname.indexOf('/documents/') === 0) {
if (typeof window.__mnoteDocumentPaneRuntime?.activatePageTab === 'function') {
window.__mnoteDocumentPaneRuntime.activatePageTab();
}
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach(function(row) {
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'false');
@@ -1268,6 +1271,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}
}
});
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
return;
}
var targetUrl = new URL('/documents/' + encodeURIComponent(nodeId), window.location.origin);
@@ -1289,6 +1293,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}).then(function(){
if (mnoteNavigationInFlight === url) mnoteNavigationInFlight = '';
document.documentElement.removeAttribute('data-mnote-navigation-pending');
selectSidebarFileTreeDocument(nodeId, { scrollIntoView: true });
}).catch(function(error){
console.warn('mnote pane 退', error);
if (window.__mnoteTreeLiveEventSource && typeof window.__mnoteTreeLiveEventSource.close === 'function') {
@@ -1323,9 +1328,32 @@ const SIDEBAR_TREE_JS: &str = r##"
window.__mnoteLocalFolderSelfChangeSuppressions = window.__mnoteLocalFolderSelfChangeSuppressions || new Map();
window.__mnoteLocalFolderSelfChangeSuppressions.set(nextDocumentId, Date.now() + 5000);
}
if (nextDocumentId && currentSourceKind() === 'local_folder') {
await refreshLocalFolderSidebarSnapshot();
selectSidebarFileTreeDocument(nextDocumentId, { scrollIntoView: true });
document.documentElement.setAttribute('data-mnote-create-page-selected-document-id', nextDocumentId);
}
navigateToDocument(nextDocumentId, nextWorkspaceId, { treeView: activeSidebarTreeMode() });
}
async function createFileTreeFolder(trigger, parentId) {
if (currentSourceKind() !== 'local_folder') return false;
var workspaceId = resolveWorkspaceId(trigger || document.body);
var effectiveParentId = String(parentId || '').trim();
var title = window.prompt('', '');
if (!title || !title.trim()) return false;
var result = await dispatchTreeCommand(trigger || document.body, {
action: 'create_folder',
workspaceId: workspaceId,
parentId: effectiveParentId || null,
title: title.trim()
});
document.documentElement.setAttribute('data-mnote-filetree-folder-created', 'true');
document.documentElement.setAttribute('data-mnote-filetree-folder-created-id', commandDocumentId(result, result.id || ''));
void refreshLocalFolderSidebarSnapshot();
return true;
}
function applySidebarTreeTab(mode, shell) {
mode = persistSidebarTreeMode(mode);
shell = shell || document.querySelector('[data-testid="wolai-sidebar-page-tree-shell"]');
@@ -2063,7 +2091,7 @@ const SIDEBAR_TREE_JS: &str = r##"
if (input.assetId) target.searchParams.set('assetId', input.assetId);
if (input.documentId) target.searchParams.set('documentId', input.documentId);
if (input.userId) target.searchParams.set('userId', input.userId);
target.searchParams.set('mode', input.mode || 'edit');
target.searchParams.set('mode', input.mode || 'view');
return target.toString();
}
@@ -2075,7 +2103,7 @@ const SIDEBAR_TREE_JS: &str = r##"
if (input.assetId) params.set('assetId', input.assetId);
if (input.documentId) params.set('documentId', input.documentId);
if (input.userId) params.set('userId', input.userId);
params.set('mode', input.mode || 'edit');
params.set('mode', input.mode || 'view');
if (input.documentId && input.assetId) {
return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId) + '?' + params.toString();
}
@@ -2094,7 +2122,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: assetId || ('local-file:' + relativePath),
documentId: documentId || currentDocumentId() || '',
userId: '',
mode: mode || 'edit'
mode: mode || 'view'
});
}
@@ -2224,6 +2252,7 @@ const SIDEBAR_TREE_JS: &str = r##"
if (!assetId) return;
var openTarget = String(detail && detail.openTarget || '').trim().toLowerCase();
var forceNewWindow = openTarget === 'new-window';
var forceEditMode = openTarget === 'edit-mode';
if (openTarget === 'side') {
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceAsSideTarget === 'function') {
void window.__mnoteDocumentPaneRuntime.openResourceAsSideTarget({
@@ -2261,7 +2290,7 @@ const SIDEBAR_TREE_JS: &str = r##"
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
return;
}
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceNewWindow ? 'edit' : 'view');
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, forceEditMode ? 'edit' : 'view');
if (localOfficeUrl) {
if (!forceNewWindow && await openLocalResourceInActiveTab({
path: localFilePath,
@@ -2328,15 +2357,29 @@ const SIDEBAR_TREE_JS: &str = r##"
var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type);
if (fileType) {
var userId = await fetchCurrentOnlyOfficeUserId();
window.open(buildOnlyOfficeOpenUrl({
var officeUrl = buildOnlyOfficeOpenUrl({
fileUrl: fileUrl,
fileName: fileName,
fileType: fileType,
assetId: assetId,
documentId: String(asset.document_id || detail.documentId || '').trim(),
userId: userId,
mode: 'edit'
}), '_blank', 'noopener,noreferrer');
mode: forceEditMode ? 'edit' : 'view'
});
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: 'resource:onlyoffice:' + String(asset.document_id || detail.documentId || '').trim() + ':' + assetId,
assetId: assetId,
title: fileName,
fileName: fileName,
kind: 'office',
officeUrl: officeUrl,
documentId: String(asset.document_id || detail.documentId || '').trim(),
workspaceId: String(detail.workspaceId || '').trim()
});
if (didOpen) return;
}
window.open(officeUrl, '_blank', 'noopener,noreferrer');
return;
}
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
@@ -2695,7 +2738,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: assetId,
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
userId: userId || '',
mode: 'edit'
mode: 'view'
});
}
return buildOnlyOfficeOpenUrl({
@@ -2705,7 +2748,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: assetId,
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
userId: userId || '',
mode: 'edit'
mode: 'view'
});
}
@@ -2803,7 +2846,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: detail.assetId,
documentId: detail.documentId || currentDocumentId() || '',
userId: '',
mode: 'edit'
mode: 'view'
}));
link.setAttribute('data-mnote-attachment-link', 'true');
link.setAttribute('data-asset-id', detail.assetId);
@@ -3172,7 +3215,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: assetId,
documentId: String(asset && (asset.document_id || asset.documentId) || currentDocumentId() || '').trim(),
userId: userId || '',
mode: 'edit'
mode: 'view'
}))
: href;
var inserted = editor.chain().focus().insertContent({
@@ -3557,6 +3600,57 @@ const SIDEBAR_TREE_JS: &str = r##"
});
}
function fileTreeCopyPath(detail, trigger) {
// 对 local_folder 复制真实相对路径,不复制标题
if (currentSourceKind() === 'local_folder') {
if (detail.assetId) {
var path = localFilePathFromAssetId(detail.assetId);
if (path) {
try {
return decodeURIComponent(path.replace(/~2F/g, '/'));
} catch (_) {
return path.replace(/~2F/g, '/');
}
}
}
if (detail.documentId) {
var docPath = String(detail.documentId || '')
.replace(/^local-md:/, '')
.replace(/^local-dir:/, '')
.replace(/~2F/g, '/');
if (docPath) {
try {
return decodeURIComponent(docPath);
} catch (_) {
return docPath;
}
}
}
}
return detail.title || '';
}
function fileTreeMenuTargetParentId(detail, trigger) {
var rowKind = String(detail && detail.rowKind || '').trim();
var rowId = String(detail && detail.rowId || '').trim();
var documentId = String(detail && detail.documentId || '').trim();
if (rowKind === 'folder' && rowId) return rowId;
if (rowKind === 'directory' && rowId) return rowId;
if (documentId) return documentId;
if (trigger && typeof trigger.getAttribute === 'function') {
var triggerKind = String(trigger.getAttribute('data-row-kind') || '').trim();
var triggerRowId = String(trigger.getAttribute('data-row-id') || '').trim();
if ((triggerKind === 'folder' || triggerKind === 'directory') && triggerRowId) return triggerRowId;
}
return '';
}
function withOfficeEditModeGuard(callback) {
document.documentElement.setAttribute('data-mnote-last-office-edit-mode-requested', 'true');
document.documentElement.setAttribute('data-mnote-last-office-edit-mode-guard', 'silent');
callback();
}
function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
@@ -3577,6 +3671,18 @@ const SIDEBAR_TREE_JS: &str = r##"
openEditorAttachmentNewWindow(detail);
return;
}
if (action === 'new-window-edit') {
withOfficeEditModeGuard(function() {
openEditorAttachmentNewWindow(detail, 'edit');
});
return;
}
if (action === 'open-edit-mode') {
withOfficeEditModeGuard(function() {
void openEditorAttachmentEditTab(detail);
});
return;
}
if (action === 'right-preview') {
dispatchSidebarEvent('tree.attachment.open-right', detail);
return;
@@ -3597,6 +3703,13 @@ const SIDEBAR_TREE_JS: &str = r##"
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
return;
}
if (isAsset && action === 'open-edit-mode') {
withOfficeEditModeGuard(function() {
recordFileTreeAction('open-edit-mode', detail);
void openConvexAssetFromFileTree({ ...detail, openTarget: 'edit-mode' });
});
return;
}
if (isAsset && action === 'open-right') {
recordFileTreeAction('open-right', detail);
void openConvexAssetFromFileTree({ ...detail, openTarget: 'side' });
@@ -3645,7 +3758,17 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
if (action === 'new-file') {
void createPage(trigger || document.body, documentId || null);
void createPage(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null);
return;
}
if (action === 'new-folder') {
recordFileTreeAction('new-folder', detail);
void createFileTreeFolder(trigger || document.body, fileTreeMenuTargetParentId(detail, trigger) || null).then(function(ok) {
recordFileTreeActionStatus(ok ? 'created' : 'skipped', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '');
});
return;
}
if (action === 'paste-into') {
@@ -3660,7 +3783,7 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
if (action === 'copy-path') {
void copyTreeContextValue(title, 'copy-path');
void copyTreeContextValue(fileTreeCopyPath(detail, trigger), 'copy-path');
return;
}
if (action === 'refresh') {
@@ -3790,6 +3913,8 @@ const SIDEBAR_TREE_JS: &str = r##"
{ separator: true },
{ action: 'popup-preview', icon: 'preview', label: '' },
{ action: 'right-preview', icon: 'right_panel_open', label: '' },
{ action: 'open-edit-mode', icon: 'edit_note', label: '' },
{ action: 'new-window-edit', icon: 'open_in_new', label: '' },
{ action: 'new-window', icon: 'open_in_new', label: '' },
{ action: 'download', icon: 'download', label: '' },
{ action: 'replace-file', icon: 'sync', label: '' },
@@ -3799,7 +3924,7 @@ const SIDEBAR_TREE_JS: &str = r##"
{ separator: true },
{ action: 'color', icon: 'format_paint', label: '' }
] : isAsset ? [
{ action: 'open-right', icon: 'open_in_new', label: '', shortcut: 'Alt + O' },
{ action: 'open-edit-mode', icon: 'edit_note', label: '使' },
{ action: 'new-window', icon: 'open_in_new', label: '' },
{ action: 'rename', icon: 'edit', label: '', shortcut: 'F2' },
{ action: 'copy-id', icon: 'tag', label: ' ID' },
@@ -3817,14 +3942,12 @@ const SIDEBAR_TREE_JS: &str = r##"
{ action: 'copy-path', icon: 'content_copy', label: 'Copy Path' },
{ separator: true },
{ action: 'new-file', icon: 'note_add', label: 'New File' },
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: true, title: 'Convex tree command' },
{ action: 'new-folder', icon: 'create_new_folder', label: 'New Folder', disabled: currentSourceKind() !== 'local_folder', title: currentSourceKind() === 'local_folder' ? '' : ' local folder ' },
{ action: 'paste-into', icon: 'content_paste', label: '', disabled: !sidebarFileTreeClipboard, title: sidebarFileTreeClipboard ? '' : '' },
{ action: 'refresh', icon: 'refresh', label: 'Refresh' },
{ action: 'collapse-all', icon: 'unfold_less', label: 'Collapse All' },
{ action: 'reveal', icon: 'my_location', label: 'Reveal' },
{ separator: true },
{ action: 'duplicate', icon: 'file_copy', label: '' },
{ separator: true },
{ action: 'rename', icon: 'edit', label: '', shortcut: 'F2' },
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '', shortcut: 'Del', danger: true }
@@ -3835,8 +3958,6 @@ const SIDEBAR_TREE_JS: &str = r##"
{ action: 'copy-reference-inline', icon: 'content_copy', label: '' },
{ action: 'copy-id', icon: 'tag', label: 'ID' },
{ separator: true },
{ action: 'duplicate', icon: 'file_copy', label: '' },
{ separator: true },
{ action: 'rename', icon: 'edit', label: '' },
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '', shortcut: 'Del', danger: true }
@@ -3929,6 +4050,33 @@ const SIDEBAR_TREE_JS: &str = r##"
return Array.from(selected);
}
function selectSidebarFileTreeDocument(documentId, options) {
var id = String(documentId || '').trim();
if (!id) return false;
var row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape('doc:' + id) + '"]')
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="' + cssEscape(id) + '"]')
|| document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="' + cssEscape(id) + '"]');
if (!(row instanceof HTMLElement)) return false;
var rowId = row.getAttribute('data-row-id') || '';
if (!rowId) return false;
sidebarFileTreeSelection.selectedRowIds = new Set([rowId]);
sidebarFileTreeSelection.anchorRowId = rowId;
sidebarFileTreeSelection.focusedRowId = rowId;
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach(function(activeRow) {
if (activeRow instanceof HTMLElement) activeRow.setAttribute('data-active', 'false');
});
syncSidebarFileTreeSelection();
row.setAttribute('data-active', 'true');
if (options && options.scrollIntoView !== false) {
try {
row.scrollIntoView({ block: 'nearest' });
} catch (_) {
row.scrollIntoView();
}
}
return true;
}
function selectedSidebarFileTreeRowIdsForDrag(row) {
var rowId = row instanceof HTMLElement ? row.getAttribute('data-row-id') || '' : '';
if (rowId && sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
@@ -4773,6 +4921,22 @@ const SIDEBAR_TREE_JS: &str = r##"
function pageAiResolvePermission(permissionId, decision) {
permissionId = String(permissionId || '').trim();
if (!permissionId) return;
// 调用后端 resolve-permission 端点,让 ACP agent 得到真实响应
var runId = pageUiState.pageAiCurrentRunId;
if (runId) {
fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ permissionId: permissionId, decision: decision })
}).then(function(response) {
if (!response.ok) console.warn('resolve-permission ', response.status);
}).catch(function(err) {
console.warn('resolve-permission ', err);
});
} else {
console.warn('resolve-permission: runId');
}
// 本地乐观更新 UI
pageUiState.pageAiMessages.forEach(function(item) {
if (item.kind === 'permission' && item.permissionId === permissionId) {
item.resolved = true;
@@ -4986,6 +5150,7 @@ const SIDEBAR_TREE_JS: &str = r##"
}
if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') {
var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim();
var rawLocations = payload.locations;
return {
role: 'tool',
content: toolName,
@@ -4995,6 +5160,7 @@ const SIDEBAR_TREE_JS: &str = r##"
status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'),
argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input),
resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error),
locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [],
traceId: String(payload.traceId || payload.trace_id || ''),
auditId: String(payload.auditId || payload.audit_id || '')
};
@@ -5610,6 +5776,8 @@ const SIDEBAR_TREE_JS: &str = r##"
traceId: traceId,
auditId: auditId
};
var rawLocations = toolEvent && toolEvent.locations;
var locations = Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [];
var existing = pageUiState.pageAiMessages.find(function(item) {
return item.role === 'tool' && item.toolCallId === toolCallId;
});
@@ -5624,6 +5792,7 @@ const SIDEBAR_TREE_JS: &str = r##"
status: status,
argsSummary: '',
resultSummary: '',
locations: locations,
traceId: traceId,
auditId: auditId
};
@@ -5637,6 +5806,7 @@ const SIDEBAR_TREE_JS: &str = r##"
existing.auditId = auditId || existing.auditId || '';
if (argsSummary) existing.argsSummary = argsSummary;
if (resultSummary) existing.resultSummary = resultSummary;
if (locations.length) existing.locations = locations;
if (status === 'completed') {
pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId);
}
@@ -6585,7 +6755,16 @@ const SIDEBAR_TREE_JS: &str = r##"
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '' : 'AI');
if (item.role === 'tool') {
var statusLabel = item.status === 'completed' ? '' : (item.status === 'failed' ? '' : '');
var locationRows = Array.isArray(item.locations) && item.locations.length
? '<div class="wolai-page-ai-tool-meta">' + item.locations.map(function(loc, idx) {
return '<span style="display:inline-flex;align-items:center;gap:4px;margin-right:8px">' +
'<span>' + escapeHtml(loc) + '</span>' +
'<button type="button" class="wolai-page-ai-ghost" style="font-size:0.85em;padding:0 4px;min-width:unset" data-page-ai-open-location="' + escapeHtml(loc) + '" data-page-ai-tool-call-id="' + escapeHtml(item.toolCallId || '') + '" title="打开文件"></button>' +
'</span>';
}).join('') + '</div>'
: '';
var detailRows = [
locationRows,
item.argsSummary ? '<div class="wolai-page-ai-tool-meta"> ' + escapeHtml(item.argsSummary) + '</div>' : '',
item.resultSummary ? '<div class="wolai-page-ai-tool-meta"> ' + escapeHtml(item.resultSummary) + '</div>' : '',
item.changedFiles && item.changedFiles.length ? '<pre class="wolai-page-ai-tool-meta wolai-page-ai-changed-files">' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '</pre>' : '',
@@ -6629,6 +6808,21 @@ const SIDEBAR_TREE_JS: &str = r##"
'</div>' +
'</div>';
}
if (item.kind === 'plan') {
var planEntries = Array.isArray(item.entries) ? item.entries : [];
var listHtml = planEntries.map(function(entry, idx) {
return '<li style="margin:2px 0">' + escapeHtml(String(entry || '')) + '</li>';
}).join('');
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant" data-page-ai-plan="true">' +
'<details class="wolai-page-ai-plan-details" open>' +
'<summary class="wolai-page-ai-message-role" style="cursor:pointer;user-select:none;opacity:0.8;font-size:0.85em"> · ' + planEntries.length + ' </summary>' +
'<div class="wolai-page-ai-message-text" style="margin-top:4px;opacity:0.75;font-size:0.9em;border-left:2px solid var(--wolai-border-color, #ddd);padding-left:8px">' +
'<ol style="margin:4px 0;padding-left:20px">' + listHtml + '</ol>' +
'</div>' +
'</details>' +
'</div>';
}
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
@@ -7046,6 +7240,40 @@ const SIDEBAR_TREE_JS: &str = r##"
pageUiState.pageAiStoppedRunIds[runId] = true;
pageAiSetRunStatus('aborted', runId);
}
if (eventName === 'session.info.updated') {
try {
var infoPayload = JSON.parse(payloadText || 'null') || {};
var newTitle = String(infoPayload.title || '').trim();
if (newTitle) {
var sessionForTitle = pageAiCurrentSession();
if (sessionForTitle) {
sessionForTitle.title = newTitle;
sessionForTitle.updatedAt = Date.now();
pageAiPersistSessions();
renderPageAiControls();
}
}
} catch (_) {}
}
if (eventName === 'plan.updated') {
try {
var planPayload = JSON.parse(payloadText || 'null') || {};
var planEntries = Array.isArray(planPayload.entries) ? planPayload.entries : [];
if (planEntries.length) {
var planMsgs = pageUiState.pageAiMessages;
var existingPlan = planMsgs.length > 0 && planMsgs[planMsgs.length - 1].kind === 'plan' ? planMsgs[planMsgs.length - 1] : null;
if (existingPlan) {
existingPlan.entries = planEntries;
existingPlan.updatedAt = Date.now();
} else {
planMsgs.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() });
}
pageAiSyncCurrentSessionMessages();
pageAiPersistSessions();
renderPageAiConversation();
}
} catch (_) {}
}
if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId);
pageAiSyncCurrentSessionMessages();
@@ -7424,7 +7652,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: url.searchParams.get('assetId') || '',
documentId: url.searchParams.get('documentId') || currentDocumentId() || '',
userId: url.searchParams.get('userId') || '',
mode: url.searchParams.get('mode') || 'edit'
mode: url.searchParams.get('mode') || 'view'
});
} catch (_) {
return String(href || '');
@@ -7453,7 +7681,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: assetId,
documentId: currentDocumentId() || '',
userId: '',
mode: 'edit'
mode: 'view'
});
} else if (isOnlyOfficeAttachmentHref(rawHref)) {
href = normalizeOnlyOfficeAttachmentHref(rawHref);
@@ -7494,7 +7722,7 @@ const SIDEBAR_TREE_JS: &str = r##"
assetId: assetId,
documentId: params.get('documentId') || currentDocumentId() || '',
userId: params.get('userId') || '',
mode: params.get('mode') || 'edit'
mode: params.get('mode') || 'view'
}));
}
attachmentClassForFileName(fileName).split(/\s+/).forEach(function(name) {
@@ -7575,20 +7803,110 @@ const SIDEBAR_TREE_JS: &str = r##"
void openCodeEditorAttachment(detail);
return;
}
var fileType = String(detail.fileType || '').trim();
if (fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
assetId: detail.assetId || '',
title: detail.fileName || '',
fileName: detail.fileName || '',
kind: 'office',
officeUrl: detail.href,
documentId: detail.documentId || '',
workspaceId: detail.workspaceId || ''
});
return;
}
window.open(detail.href, '_blank', 'noopener,noreferrer');
}
function openEditorAttachmentNewWindow(detail) {
function openEditorAttachmentNewWindow(detail, mode) {
if (!detail) return;
var requestedMode = mode === 'edit' ? 'edit' : 'view';
var localFilePath = localFilePathFromAssetId(detail.assetId);
if (localFilePath) {
var localFileName = localFilePath.split('/').pop() || localFilePath;
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit');
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, requestedMode);
var localFileUrl = buildLocalFileOpenUrl(localFilePath, false);
window.open(localOfficeUrl || localFileUrl || detail.href, '_blank', 'noopener,noreferrer');
return;
}
window.open(detail.href || detail.fileUrl, '_blank', 'noopener,noreferrer');
var href = detail.href || detail.fileUrl;
if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
try {
var url = new URL(href, window.location.origin);
url.searchParams.set('mode', requestedMode);
href = url.toString();
} catch (_) {}
} else if (detail.fileType) {
href = buildOnlyOfficeOpenUrl({
fileUrl: detail.fileUrl || href || '',
fileName: detail.fileName || '',
fileType: detail.fileType,
assetId: detail.assetId || '',
documentId: detail.documentId || currentDocumentId() || '',
userId: '',
mode: requestedMode
});
}
window.open(href, '_blank', 'noopener,noreferrer');
}
async function openEditorAttachmentEditTab(detail) {
if (!detail) return false;
var localFilePath = localFilePathFromAssetId(detail.assetId);
if (localFilePath) {
var localFileName = localFilePath.split('/').pop() || localFilePath;
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit');
if (localOfficeUrl) {
var didOpenLocalEditTab = await openLocalResourceInActiveTab({
path: localFilePath,
title: detail.fileName || localFileName,
kind: 'office',
assetId: detail.assetId,
documentId: detail.documentId || currentDocumentId() || '',
workspaceId: detail.workspaceId || resolveWorkspaceId(document.body) || '',
href: buildLocalFileOpenUrl(localFilePath, false),
officeUrl: localOfficeUrl
});
if (!didOpenLocalEditTab) window.open(localOfficeUrl, '_blank', 'noopener,noreferrer');
return didOpenLocalEditTab;
}
}
var href = detail.href || detail.fileUrl;
if (detail.fileType && isOnlyOfficeAttachmentHref(href)) {
try {
var url = new URL(href, window.location.origin);
url.searchParams.set('mode', 'edit');
href = url.toString();
} catch (_) {}
} else if (detail.fileType) {
href = buildOnlyOfficeOpenUrl({
fileUrl: detail.fileUrl || href || '',
fileName: detail.fileName || '',
fileType: detail.fileType,
assetId: detail.assetId || '',
documentId: detail.documentId || currentDocumentId() || '',
userId: '',
mode: 'edit'
});
}
if (detail.fileType && typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpenEditTab = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: 'resource:onlyoffice:' + (detail.documentId || '') + ':' + (detail.assetId || ''),
assetId: detail.assetId || '',
title: detail.fileName || '',
fileName: detail.fileName || '',
kind: 'office',
officeUrl: href,
documentId: detail.documentId || '',
workspaceId: detail.workspaceId || ''
});
if (!didOpenEditTab && href) window.open(href, '_blank', 'noopener,noreferrer');
return didOpenEditTab;
}
if (href) window.open(href, '_blank', 'noopener,noreferrer');
return false;
}
async function resolveEditorAttachmentUrl(detail) {
@@ -7999,6 +8317,21 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
var pageAiOpenLocation = closestAction(e.target, '[data-page-ai-open-location]');
if (pageAiOpenLocation) {
e.preventDefault();
var loc = String(pageAiOpenLocation.getAttribute('data-page-ai-open-location') || '').trim();
if (loc) {
openLocalResourceInActiveTab({ path: loc }).then(function(opened) {
if (!opened) {
var href = buildLocalFileOpenUrl(loc, false);
if (href) window.open(href, '_blank', 'noopener,noreferrer');
}
});
}
return;
}
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
if (pageAiSession) {
e.preventDefault();
@@ -8181,10 +8514,11 @@ const SIDEBAR_TREE_JS: &str = r##"
var tree = document.getElementById('sidebar-tree-root');
if (!tree || !tree.contains(e.target)) return;
var pageRow = closestAction(e.target, '.tree-row[data-shell-mode="page"]');
var btn = closestAction(e.target, '[data-rust-action]');
if (!btn) return;
var nodeId = btn.getAttribute('data-node-id');
var action = btn.getAttribute('data-rust-action');
if (!btn && !pageRow) return;
var nodeId = (btn && btn.getAttribute('data-node-id')) || (pageRow && pageRow.getAttribute('data-node-id')) || '';
var action = btn ? btn.getAttribute('data-rust-action') : 'open';
if (action === 'toggle') {
var row = btn.closest('.tree-row');
@@ -8192,11 +8526,12 @@ const SIDEBAR_TREE_JS: &str = r##"
toggleChildren(row, btn);
e.preventDefault();
} else if (action === 'open') {
if (btn.getAttribute('data-page-openable') === 'false') {
var openTrigger = btn || pageRow;
if (openTrigger && openTrigger.getAttribute('data-page-openable') === 'false') {
e.preventDefault();
return;
}
var workspaceId = resolveWorkspaceId(btn);
var workspaceId = resolveWorkspaceId(openTrigger);
navigateToDocument(nodeId, workspaceId, { treeView: 'page' });
e.preventDefault();
} else if (action === 'create') {
@@ -8714,6 +9049,10 @@ const SIDEBAR_TREE_JS: &str = r##"
sidebarFileTreeSelection.focusedRowId = rowId;
});
syncSidebarFileTreeSelection();
window.addEventListener('mnote:primary-document-activated', function(event) {
var detail = event && event.detail ? event.detail : {};
selectSidebarFileTreeDocument(detail.documentId, { scrollIntoView: false });
});
restoreSidebarTreeTab();
startLocalFolderSidebarWatch();
})();
@@ -9162,7 +9501,7 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("/api/auth/whoami"));
assert!(SIDEBAR_TREE_JS.contains("buildOnlyOfficeOpenUrl"));
assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('userId'"));
assert!(SIDEBAR_TREE_JS.contains("window.open(buildOnlyOfficeOpenUrl"));
assert!(SIDEBAR_TREE_JS.contains("window.open(officeUrl,"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop"));
assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop"));
assert!(SIDEBAR_TREE_JS.contains("beginFileTreeInlineRename"));
@@ -9240,6 +9579,22 @@ mod tests {
!SIDEBAR_TREE_JS.contains("(item.resolved ? ' disabled' : '')"),
"已决 ACP permission 事件不能继续展示假审批按钮"
);
assert!(
SIDEBAR_TREE_JS.contains("session.info.updated"),
"ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端"
);
assert!(
SIDEBAR_TREE_JS.contains("plan.updated"),
"ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端"
);
assert!(
SIDEBAR_TREE_JS.contains("data-page-ai-plan"),
"plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板"
);
assert!(
SIDEBAR_TREE_JS.contains("执行计划 · "),
"plan 面板标题应显示执行计划和步数"
);
}
#[test]
@@ -9358,6 +9713,18 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("mnote-tree-context-menu"));
assert!(SIDEBAR_TREE_JS.contains("复制访问链接"));
assert!(SIDEBAR_TREE_JS.contains("删除到垃圾桶"));
assert!(SIDEBAR_TREE_JS.contains("function createFileTreeFolder"));
assert!(SIDEBAR_TREE_JS.contains("action: 'create_folder'"));
assert!(SIDEBAR_TREE_JS.contains("function fileTreeMenuTargetParentId"));
assert!(SIDEBAR_TREE_JS.contains("if (action === 'new-folder')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeCopyPath(detail, trigger)"));
assert!(SIDEBAR_TREE_JS.contains(
"var pageRow = closestAction(e.target, '.tree-row[data-shell-mode=\"page\"]');"
));
assert!(SIDEBAR_TREE_JS
.contains("var action = btn ? btn.getAttribute('data-rust-action') : 'open';"));
assert!(SIDEBAR_TREE_JS.contains("var openTrigger = btn || pageRow;"));
assert!(SIDEBAR_TREE_JS.contains("var workspaceId = resolveWorkspaceId(openTrigger);"));
assert!(SIDEBAR_TREE_JS.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"#
));
@@ -9491,6 +9858,24 @@ mod tests {
"return '/office/' + encodeURIComponent(input.documentId) + '/' + encodeURIComponent(input.assetId)"
));
assert!(SIDEBAR_TREE_JS.contains("return '/onlyoffice?' + params.toString();"));
assert!(SIDEBAR_TREE_JS.contains("target.searchParams.set('mode', input.mode || 'view');"));
assert!(SIDEBAR_TREE_JS.contains("params.set('mode', input.mode || 'view');"));
assert!(SIDEBAR_TREE_JS.contains(
"{ action: 'open-edit-mode', icon: 'edit_note', label: '使用编辑模式打开' }"
));
assert!(SIDEBAR_TREE_JS
.contains("{ action: 'open-edit-mode', icon: 'edit_note', label: '弹窗编辑' }"));
assert!(SIDEBAR_TREE_JS
.contains("{ action: 'new-window-edit', icon: 'open_in_new', label: '新窗口编辑' }"));
assert!(SIDEBAR_TREE_JS.contains("function withOfficeEditModeGuard(callback)"));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-last-office-edit-mode-requested"));
assert!(SIDEBAR_TREE_JS.contains("function openEditorAttachmentEditTab(detail)"));
assert!(SIDEBAR_TREE_JS.contains("void openEditorAttachmentEditTab(detail);"));
assert!(SIDEBAR_TREE_JS.contains("buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail.documentId || currentDocumentId() || '').trim(), detail.assetId, 'edit')"));
assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentNewWindow(detail, 'edit')"));
assert!(SIDEBAR_TREE_JS.contains("forceEditMode ? 'edit' : 'view'"));
assert!(SIDEBAR_TREE_JS.contains("url.searchParams.set('mode', requestedMode);"));
assert!(SIDEBAR_TREE_JS.contains("mode: requestedMode"));
}
#[test]
+77 -4
View File
@@ -191,6 +191,40 @@ async function main() {
);
console.log(` fileUrl uses Rust-accessible URL: ${fileUrlParam}`);
// ======== Test 1b: existing office tab refreshes from view to edit ========
console.log("Test 1b: existing docx tab refreshes from view mode to edit mode");
await page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: asset.id,
documentId,
title: asset.file_name || "report.docx",
assetType: asset.asset_type || "attachment",
openTarget: "edit-mode",
},
}));
}, { asset: officeAssets.docx, documentId });
await page.waitForFunction(() => {
const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
const iframe = panel?.querySelector('iframe.mnote-resource-tab-frame');
if (!(iframe instanceof HTMLIFrameElement)) return false;
const url = new URL(iframe.getAttribute("src") || iframe.src, window.location.origin);
return url.searchParams.get("mode") === "edit";
}, null, { timeout: UI_TIMEOUT_MS });
const refreshedTabInfo = await page.evaluate(() => {
const tabs = Array.from(document.querySelectorAll('.mnote-main-tab[data-mnote-tab-kind="office"]'));
const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
const iframe = panel?.querySelector('iframe.mnote-resource-tab-frame');
return {
officeTabCount: tabs.length,
iframeSrc: iframe?.getAttribute("src") || "",
};
});
const refreshedUrl = new URL(refreshedTabInfo.iframeSrc, BASE_URL);
console.log(` Refreshed active tab URL: ${refreshedUrl.toString()}`);
assert.equal(refreshedUrl.searchParams.get("mode"), "edit", "existing Office tab should refresh to mode=edit");
assert.equal(refreshedTabInfo.officeTabCount, 1, "edit-mode should refresh the existing docx tab instead of creating another tab");
// Close the docx tab
await page.evaluate(() => {
const officeTab = document.querySelector('.mnote-main-tab.is-active[data-mnote-tab-kind="office"]');
@@ -269,8 +303,8 @@ async function main() {
});
await page.waitForTimeout(300);
// ======== Test 4: new-window opens /onlyoffice with edit mode ========
console.log("Test 4: new-window opens /onlyoffice with edit mode");
// ======== Test 4: new-window defaults to view mode ========
console.log("Test 4: new-window opens /onlyoffice with view mode");
const [officePopup] = await Promise.all([
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }).catch(() => null),
page.evaluate(({ asset, documentId }) => {
@@ -292,9 +326,9 @@ async function main() {
console.log(` New-window URL: ${popupUrl.toString()}`);
assert.equal(popupUrl.pathname, "/onlyoffice", `new-window pathname should be /onlyoffice: ${popupUrl.pathname}`);
// new-window should use edit mode
// new-window 是浏览器目标,不代表编辑权限;默认仍应只读
const popupMode = popupUrl.searchParams.get("mode") || "";
assert.equal(popupMode, "edit", `new-window mode should be 'edit', got '${popupMode}'`);
assert.equal(popupMode, "view", `new-window mode should be 'view', got '${popupMode}'`);
// Check assetId is present
const popupAssetId = popupUrl.searchParams.get("assetId") || "";
@@ -305,6 +339,45 @@ async function main() {
console.warn(" No popup triggered — this may mean the run is headless and popup was blocked. Acceptable in CI.");
}
// ======== Test 4b: explicit edit-mode opens /onlyoffice with edit mode ========
console.log("Test 4b: explicit edit-mode opens /onlyoffice with edit mode");
const [editPopup] = await Promise.all([
page.waitForEvent("popup", { timeout: UI_TIMEOUT_MS }).catch(() => null),
page.evaluate(({ asset, documentId }) => {
window.dispatchEvent(new CustomEvent("tree.asset.open", {
detail: {
assetId: asset.id,
documentId,
title: asset.file_name || "report.docx",
assetType: asset.asset_type || "attachment",
openTarget: "edit-mode",
},
}));
}, { asset: officeAssets.docx, documentId }),
]);
if (editPopup) {
await editPopup.waitForLoadState("domcontentloaded", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
const editUrl = new URL(editPopup.url());
console.log(` Edit-mode URL: ${editUrl.toString()}`);
assert.equal(editUrl.pathname, "/onlyoffice", `edit-mode pathname should be /onlyoffice: ${editUrl.pathname}`);
const editMode = editUrl.searchParams.get("mode") || "";
assert.equal(editMode, "edit", `edit-mode target should use mode=edit, got '${editMode}'`);
await editPopup.close().catch(() => undefined);
} else {
const editTabInfo = await page.evaluate(() => {
const panel = document.querySelector('.mnote-resource-tab-panel:not([hidden])');
const iframe = panel?.querySelector('iframe.mnote-resource-tab-frame');
return {
iframeSrc: iframe?.getAttribute("src") || "",
};
});
const editUrl = new URL(editTabInfo.iframeSrc, BASE_URL);
const editMode = editUrl.searchParams.get("mode") || "";
console.log(` Edit-mode active tab URL: ${editUrl.toString()}`);
assert.equal(editMode, "edit", `edit-mode active tab target should use mode=edit, got '${editMode}'`);
}
// ======== Test 5: OnlyOffice /onlyoffice page self-test (config URL/document URL/callback URL) ========
console.log("Test 5: /onlyoffice page structure validation");
const onlyofficeUrl = new URL("/onlyoffice", BASE_URL);