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:
@@ -0,0 +1,60 @@
|
||||
# Reasonix Browser Tester:Office 只读打开、编辑入口与插件噪音验证
|
||||
|
||||
你是浏览器测试员,不是代码修改员。
|
||||
|
||||
禁止修改源码、禁止 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 A:OnlyOffice 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 B:Office 编辑模式菜单与 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 C:OnlyOffice 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 Next,local-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 D:OnlyOffice 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 root;hidden / 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/deny;Page 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 handler,handler 可异步返回 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 时必须实际调用 adapter;load 返回 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
|
||||
```
|
||||
|
||||
## 最终回复格式
|
||||
|
||||
- 修改文件列表
|
||||
- 关键实现说明
|
||||
- 实际运行的验证命令和结果
|
||||
- 未完成项 / 风险
|
||||
@@ -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 与本地验证对照。
|
||||
+99
@@ -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 的可见 root;secondary 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
|
||||
```
|
||||
+42
@@ -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`
|
||||
- P0:OnlyOffice 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`。
|
||||
+46
@@ -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-21:Reasonix 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-21:Codex 复核补充。
|
||||
- 干净浏览器上下文上传并打开 `/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-21:Reasonix 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-21:Codex 浏览器复核发现并修复“已有 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`。
|
||||
+66
@@ -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 Next,local-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-21:Reasonix worker C 分析完成。
|
||||
**结论:本轮不应实现完整写回。**
|
||||
|
||||
**阻塞输入缺口:**
|
||||
|
||||
1. callback URL 当前只携带 `assetId` 和 `userId`,缺少 `rootUri` 和 `path`。
|
||||
- `buildCallbackUrl()` 在 `onlyoffice.rs` 页脚 JS 中硬编码只传两个参数。
|
||||
- 对于 local-folder asset(assetId `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 Next;local-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-21:Reasonix 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-21:Reasonix 与 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
|
||||
>
|
||||
> Owner:ACP 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 id;load 返回 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_id,null/error 返回 false。
|
||||
- 新增 `ensure_session(cwd, stored_session_id)` → 1) 复用已有 session,2) 有 stored id 时尝试 load,3) 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
|
||||
>
|
||||
> Owner:ACP 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-20:Reasonix 实施,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
|
||||
>
|
||||
> Owner:ACP 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
|
||||
>
|
||||
> Owner:ACP 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` SSE,data 包含 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 Reasonix:renderPageAiConversation 添加 kind===plan 渲染分支:collapsible details/summary 面板显示编号步骤列表。
|
||||
- 2026-05-20 Reasonix:acp_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 有 allowlist,slash 响应渲染为居中的 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]` 进入 prompt;MNote 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 安全渲染评估。
|
||||
Generated
+2
@@ -1535,6 +1535,8 @@ dependencies = [
|
||||
"comrak",
|
||||
"core-protocol",
|
||||
"futures-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"leptos",
|
||||
"mnote-editor-core",
|
||||
"notify",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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:验证更改");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¬ification_handler).await;
|
||||
Self::dispatch_message(
|
||||
msg,
|
||||
&writer,
|
||||
&pending,
|
||||
¬ification_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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms, "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(¶ms, "deny")
|
||||
.expect("deny response")
|
||||
.expect("deny should select an option");
|
||||
assert_eq!(deny["outcome"], "selected");
|
||||
assert_eq!(deny["optionId"], "reject_once");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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 }"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user