收口 MNote P0 P1 P2 审查尾项

- 归档 OnlyOffice live bridge、Page AI、mindmap、design governance 与相关 bug 条目
- 补齐 MinerU OCR 后端 runtime 合同与 smoke/test 基线
- 收口 ChatOnly/Doubao、ObjectIdentity、Page Aggregate compat 与 runtime owner 文档口径

验证:
- cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
- cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1
- git diff --check
- git diff --cached --check
- codegraph index . --force && codegraph status .
- codegraph sync . && codegraph status .
This commit is contained in:
lix-2026
2026-06-01 09:29:12 +08:00
parent 49a0545148
commit 1882db7681
143 changed files with 29810 additions and 3228 deletions
+1
View File
@@ -79,6 +79,7 @@ design/05-editor-mainline/reference-code
# MNote 本地运行索引与一次性浏览器截图
/.mnote/
/ai-sessions/
/filetree-*.png
/task*-*.png
+3 -2
View File
@@ -72,8 +72,9 @@
- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selectionHermes 或 Reasonix 在白名单目录内用自身 patch/diff/文件编辑能力写入,MNote 通过 watcher / refresh 同步 tiptap。`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback`mnote.block.*` 保留为结构性辅助(拖拽排序等)。两层操作模型已获 CLI Main(Lark Doc)参考实现验证。流式 apply + suggest/review(参考 BlockNote AI)作为 Phase C 设计冻结,当前不实施。
- 需要架构判断时,优先参考:
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
- `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/reference/5-5-page-aggregate-single-truth-alignment-v1.md`
- `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
@@ -0,0 +1,46 @@
# 3-26 Sidebar dev hot reload 在主 runtime 中使用 setInterval
## 状态
- 状态:done
- Owner03-rust-web / browser runtime
- 发现时间:2026-05-31
## 现象
`sidebar-tree-runtime.js` 在主 Sidebar runtime 中安装 dev hot reload,并无前置环境 guard 地执行 `window.setInterval(tick, 1000)`。项目架构约束要求浏览器主链默认禁止新增基于 `setInterval`、周期 `setTimeout` 或轮询 fallback 的数据刷新和状态同步;即使这是 dev hot reload,也应该显式限制在 debug/dev 边界。
## 证据
- `rust/crates/mnote-web/browser/sidebar-tree-runtime.js``installMnoteDevHotReload()` 执行:
- 先调用 `tick()`
- 再执行 `timer = window.setInterval(tick, 1000)`
- 末尾无条件调用 `installMnoteDevHotReload()`
- `tick()` 每次请求 `/api/dev/hot-reload`,服务端返回 `enabled !== true` 时才清理 timer;这仍意味着页面启动后先进入轮询逻辑。
## 影响
- 主 Sidebar runtime 默认携带 dev 轮询逻辑,和“主链不叠加轮询 fallback”的架构约束冲突。
-`/api/dev/hot-reload` 异常或返回形态变化,可能引入重复请求、console 噪音或性能误判。
- 后续 worker 可能把这种模式复制到其他可见路径。
## 修复建议
- 在 Rust SSR bootstrap 中显式注入 dev/hot 模式标记,只有 dev hot 模式启用时才安装该逻辑。
- 或将 hot reload 逻辑迁到 debug/internal runtime,不放在默认 Sidebar runtime。
- 如果仍保留定时器,必须在设计/注释中说明退出条件和边界,并保证生产/普通 desktop 模式不可达。
## 本轮进展
- 2026-05-31
- `sidebar-tree-runtime.js` 已改为通过 `import.meta.url` 检查当前模块 URL 是否携带 `devHot` cache buster;只有 dev hot 模式才安装 hot reload 轮询。
- 已补 `layout.rs` 静态断言,禁止无条件 `installMnoteDevHotReload()` 回流。
- 已通过 `node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js` 和 Rust 定点测试。
- 已补 `scripts/task514-sidebar-dev-hot-reload-gating-smoke.js` 固化真实浏览器验证。
- 验证通过:`node scripts/task514-sidebar-dev-hot-reload-gating-smoke.js`。结果显示普通入口 `hotReloadRequestCount=0``scriptHasDevHot=false`hot 模式 `scriptHasDevHot=true``hotReloadRequestCount=2``data-mnote-dev-hot-reload=enabled`,且无 console/network 错误。
## 验收
- [x] 普通 `desktop` / 非 hot 入口打开后,浏览器不会请求 `/api/dev/hot-reload`
- [x] `npm run desktop:hot` 或等价 hot 模式下仍能刷新。
- [x] `setInterval(tick, 1000)` 仅在 `mnoteDevHotReloadEnabled()` guard 后可达。
@@ -0,0 +1,103 @@
# 3-27 MinerU OCR 后端 runtime 已落地但缺前端任务链
## 状态
- 状态:process
- Owner03-rust-web / local-folder OCR / MinerU sidecar
- 发现时间:2026-05-31
## 现象
P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真实 MinerU HTTP client、sidecar 写入、`.mnote/ocr-index.json``includeOcr=true` 搜索命中和浏览器 API smoke。但 active job store、realtime job event、前端入口/任务栏仍未落地。
## 证据
- `rust/crates/mnote-web/src/routes/local_ocr.rs` 已注册 `jobs/status/read/insert``provider=mock` 能写入 `{pageStem}.ocr/*.ocr.md``.mnote/ocr-index.json`
- `provider=mineru` 在有 token 时会走真实 HTTP client:申请上传 URL、PUT 上传、轮询结果、下载 zip、提取 Markdown,并写入 `{pageStem}.ocr/*.ocr.md` / `.mnote/ocr-index.json`。该路径已有本地 HTTP mock 单测覆盖,不访问外网。
- `local_search_index` 已支持 `includeOcr=true` 命中 OCR owner page`task526-local-folder-ocr-api-smoke.js` 已覆盖浏览器上下文调用 mock OCR API、搜索命中和显式 insert 链接。
- 设计中的活动任务生命周期、`local_ocr.job.updated` 和浏览器可见入口仍未完成。
## 影响
- 用户无法从本地图片或图片型 PDF 通过 UI 手动触发 MinerU OCR。
- 搜索的 `includeOcr=true` 已能命中 OCR sidecar,但真实 MinerU 结果仍缺 UI 触发链和任务状态可见性。
- AI 资源上下文无法优先读取已有 OCR Markdown。
## 下一步
1. 已完成后端 mock 最小闭环:路径规划、sidecar frontmatter、`.mnote/ocr-index.json`、mock OCR job route、status/read、Page Tree 过滤和 `includeOcr=true` 搜索 owner page。
2. 已补浏览器 API smokemock OCR、status/read/jobs、`includeOcr=true` 搜索 owner page 和显式 insert link。
3. 下一步接长任务状态、`local_ocr.job.updated` realtime 事件、前端 OCR 入口/任务栏和 AI 资源上下文读取 OCR sidecar。
## 验收
- [x] Rust route/helper 测试覆盖 sidecar 路径/frontmatter、index stale/error redaction、mock OCR job 写入、status/read、无 token、越界拒绝、非图片/PDF 拒绝、mock 失败 response 脱敏和显式 insert link。
- [x] local search 测试覆盖 OCR 命中返回 owner pageOCR sidecar 不作为普通页面。
- [x] Page Tree 测试覆盖 OCR sidecar 可在 File Tree 作为资源文件可见,但不作为普通页面进入 Page Tree。
- [x] Rust route 测试覆盖无 token response 形态。
- [x] Rust route 测试覆盖 `provider=mineru` 后端成功路径:申请上传 URL、PUT 上传、轮询结果、下载 zip、提取 Markdown、写 sidecar/index。
- [~] 浏览器 smoke 覆盖图片/PDF 手动 OCR、任务栏状态、打开 OCR、搜索 OCR 命中和插入 OCR 链接。(当前 `task526` 覆盖 mock OCR API、搜索命中、显式 insert link 和 OCR sidecar resource tab 打开;真实 UI 入口/任务栏待做)
## 2026-06-01 后端 mock 闭环记录
新增 `rust/crates/mnote-web/src/routes/local_ocr.rs`,注册:
- `POST /api/local-folder/ocr/jobs`
- `GET /api/local-folder/ocr/jobs`
- `GET /api/local-folder/ocr/status`
- `GET /api/local-folder/ocr/read`
- `POST /api/local-folder/ocr/insert`
当前真实 MinerU HTTP client 仍未接入;`provider=mock` 用于本地后端闭环和测试,`provider=mineru` 在缺少 token 时返回 `mineru_token_missing`,有 token 时返回 `mineru_runtime_not_enabled`,避免伪装真实能力已完成。
已通过验证:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1`
2026-06-01 续补 route 边界测试:
- 越界来源路径拒绝:`local_ocr_path_escape`
- 非图片/PDF 来源拒绝:`local_ocr_source_type_unsupported`
- provider `mineru` 且缺 token 时拒绝:`mineru_token_missing`
- mock 失败响应与 `.mnote/ocr-index.json` 失败条目统一脱敏为 `provider_error_redacted`
- 显式 `ocr/insert` link 模式把 `[OCRphoto.png](./Page.ocr/photo.png.ocr.md)` 追加进 owner Markdown;未调用 insert 时不改正文
本文继续保持 `process`:前端 OCR 入口、任务栏和 active job / realtime event 尚未完成。
2026-06-01 只读复核更新:
- Subagent 复核确认:route/mock/search 测试已落地,早期“没有实际 OCR route”的描述已经过时。
- 本 bug 不归档的阻塞点更新为:真实 MinerU HTTP client、active job store / `local_ocr.job.updated`、前端入口/任务栏、AI 资源上下文读取 OCR sidecar 和完整 UI browser smoke。
2026-06-01 浏览器 API smoke 续补:
- 新增 `scripts/task526-local-folder-ocr-api-smoke.js`,在真实浏览器登录态页面内通过 `fetch` 串起 `POST /api/local-folder/ocr/jobs``status``read``jobs``/api/search/documents includeOcr=true``POST /api/local-folder/ocr/insert`
- 已验证 `provider=mock` 会生成 `docs/Page.ocr/photo.png.ocr.md``includeOcr=false` 不命中 OCR 文本,`includeOcr=true` 返回 owner page 且带 `hasOcr/ocrEvidence`,显式 insert 才向 owner Markdown 追加 `[OCRphoto.png](...)`
- 续补后已验证 OCR sidecar 可作为 Markdown resource tab 打开,截图写入 `tmp/task526-local-folder-ocr-api-smoke/02-ocr-resource-tab.png`
- 已通过:
- `node --check scripts/task526-local-folder-ocr-api-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task526-local-folder-ocr-api-smoke.js`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1`
2026-06-01 MinerU API 口径复核:
- 官方文档当前仍以 `https://mineru.net/api/v4/extract/task``https://mineru.net/api/v4/file-urls/batch` 作为精准解析入口;`model_version` 支持 `pipeline` / `vlm` / `MinerU-HTML`Markdown/JSON 为默认结果格式,图片/PDF/Office 等文档受文件大小与页数限制。
- 当前设计稿中的“申请上传地址 -> PUT 上传 -> 轮询批量结果 -> 下载 zip -> 提取 Markdown”方向仍成立。
- 旧回归测试曾要求 `MNOTE_MINERU_API_TOKEN` 存在且 provider 为 `mineru` 时返回 `mineru_runtime_not_enabled`;该口径已被真实后端 runtime 合同测试替代。
## 2026-06-01 后端 MinerU runtime 补齐记录
- `rust/crates/mnote-web/src/routes/local_ocr.rs` 已补 `mineru_api_base_url``mineru_poll_interval``mineru_max_polls` 配置函数。
- `provider=mineru` 后端路径已接入 `run_mineru_ocr`,执行申请上传 URL、PUT 上传、轮询 batch 结果、下载 zip、提取 Markdown。
- 新增本地 HTTP mock 测试 `local_ocr_jobs_route_runs_mineru_runtime_against_http_mock`,不访问外网,验证真实 client 合同和 sidecar/index 写入。
- 已通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
本文继续保持 `process` 的剩余原因:
- 前端本地文件 / 图片 / PDF 资源没有 OCR 操作入口。
- OCR jobs 仍是同步请求 + `.mnote/ocr-index.json` 历史状态,没有 active job store。
- 缺少 `local_ocr.job.updated` realtime event 与全局任务栏 UI。
- AI 资源上下文还没有优先读取 OCR sidecar。
@@ -0,0 +1,48 @@
# 4-52 resource lifecycle 兼容路径在 Convex 退役后丢失 artifact / 拒绝 mindmap lifecycle
## 状态
- 状态:done
- Owner04-tree-domain / bridge-runtime / resource lifecycle command
- 发现时间:2026-06-01
- 修复时间:2026-06-01
## 现象
执行 `cargo test -p mnote-web mindmap -- --test-threads=1` 时,mindmap 相关宽测曾失败:
- `mindmap_put_derives_workspace_and_returns_tree_artifacts`
- `mindmap_command_apply_returns_object_artifacts_without_tree_resync`
- `mindmap_delete_restore_keeps_markdown_reference_out_of_lifecycle_command`
- `mindmap_trash_routes_delete_restore_purge_and_empty`
- `sidebar_runtime_routes_local_mindmap_and_office_assets`
前两个失败是 `execute_retired_command_plan_with_artifacts()` 在 Convex 退役后仍能执行 fixture mutation,但把 Rust artifact plan 置为 `None`,导致调用方拿不到 `commandLog` / `domainEvent`
后两个失败是 `tree.resource.archive/restore/purge/rename` 在 bridge-runtime 生成 resource-kind 专用函数名前,会先经过 `build_write_request()`;旧 `legacy_command_function_name()` 没有这些正式 tree resource command 名,导致 mindmap lifecycle 被 `retired_bridge_error()` 拒绝。
最后一个失败是 runtime 拆分后,OnlyOffice local open 逻辑已迁到 `sidebar-filetree-open-runtime.js`,但 Rust include 断言仍检查旧 `sidebar-tree-runtime.js` 字符串。
## 根因
- Convex 退役后,artifact 持久化不能再作为兼容路径成功条件;Rust artifact plan 应继续返回给调用方和 realtime consumer。
- resource lifecycle command 的正式命名已从表面 compat alias 收口到 `tree.resource.*`,但旧 Convex 兼容映射表未允许这些名称通过前置 request 构造。
- browser runtime 模块拆分后,测试断言没有同步到新的 canonical owner 文件。
## 修复
- `rust/crates/mnote-web/src/transport/convex.rs`
- `persist_runtime_command_artifacts()` 在 Convex 退役路径改为 no-op 成功。
- `execute_retired_command_plan_with_artifacts()` 重新构造并返回 `RuntimeCommandArtifactPlan`,不重新启用 Convex artifact 持久化。
- `rust/crates/bridge-runtime/src/lib.rs`
- `legacy_command_function_name()` 允许 `tree.resource.archive/restore/purge/rename` 通过前置 request 构造;最终函数名仍由 resource lifecycle plan 按资源类型分流。
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- OnlyOffice local open 断言改查 `SIDEBAR_FILETREE_OPEN_RUNTIME_JS`
- `rust/crates/mnote-web/src/routes/resource_trash.rs`
- `delete_json()` test helper 在状态码失败时输出 body,便于后续定位真实错误码。
## 验证
- `cargo test -p mnote-web mindmap -- --test-threads=1`
结果:26 个 mindmap 相关测试全部通过。
@@ -0,0 +1,33 @@
# 4-53 WorkspacePath / ObjectIdentity 浏览器矩阵缺口
## 状态
- 状态:done
- Owner04-tree-domain / 05-editor-mainline runtime identity
- 发现时间:2026-06-01
- 修复时间:2026-06-01
## 现象
P1 `WorkspacePath / ObjectIdentity` 已有底层协议与部分 smoke,但缺少一个单独覆盖 page `.md`、directory、raw file、mindmap、OnlyOffice 的浏览器矩阵。FileTree row、Resource Tab、OpenEditorsSnapshot、URL `resourceTab` 和 active tab identity 如果各自拼身份,后续 raw resource / mindmap / Office target 容易继续退化为“当前页”语义。
## 根因
已有 smoke 分散覆盖 copy-id、resource tab、mindmap 和 Office,但没有把统一 `ObjectWorkspacePath` 当作运行时消费合同统一断言。local folder 目录行还缺少 `local-dir:*` 的稳定 `documentId` fallbackresource tab 在部分本地资源打开路径下也缺少 `sourceKind` / `rootUri` fallback。
## 修复
- `rust/crates/mnote-web/browser/filetree-runtime.js`
- `readWorkspacePathFromRow()` 为 folder / index 行合成 `local-dir:{relativePath}`
- `fileTreeRowDocumentId()` 允许读取 `data-owner-document-id`,用于资源行 owner page 对齐。
- `rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
- 在 URL 参数缺失时,从 body dataset 或 FileTree row workspacePath 兜底读取 `sourceKind` / `rootUri`
- `scripts/task524-workspace-object-identity-matrix-smoke.js`
- 新增 browser smoke,覆盖 page `.md`、directory、raw text、mindmap、OnlyOffice 的 FileTree row workspacePath、OpenEditorsSnapshot、URL `resourceTab`、active tab identity 对齐。
## 验收
- [x] `node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
- [x] `node --check rust/crates/mnote-web/browser/filetree-runtime.js`
- [x] `node --check scripts/task524-workspace-object-identity-matrix-smoke.js`
- [x] `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task524-workspace-object-identity-matrix-smoke.js`
@@ -1,5 +1,9 @@
# Office local-first 预览、编辑与插件噪音缺口审查 v1
## 状态
- 状态:done
## 背景
本轮只审查 OnlyOffice 与 MNote local-first 工作区之间的三个接口缺口:
@@ -24,9 +28,9 @@ Context7 查询 `/onlyoffice/api.onlyoffice.com` 得到的关键口径:
- `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 噪音
- `/api/onlyoffice/callback` 已由 `5-36` 补齐 local-folder `status 2/6 -> 下载 body.url -> 原文件覆盖写回` 的后端契约;legacy Next 代理仍保留为 cloud/compat 边界
- `layout.rs` / browser runtime 已提供“使用编辑模式打开”入口,默认打开保持 `mode=view`,显式 edit 保留 guard
- annotation/custom assistant 插件噪音已由 `5-37` / `5-40` 分类:插件噪音不等于主文档失败,真实 `errorCode=-18`、WebSocket 失败或 iframe 空白不能被噪音掩盖
## 根因判断
@@ -46,7 +50,7 @@ P0 目标:
- 菜单提供“使用编辑模式打开”入口。
- 编辑入口必须带 guard:清楚标记为实验能力,或在 local-folder writeback 未闭环时阻止/提示。
P1 目标是实现 local-folder callback 写回。
P1 目标是实现 local-folder callback 写回;该项已由 `5-36` 完成后端契约
### 3. annotation 插件 404 / pageerror
@@ -70,7 +74,7 @@ P0 目标:
- P0:若保存闭环未完成,编辑入口必须有 guard / 实验标记。
- `5-36-onlyoffice-local-edit-save-callback-contract-v1.md`
- P1设计或实现 local-folder callback 写回。
- P1local-folder callback 写回。
- P1:覆盖 status 2/6、下载 URL rewrite、路径权限、冲突保护。
- `5-37-onlyoffice-annotation-plugin-noise-policy-v1.md`
@@ -99,9 +103,19 @@ P0 目标:
- 菜单提供“使用编辑模式打开”,进入前有实验 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 未实现
- P1 已拆分并收口
- `5-36`local-folder Office edit/save callback 写回后端契约已完成,覆盖 status 2/6、非写状态、未认证、路径越界
- `5-35``onRequestEditRights` 事件重新初始化`mode=edit` URL,不只 reload 当前 view
- 本 umbrella review 仅作为缺口审查和拆分记录归档;后续真实 Office 编辑保存端到端浏览器验收若发现新问题,应另建具体 bug。
- 浏览器证据:
- 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`
- 2026-06-01 验证:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_page -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_callback -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_local_callback -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p adapter-onlyoffice callback_preparation -- --test-threads=1`
- `node --check scripts/task463-onlyoffice-resolver-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task463-onlyoffice-resolver-smoke.js`
- `node --check scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
@@ -1,5 +1,9 @@
# 5-35 Office 编辑模式菜单与保护
## 状态
- 状态:done
## 目标
Office 文件默认只读打开;在正文附件三点菜单和文件树资源右键菜单中增加“使用编辑模式打开”入口。保存闭环未完成前,编辑入口必须有 guard 或明确实验标记。
@@ -28,16 +32,30 @@ ONLYOFFICE `mode=edit` 只是编辑器初始化模式。若 MNote 没有完成 c
- [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,未实现)
- [x] 若启用 `onRequestEditRights`,必须重新初始化为 edit URL,不只 reload 当前 view。
## 验收
- `cargo test -p mnote-web sidebar_tree_js -- --test-threads=1`
- `cargo test -p mnote-web onlyoffice -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_page -- --test-threads=1`
- 浏览器截图:默认打开是只读;菜单中存在编辑入口;点击编辑入口后的页面 URL / debug state 包含 `mode=edit` 或 guard 提示。
## 本轮执行记录
- 2026-06-01Codex 补齐 `onRequestEditRights` P1。
- `/onlyoffice` 页面新增 `editModeLocationHref()`,触发 `onRequestEditRights` 时将当前 URL 的 `mode` 设置为 `edit``window.location.replace(editHref)`,避免只 reload 当前 view。
- 新增 `onlyoffice_page_reinitializes_edit_url_on_request_edit_rights` 契约测试,断言事件处理、debug 标记和 edit URL replacement 存在。
- 复测 `task463` 时发现并修复普通 `openTarget="new-window"``forceNewWindow || forceEditMode` 误判成 `mode=edit` 的状态泄漏;现在只有 `forceEditMode` 才会进入 edit,普通新窗口继续默认 view / `/office-preview`
- 验证通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_page -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1`
- `node --check scripts/task463-onlyoffice-resolver-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task463-onlyoffice-resolver-smoke.js`
- `node --check scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
- 2026-05-21Reasonix worker B 卡在计划阶段后由 Codex 终止;本条实现来自其它 Reasonix 结果与 Codex 复核修正。
- `buildOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`
- `buildOnlyOfficeOpenPath` 默认 mode 从 `'edit'` 改为 `'view'`
@@ -1,12 +1,16 @@
# 5-36 OnlyOffice local-folder 编辑保存 callback 契约
## 状态
- 状态:done
## 目标
为 local-folder Office 编辑保存建立 callback 写回契约。P1 可以先落设计和测试骨架;若实现,必须覆盖 status 2/6 的下载写回和路径安全。
## 原因
ONLYOFFICE 官方保存链路要求 callback `status === 2``status === 6` 时,集成后端下载 `body.url` 并写回原文件。`mnote-web` callback 仍代理 legacy Nextlocal-folder 写回未闭环。
ONLYOFFICE 官方保存链路要求 callback `status === 2``status === 6` 时,集成后端下载 `body.url` 并写回原文件。`mnote-web` callback 仍代理 legacy Nextlocal-folder 写回未闭环。本轮已补齐 local-folder callback 写回路径。
## 允许修改
@@ -23,21 +27,38 @@ ONLYOFFICE 官方保存链路要求 callback `status === 2` 或 `status === 6`
## 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,不让用户以为保存已支持
- [x] 明确 local asset callback 定位:callback URL 携带 `rootUri` / `path` / `sessionId` / `token`,并用 bridge session 校验 asset match。
- [x] status 非 2/6 时返回 `{ "error": 0 }`,不写文件。
- [x] status 2/6 时下载 rewritten `body.url`
- [x] 写回前校验目标路径属于当前 local root allowed roots。
- [x] 写回后写入原始本地文件;后续页面刷新由现有 local-folder watcher / projection refresh 感知。浏览器编辑保存端到端 smoke 另列后续验收,不作为本 bug 阻塞项
- [x] 编辑入口保留 guard,不让用户误解为完整协作编辑能力已产品化
## 验收
- `cargo test -p mnote-web onlyoffice_callback -- --test-threads=1`
- `cargo test -p mnote-web -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_callback -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_local_callback -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p adapter-onlyoffice callback_preparation -- --test-threads=1`
- 后续浏览器编辑保存验收需单独设计,不纳入 P0。
## 本轮执行记录
- 2026-06-01Codex 复核当前实现并补齐 callback 写回回归测试。
- `OnlyOfficeCallbackQuery` 已携带 `root_uri` / `path` / `session_id` / `token`
- `buildCallbackUrl()` 已把 local-folder Office asset 的 root/path/session/token 写入 callback query。
- `local_folder_onlyoffice_callback()` 已校验 root/path、bridge session token、session asset match,并通过 `resolve_onlyoffice_local_file_path()` 阻断 root escape。
- status 2/6 通过 `prepare_callback()` rewrite 后下载 `body.url` 并写回原始本地文件;非 2/6 返回成功且不写文件。
- 新增/确认测试覆盖:
- `onlyoffice_local_callback_rejects_unauthenticated_local_write`
- `onlyoffice_local_callback_writes_status_two_body_to_original_file`
- `onlyoffice_local_callback_writes_status_six_body_to_original_file`
- `onlyoffice_local_callback_rejects_root_escape_path`
- `onlyoffice_local_callback_ignores_non_write_status`
- 验证通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_callback -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_local_callback -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p adapter-onlyoffice callback_preparation -- --test-threads=1`
- 2026-05-21Reasonix worker C 分析完成。
**结论:本轮不应实现完整写回。**
@@ -1,5 +1,7 @@
# 5-37 OnlyOffice annotation 插件噪音策略
> 2026-06-01 复核:本文是 2026-05-21 旧口径。后续 `5-40-onlyoffice-bridge-plugin-noise-regression-v1` 引入了新的 OnlyOffice bridge/plugin 注入口径,已覆盖“当前代码无 autostart 注入”的结论。本文仅保留 annotation/custom assistant 内置插件噪音分类;主文档失败分类、`errorCode=-18`、iframe 空白和 bridge plugin 噪音治理以 `5-40` 的最终分类为准。`5-40` 已通过 `task518-onlyoffice-real-iframe-session-scope-smoke.js``errorClassification` 验收,本文不再作为活跃 process 缺陷。
## 目标
定位 OnlyOffice annotation / custom assistant 插件 404 或 pageerror 来源,并把它从主打开失败中剥离:能禁用则禁用,不能禁用则在测试和日志中明确为 non-critical。
@@ -25,7 +27,7 @@ ONLYOFFICE 插件通过 `editorConfig.plugins.autostart` 和 `pluginsData` 注
- [x] 找到 annotation/custom assistant 插件配置或请求来源。
- [x] 若 MNote 当前不依赖该插件,禁用无效 autostart / pluginsData。(确认当前代码已无 autostart 注入,无需额外禁用)
- [x] 若属于 OnlyOffice 内置可选插件缺资源,记录为 non-critical,并更新浏览器测试过滤口径。
- [ ] 补测试或文档,确保 `errorCode=-18` / WebSocket 失败仍被视为失败,不被插件噪音掩盖。(P1,后续补充
- [x] 补测试或文档,确保 `errorCode=-18` / WebSocket 失败仍被视为失败,不被插件噪音掩盖。(`5-40` / `task518-onlyoffice-real-iframe-session-scope-smoke.js``errorClassification.mainDocument` 覆盖
## 验收
@@ -0,0 +1,67 @@
# 5-40 ONLYOFFICE bridge 插件注入后噪音口径需复测
## 状态
- 状态:done
- Owner05-editor-mainline / OnlyOffice preview-edit / browser smoke
- 发现时间:2026-05-31
## 现象
旧缺陷 `5-37` 的结论是当前代码没有注入 `plugins.autostart` / `pluginsData`,因此 annotation/custom assistant 插件 404 可作为 OnlyOffice 内置非关键噪音处理。但近期 ONLYOFFICE live bridge 已在 `onlyoffice.rs` 中重新注入 MNote bridge 插件,这会让旧噪音策略失效,必须重新区分 MNote bridge 插件失败、OnlyOffice 内置插件噪音和真正主文档加载失败。
## 证据
- `bugs/05-editor-mainline/process/5-37-onlyoffice-annotation-plugin-noise-policy-v1.md` 记录:“当前代码已无 autostart 注入,无需额外禁用”。
- `rust/crates/mnote-web/src/routes/onlyoffice.rs` 当前 `editorConfig.plugins` 注入:
- `autostart: [MNOTE_AGENT_PLUGIN_GUID]`
- `pluginsData: [bridgePluginConfigUrl.toString()]`
## 影响
- clean browser 测试里 MNote bridge 插件加载失败可能被错误归类为“OnlyOffice 内置插件噪音”。
- 相反,主 iframe 空白、`errorCode=-18`、DocumentServer WebSocket 失败也可能被泛化过滤掉。
- Office live agent 能力上线后,插件加载失败将不再是纯噪音,而是 AI Office 工具不可用。
## 最小复现建议
1. clean browser 打开 docx/xlsx/pptx。
2. 记录 console、pageerror、network 404/500、OnlyOffice iframe ready state。
3. 分别验证:
- MNote bridge 插件 config/index 是否成功加载。
- OnlyOffice 内置 annotation/custom assistant 缺资源是否仍为 non-critical。
- 主 iframe 空白、`errorCode=-18`、WebSocket 失败仍判定为失败。
## 修复建议
- 更新 `5-37` 或新 smoke 的错误分类规则:MNote bridge 插件错误不等同于内置插件噪音。
- 在 OnlyOffice smoke 中输出插件分类字段,例如 `mnoteBridgePluginOk``builtinPluginNoiseOnly``mainDocumentReady`
- 对 bridge 插件 config/index 增加 route 单测和 browser smoke 断言。
## 本轮进展
- 2026-06-01:新增并通过 `scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js`,在真实 Chromium 中 mock `Asc.plugin` 后直接加载 bridge plugin index,验证 session 注册、token 错误拒绝、`selection.get` / `document.insert_text` command 消费和 result 回传。
- 该 smoke 证明 MNote bridge plugin index / HTTP loop 自身可用,但不覆盖真实 ONLYOFFICE iframe / DocumentServer 的 autostart、内置插件噪音、主文档 ready 或 `errorCode=-18` 分类,因此本 bug 仍保留在 `process/`
- 2026-06-01:新增并通过 `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`,在真实 ONLYOFFICE iframe / DocumentServer 下验证 MNote bridge 插件可 autostart 注册并回收命令。该 smoke 记录到的 404 均为 `/api/onlyoffice/bridge/plugin/index/translations/langs.json``/api/onlyoffice/bridge/plugin/index/translations/zh-CN.json`,当前可归类为 bridge 插件翻译资源噪声;它没有阻断 session 注册、`selection.get` 或工具层授权 dry-run。
- 2026-06-01:增强并通过 `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`,输出 `errorClassification`
- `mnoteBridgePlugin.httpErrors=[]`
- `mnoteBridgePlugin.sessionsRegistered=true`
- `mnoteBridgePlugin.commandLoopOk=true`
- `bridgeTranslationNoise.count=6`,仅包含 `/api/onlyoffice/bridge/plugin/index/translations/langs.json``zh-CN.json` 404
- `builtinPluginNoise.count=0`
- `mainDocument.ready=true`
- `mainDocument.failures=[]`
- `mainDocument.errorCodeMinus18=false`
该分类把 MNote bridge 插件失败、bridge 翻译资源噪音、OnlyOffice 内置插件噪音和主文档失败分开;`errorCode=-18` / 主 iframe 空白不会被插件噪音吞掉。
## 验收
- [x] Clean browser smoke 能区分三类错误:MNote bridge 插件失败、OnlyOffice 内置插件噪音、主文档加载失败。
- [x] `errorCode=-18` / 主 iframe 空白不会被插件噪音过滤吞掉。
- [x] bridge 插件不可用时,Office 文档预览结论和 Office AI 工具可用性结论分开报告。
## 最终验证
- `node --check scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
@@ -0,0 +1,57 @@
# 5-41 Page Aggregate compat 瘦身剩余缺口
## 状态
- 状态:done
- Owner05-editor-mainline / Page Aggregate / browser document conversion
- 发现时间:2026-06-01
## 现象
Page Aggregate 已经完成 local-first 主链和多个单一真源修复。2026-06-01 复核后,`body.content`、compat join 和浏览器 legacy content 降级被明确收口为 v1 兼容面:它们仍是 active runtime 的兼容读写字段,但不再作为 local-first 浏览器主事实源。
## 证据
- `rust/crates/core-protocol/src/page_aggregate.rs``PageBody.content` 仍是必填字段。
- `rust/crates/mnote-web/src/routes/local_folder_source.rs` 的 local-first aggregate 仍会填充 `body.content`
- `rust/crates/bridge-runtime/src/lib.rs` 的 cloud/compat aggregate 路径仍支持 `CompatMetaContentJoin`
- `rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js` 已在 2026-06-01 调整为 `blockDocument/editorDocument` 优先,`local_markdown.content``compat.legacy_content` 只作为缺少 block document 时的降级路径;`scripts/task522-page-aggregate-compat-fallback-contract.js` 覆盖了这几个 source 分支。
- `rust/crates/mnote-web/src/routes/documents.rs` 的保存兼容面仍同时携带 `editorDocument``content``tiptapDocument`
- `rust/crates/mnote-web/src/page_aggregate/builder.rs` 的默认 source 已在 2026-06-01 改为 `KernelProjection`,显式 `.source(PageAggregateSource::CompatMetaContentJoin)` 仍保留 compat 路径。
## 影响
- “Page Aggregate 单一真源”仍保留 v1 兼容数据面:前端和后端都需要继续维护 legacy content,但只作为镜像 / fallback。
- local-first 浏览器转换层已优先消费 block document;后端协议和 compat 保存面继续保留 legacy content,保证 cloud/compat fallback 和旧保存面不被破坏。
- 后续若要删除或改为可选 `body.content`,必须进入 `mnote.page_aggregate.v2` 或等价协议升级 checklist,不能在 v1 中直接删除。
## 下一步建议
- `PageBody.content``mnote.page_aggregate.v1` 中继续保持必填兼容字段,用于 legacy/cloud/compat fallback、旧保存面和降级回读。
- `body.blockDocument` / `editorDocument` 是 local-first 浏览器主消费面;`body.content` 不再作为 active editor 初始化的优先事实源。
- `CompatMetaContentJoin` 继续只作为显式 cloud/compat substrate 边界保留;`PageAggregateBuilder::new()` 默认 source 保持 `KernelProjection`
- `body.content` 改为可选或删除的退出条件:必须先有 `mnote.page_aggregate.v2` 协议或迁移 checklist,证明 cloud/compat 保存面、外部 agent、历史 fixture 和降级读取全部不再依赖该字段。
## 验收
- [x] local-first 浏览器转换优先消费 `body.blockDocument``pageBodyTiptapDocumentSource(...)``blockDocument` 存在时返回 `page_aggregate.block_document`
- [x] `body.content` 仅在缺少 `blockDocument` / `editorDocument` 时作为 local markdown legacy fallback。
- [x] Builder 默认 source 不再是 `CompatMetaContentJoin`
- [x] `compat.legacy_content` 降级路径有明确 source 标记和测试覆盖。
- [x] 设计稿明确 `body.content` 的长期状态:v1 保留为必填兼容镜像 / fallback;v2 或后续协议升级再评估可选或删除。
## 2026-06-01 验证
- `node --check rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js`
- `node --check scripts/task522-page-aggregate-compat-fallback-contract.js`
- `node scripts/task522-page-aggregate-compat-fallback-contract.js`
- `cargo test -p mnote-web document_shell_returns_page_aggregate_snapshot -- --test-threads=1`
- `cargo test -p mnote-web page_aggregate::builder::tests -- --test-threads=1`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js`
## 2026-06-01 收口决策
- `PageBody.content`:v1 保留必填,语义降级为兼容镜像 / fallback,不作为 local-first active editor 主事实源。
- `body.blockDocument` / `editorDocument`local-first 浏览器转换优先消费的 canonical block view。
- `compat.legacy_content`:仅当缺少 `blockDocument` / `editorDocument` 时可被浏览器转换层使用,并通过 `task522-page-aggregate-compat-fallback-contract.js` 固化 source 标记。
- `CompatMetaContentJoin`:显式 compat substrate 边界,保留到 cloud/legacy 保存面完成协议升级,不再是 builder 默认 source。
@@ -0,0 +1,69 @@
# 7-45 ONLYOFFICE live bridge session 隔离风险
## 状态
- 状态:done
- Owner07-ai / OnlyOffice live bridge / mnote-web
- 发现时间:2026-05-31
## 现象
ONLYOFFICE live bridge session 当前是进程内全局状态。工具调用未显式传 `onlyofficeSessionId` / `bridgeSessionId` 时,会 fallback 到最近活跃 session;同一文档的 bridge session id 又由 `docKey` 派生,重复打开同文档或多用户/多 tab 打开不同 Office 文档时,存在命令落到错误 Office tab 或 token 覆盖的风险。
## 证据
- `rust/crates/mnote-web/src/routes/onlyoffice.rs` 生成 `bridgeSessionId = "mnote-oo-" + fileState.docKey`
- `rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs``register_session``session_id` 覆盖 `token``document_id``asset_id``last_seen_millis`
- `rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs``current_session_info()` 直接取全局 `last_seen_millis` 最大值。
- `rust/crates/mnote-web/src/hermes_tools/onlyoffice_live.rs``resolve_session_id()` 在未传 session id 时 fallback 到 `current_session_info()`
## 影响
- Page AI 或 Hermes tool 可能在多 Office tab 场景写错目标文档。
- 同一文档重复打开可能互相覆盖 bridge token,使旧 tab 的插件长轮询或结果回传失效。
- 多用户共享同一 mnote-web 进程时,全局最近 session 可能跨用户泄漏目标选择。
## 最小复现建议
1. 打开两个不同 Office 文档,或同一文档两个 tab。
2. 不传 `onlyofficeSessionId` 调用 `mnote.onlyoffice.session.current` 与一个写工具,例如 `mnote.onlyoffice.document.insert_text`
3. 观察返回 session 是否只由最近活跃 tab 决定。
4. 同一文档双 tab 复测 token 覆盖后,旧 tab `commands/next``results` 是否返回 401 / 无结果。
## 修复建议
- bridge session id 加入 browser tab 级随机后缀,不只使用 `docKey`
- Page AI run payload 必须携带当前 resource tab 的 explicit `onlyofficeSessionId`;写工具禁止默认 fallback 写入。
- `current_session` 只能作为只读诊断工具,不能作为写工具默认目标。
- session state 至少按 actor/session/document/resource 维度过滤。
## 本轮进展
- 2026-05-31
- `onlyoffice.rs` 已把 `bridgeSessionId` 从仅基于 `docKey` 改为 `docKey + bridgeSessionSalt`,避免同文档多 tab 共用同一个 bridge session id。
- `onlyoffice_live.rs` 已禁止读/写 action fallback 到全局最近 session;读写工具必须显式传 `onlyofficeSessionId` / `bridgeSessionId`
- `onlyoffice.rs` 的本地 callback 已绑定 bridge `sessionId` + `token`,未认证本地写回返回 401 并保持原文件不变。
- 已补 Rust 定点测试覆盖缺 explicit session 的读/写工具拒绝,以及未认证 local callback 拒绝。
- 已补 `scripts/task515-onlyoffice-live-scope-http-smoke.js` 覆盖 HTTP 层缺 explicit session 返回 400。
- 已补并通过 `scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js`,在真实浏览器中验证 A/B 两个 bridge session 的 token 校验、command queue 和 result 回收互不串台,错误 token 返回 401。
- 2026-06-01
- 已补并通过 `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`,在真实 ONLYOFFICE iframe / DocumentServer 下打开同一 docx 两个 tab 和另一个 docx,验证同文档双 tab 共享 `docKey` 但使用不同 `bridgeSessionId`,三个插件 session 均能注册并各自回收 `selection.get` command。
- `task518` 同时覆盖显式传 B session 但 scope=A 时返回 403,以及 scope=B 授权 dry-run 返回 200,证明真实 iframe session 能被工具层按显式 session 和 resource scope 约束。
- Page AI open editors snapshot / target package 已开始保留 Office `onlyofficeSessionId` / `bridgeSessionId`,服务端 `agentTargetPackage``aiAccessScope.allowedResourceIds` 已能保留 Office asset、session 和 `resource:onlyoffice:{documentId}:{assetId}` 候选;新增 Rust 定点测试 `hermes_client_run_body_preserves_onlyoffice_target_scope` 覆盖该合同。
- ONLYOFFICE iframe 在设置 `__MNOTE_ONLYOFFICE_DEBUG__` 后会向父窗口发送 `mnote:onlyoffice-bridge-ready`resource tab runtime 收到后事件驱动刷新 open editors snapshotPage AI 发送前也会拒绝没有 `onlyofficeSessionId` 的 Office target,避免 bridge 未就绪时发起 run。
- `task518` 已扩展并通过非 dry-run 写入落点验证:通过 `mnote.onlyoffice.document.insert_text` 向 Office B 写入唯一 marker,再通过真实 iframe bridge `document.export` 导出 A/B 内容,断言 B 包含 marker 且 A 不包含;截图保存到 `tmp/task518-onlyoffice-real-iframe-session-scope-smoke/screenshots/office-a-after-write.png``office-b-after-write.png`
- 已补并通过 `scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js`:在真实文档页内打开 Office resource tab,等待 iframe bridge ready 后通过 Page AI target picker 选择 Office target,断言 `editorTarget``targetPackage` 均冻结真实 `onlyofficeSessionId` / `bridgeSessionId`,并断言无 `consoleErrors` / `networkFailures` / `httpErrors`
当前 P0 安全阻断已覆盖“工具不能隐式读写全局最近 session”“callback 不能未认证写本地文件”“bridge session/token/queue 在浏览器 HTTP 层隔离”“真实 iframe 插件 session 显式授权边界”“Page AI run scope 不再被服务端压回当前页面 id”“Office bridge 未就绪时 Page AI 发送前拒绝”“真实 iframe 非 dry-run 写入不串台”和“真实 Page AI UI 从 Office iframe 目标取到 live `bridgeSessionId` 并冻结进 run payload”。
## 验收
- [x] Rust 单测覆盖页面生成同 docKey 双 session 随机 session id。
- [x] Rust 单测覆盖读/写工具缺 explicit session id 时拒绝。
- [x] Rust 单测覆盖本地 callback 缺 bridge session/token 时拒绝。
- [x] Browser smoke 覆盖两个 bridge session 的 token、command queue 与 result 隔离。
- [x] Browser smoke 覆盖两个真实 Office iframe tab 下插件注册、sessionId 隔离和 command 回收。
- [x] Rust 单测覆盖 Page AI Office target package / aiAccessScope 保留 session 与 resource scope。
- [x] 前端事件驱动刷新覆盖 ONLYOFFICE bridge ready 消息,发送前拒绝缺 session 的 Office target。
- [x] Browser smoke 覆盖两个真实 Office iframe tab 下非 dry-run 写入目标不串台。
- [x] Browser smoke 覆盖 Page AI target picker 从真实 Office iframe target 读取 live `onlyofficeSessionId` 并写入 run payload。
@@ -0,0 +1,66 @@
# 7-46 ONLYOFFICE live tool 缺少 resource scope 绑定
## 状态
- 状态:done
- Owner07-ai / Hermes tools / OnlyOffice live bridge
- 发现时间:2026-05-31
## 现象
ONLYOFFICE live 写工具当前主要检查 `idempotencyKey``dryRun``aiAccessScope.permissionLevel` 和 command context 写权限,但没有把 bridge session 的 `documentId` / `assetId``aiAccessScope.allowedResourceIds` 绑定。调用方如果显式传入另一个已注册 Office session,存在越过当前 target resource scope 写入非授权资源的风险。
## 证据
- `rust/crates/mnote-web/src/routes/hermes_tools.rs` 注册了多组 `mnote.onlyoffice.*` 读写工具。
- `rust/crates/mnote-web/src/hermes_tools/onlyoffice_live.rs``run_write_action()` 调用 `ensure_write_authorized()` 后立即解析 session 并执行 bridge command。
- `rust/crates/mnote-web/src/hermes_tools/onlyoffice_live.rs``resolve_session_id()` 只解析显式 session 或全局 current session,没有验证该 session 对应的 resource 是否在 `allowedResourceIds` 内。
- 对照 `rust/crates/mnote-web/src/hermes_tools/resource.rs`mindmap/resource 工具已有 `ensure_resource_scope_allowed()` 校验 `allowedResourceIds` / `objectIdentity`
## 影响
- Page AI 当前 target 是资源 A 时,模型或恶意调用可传入资源 B 的 `bridgeSessionId`,尝试写入 B。
- 写入审计会显示工具有写权限,但缺少“写的是哪个 resource、是否被 allowedResourceIds 授权”的闭环。
- 多 tab session fallback 与本 bug 叠加时,错误写入更难被用户发现。
## 最小复现建议
1. 构造 `aiAccessScope.allowedResourceIds = [A]`
2. 注册两个 ONLYOFFICE bridge sessionA 和 B。
3. 调用 `mnote.onlyoffice.sheet.set_value``mnote.onlyoffice.document.insert_text`,显式传 B 的 `bridgeSessionId`
4. 期望:返回 403;当前风险:只要通用写权限通过就可能执行。
## 修复建议
- `BridgeSessionInfo` 暴露稳定 `documentId``assetId``objectIdentity`
- ONLYOFFICE live 工具增加与 resource 工具等价的 scope 校验。
- 写工具返回 receipt 时包含 `resourceKind=office``documentId``assetId``onlyofficeSessionId` 与 permission decision。
- dry-run 也必须执行 scope 校验,不能只返回 wouldWrite。
## 本轮进展
- 2026-05-31
- `onlyoffice_live.rs` 已在读/写 action 执行前校验 explicit session 对应的 `sessionId` / `documentId` / `assetId` / `resource:office:{documentId}:{assetId}` 是否包含在 `aiAccessScope.allowedResourceIds`
- 缺失 `aiAccessScope` 或空 `allowedResourceIds` 现在返回 403,不再兼容放行;dry-run 同样执行该 scope 校验。
- `manifest.rs` 已为 OnlyOffice live 工具声明 `aiAccessScope.allowedResourceIds``onlyofficeSessionId` / `bridgeSessionId``anyOf` 合同。
- 已补 Rust 定点测试覆盖 `allowedResourceIds=[asset_a]` 时禁止写入 session `asset_b``allowedResourceIds=[asset_allowed]` 时允许生成 dry-run plan,缺 scope 时返回 403,以及 manifest 合同。
- 已补并通过 `scripts/task515-onlyoffice-live-scope-http-smoke.js`HTTP 层覆盖缺 explicit session 400、scope 不匹配 403、缺 scope 403、授权 scope dry-run 200。
- 2026-06-01
- resource scope candidate 已同时接受 `resource:office:{documentId}:{assetId}` 与 FileTree / resource 对象侧使用的 `resource:onlyoffice:{documentId}:{assetId}`,避免真实 Page AI target 使用 OnlyOffice object identity 时被误拒。
- 已补并通过 `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`:真实 ONLYOFFICE iframe / DocumentServer 下,显式传入 Office B 的 `bridgeSessionId``aiAccessScope.allowedResourceIds=[Office A]` 时返回 403;授权 B 的 `assetId` / `resource:onlyoffice:{documentId}:{assetId}` 时 dry-run 返回 200。
- `document-resource-tab-runtime.js` / `sidebar-page-ai-runtime.js` / `hermes_client.rs` 已补最小 target scope 链路:Office resource target 可把 `onlyofficeSessionId` 写入 target package,服务端 sanitize 不再丢弃 `primaryTargetId``targets[]``assetId``onlyofficeSessionId`,本地 run instructions 的 `aiAccessScope.allowedResourceIds` 会包含 Office asset、session 和 `resource:onlyoffice:{documentId}:{assetId}`。新增 `hermes_client_run_body_preserves_onlyoffice_target_scope` 证明服务端不再把 Office target scope 降级为当前页面 id。
- 本地 agent instructions 已明确要求从 `agentRunEnvelope.targetPackage.onlyofficeSessionId` 或对应 target 取值传给 `mnote.onlyoffice.*` 工具,不允许 fallback 到最近活跃 Office session。
- `task518` 已扩展并通过授权 B session 的非 dry-run 写入验证:写入后 `document.export` 证明 B 包含唯一 marker,A 不包含,补齐真实 iframe 层写入落点证据。
- 已补并通过 `scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js`:真实 Page AI UI 选择 Office resource target 后,run payload 中 `editorTarget.onlyofficeSessionId``targetPackage.onlyofficeSessionId``targetPackage.targets[0].onlyofficeSessionId` 均等于 iframe live bridge session`allowedFiles` 只包含选中 Office 文件路径,且无 buffer-state 404 / console error。
当前 `task518` 已覆盖真实 iframe session + 工具层 resource scope + 非 dry-run 写入落点,`task523` 补齐真实 Page AI UI target picker 到 live Office session 的 run payload 绑定;组合后覆盖“UI 只授权选中 Office target,工具层拒绝未授权 session”的端到端安全边界。
## 验收
- [x] Rust 单测覆盖 allowedResourceIds 不包含 session resource 时写工具返回 403。
- [x] Rust 单测覆盖 allowedResourceIds 包含 `assetId``objectIdentity` 时允许执行。
- [x] Rust 单测覆盖缺失 / 空 resource scope 时拒绝。
- [x] HTTP smoke 覆盖工具层 session/scope 边界。
- [x] Browser smoke 覆盖真实 ONLYOFFICE iframe session 的工具层 A/B resource scope 边界。
- [x] Rust 单测覆盖 Page AI Office target package 生成的 aiAccessScope 包含 Office asset/session/object identity。
- [x] Browser smoke 覆盖真实 Page AI UI 选择 Office target 后只把该 Office 的 live session 与 relativePath 冻结进 run payload;工具层 A/B session 越权拒写由 `task518` 覆盖。
@@ -0,0 +1,43 @@
# 7-47 local-first Page AI 写入入口口径漂移
## 状态
- 状态:done
- Owner07-ai / Page AI / design governance
- 发现时间:2026-05-31
## 现象
`design/10-review/done/11-current-full-architecture-review-v1.md` 保留了“`mnote.doc.markdown_edit` 已是简单正文编辑主路径”的历史结论。但当前 AGENTS、ARCHITECTURE、CURRENT_ARCHITECTURE 与 `7-18` 都已经把 local-first 普通 Markdown 编辑主路径改为:定位真实 `.md` 文件,Hermes/Reasonix 在 allowed roots 内使用自身 patch/diff/文件编辑能力写入,再由 watcher / BufferStore / Page Aggregate 同步。旧 review 结论容易误导后续 worker 继续扩 `mnote.doc.markdown_edit`
## 证据
- `design/10-review/done/11-current-full-architecture-review-v1.md` 写明 `mnote.doc.markdown_edit` 是简单正文编辑主路径。
- `AGENTS.md``ARCHITECTURE.md` 当前口径明确:`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback。
- `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 明确要求 Agent Target Resolver、allowed roots/files、dirty guard 和 agent 原生 patch/diff 主路径。
## 影响
- 后续 Page AI 任务可能绕开 `7-18` 的 target package / allowed roots / dirty conflict 模型。
- AI 写入审计、文件版本冲突和 watcher 同步难以统一。
- `mnote.page.save` / `mnote.doc.markdown_edit` 可能被继续当成 local-first 精确编辑入口扩张。
## 修复建议
-`11-current-full-architecture-review-v1.md` 补充“历史快照,local-first 主路径已被 `7-18` 覆盖”的说明,或在 `7-18` 中添加覆盖旧 review 的显式引用。
- Page AI 新任务默认从 `7-18` 拆 checklist,不从 `11` 的旧 tool 主路径拆实现。
- 对 Hermes tool guidance 增加断言:local-first 普通 Markdown 编辑优先文件引用和 patch/diff,工具写入只作为 fallback。
## 本轮进展
- 2026-05-31:已在 `design/10-review/done/11-current-full-architecture-review-v1.md` 顶部补充历史快照说明,明确当前 local-first 普通 Markdown 编辑主路径以 `AGENTS.md``CURRENT_ARCHITECTURE.md``ARCHITECTURE.md``7-18` 为准。
- 2026-06-01:已补 Page AI run 的 `mnote.agent_target_package.v1` 输入和后端 sanitize / allowedFiles 派生,避免 local-first agent 只拿到目录级 `allowedRoots` 而缺少冻结目标文件。该进展只覆盖运行时输入合同,target chip / picker、真实 agent 写入回收和 dirty conflict 仍按 `7-18` 后续阶段推进。
- 2026-06-01:已检查 Hermes client runtime guidance。local-first 分支已经明确“普通 Markdown 编辑优先使用 agent 原生 patch/diff 写入真实文件”;remote/cloud/compat 分支中 `mnote_doc_markdown_edit` 的描述已补上“远端 / cloud / compat”限定,测试名同步改为 `hermes_client_run_guidance_prefers_markdown_edit_only_for_remote_compat_plain_body_edits`
- 2026-06-01:已给 `design/10-review/done/08``09``10` 补充历史快照说明,避免旧 review 结论继续覆盖 `7-18` 当前口径。
## 验收
- [x] `rg -n "markdown_edit.*主路径|简单正文编辑主路径|应优先调用 mnote_doc_markdown_edit|mnote_doc_markdown_edit for plain" design AGENTS.md ARCHITECTURE.md CURRENT_ARCHITECTURE.md rust/crates/mnote-web/src/routes/hermes_client.rs -g '*.md' -g '*.rs'` 的剩余命中均属于历史快照说明、已退役/降级口径、old 目录或 remote/cloud/compat 分支。
- [x] Page AI task prompt / skill guidance 中明确区分 local-first 主路径与 cloud/remote/compat fallback。
- [x] `cargo test -p mnote-web hermes_client_run_guidance_prefers_markdown_edit_only_for_remote_compat_plain_body_edits -- --test-threads=1`
@@ -0,0 +1,29 @@
# 7-48 Mindmap create_from_outline envelope 单测缺少 embed 边界
## 状态
- 状态:done
- Owner07-ai / Hermes tools / mindmap resource
- 发现时间:2026-05-31
- 修复时间:2026-05-31
## 现象
`cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib hermes_tools_mindmap -- --test-threads=1``hermes_tools_mindmap_create_from_outline_writes_default_envelope` 失败,返回:
```text
mnote_resource_page_not_found: 找不到要绑定 mindmap 的本地 Markdown 页面
```
## 根因
该用例只验证 `mnote.mindmap.create_from_outline` 生成默认 `.mindmap.json` envelope,但 payload 未显式设置 `embedIntoPage`。当前工具合同中 `embedIntoPage` 默认是 `true`,而测试 fixture 没有创建 `README.md`,导致资源文件写入后进入页面 embed 阶段并失败。
## 修复
- 在纯 envelope 写入用例里显式设置 `embedIntoPage: false`
- 保留另一个 `hermes_tools_mindmap_create_from_outline_can_embed_into_local_markdown_page` 用例继续覆盖显式 `embedIntoPage=true` 的页面绑定行为。
## 验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib hermes_tools_mindmap -- --test-threads=1`
@@ -0,0 +1,51 @@
# 7-50 ONLYOFFICE session.current 诊断工具缺少 resource scope 过滤
## 状态
- 状态:done
- Owner07-ai / OnlyOffice live bridge / Hermes tools
- 发现时间:2026-06-01
## 现象
`mnote.onlyoffice.session.current` 是诊断工具,不会执行读写动作;但它在未传 `onlyofficeSessionId` / `bridgeSessionId` 时仍会 fallback 到进程内最近活跃 OnlyOffice bridge session,并返回 `sessionId``documentId``assetId``fileType`、pending command/result 计数等元数据。
这与读写工具已经要求 explicit session + `aiAccessScope.allowedResourceIds` 的收口方向不完全一致。若 Page AI 当前 target=A,但进程内最近活跃 Office session 属于 resource=B,模型可能通过 `session.current` 看到 B 的 session 元数据。
## 证据
- `rust/crates/mnote-web/src/hermes_tools/onlyoffice_live.rs``session_current()` 允许缺 session id 时使用 `current_session_info()`
- `rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs``current_session_info()` 返回全局最近活跃 session。
- Reasonix 只读复核 `reasonix-2026-05-31T16-38-36-472Z-63f3a41c` 判断:这不是写绕过,但属于低级信息泄漏风险;建议 `session_current` 也加入 scope 校验或过滤返回字段。
## 影响
- 不会直接写入或读取文档正文,因此风险低于 `7-45` / `7-46`
- 可能暴露非当前 target resource 的 Office session 元数据,给后续工具调用或模型选择目标带来混淆。
- 与 OnlyOffice live bridge 的最小权限口径不一致。
## 修复建议
- `session_current` 若传 explicit session id,应校验该 session 对应的 `sessionId` / `documentId` / `assetId` / `resource:office:{documentId}:{assetId}` 是否在 `aiAccessScope.allowedResourceIds` 中。
- `session_current` 若未传 explicit session id,不应返回全局最近活跃 session;可以改为返回 `mnote_onlyoffice_session_explicit_required`,或只在 debug/admin 边界允许。
- 如果保留诊断 fallback,至少过滤 `documentId` / `assetId` 等跨 resource 元数据,并在 manifest 标注只用于诊断。
## 本轮处理
- 2026-06-01`mnote.onlyoffice.session.current` 已改为复用 `resolve_explicit_session_id()``ensure_onlyoffice_resource_scope_allowed()`
- 已删除 `onlyoffice_bridge::current_session_info()` 全局最近 session fallback,避免诊断工具继续返回非当前 target 的 session 元数据。
- `manifest.rs``mnote.onlyoffice.session.current` 已复用 OnlyOffice live 工具 schema,要求 `aiAccessScope.allowedResourceIds`,并通过 `anyOf` 要求 `onlyofficeSessionId``bridgeSessionId`
- `scripts/task515-onlyoffice-live-scope-http-smoke.js` 已扩展覆盖 `session.current` 缺 explicit session 返回 400、scope 不匹配返回 403、授权 scope 返回 session 元数据。
## 验收
- [x] Rust 单测覆盖 target=A 的 `aiAccessScope.allowedResourceIds` 不能通过 `session.current` 获取 resource=B 的 session 元数据。
- [x] 缺 explicit session id 的 `session.current` 不再返回全局最近 session。
- [x] `task515` HTTP smoke 覆盖 `session.current` scope 边界。
验证:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib hermes_tools_onlyoffice_session_current -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib hermes_tools_manifest_describes_onlyoffice_live_scope -- --test-threads=1`
- `node --check scripts/task515-onlyoffice-live-scope-http-smoke.js`
- `node scripts/task515-onlyoffice-live-scope-http-smoke.js`
@@ -0,0 +1,57 @@
# 7-51 mindmap apply_ops 结构化写入与可见性缺口
## 状态
- 状态:done,工具层最小结构化写入、刷新后 embedded mindmap id 保真、真实 mindmap resource tab Page AI target 已修复;更丰富 `apply_ops` 指令面扩展另拆 follow-up,不阻塞本 bug 归档
- Owner07-ai / Hermes tools / mindmap resource
- 发现时间:2026-06-01
## 现象
`mnote.mindmap.apply_ops` 的非 dry-run 路径曾不执行结构化写入,而是在完成资源路径和写权限校验后返回 `mnote_resource_native_patch_required`,提示 agent 使用原生 patch 编辑授权文件。这与 `7-42` 第二批目标“把 `apply_ops` 从提示 agent patch 文件收口到结构化写入”不一致。
2026-06-01 本轮已完成工具层最小修复:`dryRun=false` 可执行 `updateText` / `updateNode``insertChild` / `addChild``deleteNode`,写入后返回 `revision``changedFiles``markdownSummary`,并保留 `view`、未知顶层字段和未知 node 字段。当前缺口转为浏览器可见性和 Page AI resource target 集成。
## 证据
- `rust/crates/mnote-web/src/hermes_tools/resource.rs``mindmap_apply_ops()` 已实现最小结构化写入。
- `rust/crates/mnote-web/src/routes/hermes_tools.rs` 已有 `hermes_tools_mindmap_apply_ops_writes_and_preserves_envelope_fields`,覆盖非 dry-run 写入、`view` 和未知字段保留。
- `rust/crates/mnote-web/src/routes/hermes_tools.rs` 已有 `hermes_tools_mindmap_apply_ops_rejects_stale_revision`,覆盖 `expectedRevision` 不匹配拒写。
- `rust/crates/mnote-web/src/routes/hermes_tools.rs` 已有 `hermes_tools_mindmap_apply_ops_shared_read_is_forbidden`,说明 shared/read-only scope 拒写已经有单测证据,不是当前主要缺口。
- `task455``task524``task525` 已覆盖 embedded mindmap 刷新、FileTree / Resource Tab 可见性和真实 Page AI mindmap target/contextRefs。
## 影响
- Agent 已可通过结构化 tool 安全执行最小增删改 mindmap node,不再只能回退到文件 patch。
- `view`、未知字段保留和 revision conflict 已由工具层单测覆盖。
- Page AI mindmap 第二批的最小可用链路已覆盖:tool 结构化写入、embedded mindmap 刷新、FileTree / Resource Tab 可见性,以及真实 Page AI mindmap target/contextRefs。后续缺口转为真实编辑需求下的更丰富 `apply_ops` 指令面。
## 修复建议
- 按真实编辑需求继续扩展 `apply_ops`,例如 `moveNode``insertSiblingAfter``setHyperlink``setRefs``appendNote``patchView`
## 验收
- [x] Rust 单测覆盖 `apply_ops` 非 dry-run 结构化写入。
- [x] Rust 单测覆盖 `view` 和未知字段保留。
- [x] Rust 单测覆盖 revision mismatch 返回 conflict。
- [x] Rust 单测覆盖 shared/read-only scope 下写工具拒绝。
- [x] Browser smoke 覆盖 mindmap 资源在 File Tree / Resource Tab 可见。
- [x] Browser smoke 覆盖真实 mindmap resource tab 注入 Page AI `active_editor` contextRef / `targetPackage`
- [x] `task455-local-folder-mindmap-clean-smoke.js` 刷新后 `mindmapId` 保持新建资源文件名,不退化为 `"mindmap"`
## 本轮验证
- `cargo test -p mnote-web hermes_tools_mindmap_apply_ops -- --test-threads=1`
- `cargo test -p mnote-web mindmap -- --test-threads=1`
- 2026-06-01 复跑失败:`MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task455-local-folder-mindmap-clean-smoke.js`
- 失败点:`刷新后 mindmapId 应保持不变`
- 证据:`tmp/task455-local-folder-mindmap-clean-smoke/result.json`
- 关键现象:刷新前 `mindmapId=mindmap-647356316`,刷新后 DOM `mindmapId=mindmap`FileTree 仍有 `local-file:CleanPage/mindmap-647356316.json` 行。
- 2026-06-01 修复后复跑通过:`MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task455-local-folder-mindmap-clean-smoke.js`
- 修复点:`blockDocument.blocks[].attrs` 保留 `mindmapId/sourcePath/rootNodeId`,浏览器 `document-tiptap-conversion-runtime.js` 消费 blockDocument 时读取这些 attrs。
- 补充验证:`cargo test -p bridge-runtime page_aggregate_block_document_preserves_mindmap_projection_attrs -- --test-threads=1``cargo test -p mnote-web local_markdown_generated_mindmap_id_roundtrips_as_mindmap_block -- --test-threads=1`
- 回归验证:`MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task524-workspace-object-identity-matrix-smoke.js`
- 2026-06-01 新增并通过:`MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task525-page-ai-mindmap-resource-target-smoke.js`
- 覆盖真实 local-folder mindmap resource tab 打开后,Page AI run payload 中 `contextRefs.active_editor.resourceKind=mindmap`
- 覆盖 `targetPackage.primaryTargetId``targetPackage.currentFile``allowedFiles` 指向 `Page/map.mindmap.json`
@@ -0,0 +1,52 @@
# 7-52 Page AI target picker 与 resource target 仍未闭环
## 状态
- 状态:done
- Owner07-ai / Page AI sidebar runtime / Agent Target Resolver
- 发现时间:2026-06-01
## 现象
Page AI 已经有 agent selector、contextRefs popover、`targetPackage``allowedFiles` 基础输入。2026-06-01 已补上 page target、mindmap resource target、OnlyOffice resource target 与 raw local resource target 的 composer chip / picker / payload 冻结闭环;mindmap / raw file `active_editor` contextRef 均已携带 `targetId` / `objectIdentity` / `resourceKind` / `assetId` / `relativePath`。跨 workspace 多 target 的产品化确认继续由 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 跟踪,不再由本文阻塞 target picker 收口。
## 证据
- `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` 已有 `currentPageAiOpenEditorsSnapshot()``currentPageAiEditorTarget()`、target chip / picker 和 `pageAiBuildAgentTargetPackage()`
- `scripts/task502-page-ai-agent-selector-context-smoke.js` 已覆盖 page target button / popover / chip,并通过 mock `resourceEditors` 覆盖 mindmap target 选择,断言 run payload 的 `targetPackage.primaryTargetId``targets[]``resourceKind=mindmap``relativePath=maps/Task502.mindmap.json``policy.writeRequiresExplicitTarget=true`;同时断言 `active_editor` contextRef 携带 mindmap `targetId` / `objectIdentity` / `resourceKind` / `assetId` / `relativePath`
- 2026-06-01 验证命令:`MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task502-page-ai-agent-selector-context-smoke.js`,截图:`tmp/task502-page-ai-agent-selector-context-smoke/01-agent-selector-context.png`
- `scripts/task453-local-folder-page-ai-changed-files-smoke.js` 覆盖 target snapshot 冻结、跨 workspace 阻断、dirty buffer 阻断,但不是 target picker UI。
- `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 仍把 target chip/picker、跨 workspace 多选确认、真实 agent 写入回收列为未完成。
- OnlyOffice 工具层 session/scope 已有 `task515` - `task518` 覆盖;`task523-page-ai-onlyoffice-real-target-session-smoke.js` 已补真实 Page AI target 到 `onlyofficeSessionId` / `bridgeSessionId` 的 UI 绑定。
- `mnote-mindmap` skill 已注册,mindmap target 的 `active_editor` contextRef 已携带资源身份;`task525-page-ai-mindmap-resource-target-smoke.js` 已补真实 mindmap resource tab target / contextRefs 复测。
- `scripts/task520-page-ai-raw-resource-target-smoke.js` 已补真实 raw local resource tab:打开 `Page/notes.txt` 后 Page AI target chip 指向 `notes.txt`run payload 冻结 `targetPackage.primaryTargetId=resource:file:{rootUri}:Page/notes.txt``allowedFiles=["Page/notes.txt"]`,并断言 `objectIdentity` 不再退化成 `[object Object]`
## 影响
- Page AI UI 已能清楚告诉用户当前 page / mindmap / OnlyOffice target。
- Page AI target picker 已覆盖 page / mindmap / OnlyOffice / raw file 四类当前 P1 目标,不再退化为“当前页”语义。
- 真实 agent 写入回收、跨 workspace 多 target 和 mindmap resource tab 自动注入的更大产品化项继续由 `7-18` / `7-42` / `7-51` 跟踪。
## 下一步建议
- 若继续扩多目标选择,新增跨 workspace 多 target 确认 smoke,并归入 `7-18`
- 若继续扩 mindmap 多目标或更细粒度 contextRefs,另拆 `7-42` / `7-51` follow-up,不再由本文阻塞 target picker 收口。
## 验收
- [x] Page AI composer 可见 page target chip。
- [x] target picker 可在当前 page 与 mindmap resource tab 间切换。
- [x] run payload 冻结 page `targetPackage.targets[0]`
- [x] run payload 冻结 mindmap resource `targetPackage.targets[0]`
- [x] OnlyOffice target 不会把 A 页授权误用于 B session。
- [x] mindmap resource tab 可作为 `resourceKind=mindmap` target。
- [x] mindmap `active_editor` contextRef 携带 `targetId` / `objectIdentity` / `resourceKind` / `assetId`
- [x] raw file target 可从真实 resource tab 候选选择并冻结到 run payload。
- [x] 真实 mindmap resource tab target / contextRefs 已由 `task525` 覆盖。
## 本轮验证
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- `node --check scripts/task520-page-ai-raw-resource-target-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task520-page-ai-raw-resource-target-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task502-page-ai-agent-selector-context-smoke.js`
@@ -0,0 +1,134 @@
# 7-53 Page AI runtime 已触发继续拆分阈值
## 状态
- 状态:done
- Owner07-ai / Page AI sidebar runtime
- 发现时间:2026-06-01
## 现象
`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` 曾约 3458 行,已经超过 `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 中设定的 2500 行继续拆分阈值。文件内仍混合 UI render、run orchestration 等多类职责。
同时,`sidebar-tree-runtime.js` 已不再初始化 `pageUiState.pageAi*` 状态,Page AI 默认值由 `sidebar-page-ai-runtime.js``ensurePageAiStateFacade()` 集中兜底;但 tree runtime 仍传入共享 `pageUiState` 对象并保留少量 Page AI 代理函数,后续子模块拆分仍未闭环。
## 证据
- `sidebar-page-ai-runtime.js` 曾约 3458 行;2026-06-01 render helper 续补后降至 2498 行。
- `sidebar-page-ai-markdown-runtime.js` 已承接 Page AI Markdown / conversation 文本渲染 helper。
- `sidebar-page-ai-profile-runtime.js` 已承接 provider/profile/history filter/usage helper。
- `sidebar-page-ai-permission-runtime.js` 已承接 ACP permission message/dialog/resolve helper。
- `sidebar-page-ai-session-runtime.js` 已承接 session storage、backend session list/detail/search/resume/delete 和 runtime event replay helper。
- `sidebar-page-ai-skill-runtime.js` 已承接 skill source、skill preference、Hermes builtin 隐藏和 Reasonix memory preference helper。
- `sidebar-page-ai-target-runtime.js` 已承接 OpenEditorsSnapshot target 派生、WorkspacePath、run target snapshot、contextRefs、agentTargetPackage 和 target writable guard helper。
- `sidebar-page-ai-runtime.js` 包含 `function ensurePageAiStateFacade`,并集中初始化 `pageAiAcpRuntime: 'reasonix'` 等 Page AI 默认状态。
- `sidebar-tree-runtime.js` 不再包含 `pageAiAcpRuntime: 'reasonix'``pageAi*:` 默认字段,只保留 `open-page-ai` 主壳入口和代理函数。
- `design/07-ai/process/7-38-page-ai-sidebar-runtime-owner-split-v1.md` 第 3 节仍将以下项列为未完成:
-`sidebar-page-ai-runtime.js` 超过 2500 行,再按 render / conversation / run orchestration 继续拆成子模块。
## 影响
- Page AI 行为继续占用 tree runtime 委托面,tree/filetree 和 AI session owner 边界仍混杂。
- 后续新增 target picker、OnlyOffice target、mindmap contextRefs 时,容易继续堆入单一大文件。
- 浏览器事件委托散落在 tree runtime 与 Page AI runtime 之间,增加回归风险。
## 下一步建议
- 若后续再次超过 2500 行,继续按 conversation / run orchestration 分子模块拆,降低 `sidebar-page-ai-runtime.js` 单文件职责。
- 继续补 Page AI smoke,覆盖停止、关闭、history/session、permission、profile/skill 切换等 owner 迁移风险面;停止/关闭尾项已拆到 `bugs/07-ai/process/7-56-page-ai-runtime-stop-close-smoke-gap-v1.md`
## 验收
- [x] `sidebar-tree-runtime.js` 不再包含 `[data-page-ai-action=...]` click/change 分发。
- [x] `sidebar-page-ai-runtime.js` 导出并安装 `installPageAiDelegates()`
- [x] `pageUiState.pageAi*` 从通用 sidebar state 下沉或有明确兼容 getter/setter。
- [x] Page AI smoke 覆盖打开 drawer、切换 tab、切换 agent和发送;停止/关闭完整断言已拆到 `bugs/07-ai/process/7-56-page-ai-runtime-stop-close-smoke-gap-v1.md`,不阻塞本文拆分阈值归档。
## 2026-06-01 第一刀验证
已完成第一刀:`sidebar-tree-runtime.js` 中 Page AI click/input/change/keydown 的大段 action 分发已迁到 `sidebar-page-ai-runtime.js``handlePageAiClick``handlePageAiKeyDown``handlePageAiInput``handlePageAiChange`,并由 `installPageAiDelegates()` 在 Page AI owner runtime 内安装事件监听。tree runtime 仅保留 `data-mnote-action="open-page-ai"` 主壳入口。
已通过验证:
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- `node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_agent_target_picker_contract_is_visible_and_serialized -- --test-threads=1`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task490-runtime-surfaces-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task502-page-ai-agent-selector-context-smoke.js`
2026-06-01 续补:
- `sidebar-page-ai-runtime.js` 新增并导出 `installPageAiDelegates()`,内部一次性安装 Page AI click/keydown/input/change delegate。
- `sidebar-tree-runtime.js` 不再调用 `sidebarPageAi.handlePageAi*`,只在自身监听注册后调用 `sidebarPageAi.installPageAiDelegates()`
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证 drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 state facade 续补:
- `sidebar-page-ai-runtime.js` 新增 `ensurePageAiStateFacade()`,集中初始化 Page AI 默认状态并导出该 facade。
- `sidebar-tree-runtime.js``pageUiState` 初始对象删除 `pageAi*:` 默认字段,tree runtime 不再定义 Page AI 状态真相。
- Rust include/assert 已更新为:Page AI runtime 包含 `ensurePageAiStateFacade``pageAiAcpRuntime: 'reasonix'`tree runtime 不包含该默认字段。
2026-06-01 conversation helper 续补:
- 新增 `sidebar-page-ai-markdown-runtime.js`,抽出 `textFromUnknown``renderPageAiMarkdown` 和 inline Markdown 渲染 helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiMarkdownRuntime()`,保留现有 assistant message 渲染行为。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js` 静态 runtime asset,并更新 runtime asset mount 测试。
2026-06-01 profile helper 续补:
- 新增 `sidebar-page-ai-profile-runtime.js`,抽出 provider/profile/chat-only profile/history filter/usage helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiProfileRuntime()` 并保留同名代理常量,降低调用点扰动。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js` 静态 runtime asset,并更新 runtime asset mount 测试。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证二级 import 后 drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 permission helper 续补:
- 新增 `sidebar-page-ai-permission-runtime.js`,抽出 ACP permission message、dialog show/hide 和 resolve-permission helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiPermissionRuntime()` 并保留同名代理常量,主 runtime 继续负责事件流持久化、会话同步和 conversation 渲染入口。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证新增 permission 二级 import 后 sidebar/Page AI 仍可加载和发送。
2026-06-01 session helper 续补:
- 新增 `sidebar-page-ai-session-runtime.js`,抽出 session storage、backend session list/detail/search/resume/delete、session message sync 和 backend runtime event replay helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiSessionRuntime()` 并保留同名代理常量,主 runtime 继续负责 Page AI UI render、run orchestration 和事件委托入口。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证新增 session 二级 import 后 Page AI drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 skill helper 续补:
- 新增 `sidebar-page-ai-skill-runtime.js`,抽出 skill source、skill preference、Hermes builtin 隐藏和 Reasonix memory preference helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiSkillRuntime()` 并保留同名代理常量,主 runtime 继续负责 skill 异步加载、target/run orchestration 和 UI render。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- 已通过 `node --check` 覆盖 Page AI 主 runtime、skill/session/permission/profile/markdown helper 和 `sidebar-tree-runtime.js`
- 已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1``page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch``page_ai_agent_target_picker_contract_is_visible_and_serialized`
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证新增 skill 二级 import 后 Page AI drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 target helper 续补:
- 新增 `sidebar-page-ai-target-runtime.js`,抽出 OpenEditorsSnapshot target 派生、WorkspacePath、run target snapshot、contextRefs、agentTargetPackage 和 target writable guard helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiTargetRuntime()` 并保留同名代理常量,主 runtime 继续负责 target popover UI、事件分发和 run orchestration 顺序。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言;target picker 合同测试改为在 target helper 中断言 `primaryTargetId` / `targets` / `policy`
- 已通过 `node --check` 覆盖 Page AI 主 runtime、target/skill/session/permission/profile/markdown helper 和 `sidebar-tree-runtime.js`
- 已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1``page_ai_uses_backend_acp_session_runtime_store``page_ai_agent_target_picker_contract_is_visible_and_serialized``page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch`
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js``task520-page-ai-raw-resource-target-smoke.js``task525-page-ai-mindmap-resource-target-smoke.js`,验证新增 target 二级 import 后 Page AI drawer、agent/context/target/skills、raw resource target、mindmap target 和发送 payload 链路仍可用。
2026-06-01 render helper 续补:
- 新增 `sidebar-page-ai-render-runtime.js`,抽出 target/context display helper、agent/profile/model label、skill filter、drawer shell、controls render、suggestions render、conversation render 和 response humanize helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiRenderRuntime()`,保留同名转发函数,主 runtime 继续负责 state facade、agent/run orchestration、session/permission/skill/target runtime 接线与事件委托。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言,避免浏览器 ES module import 404。
- `sidebar-page-ai-runtime.js` 当前 2498 行,已低于 `7-38` 设定的 2500 行继续拆分阈值。
- 已通过 `node --check` 覆盖 Page AI 主 runtime、render/target/skill/session/permission/profile/markdown helper 和 `sidebar-tree-runtime.js`
- 已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1``page_ai_uses_backend_acp_session_runtime_store``page_ai_agent_target_picker_contract_is_visible_and_serialized``page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch`
最终验证:
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task490-runtime-surfaces-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task502-page-ai-agent-selector-context-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task504-page-ai-history-agent-filter-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task520-page-ai-raw-resource-target-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task525-page-ai-mindmap-resource-target-smoke.js`
本文已满足归档条件:`sidebar-page-ai-runtime.js` 当前 2498 行,低于 2500 行继续拆分阈值;render helper 二级 import、drawer controls、history/session filter、当前页 target、raw resource target、mindmap target 和发送 payload 链路均通过 smoke。
@@ -0,0 +1,33 @@
# 7-54 Page AI Office target 误查 Markdown buffer-state
## 状态
- 状态:done
- Owner07-ai / Page AI target runtime / OnlyOffice resource target
- 发现时间:2026-06-01
- 修复时间:2026-06-01
## 现象
真实 Page AI UI 选择 Office resource target 后,发送 run 前的 buffer guard 会按 Office 文件相对路径请求 `/api/documents/buffer-state`
```text
/api/documents/buffer-state?...&relativePath=Page/office-a.docx
```
该端点只服务 local Markdown 文档 bufferOffice target 会返回 404。虽然当前 404 没有阻断 run,但会污染 console / HTTP error 证据,并说明 Page AI 把 Office resource 当成 Markdown buffer 检查。
## 根因
`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js``fetchPageAiTargetBufferState()` 只判断 `sourceKind=local_folder`,没有区分 target `resourceKind`。Office target 已由 OnlyOffice bridge session、resource scope 和 tool 层权限保护,不应走 Markdown `BufferStore` 查询。
## 修复
- `fetchPageAiTargetBufferState()``office` / `only_office` / `onlyoffice` target 直接返回 `null`
- `scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js` 增加断言:真实 Page AI Office target run 过程中不得出现 `/api/documents/buffer-state` 404,并要求 `consoleErrors` / `networkFailures` / `httpErrors` 为空。
## 验收
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- `node --check scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js`
@@ -0,0 +1,32 @@
# 7-55 Page AI 当前页目标继承 stale workspacePath
## 状态
- 状态:done
- Owner07-ai / Page AI target runtime
- 发现时间:2026-06-01
- 修复时间:2026-06-01
## 现象
`task504-page-ai-history-agent-filter-smoke.js` 在模拟 stale `OpenEditorsSnapshot` 后,用户偏好已将 `active_editor` contextRef 关闭,只发送当前页上下文;但 `sendPageAiMessage()` 仍强制把 `scopedContext.editorTarget` 覆盖为 `currentPageAiEditorTarget()`,导致当前页请求被旧 active editor 的 `workspaceId` 拦截,前端显示:
`AI target 与当前 workspaceId 不一致,请重新选择当前工作区内的目标。`
## 根因
- `pageAiScopedPageContext()` 已按 contextRefs 计算 scoped target,但发送链路又覆盖为 `currentPageAiEditorTarget()`
- `currentPageAiPageEditorTarget()` 从 page editor snapshot 读取 `workspacePath` 时,没有把当前 `workspaceId/sourceKind/rootUri/relativePath/documentId` 写回,stale snapshot 会把旧 workspace 信息带进当前页目标。
## 修复
- `sendPageAiMessage()` 改用 `currentPageAiScopedEditorTarget()`,遵守 contextRefs 对 active editor 的开关。
- `currentPageAiPageEditorTarget()` 在复用 page editor snapshot 时显式覆盖当前 workspace path 字段,避免 stale snapshot 污染当前页 target。
## 验证
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js`
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task504-page-ai-history-agent-filter-smoke.js`
验证结果:`task504` 通过,`capturedRuns[0].contextRefs` 只包含 `current_page``runTargetSnapshot.editorTarget.workspaceId` 为当前 `local-ws:mnote-e2e:task504`,未继续使用 stale workspaceId。
@@ -0,0 +1,49 @@
# 7-56 Page AI runtime stop / close smoke 缺口
## 状态
- 状态:done
- Owner07-ai / Page AI runtime
- 发现时间:2026-06-01
## 现象
`7-53` 已完成 Page AI runtime 拆分并把 `sidebar-page-ai-runtime.js` 降到 2500 行阈值以下,但停止运行与关闭抽屉的浏览器断言仍不完整。
## 证据
- `bugs/07-ai/done/7-53-page-ai-runtime-split-threshold-triggered-v1.md` 已记录拆分完成,但验收中仍保留 stop/close smoke 的 `[~]` 项。
- `scripts/task490-runtime-surfaces-smoke.js` 会打开并点击关闭 Page AI 抽屉,但缺少明确的 drawer hidden / closed state 断言。
- 现有 smoke 未覆盖 `data-page-ai-action="stop-run"` 到 abort API / terminal event 的端到端行为。
## 影响
- Page AI runtime 拆分后,基础打开/发送/target picker 已有保护,但 stop/close 这类常用交互仍可能在后续拆分中回退。
- `7-53` 虽然可作为拆分阈值 bug 归档,但停止/关闭行为应继续作为独立 P2 测试缺口跟踪。
## 下一步
1. 扩展现有 `task490` 或新增窄 smoke,断言关闭后 Page AI drawer 进入隐藏状态,且页面根状态同步清除。
2. 增加 stop-run smoke:启动可控流式 run,点击停止,断言 abort API 被调用并收到 terminal/aborted 状态。
3. 将验证命令补入 `scripts/TESTING_REFERENCE.md`
## 验收
- [x] 浏览器 smoke 明确断言 Page AI drawer close 后不可见。
- [x] 浏览器 smoke 覆盖 stop-run -> abort/terminal event。
- [x] `node --check` 与对应 smoke 通过。
## 2026-06-01 修复记录
- `scripts/task490-runtime-surfaces-smoke.js` 已扩展 Page AI close 断言:关闭后 drawer 必须 `hidden=true`,浮动 AI 按钮 `data-state=closed``aria-expanded=false`
- `task490` 已新增可控 mock run:发送后等待 run 进入 `running`,点击 `data-page-ai-action="stop-run"`,断言 abort API 被调用一次、请求 reason 为 `page_ai_user_stop`,并等待 UI 进入 `aborted` 状态。
- 修复 Page AI render helper 拆分漏注入:`sidebar-page-ai-render-runtime.js` 使用 `pageAiProviderLabel()` 渲染 provider card,但 `sidebar-page-ai-runtime.js` 未传入该 helper;已补 context 注入。
- 已通过:
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js`
- `node --check scripts/task490-runtime-surfaces-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task490-runtime-surfaces-smoke.js`
截图:
- `tmp/task490-runtime-surfaces-smoke/02b-page-ai-stopped.png`
@@ -0,0 +1,80 @@
# 7-57 OnlyOffice Plugin Bridge P2 产品化尾项
## 状态
- 状态:done
- Owner07-ai / ONLYOFFICE live bridge / Page AI target runtime
- 发现时间:2026-06-01
- 归档时间:2026-06-01
## 现象
OnlyOffice live bridge 的 P0 安全阻断已完成:显式 session、resource scope、真实 iframe 多 session 隔离、非 dry-run 写入落点和 Page AI target picker 到 live session 的绑定均已有 smoke 证据。但 `7-43` 仍保留若干 P2 产品化尾项,缺少独立 bug 落点。
## 证据
- `design/07-ai/process/7-43-onlyoffice-plugin-bridge-design-v1.md` 仍保留 P2 follow-upOffice 主文档加载噪音治理、第三批 recipe 逐项实测。`session/current``session/close` 与 session state `docKey/pageOrigin` 已在 2026-06-01 续补完成。
- 已有 smoke 证明安全主路径完成:
- `scripts/task515-onlyoffice-live-scope-http-smoke.js`
- `scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js`
- `scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js`
- `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`
- `scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js`
## 影响
- P0 安全问题已经不再阻塞;P2 recipe 扩展已收窄为 `7-43` 的矩阵化后续,不再作为本文 bug 阻塞项。
- `7-43` 已区分已完成安全项和后续产品化项。
## 下一步
1. 记录并治理 Office 主文档加载噪音,例如 DocumentServer `errorCode=-18`、WebSocket / polling 失败等。(已完成)
2. 第三批 recipe 必须逐项实测后再暴露给 agent,不凭 API 名称直接开放。(已收窄为矩阵规则)
## 验收
- [x] `session/current``session/close` API 已实现并有 Rust route 测试。
- [x] session state 已补 `docKey` / `pageOrigin`,并由 plugin config / register session / list sessions smoke 证明透传。
- [x] Office 加载噪音有明确分类、截图或日志证据,并不误判为打开成功。
- [x] 每个已暴露 recipe 保留 Rust unit、browser direct smoke 和 tool API smoke 要求;未暴露第三批 recipe 已明确不对 agent 宣称可用。
- [x] 插件执行层已补 editorType guard`document.*` / `sheet.*` / `presentation.*` 不能跨 Word / Excel / PPT session 串用。
- [x] `design/07-ai/process/7-43...` 中 P0 已完成项与 P2 尾项拆分清晰。
## 2026-06-01 session lifecycle 续补记录
- `rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs` 已新增 `current_session` / `close_session`,并把 `docKey` / `pageOrigin` 纳入 `BridgeSessionState``BridgeSessionPayload``BridgeSessionInfo` 和 plugin index state。
- `/onlyoffice` 页面会把当前 `fileState.docKey``location.origin` 传给 bridge plugin configmock plugin direct smoke 已验证 plugin 注册后 session 中存在 `docKey/pageOrigin`
- `scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js` 已扩展 `session/current``session/close` 和关闭后 session 不再可取 command 的断言。
- 已通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1`
- `node scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3302 node scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js`
## 2026-06-01 噪音分类与主文档失败断言
- `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js` 已新增 `classifyOnlyOfficeSignals`,把 bridge plugin translation 404、ONLYOFFICE 内置插件噪音和主文档加载失败分开记录。
- smoke 会断言 MNote bridge session 注册与 command loop 可用,同时要求 `mainDocument.ready=true`
- `errorCode=-18` 不再被当作可忽略噪音;一旦在 console 或 editor error log 中出现,`task518` 会失败并把它归为主文档失败。
- `design/07-ai/process/7-43-onlyoffice-plugin-bridge-design-v1.md` 头部口径已声明 P0 session/scope 安全阻断完成,本文和 `7-43` 只继续跟踪 P2 recipe 扩展与产品化尾项。
本文继续保持 `process` 的剩余原因:
- 已归档。第三批 recipeWord 图片/修订/content controls/表格行列样式、Excel 筛选/工作表删除移动、PPT 图片/重排/主题布局/shape 样式位置、PDF/forms)仍需逐项实测后再开放给 agent,但这些是后续 `7-43` 矩阵项,不再作为本文 bug 阻塞。
## 2026-06-01 归档记录
Recipe 产品化矩阵:
| 类别 | 当前状态 | 归档口径 |
| --- | --- | --- |
| Word 基础读写 / 表格 / 评论 | exposed-with-tests | 已暴露,继续要求 Rust unit、browser direct smoke、tool API smoke。 |
| Excel sheets / range / format / dimensions / sort / chart | exposed-with-tests | 已暴露,Excel sort/chart 已纳入当前 capabilities。 |
| PPT slide texts / shapes / replace / delete / table / clear / shape | exposed-with-tests | 已暴露,PPT table/clear/shape 已纳入当前 capabilities。 |
| Word 图片 / 修订 / content controls / 表格增删行列和样式 | not-exposed | 仅作为候选,不向 agent 宣称可用。 |
| Excel 筛选 / 工作表删除移动 | not-exposed | 仅作为候选,不向 agent 宣称可用。 |
| PPT 图片 / slide 重排 / 主题布局 / shape 样式位置 | not-exposed | 仅作为候选,不向 agent 宣称可用。 |
| PDF / forms 字段读取与填写 | blocked-by-api | 需先确认 ONLYOFFICE 社区版 Plugin API 可行性。 |
验证:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1`
@@ -0,0 +1,57 @@
# 7-58 Mindmap P2 apply_ops 与真实 UI smoke 缺口
## 状态
- 状态:done
- Owner07-ai / mindmap resource skill / Page AI target runtime
- 发现时间:2026-06-01
## 现象
Mindmap skill 和资源 target 已有最小闭环:`fetch``create_from_outline``apply_ops` 基础工具、`mnote-mindmap` skill 注册、`task503``task525` smoke 已覆盖 API/target 绑定。但 P2 第二批仍缺 apply_ops 操作白名单文档化、长文本节点策略和真实 UI 交互 smoke。
## 证据
- `rust/crates/mnote-web/src/hermes_tools/resource.rs` 已有 mindmap `fetch/apply_ops/create_from_outline` 入口。
- `scripts/task503-mindmap-skill-capability-smoke.js` 覆盖 skill、create_from_outline、fetch、embed。
- `scripts/task525-page-ai-mindmap-resource-target-smoke.js` 覆盖 mindmap resource tab / Page AI targetPackage。
- 当前缺口未在独立 bugs/process 中跟踪;历史相关 bug `7-48/7-51/7-52` 已作为各自窄问题归档。
## 影响
- agent 可以调用 mindmap 工具,但不清楚 `apply_ops` 的稳定操作白名单和限制。
- 长文本节点可能破坏 mindmap 渲染或 agent 输出可读性。
- 现有 smoke 偏 API/target 合同,未覆盖用户真实 UI 交互,例如节点编辑、拖拽、导出等。
## 下一步
1. 已文档化 `apply_ops` 支持的操作白名单、payload schema、dry-run/revision 规则和失败形态。
2. 已为关键 ops 增加 Rust 单测,覆盖成功、别名、delete、dry-run、revision mismatch、越权/只读拒绝、unsupported op、invalid ops payload、禁止删除 root。
3. 已在 skill prompt 中明确长文本节点压缩策略:短语化、避免长段落,拆成 child nodes。
4. 已用真实 mindmap UI smoke 覆盖打开、插入、渲染、样式抽屉默认状态、slash 菜单层级和 resize 基础交互。
## 验收
- [x] `apply_ops` 白名单与限制写入 design / skill。
- [x] `hermes_tools_mindmap_apply_ops_*` targeted tests 覆盖核心操作。
- [x] 长文本节点策略有测试或明确降级说明。
- [x] 至少一条真实 mindmap UI browser smoke 通过并登记到 `scripts/TESTING_REFERENCE.md`
## 2026-06-01 修复记录
- `skills/mnote-mindmap/SKILL.md` 已新增 `Current apply_ops contract`,明确支持 `updateText/updateNode``insertChild/addChild``deleteNode`,记录 `nodeId/id``text/title``parentId/nodeId` 等 payload schema、未知 op 拒绝、禁止删除 root、长文本短语化/拆子节点、`dryRun` 与 revision checks。
- `rust/crates/mnote-web/src/routes/hermes_tools.rs` 已补齐 apply_ops targeted tests
- `hermes_tools_mindmap_apply_ops_accepts_common_aliases`
- `hermes_tools_mindmap_apply_ops_deletes_child_node`
- `hermes_tools_mindmap_apply_ops_dry_run_returns_diff_without_writing`
- `hermes_tools_mindmap_apply_ops_rejects_invalid_ops_payload`
- `hermes_tools_mindmap_apply_ops_rejects_root_delete`
- `hermes_tools_mindmap_apply_ops_rejects_unsupported_op`
- 已通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web hermes_tools_mindmap_apply_ops -- --test-threads=1`
- `node --check scripts/task455-local-folder-mindmap-clean-smoke.js`
- `MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3303 node scripts/task455-local-folder-mindmap-clean-smoke.js`
真实 UI smoke 结果:
- `tmp/task455-local-folder-mindmap-clean-smoke/result.json`
@@ -0,0 +1,98 @@
# 7-49 ChatOnly OpenClaw provider 单测缺口
## 状态
- 状态:process
- Owner07-ai / OpenClaw provider integration
- 发现时间:2026-06-01
## 现象
`7-44` 的 MNote 侧 ChatOnly provider conversation binding 已完成并通过真实浏览器 smoke。外部 OpenClaw 仓库已经补齐 Doubao provider 的 conversation id 抽取与 delete helper 单测,但 DeepSeek / Gemini provider 级 delete helper 与单测仍没有可靠实现落点,不能在 MNote 仓库内用集成 smoke 替代。
## 已有 MNote 侧证据
- `cargo test --manifest-path rust/Cargo.toml -p control-plane external_conversation -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib provider_conversation -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib chatonly_doubao_session_delete_calls_provider_and_marks_remote_deleted -- --test-threads=1`
- `node scripts/task512-chatonly-doubao-sync-smoke.js`
- `node scripts/task513-chatonly-provider-sync-smoke.js deepseek`
- `node scripts/task513-chatonly-provider-sync-smoke.js gemini`
## 缺口
- OpenClaw Doubao provider 的 `conversation_id` 捕获、`deleteConversation` / `/im/conversation/batch_del_user_conv` 已有 provider 仓库内单测。
- DeepSeek / Gemini provider delete helper 仍缺 provider 仓库内实现与单测;如果没有可靠远端删除 API,不应伪造 `remote_deleted`
## 2026-06-01 外部仓库复核
- 本机存在 `/home/lix/openclaw-zero-token-latest`,但该仓库已有未提交改动:
- `src/zero-token/providers/doubao-web-client-browser.ts`
- `src/zero-token/providers/gemini-web-client-browser.ts`
- `src/zero-token/streams/doubao-web-stream.ts`
- `src/zero-token/providers/web-session-binding.test.ts`
- 只读核对显示,现有 `web-session-binding.test.ts` 覆盖 Doubao `conversation_id` SSE 提取和 Gemini URL / stale candidate,但未覆盖 `deleteConversation``/im/conversation/batch_del_user_conv``remote_deleted` / `remote_delete_failed`
- 尝试运行 `pnpm exec vitest run src/zero-token/providers/web-session-binding.test.ts``pnpm exec vitest run --config vitest.unit.config.ts src/zero-token/providers/web-session-binding.test.ts` 均因缺少 `@vitest/browser-playwright` 启动失败;`scripts/vitest.zero-token-web.config.ts` 当前 include 不包含 provider test。
结论:本缺口仍是 OpenClaw 外部仓库未完成项,不应在 MNote 仓库内标记 done。
## 下一步
在 OpenClaw provider 源码仓库中继续补 DeepSeek / Gemini provider 删除能力,覆盖:
- 删除成功返回 `remote_deleted`
- 删除失败返回 `remote_delete_failed` 且脱敏错误。
## 2026-06-01 外部仓库二次复核
只读复核 `/home/lix/openclaw-zero-token-latest` 后确认本文仍保持 `process`,不能在 MNote 仓库内归档:
- 外部仓库已有未提交/未跟踪改动,包含 `src/zero-token/providers/doubao-web-client-browser.ts``src/zero-token/providers/gemini-web-client-browser.ts``src/zero-token/streams/doubao-web-stream.ts``src/zero-token/providers/web-session-binding.test.ts`,本轮不得覆盖或清理。
- Doubao stream/client 代码已有 conversation id 捕获和复用路径,但 provider 单测仍只覆盖 SSE 提取、Gemini URL normalize 和 stale candidate;未覆盖 Doubao 复用、`deleteConversation` 成功/失败。
- `rg deleteConversation` 在 provider/streams 下未发现 DeepSeek/Gemini delete helper 可见测试。
- 默认 `pnpm exec vitest run src/zero-token/providers/web-session-binding.test.ts --reporter=dot` 仍会因根 `vitest.config.ts` 导入但本机缺少 `@vitest/browser-playwright` 失败;`scripts/vitest.zero-token-web.config.ts` 又未 include provider test,强行指定 provider 文件会 `No test files found`
结论:MNote 侧 ChatOnly/Doubao binding smoke 可作为 MNote 集成证据,但 `7-49` 的 owner 是 OpenClaw provider 仓库单测缺口,仍需在外部仓库补测试配置和 provider 单测后才能移动到 `done`
## 2026-06-01 外部仓库三次推进
`/home/lix/openclaw-zero-token-latest` 已补并验证 Doubao provider 级测试:
- `src/zero-token/providers/doubao-web-client-browser.ts`
- 新增 `ProviderConversationDeleteResult``DoubaoConversationDeleteRequest``buildDoubaoConversationDeleteRequest``deleteDoubaoConversationWithFetch``DoubaoWebClientBrowser.deleteConversation`
- 删除请求固定走 `/im/conversation/batch_del_user_conv`,失败响应会脱敏 `sessionid` / `ttwid`
- `src/zero-token/providers/web-session-binding.test.ts`
- 覆盖 Doubao `conversation_id` / `conversationId` / `sessionId` / object `event_data` 抽取。
- 覆盖 `conversation_id:"0"`、malformed SSE、`[DONE]` 跳过。
- 覆盖 batch delete request、`remote_deleted` 成功、`remote_delete_failed` 脱敏失败。
- `scripts/vitest.zero-token-web.config.ts`
- 已 include provider test,并补 `openclaw/plugin-sdk/*` alias,避免根 `vitest` 多项目配置缺 `@vitest/browser-playwright` 阻断 provider 单测。
已通过:
- `pnpm exec vitest run --config scripts/vitest.zero-token-web.config.ts src/zero-token/providers/web-session-binding.test.ts --reporter=dot`
- `pnpm exec vitest run --config scripts/vitest.zero-token-web.config.ts --reporter=dot`
Reasonix 只读复核(`reasonix-2026-06-01T00-32-52-539Z-c41a37a3`)结论与主线程复核一致:
- Doubao 是当前唯一有 provider 级 `deleteConversation` / `remote_deleted` / `remote_delete_failed` helper 与单测的 web provider。
- DeepSeek / Gemini 只有 stream 层 `sessionMap` / `conversationMap` 复用,没有 provider 级删除能力。
- Qwen、GLM、Kimi、Grok、Claude、ChatGPT、XiaoMiMo、Perplexity 等其他 web provider 也未见 provider 级 delete helper;这些不是 `7-44` Doubao 绑定的阻断项,但应作为后续 OpenClaw provider cleanup 矩阵继续跟踪。
- `pnpm exec vitest run --config scripts/vitest.zero-token-web.config.ts --reporter=dot` 在外部仓库通过,3 files / 14 tests passed。
本文继续保持 `process` 的剩余原因:
- DeepSeek provider 当前没有 `deleteConversation` / delete helper;不能从 MNote 侧 `task513` 推断 provider 仓库已有可靠远端删除实现。
- Gemini provider 当前只有 DOM 对话打开和 conversation URL normalize / stale candidate 测试;没有 provider 级远端删除 helper。
- 后续需要先核实 DeepSeek / Gemini 真实远端删除 API 或明确降级语义,再补 provider 单测;不能为了归档而把本地删除伪装成 `remote_deleted`
## 2026-06-01 subagent 复核确认
只读 subagent 复核 `/home/lix/openclaw-zero-token-latest` 后确认本文继续保持 `process`
- Doubao provider delete/helper/test 与 Vitest 专用配置已补齐。
- `rg deleteConversation|remote_deleted|remote_delete_failed|batch_del_user_conv src/zero-token/providers src/zero-token/streams` 仍只命中 Doubao 和 `web-session-binding.test.ts`,未命中 DeepSeek / Gemini。
- DeepSeek 当前只有 `createChatSession` / `chatCompletions` 和 stream `sessionMap` 复用。
- Gemini 当前只有 URL normalize、DOM 打开/复用对话和 `conversation_id: page.url()`,没有 provider 级远端删除 helper。
因此 MNote 侧 `7-44` / `task512` / `task513` 只能证明 MNote provider conversation binding,不足以替代外部 provider 仓库的 DeepSeek / Gemini 删除单测。
@@ -0,0 +1,45 @@
# 10-18 design process 状态与入口漂移
## 状态
- 状态:done
- Owner10-review / design governance
- 发现时间:2026-05-31
## 现象
当前 design 主入口和若干 `process/` 文档状态已经与真实口径不一致,容易误导后续 worker 重复执行已完成任务,或从已迁移到 `old/` 的历史文档读取优先级。
## 证据
- `design/README.md` 曾把 `design/01-05-current-priority-overview.md` 写成当前优先级入口,但实际文件已在 `design/old/01-05-current-priority-overview.md`
- `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 曾在上位依据中引用同一个已迁移文件,并仍引用已归档的 `3-3` / `3-18` 作为 active process 入口。
- `design/03-rust-web/process/3-24-global-content-type-page-width-settings-v1.md` checklist 全部完成,曾仍位于 `process/`2026-05-31 已迁入 `design/03-rust-web/done/`
- `design/05-editor-mainline/process/5-31-page-settings-sqlite-preference-convergence-v1.md` 已记录 Phase A/B/C/D 最小闭环完成,但末尾推荐仍建议从 Phase A/B 开始;2026-05-31 已迁入 `design/05-editor-mainline/done/`
- `design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md` checklist 和验证记录已完成;2026-05-31 已迁入 `design/05-editor-mainline/done/`
- `design/07-ai/process/7-37-claudecode-reasonix-worker-evaluation-0524-bug2-v1.md` 是 worker 评估材料,不是 active implementation checklist2026-05-31 已迁入 `design/07-ai/reference/`
- `design/07-ai/process/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md` 文件头状态为 `done`,但仍在 `process/`2026-06-01 已迁入 `design/07-ai/done/``design/10-review/process/17-sidex-mnote-workbench-gap-execution-checklist-v1.md` 曾有同类漂移,2026-05-31 已迁入 `design/10-review/done/`
## 影响
- 后续 agent 可能按旧入口恢复过时优先级。
- 已完成设计继续占用 active process 队列,导致任务拆分噪声。
- process/done 目录语义被削弱,bug 和设计治理难以闭环。
## 修复建议
1. 已先修正 `design/README.md``1-8` 的失效入口。
2. 下一步逐个迁移或拆分状态漂移文档:
- `3-24` 已迁入 `03-rust-web/done/`Mindmap 真实消费另拆 follow-up。
- `5-31` 已迁入 `05-editor-mainline/done/`,剩余风险另拆。
- `5-32` 已迁入 `05-editor-mainline/done/`
- `5-33` 拆成已完成核心和 breadcrumb/fetch guard follow-up。
- `7-37` 已迁入 `07-ai/reference/`
- `7-41` 已迁入 `07-ai/done/`
- `17` 已迁入 `10-review/done/`
## 验收
- [x] `find design -path '*/process/*' -type f ! -path 'design/old/*'` 中不再出现文件头状态为 `done` 的文档。
- [x] `rg -n "design/01-05-current-priority-overview.md" design/README.md design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md` 无 active 入口引用。
- [x] 每个保留在 `process/` 的文档都有未完成 checklist、owner 和验收条件;`5-33` 仍保留在 `process/` 是因为 breadcrumb/fetch guard 等 follow-up 未完成。
@@ -0,0 +1,43 @@
# 10-19 AGENTS.md 架构参考路径断裂
## 状态
- 状态:done
- Owner10-review / AGENTS 协作规则
- 发现时间:2026-05-31
## 现象
发现时,`AGENTS.md` 的“需要架构判断时,优先参考”列表中有两个路径已经断裂。因为 `AGENTS.md` 是 agent 的顶层协作规则,这类失效引用会直接误导后续任务启动时的架构判断。
## 证据
- 发现时,`AGENTS.md` 仍引用 `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`,但该文件实际位于 `design/old/01-05-current-priority-overview.md`,根路径不存在。
- 发现时,`AGENTS.md` 仍引用 `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`,但实际文件位于 `design/01-tree-first-graph-kernel/reference/1-tree-first-graph-kernel-v1.md`
- 本轮已更新 `design/README.md``1-8``AGENTS.md` 的 active 入口。
## 影响
- 后续 agent 按 `AGENTS.md` 查架构依据时会遇到文件不存在。
- worker 可能因此退回旧记忆或自己猜测口径。
- `01-05` 已迁入 `old/` 但仍被当作当前入口,削弱 `design/README.md``1-8` 的入口权威。
## 修复建议
修复方式:
-`01-05-current-priority-overview.md` 替换为 `CURRENT_ARCHITECTURE.md``design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
-`process/1-tree-first-graph-kernel-v1.md` 改为 `reference/1-tree-first-graph-kernel-v1.md`,或删除该历史参考入口。
## 本轮进展
- 2026-05-31:用户设置的 P0 goal 已明确包含 `AGENTS/design 断裂引用`,本轮已更新 `AGENTS.md`
- 新增 `CURRENT_ARCHITECTURE.md`
- 新增 `design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
-`1-tree-first-graph-kernel-v1.md` 指向 `reference/`
- 移除已不存在的 root `design/01-05-current-priority-overview.md` 引用。
## 验收
- `test -f` 验证 `AGENTS.md` 中列出的每个绝对路径都存在。
- `rg -n "design/01-05-current-priority-overview.md|process/1-tree-first-graph-kernel-v1.md" AGENTS.md` 不再命中 active 断裂引用。
@@ -0,0 +1,49 @@
# 10-20 新增 smoke 未及时进入 TESTING_REFERENCE
## 状态
- 状态:done
- Owner10-review / smoke governance
- 发现时间:2026-05-31
## 现象
近期新增的 Page AI / mindmap / ChatOnly smoke 未及时进入 `scripts/TESTING_REFERENCE.md`。该文件是当前 smoke 分类与默认基线的权威入口,缺失会导致新增能力线没有明确验证入口。
## 证据
本轮发现时,`scripts/TESTING_REFERENCE.md` 未收录:
- `scripts/task503-mindmap-skill-capability-smoke.js`
- `scripts/task504-page-ai-history-agent-filter-smoke.js`
- `scripts/task512-chatonly-doubao-sync-smoke.js`
- `scripts/task513-chatonly-provider-sync-smoke.js`
## 本轮处理
已把上述 smoke 补入 `scripts/TESTING_REFERENCE.md`
- `task503` 归入资源对象与 mindmap / Page AI mindmap skill。
- `task504` 归入 Page AI history / agent filter。
- `task512``task513` 归入 ChatOnly / provider session 绑定。
- 另补入本轮新增的 `task514-sidebar-dev-hot-reload-gating-smoke.js``task515-onlyoffice-live-scope-http-smoke.js``task516-onlyoffice-bridge-multisession-browser-smoke.js`
## 剩余问题
本轮已实跑:
- `node scripts/task503-mindmap-skill-capability-smoke.js`
- `node scripts/task504-page-ai-history-agent-filter-smoke.js`
- `node scripts/task512-chatonly-doubao-sync-smoke.js`
- `node scripts/task514-sidebar-dev-hot-reload-gating-smoke.js`
- `node scripts/task515-onlyoffice-live-scope-http-smoke.js`
- `node scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js`
- `node scripts/task513-chatonly-provider-sync-smoke.js deepseek`
- `node scripts/task513-chatonly-provider-sync-smoke.js gemini`
当前 smoke reference 缺项已补齐并实跑。后续若新增 smoke,需要继续按本文件规则同步 `scripts/TESTING_REFERENCE.md`
## 验收
- `rg -n "task503|task504-page-ai-history|task512|task513" scripts/TESTING_REFERENCE.md` 能命中新条目。
- `task513-chatonly-provider-sync-smoke.js deepseek``task513-chatonly-provider-sync-smoke.js gemini` 已实跑通过,并已写回 `design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md`
@@ -0,0 +1,39 @@
# 10-21 design done 文件头状态漂移
## 状态
- 状态:done
- Owner10-review / design governance
- 发现时间:2026-06-01
## 现象
部分已经位于 `design/**/done/` 的设计稿,文件头仍保留 `process` / `PROCESS` 状态。后续 agent 或脚本如果只读文件头而不看目录状态,容易把已归档文档误判为 active process。
## 证据
- `design/07-ai/done/7-42-page-ai-mindmap-skill-and-resource-generation-v1.md` 位于 `done/`,但文件头仍写 `状态:process`
- `design/05-editor-mainline/done/5-31-page-settings-sqlite-preference-convergence-v1.md` 位于 `done/`,但文件头仍写 `状态:process`
- `design/05-editor-mainline/done/5-32-filetree-lazy-loading-sidex-alignment-v1.md` 位于 `done/`,但文件头仍写 `状态:process`
- `design/04-tree-domain/done/4-49-local-file-operation-event-contract-v1.md` 位于 `done/`,但文件头仍写 `状态:process`
## 影响
- 设计治理扫描和 worker 任务拆分可能重复打开已完成文档。
- `process/``done/` 的事实源边界被削弱。
## 下一步
1. 扫描 `design/**/done/*.md` 中的文件头状态字段。
2. 将已经归档且证据充分的文档头部状态统一改为 `done`
3. 对确实仍有未完成项的文档,不留在 `done/` 中混用;应拆 follow-up 到 `process/`
## 验收
- [x] `design/**/done/*.md` 中不再出现文件头 `状态:process` / `当前状态:PROCESS`
- [x] 若存在例外,必须在文档头部说明其为历史快照而非 active process。
## 2026-06-01 修复记录
- 已将当前扫描命中的 `design/**/done/*.md` 文件头状态统一改为 `done` / `DONE`
- 已验证:`rg -n '^> (当前状态|状态)`?(PROCESS|process)' design/*/done design/old/*/done -g '*.md'` 无结果。
@@ -0,0 +1,38 @@
# 10-22 done bug 文档残留过时 follow-up 文案
## 状态
- 状态:done
- Owner10-review / bugs governance
- 发现时间:2026-06-01
## 现象
部分已经位于 `bugs/**/done/` 的 bug 文档仍保留过时的“仍缺 / 待复测”文案,而同文档或后续 bug 已经补充了完成证据。这会让 reviewer 误判 done 条目仍未完成。
## 证据
- `bugs/07-ai/done/7-52-page-ai-target-picker-resource-target-gap-v1.md` 仍写“仍缺真实 mindmap resource tab 自动打开后的 UI 级 contextRefs 复测”,但后续 `task525-page-ai-mindmap-resource-target-smoke.js` 已覆盖 mindmap resource target。
- `bugs/07-ai/done/7-53-page-ai-runtime-split-threshold-triggered-v1.md` 的主 bug 已完成拆分阈值,但残留 stop/close smoke `[~]` 项;该缺口已拆为 `bugs/07-ai/process/7-56-page-ai-runtime-stop-close-smoke-gap-v1.md` 后,应避免继续让 `7-53` 看起来未完成。
## 影响
- `done/` bug 的完成边界不清晰。
- 后续 agent 容易重复寻找已覆盖缺口,或者把独立 follow-up 误认为原 bug 未完成。
## 下一步
1. 扫描近期 `bugs/**/done/*.md`,把已被后续 smoke 覆盖的旧 follow-up 文案改成完成记录。
2. 对仍未完成的尾项,拆到独立 `bugs/**/process/`,并在原 done 文档写明“尾项已拆出”。
3. 不改变 bug 事实源:没有真实验证的项不得从文案中删除,只能拆出跟踪。
## 验收
- [x] `7-52` 中 mindmap resource tab 复测口径与 `task525` 证据一致。
- [x] `7-53` 中 stop/close 缺口指向 `7-56`,主拆分 bug 保持 done。
- [x] `git diff --check` 通过。
## 2026-06-01 修复记录
- `7-52` 已改为记录 `task525-page-ai-mindmap-resource-target-smoke.js` 覆盖真实 mindmap resource tab target / contextRefs 复测。
- `7-53` 已把停止/关闭完整断言尾项拆到 `bugs/07-ai/process/7-56-page-ai-runtime-stop-close-smoke-gap-v1.md`,主拆分阈值 bug 保持 done。
@@ -2,7 +2,7 @@
> 创建时间:2026-05-21
>
> 当前状态:`PROCESS`
> 当前状态:`DONE`
>
> 上位入口:`design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
>
@@ -2,7 +2,7 @@
> 创建时间:2026-05-19
>
> 当前状态:`PROCESS`
> 当前状态:`DONE`
>
> 上位依据:
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
@@ -6,14 +6,18 @@
>
> 2026-05-22 归档治理补充:当前 active `process/` 只保留可直接执行的少量收口稿;长期架构、愿景、参考矩阵、Wolai 对标基线、Mindmap 总设计、旧 AI 块级执行稿和 Convex Web 迁移入口已分别移动到 `reference/`、`done/` 或 `old/`。本文是后续调度入口,不代表下列所有历史入口仍在 `process/`。
>
> 2026-05-31 口径复核:当前主线仍以 `CURRENT_ARCHITECTURE.md`、`ARCHITECTURE.md` 与本文件为准;历史 `design/01-05-current-priority-overview.md` 已迁入 `design/old/01-05-current-priority-overview.md`,不再作为 active 调度入口。`3-3`、`3-18` 等已归档文档只保留为完成证据,后续 live / local-folder runtime 收口默认看 `3-23`、`5-32` 与相关 bug。
>
> 2026-06-01 P0/P1/P2 复核:P0 阻塞项已由 `4-44/4-45/4-34/4-28` 归档证据覆盖,不再作为实现阻塞;`5-30` 与 `5-33` 已归档到 `05-editor-mainline/done/`,导航页 breadcrumb / fetch guard 尾项拆到 `5-35``7-35/7-36` 已降级为协作参考,不再占用产品 process 队列。当前 active 产品 process 重点收敛为 `3-23`、`3-25`、`5-35`、`7-18`、`7-43` 及对应 bugs/process。
>
> 目标:把本轮 design governance 后剩余的主线 `process/` 文档排成可执行顺序,避免后续 worker 在 active process 中自行猜优先级。
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/README.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/design/10-review/done/15-design-governance-mvp-post-review-v1.md`
> - `/mnt/Data1T/mnote/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md`
## 1. 一句话顺序
@@ -87,6 +91,8 @@
- `4-45` 四项 checklist 完成后归档到 `04-tree-domain/done/`
- `4-44` browser smoke 全绿后归档到 `04-tree-domain/done/`
2026-06-01 状态:本项已由 `4-44` / `4-45` done 文档和 `task471` / `task476` / `task487` 复跑证据覆盖,不再作为 P0 阻塞项。
### P0.2 local folder no-refresh / restore focus
入口文档:
@@ -109,6 +115,8 @@
- local folder 子项通过后,`4-34` 归档。
- React / Rust 主壳 focus 证据完成后,`4-28` 归档。
2026-06-01 状态:本项已由 `4-34` / `4-28` done 文档和后续 tree/filetree lifecycle smoke 证据覆盖,不再作为 P0 阻塞项。
## 4. P1:统一工作区身份、BufferStore 和 Page Aggregate
这一步是 MVP 后阶段真正的底座收口。
@@ -133,6 +141,7 @@
- `cargo test -p mnote-web filetree_runtime -- --test-threads=1`
- `cargo test -p mnote-web web_shell -- --test-threads=1`
- 覆盖 markdown / office / mindmap / raw file / directory 的 browser smoke。
- 2026-06-01 进展:已补 `scripts/task524-workspace-object-identity-matrix-smoke.js`,覆盖 page `.md`、directory、raw text、mindmap、OnlyOffice 在 FileTree row `workspacePath`、OpenEditorsSnapshot entry、URL `resourceTab`、active tab identity 之间的对齐;验证命令 `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 node scripts/task524-workspace-object-identity-matrix-smoke.js` 已通过。P1.1 的 identity 浏览器矩阵缺口已收口,后续只保留与 `1-5 / 1-6` 历史 checklist 对账。
归档条件:
@@ -163,6 +172,8 @@
- `1-6` 中 BufferStore 运行时接入、浏览器读回、冲突 UI 相关项完成。
2026-06-01 状态:历史 BufferStore 读回 / conflict UI 已由 `1-6` 与 Batch C 证据覆盖;真实 agent writeback -> watcher -> BufferStore -> Page Aggregate -> tiptap 的端到端回收仍归入 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` Phase B,不再作为本文模糊 P1.2 阻塞。
### P1.3 Page Aggregate compat 瘦身
入口文档:
@@ -177,6 +188,8 @@
2. 补 GFM AST parser 写侧尾项:web shell 单测、手写 parser 过渡标记、临时适配分支清理。
3. 明确 `PageAggregateClientState` 哪些仍是合法 draft state,哪些应退出事实源地位。
2026-06-01 收口口径:`body.blockDocument` / `editorDocument` 是 local-first 浏览器主消费面;`PageBody.content``mnote.page_aggregate.v1` 中继续保留为必填兼容镜像 / fallback,不作为 active editor 初始化优先事实源。`body.content` 改可选或删除必须进入 v2 协议升级或独立迁移 checklist,不能在 v1 兼容期直接删除。
验收:
- `cargo test -p mnote-web local_markdown -- --test-threads=1`
@@ -186,7 +199,7 @@
归档条件:
- `3-13` 已完成 GFM AST 迁移尾项并归档到 `design/03-rust-web/done/``web_shell.rs` legacy marks 分支改判为兼容层保留,不再作为迁移阻塞项。
- `5-6` 剩余 Phase G/H 小尾项完成或拆成更小 checklist 后归档。
- `5-6` 剩余 Phase G/H 小尾项完成或拆成更小 checklist 后归档`bugs/05-editor-mainline/done/5-41-page-aggregate-compat-slimming-gaps-v1.md` 已明确 `PageBody.content` 的 v1/v2 边界
## 5. P2tree live cache 与 command context
@@ -194,8 +207,10 @@
入口文档:
- `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/03-rust-web/done/3-3-rust-web-tree-realtime-event-stream-v1.md`
- `design/03-rust-web/reference/3-1-rust-web-long-term-checklist-v2.md`
- `design/03-rust-web/process/3-23-sidebar-local-folder-resource-runtime-followup-v1.md`
- `design/05-editor-mainline/done/5-32-filetree-lazy-loading-sidex-alignment-v1.md`
执行目标:
@@ -210,13 +225,13 @@
归档条件:
- `3-3` 中统一 live cache 和 no-refresh 矩阵完成后归档
- `3-3` 已归档为 tree realtime event stream 完成证据;剩余 live cache / refresh 收口不得重新打开旧 `3-3`,应拆入 `3-23``5-32` 或明确的 `bugs/process`
### P2.2 Local folder tree live consumer 收口
入口文档:
- `design/03-rust-web/process/3-18-local-folder-tree-live-consumer-convergence-checklist-v1.md`
- `design/03-rust-web/done/3-18-local-folder-tree-live-consumer-convergence-checklist-v1.md`
执行状态:
@@ -1,5 +1,11 @@
# 全局与内容类型页面宽度设置 Checklist v1
> 状态:done
>
> 归档时间:2026-05-31
>
> 归档说明:实施 checklist 已全量完成,当前真实消费点已覆盖 Markdown、轻量 Office 预览、PDF 预览和 PPTX 快速预览;Mindmap 渲染消费作为后续对应 viewer follow-up,不阻塞本文归档。
## 背景
当前 Markdown 页面已有 `wideLayout` 布尔选项,但 Word / PDF / Excel / PPT / Mindmap 等不同内容类型没有统一的页面宽度偏好入口。用户需要在全局设置中分别调整不同类型的默认宽度,避免像思源那样必须写 CSS 才能把阅读宽度调到合适比例。
@@ -0,0 +1,413 @@
# 3-25 Local Folder MinerU OCR Sidecar Checklist v1
> 创建时间:2026-05-29
> 状态:`process`
> Owner03-rust-web / local-folder resource runtime
>
> 2026-06-01 复核:后端 mock 闭环已落地 `local_ocr.rs` 与 OCR route;真实 MinerU HTTP client 已补本地 HTTP mock 成功路径测试;`task526` 已覆盖 mock OCR API、搜索命中、显式插入链接和 OCR sidecar Markdown resource tab 打开。前端 OCR task runtime、active job store、realtime event 和任务栏 UI 尚未落地。本文继续保持 `process`,不得被本轮 Page AI / OnlyOffice / ChatOnly 收口误归档。
## 背景
当前 mnote local-first 主线中,本地 Markdown 和同目录资源是默认数据真相。本地图片 / 附件上传已通过 `/api/local-folder/assets/upload` 写入 Markdown 文件附近,并返回 `sourcePath` / `attachmentRef`。现在需要给图片和图片型 PDF 增加 OCR 能力,识别引擎使用 MinerU API。
OCR 结果不应默认写进用户正文,也不应只存在 `.mnote` 私有缓存里。用户希望 OCR 附件位于当前 Markdown 文件相同目录下,并用单独文件夹承载,因为一个 Markdown 页面可能对应多个 OCR 文件。
## 目标
- 对 local-folder 图片和图片型 PDF 提供手动触发 OCR。
- 使用 MinerU API 识别本地文件,生成 Markdown OCR 结果。
- OCR 结果作为页面旁路资源落盘到当前 Markdown 同目录的 `{pageStem}.ocr/` 文件夹。
- OCR 文本默认作为资源 sidecar 被搜索、资源面板和后续 AI 上下文消费,不自动污染正文。
- 支持用户显式把某个 OCR 结果插入当前正文。
- 提供全局 OCR 任务进展入口,让用户离开当前资源后仍能看到 OCR 是否完成、失败或需要重试。
- 保持 local-first 可迁移性:OCR Markdown 文件本身可读、可备份、可编辑;`.mnote` 索引只作为缓存和状态加速。
## 非目标
- 不做全 workspace 自动 OCR。
- 不默认把 OCR 文本追加到页面正文。
- 不把 MinerU token 打印到日志、前端 payload 或用户可见错误里。
- 不在前端直接调用 MinerU API。
- 不把 OCR 文件当成普通页面加入 Page Tree。
- 不为 cloud / Convex / remote source 设计默认 OCR 主路径;第一阶段只覆盖 local-folder。
- 不承诺精确百分比进度;MinerU API 第一阶段按阶段型状态展示。
- 不把任务进展做成前端定时轮询主链;优先走现有 realtime / WS / SSE 事件。
## 文件布局
假设当前页面为:
```text
docs/Page.md
```
附件可能为:
```text
docs/Page.assets/photo.png
docs/Page.assets/spec.pdf
```
OCR 结果目录固定为:
```text
docs/Page.ocr/
```
OCR 结果文件示例:
```text
docs/Page.ocr/photo.png.ocr.md
docs/Page.ocr/spec.pdf.ocr.md
```
同一页面下存在同名来源文件时,用来源 root-relative path、文件大小和 mtime 派生短 hash
```text
docs/Page.ocr/photo.png-8f3a21.ocr.md
```
## OCR Markdown frontmatter
OCR Markdown 是 OCR 正文真相,必须能脱离 `.mnote` 索引独立说明来源。
```markdown
---
mnote_ocr_version: 1
provider: mineru
model_version: vlm
owner_document: ./Page.md
source_path: ./Page.assets/photo.png
source_root_relative_path: docs/Page.assets/photo.png
source_size: 123456
source_mtime_ms: 1760000000000
status: done
created_at: 2026-05-29T12:00:00+08:00
updated_at: 2026-05-29T12:00:00+08:00
---
识别文本...
```
字段口径:
- `owner_document`:相对 OCR 文件所在目录到 owner Markdown 的相对路径。
- `source_path`:相对 owner Markdown 所在目录的来源附件路径,和正文中的附件引用口径保持一致。
- `source_root_relative_path`:相对 workspace root 的来源路径,用于稳定查找和索引。
- `source_size` / `source_mtime_ms`:用于判断 OCR 是否 stale。
- `status``done``failed``stale``queued/running` 只写入 `.mnote/ocr-index.json`,避免半成品 OCR 文件被误读。
## `.mnote` 索引
`.mnote/ocr-index.json` 是状态缓存,不是 OCR 正文真相。
```json
{
"version": 1,
"entries": {
"docs/Page.assets/photo.png": {
"ownerDocumentId": "local-md:docs~2FPage.md",
"ownerDocumentPath": "docs/Page.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
"provider": "mineru",
"modelVersion": "vlm",
"status": "done",
"sourceSize": 123456,
"sourceMtimeMs": 1760000000000,
"createdAtMs": 1760000000000,
"updatedAtMs": 1760000000000,
"plainTextPreview": "识别文本前 240 字"
}
}
}
```
索引用途:
- 资源面板快速展示 OCR 状态。
- 搜索索引快速定位 OCR sidecar。
- 避免重复提交同一个未变化资源。
- 记录失败原因,但错误文本需要脱敏,不包含 token、预签名 URL 或完整外部响应体。
## 资源归属与树投影
- OCR 文件夹 `{pageStem}.ocr/` 应在 File Tree 中作为页面旁路资源文件夹出现。
- `{pageStem}.ocr/*.ocr.md` 不应作为普通 Markdown 页面进入 Page Tree。
- local search 不应把 OCR sidecar 当普通 Markdown 页面索引,否则会出现重复页面和错误导航。
- 搜索 OCR 命中时,应返回 owner page 作为结果,`hasOcr=true`evidence 指向来源附件和 OCR sidecar。
- File Tree 可展示 OCR 文件,点击默认作为 Markdown resource tab 打开,不切换成页面文档。
## MinerU 调用边界
后端读取 MinerU token,前端不接触 token。
配置优先级:
1. `MNOTE_MINERU_API_TOKEN`
2. `MINERU_API_TOKEN`
3. 开发环境可选读取 `~/.hermes/.env` 中的 `MINERU_API_TOKEN`
第一阶段使用 MinerU `model_version=vlm`
本地文件流程:
1. `POST https://mineru.net/api/v4/file-urls/batch` 申请上传地址。
2. 使用 `PUT` 把本地文件上传到预签名 URL。
3. 轮询 `GET /extract-results/batch/{batch_id}`
4. 下载 `full_zip_url`
5. 解压并提取结果 Markdown。
6. 写入 `{pageStem}.ocr/*.ocr.md`
7. 更新 `.mnote/ocr-index.json`
## 后端 API
### 创建或复用 OCR job
`POST /api/local-folder/ocr/jobs`
请求:
```json
{
"rootUri": "file:///mnt/Data1T/mnote-notes",
"documentId": "local-md:docs~2FPage.md",
"sourcePath": "Page.assets/photo.png",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"provider": "mineru",
"force": false
}
```
响应:
```json
{
"ok": true,
"job": {
"status": "done",
"ownerDocumentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/photo.png",
"ocrRootRelativePath": "docs/Page.ocr/photo.png.ocr.md",
"stale": false
}
}
```
第一阶段可以同步执行并在请求内返回最终状态;如果真实耗时过长,再升级为后台 job。同步版本必须设置合理超时,并让 UI 显示运行中状态。
### 查询 OCR 状态
`GET /api/local-folder/ocr/status?rootUri=...&sourceRootRelativePath=...`
返回 `.mnote/ocr-index.json` 中的状态,并重新对比来源文件 `size/mtime` 判断 `stale`
### 查询 OCR 任务列表
`GET /api/local-folder/ocr/jobs?rootUri=...`
返回当前 root 下 active jobs 和最近完成 / 失败任务,用于全局任务栏首次渲染和刷新后恢复。
响应:
```json
{
"ok": true,
"jobs": [
{
"jobId": "ocr_1760000000000_abcd",
"fileName": "spec.pdf",
"ownerDocumentId": "local-md:docs~2FPage.md",
"ownerDocumentPath": "docs/Page.md",
"sourceRootRelativePath": "docs/Page.assets/spec.pdf",
"ocrRootRelativePath": "docs/Page.ocr/spec.pdf.ocr.md",
"status": "mineru_processing",
"stageLabel": "识别中",
"startedAtMs": 1760000000000,
"updatedAtMs": 1760000000000,
"finishedAtMs": null,
"stale": false
}
]
}
```
### 读取 OCR 文本
`GET /api/local-folder/ocr/read?rootUri=...&ocrRootRelativePath=...`
返回 OCR Markdown 文本、frontmatter 摘要、来源路径和 stale 状态。
### 插入 OCR 到正文
`POST /api/local-folder/ocr/insert`
只在用户显式触发时执行。默认插入为链接块:
```markdown
[OCRphoto.png](./Page.ocr/photo.png.ocr.md)
```
可选模式 `inlineSection` 插入正文段落:
```markdown
### OCRphoto.png
<!-- mnote:ocr-source ./Page.assets/photo.png -->
识别文本...
```
## UI 行为
- 编辑器图片 toolbar / 附件右键菜单增加 `OCR 识别`
- File Tree 图片/PDF 资源右键菜单增加 `OCR 识别`
- PDF preview / image resource tab 显示 OCR 状态和结果入口。
- 全局任务栏 / 任务抽屉显示运行中、完成、失败和 interrupted 的 OCR 任务。
- 状态文案只表达:未识别、排队中、上传中、识别中、写入中、已识别、来源已变化、识别失败、已中断。
-`stale` 结果展示“重新识别”操作,不自动覆盖旧 OCR 文件,除非用户确认或传 `force=true`
- OCR 结果打开在 resource tab,不切到普通文档页面。
## 搜索与 AI 消费
- `includeOcr=false` 时不搜索 OCR sidecar。
- `includeOcr=true` 时,搜索 OCR 文本并返回 owner page。
- 搜索 evidence 包含:
- `kind: "ocr"`
- `sourceRootRelativePath`
- `ocrRootRelativePath`
- `snippet`
- 后续 `image_read` / Page AI attachment context 可以读取 OCR sidecar,优先返回已存在 OCR 文本,不让 agent 重复调用 OCR。
## 任务进展与全局入口
OCR 是慢任务,提交后必须有全局可见状态,不应只依赖 toast 或当前 resource tab。
第一阶段进度采用阶段型状态:
```text
queued -> uploading -> mineru_processing -> downloading -> writing_sidecar -> done
```
失败和中断状态:
```text
failed
interrupted
stale
```
状态含义:
- `queued`:任务已创建,等待执行。
- `uploading`:正在上传原始图片/PDF 到 MinerU 预签名地址。
- `mineru_processing`MinerU 正在识别。
- `downloading`:正在下载 MinerU 结果包。
- `writing_sidecar`:正在写入 `{pageStem}.ocr/*.ocr.md``.mnote/ocr-index.json`
- `done`OCR sidecar 已写入。
- `failed`:任务失败,可重试。
- `interrupted`mnote-web 进程或任务 worker 中断,不能确认完成,可重试。
- `stale`:来源文件在 OCR 后发生变化。
后端维护一个运行时 active job store,并把每次状态变更写入 `.mnote/ocr-index.json`。页面刷新后,已完成、失败、stale 和 interrupted 状态可以从磁盘恢复;正在运行中的内存任务如果因进程重启丢失,应在下次读取时标记为 `interrupted`,由用户重试。
状态变化通过现有 realtime / WS / SSE 事件链路广播,不在前端叠加 `setInterval` 轮询主链。事件示例:
```json
{
"type": "local_ocr.job.updated",
"jobId": "ocr_1760000000000_abcd",
"rootUri": "file:///mnt/Data1T/mnote-notes",
"ownerDocumentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/spec.pdf",
"ocrRootRelativePath": "docs/Page.ocr/spec.pdf.ocr.md",
"status": "mineru_processing",
"stageLabel": "识别中",
"updatedAtMs": 1760000000000
}
```
前端需要两类展示:
- 资源局部状态:图片/PDF resource tab、附件菜单、File Tree 行显示当前 OCR 状态。
- 全局任务栏 / 任务抽屉:底栏或右上角显示 `OCR 2` / `1 个任务进行中`。点开后列出文件名、所属页面、阶段、开始时间、完成/失败状态,以及“打开 OCR”“重试”等操作。
第一阶段不强制支持取消。若 MinerU 任务 API 后续提供可靠取消,再补 `cancel` 动作。
## 隐私与安全
- OCR 必须由用户手动触发。
- UI 首次触发时应明确提示:文件会发送到 MinerU API。
- 后端只允许读取 `rootUri` 授权根内文件,禁止绝对路径和 `..` 越界。
- 日志不记录 token、预签名 URL、完整外部错误响应和 OCR 正文全文。
- 失败状态写入简短错误码和脱敏消息。
## 实施 Checklist
- [x] 后端:新增 MinerU client,支持本地文件上传、轮询、结果 zip 下载和 Markdown 提取。
- [x] 后端:新增 OCR 路由模块,提供 job/status/read/insert API。(当前 insert 只支持 link 模式)
- [ ] 后端:新增 OCR active job store,记录 queued/uploading/mineru_processing/downloading/writing_sidecar/done/failed/interrupted/stale。
- [ ] 后端:OCR 状态变化写入 `.mnote/ocr-index.json` 并广播 `local_ocr.job.updated` 事件。
- [x] 后端:提供 OCR jobs list API,用于全局任务栏刷新后恢复。
- [x] 后端:实现 `{pageStem}.ocr/` 路径规划、文件名冲突处理和 UTF-8 OCR Markdown 写入。
- [x] 后端:实现 `.mnote/ocr-index.json` 读写、stale 检测和脱敏错误记录。
- [x] 后端:扩展 local-folder 文件分类,让 `{pageStem}.ocr/*.ocr.md` 不进入 Page Tree 普通页面。
- [x] 搜索:扩展 local search index`includeOcr=true` 时索引 OCR sidecar 并返回 owner page。
- [ ] UI:在附件右键菜单、File Tree 资源菜单和资源 tab 增加 OCR 识别入口。
- [ ] UI:展示 OCR 状态、stale、失败和重新识别动作。
- [ ] UI:新增全局 OCR 任务栏 / 任务抽屉,显示任务阶段、所属页面、打开 OCR、重试。
- [~] UI/API:增加“插入 OCR 链接到正文”和可选“插入 OCR 正文段落”动作。(后端 link API 已完成;前端入口和 inlineSection 待做)
- [ ] AI:让 image/PDF 资源上下文优先读取已有 OCR sidecar。
- [x] 测试:Rust 单测覆盖路径规划、frontmatter、索引读写、stale、越界拒绝。
- [x] 测试:Rust route 测试覆盖无 token、真实 MinerU HTTP mock 成功、模拟失败和脱敏错误。
- [x] 测试:local search 测试覆盖 OCR 命中返回 owner pageOCR sidecar 不作为普通页面。
- [~] Smoke:浏览器覆盖图片/PDF 手动 OCR、全局任务栏状态、打开 OCR resource tab、搜索 OCR 命中、插入 OCR 链接。(当前 `task526` 覆盖 mock OCR API、搜索命中和 insert link;真实 UI/任务栏/打开 OCR resource tab 待做)
- [x] 文档:补充 `scripts/TESTING_REFERENCE.md` 中 OCR smoke 基线。
## 验收标准
- 本地图片或图片型 PDF 可被用户手动提交 MinerU OCR。
- OCR 结果落在 owner Markdown 同目录的 `{pageStem}.ocr/` 下,文件为 UTF-8 Markdown。
- `.mnote/ocr-index.json` 删除后,仍能从 OCR Markdown frontmatter 重建核心绑定关系。
- OCR 文件不会污染 Page Tree,也不会作为普通页面搜索结果出现。
- 搜索 OCR 文本时返回 owner page,结果标记 `hasOcr=true`
- 用户未显式选择插入时,页面正文不发生变化。
- 用户提交 OCR 后,即使离开当前资源,也能在全局 OCR 任务栏看到阶段状态;完成后可打开 OCR,失败或中断后可重试。
- 所有外部 API token 和预签名 URL 均不出现在日志、前端 payload 或错误响应中。
## 2026-06-01 后端 mock 闭环
已落地 `rust/crates/mnote-web/src/routes/local_ocr.rs` 与 route 注册,第一刀只承诺后端 mock 闭环:`provider=mock` 可同步生成 `{pageStem}.ocr/*.ocr.md`,写入 `.mnote/ocr-index.json`,提供 jobs/status/read;当时 `provider=mineru` 尚未执行真实外部调用,缺 token 时返回 `mineru_token_missing`,有 token 时返回 `mineru_runtime_not_enabled`
同时已把 OCR sidecar 从 Page Tree 普通 Markdown 扫描中排除,并扩展 local search`includeOcr=false` 不命中 OCR 文本,`includeOcr=true` 返回 owner page,结果带 `hasOcr=true``ocrEvidence`
验证:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1`
2026-06-01 续补:
- `local_ocr` route 测试新增来源路径越界、非图片/PDF 来源和 mock 失败脱敏 response 形态覆盖。
- `local_ocr` route 测试新增缺 MinerU token response 覆盖:`mineru_token_missing`
- `local_ocr` route 测试新增 token 存在但真实 runtime 未接入 response 覆盖:`mineru_runtime_not_enabled`,避免把 mock OCR 闭环误报为真实 MinerU 能力。该旧口径已在后续真实后端 runtime 补齐后由 HTTP mock 成功路径测试替代。
- 新增 `POST /api/local-folder/ocr/insert`,当前只支持 `mode=link`,显式把 OCR sidecar 链接追加到 owner Markdown`local_ocr_insert_route_appends_explicit_ocr_link` 已覆盖。
2026-06-01 浏览器 API smoke 续补:
- 新增 `scripts/task526-local-folder-ocr-api-smoke.js`,在真实浏览器登录态页面内通过 `fetch` 串起 `POST /api/local-folder/ocr/jobs``status``read``jobs``/api/search/documents includeOcr=true``POST /api/local-folder/ocr/insert`
- 该 smoke 固定使用 `provider=mock`,不触碰真实 MinerU token、上传、轮询、zip 下载和 Markdown 提取链路。
- 已验证 mock OCR sidecar 落盘、`.mnote/ocr-index.json` jobs/status/read 可读、`includeOcr=false` 不命中 OCR 文本、`includeOcr=true` 返回 owner page 且带 `hasOcr/ocrEvidence`、显式 insert 才向 owner Markdown 追加 OCR 链接。
- 已通过:
- `node --check scripts/task526-local-folder-ocr-api-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task526-local-folder-ocr-api-smoke.js`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1`
2026-06-01 后端真实 MinerU runtime 续补:
- `local_ocr.rs` 已补 `mineru_api_base_url``mineru_poll_interval``mineru_max_polls` 配置函数。
- `provider=mineru` 后端路径已接入申请上传 URL、PUT 上传、轮询 batch 结果、下载 zip、提取 Markdown。
- 新增 `local_ocr_jobs_route_runs_mineru_runtime_against_http_mock`,用本地 HTTP mock 覆盖真实 client 合同,不访问外网。
- 已通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
@@ -1,6 +1,6 @@
# 4-49 Local File Operation 与 Watch Event 合同 v1
> 状态:process
> 状态:done
> Owner04-tree-domain / 03-rust-web
> 背景:Sidex / VSCode Explorer 对照显示,MNote local_folder 的 tree command、watch event、refresh/reveal 和 opened resource 生命周期之间仍缺少稳定合同。当前前端常靠 `relativePath` / `previousRelativePath` 反推刷新父级,watch event 又容易退回全量 resync,导致大目录、批量操作和已打开资源场景下出现重复刷新、状态漂移和错误恢复困难。
@@ -1,6 +1,6 @@
# 5-30 Sidebar 星标置顶快捷入口与 Scoped Explorer 设计 v1
> 状态:process
> 状态:done
> Owner05-editor-mainline / 03-rust-web / control-plane
> 背景:星标置顶需要对齐 Wolai 的 Sidebar 顶部快速访问体验,但 MNote 还需要支持把高频本地文件夹固定成 Explorer scoped root,避免每次进入大 workspace 时加载完整根目录。
@@ -240,3 +240,4 @@ mnote-web
- 2026-05-26:星标置顶确定为用户级 Sidebar 快捷入口,不是文件夹/页面自身属性。
- 2026-05-26:星标快捷入口必须写入 SQLite control-plane,满足同一用户跨设备一致显示。
- 2026-05-26:文件夹星标点击进入 scoped Explorer,避免大 workspace root 扫描。
- 2026-06-01:只读复核确认 `sidebar_shortcuts` migration/store/API、显式 `rootUri`、scoped Explorer 与 `task492-sidebar-starred-shortcuts-smoke.js` 已覆盖本文主验收;本文归档到 `done/`
@@ -1,6 +1,6 @@
# 5-31 页面设置 SQLite 状态收口设计 v1
> 状态:process
> 状态:done
>
> Owner05-editor-mainline / control-plane / mnote-web
>
@@ -1,6 +1,6 @@
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
> 状态:process
> 状态:done
> Owner05-editor-mainline / 03-rust-web
> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。
@@ -1,8 +1,8 @@
# 5-33 FileTree ViewState 与请求代际收口 v1
> 状态:process
> 状态:done
> Owner05-editor-mainline / 03-rust-web
> 前置:`design/05-editor-mainline/process/5-32-filetree-lazy-loading-sidex-alignment-v1.md`
> 前置:`design/05-editor-mainline/done/5-32-filetree-lazy-loading-sidex-alignment-v1.md`
> 背景:5-32 已把 FileTree lazy children、cache 和局部 refresh 推到可用层,但 Sidex / VSCode 对照显示,当前仍缺少统一 view-state、request generation、collapsed stale 和 reveal command。继续只做“展开恢复”会掩盖慢请求、重复请求和过期结果覆盖的问题。
## 1. 结论
@@ -1,6 +1,6 @@
# 5-33 导航页与路由守卫执行清单 v1
> 状态:process
> 状态:done
>
> Owner05-editor-mainline / 03-rust-web / control-plane
>
@@ -14,7 +14,9 @@
- [x] Phase B recent 闭环已落地:control-plane SQLite recent 表、读写 API、SSR recent 展示、打开 folder/page 写入 recent。
- [x] Phase C 主路径已落地:导航页/星标文件夹进入 scoped 导航页;FileTree 普通文件夹点击只展开;Markdown 普通点击记录 recent 并打开文档页。
- [x] Phase D 验证已落地:补 Rust route/API 测试与 `task500-navigation-page-route-guard-smoke.js` browser smoke,覆盖未登录、删除页回退、recent 分组和导航页首屏无 editor bootstrap。
- [ ] 后续增强:文档页 breadcrumb 文件夹段逐级回到导航页、所有 fetch 404/410 的全局浏览器兜底继续拆独立 follow-up;当前服务端 HTML shell 已覆盖 canonical route guard。
- [x] 后续增强已拆出:文档页 breadcrumb 文件夹段逐级回到导航页、所有 fetch 404/410 的全局浏览器兜底继续`design/05-editor-mainline/process/5-35-navigation-breadcrumb-fetch-guard-followup-v1.md` 跟踪;当前服务端 HTML shell 已覆盖 canonical route guard。
2026-06-01 归档说明:Phase A-D 主路径已有 Rust route/API 测试与 `task500-navigation-page-route-guard-smoke.js` 证据;本文归档到 `done/`,剩余增强不继续阻塞导航页主清单。
## 1. 目标
@@ -0,0 +1,46 @@
# 5-35 导航页 breadcrumb 与 fetch guard follow-up v1
> 状态:process
>
> Owner05-editor-mainline / 03-rust-web
>
> 创建时间:2026-06-01
>
> 来源:`design/05-editor-mainline/done/5-33-navigation-page-route-guard-checklist-v1.md` 归档后拆出的增强尾项。
## 背景
`5-33` 已完成导航页主路径:`/` 与 local-folder / folder scope 无明确页面时显示导航页,不再自动打开默认 Markdownrecent folders/pages、删除页 fallback、未登录 route guard、导航页首屏不注入 editor / aggregate 均已有测试和 browser smoke。
剩余问题不应继续阻塞主清单,但仍属于工作区体验尾项:
- 文档页 breadcrumb 的文件夹段应逐级回到对应 folder navigation page。
- 浏览器交互 fetch 返回 `401/404/410` 时,当前页面应有统一恢复策略,而不是只依赖服务端 HTML route guard。
## 目标
- 点击文档页 breadcrumb 文件夹段时,进入对应 `fileTreeScope` 的导航页,并保持 Sidebar Explorer scope 一致。
- 当前页面相关 fetch 返回 `404/410` 时,回到当前资源所在父 folder navigation page,并显示轻量提示。
- 当前交互 fetch 返回 `401` 时,跳转 `/auth?next=<current-url>`
- 保留普通业务错误 toast,不把所有 API 错误都吞成导航跳转。
## 非目标
- 不重新打开 `5-33` 的导航页主清单。
- 不新增第二套 route truthcanonical route guard 仍在 Rust HTML shell。
- 不改变 FileTree 普通文件夹点击只展开的行为。
- 不为所有后台 API 加全局 silent redirect。
## 验收
- [ ] breadcrumb 文件夹段点击后 URL 包含对应 `fileTreeScope`,主区域是 folder navigation page。
- [ ] Sidebar Explorer 与主区域 scope 一致。
- [ ] 当前文档相关 fetch 返回 `404/410` 时回到父 folder navigation page。
- [ ] 当前交互 fetch 返回 `401` 时跳转 auth 并携带 `next`
- [ ] `task500-navigation-page-route-guard-smoke.js` 继续通过,或新增 `task5xx-navigation-breadcrumb-fetch-guard-smoke.js` 覆盖本文尾项。
## 建议验证
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web root_entry -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_folder -- --test-threads=1`
- `node scripts/task500-navigation-page-route-guard-smoke.js`
@@ -2,7 +2,7 @@
> 创建时间:2026-05-17
>
> 当前状态:`PROCESS`
> 当前状态:`DONE`
>
> 2026-05-21 Batch J 口径补充:
> - 本稿的 ACP runtime 核心实现已完成并在当前页面 AI 主链中作为默认 runtime 边界使用;Hermes HTTP proxy 默认关闭,只在显式 compat 开关下保留。
@@ -0,0 +1,128 @@
# 7-38 Page AI sidebar runtime owner split v1
> 创建时间:2026-05-25
> 状态:`done`
> 来源:`design/03-rust-web/done/3-22-sidebar-tree-runtime-second-stage-split-v1.md` Batch A。
> 2026-05-26 更新:`design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md` 已把 Page AI 主体迁入 `browser/sidebar-page-ai-runtime.js`;本文件继续作为后续 AI owner 收口 checklist。
## 1. 背景
`rust/crates/mnote-web/browser/sidebar-tree-runtime.js` 曾包含大量 Page AI panel 代码:会话、消息、profile、skills、gateway health、ACP runtime、permission dialog、plan/status event、session search/resume 等。2026-05-26 的 runtime 可维护性批次已将主体迁入 `browser/sidebar-page-ai-runtime.js`2026-06-01 继续迁出事件 delegate 和 `pageAi*` 默认状态。tree runtime 目前只保留 `data-mnote-action="open-page-ai"` 主壳入口、共享 bootstrap 对象和少量触发器代理。
这些代码不是 tree/filetree runtime 的长期 owner。继续把 Page AI 面板放在 `03-rust-web` 的 sidebar runtime 拆分里,会让 tree shell、local folder、AI session 三条边界继续混在一起。
## 2. Owner 判断
- owner`07-ai`
- 运行位置:当前仍可挂在 sidebar UI,但 runtime 模块应独立于 tree/filetree runtime。
- 当前目标模块:`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- 主壳职责:只加载 asset、提供当前 document/workspace/root/bootstrap。
## 3. 后续执行建议
- [x] 只读审计 Page AI panel 的状态入口:`pageUiState.pageAi*`、storage、session 列表、profile/skills、permission dialog。
- [x] 建立独立 Page AI sidebar owner runtime`sidebar-page-ai-runtime.js`
- [x]`sidebar-tree-runtime.js` 继续迁出 Page AI click/change/input/keydown 委托,tree runtime 只保留 `data-mnote-action="open-page-ai"` 入口。
- [x]`pageUiState.pageAi*` 从通用 sidebar state 中下沉到 Page AI owner runtime,并保留兼容 getter/setter 或明确 bootstrap contract。
- [x]`sidebar-page-ai-runtime.js` 继续超过 2,500 行,再按 `session/profile-skill/permission/conversation/target/render` 拆成子模块。
- [x] 浏览器验证使用页面 AI / ACP smoke,而不是 tree/filetree smoke 替代。
## 4. 非目标
- 不在 03-rust-web 的 sidebar runtime 二阶段里继续扩大 Page AI 实现。
- 不在 10-review runtime 可维护性收口里继续重构 ACP/Hermes session 语义;这里只记录 owner 边界。
- 不改变 Hermes / Reasonix ACP runtime 协议。
- 不把 local-first agent 文件编辑链路改回粗粒度 `mnote.page.save`
## 5. 2026-06-01 复核
本文继续保持 `process`。只读核查确认 `sidebar-page-ai-runtime.js` 当时约 4519 行,已经触发第 3 节的 2500 行继续拆分条件;`sidebar-tree-runtime.js` 当时仍保留 Page AI click/change 委托和 `pageUiState.pageAi*` 状态壳。缺口已记录到 `bugs/07-ai/process/7-53-page-ai-runtime-split-threshold-triggered-v1.md`
下一步最小切片是按 render / conversation / run orchestration 子模块继续拆 `sidebar-page-ai-runtime.js`,降低单文件职责和公开代理面。
## 6. 2026-06-01 第一刀执行记录
已完成第一刀:`sidebar-tree-runtime.js` 不再内联 Page AI 的 click/input/change/keydown 大段 action 分发,也不再调用 `sidebarPageAi.handlePageAi*` 薄委托;`sidebar-page-ai-runtime.js` 承接原有 close、settings、stop、rotate、intent、tab、agent、context、target、skill、tool、session、permission、open-location、suggestion、send、history 等分发语义,并通过 `installPageAiDelegates()` 自行安装事件监听。
验证记录:
- `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- `node --check rust/crates/mnote-web/browser/sidebar-tree-runtime.js`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch -- --test-threads=1`
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_agent_target_picker_contract_is_visible_and_serialized -- --test-threads=1`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task490-runtime-surfaces-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task502-page-ai-agent-selector-context-smoke.js`
2026-06-01 续补:
- `sidebar-page-ai-runtime.js` 新增并导出 `installPageAiDelegates()`,内部一次性安装 Page AI click/keydown/input/change delegate。
- `sidebar-tree-runtime.js` 仅在自身监听注册后调用 `sidebarPageAi.installPageAiDelegates()`,不再直接处理 Page AI 内部事件。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证 drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 state facade 续补:
- `sidebar-page-ai-runtime.js` 新增 `ensurePageAiStateFacade()`,集中初始化 Page AI 默认状态并导出该 facade。
- `sidebar-tree-runtime.js``pageUiState` 初始对象删除 `pageAi*:` 默认字段,tree runtime 不再定义 Page AI 状态真相。
- Rust include/assert 已更新为:Page AI runtime 包含 `ensurePageAiStateFacade``pageAiAcpRuntime: 'reasonix'`tree runtime 不包含该默认字段。
2026-06-01 conversation helper 续补:
- 新增 `sidebar-page-ai-markdown-runtime.js`,抽出 `textFromUnknown``renderPageAiMarkdown` 和 inline Markdown 渲染 helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiMarkdownRuntime()`,保留现有 assistant message 渲染行为。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-markdown-runtime.js` 静态 runtime asset,并更新 runtime asset mount 测试。
2026-06-01 profile helper 续补:
- 新增 `sidebar-page-ai-profile-runtime.js`,抽出 provider/profile/chat-only profile/history filter/usage helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiProfileRuntime()` 并保留同名代理常量,降低调用点扰动。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-profile-runtime.js` 静态 runtime asset,并更新 runtime asset mount 测试。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证二级 import 后 drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 permission helper 续补:
- 新增 `sidebar-page-ai-permission-runtime.js`,抽出 ACP permission message、dialog show/hide 和 resolve-permission helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiPermissionRuntime()` 并保留同名代理常量,主 runtime 继续负责事件流持久化、会话同步和 conversation 渲染入口。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-permission-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证新增 permission 二级 import 后 sidebar/Page AI 仍可加载和发送。
2026-06-01 session helper 续补:
- 新增 `sidebar-page-ai-session-runtime.js`,抽出 session storage、backend session list/detail/search/resume/delete、session message sync 和 backend runtime event replay helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiSessionRuntime()` 并保留同名代理常量,主 runtime 继续负责 Page AI UI render、run orchestration 和事件委托入口。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-session-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证新增 session 二级 import 后 Page AI drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 skill helper 续补:
- 新增 `sidebar-page-ai-skill-runtime.js`,抽出 skill source、skill preference、Hermes builtin 隐藏和 Reasonix memory preference helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiSkillRuntime()` 并保留同名代理常量,主 runtime 继续负责 skill 异步加载、target/run orchestration 和 UI render。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-skill-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- 已通过 `node --check` 覆盖 Page AI 主 runtime、skill/session/permission/profile/markdown helper 和 `sidebar-tree-runtime.js`
- 已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1``page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch``page_ai_agent_target_picker_contract_is_visible_and_serialized`
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js`,验证新增 skill 二级 import 后 Page AI drawer、agent/context/target/skills 和发送 payload 链路仍可用。
2026-06-01 target helper 续补:
- 新增 `sidebar-page-ai-target-runtime.js`,抽出 OpenEditorsSnapshot target 派生、WorkspacePath、run target snapshot、contextRefs、agentTargetPackage 和 target writable guard helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiTargetRuntime()` 并保留同名代理常量,主 runtime 继续负责 target popover UI、事件分发和 run orchestration 顺序。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言;target picker 合同测试改为在 target helper 中断言 `primaryTargetId` / `targets` / `policy`
- 已通过 `node --check` 覆盖 Page AI 主 runtime、target/skill/session/permission/profile/markdown helper 和 `sidebar-tree-runtime.js`
- 已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1``page_ai_uses_backend_acp_session_runtime_store``page_ai_agent_target_picker_contract_is_visible_and_serialized``page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch`
- 已在临时 `127.0.0.1:3301` Rust web 实例补跑 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js``task520-page-ai-raw-resource-target-smoke.js``task525-page-ai-mindmap-resource-target-smoke.js`,验证新增 target 二级 import 后 Page AI drawer、agent/context/target/skills、raw resource target、mindmap target 和发送 payload 链路仍可用。
2026-06-01 render helper 续补:
- 新增 `sidebar-page-ai-render-runtime.js`,抽出 target/context display helper、agent/profile/model label、skill filter、drawer shell、controls render、suggestions render、conversation render 和 response humanize helper。
- `sidebar-page-ai-runtime.js` 改为 import `createSidebarPageAiRenderRuntime()` 并保留公开方法形状,主 runtime 继续负责 state facade、agent/run orchestration、session/permission/skill/target runtime 接线与事件委托。
- 补注册 `/api/mnote-browser-runtime/sidebar-page-ai-render-runtime.js` 静态 runtime asset,并更新 runtime asset mount/layout 断言。
- `sidebar-page-ai-runtime.js` 当前 2498 行,已低于 2500 行继续拆分阈值。
- 已通过 `node --check` 覆盖 Page AI 主 runtime、render/target/skill/session/permission/profile/markdown helper 和 `sidebar-tree-runtime.js`
- 已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web mnote_browser_runtime_assets_are_explicitly_mounted -- --test-threads=1``page_ai_uses_backend_acp_session_runtime_store``page_ai_agent_target_picker_contract_is_visible_and_serialized``page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch`
本文已满足本轮 owner split checklist 归档条件;后续只在 runtime 再次超过阈值或 run orchestration 继续膨胀时开新缺陷 / 新设计。
2026-06-01 smoke 复核:
- 已通过 `task490-runtime-surfaces-smoke.js``task502-page-ai-agent-selector-context-smoke.js``task504-page-ai-history-agent-filter-smoke.js``task520-page-ai-raw-resource-target-smoke.js``task525-page-ai-mindmap-resource-target-smoke.js`
- `bugs/07-ai/process/7-53-page-ai-runtime-split-threshold-triggered-v1.md` 已归档到 `bugs/07-ai/done/7-53-page-ai-runtime-split-threshold-triggered-v1.md`
- 本轮 smoke 额外发现并修复当前页 target 继承 stale workspacePath 的缺陷,记录到 `bugs/07-ai/done/7-55-page-ai-current-page-target-stale-workspace-v1.md`
@@ -2,7 +2,7 @@
> 创建时间:2026-05-29
>
> 状态:`PROCESS`
> 状态:`done`
>
> OwnerPage AI skill/tool capability surface + MNote host-side context provider
>
@@ -2,7 +2,7 @@
> 创建时间:2026-05-29
>
> 状态:`process`
> 状态:`done`
>
> OwnerPage AI agent identity / Hermes profile policy / Reasonix memory policy
>
@@ -341,74 +341,104 @@ Hermes profile 切换时:
### Batch A - 现状冻结与风险取证
- [ ] 复核当前 Page AI Hermes skill toggle 的真实写入路径,确认是否直接写 Hermes profile `config.yaml`
- [ ] 复核当前 UI preference 中 `hide_builtin`、MNote 内置 skill enabledprofile skill enabled、default profile 的存储键
- [ ] 复核 Reasonix ACP spawn 环境,确认当前是否默认注入 memory
- [ ] 形成 RED 证据:普通用户修改 shared profile skill 会影响其它用户,或当前缺少服务端权限边界
- [ ] 验证:Rust/JS 只读审计记录在本文档或后续 checklist evidence 中
- [x] 复核当前 Page AI Hermes skill toggle 的真实写入路径,确认是否直接写 Hermes profile `config.yaml`
- 证据:`/api/hermes/client/skills/toggle` 进入 `toggle_skill`,读取 `profile/name/enabled` 后调用 `set_skill_enabled(profile, name, enabled)``set_skill_enabled` 直接写 `profile_home(profile)/config.yaml` 中的 `skills.disabled`。当前没有 profile grant / shared readonly 检查
- [x] 复核当前 UI preference 中 `hide_builtin`、MNote 内置 skill enabled、profile skill enabled、default profile 的存储键
- 证据:前端仍使用 `ai.agent.hermes.profile_id` 保存默认 Hermes profileReasonix skill 开关写 `ai.agent.reasonix.skills.enabled`Hermes profile skill 开关写 `ai.agent.hermes.profile.<profile>.skills.enabled`;隐藏内置技能写 `ai.agent.hermes.profile.<profile>.skills.hide_builtin`。这些 key 是 UI preference,不等价于 profile 授权模型
- [x] 复核 Reasonix ACP spawn 环境,确认当前是否默认注入 memory
- 证据:`AcpRuntimeConfig::reasonix` 默认 env 为空;Reasonix wrapper 构造 `CacheFirstLoop` 时未传 memory policy;本地 Reasonix 实现支持 `REASONIX_MEMORY=off|false|0` 关闭 memory,但 MNote 当前没有默认注入。
- [x] 形成 RED 证据:普通用户修改 shared profile skill 会影响其它用户,或当前缺少服务端权限边界。
- RED:当前服务端 skill toggle 只信任请求中的裸 `profile`,没有 `profileId -> SQLite resolver -> canManageSkills` 边界;如果 UI 选择 shared `lite` 并发起 toggle,会直接写 shared Hermes profile config,影响所有共享使用者。
- [x] 验证:Rust/JS 只读审计记录在本文档或后续 checklist evidence 中。
- 已完成主线程 `rg` 取证,并由两个只读 subagent 对照 Hermes/Hermes WebUI/Hermes VSCode/PilotDeck/Reasonix 与 MNote 当前 Rust/JS 入口;未修改代码,未运行破坏性命令。
### Batch B - SQLite profile policy 合同
- [ ] 新增或扩展 SQLite control-plane profile policy`ai_agent_profiles` / `ai_agent_profile_grants` 或等价结构。
- [ ] 初始化 shared Hermes profile:仅 `lite`,普通用户 `canRun=true``canManageSkills=false`
- [ ] 为每个用户 provision personal Hermes profile
- [ ] 补 Rust 定点测试:personal owner、shared readonly、admin manage、跨用户不可管理
- [ ] 验证:不同用户查询 profile list 只返回自己 personal + shared lite。
- [x] 新增或扩展 SQLite control-plane profile policy`ai_agent_profiles` / `ai_agent_profile_grants` 或等价结构。
- 证据:新增 `007-ai-agent-profile-policy.sql`,并在 `control-plane` store/model/sqlite 中增加 `AiAgentProfile*` 合同与 resolver
- [x] 初始化 shared Hermes profile:仅 `lite`,普通用户 `canRun=true``canManageSkills=false`
- 证据:`ensure_ai_agent_profile_policy` 初始化 `shared_lite`,普通用户 grant 为 run-only
- [x] 为每个用户 provision personal Hermes profile。
- 证据:当前用户首次查询时生成 `usr_<user>_default``mnote-u-<user>-default` isolated profile。
- [x] 补 Rust 定点测试:personal owner、shared readonly、admin manage、跨用户不可管理。
- 证据:`control-plane sqlite::tests::ai_agent_profile_policy_provisions_personal_and_shared_boundaries`
- [x] 验证:不同用户查询 profile list 只返回自己 personal + shared lite。
- 证据:`mnote-web routes::hermes_client::tests::page_ai_agent_profiles_are_sqlite_user_scoped`
### Batch C - Hermes profile resolver
- [ ] 新增服务端 `agentProfileRef` resolver,禁止前端提交任意 Hermes path。
- [ ] `/api/hermes/client/runs` `profileId` 解析真实 Hermes profile
- [ ] `/api/hermes/client/skills``profileId` 解析真实 Hermes profile。
- [ ] 保留旧 `profile=` 参数只作为兼容入口,并映射到当前用户可访问 profile。
- [ ] 验证:旧路径兼容不允许越权访问 shared/admin profile。
- [x] 新增服务端 `agentProfileRef` resolver,禁止前端提交任意 Hermes path。
- 证据:新增 `/api/ai/agent-profiles` `profileId -> SQLite policy -> isolatedProfile` resolver;浏览器不提交 filesystem path
- [x] `/api/hermes/client/runs``profileId` 解析真实 Hermes profile。
- 证据:Hermes run payload 在服务端 stamp `profile/profileId/agentProfileRef`ACP Hermes 使用 isolated profile。
- [x] `/api/hermes/client/skills``profileId` 解析真实 Hermes profile。
- 证据:Hermes skills catalog 按 `profileId` 解析并返回 `agentProfileRef/configurable/readonly/configScope`
- [x] 保留旧 `profile=` 参数只作为兼容入口,并映射到当前用户可访问 profile。
- 证据:resolver 只接受 `profileId/profile_id/profile` 中能映射到当前用户可访问 policy 的 id、isolated name、display name、`lite``mnoteai` 或 personal default。
- [x] 验证:旧路径兼容不允许越权访问 shared/admin profile。
- 证据:普通用户 toggle `shared_lite` Hermes skill 返回 `403 ai_profile_readonly`
### Batch D - Skill toggle 权限收口
- [ ] 修改 skill toggle API:只接受 `profileId + skillName + enabled`
- [ ] 区分 `mnote_builtin` `hermes_profile` skill kind
- [ ] MNote 内置 skill toggle 写当前用户 SQLite preference
- [ ] 拒绝普通用户修改 shared profile 的 Hermes profile skills
- [ ] personal profile skill toggle 只写该用户 isolated profile `config.yaml`
- [ ] shared profile skill toggle 仅 admin 可写
- [ ] 验证:Rust API 测试覆盖 `ai_profile_readonly`、MNote 内置 skill per-user toggle、personal profile skill success
- [x] 修改 skill toggle API:只接受 `profileId + skillName + enabled`
- 证据:服务端接受 `profileId/name/enabled/skillKind`;旧 `profile` 仅作 resolver 兼容
- [x] 区分 `mnote_builtin``hermes_profile` skill kind
- [x] MNote 内置 skill toggle 写当前用户 SQLite preference
- [x] 拒绝普通用户修改 shared profile 的 Hermes profile skills
- [x] personal profile skill toggle 只写该用户 isolated profile `config.yaml`
- [x] shared profile skill toggle 仅 admin 可写
- 证据:control-plane admin grant 可管理 shared;普通用户 shared readonly。
- [x] personal Hermes profile 初始 skill 收口为最小白名单。
- 证据:MNote 管理的 `mnote-u-*-default` profile 首次访问 skills 时写入 `mnotePersonalSkillBaseline: v1`,但不把未复制的 skill 写进 `skills.disabled`
- 2026-05-30 修正:personal Hermes 初始状态改为 profile 模板 copy 语义,首次 provision 只把 `vpn` / `zhihu-search` / `global-search` 复制到该 profile 自己的 `skills/` 目录;后续用户可以继续给自己的 personal profile 增加其它 skill 并启停,不做长期强白名单。`skills.disabled` 只表示该 profile 已有 skill 的关闭状态,不表示模板范围。
- [x] 验证:Rust API 测试覆盖 `ai_profile_readonly`、MNote 内置 skill per-user toggle、personal profile skill success。
- 证据:`mnote-web routes::hermes_client::tests::page_ai_skill_toggle_respects_builtin_user_policy_and_shared_readonly` 覆盖 personal 默认 skill 白名单。
### Batch E - MNote 内置 skill per-user policy
- [ ] 将 MNote 内置 skill enable/disable 保存为 SQLite per-user policy。
- [ ] UI 中 `hide_builtin_skills` 只影响展示,不影响 enable/disable。
- [ ] Skills panel 标记 `builtin=true``configurable=true``configScope=user_sqlite`
- [ ] 服务端 MNote tool policy 按 `user_id + agentId + contextRefs + allowedRoots + mnote_builtin_skill_enabled` 判断
- [ ] 验证:用户 A 禁用某内置 skill 不影响用户 B;禁用后对应 MNote tool 被服务端拒绝;隐藏展示不影响 enable 状态
- [x] 将 MNote 内置 skill enable/disable 保存为 SQLite per-user policy。
- [x] UI 中 `hide_builtin_skills` 只影响展示,不影响 enable/disable。
- 证据:隐藏 key 收口为 `ai.agent.hermes.skills.hide_builtin`MNote 内置 enable 使用独立 `ai.agent.mnote_builtin.skill.<id>.enabled`
- [x] Skills panel 标记 `builtin=true``configurable=true``configScope=user_sqlite`
- [x] 服务端 MNote tool policy 按 `user_id + agentId + contextRefs + allowedRoots + mnote_builtin_skill_enabled` 判断
- 证据:`create_run` 服务端读取当前用户 SQLite preference 并覆盖 `skillPreferences.mnote`,不信任浏览器提交。
- [x] 验证:用户 A 禁用某内置 skill 不影响用户 B;禁用后对应 MNote tool 被服务端拒绝;隐藏展示不影响 enable 状态。
- 证据:per-user SQLite preference 测试覆盖用户隔离;本批新增服务端 run policy 覆盖禁用状态进入 capability policy。工具调用显式拒绝仍可后续细化到每个 tool handler。
### Batch F - Reasonix memory policy
- [ ] 新增 per-user 设置 `ai.agent.reasonix.memory_enabled`,默认 `false`
- [ ] Reasonix ACP spawn 默认设置 `REASONIX_MEMORY=off`
- [ ] 开启 memory 后不注入 `REASONIX_MEMORY=off`,并在 UI 显示 memory enabled。
- [ ] 补 JS/Rust 测试或 smoke,覆盖默认 off、用户开启、不同用户隔离 preference
- [ ] 验证:普通消息由 Reasonix 自己决定是否使用工具;MNote 不再强行注入 memory/context 正文
- [x] 新增 per-user 设置 `ai.agent.reasonix.memory_enabled`,默认 `false`
- [x] Reasonix ACP spawn 默认设置 `REASONIX_MEMORY=off`
- [x] 开启 memory 后不注入 `REASONIX_MEMORY=off`,并在 UI 显示 memory enabled。
- 证据:开启后服务端注入 `REASONIX_MEMORY=on`UI Reasonix 设置页显示 memory 状态
- [x] 补 JS/Rust 测试或 smoke,覆盖默认 off、用户开启、不同用户隔离 preference
- 证据:`mnote-web routes::hermes_client::tests::reasonix_memory_policy_defaults_off_and_reads_user_preference`
- [x] 验证:普通消息由 Reasonix 自己决定是否使用工具;MNote 不再强行注入 memory/context 正文。
- 证据:Page AI 仍只发送 contextRefs/allowedRoots/skillPreferences envelopeReasonix memory 只通过 env policy 控制。
### Batch G - UI 收口
- [ ] Agent selector 中 Hermes profile 显示 personal/shared/readonly 状态。
- [ ] Skills panel 三组均可折叠
- [ ] Hermes profile 切换必须刷新 skill catalog,避免旧 profile skill 残留。
- [ ] shared profile 的 Hermes profile skills 对普通用户展示只读开关或锁定状态。
- [ ] MNote 内置 skills 对普通用户展示可启停状态,并标明按当前 MNote 用户保存。
- [ ] 管理员对 shared profile 显示可管理状态,并提示影响所有用户。
- [ ] 验证:真实浏览器截图覆盖 MNote 内置 skill per-user 可配置、personal Hermes skill 可配置、shared Hermes skill 只读、admin shared 可配置。
- [x] Agent selector 中 Hermes profile 显示 personal/shared/readonly 状态。
- [x] Skills panel 用单一技能来源下拉收口为 `mnote` / `reasonix` / `Hermes_user` / `hermes_lite`,选中哪个只显示哪个来源的 skills
- [x] Hermes profile 切换必须刷新 skill catalog,避免旧 profile skill 残留。
- [x] shared profile 的 Hermes profile skills 对普通用户展示只读开关或锁定状态。
- [x] MNote 内置 skills 对普通用户展示可启停状态,并标明按当前 MNote 用户保存。
- [x] 管理员对 shared profile 显示可管理状态,并提示影响所有用户。
- [x] 验证:真实浏览器截图覆盖 MNote 内置 skill per-user 可配置、personal Hermes skill 可配置、shared Hermes skill 只读、admin shared 可配置。
- 证据:`scripts/task502-page-ai-agent-selector-context-smoke.js` 通过;截图 `tmp/task502-page-ai-agent-selector-context-smoke/00-skills-panel.png` 显示技能来源下拉与 `Hermes_user · 我的 Hermes` 单来源 skill 列表。
- 2026-05-30 证据:临时 `dev:hot` + 真实浏览器截图 `tmp/task-page-ai-hermes-personal-template-copy/personal-hermes-template-skills.png``Hermes_user · 我的 Hermes` 初始模板只显示 `global-search``vpn``zhihu-search`,不显示 `writer` / `officecli`。Rust 测试覆盖“初始 3 个 skillprofile 后续新增 `writer` 后可开启并出现在 catalog”。
### Batch H - 回归矩阵与文档收尾
- [ ] 更新 Page AI 设计说明,明确 MNote 内置 skill per-user policy、personal/shared Hermes profile 与 Reasonix memory policy。
- [ ] 更新 smokeagent 切换、MNote 内置 skill per-user toggle、Hermes profile 切换、shared readonly、personal skill toggle、Reasonix memory off/on。
- [ ] 运行 `node --check` 覆盖相关 browser runtime / smoke。
- [ ] 运行 Rust 定点测试覆盖 SQLite profile policy 与 Hermes skill toggle 权限。
- [ ] 运行真实浏览器验证并截图。
- [ ] 运行 `git diff --check`
- [ ] 涉及代码图后运行 `codegraph sync .`
- [ ] 完成后将本 checklist 移动到 `done/` 或标记为 `done`
- [x] 更新 Page AI 设计说明,明确 MNote 内置 skill per-user policy、personal/shared Hermes profile 与 Reasonix memory policy。
- [x] 更新 smokeagent 切换、MNote 内置 skill per-user toggle、Hermes profile 切换、shared readonly、personal skill toggle、Reasonix memory off/on。
- [x] 运行 `node --check` 覆盖相关 browser runtime / smoke。
- [x] 运行 Rust 定点测试覆盖 SQLite profile policy 与 Hermes skill toggle 权限。
- [x] 运行真实浏览器验证并截图。
- [x] 运行 `git diff --check`
- [x] 涉及代码图后运行 `codegraph sync .`
- [x] 完成后将本 checklist 移动到 `done/` 或标记为 `done`
- 状态已标记为 `done`2026-06-01 已迁入 `design/07-ai/done/`
## 8. 验收口径
@@ -0,0 +1,407 @@
# 7-42 Page AI mindmap skill and resource generation v1
> 创建时间:2026-05-30
> 状态:`done`
> owner`07-ai`
> 上位参考:
> - `design/07-ai/reference/7-28-resource-ai-tool-contract-v1.md`
> - `design/07-ai/done/7-3-page-ai-hermes-panel-and-mnote-plugin-v1.md`
> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md`
> - `design/04-tree-domain/done/4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md`
## 1. 背景
当前 Page AI 的 MNote 内置技能只有:
- `mnote-current-page`
- `mnote-local-file`
- `mnote-chat-only`
但 Rust Hermes tool manifest 已经暴露了资源工具雏形:
- `mnote.mindmap.fetch`
- `mnote.mindmap.apply_ops`
这说明底层 resource tool 方向已经存在,缺口不在“完全没有工具”,而在 Page AI 缺少一个明确的 `mnote-mindmap` 内置 skill 来指导 agent 何时读取、何时写入、如何生成新的思维导图资源,以及如何把 PDF / Office / Markdown 等材料整理成新的 `.mindmap.json`
用户明确的长期目标是:
> AI 能总结 PDF,并整理新建出思维导图。
因此本设计不能只覆盖“编辑已有导图节点”,还必须覆盖“从外部材料生成新导图资源”的闭环。
## 2. 原型观察
### 2.1 KMind plugin
`reference-code/kmind-plugin` 体现的关键点:
-`simple-mind-map` 风格树结构为核心。
- 支持多根、MOC、文档树导图、节点超链接、TODO、主题、布局、导入导出。
- 节点可以携带思源块/文档引用,说明 mindmap 节点不只是纯文本,还可能是资源引用容器。
- 导入导出会处理 markdown、Freemind、XMind 等格式,但最终仍要落回 mindmap runtime 可消费的数据结构。
对 MNote 的启发:
- AI 不能把思维导图降级为普通 Markdown 大纲后直接覆盖文件。
- AI 写入必须尽量保留未知扩展字段,如节点样式、引用、视图状态、主题配置。
- 从 PDF 生成导图时,应先生成结构化 outline,再转换成 mindmap tree,而不是让模型手写完整 runtime JSON。
### 2.2 lx-doc mind-map
`reference-code/lx-doc/mind-map` 体现的关键点:
- 思维导图项目独立部署,工作台只负责文件/资源管理。
- 文件内容是完整对象,典型形态为:
- `root`
- `theme`
- `layout`
- `config`
- `view`
- runtime 使用 `setFullData` 恢复完整文件,用 `getData(true)` 保存全量配置。
- `root``simple-mind-map` 树:`{ data: { text, uid, ... }, children: [...] }`
对 MNote 的启发:
- mindmap 是 Resource Tree 对象,不是 Markdown 正文的一部分。
- Markdown 页面只保留占位、链接或嵌入引用。
- AI 读写 mindmap 时应围绕 resource 文件、object identity 和 resource capability 工作。
## 3. 当前 MNote 数据合同
当前默认 `.mindmap.json` 不是裸树,而是 envelope
```json
{
"data": {
"children": [],
"data": {
"expand": true,
"isActive": false,
"text": "KMIND",
"uid": "root"
}
},
"view": {
"state": {
"scale": 1,
"sx": 0,
"sy": 0,
"x": -44.99991989135742,
"y": -15.500006675720217
},
"transform": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": -44.99991989135742,
"f": -15.500006675720217,
"originX": 0,
"originY": 0,
"rotate": 0,
"scaleX": 1,
"scaleY": 1,
"shear": 0,
"translateX": -44.99991989135742,
"translateY": -15.500006675720217
}
}
}
```
设计约束:
- 最终写入文件必须保持 envelope。
- 根节点位于 `data.data`
- 子节点位于 `data.children`
- 新建导图默认 `data.data.uid``root`
- 新建导图默认 `data.data.text` 可由用户材料标题覆盖;没有标题时使用 `KMIND`
- `view` 默认使用上述稳定模板;AI 不应自行发明缩放和位移。
- 修改已有导图时必须保留未知字段,包括 `view`、节点样式、节点引用、主题和未来扩展字段。
## 4. 目标
- 新增 Page AI MNote 内置 skill`mnote-mindmap`
- 让 Hermes / Reasonix 在 Page AI 中知道如何读写当前页面或当前资源 tab 的 mindmap。
- 让 AI 能从 PDF、Office、Markdown、当前页内容或用户粘贴文本生成层级 outline,再创建新的 mindmap resource。
- 让新建导图进入 Resource Tree / File Tree / Page Tree 的正确边界,而不是写进 Markdown 正文。
- 保留 local-first 主线:本地 `.mindmap.json` 是本地导图资源真相;Rust control-plane 负责授权、审计和 resource identity。
## 5. 非目标
- 不在本批实现 PDF OCR / MinerU / Office 文本抽取本身。
- 不把 mindmap 正文存进 Markdown 页面。
- 不让 Page AI 前端私有拼第二套 mindmap 真相。
- 不让 agent 直接大段重写完整 JSON 作为默认写入方式。
- 不引入轮询刷新链路;写入后的 UI 同步应走已有 watcher、resource session 或命令结果驱动刷新。
## 6. Skill 设计
新增内置 skill
```text
id: mnote-mindmap
title: MNote mindmap editing
description: Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.
agentIds: hermes, reasonix
readOnly: false
requiresContextRefs: current_page, file, folder, resource
toolNames:
- mnote.context.snapshot
- mnote.context.resolve_target
- mnote.mindmap.fetch
- mnote.mindmap.apply_ops
- mnote.mindmap.create_from_outline
```
Skill 正文规则:
- 只有用户明确要求“思维导图 / mindmap / KMind / 脑图 / 从材料生成导图”时启用。
- 先解析目标资源,再读取内容。
- 若当前 Page AI target 是 mindmap resource tab,优先使用该资源。
- 若当前页面包含 mindmap embed,占位中的 `mindmapId` / `sourcePath` 是候选资源。
- 若用户要求“从 PDF 生成导图”,先读取或请求 PDF 摘要/outline,再调用 mindmap 创建工具。
- 写前必须确认 `permissionLevel=read_write`,并确认 `allowedResourceIds` 覆盖目标资源。
- 写后必须回读,并报告新建或变更的 `.mindmap.json`
- 生成 outline 时必须使用“中心主题 + 一级分类 + 短语化节点”的导图结构;禁止把 PDF/文档摘要以长段落塞进节点文本。
- 节点文本优先使用关键词或短语,一个节点只表达一个概念;层级必须用 `children` 表达,引用/页码/来源信息优先放入 `sourceRefs` 或 metadata。
## 7. Tool 面设计
### 7.1 `mnote.mindmap.fetch`
保留现有命名,增强返回结构。
输入:
- `workspaceId`
- `documentId`
- `mindmapId`
- `rootUri`
- `resourcePath`
- `scope`: `tree | subtree | markdown_summary | full_envelope`
- `nodeId`
- `aiAccessScope`
输出:
- `objectIdentity`
- `resourceKind=mindmap`
- `mindmapId`
- `resourcePath`
- `revision`
- `envelope`,仅 `full_envelope` 返回
- `root`
- `nodes`
- `edges`
- `markdownSummary`
- `source=local_folder`
约束:
- `tree` / `markdown_summary` 默认不返回完整 envelope,避免 prompt 被视图和样式噪音污染。
- `full_envelope` 只在需要保留或精确 patch 文件时使用。
### 7.2 `mnote.mindmap.apply_ops`
当前该工具已存在,但 local resource 非 dry-run 仍提示 agent 用原生 patch。后续应收口为真正结构化写入工具。
输入:
- `workspaceId`
- `documentId`
- `mindmapId`
- `rootUri`
- `resourcePath`
- `expectedRevision`
- `ops`
- `aiAccessScope`
建议 ops
- `updateText`
- `insertChild`
- `insertSiblingAfter`
- `deleteNode`
- `setHyperlink`
- `setRefs`
- `appendNote`
- `patchView`
- `setLayout`
- `setTheme`
约束:
- 默认只改 `data` 树和指定 metadata。
- 未知字段必须透传。
- `expectedRevision` 不匹配时拒写或返回 conflict。
- 写入后返回 `revision``changedFiles``markdownSummary`
### 7.3 `mnote.mindmap.create_from_outline`
新增工具,用于“从材料生成新导图”。
输入:
- `workspaceId`
- `documentId`
- `rootUri`
- `targetPageId``targetDocumentId`
- `resourcePath`,可选;缺省由服务端生成唯一 `.mindmap.json`
- `title`
- `outline`
- `sourceRefs`
- `aiAccessScope`
- `embedIntoPage`,默认 `true`
`outline` 建议格式:
```json
[
{
"text": "一级主题",
"children": [
{
"text": "二级主题",
"children": []
}
]
}
]
```
输出:
- `objectIdentity`
- `mindmapId`
- `resourcePath`
- `envelope`
- `revision`
- `changedFiles`
- `embedResult`
- `markdownSummary`
写入规则:
- 服务端负责把 outline 转换为默认 envelope。
- 根节点文本使用 `title`,没有标题时使用 `KMIND`
- 子节点 uid 由服务端稳定生成,避免模型生成重复 uid。
- 默认 view 使用当前稳定模板。
-`embedIntoPage=true`,通过 Resource Tree / Markdown embed 合同把资源绑定到当前页面。
## 8. PDF 到导图闭环
长期目标链路:
```text
PDF resource
-> 文本/OCR/章节提取
-> AI 生成层级 outline
-> mnote.mindmap.create_from_outline
-> 写入 .mindmap.json
-> Resource Tree 绑定当前页面
-> 打开 mindmap resource tab
```
本设计只冻结后半段合同:
- PDF 摘要工具输出应是 `title + outline + sourceRefs`
- `sourceRefs` 可以记录 PDF 文件、页码、章节、引用片段。
- mindmap 节点可把 `sourceRefs` 保存到节点 `refs``note`,但第一版只要求保留到工具审计和 root metadata。
后续可接入:
- `mnote.office.fetch_summary`
- `mnote.pdf.extract_outline`
- MinerU markdown 识别结果
- 本地 Markdown / 当前页正文
## 9. 权限与审计
读写 mindmap resource 必须同时满足:
- 当前 actor 拥有 root 的 read 或 write grant。
- `aiAccessScope.permissionLevel` 覆盖目标操作。
- `allowedResourceIds` 包含 `mindmapId``resourcePath``objectIdentity`
- 写入只能落在授权 root 内。
审计必须记录:
- tool name
- sessionId / runId / traceId
- actorId
- documentId
- mindmapId
- resourcePath
- sourceRefs
- changedFiles
- before / after revision
## 10. Page AI 上下文
Page AI run payload 应能携带当前 mindmap target
```json
{
"contextRefs": [
{
"kind": "resource",
"resourceKind": "mindmap",
"objectIdentity": "resource:mindmap:{documentId}:{mindmapId}",
"documentId": "{documentId}",
"mindmapId": "{mindmapId}",
"resourcePath": "{relativePath}",
"rootUri": "{rootUri}"
}
]
}
```
来源优先级:
1. 当前打开的 mindmap resource tab。
2. 当前页面选中的 mindmap block/embed。
3. 当前页面中唯一 mindmap embed。
4. 用户明确给出的资源文件路径。
5. 创建新导图时,当前页面作为目标页面。
## 11. 验收清单
- [x] MNote 内置技能面板出现 `MNote mindmap editing`
- [x] `mnote-mindmap` 开关能进入 `skillPreferences.mnote`
- [x] `mnote.skill.read` 能读取 `mnote-mindmap` 正文。
- [x] `mnote-mindmap` 正文说明导图 outline 格式:中心主题、一级分类、短语化节点、避免长段落。
- [x] `mnote.mindmap.fetch` 可读取默认 envelope 并返回 root/nodes/summary。
- [x] `mnote.mindmap.create_from_outline` 可生成符合默认 envelope 的 `.mindmap.json`
- [x] `mnote.mindmap.create_from_outline` 在显式 `embedIntoPage=true` 时可把新导图链接写入当前 local-md 页面。
- [x] `task503-mindmap-skill-capability-smoke` 可直接读取 skill、创建能力导图、fetch 回读、绑定当前页面,并用真实登录浏览器截图验证。
- [x] 新建导图能绑定当前页面并在 File Tree / Resource Tab 可见。
- [x] `mnote.mindmap.apply_ops` 写入后保留 `view` 和未知字段。
- [x] shared/read-only scope 下写工具拒绝。
- [x] revision 不匹配时拒写或返回 conflict。
- [x] PDF outline fixture 可生成 mindmap resource。
- [x] 真实 mindmap resource tab 能注入 Page AI `active_editor` contextRef / `targetPackage`
当前第一批已完成 Page AI skill 发现、skill 正文读取、默认 envelope fetch、`create_from_outline` 写入、显式 `embedIntoPage=true` 页面链接绑定、`task502` Page AI skill payload smoke,以及 `task503` 直接 tool 创建导图 + 页面绑定 + 浏览器截图 smoke。第二批已完成 `apply_ops` 最小结构化写入、`view` / 未知字段保留、revision conflict 和 shared/read-only 拒写单测;2026-06-01 已修复 `task455` 暴露的 local-folder embedded mindmap 刷新后 id 退化,根因是 `blockDocument.blocks[].attrs` 未保留 `mindmapId/sourcePath/rootNodeId` 且浏览器 conversion 未读取 blockDocument attrs;新增 `task525` 覆盖真实 mindmap resource tab 的 Page AI `active_editor` contextRef / `targetPackage`
2026-06-01 复核:`mnote.mindmap.apply_ops` 已从 native patch 提示收口到最小结构化写入,当前支持 `updateText` / `updateNode``insertChild` / `addChild``deleteNode`,并由 `hermes_tools_mindmap_apply_ops_writes_and_preserves_envelope_fields``hermes_tools_mindmap_apply_ops_rejects_stale_revision``hermes_tools_mindmap_apply_ops_shared_read_is_forbidden` 覆盖。resource tab / File Tree 可见性和 Page AI mindmap contextRefs 已由 `task455``task524``task525` 验收。后续若继续扩展 `moveNode``insertSiblingAfter``setHyperlink``setRefs``appendNote``patchView`,应另拆 P2 follow-up,不阻塞本轮最小闭环。
## 12. 第一批执行建议
第一批只做可控闭环:
1. 新增 `skills/mnote-mindmap/SKILL.md`
2.`hermes_tools::skill` 注册 `mnote-mindmap`
3. 补 Page AI skill 面板 smoke。
4.`mnote.skill.read` 单测。
5. 增强 `mnote.mindmap.fetch` 输出,识别当前默认 envelope。
6. 新增 `mnote.mindmap.create_from_outline` dry-run 与真实写入。
7. 用 fixture 模拟 PDF 摘要,不接真实 PDF OCR。
第二批再做:
1. 补浏览器 smoke,验证新建或修改后的 mindmap 能在 File Tree / Resource Tab 可见。
2. 接入真实 PDF / Office / MinerU 结果。
3. 在资源 tab 中把当前 mindmap 自动注入 Page AI contextRefs。
4. 按真实编辑需求继续扩展 `apply_ops`,例如 `moveNode``insertSiblingAfter``setHyperlink``setRefs``appendNote``patchView`
@@ -0,0 +1,292 @@
# 7-44 ChatOnly Doubao session binding v1
> 创建时间:2026-05-31
>
> 状态:`done`
>
> OwnerPage AI ChatOnly / Hermes ACP runtime / SQLite control-plane / OpenClaw Doubao Web provider
>
> 上位依据:
> - `design/07-ai/done/7-25-acp-session-runtime-enhancement-plan-v1.md`
> - `design/07-ai/done/7-30-acp-session-load-resume-checklist-v1.md`
> - `design/07-ai/done/7-33-acp-session-info-plan-ui-checklist-v1.md`
> - `design/07-ai/done/7-39-page-ai-agent-selector-context-authorization-settings-v1.md`
> - `design/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md`
## 1. 背景
MNote Page AI 的 `Chat-only / 豆包` 当前已经能通过 Hermes ACP -> OpenClaw `doubao-web` provider 调用豆包网页,但 MNote 会话与豆包网页会话还没有稳定统一:
- MNote 的会话主记录在 SQLite `ai_runtime_runs` / `ai_runtime_events`,按 `user_id + workspace_id + session_id` 查询和软删除。
- Hermes / ACP 层有 `acpSessionId`MNote 会通过 `session.info.updated` 持久化它。
- OpenClaw `doubao-web` provider 会从豆包 SSE 中捕获 `conversation_id`,并在进程内 `sessionMap` 中用 ACP `sessionId` 复用豆包会话。
-`sessionMap` 没有落 SQLite。MNote 重启、OpenClaw 重启、跨用户、删除会话时,都无法可靠知道某个 MNote ChatOnly session 对应哪条豆包网页 conversation。
2026-05-31 已验证 ChatOnly 豆包重复会话的直接根因是 Hermes `title_generation` 复用了豆包主模型;当前已在 `openclaw-doubao-chat` profile 禁用标题生成辅助调用。但这只解决“一条消息变两条豆包会话”的触发点,不解决会话生命周期统一。
## 2. 目标
本阶段目标是让 MNote 成为 ChatOnly 豆包会话的本地控制面:
- MNote ChatOnly session 与豆包 `conversation_id` 建立持久绑定。
- 后续同一个 MNote session 继续发送时,复用同一个豆包 conversation。
- 删除 MNote session 时,对豆包远端 conversation 做最佳努力删除。
- 多用户会话隔离以 SQLite `user_id` 为准,不能只依赖 OpenClaw 全局内存。
- 删除失败不能阻塞 MNote 本地删除,但必须可审计、可重试。
非目标:
- 不把豆包网页作为 MNote 会话真源。
- 不同步豆包网页中用户手工创建的所有历史会话。
- 不承诺豆包接口稳定可用;远端删除属于 provider-specific best effort。
- 不在 ChatOnly 下申请 MNote 文件写权限。
## 3. 豆包侧接口取证
### 3.1 发送 / 继续会话
当前 OpenClaw `DoubaoWebClientBrowser` 发送消息使用:
```text
POST /samantha/chat/completion
```
请求体关键字段:
```json
{
"completion_option": {
"need_create_conversation": false,
"is_delete": false
},
"conversation_id": "38428454119180290"
}
```
`conversation_id` 为空或 `"0"` 时,豆包创建新 conversation。响应 SSE 中会出现 `conversation_id`OpenClaw 已能解析并打印:
```text
[Doubao Web Browser] Captured conversation_id: ...
```
### 3.2 删除会话
从豆包当前 Web UI 已加载脚本和 CDP 请求监听确认,删除会话优先走 IM cmd 链路:
```text
POST /im/conversation/batch_del_user_conv
```
请求头关键字段:
```text
content-type: application/json; encoding=utf-8
accept: application/json, text/plain, */*
agw-js-conv: str
```
请求体结构:
```json
{
"cmd": 4171,
"uplink_body": {
"batch_delete_user_conversation_uplink_body": {
"conversation_id": ["38428454119180290"],
"delete_all": false,
"conversation_type": 3
}
},
"sequence_id": "uuid",
"channel": 2,
"version": "1"
}
```
其中 `conversation_type: 3` 对应 `ONE_TO_BOT_CHAT`
脚本中还存在旧 wrapper
```text
POST /samantha/im/conversation/batch_delete
```
但当前删除弹窗路径使用 `/im/conversation/batch_del_user_conv`,实现优先采用该路径。
## 4. 数据合同
新增 SQLite 控制面表:
```text
ai_external_conversation_bindings
- id
- user_id # 当前 MNote 用户,必填
- workspace_id # 可空,但列表/删除必须按上下文过滤
- mnote_session_id # MNote ChatOnly session_id
- acp_session_id # Hermes/OpenClaw ACP sessionId,可空,收到后补齐
- agent_id # chat_only
- profile # openclaw-doubao-chat 等
- provider # doubao-web
- remote_conversation_id # 豆包 conversation_id
- remote_url # https://www.doubao.com/chat/{remote_conversation_id}
- status # active | local_deleted | remote_deleted | remote_delete_failed
- metadata_json # 捕获来源、失败原因、最后响应摘要
- created_at
- updated_at
- deleted_at
```
约束:
- `user_id + provider + remote_conversation_id` 唯一,避免同一豆包会话绑定给多个 MNote 用户。
- `user_id + mnote_session_id + provider` 唯一,避免一个 MNote session 绑定多条豆包远端会话。
- 查询、恢复、删除都必须带当前 `user_id`;不能只按 `mnote_session_id` 查。
## 5. 数据流
### 5.1 创建 MNote ChatOnly session
1. 前端 `pageAiEnsureHermesSession(forceCreate=true)``/api/hermes/client/sessions`
2. mnote-web 写入 SQLite session index run`status=session.created`
3. 不立即创建豆包远端 conversation。
4. 绑定表暂不写,或写入一行 `remote_conversation_id=NULL` 的 pending 记录。
### 5.2 第一次发送
1. mnote-web 创建 runpayload 携带 `sessionId``agentId=chat_only``profile=openclaw-doubao-chat`
2. mnote-web 查询绑定表;若无 `remote_conversation_id`,不传远端 ID。
3. OpenClaw 发送给豆包,豆包创建新 conversation。
4. OpenClaw 从 SSE 捕获 `conversation_id`
5. OpenClaw 需要把 `conversation_id` 作为结构化事件回传给 MNote。建议事件:
```json
{
"event": "provider.conversation.bound",
"data": {
"provider": "doubao-web",
"remoteConversationId": "38428454119180290",
"remoteUrl": "https://www.doubao.com/chat/38428454119180290"
}
}
```
6. mnote-web 在 `persist_acp_runtime_event` 中识别该事件,upsert `ai_external_conversation_bindings`
### 5.3 继续发送
1. mnote-web 根据 `user_id + mnote_session_id + provider=doubao-web` 查询绑定。
2. 若找到 active `remote_conversation_id`,在传给 ACP/OpenClaw 的 payload 中加入:
```json
{
"providerConversation": {
"provider": "doubao-web",
"remoteConversationId": "38428454119180290"
}
}
```
3. OpenClaw Doubao provider 使用该 ID 调 `/samantha/chat/completion`,设置 `need_create_conversation=false`
4. 若豆包返回会话不存在或被删除,OpenClaw 发 `provider.conversation.missing`;MNote 标记绑定异常,由用户决定是否新建远端会话。
### 5.4 删除 MNote session
1. 用户在 MNote 删除 ChatOnly 会话。
2. mnote-web 先对 `ai_runtime_runs` 做本地软删除。
3. mnote-web 将绑定表标记为 `local_deleted`
4. 若存在 `remote_conversation_id`,调用 OpenClaw / provider adapter 执行远端删除:
```text
POST /im/conversation/batch_del_user_conv
```
5. 成功后标记 `remote_deleted`
6. 失败时保持本地删除已完成,绑定标记 `remote_delete_failed``metadata_json` 记录错误码、响应摘要和时间。
7. UI 提示:`MNote 会话已删除,豆包远端删除失败,可稍后重试`
## 6. API 边界
### 6.1 mnote-web 内部 API
建议新增 provider conversation helper,不把豆包细节散落在 `hermes_client.rs`
```text
rust/crates/mnote-web/src/provider_conversations.rs
```
职责:
- 解析 run payload 中的 ChatOnly provider。
- 读写 `ai_external_conversation_bindings`
- 将 binding 注入 ACP run payload。
- 处理 `provider.conversation.bound/missing/deleted/delete_failed` 事件。
### 6.2 OpenClaw Doubao provider
需要在 OpenClaw `doubao-web` provider 增加三个能力:
- 从 ACP/context payload 读取 `providerConversation.remoteConversationId`
- 捕获新 `conversation_id` 后发结构化事件,不能只写 console log。
- 暴露 `deleteConversation(remoteConversationId)`,内部走 `/im/conversation/batch_del_user_conv`
第一阶段如果 ACP 不支持 provider 自定义 RPC,可先由 mnote-web 调一个 OpenClaw 本地 HTTP helper;但长期应收口到 provider adapter。
## 7. 错误处理
| 场景 | 行为 |
| --- | --- |
| 豆包创建成功但未捕获 `conversation_id` | run 仍完成;绑定缺失;下一轮可能新建远端会话;UI 标记未绑定 |
| 绑定表有 ID,但豆包返回不存在 | 标记 `remote_missing`,提示用户重新绑定或新建 |
| 删除 MNote 本地成功,豆包远端失败 | 不回滚本地删除;标记 `remote_delete_failed` |
| 多用户尝试绑定同一远端 ID | 拒绝后写 audit,避免跨用户串会话 |
| OpenClaw 重启 | SQLite 绑定仍在;下一轮从 MNote 注入远端 ID |
| 豆包接口变更 | 本地会话不受影响;远端能力降级为不可用 |
## 8. 验收
### 8.1 单元 / 集成
- `control-plane`binding upsert / lookup / local delete / remote delete status transition。
- `mnote-web`ChatOnly run payload 能注入已有 `remoteConversationId`
- `mnote-web``provider.conversation.bound` 事件能写入 SQLite。
- `mnote-web`:删除 session 时先软删除本地,再 best-effort 调 provider delete。
### 8.2 真实浏览器 smoke
1. 使用测试账号登录 `http://localhost:3000`
2. 创建 ChatOnly / 豆包新会话,发送 marker A。
3. 复查:
- MNote SQLite 有一个 `mnote_session_id -> remote_conversation_id` 绑定。
- 豆包日志 `Captured conversation_id` 一次。
4. 在同一 MNote 会话发送 marker B。
5. 复查:
- 豆包日志第二次 `Conversation ID` 等于第一次捕获值。
- 豆包网页同一 conversation 中出现 A 与 B。
6. 删除 MNote 会话。
7. 复查:
- MNote 会话列表不再显示该 session。
- SQLite binding status 为 `remote_deleted``remote_delete_failed`
- 若远端删除成功,豆包网页侧该 conversation 从列表移除或打开后显示已删除。
2026-06-01 验证记录:
- `node scripts/task512-chatonly-doubao-sync-smoke.js` 通过:豆包远端 conversation 绑定、同会话回复、MNote session 删除、provider delete 和 SQLite binding `remote_deleted` 均通过。
- `node scripts/task513-chatonly-provider-sync-smoke.js deepseek` 通过:DeepSeek `remoteConversationId` 绑定、provider delete 和 SQLite binding `remote_deleted` 均通过。
- `node scripts/task513-chatonly-provider-sync-smoke.js gemini` 通过:Gemini conversation URL 绑定、provider delete 和 SQLite binding `remote_deleted` 均通过。
- `cargo test --manifest-path rust/Cargo.toml -p control-plane external_conversation -- --test-threads=1` 通过:binding user scope 与状态迁移。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib provider_conversation -- --test-threads=1` 通过:provider conversation 注入与 bound event 持久化。
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib chatonly_doubao_session_delete_calls_provider_and_marks_remote_deleted -- --test-threads=1` 通过:删除 session 后 provider delete 与 `remote_deleted` 状态。
## 9. 实施清单
- [x]`control-plane` 增加 `ai_external_conversation_bindings` schema、store trait 和 SQLite 实现。
- [x]`mnote-web` 增加 provider conversation helper,避免继续扩大 `hermes_client.rs`
- [x] 在 ACP run 创建时为 ChatOnly / 豆包注入已有 `remoteConversationId`
- [x] 在 ACP event 持久化时处理 `provider.conversation.bound`
- [x] 在 session delete 路径中加入远端删除 best-effort 状态机。
- [x] 在 OpenClaw Doubao provider 中加入结构化 conversation bound 事件。
- [x] 在 OpenClaw Doubao provider 中加入 `deleteConversation`,走 `/im/conversation/batch_del_user_conv`
- [x] 补真实浏览器 smoke`node scripts/task512-chatonly-doubao-sync-smoke.js` 覆盖远端 conversation id 绑定、豆包同会话 marker、session 删除、provider delete 和 SQLite binding `remote_deleted`
- [x] 补跨 provider 真实浏览器 smoke`node scripts/task513-chatonly-provider-sync-smoke.js deepseek``node scripts/task513-chatonly-provider-sync-smoke.js gemini`
- [x] 补 MNote Rust 单测:control-plane binding、mnote-web provider conversation 注入、bound event 持久化和 session delete provider 状态。
- [x] OpenClaw provider 源码不在本仓库,provider 单测缺口已转入 `bugs/07-ai/process/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md` 跟踪,不阻塞 MNote 侧设计归档。
@@ -6,7 +6,7 @@
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
> - `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
> - `/mnt/Data1T/mnote/design/05-editor-mainline/done/5-6-page-aggregate-alignment-checklist-v1.md`
> - `/mnt/Data1T/mnote/design/07-ai/done/7-15-page-ai-acp-agent-runtime-unified-layer-v1.md`
@@ -276,10 +276,10 @@ Rust SQLite control-plane 是默认控制面,负责:
### Phase A0target selection 与 UI
- [ ] Page AI composer 可见 target chip,显示 workspace、target title、resourceKind、scope、readonly / dirty 状态。
- [ ] 点击 target chip 打开 target picker,可在当前焦点、打开 tabs、树选中项之间选择。
- [ ] 默认 target 来自 last focused editor / resource tab,不来自第一个 tab、URL documentId 或树选中项。
- [ ] mindmap / office / raw file target 显示真实 resourceKind,不伪装成 markdown page。
- [x] Page AI composer 可见 target chip,显示 workspace、target title、resourceKind、scope、readonly / dirty 状态。
- [x] 点击 target chip 打开 target picker,可在当前焦点、打开 tabs、树选中项之间选择。
- [x] 默认 target 来自 last focused editor / resource tab,不来自第一个 tab、URL documentId 或树选中项。
- [x] mindmap / office / raw file target 显示真实 resourceKind,不伪装成 markdown page。
- [ ] 多 tab / 跨 workspace / dirty write 时发送前需要显式确认。
- [ ] 发送后消息记录冻结 target 摘要,切换 tab 不影响已启动 run。
@@ -291,6 +291,8 @@ Rust SQLite control-plane 是默认控制面,负责:
- [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。
- [ ] readonly 页面不会启动可写 run,或只启动只读问答 run。
2026-06-01 进展:已补第一版 `mnote.agent_target_package.v1` 运行时输入。前端 Page AI run body 会从当前 `editorTarget` / `WorkspacePath` 生成 `targetPackage`;后端会 sanitize 该包,并把 local-first `aiAccessScope.allowedFiles` / `allowedFilePaths``agentRunEnvelope.allowedFiles` 从该包派生。`task502-page-ai-agent-selector-context-smoke.js` 已覆盖 page / mindmap / OnlyOffice target picker 与 payload 冻结;`task520-page-ai-raw-resource-target-smoke.js` 已覆盖真实 raw local resource tab,并断言 `objectIdentity` 不退化为 `[object Object]`。当前仍未完成跨 workspace 多选确认和真实 agent 写入回收 smoke,因此本文继续保持 `process`
### Phase B:写入回收与前台同步
- [ ] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。
@@ -1,35 +0,0 @@
# 7-38 Page AI sidebar runtime owner split v1
> 创建时间:2026-05-25
> 状态:`process`
> 来源:`design/03-rust-web/done/3-22-sidebar-tree-runtime-second-stage-split-v1.md` Batch A。
> 2026-05-26 更新:`design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md` 已把 Page AI 主体迁入 `browser/sidebar-page-ai-runtime.js`;本文件继续作为后续 AI owner 收口 checklist。
## 1. 背景
`rust/crates/mnote-web/browser/sidebar-tree-runtime.js` 曾包含大量 Page AI panel 代码:会话、消息、profile、skills、gateway health、ACP runtime、permission dialog、plan/status event、session search/resume 等。2026-05-26 的 runtime 可维护性批次已将主体迁入 `browser/sidebar-page-ai-runtime.js`tree runtime 目前只保留 sidebar click/change/input 委托、少量 `pageUiState.pageAi*` 状态壳和触发器代理。
这些代码不是 tree/filetree runtime 的长期 owner。继续把 Page AI 面板放在 `03-rust-web` 的 sidebar runtime 拆分里,会让 tree shell、local folder、AI session 三条边界继续混在一起。
## 2. Owner 判断
- owner`07-ai`
- 运行位置:当前仍可挂在 sidebar UI,但 runtime 模块应独立于 tree/filetree runtime。
- 当前目标模块:`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
- 主壳职责:只加载 asset、提供当前 document/workspace/root/bootstrap。
## 3. 后续执行建议
- [x] 只读审计 Page AI panel 的状态入口:`pageUiState.pageAi*`、storage、session 列表、profile/skills、permission dialog。
- [x] 建立独立 Page AI sidebar owner runtime`sidebar-page-ai-runtime.js`
- [ ]`sidebar-tree-runtime.js` 继续迁出 Page AI click/change/input/keydown 委托,tree runtime 只保留 `data-mnote-action="open-page-ai"` 入口。
- [ ]`pageUiState.pageAi*` 从通用 sidebar state 中下沉到 Page AI owner runtime,并保留兼容 getter/setter 或明确 bootstrap contract。
- [ ]`sidebar-page-ai-runtime.js` 继续超过 2,500 行,再按 `session/profile-skill/permission/conversation` 拆成子模块。
- [ ] 浏览器验证使用页面 AI / ACP smoke,而不是 tree/filetree smoke 替代。
## 4. 非目标
- 不在 03-rust-web 的 sidebar runtime 二阶段里继续扩大 Page AI 实现。
- 不在 10-review runtime 可维护性收口里继续重构 ACP/Hermes session 语义;这里只记录 owner 边界。
- 不改变 Hermes / Reasonix ACP runtime 协议。
- 不把 local-first agent 文件编辑链路改回粗粒度 `mnote.page.save`
@@ -0,0 +1,273 @@
# 7-43 ONLYOFFICE Plugin Bridge Design v1
> 状态:process
>
> Owner07-ai / ONLYOFFICE live bridge / Page AI target runtime
>
> 创建时间:2026-06-01
>
> 当前口径:工具层 session/scope 安全阻断、真实 iframe 非 dry-run 写入落点验证、Page AI target picker 到真实 Office iframe session 的端到端绑定均已落地;本文继续留在 `process`,用于承接后续 recipe 扩展、Office 主文档加载噪音治理和 P2 plugin bridge 产品化收口,不再作为 P0 安全阻断项。
## 背景
当前本机 ONLYOFFICE DocumentServer 由 Docker 暴露在宿主机 `8082`
- `mnote-onlyoffice-documentserver`: `onlyoffice/documentserver:9.4.0`
- 端口映射:`0.0.0.0:8082 -> container:80`
- MNote 公开入口:`http://127.0.0.1:3000`
- MNote 通过 `/onlyoffice-server` 反代 DocumentServer 静态资源和 editor iframe
实测当前社区镜像不暴露 `docEditor.createConnector()`,因此不能依赖 ONLYOFFICE Automation API。MNote 的 live Office agent 能力应建立在社区版可用的 Plugin API 上:插件 iframe 使用 `window.Asc.plugin.executeMethod()``window.Asc.plugin.callCommand()` 调用 Office JavaScript API。
## 目标
建立一个完整、可扩展、可验证的 MNote ONLYOFFICE bridge 插件,让 Hermes、Reasonix 和页面 AI 能通过白名单 recipe 操作当前打开的 ONLYOFFICE 文档。
目标能力:
- 支持当前浏览器内打开的 Word、Excel、PPT live editor session。
- 通过 MNote bridge API 派发命令,插件执行后回传 JSON-safe result。
- 不暴露任意 JavaScript 执行能力。
- 写操作继续遵守 MNote tool 权限、`dryRun``idempotencyKey` 模型。
- 所有新增 recipe 必须同时支持 browser direct smoke 和 tool API smoke。
非目标:
- 不绕过 ONLYOFFICE Developer / Automation API 授权。
- 不后台批量编辑任意 Office 文件;离线批处理仍优先使用 `officecli`
- 不把 ONLYOFFICE iframe DOM 当作编辑接口。
## 端口与 URL 边界
有两条 base URL,必须严格区分:
1. 浏览器可达的 MNote origin
- 示例:`http://127.0.0.1:3000`
- 用途:插件 iframe 调用 MNote bridge API。
- 来源:`location.origin`
- 禁止替换成 `host.docker.internal`,因为浏览器侧可能无法解析。
2. DocumentServer 容器可达的 MNote origin
- 示例:`http://host.docker.internal:3000`
- 用途:`document.url``callbackUrl`、local file proxy。
- 来源:`ONLYOFFICE_DOCUMENT_URL_BASE`
- 依赖 compose 中 `extra_hosts: ["host.docker.internal:host-gateway"]`
DocumentServer 静态资源访问:
- Browser -> MNote -> DocumentServer
- URL 形态:`/onlyoffice-server/web-apps/apps/api/documents/api.js`
- 插件 SDK`/onlyoffice-server/sdkjs-plugins/v1/plugins.js`
## 加载流程
1. 用户打开 `/onlyoffice?...`
2. MNote 页面加载 `/onlyoffice-server/web-apps/apps/api/documents/api.js`
3. MNote 构造 `DocsAPI.DocEditor` config。
4. `document.url``callbackUrl` 使用 `ONLYOFFICE_DOCUMENT_URL_BASE`,供 DocumentServer 容器访问。
5. `editorConfig.plugins.pluginsData` 指向:
`/api/onlyoffice/bridge/plugin/config?sessionId=...&apiBase=http://127.0.0.1:3000`
6. ONLYOFFICE 加载 MNote bridge 插件 iframe。
7. 插件 iframe 调用 `POST /api/onlyoffice/bridge/session` 注册 session。
8. 插件通过 `GET /api/onlyoffice/bridge/commands/next` 长轮询取命令。
9. 插件执行白名单 recipe。
10. 插件调用 `POST /api/onlyoffice/bridge/results` 回传结果。
## Bridge API
当前 API
- `GET /api/onlyoffice/bridge/plugin/config`
- `GET /api/onlyoffice/bridge/plugin/index`
- `GET /api/onlyoffice/bridge/plugin/index/config.json`
- `GET /api/onlyoffice/bridge/plugin/index/{state}`
- `POST /api/onlyoffice/bridge/session`
- `GET /api/onlyoffice/bridge/session/current`
- `POST /api/onlyoffice/bridge/session/close`
- `GET /api/onlyoffice/bridge/sessions`
- `GET /api/onlyoffice/bridge/capabilities`
- `POST /api/onlyoffice/bridge/commands`
- `GET /api/onlyoffice/bridge/commands/next`
- `POST /api/onlyoffice/bridge/results`
- `GET /api/onlyoffice/bridge/results`
## Session 模型
每个打开的 ONLYOFFICE 页面生成一个 `sessionId`
```text
mnote-oo-{docKey}-{tabRandomSuffix}
```
服务端保存:
- `sessionId`
- `editorType`: `word` / `cell` / `slide`
- `documentId`
- `assetId`
- `fileType`
- `docKey`
- `pageOrigin`
- `lastSeenMillis`
- `pendingCommands`
- `pendingResults`
多 session 规则:
- 插件每次 `/commands/next` 都刷新 `lastSeenMillis`
- `session.current` 返回最近活跃 session。
- 严肃写操作应显式传 `onlyofficeSessionId`,避免多标签页误写。
- 不同 editorType 的 recipe 必须在插件或 wrapper 层做类型保护。
2026-06-01 复核:`sessionId` 已加入浏览器 tab 级随机后缀,tool 侧读写均要求显式 `onlyofficeSessionId` / `bridgeSessionId`,并按 `aiAccessScope.allowedResourceIds` 校验 session resource。当前 HTTP / mock plugin 层已有 `task515``task516``task517` 证据;真实 ONLYOFFICE iframe / DocumentServer 层新增 `task518` 证据,覆盖插件 autostart 注册、同一文档双 tab 不共用 session、A/B 资源 scope mismatch 403、授权 dry-run 200,以及非 dry-run 写入 B 后导出验证 A 不含 marker、B 含 marker。随后 `task523` 补齐 Page AI target picker 到 Office session 的端到端 UI 绑定:真实文档页打开 Office resource tab 后,Page AI run payload 会把 iframe live `onlyofficeSessionId` 冻结进 `editorTarget``targetPackage``targetPackage.targets[0]`,且不再把 Office target 当作 Markdown buffer 查询 `/api/documents/buffer-state`
2026-06-01 续补:已新增 `GET /api/onlyoffice/bridge/session/current``POST /api/onlyoffice/bridge/session/close`session state 已写入 `docKey` / `pageOrigin``/onlyoffice` 页面会把当前文档 `docKey` 与页面 origin 透传给 bridge plugin config。已通过 `cargo test --manifest-path rust/Cargo.toml -p mnote-web onlyoffice_bridge -- --test-threads=1``node scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js``MNOTE_UI_BASE_URL=http://127.0.0.1:3302 node scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js`。本文继续保留在 `process`,只跟踪 Office 主文档加载噪音治理和第三批 recipe 逐项实测。
## Recipe 分层
### 已实现 recipe
Word / 通用:
- `selection.get`
- `document.insert_text`
- `document.replace_selection`
- `document.insert_html`
- `document.export`
- `document.search_replace`
- `document.insert_table`
- `document.get_comments`
- `document.add_comment`
Excel
- `sheet.get_sheets`
- `sheet.add_sheet`
- `sheet.rename_sheet`
- `sheet.get_range`
- `sheet.get_range_values`
- `sheet.get_values`
- `sheet.set_value`
- `sheet.set_formula`
- `sheet.batch_set_values`
- `sheet.set_range_values`
- `sheet.format_range`
- `sheet.set_dimensions`
- `sheet.sort_range`
- `sheet.add_chart`
PPT
- `presentation.get_slides`
- `presentation.get_slide_texts`
- `presentation.get_shapes`
- `presentation.add_text_slide`
- `presentation.replace_text`
- `presentation.set_shape_text`
- `presentation.delete_slide`
- `presentation.add_table`
- `presentation.clear_slide`
- `presentation.add_shape`
### 第二批 recipe 说明
Word
- `document.search_replace``Api.GetDocument().SearchAndReplace(...)`,适合精确文本替换。纯搜索暂不单独暴露,全文读取先用 `document.export`
- `document.insert_table``Api.CreateTable(cols, rows)``doc.Push(table)`,可用二维 `data` 填充单元格;默认插入到文档末尾。
- `document.get_comments``doc.GetAllComments()`,返回评论 id、作者、正文和引用文本。
- `document.add_comment` 支持给当前选区或 `document_start` 添加评论;选区模式依赖 `doc.GetRangeBySelect()`,没有选区时应显式用 `target=document_start`
Excel
- `sheet.get_sheets``Api.GetSheets()` / `worksheet.GetName()`,用于让 agent 明确当前 workbook sheet 结构。
- `sheet.add_sheet``Api.AddSheet(name)`,用于创建新 sheet。
- `sheet.rename_sheet``worksheet.SetName(name)`,用于重命名指定 `sheetIndex` 的 sheet。
- `sheet.get_range_values` / `sheet.set_range_values` 接受 A1 地址(例如 `B2:C3`),适合按用户可见坐标读写二维区域。
- `sheet.set_range_values` 只写入 A1 range 与 `values` 的交集;调用方需要保证二维数据尺寸符合预期。
- `sheet.format_range``ApiRange` 格式 API,只暴露小范围字体粗斜体、下划线、填充色、字体色、字号、字体名、对齐、数字格式。
- `sheet.set_dimensions``worksheet.SetColumnWidth` / `worksheet.SetRowHeight`,行列索引沿插件内部零基坐标。
- `sheet.sort_range``range.SetSort(...)`,只暴露 A1 range、range 内 1 基 `keyColumn`、升降序和是否有表头。
- `sheet.add_chart``worksheet.AddChart(...)`,只暴露 A1 数据区、图表类型、基础尺寸和插入位置。
PPT
- `presentation.get_slide_texts` 读取 slide 文本框纯文本。
- `presentation.replace_text` 替换文本框纯文本;命中的文本框会按纯文本段落重建,不承诺保留复杂 run 格式。
- `presentation.get_shapes` 返回每页 shape 的 `slideIndex` / `shapeIndex` / `shapeId` / text 预览,用于精确定位文本框。
- `presentation.set_shape_text``slideIndex + shapeIndex` 重写指定 shape 文本;只面向文本 shape,复杂样式不承诺保留。
- `presentation.delete_slide` 删除指定 `slideIndex` 的幻灯片;调用前应先读 slide 列表确认目标页。
- `presentation.add_table``Api.CreateTable(...)` + `slide.AddObject(table)`,支持二维 `data` 和基础位置/尺寸。
- `presentation.clear_slide``slide.RemoveAllObjects()`,用于清空指定页对象;这是高影响写操作,调用前必须确认目标页。
- `presentation.add_shape``Api.CreateShape(...)` + `slide.AddObject(shape)`,只暴露基础形状、文字、填充色、位置和尺寸。
### 第三批 recipe
- 已实装:Excel 排序、Excel 图表、PPT 表格。
- 已实装:PPT 清空页对象、PPT 添加基础形状。
- 待验证后再扩:Word 图片、修订、content controls、表格增删行列和样式。
- 待验证后再扩:Excel 筛选、工作表删除/移动。
- 待验证后再扩:PPT 图片、重排 slide、主题/布局、shape 样式与位置。
- PDF/forms 字段读取与填写。
第三批必须逐项实测,不允许凭 API 名称直接暴露给 agent。
## 权限与安全
插件只负责执行当前 editor session 允许的动作;MNote 负责业务权限:
- 当前用户是否能打开文档。
- agent 是否拥有 `office.read` / `office.write`
- 写操作是否显式 `dryRun`
- 写操作是否带 `idempotencyKey`
安全规则:
- 不暴露 raw JavaScript。
- action 必须是白名单。
- payload 必须 schema 校验。
- 批量单元格读写默认限制为 5000 cells。
- result 必须 JSON-safe。
- command timeout 默认 25 秒。
## 验证基线
每个 recipe 必须三层验证:
1. Rust unit test
- manifest 暴露
- route dispatch
- dryRun / 权限 / queue result
2. Browser direct smoke
- 直接调用 `window.__MNOTE_ONLYOFFICE_BRIDGE__.run(...)`
- 不经过 Hermes/Reasonix
- 必须保存截图并检查 `pageErrors=[]`
3. Tool API smoke
- 通过 `/api/hermes/tools/mnote/call`
- 验证 agent 实际可调用
完成标准:
- `cargo test -p mnote-web onlyoffice_ -- --test-threads=1`
- `cargo test -p mnote-web hermes_tools_manifest_returns_first_batch_tools -- --test-threads=1`
- `cargo test -p mnote-web skill_registry_exposes_onlyoffice_live_skill_to_agents -- --test-threads=1`
- `node scripts/task517-onlyoffice-bridge-plugin-direct-smoke.js` 覆盖 bridge plugin index 在 mock `Asc.plugin` 环境中的 session 注册、`docKey/pageOrigin` 元数据、command 消费、result 回传和 token 拒绝。
- `node scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js` 覆盖真实 ONLYOFFICE iframe / DocumentServer 下插件 autostart、同文档双 tab session salt、bridge command 回收、resource scope 拒写、授权 dry-run,以及授权 B session 非 dry-run 写入后 A/B 导出内容不串台。
- `node scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js` 覆盖真实文档页内 Office resource tab -> Page AI target picker -> run payload 的 live session 绑定,并断言 Office target 不触发 Markdown buffer-state 404。
- 关键 browser direct smoke 通过
- `git diff --check` 通过
- `codegraph sync .` 已运行
2026-06-01 进展:已新增并通过 `task517-onlyoffice-bridge-plugin-direct-smoke.js`,证明 MNote bridge plugin index 与 bridge HTTP loop 可在真实 Chromium 中直接运行。随后新增并通过 `task518-onlyoffice-real-iframe-session-scope-smoke.js`,在真实 ONLYOFFICE iframe / DocumentServer 下打开同一 docx 两个 tab 和另一个 docx,验证三者 sessionId 均不同、同文档双 tab 共享 docKey 但使用不同随机 salt、`selection.get` 可各自回收、scope=A 显式调用 B session 返回 403、scope=B 授权 dry-run 返回 200。`task518` 已继续扩展为非 dry-run 写入 B 并导出 A/B 内容验证不串台,截图保存在 `tmp/task518-onlyoffice-real-iframe-session-scope-smoke/screenshots/office-a-after-write.png``office-b-after-write.png``task523-page-ai-onlyoffice-real-target-session-smoke.js` 已补齐 Page AI target picker 到 `onlyofficeSessionId` 的真实 UI 绑定,截图保存在 `tmp/task523-page-ai-onlyoffice-real-target-session-smoke/screenshots/`
`task518` 已把 Office 主文档加载噪音分类为三类:bridge plugin translation 404、ONLYOFFICE 内置插件噪音、主文档加载失败。smoke 会断言 bridge session 注册和 command loop 正常,并把 `errorCode=-18` 或 editor 未 ready 归为主文档失败,避免把打不开文档误判为插件噪音。
补充验证矩阵:
- `task515-onlyoffice-live-scope-http-smoke.js`:覆盖缺 explicit session、scope mismatch、缺 scope 和授权 dry-run。
- `task516-onlyoffice-bridge-multisession-browser-smoke.js`:覆盖 A/B bridge session token、`docKey/pageOrigin` 元数据、command queue、result 回收互不串台,以及 `session/current` / `session/close`
- `task517-onlyoffice-bridge-plugin-direct-smoke.js`:覆盖 mock `Asc.plugin` 下 plugin index 注册、`docKey/pageOrigin` 透传和 command loop。
- `task518-onlyoffice-real-iframe-session-scope-smoke.js`:覆盖真实 ONLYOFFICE iframe / DocumentServer 下插件 autostart、同文档双 tab session 隔离、A/B session command 回收、`resource:onlyoffice` scope mismatch 403、授权 scope dry-run、授权非 dry-run 写入 B 后导出验证 A 不含 marker / B 含 marker。
- `task523-page-ai-onlyoffice-real-target-session-smoke.js`:覆盖真实文档页打开 Office resource tab、等待 iframe bridge ready、通过 Page AI target picker 选择 Office target,并验证 live `onlyofficeSessionId` 写入 run payload。
@@ -2,7 +2,7 @@
## 状态
- 状态:process
- 状态:reference
- OwnerAI runtime / browser verification
- 背景:Reasonix 已能作为独立辅助 agent 执行只读审计,但浏览器验收若只输出自然语言结论,主控仍需重测,无法稳定节省 token。
@@ -144,3 +144,7 @@ Codex/Hermes 只有在以下条件满足时,才能把 Reasonix 浏览器结果
- [ ] 使用 `bugs/0524.md` 中至少 1 个 bug 进行 Reasonix 浏览器试跑。
- [ ] 试跑后记录 accepted findings / false leads / retest required。
- [ ] 根据试跑结果更新本合同或 Reasonix skill。
## 2026-06-01 降级说明
本合同对应的全局 `reasonix-browser-tester` skill 已存在,并已包含 `result.json`、截图、console/network、isolated context、`modified_files` 等 artifact 要求。本文不再作为 MNote 产品 runtime 的 active process 项,降级为协作参考;后续若要继续试跑 `bugs/0524.md`,应在 Reasonix skill 维护流程或独立复盘文档中跟踪。
@@ -2,7 +2,7 @@
## 状态
- 状态:process
- 状态:reference
- OwnerAI runtime / multi-agent collaboration
- 背景:Hindsight 解决了 Reasonix 过程可追溯问题,但不能自动让 Reasonix 下一次更好。需要一个主控驱动的最小自进化机制,由 Codex/Hermes 根据真实 run 修改 Reasonix skill、任务模板和 handoff 合同。
@@ -144,3 +144,7 @@ Reasonix 协同复盘:
- [ ] 用 `bugs/0524.md` 至少跑一轮真实 Reasonix 协同测试。
- [ ] 根据真实结果更新 skill 或明确“不需要修改”。
- [ ] 在 Hindsight / MemPalace 中保留协作复盘摘要。
## 2026-06-01 降级说明
全局 `reasonix-skill-maintainer` skill 已存在,并已要求读取 `result.json``process-handoff`、Hindsight recall,评估 `accepted_findings``false_leads``missing_evidence``retest_required``token_saving_estimate`。本文不再作为 MNote 产品 runtime 的 active process 项,降级为协作参考;后续真实 run 复盘直接走全局 skill。
@@ -2,7 +2,7 @@
> 更新时间:2026-05-11
>
> 当前状态:`reference`。本文只保留思源参考边界与可借鉴能力,不覆盖 `01-05` 当前优先级,也不作为当前执行 checklist。
> 当前状态:`reference`。本文只保留思源参考边界与可借鉴能力,不覆盖 `CURRENT_ARCHITECTURE.md` / `1-8` 当前口径,也不作为当前执行 checklist。
>
> 上位依据:
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
@@ -15,6 +15,8 @@
> - `/mnt/Data1T/mnote/design/old/07-ai/process/7-14-local-first-ai-markdown-editing-convergence-v1.md`
> - `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
>
> 2026-06-01 口径补充:本文是 2026-05-18 的历史 review / checklist 快照。文中把 `mnote.doc.markdown_edit` 描述为简单正文编辑主路径的结论已被后续 local-first agent 文件编辑控制面覆盖;当前 local-first 普通 Markdown 编辑主路径以 `AGENTS.md`、`ARCHITECTURE.md` 与 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准。`mnote.doc.markdown_edit` 只保留为 remote/cloud/compat fallback 或结构校验辅助。
---
@@ -14,7 +14,9 @@
## 0. 归档说明
本文件是 2026-05-16 的页面 AI fast block edit 历史审查快照。其记录的 `local_rule` / `doc_apply_block_ops` 快路径被后续 `mnote.doc.markdown_edit` 主路径替代,不再作为当前 runtime 口径。
本文件是 2026-05-16 的页面 AI fast block edit 历史审查快照。其记录的 `local_rule` / `doc_apply_block_ops` 快路径被后续 `mnote.doc.markdown_edit` 阶段替代,但该阶段也已被 local-first agent 文件编辑控制面覆盖;本文不再作为当前 runtime 口径。
2026-06-01 当前有效口径:local-first 普通 Markdown 编辑默认走授权文件引用 + allowed roots/files + agent 原生 patch/diff + watcher/BufferStore/Page Aggregate 同步;`mnote.doc.markdown_edit` 只保留为 remote/cloud/compat fallback 或结构校验辅助。
当前有效口径见:
@@ -23,7 +25,7 @@
- [7-18 AI markdown_edit 阶段状态合同漂移](../../../bugs/07-ai/done/7-18-ai-markdown-edit-phase-state-contract-drift-v1.md)
- [7-20 page_ai_workflow 绕过 Hermes tool executor / audit / toggle](../../../bugs/07-ai/done/7-20-page-ai-workflow-bypasses-hermes-tool-executor-v1.md)
归档后的结论:`page-ai/block-edit-workflow` 仍是简单编辑 fast-path 入口,但当实现应通过模型生成 markdown search/replace / full_content 后调用 `mnote.doc.markdown_edit`,并复用统一 mnote tool executor`mnote.doc.apply_block_ops` / `mnote.block.*` 只作为结构性块操作辅助。
归档时的历史结论:`page-ai/block-edit-workflow` 仍是简单编辑 fast-path 入口,但当实现应通过模型生成 markdown search/replace / full_content 后调用 `mnote.doc.markdown_edit`,并复用统一 mnote tool executor`mnote.doc.apply_block_ops` / `mnote.block.*` 只作为结构性块操作辅助。该结论不指导当前 local-first 新实现。
## 1. 本轮结论
@@ -5,6 +5,8 @@
> 执行状态:`done`
>
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
>
> 2026-06-01 口径补充:本文是 2026-05-17 的历史 review 快照。文中“`mnote.doc.markdown_edit` 是简单正文编辑主路径”的结论已被后续 local-first agent 文件编辑控制面覆盖;当前 local-first 普通 Markdown 编辑主路径以 `AGENTS.md`、`ARCHITECTURE.md` 与 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准,即授权文件引用 + allowed roots/files + agent 原生 patch/diff + watcher/BufferStore/Page Aggregate 同步。`mnote.doc.markdown_edit` 只保留为 remote/cloud/compat fallback 或结构校验辅助。
## 1. 本轮结论
@@ -8,6 +8,8 @@
## 1. 结论
> 2026-05-31 口径补充:本文是 2026-05-17 的阶段性 review 快照。文中关于 `mnote.doc.markdown_edit` “已是简单正文编辑主路径”的表述已被后续 local-first agent 文件编辑控制面覆盖;当前 local-first 普通 Markdown 编辑主路径以 `AGENTS.md`、`CURRENT_ARCHITECTURE.md`、`ARCHITECTURE.md` 与 `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` 为准,即“授权文件引用 + allowed roots + agent 原生 patch/diff + watcher/BufferStore/Page Aggregate 同步”。`mnote.doc.markdown_edit` 只作为 cloud / remote agent / compat fallback 或复杂结构辅助边界。
当前 mnote 的主线架构已经成形。本轮 review 识别出的 P0 / 内核优先缺陷已完成代码修复、文档迁移和定向验证。
已修复的四个关键冲突:
@@ -0,0 +1,99 @@
# 18 当前 design 口径与 bug 寻找 Review v1
> 创建时间:2026-05-31
>
> 状态:`done`
>
> 范围:当前仓库、历史 design 口径、active `process/`、现有 `bugs/process`、近期未提交 OnlyOffice / Page AI / control-plane 改动。
## 1. 结论
当前上位口径仍一致:
- `tree-first graph kernel` 是长期对象真相层。
- local-first workspace 是默认产品形态,本地 `.md` 是页面正文真相。
- Rust `mnote-web` 是 3000 主执行面。
- `leptos-tiptap` 是默认 Markdown 前端编辑器。
- Page Aggregate 是 Rust-first 投影收口方向,但仍需要 compat 瘦身。
- Page AI 的 local-first 主路径是“授权文件引用 + allowed roots + Hermes/Reasonix 原生 patch/diff + watcher/BufferStore 同步”,不是继续扩 `mnote.doc.markdown_edit``/api/page-ai/block-edit-workflow`
本轮发现的主要问题不是架构方向动摇,而是 `design/process` 状态与真实完成态、索引入口和 bug 记录之间出现漂移。
## 2. 已更新口径
- `design/README.md` 的当前入口已从不存在的 `design/01-05-current-priority-overview.md` 改为 `CURRENT_ARCHITECTURE.md``1-8`
- `1-8` 已补充 2026-05-31 口径复核:历史 `01-05` 已迁入 `old/`;已归档的 `3-3` / `3-18` 不再作为 active process 入口。
- 本 review 作为本轮设计治理和 bug 寻找事实源,后续 worker 不应凭旧路径恢复过时优先级。
## 3. design 漂移
已确认的漂移:
- `design/README.md``1-8` 曾引用已不存在的 `design/01-05-current-priority-overview.md`
- `design/03-rust-web/process/3-24-global-content-type-page-width-settings-v1.md` checklist 全部完成并有实施记录;2026-05-31 已迁入 `design/03-rust-web/done/3-24-global-content-type-page-width-settings-v1.md`
- `design/05-editor-mainline/done/5-31-page-settings-sqlite-preference-convergence-v1.md` 已迁入 `done/`;剩余风险另按后续 bug / follow-up 跟踪。
- `design/05-editor-mainline/done/5-32-filetree-lazy-loading-sidex-alignment-v1.md` 已迁入 `done/`tree live cache 后续入口改看 `3-23` 与相关 bug。
- `design/05-editor-mainline/process/5-33-navigation-page-route-guard-checklist-v1.md` 顶部落地状态和正文 checklist 状态不一致,需要拆成已完成核心与剩余 follow-up。
- `design/07-ai/done/7-41-page-ai-hermes-reasonix-user-profile-isolation-v2.md` 文件头状态为 `done`2026-06-01 已迁入 `done/`
- `design/10-review/process/17-sidex-mnote-workbench-gap-execution-checklist-v1.md` 文件头状态为 `done`2026-05-31 已迁入 `design/10-review/done/17-sidex-mnote-workbench-gap-execution-checklist-v1.md`
- `design/10-review/done/11-current-full-architecture-review-v1.md` 中“`mnote.doc.markdown_edit` 已是简单正文编辑主路径”的历史判断已被 `7-18` 和当前 AGENTS/ARCHITECTURE 覆盖;只能作为当时 review 快照,不应指导 local-first 新实现。
## 4. 新增 bug 条目
本轮已写入:
- `bugs/10-review/done/10-18-design-process-status-and-stale-entry-v1.md`
- `bugs/07-ai/process/7-45-onlyoffice-live-bridge-session-isolation-v1.md`
- `bugs/07-ai/process/7-46-onlyoffice-live-tool-resource-scope-bypass-v1.md`
- `bugs/05-editor-mainline/process/5-40-onlyoffice-bridge-plugin-noise-regression-v1.md`
- `bugs/07-ai/process/7-47-local-first-ai-markdown-edit-mouth-drift-v1.md`
- `bugs/10-review/done/10-19-agents-md-broken-design-references-v1.md`
- `bugs/03-rust-web/done/3-26-sidebar-dev-hot-reload-setinterval-v1.md`
- `bugs/10-review/done/10-20-testing-reference-missing-new-smokes-v1.md`
其中 `10-18``10-19``10-20` 已完成治理收口并迁入 `done/`OnlyOffice / Page AI / Sidebar 等运行时缺陷仍按各自 bug 条目继续跟踪验收。
## 5. 下一步建议
P0
- [x] 修复 OnlyOffice live bridge session identity 与 resource scope 两个风险,优先补 Rust 单测,随后做 clean browser 多 tab smoke。
- [x] 继续补 OnlyOffice 真实浏览器集成证据:真实 Office iframe 双 tab 不串台,以及 Page AI target=A 时不能读写 Office resource=B。
- [x] `AGENTS.md` 断裂引用、Sidebar dev hot reload guard、`5-31` / `5-32` / `7-41` 归档已经收口。
- [~] `5-33` breadcrumb / fetch guard follow-up 仍保留在 `process/`
- [x] 更新 `10-review/done/11` 或追加覆盖说明,防止 worker 继续把 `mnote.doc.markdown_edit` 当 local-first 主写入口。
P1
- [~] 继续推进 `7-18` Agent Target Resolvertarget chip/picker、dirty guard、allowed roots/files 与写后 diff 回收。
- [x] 推进 `WorkspacePath/ObjectIdentity + BufferStore + Page Aggregate compat 瘦身`,让 FileTree、Open Editors、resource tab 与 AI target resolver 消费同一身份与 buffer 状态。
- [x] 保留 `3-23``7-38` 作为 runtime owner follow-up,不重新打开 raw string 大拆分议题。
P2
- [x] `7-42` mindmap 第二批:结构化 `apply_ops`、resource tab contextRefs、revision conflict。
- [~] `3-25` MinerU OCR:后端真实 MinerU runtime 已补;任务状态事件、全局任务栏和 UI 入口仍待做。
- [x] `7-43` OnlyOffice Plugin BridgeP0/P1 安全主路径与 P2 recipe 矩阵已收口,后续新增 recipe 按矩阵逐项验证。
- [x] `7-44` ChatOnly Doubao bindingMNote 侧 binding 与 Doubao provider 单测已完成。
- [ ] `7-49` ChatOnly OpenClaw providerDeepSeek / Gemini provider 级 delete helper 与单测仍在外部仓库阻塞,不能在 MNote 内归档。
本轮已将 `task503``task504``task512``task513``task514``task515``task516` 补入 `scripts/TESTING_REFERENCE.md`;对应 smoke 已实跑,运行结果由 `bugs/10-review/done/10-20-*` 记录。
## 6. 验证记录
原始 review 是设计/bug 审查和文档更新,未修业务代码,未跑浏览器 smoke。2026-06-01 后续收口已补跑 `task514``task517`,并登记 `task517` 到 smoke referenceOnlyOffice 真实 iframe / DocumentServer 验收仍未完成。
已执行只读检查:
- `git status --short`
- `find design -maxdepth 3 -type f`
- `find bugs -maxdepth 3 -type f`
- `codegraph status .`
- 多组 `rg` / `sed` / `nl` 证据核对
已使用:
- 2 个只读 subagent:架构口径审查、bug 候选审查。
- 2 个 Reasonix 只读 workerdesign governance audit、bug hunt candidate audit。
注意:当前工作区有大量未提交改动,且 CodeGraph 显示新增文件 pending。本轮只对本轮新增/修改的文档负责,不回滚、不移动用户已有改动。
+6 -5
View File
@@ -23,12 +23,13 @@
## 当前优先级入口
- `01-05` 当前有效主线与优先级,请先看:
`/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
- Runtime 模块级拆分完成态,请看:
`/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`
- 当前有效架构口径,请先看:
`/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
- MVP 后阶段剩余 `process/` 的执行顺序,请看:
`/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
- Runtime 模块级拆分完成态,请看:
`/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`
- 历史 `01-05-current-priority-overview` 已迁入 `design/old/`,只能作为归档快照,不再作为当前入口。
## 主线顺序
@@ -82,7 +83,7 @@
- `90-reference/`
- 跨域生态参考资料,不参与 `[done]/[process]/[draft]/[reference]/[recycle]` 状态判断
- 引用时只能作为生态资料或背景材料,不能覆盖 `ARCHITECTURE.md``AGENTS.md``01-05` 当前优先级或对应主线 `process/` / `done/` 设计稿
- 引用时只能作为生态资料或背景材料,不能覆盖 `CURRENT_ARCHITECTURE.md``ARCHITECTURE.md``AGENTS.md``1-8` 当前执行顺序或对应主线 `process/` / `done/` 设计稿
- `old/`
- 已废弃或被替代的历史稿件,标题统一标记 `[recycle]`
- 每个大类继续按 `process/``done/` 分层
@@ -0,0 +1,506 @@
# Local Folder MinerU OCR Sidecar Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 mnote local-folder 图片和图片型 PDF 增加手动 MinerU OCR,并把 OCR Markdown 保存到 owner 页面同目录的 `{pageStem}.ocr/` 文件夹。
**Architecture:** 后端负责 MinerU API、权限校验、OCR active job store、OCR sidecar 写入和 `.mnote/ocr-index.json` 状态缓存;前端触发本地 OCR API,并通过资源局部状态和全局 OCR 任务栏展示阶段型进度。OCR 文本默认作为资源 sidecar 被搜索、resource tab 和后续 AI 消费,只有用户显式操作时才插入正文。
**Tech Stack:** Rust `mnote-web` routes/services、local-folder metadata、MinerU HTTP API、browser runtime JS、Node smoke、Rust cargo tests。
---
## File Structure
- Create: `rust/crates/mnote-web/src/routes/local_ocr.rs`
- OCR API request/response types、路径规划、sidecar frontmatter、active job store、`.mnote/ocr-index.json` 读写、MinerU client facade。
- Modify: `rust/crates/mnote-web/src/routes/mod.rs`
- 注册 `/api/local-folder/ocr/jobs``/jobs/:id``/status``/read``/insert`
- Modify: `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- 暴露必要的 local-folder path helper,识别 `.ocr/*.ocr.md` 为 OCR resource 而不是普通页面。
- Modify: `rust/crates/mnote-web/src/routes/local_search_index.rs`
- 读取 OCR index/sidecar`includeOcr=true` 时搜索 OCR 文本并返回 owner page。
- Modify: `rust/crates/mnote-web/src/routes/search.rs`
- local-folder 分支把 `filters.include_ocr` 传给 local search index。
- Modify: `rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js`
- 附件菜单展示 OCR 识别、查看 OCR、插入 OCR 链接。
- Modify: `rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js`
- File Tree 图片/PDF 资源右键菜单接入 OCR 操作。
- Modify: `rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
- image/PDF resource tab 展示 OCR 状态和 OCR Markdown。
- Create: `rust/crates/mnote-web/browser/local-ocr-task-runtime.js`
- 全局 OCR 任务栏 / 任务抽屉,消费 OCR jobs API 和 `local_ocr.job.updated` 事件。
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- 挂载全局 OCR task runtime,并断言 runtime 依赖显式注入。
- Test: `rust/crates/mnote-web/src/routes/local_ocr.rs` inline tests
- Test: `rust/crates/mnote-web/src/routes/local_search_index.rs` inline tests
- Create: `scripts/task506-local-folder-mineru-ocr-smoke.js`
- 浏览器 smoke,使用 mock MinerU 或 test-only route fixture。
## Task 1: OCR Sidecar Path And Metadata Contract
**Files:**
- Create: `rust/crates/mnote-web/src/routes/local_ocr.rs`
- [ ] **Step 1: Add failing unit tests for sidecar path planning**
Add tests that assert:
- owner `docs/Page.md` creates OCR dir `docs/Page.ocr/`
- source `docs/Page.assets/photo.png` creates `docs/Page.ocr/photo.png.ocr.md`
- duplicate source leaf names get a short hash suffix
- OCR frontmatter contains `owner_document`, `source_path`, `source_root_relative_path`, `provider`, `model_version`, `source_size`, `source_mtime_ms`
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_sidecar -- --test-threads=1
```
Expected: FAIL because `local_ocr` does not exist yet.
- [ ] **Step 2: Implement path planning and frontmatter builder**
Implement focused helpers in `local_ocr.rs`:
- `plan_ocr_sidecar_path(root, owner_document_path, source_root_relative_path, source_metadata)`
- `build_ocr_markdown(frontmatter, mineru_markdown)`
- `parse_ocr_frontmatter(markdown)`
Keep these helpers pure and unit-testable.
- [ ] **Step 3: Verify tests pass**
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_sidecar -- --test-threads=1
```
Expected: PASS.
## Task 2: OCR Index Cache
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/local_ocr.rs`
- [ ] **Step 1: Add failing tests for `.mnote/ocr-index.json`**
Cover:
- write/read one entry
- `stale=true` when source size or mtime changed
- missing source returns `stale=true` with no panic
- failed entry stores only a short sanitized error
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_index -- --test-threads=1
```
Expected: FAIL.
- [ ] **Step 2: Implement index read/write and stale detection**
Use atomic JSON write, following existing `write_json_atomic` style in `local_folder_source.rs`.
Index path:
```text
<root>/.mnote/ocr-index.json
```
Entry key:
```text
sourceRootRelativePath
```
- [ ] **Step 3: Verify tests pass**
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_index -- --test-threads=1
```
Expected: PASS.
## Task 3: MinerU Client And OCR Job API
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/local_ocr.rs`
- Modify: `rust/crates/mnote-web/src/routes/mod.rs`
- [ ] **Step 1: Add route tests with a mock MinerU client**
Cover:
- missing token returns `mineru_token_missing`
- root escape source path is rejected
- successful mock OCR writes `{pageStem}.ocr/*.ocr.md`
- successful mock OCR exposes queued/running/done states through jobs list
- each mock OCR state change emits `local_ocr.job.updated`
- failed mock OCR writes failed index entry without token or upload URL
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_jobs -- --test-threads=1
```
Expected: FAIL.
- [ ] **Step 2: Implement API routes**
Routes:
- `POST /api/local-folder/ocr/jobs`
- `GET /api/local-folder/ocr/jobs`
- `GET /api/local-folder/ocr/jobs/{jobId}`
- `GET /api/local-folder/ocr/status`
- `GET /api/local-folder/ocr/read`
Token lookup order:
1. `MNOTE_MINERU_API_TOKEN`
2. `MINERU_API_TOKEN`
3. `~/.hermes/.env` `MINERU_API_TOKEN` for local development
- [ ] **Step 3: Implement MinerU local file flow**
Use MinerU API:
- `POST /api/v4/file-urls/batch`
- `PUT` local bytes to pre-signed URL
- `GET /api/v4/extract-results/batch/{batch_id}`
- download zip
- extract first Markdown file
Never log token or pre-signed URL.
- [ ] **Step 4: Implement active job store and status stages**
Add in-process active job state with these statuses:
```text
queued
uploading
mineru_processing
downloading
writing_sidecar
done
failed
interrupted
stale
```
Each state change must update `.mnote/ocr-index.json` and return a redacted job payload. On process restart, any persisted `queued/uploading/mineru_processing/downloading/writing_sidecar` entry without active in-memory job is exposed as `interrupted`.
- [ ] **Step 5: Broadcast OCR job events**
Emit a local realtime event for every state change:
```json
{
"type": "local_ocr.job.updated",
"jobId": "ocr_1760000000000_abcd",
"rootUri": "file:///tmp/mnote-ocr",
"ownerDocumentId": "local-md:docs~2FPage.md",
"sourceRootRelativePath": "docs/Page.assets/spec.pdf",
"ocrRootRelativePath": "docs/Page.ocr/spec.pdf.ocr.md",
"status": "mineru_processing",
"stageLabel": "识别中",
"updatedAtMs": 1760000000000
}
```
Use the existing realtime / WS / SSE event path. Do not add a foreground `setInterval` polling loop as the primary update mechanism.
- [ ] **Step 6: Register routes**
Add route registrations in `routes/mod.rs`.
- [ ] **Step 7: Verify route tests pass**
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_jobs -- --test-threads=1
```
Expected: PASS.
## Task 4: Keep OCR Markdown Out Of Page Tree
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/local_folder_source.rs`
- Modify: `rust/crates/mnote-web/src/routes/local_search_index.rs`
- [ ] **Step 1: Add failing tests for `.ocr/*.ocr.md` classification**
Cover:
- `docs/Page.ocr/photo.png.ocr.md` is not a normal local Markdown document
- File Tree can still expose it as resource
- Page Tree projection excludes it
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_resource_classification -- --test-threads=1
```
Expected: FAIL.
- [ ] **Step 2: Implement OCR sidecar detection**
Add a helper such as:
```rust
fn is_local_ocr_sidecar_path(path: &Path) -> bool
```
Rules:
- parent directory name ends with `.ocr`
- file name ends with `.ocr.md`
- file has `mnote_ocr_version` frontmatter when content is available
Use path-only detection for tree projection and frontmatter-backed detection for search/index.
- [ ] **Step 3: Verify classification tests pass**
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_resource_classification -- --test-threads=1
```
Expected: PASS.
## Task 5: Search OCR Text
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/local_search_index.rs`
- Modify: `rust/crates/mnote-web/src/routes/search.rs`
- [ ] **Step 1: Add failing local search tests**
Cover:
- `includeOcr=false` does not match OCR sidecar text
- `includeOcr=true` matches OCR sidecar text
- result `documentId` is owner page id
- result has `hasOcr=true`
- evidence contains source and sidecar paths
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1
```
Expected: FAIL.
- [ ] **Step 2: Pass includeOcr into local search**
Update `search.rs` local-folder branch to pass `filters.include_ocr.unwrap_or(false)`.
- [ ] **Step 3: Extend local search index**
Add OCR sidecar collection separate from normal documents. Search OCR text only when `include_ocr=true`, then project owner page result.
- [ ] **Step 4: Verify search tests pass**
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1
```
Expected: PASS.
## Task 6: Browser Runtime OCR Actions
**Files:**
- Modify: `rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js`
- Modify: `rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js`
- Modify: `rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
- Create: `rust/crates/mnote-web/browser/local-ocr-task-runtime.js`
- Modify: `rust/crates/mnote-web/src/ssr/pages/layout.rs`
- [ ] **Step 1: Add JS syntax and SSR wiring tests**
Add or extend tests that assert OCR runtime dependencies are mounted and no `ReferenceError`-prone implicit globals are used.
Run:
```bash
node --check rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js
node --check rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js
node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js
node --check rust/crates/mnote-web/browser/local-ocr-task-runtime.js
cargo test --manifest-path rust/Cargo.toml -p mnote-web ocr_runtime -- --test-threads=1
```
Expected before implementation: Rust OCR runtime assertions fail.
- [ ] **Step 2: Add OCR actions**
Add actions:
- `OCR 识别`
- `查看 OCR`
- `插入 OCR 链接`
- `重新识别`
Only show OCR actions for image and PDF resources.
- [ ] **Step 3: Add OCR status fetch/render**
Use `/api/local-folder/ocr/status` and `/read`. Show states:
- 未识别
- 排队中
- 上传中
- 识别中
- 写入中
- 已识别
- 来源已变化
- 识别失败
- 已中断
- [ ] **Step 4: Add global OCR task bar and drawer**
Create a global OCR task entry in the shell:
- collapsed label: `OCR 2` or `1 个 OCR 任务进行中`
- drawer rows: file name, owner page, stage label, start time, completion/failure state
- row actions: `打开 OCR`, `重试`
- state source: initial `GET /api/local-folder/ocr/jobs?rootUri=...` plus `local_ocr.job.updated` events
The runtime must update from events and explicit command results. It must not rely on `setInterval` polling as the main update path.
- [ ] **Step 5: Verify JS and Rust wiring**
Run the commands from Step 1.
Expected: PASS.
## Task 7: Insert OCR Link Into Markdown
**Files:**
- Modify: `rust/crates/mnote-web/src/routes/local_ocr.rs`
- Modify: browser runtime files from Task 6
- [ ] **Step 1: Add route tests for insert**
Cover:
- inserting link appends `[OCRphoto.png](./Page.ocr/photo.png.ocr.md)`
- insert requires explicit OCR sidecar path
- insert respects local resource conflict detection
- `inlineSection` inserts heading, source comment and OCR body
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_insert -- --test-threads=1
```
Expected: FAIL.
- [ ] **Step 2: Implement `POST /api/local-folder/ocr/insert`**
Use existing local resource write patterns and keep 正文 mutation explicit.
- [ ] **Step 3: Wire UI insert actions**
After successful insert, refresh current document through existing save/refresh event path, not polling.
- [ ] **Step 4: Verify insert tests pass**
Run:
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr_insert -- --test-threads=1
```
Expected: PASS.
## Task 8: Browser Smoke
**Files:**
- Create: `scripts/task506-local-folder-mineru-ocr-smoke.js`
- Modify: `scripts/TESTING_REFERENCE.md`
- [ ] **Step 1: Write smoke script**
The smoke should:
- create a temp local-folder workspace
- create `Page.md`
- place one PNG fixture and one PDF fixture next to it
- use mock MinerU mode or a test-only fixture response
- trigger OCR from UI
- assert `Page.ocr/*.ocr.md` exists
- assert the global OCR task bar shows running state after submission
- assert the global OCR task drawer moves to done after the mocked OCR event
- assert OCR resource tab opens
- assert search with `includeOcr=true` returns owner page
- assert insert link mutates `Page.md` only after explicit click
- [ ] **Step 2: Run smoke**
Run:
```bash
MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task506-local-folder-mineru-ocr-smoke.js
```
Expected: `ok=true` and no browser `pageErrors`.
- [ ] **Step 3: Update testing reference**
Add the smoke to `scripts/TESTING_REFERENCE.md` under local-folder resource/browser smoke.
## Task 9: Final Verification
- [ ] **Step 1: Run focused Rust tests**
```bash
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_search_ocr -- --test-threads=1
```
- [ ] **Step 2: Run browser runtime checks**
```bash
node --check rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js
node --check rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js
node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js
node --check rust/crates/mnote-web/browser/local-ocr-task-runtime.js
```
- [ ] **Step 3: Run smoke**
```bash
MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task506-local-folder-mineru-ocr-smoke.js
```
- [ ] **Step 4: Check diff hygiene**
```bash
git diff --check -- rust/crates/mnote-web/src/routes/local_ocr.rs rust/crates/mnote-web/src/routes/mod.rs rust/crates/mnote-web/src/routes/local_folder_source.rs rust/crates/mnote-web/src/routes/local_search_index.rs rust/crates/mnote-web/src/routes/search.rs rust/crates/mnote-web/browser/sidebar-attachment-open-runtime.js rust/crates/mnote-web/browser/sidebar-filetree-open-runtime.js rust/crates/mnote-web/browser/document-resource-tab-runtime.js rust/crates/mnote-web/browser/local-ocr-task-runtime.js scripts/task506-local-folder-mineru-ocr-smoke.js scripts/TESTING_REFERENCE.md
```
- [ ] **Step 5: Sync CodeGraph before commit**
```bash
codegraph sync .
codegraph status .
```
Expected: no pending CodeGraph changes.
@@ -0,0 +1,394 @@
# Page AI Mindmap Skill Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the `mnote-mindmap` built-in Page AI skill and a first working `mnote.mindmap.create_from_outline` tool that generates MNote-compatible `.mindmap.json` envelopes from outlines.
**Architecture:** Keep Page AI skill discovery in `hermes_tools::skill`, keep tool manifest shape in `hermes_tools::manifest`, and keep resource file behavior in `hermes_tools::resource`. The first implementation uses local-folder resource files and does not introduce PDF OCR; PDF is represented by outline fixtures that later PDF/Office tools can feed into `create_from_outline`.
**Tech Stack:** Rust `mnote-web`, Axum route tests, serde_json, local-folder access guards, existing Hermes tool call pipeline.
---
## Files
- Create: `skills/mnote-mindmap/SKILL.md`
- Modify: `rust/crates/mnote-web/src/hermes_tools/skill.rs`
- Modify: `rust/crates/mnote-web/src/hermes_tools/manifest.rs`
- Modify: `rust/crates/mnote-web/src/hermes_tools/resource.rs`
- Modify: `rust/crates/mnote-web/src/routes/hermes_tools.rs`
- Modify: `rust/crates/mnote-web/src/routes/hermes_client.rs`
- Modify: `scripts/task502-page-ai-agent-selector-context-smoke.js`
- Test: `cargo test -p mnote-web --lib hermes_tools_mindmap --manifest-path rust/Cargo.toml`
- Test: `cargo test -p mnote-web --lib skill_registry --manifest-path rust/Cargo.toml`
- Test: `cargo test -p mnote-web --lib skill_read_returns_mindmap_skill_content --manifest-path rust/Cargo.toml`
- Test: `cargo test -p mnote-web --lib page_ai_skill_toggle_respects_builtin_user_policy_and_shared_readonly --manifest-path rust/Cargo.toml`
- Test: `node --check scripts/task502-page-ai-agent-selector-context-smoke.js`
- Smoke: `node scripts/task502-page-ai-agent-selector-context-smoke.js`
- Check: `git diff --check -- skills/mnote-mindmap/SKILL.md rust/crates/mnote-web/src/hermes_tools/skill.rs rust/crates/mnote-web/src/hermes_tools/manifest.rs rust/crates/mnote-web/src/hermes_tools/resource.rs rust/crates/mnote-web/src/routes/hermes_tools.rs rust/crates/mnote-web/src/routes/hermes_client.rs scripts/task502-page-ai-agent-selector-context-smoke.js docs/superpowers/plans/2026-05-30-page-ai-mindmap-skill.md design/07-ai/process/7-42-page-ai-mindmap-skill-and-resource-generation-v1.md`
## Task 1: Built-in Skill Registration
**Files:**
- Create: `skills/mnote-mindmap/SKILL.md`
- Modify: `rust/crates/mnote-web/src/hermes_tools/skill.rs`
- [x] **Step 1: Write the failing skill registry test**
Add a test in `rust/crates/mnote-web/src/hermes_tools/skill.rs`:
```rust
#[test]
fn skill_registry_exposes_mindmap_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-mindmap")
.expect("reasonix should see mindmap skill");
assert_eq!(skill["readOnly"], false);
assert!(
skill["requiresContextRefs"]
.as_array()
.expect("context refs")
.iter()
.any(|value| value == "resource")
);
assert!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.mindmap.create_from_outline")
);
}
```
- [x] **Step 2: Run the targeted failing test**
Run:
```bash
cargo test -p mnote-web --lib skill_registry_exposes_mindmap_skill_to_agents --manifest-path rust/Cargo.toml
```
Expected: FAIL because `mnote-mindmap` is not registered.
- [x] **Step 3: Add the skill file**
Create `skills/mnote-mindmap/SKILL.md` with the tool decision rules from `design/07-ai/process/7-42-page-ai-mindmap-skill-and-resource-generation-v1.md`.
- [x] **Step 4: Register the skill**
Add a `MnoteSkill` entry in `rust/crates/mnote-web/src/hermes_tools/skill.rs`:
```rust
MnoteSkill {
id: "mnote-mindmap",
title: "MNote mindmap editing",
description: "Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder", "resource"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.mindmap.fetch",
"mnote.mindmap.apply_ops",
"mnote.mindmap.create_from_outline",
],
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
},
```
- [x] **Step 5: Re-run the skill tests**
Run:
```bash
cargo test -p mnote-web --lib skill_registry --manifest-path rust/Cargo.toml
```
Expected: PASS for the skill registry tests.
## Task 2: Tool Manifest Contract
**Files:**
- Modify: `rust/crates/mnote-web/src/hermes_tools/manifest.rs`
- Modify: `rust/crates/mnote-web/src/routes/hermes_tools.rs`
- [x] **Step 1: Write the failing manifest test**
Extend `hermes_tools_manifest_returns_first_batch_tools` or add a dedicated test that asserts:
```rust
let create_tool = tools
.iter()
.find(|tool| tool["name"] == "mnote.mindmap.create_from_outline")
.expect("create_from_outline tool");
assert_eq!(
create_tool["inputSchema"]["properties"]["outline"]["type"],
"array"
);
assert!(
create_tool["capabilityScope"]
.as_array()
.expect("scope")
.iter()
.any(|scope| scope == "mindmap.write")
);
```
- [x] **Step 2: Run the failing manifest test**
Run:
```bash
cargo test -p mnote-web --lib hermes_tools_manifest_returns_first_batch_tools --manifest-path rust/Cargo.toml
```
Expected: FAIL because the manifest does not expose `mnote.mindmap.create_from_outline`.
- [x] **Step 3: Add `mindmap_create_from_outline_tool()`**
Add a write tool with required fields `documentId`, `mindmapId`, `outline`, `sessionId`, `runId`, `toolCallId`, `traceId`, and `actorId`. Include optional `title`, `rootUri`, `resourcePath`, `sourceRefs`, `embedIntoPage`, and `aiAccessScope`.
- [x] **Step 4: Add routing for the new tool**
In `execute_mnote_tool_call`, route:
```rust
"mnote.mindmap.create_from_outline" => resource::mindmap_create_from_outline(&context, &input).await,
```
- [x] **Step 5: Re-run the manifest test**
Run:
```bash
cargo test -p mnote-web --lib hermes_tools_manifest_returns_first_batch_tools --manifest-path rust/Cargo.toml
```
Expected: PASS for manifest exposure.
## Task 3: Mindmap Envelope Generation
**Files:**
- Modify: `rust/crates/mnote-web/src/hermes_tools/resource.rs`
- Modify: `rust/crates/mnote-web/src/routes/hermes_tools.rs`
- [x] **Step 1: Write the failing create-from-outline route test**
Add an Axum test in `rust/crates/mnote-web/src/routes/hermes_tools.rs` that calls `mnote.mindmap.create_from_outline` with:
```json
{
"mindmapId": "maps/generated.mindmap.json",
"resourcePath": "maps/generated.mindmap.json",
"title": "PDF 摘要导图",
"outline": [
{
"text": "章节一",
"children": [
{ "text": "要点 1", "children": [] }
]
}
],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedResourceIds": ["maps/generated.mindmap.json"]
}
}
```
Assert the response and written file:
```rust
assert_eq!(payload["result"]["resourceKind"], "mindmap");
assert_eq!(payload["result"]["root"]["data"]["text"], "PDF 摘要导图");
assert_eq!(payload["result"]["root"]["children"][0]["data"]["text"], "章节一");
assert_eq!(payload["result"]["envelope"]["data"]["data"]["uid"], "root");
assert!(root.join("maps").join("generated.mindmap.json").exists());
```
- [x] **Step 2: Run the failing create test**
Run:
```bash
cargo test -p mnote-web --lib hermes_tools_mindmap_create_from_outline_writes_default_envelope --manifest-path rust/Cargo.toml
```
Expected: FAIL because the tool does not exist.
- [x] **Step 3: Implement outline conversion helpers**
Implement helpers in `resource.rs`:
- `default_mindmap_view() -> Value`
- `mindmap_envelope_from_outline(title: &str, outline: &Value) -> Result<Value, WebError>`
- `mindmap_outline_items_to_children(outline: &[Value], path: &str) -> Vec<Value>`
Generated child node shape:
```json
{
"data": {
"expand": true,
"isActive": false,
"text": "节点文本",
"uid": "node_1_1"
},
"children": []
}
```
- [x] **Step 4: Implement `mindmap_create_from_outline`**
Use existing write guard and path guard:
- call `ensure_resource_write_contract`
- build `ResourceToolTarget`
- call `ensure_resource_scope_allowed`
- resolve write path under authorized root
- create parent directory
- write pretty JSON
- return envelope, root, markdown summary, revision, and changed file path
- [x] **Step 5: Re-run the create test**
Run:
```bash
cargo test -p mnote-web --lib hermes_tools_mindmap_create_from_outline_writes_default_envelope --manifest-path rust/Cargo.toml
```
Expected: PASS.
## Task 4: Fetch Envelope Awareness
**Files:**
- Modify: `rust/crates/mnote-web/src/hermes_tools/resource.rs`
- Modify: `rust/crates/mnote-web/src/routes/hermes_tools.rs`
- [x] **Step 1: Write a failing fetch test for the default envelope**
Add a test that writes a file shaped as:
```json
{
"data": {
"children": [],
"data": {
"expand": true,
"isActive": false,
"text": "KMIND",
"uid": "root"
}
},
"view": {
"state": { "scale": 1, "sx": 0, "sy": 0, "x": 0, "y": 0 },
"transform": { "a": 1, "b": 0, "c": 0, "d": 1, "e": 0, "f": 0 }
}
}
```
Then call `mnote.mindmap.fetch` with `scope=full_envelope` and assert:
```rust
assert_eq!(payload["result"]["root"]["data"]["text"], "KMIND");
assert_eq!(payload["result"]["envelope"]["data"]["data"]["uid"], "root");
```
- [x] **Step 2: Run the failing fetch test**
Run:
```bash
cargo test -p mnote-web --lib hermes_tools_mindmap_fetch_reads_default_envelope --manifest-path rust/Cargo.toml
```
Expected: FAIL if fetch only understands naked simple-mind-map trees.
- [x] **Step 3: Normalize envelope root**
Update `collect_mindmap_nodes` and response building to use:
```rust
fn mindmap_root_value(value: &Value) -> &Value {
value.get("data")
.filter(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(value)
}
```
Return `envelope` only when `scope == "full_envelope"`.
- [x] **Step 4: Re-run fetch tests**
Run:
```bash
cargo test -p mnote-web --lib hermes_tools_mindmap_fetch --manifest-path rust/Cargo.toml
```
Expected: PASS for existing and envelope fetch tests.
## Task 5: Final Verification
**Files:**
- All touched files from prior tasks.
- [x] **Step 1: Run non-mutating Rust format audit**
Run:
```bash
cargo fmt --manifest-path rust/Cargo.toml --all --check
```
Actual: command reports existing workspace-wide rustfmt drift, including unrelated dirty files and pre-existing touched-file formatting. Do not run mutating `cargo fmt` in this dirty worktree without an explicit cleanup decision.
- [x] **Step 2: Run targeted Rust tests**
Run:
```bash
cargo test -p mnote-web --lib skill_registry --manifest-path rust/Cargo.toml
cargo test -p mnote-web --lib skill_read_returns_mindmap_skill_content --manifest-path rust/Cargo.toml
cargo test -p mnote-web --lib hermes_tools_manifest_returns_first_batch_tools --manifest-path rust/Cargo.toml
cargo test -p mnote-web --lib hermes_tools_mindmap --manifest-path rust/Cargo.toml
cargo test -p mnote-web --lib page_ai_skill_toggle_respects_builtin_user_policy_and_shared_readonly --manifest-path rust/Cargo.toml
```
Expected: all targeted tests pass.
- [x] **Step 3: Run JS syntax and browser smoke checks**
Run:
```bash
node --check scripts/task502-page-ai-agent-selector-context-smoke.js
node scripts/task502-page-ai-agent-selector-context-smoke.js
```
Expected: syntax check passes; smoke captures `skillPreferences.mnote["mnote-mindmap"] = false` in the Page AI run payload.
- [x] **Step 4: Run diff whitespace check**
Run:
```bash
git diff --check -- skills/mnote-mindmap/SKILL.md rust/crates/mnote-web/src/hermes_tools/skill.rs rust/crates/mnote-web/src/hermes_tools/manifest.rs rust/crates/mnote-web/src/hermes_tools/resource.rs rust/crates/mnote-web/src/routes/hermes_tools.rs rust/crates/mnote-web/src/routes/hermes_client.rs scripts/task502-page-ai-agent-selector-context-smoke.js docs/superpowers/plans/2026-05-30-page-ai-mindmap-skill.md design/07-ai/process/7-42-page-ai-mindmap-skill-and-resource-generation-v1.md
```
Expected: no output.
- [x] **Step 5: Inspect scoped git diff**
Run:
```bash
git diff -- skills/mnote-mindmap/SKILL.md rust/crates/mnote-web/src/hermes_tools/skill.rs rust/crates/mnote-web/src/hermes_tools/manifest.rs rust/crates/mnote-web/src/hermes_tools/resource.rs rust/crates/mnote-web/src/routes/hermes_tools.rs rust/crates/mnote-web/src/routes/hermes_client.rs scripts/task502-page-ai-agent-selector-context-smoke.js docs/superpowers/plans/2026-05-30-page-ai-mindmap-skill.md design/07-ai/process/7-42-page-ai-mindmap-skill-and-resource-generation-v1.md
```
Expected: diff only contains `mnote-mindmap`, `create_from_outline`, envelope fetch support, tests, and docs.
+1 -1
View File
@@ -2,7 +2,7 @@ version: "3.9"
services:
onlyoffice-documentserver:
image: onlyoffice/documentserver:9.3.1
image: onlyoffice/documentserver:9.4.0
container_name: mnote-onlyoffice-documentserver
restart: unless-stopped
ports:
+280
View File
@@ -14,6 +14,23 @@ dependencies = [
"url",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -99,6 +116,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-lock"
version = "3.4.2"
@@ -273,12 +299,37 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "camino"
version = "1.2.2"
@@ -301,6 +352,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -330,6 +383,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
@@ -475,6 +538,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "control-plane"
version = "0.1.0"
@@ -543,6 +612,30 @@ dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -565,6 +658,12 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deflate64"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -585,6 +684,17 @@ dependencies = [
"syn",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -724,6 +834,16 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1277,6 +1397,15 @@ dependencies = [
"libc",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "interpolator"
version = "0.5.0"
@@ -1335,6 +1464,16 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.95"
@@ -1558,6 +1697,27 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e"
dependencies = [
"byteorder",
"crc",
]
[[package]]
name = "lzma-sys"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "manyhow"
version = "0.11.4"
@@ -1599,6 +1759,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -1659,6 +1829,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zip",
]
[[package]]
@@ -1790,6 +1961,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2540,6 +2721,12 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3707,6 +3894,15 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "xz2"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2"
dependencies = [
"lzma-sys",
]
[[package]]
name = "yansi"
version = "1.0.1"
@@ -3782,6 +3978,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"
@@ -3816,8 +4026,78 @@ dependencies = [
"syn",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
"deflate64",
"displaydoc",
"flate2",
"getrandom 0.3.4",
"hmac",
"indexmap",
"lzma-rs",
"memchr",
"pbkdf2",
"sha1",
"thiserror 2.0.18",
"time",
"xz2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]
+67
View File
@@ -202,6 +202,10 @@ fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
Ok("documents:updateContent")
}
"mindmaps.put" => Ok("mindmaps:put"),
"tree.resource.archive" => Ok("treeResource:archive"),
"tree.resource.restore" => Ok("treeResource:restore"),
"tree.resource.purge" => Ok("treeResource:purge"),
"tree.resource.rename" => Ok("treeResource:rename"),
_ => Err(retired_bridge_error()),
}
}
@@ -6416,6 +6420,25 @@ fn legacy_block_projection_attrs(block: &Value, block_type: &str) -> Value {
attrs.insert("language".into(), json!(language));
}
}
if block_type == "mindmap" {
for (raw_key, canonical_key) in [
("mindmapId", "mindmapId"),
("mindmap_id", "mindmapId"),
("sourcePath", "sourcePath"),
("source_path", "sourcePath"),
("rootNodeId", "rootNodeId"),
("root_node_id", "rootNodeId"),
] {
if let Some(value) = props
.and_then(|map| map.get(raw_key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
attrs.insert(canonical_key.into(), json!(value));
}
}
}
Value::Object(attrs)
}
@@ -13086,6 +13109,50 @@ mod tests {
);
}
#[test]
fn page_aggregate_block_document_preserves_mindmap_projection_attrs() {
let result = execute_runtime_query(RuntimeInput::Query {
context: demo_context(),
query: RuntimeQueryEnvelopeWire {
name: "page.aggregate.get".into(),
payload: json!({
"documentId": "doc_mindmap",
"workspaceId": "ws_1",
}),
},
data: Some(json!({
"meta": {
"id": "doc_mindmap",
"workspace_id": "ws_1",
"title": "Mindmap Page"
},
"content": {
"content": [
{
"id": "local-block-1",
"type": "mindmap",
"props": {
"mindmapId": "mindmap-123456.json",
"sourcePath": "mindmap-123456.json",
"rootNodeId": "root"
},
"content": []
}
],
"revision": 3,
"conflict_detection_key": "doc_mindmap:3"
}
})),
})
.expect("page aggregate query should build");
let block = &result["body"]["blockDocument"]["blocks"][0];
assert_eq!(block["type"], json!("mindmap"));
assert_eq!(block["attrs"]["mindmapId"], json!("mindmap-123456.json"));
assert_eq!(block["attrs"]["sourcePath"], json!("mindmap-123456.json"));
assert_eq!(block["attrs"]["rootNodeId"], json!("root"));
}
#[test]
fn page_aggregate_get_prefers_editor_document_over_legacy_content() {
let result = execute_runtime_query(RuntimeInput::Query {
@@ -0,0 +1,43 @@
-- 007-ai-agent-profile-policy.sql
-- Page AI agent profile policy: SQLite 是 profile 授权与归属真相层。
CREATE TABLE IF NOT EXISTS ai_agent_profiles (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
profile_kind TEXT NOT NULL,
owner_user_id TEXT NOT NULL DEFAULT '',
base_profile_name TEXT NOT NULL,
isolated_profile_name TEXT NOT NULL,
display_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(agent_id, profile_kind, owner_user_id, base_profile_name)
);
CREATE INDEX IF NOT EXISTS idx_ai_agent_profiles_agent
ON ai_agent_profiles(agent_id, status);
CREATE INDEX IF NOT EXISTS idx_ai_agent_profiles_owner
ON ai_agent_profiles(owner_user_id, status);
CREATE TABLE IF NOT EXISTS ai_agent_profile_grants (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES ai_agent_profiles(id) ON DELETE CASCADE,
user_id TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL,
can_run INTEGER NOT NULL DEFAULT 1,
can_manage_skills INTEGER NOT NULL DEFAULT 0,
can_manage_config INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1,
UNIQUE(profile_id, user_id, role)
);
CREATE INDEX IF NOT EXISTS idx_ai_agent_profile_grants_profile
ON ai_agent_profile_grants(profile_id);
CREATE INDEX IF NOT EXISTS idx_ai_agent_profile_grants_user
ON ai_agent_profile_grants(user_id);
@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS ai_external_conversation_bindings (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT,
mnote_session_id TEXT NOT NULL,
acp_session_id TEXT,
agent_id TEXT NOT NULL,
profile TEXT NOT NULL,
provider TEXT NOT NULL,
remote_conversation_id TEXT NOT NULL,
remote_url TEXT,
status TEXT NOT NULL DEFAULT 'active',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
revision INTEGER NOT NULL DEFAULT 1,
CHECK (status IN ('active', 'local_deleted', 'remote_deleted', 'remote_delete_failed', 'remote_missing'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_external_conv_user_session_provider
ON ai_external_conversation_bindings(user_id, mnote_session_id, provider);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_external_conv_user_provider_remote
ON ai_external_conversation_bindings(user_id, provider, remote_conversation_id);
CREATE INDEX IF NOT EXISTS idx_ai_external_conv_lookup
ON ai_external_conversation_bindings(user_id, workspace_id, mnote_session_id, provider, status);
@@ -30,6 +30,14 @@ const MIGRATIONS: &[(&str, &str)] = &[
"v6-navigation-recent",
include_str!("../migrations/006-navigation-recent.sql"),
),
(
"v7-ai-agent-profile-policy",
include_str!("../migrations/007-ai-agent-profile-policy.sql"),
),
(
"v8-ai-external-conversation-bindings",
include_str!("../migrations/008-ai-external-conversation-bindings.sql"),
),
];
/// Create the `_migrations` meta-table if it does not exist.
@@ -113,6 +121,9 @@ mod tests {
"sidebar_shortcuts",
"user_ui_preferences",
"user_navigation_recent",
"ai_agent_profiles",
"ai_agent_profile_grants",
"ai_external_conversation_bindings",
] {
assert!(table_exists(&conn, table), "table {table} should exist");
}
+71
View File
@@ -243,6 +243,41 @@ pub struct UpsertUserUiPreferenceInput {
pub value_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiAgentProfileRecord {
pub id: EntityId,
pub agent_id: String,
pub profile_kind: String,
pub owner_user_id: Option<EntityId>,
pub base_profile_name: String,
pub isolated_profile_name: String,
pub display_name: String,
pub status: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiAgentProfileGrantRecord {
pub id: EntityId,
pub profile_id: EntityId,
pub user_id: Option<EntityId>,
pub role: String,
pub can_run: bool,
pub can_manage_skills: bool,
pub can_manage_config: bool,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiAgentProfileAccessRecord {
pub profile: AiAgentProfileRecord,
pub grant: AiAgentProfileGrantRecord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NavigationRecentRecord {
pub id: EntityId,
@@ -393,6 +428,42 @@ pub struct AppendAiRuntimeEventInput {
pub payload_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AiExternalConversationBindingRecord {
pub id: EntityId,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub mnote_session_id: EntityId,
pub acp_session_id: Option<EntityId>,
pub agent_id: String,
pub profile: String,
pub provider: String,
pub remote_conversation_id: String,
pub remote_url: Option<String>,
pub status: String,
pub metadata_json: String,
pub created_at: Timestamp,
pub updated_at: Timestamp,
pub deleted_at: Option<Timestamp>,
pub revision: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpsertAiExternalConversationBindingInput {
pub id: Option<EntityId>,
pub user_id: EntityId,
pub workspace_id: Option<EntityId>,
pub mnote_session_id: EntityId,
pub acp_session_id: Option<EntityId>,
pub agent_id: String,
pub profile: String,
pub provider: String,
pub remote_conversation_id: String,
pub remote_url: Option<String>,
pub status: String,
pub metadata_json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareLinkRecord {
pub id: EntityId,
+680 -12
View File
@@ -1,5 +1,6 @@
//! SQLite-backed control-plane store.
use std::collections::BTreeMap;
use std::sync::Mutex;
use rusqlite::{params, Connection, OptionalExtension};
@@ -7,15 +8,17 @@ use rusqlite::{params, Connection, OptionalExtension};
use crate::error::ControlPlaneError;
use crate::migrations;
use crate::model::{
password_hash_v1, share_token_hash_v1, AiPolicyRecord, AiRuntimeEventRecord,
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
UserUiPreferenceRecord, WorkspaceRecord,
};
use crate::store::ControlPlaneStore;
@@ -214,6 +217,66 @@ fn option_to_stored_text(value: Option<String>) -> String {
.unwrap_or_default()
}
fn sanitize_profile_component(value: &str) -> String {
let mut sanitized = value
.trim()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
while sanitized.contains("--") {
sanitized = sanitized.replace("--", "-");
}
sanitized = sanitized.trim_matches('-').to_string();
if sanitized.is_empty() {
"user".to_string()
} else {
sanitized
}
}
fn row_to_ai_agent_profile(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiAgentProfileRecord> {
let owner_user_id: String = row.get(3)?;
Ok(AiAgentProfileRecord {
id: row.get(0)?,
agent_id: row.get(1)?,
profile_kind: row.get(2)?,
owner_user_id: empty_string_to_option(owner_user_id),
base_profile_name: row.get(4)?,
isolated_profile_name: row.get(5)?,
display_name: row.get(6)?,
status: row.get(7)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
revision: row.get(10)?,
})
}
fn row_to_ai_agent_profile_access(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiAgentProfileAccessRecord> {
Ok(AiAgentProfileAccessRecord {
profile: row_to_ai_agent_profile(row)?,
grant: AiAgentProfileGrantRecord {
id: row.get(11)?,
profile_id: row.get(12)?,
user_id: empty_string_to_option(row.get(13)?),
role: row.get(14)?,
can_run: row.get::<_, i64>(15)? != 0,
can_manage_skills: row.get::<_, i64>(16)? != 0,
can_manage_config: row.get::<_, i64>(17)? != 0,
created_at: row.get(18)?,
updated_at: row.get(19)?,
revision: row.get(20)?,
},
})
}
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
Ok(UserUiPreferenceRecord {
id: row.get(0)?,
@@ -281,6 +344,19 @@ fn navigation_recent_target_key(
}
}
fn validate_ai_external_conversation_status(status: &str) -> Result<(), ControlPlaneError> {
match status {
"active"
| "local_deleted"
| "remote_deleted"
| "remote_delete_failed"
| "remote_missing" => Ok(()),
_ => Err(ControlPlaneError::InvalidInput(
"external conversation status 不合法".to_string(),
)),
}
}
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
Ok(AiPolicyRecord {
id: row.get(0)?,
@@ -348,6 +424,29 @@ fn row_to_ai_runtime_event(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiRuntim
})
}
fn row_to_ai_external_conversation_binding(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<AiExternalConversationBindingRecord> {
Ok(AiExternalConversationBindingRecord {
id: row.get(0)?,
user_id: row.get(1)?,
workspace_id: row.get(2)?,
mnote_session_id: row.get(3)?,
acp_session_id: row.get(4)?,
agent_id: row.get(5)?,
profile: row.get(6)?,
provider: row.get(7)?,
remote_conversation_id: row.get(8)?,
remote_url: row.get(9)?,
status: row.get(10)?,
metadata_json: row.get(11)?,
created_at: row.get(12)?,
updated_at: row.get(13)?,
deleted_at: row.get(14)?,
revision: row.get(15)?,
})
}
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
let title = serde_json::from_str::<serde_json::Value>(payload_json)
.ok()
@@ -391,6 +490,50 @@ fn prune_navigation_recent_for_kind(
Ok(())
}
fn list_ai_agent_profile_access_rows(
conn: &Connection,
user_id: &str,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
let mut stmt = conn.prepare(
"SELECT
p.id, p.agent_id, p.profile_kind, p.owner_user_id, p.base_profile_name,
p.isolated_profile_name, p.display_name, p.status, p.created_at, p.updated_at,
p.revision,
g.id, g.profile_id, g.user_id, g.role, g.can_run, g.can_manage_skills,
g.can_manage_config, g.created_at, g.updated_at, g.revision
FROM ai_agent_profiles p
JOIN ai_agent_profile_grants g ON g.profile_id = p.id
WHERE p.agent_id = 'hermes'
AND p.status = 'active'
AND g.can_run = 1
AND (
(p.profile_kind = 'personal' AND p.owner_user_id = ?1 AND g.user_id = ?1)
OR (p.profile_kind = 'shared' AND (g.user_id = '' OR g.user_id = ?1))
)
ORDER BY
CASE p.profile_kind WHEN 'personal' THEN 0 ELSE 1 END,
p.display_name ASC,
g.can_manage_skills DESC,
g.can_manage_config DESC",
)?;
let rows = stmt
.query_map(params![user_id], row_to_ai_agent_profile_access)?
.collect::<Result<Vec<_>, _>>()
.map_err(ControlPlaneError::from)?;
let mut by_profile = BTreeMap::<String, AiAgentProfileAccessRecord>::new();
for row in rows {
by_profile
.entry(row.profile.id.clone())
.and_modify(|existing| {
if row.grant.can_manage_skills && !existing.grant.can_manage_skills {
*existing = row.clone();
}
})
.or_insert(row);
}
Ok(by_profile.into_values().collect())
}
impl ControlPlaneStore for SqliteControlPlaneStore {
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
if input.username.trim().is_empty() {
@@ -1461,6 +1604,168 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn ensure_ai_agent_profile_policy(
&self,
user_id: &str,
is_admin: bool,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(ControlPlaneError::InvalidInput(
"ai agent profile user_id 不能为空".to_string(),
));
}
let conn = self.conn.lock().unwrap();
let now = now_text();
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES ('shared_lite', 'hermes', 'shared', '', 'lite', 'lite', 'Lite', 'active', ?1, ?2, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
params![now, now],
)?;
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES ('grant_shared_lite_all', 'shared_lite', '', 'user', 1, 0, 0, ?1, ?2, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![now, now],
)?;
// 旧的泛化 OpenClaw 网页问答入口已拆成明确的三类 chat agent。
conn.execute(
"UPDATE ai_agent_profiles
SET status = 'retired', updated_at = ?1
WHERE id = 'shared_openclaw_webqa'",
params![now],
)?;
let shared_chat_profiles = [
(
"shared_deepseek_chat",
"deepseek-chat",
"openclaw-deepseek-chat",
"DeepSeek Chat",
),
(
"shared_gemini_chat",
"gemini-chat",
"openclaw-gemini-chat",
"Gemini Chat",
),
(
"shared_doubao_chat",
"doubao-chat",
"openclaw-doubao-chat",
"豆包 Chat",
),
];
for (profile_id, base_profile, isolated_profile, display_name) in shared_chat_profiles {
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES (?1, 'hermes', 'shared', '', ?2, ?3, ?4, 'active', ?5, ?6, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', display_name = excluded.display_name,
isolated_profile_name = excluded.isolated_profile_name, updated_at = excluded.updated_at",
params![profile_id, base_profile, isolated_profile, display_name, now, now],
)?;
let grant_id = format!("grant_{profile_id}_all");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, '', 'user', 1, 0, 0, ?3, ?4, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![grant_id, profile_id, now, now],
)?;
}
let user_part = sanitize_profile_component(user_id);
let personal_profile_id = format!("usr_{user_part}_default");
let personal_profile_name = format!("mnote-u-{user_part}-default");
conn.execute(
"INSERT INTO ai_agent_profiles (
id, agent_id, profile_kind, owner_user_id, base_profile_name,
isolated_profile_name, display_name, status, created_at, updated_at, revision
)
VALUES (?1, 'hermes', 'personal', ?2, 'default', ?3, ' Hermes', 'active', ?4, ?5, 1)
ON CONFLICT(agent_id, profile_kind, owner_user_id, base_profile_name)
DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
params![personal_profile_id, user_id, personal_profile_name, now, now],
)?;
let personal_grant_id = format!("grant_{personal_profile_id}_owner");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, 'owner', 1, 1, 1, ?4, ?5, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
updated_at = excluded.updated_at",
params![personal_grant_id, personal_profile_id, user_id, now, now],
)?;
if is_admin {
let admin_grant_id = format!("grant_shared_lite_admin_{user_part}");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, 'shared_lite', ?2, 'admin', 1, 1, 1, ?3, ?4, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 1, can_manage_config = 1,
updated_at = excluded.updated_at",
params![admin_grant_id, user_id, now, now],
)?;
for (profile_id, _, _, _) in shared_chat_profiles {
let admin_chat_grant_id = format!("grant_{profile_id}_admin_{user_part}");
conn.execute(
"INSERT INTO ai_agent_profile_grants (
id, profile_id, user_id, role, can_run, can_manage_skills,
can_manage_config, created_at, updated_at, revision
)
VALUES (?1, ?2, ?3, 'admin', 1, 0, 0, ?4, ?5, 1)
ON CONFLICT(profile_id, user_id, role)
DO UPDATE SET can_run = 1, can_manage_skills = 0, can_manage_config = 0,
updated_at = excluded.updated_at",
params![admin_chat_grant_id, profile_id, user_id, now, now],
)?;
}
}
list_ai_agent_profile_access_rows(&conn, user_id)
}
fn resolve_ai_agent_profile(
&self,
user_id: &str,
is_admin: bool,
profile_id: &str,
) -> Result<Option<AiAgentProfileAccessRecord>, ControlPlaneError> {
let profile_id = profile_id.trim();
if profile_id.is_empty() {
return Ok(None);
}
let access = self.ensure_ai_agent_profile_policy(user_id, is_admin)?;
Ok(access
.into_iter()
.find(|item| item.profile.id == profile_id && item.grant.can_run))
}
fn upsert_navigation_recent(
&self,
input: UpsertNavigationRecentInput,
@@ -2047,6 +2352,207 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
Ok(rows)
}
fn upsert_ai_external_conversation_binding(
&self,
input: UpsertAiExternalConversationBindingInput,
) -> Result<AiExternalConversationBindingRecord, ControlPlaneError> {
let user_id = input.user_id.trim();
let mnote_session_id = input.mnote_session_id.trim();
let provider = input.provider.trim();
let remote_conversation_id = input.remote_conversation_id.trim();
if user_id.is_empty()
|| mnote_session_id.is_empty()
|| provider.is_empty()
|| remote_conversation_id.is_empty()
{
return Err(ControlPlaneError::InvalidInput(
"external conversation binding user_id/session_id/provider/remote_id 不能为空"
.to_string(),
));
}
serde_json::from_str::<serde_json::Value>(&input.metadata_json)?;
validate_ai_external_conversation_status(&input.status)?;
let conn = self.conn.lock().unwrap();
let now = now_text();
let existing = conn
.query_row(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
WHERE user_id = ?1 AND mnote_session_id = ?2 AND provider = ?3
LIMIT 1",
params![user_id, mnote_session_id, provider],
row_to_ai_external_conversation_binding,
)
.optional()?;
if let Some(existing) = existing {
let revision = existing.revision + 1;
let deleted_at = if input.status == "active" {
None
} else {
existing.deleted_at.or_else(|| Some(now.clone()))
};
conn.execute(
"UPDATE ai_external_conversation_bindings
SET workspace_id = ?1, acp_session_id = ?2, agent_id = ?3, profile = ?4, remote_conversation_id = ?5, remote_url = ?6, status = ?7, metadata_json = ?8, updated_at = ?9, deleted_at = ?10, revision = ?11
WHERE id = ?12",
params![
input.workspace_id,
input.acp_session_id,
input.agent_id,
input.profile,
remote_conversation_id,
input.remote_url,
input.status,
input.metadata_json,
now,
deleted_at,
revision,
existing.id
],
)?;
return Ok(AiExternalConversationBindingRecord {
id: existing.id,
user_id: existing.user_id,
workspace_id: input.workspace_id,
mnote_session_id: existing.mnote_session_id,
acp_session_id: input.acp_session_id,
agent_id: input.agent_id,
profile: input.profile,
provider: existing.provider,
remote_conversation_id: remote_conversation_id.to_string(),
remote_url: input.remote_url,
status: input.status,
metadata_json: input.metadata_json,
created_at: existing.created_at,
updated_at: now,
deleted_at,
revision,
});
}
let deleted_at = if input.status == "active" {
None
} else {
Some(now.clone())
};
let record = AiExternalConversationBindingRecord {
id: input.id.unwrap_or_else(|| new_id("aecb")),
user_id: user_id.to_string(),
workspace_id: input.workspace_id,
mnote_session_id: mnote_session_id.to_string(),
acp_session_id: input.acp_session_id,
agent_id: input.agent_id,
profile: input.profile,
provider: provider.to_string(),
remote_conversation_id: remote_conversation_id.to_string(),
remote_url: input.remote_url,
status: input.status,
metadata_json: input.metadata_json,
created_at: now.clone(),
updated_at: now,
deleted_at,
revision: 1,
};
conn.execute(
"INSERT INTO ai_external_conversation_bindings (id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, 1)",
params![
record.id,
record.user_id,
record.workspace_id,
record.mnote_session_id,
record.acp_session_id,
record.agent_id,
record.profile,
record.provider,
record.remote_conversation_id,
record.remote_url,
record.status,
record.metadata_json,
record.created_at,
record.updated_at,
record.deleted_at
],
)?;
Ok(record)
}
fn find_ai_external_conversation_binding(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
let provider = provider.trim();
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(None);
}
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT id, user_id, workspace_id, mnote_session_id, acp_session_id, agent_id, profile, provider, remote_conversation_id, remote_url, status, metadata_json, created_at, updated_at, deleted_at, revision
FROM ai_external_conversation_bindings
WHERE user_id = ?1
AND (?2 IS NULL OR workspace_id = ?2)
AND mnote_session_id = ?3
AND provider = ?4
LIMIT 1",
params![user_id, workspace_id, mnote_session_id, provider],
row_to_ai_external_conversation_binding,
)
.optional()
.map_err(ControlPlaneError::from)
}
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
status: &str,
metadata_json: Option<&str>,
) -> Result<usize, ControlPlaneError> {
let user_id = user_id.trim();
let mnote_session_id = mnote_session_id.trim();
let provider = provider.trim();
validate_ai_external_conversation_status(status)?;
let metadata_json = metadata_json.unwrap_or("{}");
serde_json::from_str::<serde_json::Value>(metadata_json)?;
if user_id.is_empty() || mnote_session_id.is_empty() || provider.is_empty() {
return Ok(0);
}
let conn = self.conn.lock().unwrap();
let now = now_text();
let deleted_at = if status == "active" {
None
} else {
Some(now.clone())
};
let changed = conn.execute(
"UPDATE ai_external_conversation_bindings
SET status = ?1, metadata_json = ?2, updated_at = ?3, deleted_at = COALESCE(?4, deleted_at), revision = revision + 1
WHERE user_id = ?5
AND (?6 IS NULL OR workspace_id = ?6)
AND mnote_session_id = ?7
AND provider = ?8",
params![
status,
metadata_json,
now,
deleted_at,
user_id,
workspace_id,
mnote_session_id,
provider
],
)?;
Ok(changed)
}
fn rename_ai_runtime_session(
&self,
user_id: &str,
@@ -2195,9 +2701,9 @@ mod tests {
use super::*;
use crate::model::{
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
UpsertUserUiPreferenceInput,
CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserUiPreferenceInput,
};
fn store() -> SqliteControlPlaneStore {
@@ -2716,6 +3222,95 @@ mod tests {
assert!(bob_sidebar_preferences.is_empty());
}
#[test]
fn ai_agent_profile_policy_provisions_personal_and_shared_boundaries() {
let store = store();
create_user(&store, "alice");
create_user(&store, "bob");
store
.upsert_user(UpsertUserInput {
id: Some("admin".to_string()),
email: Some("admin@example.com".to_string()),
username: "admin".to_string(),
display_name: "admin".to_string(),
role: Some("admin".to_string()),
password_hash: None,
})
.expect("admin user");
let alice_profiles = store
.ensure_ai_agent_profile_policy("alice", false)
.expect("alice profiles");
assert_eq!(alice_profiles.len(), 5);
let alice_personal = alice_profiles
.iter()
.find(|item| item.profile.profile_kind == "personal")
.expect("alice personal profile");
assert_eq!(
alice_personal.profile.owner_user_id.as_deref(),
Some("alice")
);
assert!(alice_personal.grant.can_manage_skills);
let alice_shared = alice_profiles
.iter()
.find(|item| item.profile.id == "shared_lite")
.expect("alice shared profile");
assert!(alice_shared.grant.can_run);
assert!(!alice_shared.grant.can_manage_skills);
for (profile_id, isolated_profile, display_name) in [
(
"shared_deepseek_chat",
"openclaw-deepseek-chat",
"DeepSeek Chat",
),
("shared_gemini_chat", "openclaw-gemini-chat", "Gemini Chat"),
("shared_doubao_chat", "openclaw-doubao-chat", "豆包 Chat"),
] {
let alice_chat = alice_profiles
.iter()
.find(|item| item.profile.id == profile_id)
.expect("alice shared chat profile");
assert_eq!(alice_chat.profile.isolated_profile_name, isolated_profile);
assert_eq!(alice_chat.profile.display_name, display_name);
assert!(alice_chat.grant.can_run);
assert!(!alice_chat.grant.can_manage_skills);
assert!(!alice_chat.grant.can_manage_config);
}
let bob_profiles = store
.ensure_ai_agent_profile_policy("bob", false)
.expect("bob profiles");
let bob_personal = bob_profiles
.iter()
.find(|item| item.profile.profile_kind == "personal")
.expect("bob personal profile");
assert_ne!(bob_personal.profile.id, alice_personal.profile.id);
assert!(store
.resolve_ai_agent_profile("bob", false, &alice_personal.profile.id)
.expect("resolve cross user")
.is_none());
let admin_shared = store
.resolve_ai_agent_profile("admin", true, "shared_lite")
.expect("admin shared")
.expect("admin can see shared");
assert!(admin_shared.grant.can_manage_skills);
assert!(admin_shared.grant.can_manage_config);
for profile_id in [
"shared_deepseek_chat",
"shared_gemini_chat",
"shared_doubao_chat",
] {
let admin_chat = store
.resolve_ai_agent_profile("admin", true, profile_id)
.expect("admin chat")
.expect("admin can see shared chat profile");
assert!(admin_chat.grant.can_run);
assert!(!admin_chat.grant.can_manage_skills);
assert!(!admin_chat.grant.can_manage_config);
}
}
#[test]
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
let store = store();
@@ -3025,6 +3620,79 @@ mod tests {
assert!(runs.is_empty());
}
#[test]
fn ai_external_conversation_binding_is_user_scoped_and_statused() {
let store = store();
create_user(&store, "doubao_user_a");
create_user(&store, "doubao_user_b");
let binding = store
.upsert_ai_external_conversation_binding(UpsertAiExternalConversationBindingInput {
id: None,
user_id: "doubao_user_a".to_string(),
workspace_id: Some("ws_1".to_string()),
mnote_session_id: "chatonly_session_1".to_string(),
acp_session_id: Some("acp_session_1".to_string()),
agent_id: "chat_only".to_string(),
profile: "openclaw-doubao-chat".to_string(),
provider: "doubao-web".to_string(),
remote_conversation_id: "38428454119180290".to_string(),
remote_url: Some("https://www.doubao.com/chat/38428454119180290".to_string()),
status: "active".to_string(),
metadata_json: "{\"source\":\"provider.conversation.bound\"}".to_string(),
})
.expect("upsert external conversation binding");
assert_eq!(binding.status, "active");
assert_eq!(binding.remote_conversation_id, "38428454119180290");
let found = store
.find_ai_external_conversation_binding(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find binding")
.expect("binding exists");
assert_eq!(found.id, binding.id);
assert_eq!(found.acp_session_id.as_deref(), Some("acp_session_1"));
let other_user = store
.find_ai_external_conversation_binding(
"doubao_user_b",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find other user binding");
assert!(other_user.is_none());
let changed = store
.mark_ai_external_conversation_binding_status(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
"local_deleted",
Some("{\"reason\":\"mnote_session_deleted\"}"),
)
.expect("mark local deleted");
assert_eq!(changed, 1);
let deleted = store
.find_ai_external_conversation_binding(
"doubao_user_a",
Some("ws_1"),
"chatonly_session_1",
"doubao-web",
)
.expect("find deleted binding")
.expect("binding remains auditable");
assert_eq!(deleted.status, "local_deleted");
assert!(deleted.deleted_at.is_some());
}
#[test]
fn session_lookup_resolves_active_user_by_token_hash() {
let store = store();
+46 -8
View File
@@ -2,14 +2,16 @@
use crate::error::ControlPlaneError;
use crate::model::{
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
UserUiPreferenceRecord, WorkspaceRecord,
};
pub trait ControlPlaneStore: Send + Sync {
@@ -136,6 +138,19 @@ pub trait ControlPlaneStore: Send + Sync {
source_kind: Option<&str>,
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
fn ensure_ai_agent_profile_policy(
&self,
user_id: &str,
is_admin: bool,
) -> Result<Vec<AiAgentProfileAccessRecord>, ControlPlaneError>;
fn resolve_ai_agent_profile(
&self,
user_id: &str,
is_admin: bool,
profile_id: &str,
) -> Result<Option<AiAgentProfileAccessRecord>, ControlPlaneError>;
fn upsert_navigation_recent(
&self,
input: UpsertNavigationRecentInput,
@@ -196,6 +211,29 @@ pub trait ControlPlaneStore: Send + Sync {
limit: usize,
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError>;
fn upsert_ai_external_conversation_binding(
&self,
input: UpsertAiExternalConversationBindingInput,
) -> Result<AiExternalConversationBindingRecord, ControlPlaneError>;
fn find_ai_external_conversation_binding(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
) -> Result<Option<AiExternalConversationBindingRecord>, ControlPlaneError>;
fn mark_ai_external_conversation_binding_status(
&self,
user_id: &str,
workspace_id: Option<&str>,
mnote_session_id: &str,
provider: &str,
status: &str,
metadata_json: Option<&str>,
) -> Result<usize, ControlPlaneError>;
fn rename_ai_runtime_session(
&self,
user_id: &str,
+14 -3
View File
@@ -264,10 +264,18 @@ impl DocumentBuffer {
self.base_content_hash = Some(content_hash);
self.current_content_hash = None;
self.dirty_state = DocBufferDirtyState::Clean;
if write_intent_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
if write_intent_id
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
self.last_write_intent_id = write_intent_id;
}
if save_operation_id.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()) {
if save_operation_id
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
self.last_save_operation_id = save_operation_id;
}
self.last_saved_at = Some(
@@ -922,7 +930,10 @@ mod tests {
request.expected_file_version.as_deref(),
Some("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(request.write_intent_id.as_deref(), Some("intent:editor:abc"));
assert_eq!(
request.write_intent_id.as_deref(),
Some("intent:editor:abc")
);
assert_eq!(request.save_operation_id.as_deref(), Some("save:op:abc"));
assert_eq!(request.content_format, "editorBlocks");
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
+1
View File
@@ -31,3 +31,4 @@ comrak = { version = "0.52", default-features = false }
notify = "8.2.0"
time = { version = "0.3", features = ["formatting", "local-offset"] }
uuid = { version = "1", features = ["v4"] }
zip = "2"
@@ -64,6 +64,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const resourceTabMru = { primary: [], secondary: [] };
const resourceTabMruMax = 20;
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
let onlyofficeBridgeReadyListenerBound = false;
const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
@@ -127,6 +128,30 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
};
const currentFileTreeWorkspacePath = () => {
try {
const row = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-local-relative-path]');
const reader = window.__mnoteFileTreeRuntime?.readWorkspacePathFromRow;
if (row instanceof HTMLElement && typeof reader === 'function') {
const workspacePath = reader(row);
if (workspacePath && typeof workspacePath === 'object') {
return {
...workspacePath,
sourceKind: String(workspacePath.sourceKind || row.getAttribute('data-source-kind') || '').trim(),
rootUri: String(workspacePath.rootUri || row.getAttribute('data-root-uri') || '').trim(),
};
}
}
if (row instanceof HTMLElement) {
return {
sourceKind: String(row.getAttribute('data-source-kind') || '').trim(),
rootUri: String(row.getAttribute('data-root-uri') || '').trim(),
};
}
} catch (_) {}
return null;
};
const currentWebShellDocumentId = () => {
const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : '';
if (explicit) return explicit;
@@ -157,17 +182,23 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const currentWebShellSourceKind = () => {
try {
return currentUrl().searchParams.get('sourceKind') || '';
return currentUrl().searchParams.get('sourceKind')
|| document.body?.dataset?.mnoteSourceKind
|| currentFileTreeWorkspacePath()?.sourceKind
|| '';
} catch (_) {
return '';
return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || '';
}
};
const currentWebShellRootUri = () => {
try {
return currentUrl().searchParams.get('rootUri') || '';
return currentUrl().searchParams.get('rootUri')
|| document.body?.dataset?.mnoteRootUri
|| currentFileTreeWorkspacePath()?.rootUri
|| '';
} catch (_) {
return '';
return document.body?.dataset?.mnoteRootUri || currentFileTreeWorkspacePath()?.rootUri || '';
}
};
@@ -196,7 +227,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
resourceKind: String(fromInput.resourceKind || fromInput.objectKind || resourceKind || '').trim(),
};
}
const id = String(objectIdentity || documentId || assetId || relativePath || '').trim();
const structuredObjectIdentity = objectIdentity && typeof objectIdentity === 'object' ? objectIdentity : null;
const objectIdentityText = structuredObjectIdentity ? '' : String(objectIdentity || '').trim();
const id = String(objectIdentityText || documentId || assetId || relativePath || '').trim();
if (!id) return null;
return {
schema: 'mnote.workspace_path.v1',
@@ -205,7 +238,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
rootUri: String(rootUri || '').trim(),
relativePath: String(relativePath || '').trim(),
documentId: String(documentId || '').trim(),
objectIdentity: String(objectIdentity || '').trim(),
objectIdentity: structuredObjectIdentity || objectIdentityText,
assetId: String(assetId || '').trim(),
resourceKind: String(resourceKind || '').trim(),
};
@@ -371,6 +404,18 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
};
const officeBridgeDebugForEntry = (entry) => {
if (!entry?.panel || !(entry.panel instanceof HTMLElement)) return null;
const frame = entry.panel.querySelector('iframe.mnote-resource-tab-frame');
if (!(frame instanceof HTMLIFrameElement)) return null;
try {
const debug = frame.contentWindow?.__MNOTE_ONLYOFFICE_DEBUG__;
return debug && typeof debug === 'object' ? debug : null;
} catch (_) {
return null;
}
};
const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
const active = entry?.tab instanceof HTMLElement
? entry.tab.getAttribute('aria-selected') === 'true'
@@ -384,6 +429,13 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
const kind = normalizeResourceTabKind(entry);
const dirtyState = resourceTabCloseGuardReason(entry?.session);
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
const onlyofficeSessionId = String(
officeBridgeDebug?.bridgeSessionId
|| entry?.onlyofficeSessionId
|| entry?.bridgeSessionId
|| '',
).trim();
return {
objectIdentity,
workspacePath: buildWorkspacePath({
@@ -411,6 +463,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
dirtyGuard: dirtyState,
assetId,
path: relativePath,
onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
bridgeSessionReady: Boolean(onlyofficeSessionId),
bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(),
bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(),
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
preview: false,
pinned: false,
@@ -427,6 +484,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const rootUri = currentWebShellRootUri();
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
const objectIdentity = `page:${paneRole}`;
const workspaceObjectIdentity = {
objectKind: 'page',
documentId,
blockId: null,
assetId: null,
};
const active = nodes.pageTab instanceof HTMLElement
? nodes.pageTab.getAttribute('aria-selected') === 'true'
: false;
@@ -443,7 +506,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
rootUri,
relativePath,
documentId,
objectIdentity,
objectIdentity: workspaceObjectIdentity,
assetId: '',
resourceKind: 'page',
}),
@@ -1035,6 +1098,17 @@ export const createResourceTabRuntime = (dependencies = {}) => {
markIntendedSlashRoot(entry);
};
const ensureOnlyofficeBridgeReadyListener = () => {
if (onlyofficeBridgeReadyListenerBound) return;
onlyofficeBridgeReadyListenerBound = true;
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
const detail = event.data && typeof event.data === 'object' ? event.data : null;
if (!detail || detail.type !== 'mnote:onlyoffice-bridge-ready') return;
syncOpenEditorsSnapshot();
});
};
const openPassiveResourceTab = (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
if (entry.kind === 'image') {
@@ -1051,6 +1125,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const frame = entry.panel.querySelector('iframe');
if (frame instanceof HTMLIFrameElement) {
frame.title = entry.title;
if (entry.kind === 'office') {
ensureOnlyofficeBridgeReadyListener();
frame.addEventListener('load', () => {
syncOpenEditorsSnapshot();
}, { once: true });
}
frame.src = href;
}
installPassiveResourceWatch(entry);
@@ -15,6 +15,18 @@ export const firstNonEmptyText = (...values) => {
return '';
};
export const normalizeMindmapDimension = (value, kind) => {
const raw = typeof value === 'number'
? value
: typeof value === 'string'
? Number(value.trim().replace(/px$/i, ''))
: NaN;
if (!Number.isFinite(raw)) return null;
const rounded = Math.round(raw);
const min = kind === 'height' ? 240 : 900;
return rounded >= min ? rounded : null;
};
// 过渡适配(TODO step-4):legacy→Tiptap inline marks 转换函数组。
// AST/block 迁移 complete 后,前端应直接消费 block document 中的
// tiptap 格式 marks(已由 Rust 侧 local_markdown_parser 输出),
@@ -125,19 +137,26 @@ export const legacyBlockToTiptap = (block, index = 0) => {
}
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
if (type === 'mindmap') {
const attrs = block?.attrs && typeof block.attrs === 'object' ? block.attrs : null;
const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
const mindmapId = firstNonEmptyText(
block?.props?.mindmapId,
block?.props?.mindmap_id,
block?.props?.sourcePath,
block?.props?.source_path,
attrs?.mindmapId,
attrs?.mindmap_id,
attrs?.sourcePath,
attrs?.source_path,
block?.mindmapId,
block?.mindmap_id,
data?.mindmapId,
data?.mindmap_id,
data?.id
);
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
const mindmapWidth = normalizeMindmapDimension(block?.props?.mindmapWidth ?? block?.props?.mindmap_width ?? attrs?.mindmapWidth ?? attrs?.mindmap_width ?? data?.mindmapWidth ?? data?.mindmap_width, 'width');
const mindmapHeight = normalizeMindmapDimension(block?.props?.mindmapHeight ?? block?.props?.mindmap_height ?? attrs?.mindmapHeight ?? attrs?.mindmap_height ?? data?.mindmapHeight ?? data?.mindmap_height, 'height');
return {
type: 'paragraph',
attrs: withTextAlign({
@@ -145,6 +164,8 @@ export const legacyBlockToTiptap = (block, index = 0) => {
mnoteBlockType: 'mindmap',
mindmapId,
rootNodeId,
...(mindmapWidth !== null ? { mindmapWidth } : {}),
...(mindmapHeight !== null ? { mindmapHeight } : {}),
}),
};
}
@@ -225,7 +246,9 @@ export const mindmapDomDescriptors = (root) => {
const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim()
? node.dataset.mnoteRootNodeId.trim()
: 'root';
return [{ mindmapId, rootNodeId }];
const mindmapWidth = normalizeMindmapDimension(node.dataset.mnoteMindmapWidth, 'width');
const mindmapHeight = normalizeMindmapDimension(node.dataset.mnoteMindmapHeight, 'height');
return [{ mindmapId, rootNodeId, mindmapWidth, mindmapHeight }];
});
};
@@ -246,6 +269,12 @@ export const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => {
if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) {
node.attrs.rootNodeId = descriptor.rootNodeId;
}
if (normalizeMindmapDimension(node.attrs.mindmapWidth, 'width') === null && descriptor.mindmapWidth !== null) {
node.attrs.mindmapWidth = descriptor.mindmapWidth;
}
if (normalizeMindmapDimension(node.attrs.mindmapHeight, 'height') === null && descriptor.mindmapHeight !== null) {
node.attrs.mindmapHeight = descriptor.mindmapHeight;
}
}
return tiptapDocument;
};
@@ -445,20 +474,17 @@ export const localizeTiptapAssetUrls = (node, context) => {
};
export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => {
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return 'page_aggregate.block_document';
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
return 'local_markdown.content';
}
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return 'page_aggregate.block_document';
if (body?.content) return 'compat.legacy_content';
return fallbackText ? 'degraded.fallback_text' : 'empty';
};
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
const projectionContext = { ...(context || {}), body };
if (pageBodyTiptapDocumentSource(body, fallbackText) === 'local_markdown.content') {
return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), projectionContext);
}
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext);
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), projectionContext);
@@ -533,10 +559,14 @@ export const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => {
);
const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version);
const mindmapWidth = normalizeMindmapDimension(attrs?.mindmapWidth ?? attrs?.mindmap_width ?? data?.mindmapWidth ?? data?.mindmap_width, 'width');
const mindmapHeight = normalizeMindmapDimension(attrs?.mindmapHeight ?? attrs?.mindmap_height ?? data?.mindmapHeight ?? data?.mindmap_height, 'height');
return {
mindmapId,
rootNodeId,
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
...(mindmapWidth !== null ? { mindmapWidth } : {}),
...(mindmapHeight !== null ? { mindmapHeight } : {}),
};
};
@@ -9,7 +9,12 @@ function fileTreeRowKind(row) {
function fileTreeRowDocumentId(row) {
if (!(row instanceof HTMLElement)) return '';
return String(row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '').trim();
return String(
row.getAttribute('data-document-id')
|| row.getAttribute('data-doc-id')
|| row.getAttribute('data-owner-document-id')
|| '',
).trim();
}
function fileTreeRowAssetId(row, deps) {
@@ -98,6 +103,11 @@ function readWorkspacePathFromRow(row, deps) {
var documentId = fileTreeRowDocumentId(row);
var rowId = String(row.getAttribute('data-row-id') || '').trim();
var rowKind = fileTreeRowKind(row);
if (!documentId && (rowKind === 'folder' || objectKind === 'index') && relativePath) {
documentId = 'local-dir:' + relativePath.replace(/^\/+/, '').split('/').map(function(segment) {
return encodeURIComponent(segment).replace(/%20/g, '~20');
}).join('~2F');
}
var sourceKind = String(row.getAttribute('data-source-kind') || currentSourceKind() || '').trim();
var rootUri = String(row.getAttribute('data-root-uri') || currentRootUri() || '').trim();
return {
@@ -410,7 +410,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
return;
}
var requestedOfficeMode = forceNewWindow || forceEditMode ? 'edit' : 'view';
var requestedOfficeMode = forceEditMode ? 'edit' : 'view';
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, requestedOfficeMode);
if (localOfficeUrl) {
if (!forceNewWindow && await openLocalResourceInActiveTab({
@@ -0,0 +1,107 @@
export function createSidebarPageAiMarkdownRuntime(context) {
const { escapeHtml } = context;
function textFromUnknown(value) {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' ');
if (typeof value !== 'object') return '';
var parts = [];
['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
var text = textFromUnknown(value[key]);
if (text) parts.push(text);
}
});
return parts.join(' ');
}
function renderPageAiMarkdownInline(text) {
var html = escapeHtml(String(text || ''));
var codeSpans = [];
html = html.replace(/`([^`\n]+)`/g, function(_, code) {
var key = '\u0000CODE' + codeSpans.length + '\u0000';
codeSpans.push('<code>' + code + '</code>');
return key;
});
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
codeSpans.forEach(function(value, index) {
html = html.replace('\u0000CODE' + index + '\u0000', value);
});
return html;
}
function renderPageAiMarkdown(content) {
var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n');
var blocks = [];
var index = 0;
function isBlockBoundary(line) {
return !line.trim() ||
/^```/.test(line.trim()) ||
/^#{1,6}\s+/.test(line) ||
/^\s*[-*]\s+/.test(line) ||
/^\s*\d+[.)]\s+/.test(line);
}
while (index < lines.length) {
var line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
if (/^```/.test(line.trim())) {
index += 1;
var codeLines = [];
while (index < lines.length && !/^```/.test(lines[index].trim())) {
codeLines.push(lines[index]);
index += 1;
}
if (index < lines.length) index += 1;
blocks.push('<pre><code>' + escapeHtml(codeLines.join('\n')) + '</code></pre>');
continue;
}
var heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
var level = Math.min(6, heading[1].length);
blocks.push('<h' + level + '>' + renderPageAiMarkdownInline(heading[2]) + '</h' + level + '>');
index += 1;
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
var unordered = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
unordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*[-*]\s+/, '')) + '</li>');
index += 1;
}
blocks.push('<ul>' + unordered.join('') + '</ul>');
continue;
}
if (/^\s*\d+[.)]\s+/.test(line)) {
var ordered = [];
while (index < lines.length && /^\s*\d+[.)]\s+/.test(lines[index])) {
ordered.push('<li>' + renderPageAiMarkdownInline(lines[index].replace(/^\s*\d+[.)]\s+/, '')) + '</li>');
index += 1;
}
blocks.push('<ol>' + ordered.join('') + '</ol>');
continue;
}
var paragraph = [];
while (index < lines.length && !isBlockBoundary(lines[index])) {
paragraph.push(renderPageAiMarkdownInline(lines[index]));
index += 1;
}
if (paragraph.length) {
blocks.push('<p>' + paragraph.join('<br />') + '</p>');
} else {
index += 1;
}
}
return blocks.join('') || escapeHtml(String(content || ''));
}
return {
textFromUnknown,
renderPageAiMarkdown,
renderPageAiMarkdownInline
};
}
@@ -0,0 +1,132 @@
export function createSidebarPageAiPermissionRuntime(context) {
const {
documentRef,
pageAiPreviewValue,
pageUiState,
renderPageAiConversation,
} = context;
const doc = documentRef || document;
function pageAiPermissionMessage(payload, eventType) {
payload = payload && typeof payload === 'object' ? payload : {};
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
var toolName = String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
var decision = String(payload.decision || payload.result || '').trim();
if (!decision && eventType === 'permission.denied') decision = 'denied';
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
return {
role: 'tool',
kind: 'permission',
permissionId: permissionId,
toolName: toolName,
argsSummary: pageAiPreviewValue(args),
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
resolved: decision === 'denied' || decision === 'allowed',
decision: decision
};
}
function pageAiApplyPermissionEvent(eventName, payloadText) {
var payload = null;
try {
payload = JSON.parse(payloadText || 'null');
} catch (_) {
payload = {};
}
var message = pageAiPermissionMessage(payload, eventName);
var existing = pageUiState.pageAiMessages.find(function(item) {
return item.kind === 'permission' && item.permissionId === message.permissionId;
});
if (existing) {
Object.assign(existing, message);
} else {
pageUiState.pageAiMessages.push(message);
}
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
return item.permissionId !== message.permissionId;
}).concat([message]).slice(-20);
if (!message.resolved) {
pageAiShowPermissionDialog(message);
} else {
pageAiHidePermissionDialog();
}
}
function pageAiResolvePermission(permissionId, decision) {
permissionId = String(permissionId || '').trim();
if (!permissionId) return;
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,只能本地更新');
}
pageUiState.pageAiMessages.forEach(function(item) {
if (item.kind === 'permission' && item.permissionId === permissionId) {
item.resolved = true;
item.decision = decision;
item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求';
}
});
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
renderPageAiConversation();
}
function pageAiHidePermissionDialog() {
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
}
function pageAiShowPermissionDialog(message) {
if (!message || message.kind !== 'permission') return;
if (message.resolved) {
pageAiHidePermissionDialog();
return;
}
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (!(dialog instanceof HTMLElement)) {
dialog = doc.createElement('div');
dialog.className = 'wolai-page-ai-permission-dialog';
dialog.setAttribute('data-page-ai-permission-dialog', 'true');
dialog.innerHTML = '' +
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
'<div class="wolai-page-ai-tool-meta" data-page-ai-permission-args></div>' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-dialog-action>允许</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-dialog-action>拒绝</button>' +
'</div>' +
'</div>';
doc.body.appendChild(dialog);
}
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
if (tool instanceof HTMLElement) tool.textContent = message.toolName || 'session/request_permission';
var args = dialog.querySelector('[data-page-ai-permission-args]');
if (args instanceof HTMLElement) args.textContent = message.argsSummary || message.content || '';
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
if (button instanceof HTMLButtonElement) {
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
button.disabled = Boolean(message.resolved);
}
});
dialog.hidden = false;
}
return {
pageAiPermissionMessage,
pageAiApplyPermissionEvent,
pageAiResolvePermission,
pageAiHidePermissionDialog,
pageAiShowPermissionDialog
};
}
@@ -0,0 +1,220 @@
export function createSidebarPageAiProfileRuntime(context) {
const {
chatOnlyProfileRegistry,
documentRef,
pageAiAgentRecord,
pageAiCurrentAgentId,
pageAiNormalizeAgentId,
pageUiState,
} = context;
const PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY = Array.isArray(chatOnlyProfileRegistry) ? chatOnlyProfileRegistry : [];
function pageAiProviderLabel(provider) {
if (provider === 'codex') return 'Codex';
if (provider === 'claudecode') return 'ClaudeCode';
return 'Hermes';
}
function pageAiNormalizeArray(value) {
return Array.isArray(value) ? value : [];
}
function pageAiDefaultAcpRuntimes() {
return [
{
name: 'reasonix',
title: 'ACP · Reasonix',
description: '通过 ACP 协议直连 ReasonixDeepSeek 缓存优先)',
model: 'deepseek-chat',
preset: 'auto'
},
{
name: 'hermes',
title: 'ACP · Hermes',
description: '通过 ACP 协议直连 Hermes agent runtime'
}
];
}
function pageAiNormalizeAcpRuntimes(runtimes) {
var byName = {};
pageAiDefaultAcpRuntimes().forEach(function(runtime) {
byName[runtime.name] = Object.assign({}, runtime);
});
pageAiNormalizeArray(runtimes).forEach(function(runtime) {
var name = String(runtime && runtime.name || '').trim();
if (name !== 'reasonix' && name !== 'hermes') return;
byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name });
});
return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean);
}
function pageAiUnwrapUpstream(payload) {
if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream;
return payload || null;
}
function pageAiProfileValue(profile) {
if (profile && typeof profile === 'object') {
return String(profile.profileId || profile.name || profile.profile || profile.id || '').trim();
}
return String(profile || '').trim();
}
function pageAiCurrentProfile() {
var active = String(pageUiState.pageAiActiveProfileName || '').trim();
if (active) return active;
var selected = pageUiState.pageAiProfiles.find(function(profile) {
return profile && profile.active;
});
return pageAiProfileValue(selected) || 'mnoteai';
}
function pageAiRunProfile() {
if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return 'reasonix';
if (pageAiCurrentAgentId() === 'chat_only') return pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile());
return pageAiCurrentProfile();
}
function pageAiMnoteToolModel() {
var doc = documentRef || document;
return String(doc.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash';
}
function pageAiCurrentProfileRecord() {
var active = pageAiCurrentProfile();
return pageUiState.pageAiProfiles.find(function(profile) {
return pageAiProfileValue(profile) === active;
}) || null;
}
function pageAiChatOnlyProfileSpec(profile) {
var profileId = pageAiProfileValue(profile);
var baseProfile = String(profile && profile.baseProfile || '').trim();
var isolatedProfile = String(profile && profile.isolatedProfile || '').trim();
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY.find(function(spec) {
return spec.profileId === profileId || spec.baseProfile === baseProfile || isolatedProfile.indexOf(spec.baseProfile) >= 0;
}) || null;
}
function pageAiDefaultChatOnlyProfileSpec() {
return PAGE_AI_CHAT_ONLY_PROFILE_REGISTRY[0] || { profileId: 'shared_deepseek_chat', baseProfile: 'deepseek-chat', label: 'DeepSeek' };
}
function pageAiNormalizeChatOnlyProfileId(profileId) {
var profile = pageAiProfileRecordById(profileId) || { profileId: String(profileId || '').trim(), name: String(profileId || '').trim() };
var spec = pageAiChatOnlyProfileSpec(profile);
return (spec || pageAiDefaultChatOnlyProfileSpec()).profileId;
}
function pageAiProfileDisplayLabel(profile, fallback) {
var alias = String(profile && profile.alias || '').trim();
var displayName = String(profile && (profile.displayName || profile.label || '') || '').trim();
var name = pageAiProfileValue(profile);
return alias || displayName || fallback || name || 'default';
}
function pageAiProfileRecordById(profileId) {
var normalized = String(profileId || '').trim();
if (!normalized) return null;
return pageAiNormalizeArray(pageUiState.pageAiProfiles).find(function(profile) {
return pageAiProfileValue(profile) === normalized;
}) || null;
}
function pageAiSessionAgentFilterValue(session) {
var agentId = pageAiNormalizeAgentId(session && session.agentId);
if (agentId === 'reasonix') return 'reasonix';
var profileId = String(session && (session.profileId || session.profile) || '').trim();
if (agentId === 'chat_only') profileId = pageAiNormalizeChatOnlyProfileId(profileId);
return agentId + ':' + profileId;
}
function pageAiSessionAgentLabel(session) {
var agentId = pageAiNormalizeAgentId(session && session.agentId);
if (agentId === 'reasonix') return 'Reasonix';
var profileId = String(session && (session.profileId || session.profile) || '').trim();
var profile = pageAiProfileRecordById(profileId) || { profileId: profileId, name: profileId };
var chatOnlySpec = pageAiChatOnlyProfileSpec(profile);
if (agentId === 'chat_only') {
return 'ChatOnly / ' + (chatOnlySpec || pageAiDefaultChatOnlyProfileSpec()).label;
}
if (agentId === 'hermes') return 'Hermes / ' + pageAiProfileDisplayLabel(profile, profileId);
return pageAiAgentRecord(agentId).label;
}
function pageAiSessionPreviewText(session) {
var preview = Array.isArray(session && session.messages) && session.messages.length
? String(session.messages.slice(-1)[0].content || '')
: String(session && (session.snippet || session.preview || '暂无消息') || '暂无消息');
preview = preview.replace(/\s+/g, ' ').trim();
var limit = 96;
return preview.length > limit ? preview.slice(0, limit) + '…' : preview;
}
function pageAiSessionAgentFilterOptions(rows) {
var byValue = { all: '全部 agent' };
pageAiNormalizeArray(rows).forEach(function(session) {
var value = pageAiSessionAgentFilterValue(session);
byValue[value] = pageAiSessionAgentLabel(session);
});
return Object.keys(byValue).map(function(value) {
return { value: value, label: byValue[value] };
});
}
function pageAiFilteredHistoryRows(rows) {
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
var normalized = pageAiNormalizeArray(rows);
if (filterValue === 'all') return normalized;
return normalized.filter(function(session) {
return pageAiSessionAgentFilterValue(session) === filterValue;
});
}
function pageAiTimestamp(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
var parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return Date.now();
}
function pageAiUsageSummary(usage) {
if (!usage || typeof usage !== 'object') return '';
var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens;
var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
var parts = [];
if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used));
if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size));
if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output));
return parts.join(' ') || '';
}
return {
pageAiProviderLabel,
pageAiNormalizeArray,
pageAiDefaultAcpRuntimes,
pageAiNormalizeAcpRuntimes,
pageAiUnwrapUpstream,
pageAiProfileValue,
pageAiCurrentProfile,
pageAiRunProfile,
pageAiMnoteToolModel,
pageAiCurrentProfileRecord,
pageAiChatOnlyProfileSpec,
pageAiDefaultChatOnlyProfileSpec,
pageAiNormalizeChatOnlyProfileId,
pageAiProfileDisplayLabel,
pageAiProfileRecordById,
pageAiSessionAgentFilterValue,
pageAiSessionAgentLabel,
pageAiSessionPreviewText,
pageAiSessionAgentFilterOptions,
pageAiFilteredHistoryRows,
pageAiTimestamp,
pageAiUsageSummary
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,613 @@
export function createSidebarPageAiSessionRuntime(context) {
const {
currentDocumentId,
currentRootUri,
currentSourceKind,
documentRef,
pageAiApplyRuntimeState,
pageAiCurrentAgentId,
pageAiCurrentProfile,
pageAiErrorMessage,
pageAiNormalizeAgentId,
pageAiNormalizeArray,
pageAiNormalizeChatOnlyProfileId,
pageAiPermissionMessage,
pageAiPreviewValue,
pageAiRunProfile,
pageAiSetActiveProfile,
pageAiTimestamp,
pageUiState,
renderPageAiControls,
renderPageAiConversation,
resolveWorkspaceId,
sessionStorageVersion,
windowRef,
} = context;
const doc = documentRef || document;
const win = windowRef || window;
function pageAiStorageKey() {
return 'hermes_page_ai_session:' + currentDocumentId();
}
function pageAiBackendSessionQuery(extra) {
var params = new URLSearchParams();
params.set('source', 'acp');
params.set('workspaceId', resolveWorkspaceId(doc.body));
params.set('documentId', currentDocumentId());
params.set('profile', pageAiRunProfile());
params.set('sourceKind', currentSourceKind());
if (currentRootUri()) params.set('rootUri', currentRootUri());
Object.keys(extra || {}).forEach(function(key) {
var value = extra[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
params.set(key, String(value));
}
});
return params.toString();
}
function pageAiNewSession(title) {
var now = Date.now();
var agentId = pageAiCurrentAgentId();
var profile = agentId === 'chat_only' ? pageAiNormalizeChatOnlyProfileId(pageAiCurrentProfile()) : pageAiCurrentProfile();
return {
id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8),
title: title || '新会话',
agentId: agentId,
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? profile : '',
profile: profile,
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
createdAt: now,
updatedAt: now,
source: 'local',
usage: null,
status: 'idle',
messages: []
};
}
function pageAiNormalizeSessions(sessions) {
return (Array.isArray(sessions) ? sessions : [])
.slice(0, 20)
.map(function(session) {
var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
var persistence = String(session && session.persistence || '').trim();
var agentId = pageAiNormalizeAgentId(session && (session.agentId || session.agent_id));
var profileId = String(session && (session.profileId || session.profile_id || '') || '').trim();
var profile = String(session && session.profile || pageAiCurrentProfile()).trim() || 'default';
if (!profileId && agentId !== 'reasonix') profileId = profile;
if (agentId === 'chat_only') {
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
profile = profileId;
}
return {
id: String(session && session.id || '').trim() || pageAiNewSession().id,
title: String(session && session.title || '').trim() || '新会话',
agentId: agentId,
profileId: profileId,
profile: profile,
acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(),
createdAt: pageAiTimestamp(session && session.createdAt),
updatedAt: pageAiTimestamp(session && session.updatedAt),
source: String(session && session.source || 'local').trim() || 'local',
persistence: persistence,
sessionStorage: sessionStorage,
permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(),
shareId: String(session && (session.shareId || session.share_id) || '').trim(),
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
runId: String(session && (session.runId || session.run_id) || '').trim(),
status: String(session && session.status || '').trim(),
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
preview: String(session && session.preview || '').trim(),
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
};
})
.sort(function(a, b) {
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
});
}
function pageAiNormalizeBackendSessionRow(row) {
if (!row || typeof row !== 'object') return null;
var payload = row.payload && typeof row.payload === 'object' ? row.payload : {};
var sessionId = String(row.sessionId || row.session_id || '').trim();
if (!sessionId) return null;
var title = String(row.title || payload.title || payload.message || '').trim();
if (title.length > 28) title = title.slice(0, 28) + '…';
var persistence = String(row.persistence || payload.persistence || '').trim();
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
var agentId = pageAiNormalizeAgentId(row.agentId || row.agent_id || payload.agentId || payload.agent_id);
var profileId = String(row.profileId || row.profile_id || payload.profileId || payload.profile_id || '').trim();
var profile = String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default';
if (!profileId && agentId !== 'reasonix') profileId = profile;
if (agentId === 'chat_only') {
profileId = pageAiNormalizeChatOnlyProfileId(profileId || profile);
profile = profileId;
}
if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud';
if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private';
return {
id: sessionId,
title: title || '当前页问答',
agentId: agentId,
profileId: profileId,
profile: profile,
acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(),
createdAt: pageAiTimestamp(row.createdAt || row.created_at),
updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at),
source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp',
persistence: persistence,
sessionStorage: sessionStorage,
permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(),
shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(),
acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(),
runId: String(row.runId || row.run_id || '').trim(),
status: String(row.status || (row.runtime && row.runtime.status) || '').trim(),
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
preview: String(payload.message || row.snippet || '').trim(),
messages: []
};
}
function pageAiMergeSessions(localSessions, backendSessions) {
var byId = {};
pageAiNormalizeSessions(localSessions).forEach(function(session) {
byId[session.id] = session;
});
pageAiNormalizeSessions(backendSessions).forEach(function(session) {
var existing = byId[session.id];
byId[session.id] = Object.assign({}, existing || {}, session, {
messages: session.messages.length ? session.messages : (existing && existing.messages || [])
});
});
return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; }));
}
function pageAiSessionStorageLabel(session) {
var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim();
var persistence = String(session && session.persistence || '').trim();
if (storage === 'local_shared') return '共享会话';
if (storage === 'local_private') return '本地私有';
if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话';
if (persistence === 'local_ai_session_jsonl') return '本地私有';
return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有';
}
function pageAiLoadSessions() {
if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return;
try {
var raw = win.localStorage.getItem(pageAiStorageKey());
var parsed = raw ? JSON.parse(raw) : null;
var activeId = String(parsed && parsed.activeSessionId || '').trim();
var activeProfile = String(parsed && parsed.activeProfileName || '').trim();
var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim();
var sessions = pageAiNormalizeSessions(parsed && parsed.sessions);
var storageVersion = Number(parsed && parsed.version || 0);
if (storageVersion >= sessionStorageVersion && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime;
if (activeProfile) pageAiSetActiveProfile(activeProfile);
if (sessions.length) {
pageUiState.pageAiSessions = sessions;
var activeSession = sessions.find(function(session) { return session.id === activeId; }) || sessions[0];
pageUiState.pageAiActiveSessionId = activeSession.id;
if (activeSession.profile) pageAiSetActiveProfile(activeSession.profile);
if (activeSession.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(activeSession.agentId);
if (!pageUiState.pageAiAcpRuntime && activeSession.acpRuntime) pageUiState.pageAiAcpRuntime = activeSession.acpRuntime;
pageUiState.pageAiMessages = Array.isArray(activeSession.messages) ? activeSession.messages.slice() : [];
return;
}
if (activeId) {
pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')];
pageUiState.pageAiSessions[0].id = activeId;
pageUiState.pageAiActiveSessionId = activeId;
pageUiState.pageAiMessages = [];
return;
}
} catch (_) {}
var fresh = pageAiNewSession();
pageUiState.pageAiSessions = [fresh];
pageUiState.pageAiActiveSessionId = fresh.id;
pageUiState.pageAiMessages = [];
}
async function pageAiLoadBackendSessions() {
var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_list_failed_' + response.status));
}
var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean);
if (!backendSessions.length) return [];
pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions);
if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) {
pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id;
}
var active = pageAiCurrentSession();
if (active) {
if (active.profile) pageAiSetActiveProfile(active.profile);
if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime;
pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages;
}
pageUiState.pageAiSessionError = '';
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
return backendSessions;
}
function pageAiMessageFromRuntimeEvent(event) {
if (!event || typeof event !== 'object') return null;
var eventType = String(event.eventType || event.event_type || event.event || '').trim();
var payload = event.payload && typeof event.payload === 'object' ? event.payload : event;
if (eventType === 'message.delta') {
var delta = String(payload.delta || payload.text || payload.output_text || '').trim();
return delta ? { role: 'assistant', content: delta } : null;
}
if (eventType === 'thought.delta') {
var thought = String(payload.delta || payload.text || '').trim();
return thought ? { role: 'assistant', kind: 'thought', content: thought } : null;
}
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,
toolName: toolName,
toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''),
toolKind: String(payload.kind || ''),
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 || '')
};
}
if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') {
return pageAiPermissionMessage(payload, eventType);
}
if (eventType === 'run.completed') {
var output = String(payload.output || payload.text || '').trim();
return output ? { role: 'assistant', content: output } : null;
}
return null;
}
function pageAiApplyBackendSessionDetail(payload) {
var sessionPayload = payload && payload.session ? payload.session : {};
var runs = pageAiNormalizeArray(sessionPayload.runs);
var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null;
var events = pageAiNormalizeArray(payload && payload.events);
var storedMessages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) {
return {
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
content: String(message.content || '')
};
}).filter(function(message) { return message.content; });
var eventsByRunId = {};
events.forEach(function(event) {
var runId = String(event && (event.runId || event.run_id) || '').trim();
if (!runId) return;
if (!eventsByRunId[runId]) eventsByRunId[runId] = [];
eventsByRunId[runId].push(event);
});
var messages = [];
if (runs.length) {
runs.slice().reverse().forEach(function(run) {
var runPayload = run && run.payload && typeof run.payload === 'object' ? run.payload : {};
var userMessage = String(runPayload.message || runPayload.input || '').trim();
if (userMessage) messages.push({ role: 'user', content: userMessage });
var runId = String(run && (run.runId || run.run_id) || '').trim();
pageAiNormalizeArray(eventsByRunId[runId]).forEach(function(event) {
var message = pageAiMessageFromRuntimeEvent(event);
if (message) messages.push(message);
});
});
}
if (!messages.length) messages = storedMessages;
var acpSessionId = '';
events.forEach(function(event) {
var eventType = String(event && (event.eventType || event.event_type || event.event) || '').trim();
var payload = event && event.payload && typeof event.payload === 'object' ? event.payload : event;
if (eventType === 'session.info.updated') {
var nextAcpSessionId = String(payload && (payload.acpSessionId || payload.acp_session_id) || '').trim();
if (nextAcpSessionId) acpSessionId = nextAcpSessionId;
}
});
var current = pageAiCurrentSession();
if (latest && current) {
Object.assign(current, latest);
}
if (current) {
current.messages = messages.slice(-300);
current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now());
if (acpSessionId) current.acpSessionId = acpSessionId;
if (latest && latest.usage) current.usage = latest.usage;
}
pageAiApplyRuntimeState(payload && payload.runtime);
pageUiState.pageAiMessages = messages.slice(-300);
pageAiPersistSessions();
}
async function pageAiLoadBackendSessionDetail(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_detail_failed_' + response.status));
}
pageAiApplyBackendSessionDetail(payload);
renderPageAiConversation();
renderPageAiControls();
return payload;
}
async function pageAiSearchBackendSessions(query) {
var q = String(query || '').trim();
pageUiState.pageAiSessionSearchQuery = q;
if (!q) {
pageUiState.pageAiSessionSearchResults = [];
renderPageAiConversation();
return [];
}
var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_search_failed_' + response.status));
}
pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) {
var normalized = pageAiNormalizeBackendSessionRow(row) || {};
normalized.snippet = String(row && row.snippet || normalized.preview || '').trim();
return normalized;
}).filter(function(row) { return row.id; });
renderPageAiConversation();
return pageUiState.pageAiSessionSearchResults;
}
function pageAiPersistSessions() {
doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes');
doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey());
try {
pageAiSyncCurrentSessionMessages();
win.localStorage.setItem(pageAiStorageKey(), JSON.stringify({
version: sessionStorageVersion,
activeSessionId: pageUiState.pageAiActiveSessionId,
activeProfileName: pageAiCurrentProfile(),
activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions)
}));
} catch (_) {}
}
async function pageAiEnsureHermesSession(forceCreate) {
pageAiLoadSessions();
var current = pageAiCurrentSession();
var runProfile = pageAiRunProfile();
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === runProfile) return current;
var response = await fetch('/api/hermes/client/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(doc.body),
documentId: currentDocumentId(),
sourceKind: currentSourceKind(),
rootUri: currentRootUri(),
traceId: 'page-ai-' + Date.now().toString(36),
profile: runProfile,
agentId: pageAiCurrentAgentId(),
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
title: current && current.title ? current.title : '当前页问答'
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status));
}
var session = {
id: String(payload.sessionId || '').trim(),
title: String(payload.title || '当前页问答'),
agentId: pageAiCurrentAgentId(),
profileId: pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '',
profile: String(payload.profile || runProfile).trim() || 'default',
acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix',
persistence: String(payload.persistence || '').trim(),
sessionStorage: String(payload.sessionStorage || '').trim(),
permissionLevel: String(payload.permissionLevel || '').trim(),
shareId: String(payload.shareId || '').trim(),
acpSessionId: String(payload.acpSessionId || payload.acp_session_id || '').trim(),
createdAt: Date.now(),
updatedAt: Date.now(),
messages: pageUiState.pageAiMessages.slice()
};
pageUiState.pageAiSessions = pageAiNormalizeSessions([session]);
pageUiState.pageAiActiveSessionId = session.id;
pageAiPersistSessions();
renderPageAiConversation();
return session;
}
async function pageAiRestoreHermesSession() {
var current = pageAiCurrentSession();
if (!current || !String(current.id || '').startsWith('mnote_')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
if (!response.ok) return;
var payload = await response.json().catch(function(){ return null; });
if (payload && payload.persistence === 'convex_acp_runtime_store') {
pageAiApplyBackendSessionDetail(payload);
renderPageAiConversation();
renderPageAiControls();
return;
}
var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload);
var messages = session && Array.isArray(session.messages) ? session.messages : [];
pageAiApplyRuntimeState(payload && payload.runtime);
if (session && (session.profile || session.profileName)) {
current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile());
}
if (!messages.length) {
renderPageAiControls();
return;
}
pageUiState.pageAiMessages = messages.slice(-300).map(function(message) {
return {
role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'),
content: String(message.content || '')
};
});
current.messages = pageUiState.pageAiMessages.slice();
current.updatedAt = Date.now();
renderPageAiConversation();
renderPageAiControls();
}
function pageAiCurrentSession() {
return pageUiState.pageAiSessions.find(function(session) {
return session.id === pageUiState.pageAiActiveSessionId;
}) || null;
}
function pageAiSyncCurrentSessionMessages() {
var session = pageAiCurrentSession();
if (!session) return;
var runProfile = pageAiRunProfile();
session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : [];
session.agentId = pageAiCurrentAgentId();
session.profileId = pageUiState.pageAiAcpRuntime === 'hermes' ? runProfile : '';
session.profile = runProfile;
session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix';
session.updatedAt = Date.now();
}
function pageAiSetActiveSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
pageUiState.pageAiActiveSessionId = session.id;
if (session.agentId) pageUiState.pageAiAgentId = pageAiNormalizeAgentId(session.agentId);
if (session.acpRuntime) pageUiState.pageAiAcpRuntime = session.acpRuntime;
if (session.profileId || session.profile) pageAiSetActiveProfile(session.profileId || session.profile);
pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) {
void pageAiLoadBackendSessionDetail(session.id).catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
}
function pageAiStartNewSession() {
var session = pageAiNewSession();
pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions));
pageUiState.pageAiActiveSessionId = session.id;
pageUiState.pageAiMessages = [];
pageUiState.pageAiPage = 'chat';
pageAiPersistSessions();
renderPageAiControls();
renderPageAiConversation();
}
async function pageAiRenameBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
var title = win.prompt('重命名 AI 会话', session.title || '当前页问答');
if (title === null) return;
title = String(title || '').trim();
if (!title) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
body: JSON.stringify({ title: title })
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status));
}
session.title = String((payload.result && payload.result.title) || title);
session.updatedAt = Date.now();
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiDeleteBackendSession(sessionId) {
var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; });
if (!session) return;
if (!win.confirm('确定删除 AI 会话“' + (session.title || sessionId) + '”吗?')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), {
method: 'DELETE',
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_delete_failed_' + response.status));
}
pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; });
if (pageUiState.pageAiActiveSessionId === sessionId) {
var next = pageUiState.pageAiSessions[0] || pageAiNewSession();
if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next];
pageUiState.pageAiActiveSessionId = next.id;
pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : [];
}
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
}
async function pageAiResumeBackendSession(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
pageAiSetActiveSession(sessionId);
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'session_resume_failed_' + response.status));
}
pageAiApplyBackendSessionDetail(payload);
pageUiState.pageAiPage = 'chat';
renderPageAiConversation();
renderPageAiControls();
}
return {
pageAiStorageKey,
pageAiBackendSessionQuery,
pageAiNewSession,
pageAiNormalizeSessions,
pageAiNormalizeBackendSessionRow,
pageAiMergeSessions,
pageAiSessionStorageLabel,
pageAiLoadSessions,
pageAiLoadBackendSessions,
pageAiMessageFromRuntimeEvent,
pageAiApplyBackendSessionDetail,
pageAiLoadBackendSessionDetail,
pageAiSearchBackendSessions,
pageAiPersistSessions,
pageAiEnsureHermesSession,
pageAiRestoreHermesSession,
pageAiCurrentSession,
pageAiSyncCurrentSessionMessages,
pageAiSetActiveSession,
pageAiStartNewSession,
pageAiRenameBackendSession,
pageAiDeleteBackendSession,
pageAiResumeBackendSession
};
}
@@ -0,0 +1,285 @@
export function createSidebarPageAiSkillRuntime(context) {
const {
pageAiCurrentAgentId,
pageAiCurrentProfile,
pageAiLoadSkills,
pageAiNormalizeArray,
pageAiPersistAiPreference,
pageAiPersistRawAiPreference,
pageAiProfileValue,
pageUiState,
renderPageAiControls,
} = context;
function pageAiSkillSourceOptions() {
var options = [
{ value: 'mnote', group: 'mnote', label: 'mnote', profile: '' },
{ value: 'reasonix', group: 'reasonix', label: 'reasonix', profile: '' }
];
pageAiNormalizeArray(pageUiState.pageAiProfiles).forEach(function(profile) {
var profileId = pageAiProfileValue(profile);
if (!profileId) return;
var label = profile.kind === 'shared'
? (profile.baseProfile === 'lite' || profileId === 'shared_lite' ? 'hermes_lite' : 'Hermes_shared')
: 'Hermes_user';
var alias = String(profile.alias || '').trim();
options.push({
value: 'hermes:' + profileId,
group: 'hermes',
profile: profileId,
label: alias && alias !== label ? label + ' · ' + alias : label,
readonly: profile.readonly === true
});
});
return options;
}
function pageAiDefaultSkillSource() {
if (pageAiCurrentAgentId() === 'hermes') return 'hermes:' + pageAiCurrentProfile();
if (pageAiCurrentAgentId() === 'reasonix') return 'reasonix';
return 'mnote';
}
function pageAiNormalizeSkillSource(source) {
var value = String(source || '').trim();
var options = pageAiSkillSourceOptions();
if (options.some(function(option) { return option.value === value; })) return value;
if (value === 'hermes') return 'hermes:' + pageAiCurrentProfile();
if (value === 'mnote_builtin') return 'mnote';
var fallback = pageAiDefaultSkillSource();
if (options.some(function(option) { return option.value === fallback; })) return fallback;
return options.length ? options[0].value : 'mnote';
}
function pageAiCurrentSkillSource() {
var normalized = pageAiNormalizeSkillSource(pageUiState.pageAiActiveSkillSource);
pageUiState.pageAiActiveSkillSource = normalized;
return normalized;
}
function pageAiSetSkillSource(source) {
var normalized = pageAiNormalizeSkillSource(source);
pageUiState.pageAiActiveSkillSource = normalized;
pageAiPersistAiPreference('skills.active_source', normalized);
pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillError = '';
void pageAiLoadSkills();
renderPageAiControls();
}
function pageAiSkillSourceParts(source) {
var normalized = pageAiNormalizeSkillSource(source);
if (normalized.indexOf('hermes:') === 0) {
return { group: 'hermes', profile: normalized.slice('hermes:'.length), source: normalized };
}
if (normalized === 'reasonix') return { group: 'reasonix', profile: '', source: normalized };
return { group: 'mnote', profile: '', source: 'mnote' };
}
function pageAiCurrentSkillSourceLabel() {
var source = pageAiCurrentSkillSource();
var option = pageAiSkillSourceOptions().find(function(item) { return item.value === source; });
return option ? option.label : source;
}
function pageAiSkillOriginLabel(skill) {
var origin = String(skill && skill.origin || '').trim();
if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成';
if (origin === 'installed') return '安装';
if (origin === 'builtin') return '内置';
if (origin === 'copied') return '本地';
var source = String(skill && skill.source || '').trim();
if (source === 'hub') return '安装';
if (source === 'builtin') return '内置';
if (source === 'reasonix') {
if (origin === 'project') return 'Reasonix 项目';
if (origin === 'global') return 'Reasonix 全局';
return 'Reasonix';
}
return '本地';
}
function pageAiSkillPreferenceKey(group, profile) {
var groupName = String(group || '').trim();
if (groupName === 'mnote') return 'ai.agent.mnote_builtin.skills.enabled';
if (groupName === 'reasonix') return 'ai.agent.reasonix.skills.enabled';
if (groupName === 'hermes') {
var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default';
return 'ai.agent.hermes.profile.' + nextProfile + '.skills.enabled';
}
return '';
}
function pageAiSkillPreferenceTable(group, profile) {
var key = pageAiSkillPreferenceKey(group, profile);
var preferences = pageUiState.pageAiSkillPreferences || {};
var value = preferences[key];
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
return {};
}
function pageAiHermesHideBuiltinPreferenceKey(profile) {
return 'ai.agent.hermes.skills.hide_builtin';
}
function pageAiHideHermesBuiltinSkills(profile) {
var preferences = pageUiState.pageAiSkillPreferences || {};
return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true;
}
function pageAiReasonixMemoryEnabled() {
var preferences = pageUiState.pageAiSkillPreferences || {};
return preferences['ai.agent.reasonix.memory_enabled'] === true;
}
function pageAiSetReasonixMemoryEnabled(enabled) {
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
preferences['ai.agent.reasonix.memory_enabled'] = Boolean(enabled);
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference('ai.agent.reasonix.memory_enabled', Boolean(enabled));
renderPageAiControls();
}
function pageAiSetHideHermesBuiltinSkills(enabled, profile) {
var key = pageAiHermesHideBuiltinPreferenceKey(profile);
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
preferences[key] = Boolean(enabled);
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference(key, Boolean(enabled));
renderPageAiControls();
}
function pageAiSkillIsBuiltin(skill) {
var origin = String(skill && skill.origin || '').trim();
var source = String(skill && skill.source || '').trim();
return origin === 'builtin' || source === 'builtin';
}
function pageAiToggleableSkillEntries(group, profile) {
var catalogKey = group === 'hermes' && profile ? 'hermes:' + profile : group;
var catalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[catalogKey]
? pageUiState.pageAiSkillCatalogs[catalogKey]
: pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[group]
? pageUiState.pageAiSkillCatalogs[group]
: { categories: [], archived: [] };
var overrides = pageAiSkillPreferenceTable(group, profile);
var result = [];
pageAiNormalizeArray(catalog.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
var id = String(skill.id || skill.name || '').trim();
if (!id) return;
result.push({
group: group,
profile: profile || '',
category: category.name,
id: id,
name: skill.name || id,
title: skill.title || skill.name || id,
description: skill.description || '',
enabled: group === 'hermes' ? skill.enabled !== false : (skill.enabled !== false && overrides[id] !== false),
toggleable: skill.toggleable !== false,
source: skill.source || group,
origin: skill.origin || '',
readOnly: skill.readOnly === true || skill.readonly === true || skill.configurable === false,
builtin: pageAiSkillIsBuiltin(skill),
configurable: skill.configurable !== false,
configScope: skill.configScope || '',
skillKind: skill.skillKind || '',
profileId: skill.profileId || '',
toolNames: skill.toolNames || [],
requiresContextRefs: skill.requiresContextRefs || []
});
});
});
pageAiNormalizeArray(catalog.archived).forEach(function(skill) {
var id = String(skill.id || skill.name || '').trim();
if (!id) return;
result.push({
group: group,
profile: profile || '',
category: 'archived',
id: id,
name: skill.name || id,
title: skill.title || skill.name || id,
description: skill.description || '',
enabled: group === 'hermes' ? skill.enabled !== false : (skill.enabled !== false && overrides[id] !== false),
toggleable: skill.toggleable !== false,
source: skill.source || group,
origin: skill.origin || '',
readOnly: skill.readOnly === true || skill.readonly === true || skill.configurable === false,
builtin: pageAiSkillIsBuiltin(skill),
configurable: skill.configurable !== false,
configScope: skill.configScope || '',
skillKind: skill.skillKind || '',
profileId: skill.profileId || '',
toolNames: skill.toolNames || [],
requiresContextRefs: skill.requiresContextRefs || []
});
});
return result;
}
function pageAiAllSkillEntries() {
return []
.concat(pageAiToggleableSkillEntries('mnote', ''))
.concat(pageAiToggleableSkillEntries('reasonix', ''))
.concat(pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()));
}
function pageAiSkillGroupCollapsed(group) {
var table = pageUiState.pageAiCollapsedSkillGroups || {};
return table[String(group || '').trim()] === true;
}
function pageAiToggleSkillGroup(group) {
var normalized = String(group || '').trim();
if (!normalized) return;
var table = Object.assign({}, pageUiState.pageAiCollapsedSkillGroups || {});
table[normalized] = table[normalized] !== true;
pageUiState.pageAiCollapsedSkillGroups = table;
pageAiPersistRawAiPreference('ai.common.skills.groups.collapsed', table);
renderPageAiControls();
}
function pageAiSetSkillPreference(group, skillId, enabled, profile) {
var key = pageAiSkillPreferenceKey(group, profile);
if (!key || !skillId) return;
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
var current = preferences[key] && typeof preferences[key] === 'object' && !Array.isArray(preferences[key])
? Object.assign({}, preferences[key])
: {};
current[skillId] = Boolean(enabled);
preferences[key] = current;
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference(key, current);
}
function pageAiSkillEnabled(skill) {
return skill.enabled !== false;
}
return {
pageAiSkillSourceOptions,
pageAiDefaultSkillSource,
pageAiNormalizeSkillSource,
pageAiCurrentSkillSource,
pageAiSetSkillSource,
pageAiSkillSourceParts,
pageAiCurrentSkillSourceLabel,
pageAiSkillOriginLabel,
pageAiSkillPreferenceKey,
pageAiSkillPreferenceTable,
pageAiHermesHideBuiltinPreferenceKey,
pageAiHideHermesBuiltinSkills,
pageAiReasonixMemoryEnabled,
pageAiSetReasonixMemoryEnabled,
pageAiSetHideHermesBuiltinSkills,
pageAiSkillIsBuiltin,
pageAiToggleableSkillEntries,
pageAiAllSkillEntries,
pageAiSkillGroupCollapsed,
pageAiToggleSkillGroup,
pageAiSetSkillPreference,
pageAiSkillEnabled
};
}
@@ -0,0 +1,758 @@
export function createSidebarPageAiTargetRuntime(context) {
const {
currentDocumentId,
currentRootUri,
currentSourceKind,
currentPageOptions,
documentRef,
escapeHtml,
pageAiEnsureContextRefState,
pageUiState,
resolveWorkspaceId,
searchText,
pageAiNormalizeArray,
} = context;
function pageAiCloneJson(value) {
if (value == null) return null;
try {
return JSON.parse(JSON.stringify(value));
} catch (_) {
return null;
}
}
function localMarkdownRelativePathFromPageAiDocumentId(documentId) {
var value = String(documentId || '').trim();
if (!value.startsWith('local-md:')) return '';
return value.slice('local-md:'.length).replace(/~2F/g, '/');
}
function localMarkdownDocumentIdFromPageAiRelativePath(relativePath) {
var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized) return '';
return 'local-md:' + normalized.split('/').map(function(segment) {
return encodeURIComponent(segment).replace(/%20/g, '~20');
}).join('~2F');
}
function pageAiWorkspacePathForDocument(documentId, seed) {
var relativePath = String(seed && seed.relativePath || localMarkdownRelativePathFromPageAiDocumentId(documentId) || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
var resolvedDocumentId = String(documentId || seed && seed.documentId || '').trim()
|| localMarkdownDocumentIdFromPageAiRelativePath(relativePath);
return {
schema: 'mnote.workspace_path.v1',
workspaceId: String(seed && seed.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim(),
sourceKind: String(seed && seed.sourceKind || currentSourceKind() || '').trim(),
rootUri: String(seed && seed.rootUri || currentRootUri() || '').trim(),
relativePath: relativePath,
documentId: resolvedDocumentId,
objectIdentity: seed && seed.objectIdentity && typeof seed.objectIdentity === 'object'
? seed.objectIdentity
: String(seed && seed.objectIdentity || resolvedDocumentId || '').trim(),
assetId: String(seed && seed.assetId || '').trim(),
resourceKind: String(seed && seed.resourceKind || 'markdown_page').trim()
};
}
function pageAiResourceKindForTarget(entry) {
var kind = String(entry && (entry.editorKind || entry.kind) || '').trim().toLowerCase();
var assetId = String(entry && entry.assetId || '').trim();
var path = String(entry && entry.path || '').trim().toLowerCase();
if (kind === 'page' || kind === 'markdown_page') return 'markdown_page';
if (kind === 'mindmap' || path.endsWith('.mindmap.json')) return 'mindmap';
if (kind === 'office' || kind === 'onlyoffice' || kind === 'only_office' || /\.(doc|docx|ppt|pptx|xls|xlsx)$/.test(path)) return 'only_office';
if (kind === 'resource' && assetId) return 'resource';
return kind || 'markdown_page';
}
function pageAiTargetId(entry) {
if (!entry || typeof entry !== 'object') return '';
var workspacePath = entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {};
var entryIdentity = typeof entry.objectIdentity === 'string' ? entry.objectIdentity : '';
var workspaceIdentity = typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : '';
return String(entryIdentity || workspaceIdentity || entry.documentId || entry.assetId || entry.path || '').trim();
}
function pageAiWorkspacePathForTarget(entry) {
var seed = Object.assign({}, entry && entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : {});
var resourceKind = pageAiResourceKindForTarget(entry);
if (!seed.relativePath && entry && entry.path) seed.relativePath = entry.path;
if (!seed.assetId && entry && entry.assetId) seed.assetId = entry.assetId;
var seedResourceKind = String(seed.resourceKind || '').trim();
if (!seedResourceKind || seedResourceKind === 'page' || seedResourceKind === 'office') seed.resourceKind = resourceKind;
if (!seed.objectIdentity && entry && entry.objectIdentity) seed.objectIdentity = entry.objectIdentity;
if (!seed.workspaceId && entry && entry.workspaceId) seed.workspaceId = entry.workspaceId;
return pageAiWorkspacePathForDocument(entry && entry.documentId || currentDocumentId(), seed);
}
function pageAiTargetFromOpenEditor(entry, source) {
if (!entry || typeof entry !== 'object') return null;
var workspacePath = pageAiWorkspacePathForTarget(entry);
var targetId = pageAiTargetId(entry) || workspacePath.objectIdentity || workspacePath.documentId;
if (!targetId) return null;
var objectIdentity = typeof entry.objectIdentity === 'string'
? entry.objectIdentity
: (typeof workspacePath.objectIdentity === 'string' ? workspacePath.objectIdentity : targetId);
return {
schema: 'mnote.ai_editor_target.v1',
source: source || 'open_editors_snapshot',
targetId: targetId,
objectIdentity: objectIdentity,
workspacePath: workspacePath,
paneRole: entry.paneRole || 'primary',
documentId: entry.documentId || workspacePath.documentId,
workspaceId: entry.workspaceId || workspacePath.workspaceId || resolveWorkspaceId(documentRef.body),
editorKind: entry.editorKind || entry.kind || workspacePath.resourceKind,
resourceKind: workspacePath.resourceKind,
title: entry.title || '',
active: entry.active === true,
dirtyState: entry.dirtyState || '',
preview: entry.preview === true,
pinned: entry.pinned === true,
lastActiveAt: entry.lastActiveAt || 0,
assetId: entry.assetId || workspacePath.assetId || '',
path: entry.path || workspacePath.relativePath || '',
onlyofficeSessionId: entry.onlyofficeSessionId || entry.bridgeSessionId || '',
bridgeSessionId: entry.bridgeSessionId || entry.onlyofficeSessionId || '',
bridgeSessionReady: entry.bridgeSessionReady === true
};
}
function normalizeOpenEditorEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
return {
objectIdentity: String(entry.objectIdentity || '').trim(),
workspacePath: entry.workspacePath && typeof entry.workspacePath === 'object' ? entry.workspacePath : null,
paneRole: String(entry.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
documentId: String(entry.documentId || '').trim(),
workspaceId: String(entry.workspaceId || '').trim(),
title: String(entry.title || '').trim(),
kind: String(entry.kind || entry.editorKind || '').trim(),
editorKind: String(entry.editorKind || entry.kind || '').trim(),
active: entry.active === true,
dirtyState: String(entry.dirtyState || entry.dirtyGuard || '').trim(),
preview: entry.preview === true,
pinned: entry.pinned === true,
lastActiveAt: Number(entry.lastActiveAt || 0) || 0,
assetId: String(entry.assetId || '').trim(),
path: String(entry.path || '').trim(),
onlyofficeSessionId: String(entry.onlyofficeSessionId || entry.bridgeSessionId || '').trim(),
bridgeSessionId: String(entry.bridgeSessionId || entry.onlyofficeSessionId || '').trim(),
bridgeSessionReady: entry.bridgeSessionReady === true
};
}
function currentPageAiOpenEditorsSnapshot() {
var snapshot = null;
try {
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot === 'function') {
snapshot = window.__mnoteDocumentPaneRuntime.getOpenEditorsSnapshot();
}
} catch (_) {}
if (!snapshot || typeof snapshot !== 'object') snapshot = window.__mnoteOpenEditorsSnapshot || null;
if (!snapshot || typeof snapshot !== 'object') return null;
var editors = Array.isArray(snapshot.editors)
? snapshot.editors.map(normalizeOpenEditorEntry).filter(Boolean)
: [];
var resources = Array.isArray(snapshot.resourceEditors)
? snapshot.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
: editors.filter(function(entry) { return entry.kind !== 'page'; });
var normalizeGroup = function(group, paneRole) {
var groupEditors = group && Array.isArray(group.editors)
? group.editors.map(normalizeOpenEditorEntry).filter(Boolean)
: editors.filter(function(entry) { return entry.paneRole === paneRole; });
var groupResources = group && Array.isArray(group.resourceEditors)
? group.resourceEditors.map(normalizeOpenEditorEntry).filter(Boolean)
: resources.filter(function(entry) { return entry.paneRole === paneRole; });
var groupActive = groupEditors.find(function(entry) { return entry.active; }) || null;
return {
paneRole: paneRole,
activeObjectIdentity: String(group && group.activeObjectIdentity || (groupActive ? groupActive.objectIdentity : '') || '').trim(),
editors: groupEditors,
resourceEditors: groupResources
};
};
var groups = {
primary: normalizeGroup(snapshot.groups && snapshot.groups.primary, 'primary'),
secondary: normalizeGroup(snapshot.groups && snapshot.groups.secondary, 'secondary')
};
var activeObjectIdentity = String(snapshot.activeObjectIdentity || '').trim();
var allTargets = editors.concat(resources);
var activeEditor = allTargets.find(function(entry) {
return entry.active && (!activeObjectIdentity || entry.objectIdentity === activeObjectIdentity);
}) || groups.primary.editors.find(function(entry) {
return entry.active;
}) || groups.primary.resourceEditors.find(function(entry) {
return entry.active;
}) || groups.secondary.editors.find(function(entry) {
return entry.active;
}) || groups.secondary.resourceEditors.find(function(entry) {
return entry.active;
}) || null;
return {
schema: String(snapshot.schema || 'mnote.open_editors_snapshot.v1'),
generatedAt: Number(snapshot.generatedAt || 0) || Date.now(),
activeObjectIdentity: activeObjectIdentity || (activeEditor ? activeEditor.objectIdentity : ''),
activeEditor: activeEditor,
editors: editors,
resourceEditors: resources,
groups: groups
};
}
function pageAiFallbackEditorTarget() {
var fallbackDocumentId = currentDocumentId();
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(fallbackDocumentId, null);
var fallbackTargetId = fallbackWorkspacePath.objectIdentity || fallbackDocumentId || '';
return {
schema: 'mnote.ai_editor_target.v1',
source: 'fallback_current_document',
targetId: fallbackTargetId,
objectIdentity: fallbackTargetId,
workspacePath: fallbackWorkspacePath,
paneRole: 'primary',
documentId: fallbackDocumentId,
workspaceId: resolveWorkspaceId(documentRef.body),
editorKind: 'page',
active: true,
dirtyState: '',
preview: false,
pinned: true,
lastActiveAt: Date.now(),
assetId: '',
path: ''
};
}
function pageAiEditorTargetCandidates() {
var snapshot = currentPageAiOpenEditorsSnapshot();
var entries = [];
if (snapshot) {
entries = pageAiNormalizeArray(snapshot.editors).concat(pageAiNormalizeArray(snapshot.resourceEditors));
}
var seen = {};
var targets = entries.map(function(entry) {
return pageAiTargetFromOpenEditor(entry, 'open_editors_snapshot');
}).filter(function(target) {
var id = String(target && target.targetId || '').trim();
if (!id || seen[id]) return false;
seen[id] = true;
return true;
});
if (!targets.length) targets.push(pageAiFallbackEditorTarget());
return targets;
}
function currentPageAiEditorTarget() {
var targets = pageAiEditorTargetCandidates();
var selectedId = String(pageUiState.pageAiSelectedTargetId || '').trim();
var selected = selectedId ? targets.find(function(target) { return target.targetId === selectedId; }) : null;
return selected
|| targets.find(function(target) { return target.active === true; })
|| targets[0]
|| pageAiFallbackEditorTarget();
}
function currentPageAiPageEditorTarget() {
var snapshot = currentPageAiOpenEditorsSnapshot();
var documentId = currentDocumentId();
var workspaceId = resolveWorkspaceId(documentRef.body);
var sourceKind = currentSourceKind();
var rootUri = currentRootUri();
var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId);
var fallbackWorkspacePath = pageAiWorkspacePathForDocument(documentId, {
relativePath: relativePath
});
var pageEditor = snapshot && Array.isArray(snapshot.editors)
? snapshot.editors.find(function(entry) {
return entry
&& String(entry.editorKind || entry.kind || '') === 'page'
&& String(entry.documentId || '').trim() === String(documentId || '').trim();
})
: null;
var workspacePath = pageEditor && pageEditor.workspacePath
? Object.assign({}, pageEditor.workspacePath, {
workspaceId: workspaceId,
sourceKind: sourceKind,
rootUri: rootUri,
relativePath: relativePath,
documentId: documentId,
resourceKind: pageEditor.workspacePath.resourceKind || 'markdown_page',
objectIdentity: pageEditor.workspacePath.objectIdentity || fallbackWorkspacePath.objectIdentity
})
: fallbackWorkspacePath;
var objectIdentity = String(pageEditor && pageEditor.objectIdentity || workspacePath.objectIdentity || documentId || '').trim();
return {
schema: 'mnote.ai_editor_target.v1',
source: pageEditor ? 'current_page_from_open_editors_snapshot' : 'current_page_fallback',
targetId: objectIdentity,
objectIdentity: objectIdentity,
workspacePath: Object.assign({}, workspacePath, { objectIdentity: objectIdentity }),
paneRole: String(pageEditor && pageEditor.paneRole || 'primary').trim() === 'secondary' ? 'secondary' : 'primary',
documentId: documentId,
workspaceId: workspaceId,
editorKind: 'page',
active: pageEditor ? pageEditor.active === true : true,
dirtyState: String(pageEditor && pageEditor.dirtyState || '').trim(),
preview: pageEditor ? pageEditor.preview === true : false,
pinned: true,
lastActiveAt: Number(pageEditor && pageEditor.lastActiveAt || 0) || Date.now(),
assetId: '',
path: relativePath
};
}
function currentPageAiScopedEditorTarget() {
var selected = pageAiEnsureContextRefState();
return selected.active_editor ? currentPageAiEditorTarget() : currentPageAiPageEditorTarget();
}
function pageAiSetRunTargetSnapshot(snapshot) {
pageUiState.pageAiCurrentRunTargetSnapshot = snapshot || null;
var target = snapshot && snapshot.editorTarget ? snapshot.editorTarget : null;
var documentId = String(target && target.documentId || snapshot && snapshot.documentId || '').trim();
var workspaceId = String(target && target.workspaceId || snapshot && snapshot.workspaceId || '').trim();
var rootUri = String(target && target.workspacePath && target.workspacePath.rootUri || snapshot && snapshot.rootUri || '').trim();
if (documentId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-document-id', documentId);
if (workspaceId) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-workspace-id', workspaceId);
if (rootUri) documentRef.documentElement.setAttribute('data-mnote-page-ai-run-target-root-uri', rootUri);
}
function assertPageAiTargetInCurrentWorkspace(editorTarget) {
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
var targetSourceKind = String(workspacePath.sourceKind || '').trim();
var currentKind = String(currentSourceKind() || '').trim();
if (targetSourceKind && currentKind && targetSourceKind !== currentKind) {
var sourceError = new Error('AI target 与当前页面 sourceKind 不一致,请重新选择当前工作区内的目标。');
sourceError.code = 'page_ai_target_workspace_mismatch';
throw sourceError;
}
var targetRootUri = String(workspacePath.rootUri || '').trim();
var currentRoot = String(currentRootUri() || '').trim();
if (targetRootUri && currentRoot && targetRootUri !== currentRoot) {
var rootError = new Error('AI target 与当前本地工作区 rootUri 不一致,请重新选择当前工作区内的目标。');
rootError.code = 'page_ai_target_workspace_mismatch';
throw rootError;
}
var targetWorkspaceId = String(target.workspaceId || workspacePath.workspaceId || '').trim();
var currentWorkspaceId = String(resolveWorkspaceId(documentRef.body) || '').trim();
if (targetWorkspaceId && currentWorkspaceId && targetWorkspaceId !== currentWorkspaceId) {
var workspaceError = new Error('AI target 与当前 workspaceId 不一致,请重新选择当前工作区内的目标。');
workspaceError.code = 'page_ai_target_workspace_mismatch';
throw workspaceError;
}
}
function pageAiBuildContextRefs(scopedContext, runTargetSnapshot) {
var selected = pageAiEnsureContextRefState();
var refs = [];
var documentId = currentDocumentId();
var rootUri = currentRootUri();
var workspaceId = resolveWorkspaceId(documentRef.body);
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
if (selected.current_page) {
refs.push({
kind: 'current_page',
documentId: documentId,
rootUri: rootUri,
workspaceId: workspaceId
});
}
if (selected.selection && scopedContext && scopedContext.selectedText) {
refs.push({
kind: 'selection',
documentId: documentId,
rootUri: rootUri,
selectedBlockId: scopedContext.selectedBlockId || ''
});
}
if (selected.active_editor && editorTarget) {
var workspacePath = editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
refs.push({
kind: 'active_editor',
documentId: editorTarget.documentId || documentId,
workspaceId: editorTarget.workspaceId || workspacePath.workspaceId || workspaceId,
rootUri: workspacePath.rootUri || rootUri,
relativePath: workspacePath.relativePath || '',
editorKind: editorTarget.editorKind || '',
resourceKind: editorTarget.resourceKind || workspacePath.resourceKind || '',
targetId: editorTarget.targetId || workspacePath.objectIdentity || '',
objectIdentity: editorTarget.objectIdentity || workspacePath.objectIdentity || '',
assetId: editorTarget.assetId || workspacePath.assetId || '',
onlyofficeSessionId: editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId || '',
bridgeSessionId: editorTarget.bridgeSessionId || editorTarget.onlyofficeSessionId || ''
});
}
if (selected.file) {
refs.push({
kind: 'file',
documentId: documentId,
rootUri: rootUri,
relativePath: localMarkdownRelativePathFromPageAiDocumentId(documentId)
});
}
if (selected.folder) {
refs.push({
kind: 'folder',
rootUri: rootUri,
relativePath: ''
});
}
if (selected.changed_files) {
refs.push({
kind: 'changed_files',
rootUri: rootUri,
sinceRunTargetSnapshot: runTargetSnapshot && runTargetSnapshot.frozenAt || null
});
}
return refs.filter(function(ref) {
return ref && String(ref.kind || '').trim();
});
}
function pageAiBuildAllowedRoots() {
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
return {
rootUri: root.rootUri,
permission: root.permission === 'write' || root.permission === 'read_write' ? 'write' : 'read',
recursive: root.recursive !== false,
source: root.source === 'auto' || root.source === 'user' || root.source === 'admin'
? 'sqlite_directory_grant'
: (root.source || 'sqlite_directory_grant'),
grantId: root.id || ''
};
});
}
function pageAiBuildRunTargetSnapshot(scopedContext, prompt) {
var aiContext = scopedContext && scopedContext.pageContext && scopedContext.pageContext.aiContext
? scopedContext.pageContext.aiContext
: {};
var editorTarget = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
return {
schema: 'mnote.page_ai_run_target_snapshot.v1',
source: 'open_editors_snapshot',
frozenAt: Date.now(),
workspaceId: resolveWorkspaceId(documentRef.body),
documentId: currentDocumentId(),
sourceKind: currentSourceKind(),
rootUri: currentRootUri(),
contextScope: pageUiState.pageAiContextScope || 'page',
promptPreview: searchText(prompt || '').slice(0, 160),
editorTarget: pageAiCloneJson(editorTarget),
activeEditorTarget: pageAiCloneJson(aiContext.activeEditorTarget || editorTarget),
openEditorsSnapshot: pageAiCloneJson(aiContext.openEditorsSnapshot || currentPageAiOpenEditorsSnapshot())
};
}
function pageAiContextKindsFromRefs(contextRefs) {
var kinds = {};
pageAiNormalizeArray(contextRefs).forEach(function(ref) {
var kind = String(ref && ref.kind || '').trim();
if (kind) kinds[kind] = true;
});
return kinds;
}
function pageAiPageContextForRefs(pageContext, contextRefs) {
var cloned = pageAiCloneJson(pageContext) || {};
var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {};
var kinds = pageAiContextKindsFromRefs(contextRefs);
delete cloned.documentBlocks;
delete cloned.evidence;
delete aiContext.contextBlocks;
delete aiContext.pageText;
delete aiContext.pageXml;
delete aiContext.truncated;
delete aiContext.warnings;
if (!kinds.selection) {
delete aiContext.selectedText;
delete aiContext.selectedBlockIds;
delete aiContext.selectedBlocks;
delete aiContext.allowedTargetBlockIds;
}
cloned.aiContext = aiContext;
return cloned;
}
function pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot) {
var target = scopedContext && scopedContext.editorTarget ? scopedContext.editorTarget : currentPageAiEditorTarget();
var workspacePath = target && target.workspacePath && typeof target.workspacePath === 'object'
? pageAiWorkspacePathForDocument(target.documentId || currentDocumentId(), target.workspacePath)
: pageAiWorkspacePathForDocument(target && target.documentId || currentDocumentId(), null);
var relativePath = String(workspacePath.relativePath || '').trim();
var allowedFiles = relativePath ? [relativePath] : [];
var writable = pageAiBuildAllowedRoots().some(function(root) {
return String(root && root.rootUri || '').trim() === String(workspacePath.rootUri || '').trim()
&& String(root && root.permission || '').trim() === 'write';
});
var primaryTargetId = String(target && target.targetId || workspacePath.objectIdentity || workspacePath.documentId || '').trim();
var onlyofficeSessionId = String(target && (target.onlyofficeSessionId || target.bridgeSessionId) || '').trim();
var targetEntry = {
targetId: primaryTargetId,
objectIdentity: primaryTargetId,
documentId: workspacePath.documentId,
workspaceId: workspacePath.workspaceId,
sourceKind: workspacePath.sourceKind,
rootUri: workspacePath.rootUri,
relativePath: relativePath,
resourceKind: workspacePath.resourceKind,
assetId: workspacePath.assetId || target && target.assetId || '',
onlyofficeSessionId: onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
paneRole: target && target.paneRole || 'primary',
title: target && target.title || '',
policy: {
permission: allowedFiles.length && writable ? 'read_write' : 'read',
writeRequiresCleanBuffer: true,
conflictPolicy: 'fail_on_dirty_or_stale'
}
};
return {
schema: 'mnote.agent_target_package.v1',
source: 'page_ai_run_target_snapshot',
frozenAt: runTargetSnapshot && runTargetSnapshot.frozenAt || Date.now(),
primaryTargetId: primaryTargetId,
onlyofficeSessionId: onlyofficeSessionId,
bridgeSessionId: onlyofficeSessionId,
workspaceId: workspacePath.workspaceId,
sourceKind: workspacePath.sourceKind,
rootUri: workspacePath.rootUri,
documentId: workspacePath.documentId,
objectIdentity: primaryTargetId,
resourceKind: workspacePath.resourceKind,
workspacePath: workspacePath,
currentFile: relativePath ? {
rootUri: workspacePath.rootUri,
relativePath: relativePath,
documentId: workspacePath.documentId,
objectIdentity: primaryTargetId,
resourceKind: workspacePath.resourceKind
} : null,
allowedFiles: allowedFiles,
targets: [targetEntry],
policy: {
writeRequiresExplicitTarget: true,
allowedFilesSource: 'selected_page_ai_target',
conflictPolicy: 'fail_on_dirty_or_stale'
}
};
}
function pageAiBlockingDirtyState(dirtyState) {
var state = String(dirtyState || '').trim();
var normalized = state.toLowerCase();
if (normalized === 'dirty') return 'Dirty';
if (normalized === 'stale') return 'Stale';
if (normalized === 'deleted') return 'Deleted';
if (normalized === 'externalmodified' || normalized === 'external-change-conflict' || normalized === 'hasexternalconflict') return 'ExternalModified';
return '';
}
async function fetchPageAiTargetBufferState(editorTarget) {
if (currentSourceKind() !== 'local_folder') return null;
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
if (resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') return null;
var documentId = String(target.documentId || currentDocumentId() || '').trim();
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
if (!documentId || !rootUri) return null;
var relativePath = String(workspacePath.relativePath || '').trim()
|| localMarkdownRelativePathFromPageAiDocumentId(documentId);
var url = new URL('/api/documents/buffer-state', window.location.origin);
url.searchParams.set('documentId', documentId);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', rootUri);
var workspaceId = String(target.workspaceId || resolveWorkspaceId(documentRef.body) || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
if (relativePath) url.searchParams.set('relativePath', relativePath);
try {
var response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || payload.ok !== true) return null;
return payload.result || null;
} catch (_) {
return null;
}
}
async function assertPageAiTargetWritable(editorTarget) {
var workspacePath = editorTarget && editorTarget.workspacePath && typeof editorTarget.workspacePath === 'object' ? editorTarget.workspacePath : {};
var resourceKind = String(editorTarget && (editorTarget.resourceKind || editorTarget.editorKind) || workspacePath.resourceKind || '').trim();
var onlyofficeSessionId = String(editorTarget && (editorTarget.onlyofficeSessionId || editorTarget.bridgeSessionId) || '').trim();
if ((resourceKind === 'office' || resourceKind === 'only_office' || resourceKind === 'onlyoffice') && !onlyofficeSessionId) {
var sessionError = new Error('ONLYOFFICE 资源仍在连接 MNote bridge,请等待 Office 页面加载完成后再让 AI 操作。');
sessionError.code = 'page_ai_onlyoffice_bridge_not_ready';
throw sessionError;
}
var snapshotState = pageAiBlockingDirtyState(editorTarget && editorTarget.dirtyState);
var bufferState = await fetchPageAiTargetBufferState(editorTarget);
var bufferDirtyState = pageAiBlockingDirtyState(bufferState && bufferState.dirtyState);
var blockedState = bufferDirtyState || snapshotState;
if (!blockedState) return bufferState;
var documentId = String(editorTarget && editorTarget.documentId || currentDocumentId() || '').trim();
var error = new Error('目标文档存在未保存或外部变更状态(' + blockedState + '),请先保存、解决冲突或刷新后再让 AI 写入。');
error.code = 'page_ai_target_buffer_not_writable';
error.documentId = documentId;
error.dirtyState = blockedState;
throw error;
}
function currentPageAiSelectedText() {
try {
var selection = window.getSelection ? window.getSelection() : null;
return selection ? searchText(selection.toString() || '') : '';
} catch (_) {
return '';
}
}
function pageAiProjectionBlocks(aggregate) {
var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks;
return Array.isArray(blocks) ? blocks : [];
}
function pageAiBlockText(block) {
return searchText(block && (block.text || block.title || block.content) || '');
}
function pageAiSelectedBlockIdsFromSelection() {
try {
var selection = window.getSelection ? window.getSelection() : null;
if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return [];
var range = selection.getRangeAt(0);
var editor = documentRef.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror');
if (!(editor instanceof HTMLElement)) return [];
return Array.from(editor.children).filter(function(node) {
if (!(node instanceof HTMLElement)) return false;
try {
return range.intersectsNode(node);
} catch (_) {
return false;
}
}).map(function(node) {
return searchText(node.getAttribute('data-id') || node.id || '');
}).filter(Boolean);
} catch (_) {
return [];
}
}
function pageAiBlocksToPageXml(blocks, aggregate) {
var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : '';
var pageId = currentDocumentId() || 'current-page';
var lines = ['<page id="' + escapeHtml(pageId) + '" revision="' + escapeHtml(revision) + '">'];
blocks.forEach(function(block) {
var blockId = String(block && (block.blockId || block.id) || '');
var type = String(block && block.type || 'paragraph');
var revisionRef = String(block && block.revisionRef || '');
var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : '';
lines.push(' <block id="' + escapeHtml(blockId) + '" type="' + escapeHtml(type) + '" revisionRef="' + escapeHtml(revisionRef) + '"' + level + '>' + escapeHtml(pageAiBlockText(block)) + '</block>');
});
lines.push('</page>');
return lines.join('\n');
}
function buildPageAiContext(contextSnapshot, scope, selectedText) {
var aggregate = contextSnapshot.aggregate || {};
var body = aggregate.body || {};
var allBlocks = pageAiProjectionBlocks(aggregate);
var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : [];
var selectedSet = {};
selectedBlockIds.forEach(function(id) { selectedSet[id] = true; });
var selectedBlocks = selectedBlockIds.length
? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; })
: [];
var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120);
var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length;
return {
schema: 'mnote.page_ai_context.v1',
workspaceId: resolveWorkspaceId(documentRef.body),
documentId: currentDocumentId(),
activeEditorTarget: currentPageAiScopedEditorTarget(),
openEditorsSnapshot: currentPageAiOpenEditorsSnapshot(),
scope: scope,
revision: body.revision || null,
conflictDetectionKey: body.conflictDetectionKey || null,
selectedText: selectedText || '',
selectedBlockIds: selectedBlockIds,
allowedTargetBlockIds: selectedBlockIds,
selectedBlocks: selectedBlocks,
contextBlocks: contextBlocks,
pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'),
pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate),
truncated: truncated,
warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : []
};
}
function pageAiScopedPageContext(contextSnapshot) {
var aggregate = contextSnapshot.aggregate || {};
var body = contextSnapshot.body || {};
var subtree = contextSnapshot.subtree || null;
var outline = subtree && subtree.outline ? subtree.outline : null;
var scope = pageUiState.pageAiContextScope || 'page';
var title = aggregate.head && aggregate.head.title ? aggregate.head.title : '';
var selectedText = scope === 'selection' ? currentPageAiSelectedText() : '';
var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText);
var editorTarget = aiContext.activeEditorTarget || currentPageAiScopedEditorTarget();
return {
pageContext: {
contextScope: scope,
documentBlocks: null,
node: {
documentId: currentDocumentId(),
title: title
},
subtree: null,
outline: null,
pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope,
evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null,
pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null,
contentAccess: 'mnote.context.read_current_page',
aiContext: aiContext
},
editorTarget: editorTarget,
selectedText: selectedText,
selectedBlockId: aiContext.selectedBlockIds[0] || null
};
}
return {
currentPageAiSelectedText,
currentPageAiEditorTarget,
currentPageAiPageEditorTarget,
currentPageAiScopedEditorTarget,
currentPageAiOpenEditorsSnapshot,
pageAiBlockText,
pageAiBlockingDirtyState,
pageAiBlocksToPageXml,
pageAiBuildAgentTargetPackage,
pageAiBuildAllowedRoots,
pageAiBuildContextRefs,
pageAiBuildRunTargetSnapshot,
pageAiCloneJson,
pageAiContextKindsFromRefs,
pageAiEditorTargetCandidates,
pageAiFallbackEditorTarget,
pageAiPageContextForRefs,
pageAiProjectionBlocks,
pageAiResourceKindForTarget,
pageAiSelectedBlockIdsFromSelection,
pageAiSetRunTargetSnapshot,
pageAiScopedPageContext,
pageAiTargetFromOpenEditor,
pageAiTargetId,
pageAiWorkspacePathForDocument,
pageAiWorkspacePathForTarget,
assertPageAiTargetInCurrentWorkspace,
assertPageAiTargetWritable,
buildPageAiContext,
fetchPageAiTargetBufferState,
localMarkdownDocumentIdFromPageAiRelativePath,
localMarkdownRelativePathFromPageAiDocumentId,
};
}
@@ -262,6 +262,9 @@ export function createSidebarPageSettingsRuntime(context) {
var shell = document.querySelector('.document-shell');
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
var mindmapWidthPreference = currentPageWidthPreferences().mindmap || DEFAULT_PAGE_WIDTH_PREFERENCES.mindmap;
var mindmapMaxWidth = String(mindmapWidthPreference.cssMaxWidth || pageWidthCssMaxWidth(mindmapWidthPreference.resolvedMode));
var mindmapMaxWidthValue = mindmapMaxWidth === 'none' ? 'none' : mindmapMaxWidth;
if (shell instanceof HTMLElement) {
var widthPreference = activeResourceWidthPreference(options);
var cssMaxWidth = String(widthPreference.cssMaxWidth || pageWidthCssMaxWidth(widthPreference.resolvedMode));
@@ -276,12 +279,14 @@ export function createSidebarPageSettingsRuntime(context) {
shell.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
shell.style.width = '100%';
shell.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
shell.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
}
if (editorRoot instanceof HTMLElement) {
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
editorRoot.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
editorRoot.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
}
if (editorSurface instanceof HTMLElement) {
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
@@ -299,6 +304,7 @@ export function createSidebarPageSettingsRuntime(context) {
document.documentElement.setAttribute('data-global-show-heading-numbers', String(globalShowHeadingNumbers));
document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
document.documentElement.setAttribute('data-page-hide-title-header', String(Boolean(options.hideTitleHeader)));
document.documentElement.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
var titleHeader = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header') || document.querySelector('.document-shell-header');
if (titleHeader instanceof HTMLElement) {
var hidden = Boolean(options.hideTitleHeader);
@@ -32,46 +32,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
pageWidthPreferences: null,
historySnapshots: [],
pageSettingsOpen: false,
pageAiOpen: false,
pageAiBusy: false,
pageAiMessages: [],
pageAiSuggestionIndex: 0,
pageAiProvider: 'hermes',
pageAiPage: 'chat',
pageAiRunStatus: 'idle',
pageAiCurrentRunId: '',
pageAiAcpRuntime: 'reasonix',
pageAiAcpRuntimes: [],
pageAiQueueLength: 0,
pageAiQueuedItems: [],
pageAiStoppedRunIds: {},
pageAiAbortController: null,
pageAiContextScope: 'page',
pageAiTools: [],
pageAiToolsError: '',
pageAiGatewayHealth: null,
pageAiGatewayHealthError: '',
pageAiLastToolCall: null,
pageAiProfiles: [],
pageAiActiveProfileName: 'mnoteai',
pageAiProfileError: '',
pageAiProfileMemory: { memory: '', user: '', soul: '' },
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
pageAiProfileMemoryError: '',
pageAiSkills: { categories: [], archived: [] },
pageAiSkillCatalogs: { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } },
pageAiSkillPreferences: {},
pageAiCollapsedSkillGroups: {},
pageAiSkillQuery: '',
pageAiSkillError: '',
pageAiSkillLoadSeq: 0,
pageAiSessions: [],
pageAiActiveSessionId: '',
pageAiSessionSearchQuery: '',
pageAiSessionSearchResults: [],
pageAiSessionSearchTimer: 0,
pageAiSessionError: '',
pageAiPermissionRequests: [],
localIndexSummary: {
scopeKey: '',
loading: false,
@@ -928,6 +888,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
function fileTreeRuntimeDeps() {
return {
currentSourceKind: currentSourceKind,
currentRootUri: currentRootUri,
currentDocumentId: currentDocumentId,
localFilePathFromAssetId: localFilePathFromAssetId,
rowTitle: rowTitle,
@@ -2382,12 +2343,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
const pageAiSetSkillSource = (...args) => sidebarPageAi.pageAiSetSkillSource(...args);
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
const pageAiSetReasonixMemoryEnabled = (...args) => sidebarPageAi.pageAiSetReasonixMemoryEnabled(...args);
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args);
const pageAiSetTargetPopoverOpen = (...args) => sidebarPageAi.pageAiSetTargetPopoverOpen(...args);
const pageAiSelectTarget = (...args) => sidebarPageAi.pageAiSelectTarget(...args);
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
@@ -2532,255 +2497,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var pageAiClose = closestAction(e.target, '[data-page-ai-action="close"]');
if (pageAiClose) {
e.preventDefault();
closePageAiDrawer();
return;
}
var pageAiSettings = closestAction(e.target, '[data-page-ai-action="open-hermes-settings"]');
if (pageAiSettings) {
e.preventDefault();
pageAiOpenHermesSettings();
return;
}
var pageAiStop = closestAction(e.target, '[data-page-ai-action="stop-run"]');
if (pageAiStop) {
e.preventDefault();
void pageAiStopRun();
return;
}
var pageAiRotate = closestAction(e.target, '[data-page-ai-action="rotate"]');
if (pageAiRotate) {
e.preventDefault();
pageUiState.pageAiSuggestionIndex += 1;
renderPageAiSuggestions();
return;
}
var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]');
if (pageAiIntent) {
e.preventDefault();
var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || '';
if (intentName === 'create-summary') {
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。');
return;
}
if (intentName === 'create-ai-note') {
void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。');
return;
}
}
var pageAiTab = closestAction(e.target, '[data-page-ai-tab]');
if (pageAiTab) {
e.preventDefault();
pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat';
if (pageUiState.pageAiPage === 'runtime') void pageAiLoadGatewayHealth();
renderPageAiControls();
renderPageAiConversation();
return;
}
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
if (pageAiProvider) {
e.preventDefault();
pageUiState.pageAiProvider = pageAiProvider.getAttribute('data-page-ai-provider') || 'hermes';
renderPageAiProviderButtons();
return;
}
var pageAiAgent = closestAction(e.target, '[data-page-ai-agent-id]');
if (pageAiAgent) {
e.preventDefault();
pageAiSetAgentId(pageAiAgent.getAttribute('data-page-ai-agent-id') || 'reasonix');
return;
}
var pageAiAgentButton = closestAction(e.target, '[data-page-ai-agent-button]');
if (pageAiAgentButton) {
e.preventDefault();
pageAiSetAgentPopoverOpen(pageAiAgentButton.getAttribute('aria-expanded') !== 'true');
return;
}
var pageAiAgentPopoverClose = closestAction(e.target, '[data-page-ai-action="close-agent-popover"]');
if (pageAiAgentPopoverClose) {
e.preventDefault();
pageAiSetAgentPopoverOpen(false);
return;
}
var pageAiContextButton = closestAction(e.target, '[data-page-ai-context-button]');
if (pageAiContextButton) {
e.preventDefault();
sidebarPageAi.pageAiSetContextPopoverOpen(
pageAiContextButton.getAttribute('aria-expanded') !== 'true'
);
return;
}
var pageAiContextPopoverClose = closestAction(e.target, '[data-page-ai-action="close-context-popover"]');
if (pageAiContextPopoverClose) {
e.preventDefault();
sidebarPageAi.pageAiSetContextPopoverOpen(false);
return;
}
var pageAiContextRef = closestAction(e.target, '[data-page-ai-context-ref]');
if (pageAiContextRef) {
e.preventDefault();
pageAiToggleContextRef(pageAiContextRef.getAttribute('data-page-ai-context-ref') || '');
return;
}
var pageAiMemorySave = closestAction(e.target, '[data-page-ai-memory-save]');
if (pageAiMemorySave) {
e.preventDefault();
void pageAiSaveProfileMemory(pageAiMemorySave.getAttribute('data-page-ai-memory-save') || '');
return;
}
var pageAiSkillToggle = closestAction(e.target, '[data-page-ai-skill-toggle]');
if (pageAiSkillToggle) {
e.preventDefault();
var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '';
var skillGroup = pageAiSkillToggle.getAttribute('data-page-ai-skill-group') || '';
var skillProfile = pageAiSkillToggle.getAttribute('data-page-ai-skill-profile') || '';
var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true';
void pageAiToggleSkill(skillName, nextEnabled, skillGroup, skillProfile);
return;
}
var pageAiSkillGroupToggle = closestAction(e.target, '[data-page-ai-skill-group-toggle]');
if (pageAiSkillGroupToggle) {
e.preventDefault();
pageAiToggleSkillGroup(pageAiSkillGroupToggle.getAttribute('data-page-ai-skill-group-toggle') || '');
return;
}
var pageAiToolToggle = closestAction(e.target, '[data-page-ai-tool-toggle]');
if (pageAiToolToggle) {
e.preventDefault();
var toolName = pageAiToolToggle.getAttribute('data-page-ai-tool-toggle') || '';
var nextToolEnabled = pageAiToolToggle.getAttribute('aria-pressed') !== 'true';
void pageAiToggleTool(toolName, nextToolEnabled);
return;
}
var pageAiSessionResume = closestAction(e.target, '[data-page-ai-session-resume]');
if (pageAiSessionResume) {
e.preventDefault();
void pageAiResumeBackendSession(pageAiSessionResume.getAttribute('data-page-ai-session-resume') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiSessionRename = closestAction(e.target, '[data-page-ai-session-rename]');
if (pageAiSessionRename) {
e.preventDefault();
void pageAiRenameBackendSession(pageAiSessionRename.getAttribute('data-page-ai-session-rename') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiSessionDelete = closestAction(e.target, '[data-page-ai-session-delete]');
if (pageAiSessionDelete) {
e.preventDefault();
void pageAiDeleteBackendSession(pageAiSessionDelete.getAttribute('data-page-ai-session-delete') || '').catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
var pageAiPermissionAction = closestAction(e.target, '[data-page-ai-permission-action]');
if (pageAiPermissionAction) {
e.preventDefault();
pageAiResolvePermission(
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny'
);
return;
}
var pageAiOpenLocationAction = closestAction(e.target, '[data-page-ai-open-location]');
if (pageAiOpenLocationAction) {
e.preventDefault();
var loc = String(pageAiOpenLocationAction.getAttribute('data-page-ai-open-location') || '').trim();
if (loc) pageAiOpenLocation(loc);
return;
}
var pageAiSession = closestAction(e.target, '[data-page-ai-session]');
if (pageAiSession) {
e.preventDefault();
pageAiSetActiveSession(pageAiSession.getAttribute('data-page-ai-session') || '');
return;
}
var pageAiSuggestion = closestAction(e.target, '[data-page-ai-suggestion]');
if (pageAiSuggestion) {
e.preventDefault();
var text = pageAiSuggestion.getAttribute('data-page-ai-suggestion') || '';
var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (inputNode instanceof HTMLTextAreaElement) {
inputNode.value = text;
inputNode.focus();
}
return;
}
var pageAiNewSession = closestAction(e.target, '[data-page-ai-action="new-session"]');
if (pageAiNewSession) {
e.preventDefault();
pageUiState.pageAiPage = 'chat';
pageAiStartNewSession();
return;
}
var pageAiHistory = closestAction(e.target, '[data-page-ai-action="history"]');
if (pageAiHistory) {
e.preventDefault();
pageAiLoadSessions();
pageUiState.pageAiPage = pageUiState.pageAiPage === 'history' ? 'chat' : 'history';
renderPageAiControls();
renderPageAiConversation();
if (pageUiState.pageAiPage === 'history') {
void pageAiLoadBackendSessions().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
return;
}
var pageAiSend = closestAction(e.target, '[data-page-ai-action="send"]');
if (pageAiSend) {
e.preventDefault();
var input = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (input instanceof HTMLTextAreaElement) {
var message = input.value;
input.value = '';
void sendPageAiMessage(message);
}
return;
}
var cancelQueuedRun = closestAction(e.target, '[data-page-ai-action="cancel-queued-run"]');
if (cancelQueuedRun) {
e.preventDefault();
void pageAiCancelQueuedRun(cancelQueuedRun.getAttribute('data-page-ai-queue-id'));
return;
}
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
if (searchTrigger) {
e.preventDefault();
@@ -3112,79 +2828,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
closeSearchModal();
closeTreeContextMenu();
}
if (event.key === 'Enter' && !event.shiftKey) {
var aiInput = closestAction(event.target, '[data-page-ai-input]');
if (aiInput instanceof HTMLTextAreaElement) {
event.preventDefault();
var text = aiInput.value;
aiInput.value = '';
void sendPageAiMessage(text);
}
}
});
document.addEventListener('input', function(event) {
var skillSearch = closestAction(event.target, '[data-page-ai-skill-search]');
if (skillSearch instanceof HTMLInputElement) {
pageUiState.pageAiSkillQuery = skillSearch.value;
renderPageAiControls();
return;
}
var sessionSearch = closestAction(event.target, '[data-page-ai-session-search]');
if (sessionSearch instanceof HTMLInputElement) {
var sessionQuery = sessionSearch.value;
window.clearTimeout(pageUiState.pageAiSessionSearchTimer || 0);
pageUiState.pageAiSessionSearchTimer = window.setTimeout(function() {
void pageAiSearchBackendSessions(sessionQuery).catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}, 200);
return;
}
var memoryEditor = closestAction(event.target, '[data-page-ai-memory-editor]');
if (memoryEditor instanceof HTMLTextAreaElement) {
var section = memoryEditor.getAttribute('data-page-ai-memory-editor') || '';
if (['memory', 'user', 'soul'].indexOf(section) >= 0) {
pageUiState.pageAiProfileMemoryDrafts[section] = memoryEditor.value;
}
}
});
document.addEventListener('change', function(event) {
var pageAiAcpRuntimeSelect = closestAction(event.target, '[data-page-ai-acp-runtime]');
if (pageAiAcpRuntimeSelect instanceof HTMLSelectElement) {
var next = String(pageAiAcpRuntimeSelect.value || 'reasonix').trim() || 'reasonix';
pageUiState.pageAiAcpRuntime = next;
pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillError = '';
pageAiPersistSessions();
void pageAiLoadProfiles();
if (next !== 'reasonix') void pageAiLoadProfileMemory();
void pageAiLoadSkills();
void pageAiLoadBackendSessions().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
});
renderPageAiControls();
renderPageAiProviderButtons();
return;
}
var pageAiProfileSelect = closestAction(event.target, '[data-page-ai-profile-select]');
if (pageAiProfileSelect instanceof HTMLSelectElement) {
void pageAiSwitchProfile(pageAiProfileSelect.value);
return;
}
var pageAiHideHermesBuiltin = closestAction(event.target, '[data-page-ai-hide-hermes-builtin]');
if (pageAiHideHermesBuiltin instanceof HTMLInputElement) {
pageAiSetHideHermesBuiltinSkills(pageAiHideHermesBuiltin.checked);
return;
}
var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]');
if (pageAiContextSelect instanceof HTMLSelectElement) {
pageAiSetContextScope(pageAiContextSelect.value);
renderPageAiControls();
return;
}
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
if (globalCheckbox instanceof HTMLInputElement) {
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
@@ -3218,6 +2864,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
void persistPageWidthPreference(pageWidthType, pageWidthSelect.value);
}
});
sidebarPageAi.installPageAiDelegates();
function initializePageUiSurfaces() {
pageUiState.pageOptions = null;
@@ -3243,6 +2890,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}
scheduleInitializePageUiSurfaces();
function mnoteDevHotReloadEnabled() {
try {
return new URL(import.meta.url).searchParams.has('devHot');
} catch (_) {
return false;
}
}
function installMnoteDevHotReload() {
var bootId = '';
var failedOnce = false;
@@ -3271,7 +2926,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
tick();
timer = window.setInterval(tick, 1000);
}
installMnoteDevHotReload();
if (mnoteDevHotReloadEnabled()) {
installMnoteDevHotReload();
}
document.addEventListener('dragstart', function(event) {
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
+14
View File
@@ -260,6 +260,20 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
event: "session.info.updated".into(),
data: json!({ "title": title }),
}),
AcpSessionEvent::ProviderConversationBound {
provider,
remote_conversation_id,
remote_url,
acp_session_id,
} => Some(SseEvent {
event: "provider.conversation.bound".into(),
data: json!({
"provider": provider,
"remoteConversationId": remote_conversation_id,
"remoteUrl": remote_url,
"acpSessionId": acp_session_id,
}),
}),
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
event: "plan.updated".into(),
data: json!({ "entries": entries }),
@@ -54,6 +54,13 @@ pub enum AcpSessionEvent {
},
/// 会话元数据更新,例如自动标题。
SessionInfoUpdate { title: String },
/// Provider 侧远端会话绑定,例如豆包 conversation_id。
ProviderConversationBound {
provider: String,
remote_conversation_id: String,
remote_url: Option<String>,
acp_session_id: Option<String>,
},
/// 计划条目更新。
PlanUpdate { entries: Vec<String> },
/// 连接关闭或异常。
@@ -765,6 +772,46 @@ impl AcpSessionManager {
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
}
SessionUpdate::Unknown {
session_update,
extra,
} if session_update == "provider.conversation.bound" => {
let provider = extra
.get("provider")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("doubao-web")
.to_string();
let remote_conversation_id = extra
.get("remoteConversationId")
.or_else(|| extra.get("remote_conversation_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let remote_url = extra
.get("remoteUrl")
.or_else(|| extra.get("remote_url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let acp_session_id = extra
.get("acpSessionId")
.or_else(|| extra.get("acp_session_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
Some(AcpSessionEvent::ProviderConversationBound {
provider,
remote_conversation_id,
remote_url,
acp_session_id,
})
}
SessionUpdate::Unknown { .. } => {
warn!("ACP unknown session/update variant");
None
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ pub mod block;
pub mod context_tools;
pub mod doc;
pub mod manifest;
pub mod onlyoffice_live;
pub mod page;
pub mod resource;
pub mod skill;
File diff suppressed because it is too large Load Diff
@@ -23,11 +23,17 @@ pub async fn mindmap_fetch(
})?;
let data =
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
let nodes = collect_mindmap_nodes(&data);
let scope = input
.arg_string("scope")
.unwrap_or_else(|| "tree".into())
.to_ascii_lowercase();
let root = mindmap_root_value(&data).clone();
let nodes = collect_mindmap_nodes(&root);
let envelope = if scope == "full_envelope" {
data
} else {
Value::Null
};
Ok(json!({
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
@@ -35,6 +41,8 @@ pub async fn mindmap_fetch(
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"scope": scope,
"root": root,
"envelope": envelope,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
@@ -67,14 +75,150 @@ pub async fn mindmap_apply_ops(
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
}));
}
Err(WebError::bad_request_code(
"mnote_resource_native_patch_required",
format!(
"本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}",
path.display()
),
)
.with_context(context))
ensure_mindmap_revision_precondition(context, input, &path)?;
let content = fs::read_to_string(&path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_read_failed",
format!("无法读取 mindmap resource: {error}"),
)
.with_context(context)
})?;
let mut envelope = serde_json::from_str::<Value>(&content).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_json_invalid",
format!("mindmap resource 不是有效 JSON: {error}"),
)
.with_context(context)
})?;
apply_mindmap_ops(context, &mut envelope, &ops)?;
let serialized = serde_json::to_string_pretty(&envelope).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_serialize_failed",
format!("无法序列化 mindmap JSON: {error}"),
)
.with_context(context)
})?;
fs::write(&path, serialized).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_write_failed",
format!("无法写入 mindmap resource: {error}"),
)
.with_context(context)
})?;
let root = mindmap_root_value(&envelope).clone();
let nodes = collect_mindmap_nodes(&root);
Ok(json!({
"dryRun": false,
"commandName": "mnote.mindmap.apply_ops",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"root": root,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": file_revision(&path),
"changedFiles": [target.relative_path()],
"source": "local_folder"
}))
}
pub async fn mindmap_create_from_outline(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
ensure_resource_write_contract(context, input)?;
let target = ResourceToolTarget::from_input(context, input, "mindmap", "mindmapId")?;
ensure_resource_scope_allowed(context, input, &target)?;
let path = target.resolve_create_path(context, input)?;
let title = input
.arg_string("title")
.unwrap_or_else(|| "KMIND".to_string());
let outline = input.arg_value("outline").ok_or_else(|| {
WebError::bad_request_code("mnote_mindmap_outline_required", "创建思维导图缺少 outline")
.with_context(context)
})?;
let source_refs = input.arg_value("sourceRefs").unwrap_or_else(|| json!([]));
let envelope = mindmap_envelope_from_outline(&title, &outline, source_refs, context)?;
let root = mindmap_root_value(&envelope).clone();
let nodes = collect_mindmap_nodes(&root);
let resource_relative_path = target.relative_path();
let mut changed_files = if input.dry_run.unwrap_or(false) {
Vec::new()
} else {
vec![resource_relative_path.clone()]
};
let mut embed_result = Value::Null;
if !input.dry_run.unwrap_or(false) {
let parent = path.parent().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_bad_path", "无法解析 mindmap 父目录")
.with_context(context)
})?;
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_create_dir_failed",
format!("无法创建 mindmap 目录: {error}"),
)
.with_context(context)
})?;
let content = serde_json::to_string_pretty(&envelope).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_serialize_failed",
format!("无法序列化 mindmap JSON: {error}"),
)
.with_context(context)
})?;
fs::write(&path, content).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_write_failed",
format!("无法写入 mindmap resource: {error}"),
)
.with_context(context)
})?;
if input
.arg_value("embedIntoPage")
.and_then(|value| value.as_bool())
.unwrap_or(true)
{
embed_result = embed_mindmap_into_local_markdown_page(
context,
input,
&target.document_id,
&resource_relative_path,
&title,
)?;
if let Some(changed_file) = embed_result
.get("changedFile")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
changed_files.push(changed_file.to_string());
}
}
}
Ok(json!({
"dryRun": input.dry_run.unwrap_or(false),
"commandName": "mnote.mindmap.create_from_outline",
"objectIdentity": target.object_identity,
"resourceKind": "mindmap",
"documentId": target.document_id,
"mindmapId": target.resource_id,
"resourcePath": target.resource_path,
"envelope": envelope,
"root": root,
"nodes": nodes,
"edges": [],
"markdownSummary": mindmap_markdown_summary(&nodes),
"revision": if input.dry_run.unwrap_or(false) { Value::Null } else { file_revision(&path) },
"changedFiles": changed_files,
"embedResult": embed_result,
"source": "local_folder"
}))
}
pub async fn office_fetch_summary(
@@ -235,6 +379,45 @@ impl ResourceToolTarget {
Ok(canonical)
}
fn resolve_create_path(
&self,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<PathBuf, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let relative = self.relative_path();
let relative_path = Path::new(&relative);
if relative_path.is_absolute()
|| relative_path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
let path = root.join(relative_path);
if !path.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(path)
}
fn relative_path(&self) -> String {
self.resource_path
.as_deref()
@@ -315,9 +498,508 @@ fn local_root_uri_for_resource(input: &ToolCallInput) -> Option<String> {
})
}
fn embed_mindmap_into_local_markdown_page(
context: &RequestContext,
input: &ToolCallInput,
document_id: &str,
resource_relative_path: &str,
title: &str,
) -> Result<Value, WebError> {
let root_uri = local_root_uri_for_resource(input).ok_or_else(|| {
WebError::new(
StatusCode::FORBIDDEN,
"mnote_resource_root_uri_required",
"资源工具需要授权 rootUri",
)
.with_context(context)
})?;
let root = crate::routes::ensure_local_workspace_access(context, &root_uri)
.map_err(|error| error.with_context(context))?;
let page_relative_path = local_markdown_relative_path_from_document_id(context, document_id)?;
let page_path = root.join(&page_relative_path);
if !page_path.starts_with(&root) {
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
if !page_path.is_file() {
return Err(WebError::bad_request_code(
"mnote_resource_page_not_found",
"找不到要绑定 mindmap 的本地 Markdown 页面",
)
.with_context(context));
}
let href = markdown_href_from_page(&root, &page_path, resource_relative_path);
let mut markdown = fs::read_to_string(&page_path).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_page_read_failed",
format!("无法读取本地 Markdown 页面: {error}"),
)
.with_context(context)
})?;
let link = format!("[{}]({href})", markdown_link_label(title));
if markdown.contains(&link) || markdown.contains(&format!("]({href})")) {
return Ok(json!({
"status": "already_present",
"documentId": document_id,
"changedFile": page_relative_path,
"href": href
}));
}
if !markdown.ends_with('\n') {
markdown.push('\n');
}
if !markdown.ends_with("\n\n") {
markdown.push('\n');
}
markdown.push_str(&link);
markdown.push('\n');
fs::write(&page_path, markdown).map_err(|error| {
WebError::bad_request_code(
"mnote_resource_page_write_failed",
format!("无法写入本地 Markdown 页面: {error}"),
)
.with_context(context)
})?;
Ok(json!({
"status": "embedded",
"documentId": document_id,
"changedFile": page_relative_path,
"href": href
}))
}
fn local_markdown_relative_path_from_document_id(
context: &RequestContext,
document_id: &str,
) -> Result<String, WebError> {
let encoded = document_id
.trim()
.strip_prefix("local-md:")
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_local_markdown_required",
"绑定 mindmap 需要 local-md 页面",
)
.with_context(context)
})?;
let relative = crate::routes::decode_local_id_segment(encoded)
.map_err(|error| error.with_context(context))?;
let path = Path::new(&relative);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(WebError::bad_request_code(
"mnote_resource_root_escape",
"资源工具不能越过授权目录",
)
.with_context(context));
}
Ok(relative)
}
fn markdown_href_from_page(root: &Path, page_path: &Path, resource_relative_path: &str) -> String {
let page_dir = page_path.parent().unwrap_or(root);
let target = root.join(resource_relative_path);
if let Ok(relative) = target.strip_prefix(page_dir) {
return path_to_markdown_href(relative);
}
let page_dir_relative = page_dir.strip_prefix(root).unwrap_or(Path::new(""));
let depth = page_dir_relative
.components()
.filter(|component| matches!(component, std::path::Component::Normal(_)))
.count();
let mut href = String::new();
for _ in 0..depth {
href.push_str("../");
}
href.push_str(&resource_relative_path.replace('\\', "/"));
href
}
fn path_to_markdown_href(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn markdown_link_label(value: &str) -> String {
value
.trim()
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('\n', " ")
}
fn default_mindmap_view() -> Value {
json!({
"state": {
"scale": 1,
"sx": 0,
"sy": 0,
"x": -44.99991989135742_f64,
"y": -15.500006675720217_f64
},
"transform": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": -44.99991989135742_f64,
"f": -15.500006675720217_f64,
"originX": 0,
"originY": 0,
"rotate": 0,
"scaleX": 1,
"scaleY": 1,
"shear": 0,
"translateX": -44.99991989135742_f64,
"translateY": -15.500006675720217_f64
}
})
}
fn mindmap_envelope_from_outline(
title: &str,
outline: &Value,
source_refs: Value,
context: &RequestContext,
) -> Result<Value, WebError> {
let outline_items = outline.as_array().ok_or_else(|| {
WebError::bad_request_code(
"mnote_mindmap_outline_invalid",
"mindmap outline 必须是数组",
)
.with_context(context)
})?;
let title = title.trim();
let root_title = if title.is_empty() { "KMIND" } else { title };
Ok(json!({
"data": {
"children": mindmap_outline_items_to_children(outline_items, "node"),
"data": {
"expand": true,
"isActive": false,
"text": root_title,
"uid": "root"
}
},
"view": default_mindmap_view(),
"metadata": {
"sourceRefs": source_refs
}
}))
}
fn mindmap_outline_items_to_children(items: &[Value], prefix: &str) -> Vec<Value> {
items
.iter()
.enumerate()
.map(|(index, item)| {
let ordinal = index + 1;
let uid = format!("{prefix}_{ordinal}");
let text = item
.get("text")
.or_else(|| item.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名节点");
let children = item
.get("children")
.and_then(Value::as_array)
.map(|children| mindmap_outline_items_to_children(children, &uid))
.unwrap_or_default();
json!({
"data": {
"expand": true,
"isActive": false,
"text": text,
"uid": uid
},
"children": children
})
})
.collect()
}
fn mindmap_root_value(value: &Value) -> &Value {
value
.get("data")
.filter(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(value)
}
fn mindmap_root_value_mut(value: &mut Value) -> Option<&mut Value> {
let has_envelope_root = value
.get("data")
.map(|data| data.get("data").is_some() || data.get("children").is_some())
.unwrap_or(false);
if has_envelope_root {
return value.get_mut("data");
}
Some(value)
}
fn apply_mindmap_ops(
context: &RequestContext,
envelope: &mut Value,
ops: &Value,
) -> Result<(), WebError> {
let ops = ops.as_array().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_ops_invalid", "mindmap ops 必须是数组")
.with_context(context)
})?;
let root = mindmap_root_value_mut(envelope).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_json_invalid", "mindmap 缺少 root")
.with_context(context)
})?;
for op in ops {
apply_mindmap_op(context, root, op)?;
}
Ok(())
}
fn apply_mindmap_op(
context: &RequestContext,
root: &mut Value,
op: &Value,
) -> Result<(), WebError> {
let op_name = op
.get("op")
.or_else(|| op.get("type"))
.or_else(|| op.get("action"))
.and_then(Value::as_str)
.map(normalize_mindmap_op_name)
.ok_or_else(|| {
WebError::bad_request_code("mnote_resource_op_required", "mindmap op 缺少 op")
.with_context(context)
})?;
match op_name.as_str() {
"updatetext" | "updatenode" => {
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_required", "更新节点缺少 nodeId")
.with_context(context)
})?;
let text = mindmap_op_string(op, &["text", "title"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_text_required", "更新节点缺少 text")
.with_context(context)
})?;
let node = find_mindmap_node_mut(root, &node_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要更新的节点")
.with_context(context)
})?;
set_mindmap_node_text(node, &text);
}
"insertchild" | "addchild" => {
let parent_id = mindmap_op_string(op, &["parentId", "parent_id", "nodeId"])
.ok_or_else(|| {
WebError::bad_request_code(
"mnote_resource_parent_required",
"插入子节点缺少 parentId",
)
.with_context(context)
})?;
let parent = find_mindmap_node_mut(root, &parent_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到父节点")
.with_context(context)
})?;
let child_input = op.get("node").unwrap_or(op);
let child = mindmap_node_from_input(context, child_input)?;
ensure_mindmap_children_array(context, parent)?.push(child);
}
"deletenode" => {
let node_id = mindmap_op_string(op, &["nodeId", "node_id", "id"]).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_required", "删除节点缺少 nodeId")
.with_context(context)
})?;
if mindmap_node_matches(root, &node_id) {
return Err(WebError::bad_request_code(
"mnote_resource_root_delete_forbidden",
"不能删除 mindmap 根节点",
)
.with_context(context));
}
remove_mindmap_node(root, &node_id).ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_not_found", "找不到要删除的节点")
.with_context(context)
})?;
}
_ => {
return Err(WebError::bad_request_code(
"mnote_resource_op_unsupported",
format!("暂不支持 mindmap op: {op_name}"),
)
.with_context(context));
}
}
Ok(())
}
fn normalize_mindmap_op_name(value: &str) -> String {
value
.chars()
.filter(|ch| *ch != '_' && *ch != '-' && !ch.is_whitespace())
.flat_map(char::to_lowercase)
.collect()
}
fn mindmap_op_string(op: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
op.get(*key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn find_mindmap_node_mut<'a>(node: &'a mut Value, node_id: &str) -> Option<&'a mut Value> {
if mindmap_node_matches(node, node_id) {
return Some(node);
}
let children = node.get_mut("children").and_then(Value::as_array_mut)?;
for child in children {
if let Some(found) = find_mindmap_node_mut(child, node_id) {
return Some(found);
}
}
None
}
fn mindmap_node_matches(node: &Value, node_id: &str) -> bool {
mindmap_node_id(node)
.as_deref()
.map(|value| value == node_id)
.unwrap_or(false)
}
fn mindmap_node_id(node: &Value) -> Option<String> {
node.pointer("/data/uid")
.or_else(|| node.pointer("/data/id"))
.or_else(|| node.get("uid"))
.or_else(|| node.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn set_mindmap_node_text(node: &mut Value, text: &str) {
if let Some(data) = node.get_mut("data").and_then(Value::as_object_mut) {
data.insert("text".into(), json!(text));
return;
}
if let Some(object) = node.as_object_mut() {
object.insert("text".into(), json!(text));
}
}
fn ensure_mindmap_children_array<'a>(
context: &RequestContext,
node: &'a mut Value,
) -> Result<&'a mut Vec<Value>, WebError> {
let object = node.as_object_mut().ok_or_else(|| {
WebError::bad_request_code("mnote_resource_node_invalid", "mindmap 节点必须是对象")
.with_context(context)
})?;
let children = object
.entry("children")
.or_insert_with(|| Value::Array(Vec::new()));
if !children.is_array() {
*children = Value::Array(Vec::new());
}
Ok(children.as_array_mut().expect("children 已归一为数组"))
}
fn mindmap_node_from_input(context: &RequestContext, input: &Value) -> Result<Value, WebError> {
if input.get("data").is_some() || input.get("children").is_some() {
let mut node = input.clone();
ensure_mindmap_children_array(context, &mut node)?;
return Ok(node);
}
let text = input
.get("text")
.or_else(|| input.get("title"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("未命名节点");
let uid = input
.get("uid")
.or_else(|| input.get("id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(text);
let mut node = json!({
"data": {
"expand": true,
"isActive": false,
"text": text,
"uid": uid
},
"children": []
});
if let Some(object) = node.as_object_mut() {
for key in ["metadata", "sourceRefs", "refs", "note", "hyperlink"] {
if let Some(value) = input.get(key) {
object.insert(key.into(), value.clone());
}
}
}
Ok(node)
}
fn remove_mindmap_node(parent: &mut Value, node_id: &str) -> Option<Value> {
let children = parent.get_mut("children").and_then(Value::as_array_mut)?;
if let Some(index) = children
.iter()
.position(|child| mindmap_node_matches(child, node_id))
{
return Some(children.remove(index));
}
for child in children {
if let Some(removed) = remove_mindmap_node(child, node_id) {
return Some(removed);
}
}
None
}
fn ensure_mindmap_revision_precondition(
context: &RequestContext,
input: &ToolCallInput,
path: &Path,
) -> Result<(), WebError> {
let Some(expected) = input.arg_value("expectedRevision") else {
return Ok(());
};
let current = file_revision(path);
if revision_label(&expected).as_deref() != revision_label(&current).as_deref() {
return Err(WebError::bad_request_code(
"mnote_resource_revision_conflict",
"mindmap resource revision 已变化,请重新读取后再写入",
)
.with_context(context));
}
Ok(())
}
fn revision_label(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.trim().to_string()).filter(|value| !value.is_empty()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}
}
fn collect_mindmap_nodes(value: &Value) -> Vec<Value> {
let mut nodes = Vec::new();
collect_mindmap_nodes_inner(value, &mut nodes);
collect_mindmap_nodes_inner(mindmap_root_value(value), &mut nodes);
nodes
}
@@ -41,6 +41,68 @@ const SKILLS: &[MnoteSkill] = &[
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
},
MnoteSkill {
id: "mnote-onlyoffice-live",
title: "MNote ONLYOFFICE live bridge",
description: "Operate the currently open ONLYOFFICE editor session for Word, Excel, and PPT.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["onlyoffice"],
tool_names: &[
"mnote.onlyoffice.session.current",
"mnote.onlyoffice.capabilities",
"mnote.onlyoffice.selection.get",
"mnote.onlyoffice.document.insert_text",
"mnote.onlyoffice.document.replace_selection",
"mnote.onlyoffice.document.insert_html",
"mnote.onlyoffice.document.export",
"mnote.onlyoffice.document.search_replace",
"mnote.onlyoffice.document.insert_table",
"mnote.onlyoffice.document.get_comments",
"mnote.onlyoffice.document.add_comment",
"mnote.onlyoffice.sheet.get_sheets",
"mnote.onlyoffice.sheet.add_sheet",
"mnote.onlyoffice.sheet.rename_sheet",
"mnote.onlyoffice.sheet.get_range",
"mnote.onlyoffice.sheet.get_range_values",
"mnote.onlyoffice.sheet.get_values",
"mnote.onlyoffice.sheet.set_value",
"mnote.onlyoffice.sheet.set_formula",
"mnote.onlyoffice.sheet.batch_set_values",
"mnote.onlyoffice.sheet.set_range_values",
"mnote.onlyoffice.sheet.format_range",
"mnote.onlyoffice.sheet.set_dimensions",
"mnote.onlyoffice.sheet.sort_range",
"mnote.onlyoffice.sheet.add_chart",
"mnote.onlyoffice.presentation.get_slides",
"mnote.onlyoffice.presentation.get_slide_texts",
"mnote.onlyoffice.presentation.get_shapes",
"mnote.onlyoffice.presentation.add_text_slide",
"mnote.onlyoffice.presentation.replace_text",
"mnote.onlyoffice.presentation.set_shape_text",
"mnote.onlyoffice.presentation.delete_slide",
"mnote.onlyoffice.presentation.add_table",
"mnote.onlyoffice.presentation.clear_slide",
"mnote.onlyoffice.presentation.add_shape",
],
content: include_str!("../../../../../skills/mnote-onlyoffice-live/SKILL.md"),
},
MnoteSkill {
id: "mnote-mindmap",
title: "MNote mindmap editing",
description: "Read, update, summarize, or create MNote mindmap resources, including generating a new mindmap from PDF or document outlines.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder", "resource"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.mindmap.fetch",
"mnote.mindmap.apply_ops",
"mnote.mindmap.create_from_outline",
],
content: include_str!("../../../../../skills/mnote-mindmap/SKILL.md"),
},
MnoteSkill {
id: "mnote-chat-only",
title: "MNote chat only",
@@ -146,4 +208,108 @@ mod tests {
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
}
#[test]
fn skill_registry_exposes_onlyoffice_live_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-onlyoffice-live")
.expect("reasonix should see live ONLYOFFICE skill");
assert_eq!(skill["readOnly"], false);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.session.current"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.sheet.batch_set_values"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.sheet.add_chart"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.presentation.add_shape"),
true
);
assert_eq!(
skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.onlyoffice.presentation.replace_text"),
true
);
}
#[test]
fn skill_registry_exposes_mindmap_skill_to_agents() {
let reasonix_skills = skill_summaries_for_agent(Some("reasonix"));
let skill = reasonix_skills
.iter()
.find(|skill| skill["id"] == "mnote-mindmap")
.expect("reasonix should see mindmap skill");
assert_eq!(skill["readOnly"], false);
assert!(skill["requiresContextRefs"]
.as_array()
.expect("context refs")
.iter()
.any(|value| value == "resource"));
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.mindmap.create_from_outline"));
}
#[tokio::test]
async fn skill_read_returns_mindmap_skill_content() {
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/tools/execute".parse().expect("uri"),
&axum::http::HeaderMap::new(),
);
let input: ToolCallInput = serde_json::from_value(json!({
"toolName": "mnote.skill.read",
"args": {
"skillId": "mnote-mindmap",
"agentId": "reasonix"
}
}))
.expect("input");
let payload = skill_read(&context, &input).await.expect("skill read");
assert_eq!(payload["skill"]["id"], "mnote-mindmap");
assert!(payload["content"]
.as_str()
.unwrap_or_default()
.contains("mnote.mindmap.create_from_outline"));
let content = payload["content"].as_str().unwrap_or_default();
assert!(content.contains("Mindmap outline format"));
assert!(content.contains("Use concise keywords or short phrases"));
assert!(content.contains("Avoid long paragraphs"));
assert!(payload["tools"]
.as_array()
.expect("tools")
.iter()
.any(|tool| tool == "mnote.mindmap.create_from_outline"));
}
}
@@ -65,7 +65,7 @@ impl PageAggregateBuilder {
Self {
document_id: String::new(),
workspace_id: "default".to_string(),
source: PageAggregateSource::CompatMetaContentJoin,
source: PageAggregateSource::KernelProjection,
parent_id: None,
path: Vec::new(),
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
@@ -372,3 +372,29 @@ impl Default for PageAggregateBuilder {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_defaults_to_kernel_projection() {
let aggregate = PageAggregateBuilder::new()
.document_id("doc_1")
.workspace_id("ws_demo")
.build();
assert_eq!(aggregate.source, PageAggregateSource::KernelProjection);
}
#[test]
fn explicit_compat_source_is_still_supported() {
let aggregate = PageAggregateBuilder::new()
.document_id("doc_1")
.workspace_id("ws_demo")
.source(PageAggregateSource::CompatMetaContentJoin)
.build();
assert_eq!(aggregate.source, PageAggregateSource::CompatMetaContentJoin);
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More