收口 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:
@@ -79,6 +79,7 @@ design/05-editor-mainline/reference-code
|
|||||||
|
|
||||||
# MNote 本地运行索引与一次性浏览器截图
|
# MNote 本地运行索引与一次性浏览器截图
|
||||||
/.mnote/
|
/.mnote/
|
||||||
|
/ai-sessions/
|
||||||
/filetree-*.png
|
/filetree-*.png
|
||||||
/task*-*.png
|
/task*-*.png
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,9 @@
|
|||||||
- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selection,Hermes 或 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 设计冻结,当前不实施。
|
- 页面 AI 编辑当前 local-first 主路径为:页面定位到真实 `.md` 文件,MNote 计算 `AiAccessScope` / allowed roots / selection,Hermes 或 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/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-tree-first-graph-kernel-v1.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/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/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`
|
- `/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
|
||||||
|
- Owner:03-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
|
||||||
|
- Owner:03-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 smoke:mock 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 page,OCR 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 模式把 `[OCR:photo.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 追加 `[OCR:photo.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。
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# 4-52 resource lifecycle 兼容路径在 Convex 退役后丢失 artifact / 拒绝 mindmap lifecycle
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 状态:done
|
||||||
|
- Owner:04-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
|
||||||
|
- Owner:04-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` fallback,resource 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`
|
||||||
+22
-8
@@ -1,5 +1,9 @@
|
|||||||
# Office local-first 预览、编辑与插件噪音缺口审查 v1
|
# Office local-first 预览、编辑与插件噪音缺口审查 v1
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 状态:done
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
本轮只审查 OnlyOffice 与 MNote local-first 工作区之间的三个接口缺口:
|
本轮只审查 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/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 模式不阻断打开”的原因。
|
- `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`。
|
- 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 同步` 没有完整闭环。
|
- `/api/onlyoffice/callback` 已由 `5-36` 补齐 local-folder `status 2/6 -> 下载 body.url -> 原文件覆盖写回` 的后端契约;legacy Next 代理仍保留为 cloud/compat 边界。
|
||||||
- `layout.rs` 中正文附件菜单已有 `new-window`,但没有“使用编辑模式打开”。部分路径当前会默认生成 `mode=edit`,这与“默认只读、显式编辑”的产品口径不一致。
|
- `layout.rs` / browser runtime 已提供“使用编辑模式打开”入口,默认打开保持 `mode=view`,显式 edit 保留 guard。
|
||||||
- annotation 插件 404 / pageerror 暂未证明会影响文档渲染;当前更像是 OnlyOffice 静态插件包或自定义插件配置缺失导致的 console 噪音。
|
- annotation/custom assistant 插件噪音已由 `5-37` / `5-40` 分类:插件噪音不等于主文档失败,真实 `errorCode=-18`、WebSocket 失败或 iframe 空白不能被噪音掩盖。
|
||||||
|
|
||||||
## 根因判断
|
## 根因判断
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ P0 目标:
|
|||||||
- 菜单提供“使用编辑模式打开”入口。
|
- 菜单提供“使用编辑模式打开”入口。
|
||||||
- 编辑入口必须带 guard:清楚标记为实验能力,或在 local-folder writeback 未闭环时阻止/提示。
|
- 编辑入口必须带 guard:清楚标记为实验能力,或在 local-folder writeback 未闭环时阻止/提示。
|
||||||
|
|
||||||
P1 目标才是实现 local-folder callback 写回。
|
P1 目标是实现 local-folder callback 写回;该项已由 `5-36` 完成后端契约。
|
||||||
|
|
||||||
### 3. annotation 插件 404 / pageerror
|
### 3. annotation 插件 404 / pageerror
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ P0 目标:
|
|||||||
- P0:若保存闭环未完成,编辑入口必须有 guard / 实验标记。
|
- P0:若保存闭环未完成,编辑入口必须有 guard / 实验标记。
|
||||||
|
|
||||||
- `5-36-onlyoffice-local-edit-save-callback-contract-v1.md`
|
- `5-36-onlyoffice-local-edit-save-callback-contract-v1.md`
|
||||||
- P1:设计或实现 local-folder callback 写回。
|
- P1:local-folder callback 写回。
|
||||||
- P1:覆盖 status 2/6、下载 URL rewrite、路径权限、冲突保护。
|
- P1:覆盖 status 2/6、下载 URL rewrite、路径权限、冲突保护。
|
||||||
|
|
||||||
- `5-37-onlyoffice-annotation-plugin-noise-policy-v1.md`
|
- `5-37-onlyoffice-annotation-plugin-noise-policy-v1.md`
|
||||||
@@ -99,9 +103,19 @@ P0 目标:
|
|||||||
- 菜单提供“使用编辑模式打开”,进入前有实验 guard。
|
- 菜单提供“使用编辑模式打开”,进入前有实验 guard。
|
||||||
- 已存在 Office resource tab 从 view 切 edit 会刷新同一 iframe URL,不再只激活旧 tab。
|
- 已存在 Office resource tab 从 view 切 edit 会刷新同一 iframe URL,不再只激活旧 tab。
|
||||||
- annotation/custom assistant 插件 404 已归类为 non-critical noise,不作为主文档打开失败。
|
- annotation/custom assistant 插件 404 已归类为 non-critical noise,不作为主文档打开失败。
|
||||||
- P1 仍保留:
|
- P1 已拆分并收口:
|
||||||
- local-folder Office edit/save callback 写回闭环未实现,编辑模式仍不能承诺保存到原文件。
|
- `5-36`:local-folder Office edit/save callback 写回后端契约已完成,覆盖 status 2/6、非写状态、未认证、路径越界。
|
||||||
- `onRequestEditRights` 事件重新初始化 edit URL 未实现。
|
- `5-35`:`onRequestEditRights` 事件会重新初始化为 `mode=edit` URL,不只 reload 当前 view。
|
||||||
|
- 本 umbrella review 仅作为缺口审查和拆分记录归档;后续真实 Office 编辑保存端到端浏览器验收若发现新问题,应另建具体 bug。
|
||||||
- 浏览器证据:
|
- 浏览器证据:
|
||||||
- Reasonix:`tmp/reasonix-office-view-edit-plugin-2026-05-21/result.json` 与截图。
|
- 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`。
|
- 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`
|
||||||
+21
-3
@@ -1,5 +1,9 @@
|
|||||||
# 5-35 Office 编辑模式菜单与保护
|
# 5-35 Office 编辑模式菜单与保护
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 状态:done
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
Office 文件默认只读打开;在正文附件三点菜单和文件树资源右键菜单中增加“使用编辑模式打开”入口。保存闭环未完成前,编辑入口必须有 guard 或明确实验标记。
|
Office 文件默认只读打开;在正文附件三点菜单和文件树资源右键菜单中增加“使用编辑模式打开”入口。保存闭环未完成前,编辑入口必须有 guard 或明确实验标记。
|
||||||
@@ -28,16 +32,30 @@ ONLYOFFICE `mode=edit` 只是编辑器初始化模式。若 MNote 没有完成 c
|
|||||||
- [x] 编辑模式入口明确使用 `mode=edit` 打开到主 resource tab 或新窗口,行为与现有打开目标一致。
|
- [x] 编辑模式入口明确使用 `mode=edit` 打开到主 resource tab 或新窗口,行为与现有打开目标一致。
|
||||||
- [x] 在 local-folder save callback 未闭环时,编辑入口有 guard:可提示“编辑保存仍在实验中”,或通过 data/status 标记便于测试识别。
|
- [x] 在 local-folder save callback 未闭环时,编辑入口有 guard:可提示“编辑保存仍在实验中”,或通过 data/status 标记便于测试识别。
|
||||||
- [x] 已存在 Office resource tab 从 `mode=view` 切到显式 `mode=edit` 时,刷新同一 tab 的 iframe URL,而不是只激活旧 tab。
|
- [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 --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_opens_office_assets_through_resource_shell -- --test-threads=1`
|
||||||
- `cargo test -p mnote-web onlyoffice -- --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 提示。
|
- 浏览器截图:默认打开是只读;菜单中存在编辑入口;点击编辑入口后的页面 URL / debug state 包含 `mode=edit` 或 guard 提示。
|
||||||
|
|
||||||
## 本轮执行记录
|
## 本轮执行记录
|
||||||
|
|
||||||
|
- 2026-06-01:Codex 补齐 `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-21:Reasonix worker B 卡在计划阶段后由 Codex 终止;本条实现来自其它 Reasonix 结果与 Codex 复核修正。
|
- 2026-05-21:Reasonix worker B 卡在计划阶段后由 Codex 终止;本条实现来自其它 Reasonix 结果与 Codex 复核修正。
|
||||||
- `buildOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`。
|
- `buildOnlyOfficeOpenUrl` 默认 mode 从 `'edit'` 改为 `'view'`。
|
||||||
- `buildOnlyOfficeOpenPath` 默认 mode 从 `'edit'` 改为 `'view'`。
|
- `buildOnlyOfficeOpenPath` 默认 mode 从 `'edit'` 改为 `'view'`。
|
||||||
+30
-9
@@ -1,12 +1,16 @@
|
|||||||
# 5-36 OnlyOffice local-folder 编辑保存 callback 契约
|
# 5-36 OnlyOffice local-folder 编辑保存 callback 契约
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
- 状态:done
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
为 local-folder Office 编辑保存建立 callback 写回契约。P1 可以先落设计和测试骨架;若实现,必须覆盖 status 2/6 的下载写回和路径安全。
|
为 local-folder Office 编辑保存建立 callback 写回契约。P1 可以先落设计和测试骨架;若实现,必须覆盖 status 2/6 的下载写回和路径安全。
|
||||||
|
|
||||||
## 原因
|
## 原因
|
||||||
|
|
||||||
ONLYOFFICE 官方保存链路要求 callback `status === 2` 或 `status === 6` 时,集成后端下载 `body.url` 并写回原文件。当前 `mnote-web` callback 仍代理 legacy Next,local-folder 写回未闭环。
|
ONLYOFFICE 官方保存链路要求 callback `status === 2` 或 `status === 6` 时,集成后端下载 `body.url` 并写回原文件。此前 `mnote-web` callback 仍代理 legacy Next,local-folder 写回未闭环。本轮已补齐 local-folder callback 写回路径。
|
||||||
|
|
||||||
## 允许修改
|
## 允许修改
|
||||||
|
|
||||||
@@ -23,21 +27,38 @@ ONLYOFFICE 官方保存链路要求 callback `status === 2` 或 `status === 6`
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] 明确 local asset callback 定位:从 `assetId` / `fileUrl` / session 推导 rootUri 与 path。
|
- [x] 明确 local asset callback 定位:callback URL 携带 `rootUri` / `path` / `sessionId` / `token`,并用 bridge session 校验 asset match。
|
||||||
- [ ] status 非 2/6 时返回 `{ "error": 0 }`,不写文件。
|
- [x] status 非 2/6 时返回 `{ "error": 0 }`,不写文件。
|
||||||
- [ ] status 2/6 时下载 rewritten `body.url`。
|
- [x] status 2/6 时下载 rewritten `body.url`。
|
||||||
- [ ] 写回前校验目标路径属于当前 local root allowed roots。
|
- [x] 写回前校验目标路径属于当前 local root allowed roots。
|
||||||
- [ ] 写回后触发 watcher / projection 刷新或说明现有 watcher 如何感知。
|
- [x] 写回后写入原始本地文件;后续页面刷新由现有 local-folder watcher / projection refresh 感知。浏览器编辑保存端到端 smoke 另列后续验收,不作为本 bug 阻塞项。
|
||||||
- [ ] 若本轮不实现完整写回,必须在编辑入口保留 guard,不让用户以为保存已支持。
|
- [x] 编辑入口仍保留 guard,不让用户误解为完整协作编辑能力已产品化。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
- `cargo test -p mnote-web onlyoffice_callback -- --test-threads=1`
|
- `cargo test --manifest-path rust/Cargo.toml -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_local_callback -- --test-threads=1`
|
||||||
|
- `cargo test --manifest-path rust/Cargo.toml -p adapter-onlyoffice callback_preparation -- --test-threads=1`
|
||||||
- 后续浏览器编辑保存验收需单独设计,不纳入 P0。
|
- 后续浏览器编辑保存验收需单独设计,不纳入 P0。
|
||||||
|
|
||||||
## 本轮执行记录
|
## 本轮执行记录
|
||||||
|
|
||||||
|
- 2026-06-01:Codex 复核当前实现并补齐 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-21:Reasonix worker C 分析完成。
|
- 2026-05-21:Reasonix worker C 分析完成。
|
||||||
**结论:本轮不应实现完整写回。**
|
**结论:本轮不应实现完整写回。**
|
||||||
|
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
# 5-37 OnlyOffice annotation 插件噪音策略
|
# 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。
|
定位 OnlyOffice annotation / custom assistant 插件 404 或 pageerror 来源,并把它从主打开失败中剥离:能禁用则禁用,不能禁用则在测试和日志中明确为 non-critical。
|
||||||
@@ -25,7 +27,7 @@ ONLYOFFICE 插件通过 `editorConfig.plugins.autostart` 和 `pluginsData` 注
|
|||||||
- [x] 找到 annotation/custom assistant 插件配置或请求来源。
|
- [x] 找到 annotation/custom assistant 插件配置或请求来源。
|
||||||
- [x] 若 MNote 当前不依赖该插件,禁用无效 autostart / pluginsData。(确认当前代码已无 autostart 注入,无需额外禁用)
|
- [x] 若 MNote 当前不依赖该插件,禁用无效 autostart / pluginsData。(确认当前代码已无 autostart 注入,无需额外禁用)
|
||||||
- [x] 若属于 OnlyOffice 内置可选插件缺资源,记录为 non-critical,并更新浏览器测试过滤口径。
|
- [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
|
||||||
|
- Owner:05-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
|
||||||
|
- Owner:05-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
|
||||||
|
- Owner:07-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 snapshot;Page 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
|
||||||
|
- Owner:07-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 session:A 和 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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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 归档
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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 文档 buffer,Office 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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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-up:Office 主文档加载噪音治理、第三批 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 config;mock 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` 的剩余原因:
|
||||||
|
|
||||||
|
- 已归档。第三批 recipe(Word 图片/修订/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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:07-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
|
||||||
|
- Owner:10-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 checklist;2026-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
|
||||||
|
- Owner:10-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
|
||||||
|
- Owner:10-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
|
||||||
|
- Owner:10-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
|
||||||
|
- Owner:10-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。
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 创建时间:2026-05-21
|
> 创建时间:2026-05-21
|
||||||
>
|
>
|
||||||
> 当前状态:`PROCESS`
|
> 当前状态:`DONE`
|
||||||
>
|
>
|
||||||
> 上位入口:`design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
|
> 上位入口:`design/01-tree-first-graph-kernel/process/1-8-mvp-post-process-execution-order-v1.md`
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 创建时间:2026-05-19
|
> 创建时间:2026-05-19
|
||||||
>
|
>
|
||||||
> 当前状态:`PROCESS`
|
> 当前状态:`DONE`
|
||||||
>
|
>
|
||||||
> 上位依据:
|
> 上位依据:
|
||||||
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
> - `/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
||||||
|
|||||||
+20
-5
@@ -6,14 +6,18 @@
|
|||||||
>
|
>
|
||||||
> 2026-05-22 归档治理补充:当前 active `process/` 只保留可直接执行的少量收口稿;长期架构、愿景、参考矩阵、Wolai 对标基线、Mindmap 总设计、旧 AI 块级执行稿和 Convex Web 迁移入口已分别移动到 `reference/`、`done/` 或 `old/`。本文是后续调度入口,不代表下列所有历史入口仍在 `process/`。
|
> 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 中自行猜优先级。
|
> 目标:把本轮 design governance 后剩余的主线 `process/` 文档排成可执行顺序,避免后续 worker 在 active process 中自行猜优先级。
|
||||||
>
|
>
|
||||||
> 上位依据:
|
> 上位依据:
|
||||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
||||||
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
|
> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
|
||||||
> - `/mnt/Data1T/mnote/design/README.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/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. 一句话顺序
|
## 1. 一句话顺序
|
||||||
|
|
||||||
@@ -87,6 +91,8 @@
|
|||||||
- `4-45` 四项 checklist 完成后归档到 `04-tree-domain/done/`。
|
- `4-45` 四项 checklist 完成后归档到 `04-tree-domain/done/`。
|
||||||
- `4-44` browser smoke 全绿后归档到 `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
|
### P0.2 local folder no-refresh / restore focus
|
||||||
|
|
||||||
入口文档:
|
入口文档:
|
||||||
@@ -109,6 +115,8 @@
|
|||||||
- local folder 子项通过后,`4-34` 归档。
|
- local folder 子项通过后,`4-34` 归档。
|
||||||
- React / Rust 主壳 focus 证据完成后,`4-28` 归档。
|
- React / Rust 主壳 focus 证据完成后,`4-28` 归档。
|
||||||
|
|
||||||
|
2026-06-01 状态:本项已由 `4-34` / `4-28` done 文档和后续 tree/filetree lifecycle smoke 证据覆盖,不再作为 P0 阻塞项。
|
||||||
|
|
||||||
## 4. P1:统一工作区身份、BufferStore 和 Page Aggregate
|
## 4. P1:统一工作区身份、BufferStore 和 Page Aggregate
|
||||||
|
|
||||||
这一步是 MVP 后阶段真正的底座收口。
|
这一步是 MVP 后阶段真正的底座收口。
|
||||||
@@ -133,6 +141,7 @@
|
|||||||
- `cargo test -p mnote-web filetree_runtime -- --test-threads=1`
|
- `cargo test -p mnote-web filetree_runtime -- --test-threads=1`
|
||||||
- `cargo test -p mnote-web web_shell -- --test-threads=1`
|
- `cargo test -p mnote-web web_shell -- --test-threads=1`
|
||||||
- 覆盖 markdown / office / mindmap / raw file / directory 的 browser smoke。
|
- 覆盖 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 相关项完成。
|
- `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 瘦身
|
### P1.3 Page Aggregate compat 瘦身
|
||||||
|
|
||||||
入口文档:
|
入口文档:
|
||||||
@@ -177,6 +188,8 @@
|
|||||||
2. 补 GFM AST parser 写侧尾项:web shell 单测、手写 parser 过渡标记、临时适配分支清理。
|
2. 补 GFM AST parser 写侧尾项:web shell 单测、手写 parser 过渡标记、临时适配分支清理。
|
||||||
3. 明确 `PageAggregateClientState` 哪些仍是合法 draft state,哪些应退出事实源地位。
|
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`
|
- `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 分支改判为兼容层保留,不再作为迁移阻塞项。
|
- `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. P2:tree live cache 与 command context
|
## 5. P2:tree 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/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 收口
|
### 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`
|
||||||
|
|
||||||
执行状态:
|
执行状态:
|
||||||
|
|
||||||
|
|||||||
+6
@@ -1,5 +1,11 @@
|
|||||||
# 全局与内容类型页面宽度设置 Checklist v1
|
# 全局与内容类型页面宽度设置 Checklist v1
|
||||||
|
|
||||||
|
> 状态:done
|
||||||
|
>
|
||||||
|
> 归档时间:2026-05-31
|
||||||
|
>
|
||||||
|
> 归档说明:实施 checklist 已全量完成,当前真实消费点已覆盖 Markdown、轻量 Office 预览、PDF 预览和 PPTX 快速预览;Mindmap 渲染消费作为后续对应 viewer follow-up,不阻塞本文归档。
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
当前 Markdown 页面已有 `wideLayout` 布尔选项,但 Word / PDF / Excel / PPT / Mindmap 等不同内容类型没有统一的页面宽度偏好入口。用户需要在全局设置中分别调整不同类型的默认宽度,避免像思源那样必须写 CSS 才能把阅读宽度调到合适比例。
|
当前 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`
|
||||||
|
> Owner:03-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
|
||||||
|
[OCR:photo.png](./Page.ocr/photo.png.ocr.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
可选模式 `inlineSection` 插入正文段落:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### OCR:photo.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 page,OCR 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
|
# 4-49 Local File Operation 与 Watch Event 合同 v1
|
||||||
|
|
||||||
> 状态:process
|
> 状态:done
|
||||||
> Owner:04-tree-domain / 03-rust-web
|
> Owner:04-tree-domain / 03-rust-web
|
||||||
> 背景:Sidex / VSCode Explorer 对照显示,MNote local_folder 的 tree command、watch event、refresh/reveal 和 opened resource 生命周期之间仍缺少稳定合同。当前前端常靠 `relativePath` / `previousRelativePath` 反推刷新父级,watch event 又容易退回全量 resync,导致大目录、批量操作和已打开资源场景下出现重复刷新、状态漂移和错误恢复困难。
|
> 背景:Sidex / VSCode Explorer 对照显示,MNote local_folder 的 tree command、watch event、refresh/reveal 和 opened resource 生命周期之间仍缺少稳定合同。当前前端常靠 `relativePath` / `previousRelativePath` 反推刷新父级,watch event 又容易退回全量 resync,导致大目录、批量操作和已打开资源场景下出现重复刷新、状态漂移和错误恢复困难。
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# 5-30 Sidebar 星标置顶快捷入口与 Scoped Explorer 设计 v1
|
# 5-30 Sidebar 星标置顶快捷入口与 Scoped Explorer 设计 v1
|
||||||
|
|
||||||
> 状态:process
|
> 状态:done
|
||||||
> Owner:05-editor-mainline / 03-rust-web / control-plane
|
> Owner:05-editor-mainline / 03-rust-web / control-plane
|
||||||
> 背景:星标置顶需要对齐 Wolai 的 Sidebar 顶部快速访问体验,但 MNote 还需要支持把高频本地文件夹固定成 Explorer scoped root,避免每次进入大 workspace 时加载完整根目录。
|
> 背景:星标置顶需要对齐 Wolai 的 Sidebar 顶部快速访问体验,但 MNote 还需要支持把高频本地文件夹固定成 Explorer scoped root,避免每次进入大 workspace 时加载完整根目录。
|
||||||
|
|
||||||
@@ -240,3 +240,4 @@ mnote-web:
|
|||||||
- 2026-05-26:星标置顶确定为用户级 Sidebar 快捷入口,不是文件夹/页面自身属性。
|
- 2026-05-26:星标置顶确定为用户级 Sidebar 快捷入口,不是文件夹/页面自身属性。
|
||||||
- 2026-05-26:星标快捷入口必须写入 SQLite control-plane,满足同一用户跨设备一致显示。
|
- 2026-05-26:星标快捷入口必须写入 SQLite control-plane,满足同一用户跨设备一致显示。
|
||||||
- 2026-05-26:文件夹星标点击进入 scoped Explorer,避免大 workspace root 扫描。
|
- 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
-1
@@ -1,6 +1,6 @@
|
|||||||
# 5-31 页面设置 SQLite 状态收口设计 v1
|
# 5-31 页面设置 SQLite 状态收口设计 v1
|
||||||
|
|
||||||
> 状态:process
|
> 状态:done
|
||||||
>
|
>
|
||||||
> Owner:05-editor-mainline / control-plane / mnote-web
|
> Owner:05-editor-mainline / control-plane / mnote-web
|
||||||
>
|
>
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
|
# 5-32 FileTree 大目录懒加载与展开状态设计 v1
|
||||||
|
|
||||||
> 状态:process
|
> 状态:done
|
||||||
> Owner:05-editor-mainline / 03-rust-web
|
> Owner:05-editor-mainline / 03-rust-web
|
||||||
> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。
|
> 背景:用户在 localhost 打开 `mnote/design` 及其子目录时仍感到加载慢,并观察到“全部加载完又折叠,再点很快”。上一版仅靠恢复展开状态不够,因为它没有消除慢请求、重复请求和整树替换。
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# 5-33 FileTree ViewState 与请求代际收口 v1
|
# 5-33 FileTree ViewState 与请求代际收口 v1
|
||||||
|
|
||||||
> 状态:process
|
> 状态:done
|
||||||
> Owner:05-editor-mainline / 03-rust-web
|
> Owner:05-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。继续只做“展开恢复”会掩盖慢请求、重复请求和过期结果覆盖的问题。
|
> 背景:5-32 已把 FileTree lazy children、cache 和局部 refresh 推到可用层,但 Sidex / VSCode 对照显示,当前仍缺少统一 view-state、request generation、collapsed stale 和 reveal command。继续只做“展开恢复”会掩盖慢请求、重复请求和过期结果覆盖的问题。
|
||||||
|
|
||||||
## 1. 结论
|
## 1. 结论
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
# 5-33 导航页与路由守卫执行清单 v1
|
# 5-33 导航页与路由守卫执行清单 v1
|
||||||
|
|
||||||
> 状态:process
|
> 状态:done
|
||||||
>
|
>
|
||||||
> Owner:05-editor-mainline / 03-rust-web / control-plane
|
> Owner:05-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 B recent 闭环已落地:control-plane SQLite recent 表、读写 API、SSR recent 展示、打开 folder/page 写入 recent。
|
||||||
- [x] Phase C 主路径已落地:导航页/星标文件夹进入 scoped 导航页;FileTree 普通文件夹点击只展开;Markdown 普通点击记录 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。
|
- [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. 目标
|
## 1. 目标
|
||||||
|
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# 5-35 导航页 breadcrumb 与 fetch guard follow-up v1
|
||||||
|
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> Owner:05-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 无明确页面时显示导航页,不再自动打开默认 Markdown;recent 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 truth;canonical 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
|
> 创建时间:2026-05-17
|
||||||
>
|
>
|
||||||
> 当前状态:`PROCESS`
|
> 当前状态:`DONE`
|
||||||
>
|
>
|
||||||
> 2026-05-21 Batch J 口径补充:
|
> 2026-05-21 Batch J 口径补充:
|
||||||
> - 本稿的 ACP runtime 核心实现已完成并在当前页面 AI 主链中作为默认 runtime 边界使用;Hermes HTTP proxy 默认关闭,只在显式 compat 开关下保留。
|
> - 本稿的 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
|
> 创建时间:2026-05-29
|
||||||
>
|
>
|
||||||
> 状态:`PROCESS`
|
> 状态:`done`
|
||||||
>
|
>
|
||||||
> Owner:Page AI skill/tool capability surface + MNote host-side context provider
|
> Owner:Page AI skill/tool capability surface + MNote host-side context provider
|
||||||
>
|
>
|
||||||
|
|||||||
+78
-48
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 创建时间:2026-05-29
|
> 创建时间:2026-05-29
|
||||||
>
|
>
|
||||||
> 状态:`process`
|
> 状态:`done`
|
||||||
>
|
>
|
||||||
> Owner:Page AI agent identity / Hermes profile policy / Reasonix memory policy
|
> Owner:Page AI agent identity / Hermes profile policy / Reasonix memory policy
|
||||||
>
|
>
|
||||||
@@ -341,74 +341,104 @@ Hermes profile 切换时:
|
|||||||
|
|
||||||
### Batch A - 现状冻结与风险取证
|
### Batch A - 现状冻结与风险取证
|
||||||
|
|
||||||
- [ ] 复核当前 Page AI Hermes skill toggle 的真实写入路径,确认是否直接写 Hermes profile `config.yaml`。
|
- [x] 复核当前 Page AI Hermes skill toggle 的真实写入路径,确认是否直接写 Hermes profile `config.yaml`。
|
||||||
- [ ] 复核当前 UI preference 中 `hide_builtin`、MNote 内置 skill enabled、profile skill enabled、default profile 的存储键。
|
- 证据:`/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 检查。
|
||||||
- [ ] 复核 Reasonix ACP spawn 环境,确认当前是否默认注入 memory。
|
- [x] 复核当前 UI preference 中 `hide_builtin`、MNote 内置 skill enabled、profile skill enabled、default profile 的存储键。
|
||||||
- [ ] 形成 RED 证据:普通用户修改 shared profile skill 会影响其它用户,或当前缺少服务端权限边界。
|
- 证据:前端仍使用 `ai.agent.hermes.profile_id` 保存默认 Hermes profile;Reasonix 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 授权模型。
|
||||||
- [ ] 验证:Rust/JS 只读审计记录在本文档或后续 checklist evidence 中。
|
- [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 合同
|
### Batch B - SQLite profile policy 合同
|
||||||
|
|
||||||
- [ ] 新增或扩展 SQLite control-plane profile policy:`ai_agent_profiles` / `ai_agent_profile_grants` 或等价结构。
|
- [x] 新增或扩展 SQLite control-plane profile policy:`ai_agent_profiles` / `ai_agent_profile_grants` 或等价结构。
|
||||||
- [ ] 初始化 shared Hermes profile:仅 `lite`,普通用户 `canRun=true`、`canManageSkills=false`。
|
- 证据:新增 `007-ai-agent-profile-policy.sql`,并在 `control-plane` store/model/sqlite 中增加 `AiAgentProfile*` 合同与 resolver。
|
||||||
- [ ] 为每个用户 provision personal Hermes profile。
|
- [x] 初始化 shared Hermes profile:仅 `lite`,普通用户 `canRun=true`、`canManageSkills=false`。
|
||||||
- [ ] 补 Rust 定点测试:personal owner、shared readonly、admin manage、跨用户不可管理。
|
- 证据:`ensure_ai_agent_profile_policy` 初始化 `shared_lite`,普通用户 grant 为 run-only。
|
||||||
- [ ] 验证:不同用户查询 profile list 只返回自己 personal + shared lite。
|
- [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
|
### Batch C - Hermes profile resolver
|
||||||
|
|
||||||
- [ ] 新增服务端 `agentProfileRef` resolver,禁止前端提交任意 Hermes path。
|
- [x] 新增服务端 `agentProfileRef` resolver,禁止前端提交任意 Hermes path。
|
||||||
- [ ] `/api/hermes/client/runs` 从 `profileId` 解析真实 Hermes profile。
|
- 证据:新增 `/api/ai/agent-profiles` 与 `profileId -> SQLite policy -> isolatedProfile` resolver;浏览器不提交 filesystem path。
|
||||||
- [ ] `/api/hermes/client/skills` 从 `profileId` 解析真实 Hermes profile。
|
- [x] `/api/hermes/client/runs` 从 `profileId` 解析真实 Hermes profile。
|
||||||
- [ ] 保留旧 `profile=` 参数只作为兼容入口,并映射到当前用户可访问 profile。
|
- 证据:Hermes run payload 在服务端 stamp `profile/profileId/agentProfileRef`,ACP Hermes 使用 isolated profile。
|
||||||
- [ ] 验证:旧路径兼容不允许越权访问 shared/admin 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 权限收口
|
### Batch D - Skill toggle 权限收口
|
||||||
|
|
||||||
- [ ] 修改 skill toggle API:只接受 `profileId + skillName + enabled`。
|
- [x] 修改 skill toggle API:只接受 `profileId + skillName + enabled`。
|
||||||
- [ ] 区分 `mnote_builtin` 与 `hermes_profile` skill kind。
|
- 证据:服务端接受 `profileId/name/enabled/skillKind`;旧 `profile` 仅作 resolver 兼容。
|
||||||
- [ ] MNote 内置 skill toggle 写当前用户 SQLite preference。
|
- [x] 区分 `mnote_builtin` 与 `hermes_profile` skill kind。
|
||||||
- [ ] 拒绝普通用户修改 shared profile 的 Hermes profile skills。
|
- [x] MNote 内置 skill toggle 写当前用户 SQLite preference。
|
||||||
- [ ] personal profile skill toggle 只写该用户 isolated profile `config.yaml`。
|
- [x] 拒绝普通用户修改 shared profile 的 Hermes profile skills。
|
||||||
- [ ] shared profile skill toggle 仅 admin 可写。
|
- [x] personal profile skill toggle 只写该用户 isolated profile `config.yaml`。
|
||||||
- [ ] 验证:Rust API 测试覆盖 `ai_profile_readonly`、MNote 内置 skill per-user toggle、personal profile skill success。
|
- [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
|
### Batch E - MNote 内置 skill per-user policy
|
||||||
|
|
||||||
- [ ] 将 MNote 内置 skill enable/disable 保存为 SQLite per-user policy。
|
- [x] 将 MNote 内置 skill enable/disable 保存为 SQLite per-user policy。
|
||||||
- [ ] UI 中 `hide_builtin_skills` 只影响展示,不影响 enable/disable。
|
- [x] UI 中 `hide_builtin_skills` 只影响展示,不影响 enable/disable。
|
||||||
- [ ] Skills panel 标记 `builtin=true`、`configurable=true`、`configScope=user_sqlite`。
|
- 证据:隐藏 key 收口为 `ai.agent.hermes.skills.hide_builtin`;MNote 内置 enable 使用独立 `ai.agent.mnote_builtin.skill.<id>.enabled`。
|
||||||
- [ ] 服务端 MNote tool policy 按 `user_id + agentId + contextRefs + allowedRoots + mnote_builtin_skill_enabled` 判断。
|
- [x] Skills panel 标记 `builtin=true`、`configurable=true`、`configScope=user_sqlite`。
|
||||||
- [ ] 验证:用户 A 禁用某内置 skill 不影响用户 B;禁用后对应 MNote tool 被服务端拒绝;隐藏展示不影响 enable 状态。
|
- [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
|
### Batch F - Reasonix memory policy
|
||||||
|
|
||||||
- [ ] 新增 per-user 设置 `ai.agent.reasonix.memory_enabled`,默认 `false`。
|
- [x] 新增 per-user 设置 `ai.agent.reasonix.memory_enabled`,默认 `false`。
|
||||||
- [ ] Reasonix ACP spawn 默认设置 `REASONIX_MEMORY=off`。
|
- [x] Reasonix ACP spawn 默认设置 `REASONIX_MEMORY=off`。
|
||||||
- [ ] 开启 memory 后不注入 `REASONIX_MEMORY=off`,并在 UI 显示 memory enabled。
|
- [x] 开启 memory 后不注入 `REASONIX_MEMORY=off`,并在 UI 显示 memory enabled。
|
||||||
- [ ] 补 JS/Rust 测试或 smoke,覆盖默认 off、用户开启、不同用户隔离 preference。
|
- 证据:开启后服务端注入 `REASONIX_MEMORY=on`;UI Reasonix 设置页显示 memory 状态。
|
||||||
- [ ] 验证:普通消息由 Reasonix 自己决定是否使用工具;MNote 不再强行注入 memory/context 正文。
|
- [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 envelope;Reasonix memory 只通过 env policy 控制。
|
||||||
|
|
||||||
### Batch G - UI 收口
|
### Batch G - UI 收口
|
||||||
|
|
||||||
- [ ] Agent selector 中 Hermes profile 显示 personal/shared/readonly 状态。
|
- [x] Agent selector 中 Hermes profile 显示 personal/shared/readonly 状态。
|
||||||
- [ ] Skills panel 三组均可折叠。
|
- [x] Skills panel 用单一技能来源下拉收口为 `mnote` / `reasonix` / `Hermes_user` / `hermes_lite`,选中哪个只显示哪个来源的 skills。
|
||||||
- [ ] Hermes profile 切换必须刷新 skill catalog,避免旧 profile skill 残留。
|
- [x] Hermes profile 切换必须刷新 skill catalog,避免旧 profile skill 残留。
|
||||||
- [ ] shared profile 的 Hermes profile skills 对普通用户展示只读开关或锁定状态。
|
- [x] shared profile 的 Hermes profile skills 对普通用户展示只读开关或锁定状态。
|
||||||
- [ ] MNote 内置 skills 对普通用户展示可启停状态,并标明按当前 MNote 用户保存。
|
- [x] MNote 内置 skills 对普通用户展示可启停状态,并标明按当前 MNote 用户保存。
|
||||||
- [ ] 管理员对 shared profile 显示可管理状态,并提示影响所有用户。
|
- [x] 管理员对 shared profile 显示可管理状态,并提示影响所有用户。
|
||||||
- [ ] 验证:真实浏览器截图覆盖 MNote 内置 skill per-user 可配置、personal Hermes skill 可配置、shared Hermes skill 只读、admin shared 可配置。
|
- [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 个 skill,profile 后续新增 `writer` 后可开启并出现在 catalog”。
|
||||||
|
|
||||||
### Batch H - 回归矩阵与文档收尾
|
### Batch H - 回归矩阵与文档收尾
|
||||||
|
|
||||||
- [ ] 更新 Page AI 设计说明,明确 MNote 内置 skill per-user policy、personal/shared Hermes profile 与 Reasonix memory policy。
|
- [x] 更新 Page AI 设计说明,明确 MNote 内置 skill per-user policy、personal/shared Hermes profile 与 Reasonix memory policy。
|
||||||
- [ ] 更新 smoke:agent 切换、MNote 内置 skill per-user toggle、Hermes profile 切换、shared readonly、personal skill toggle、Reasonix memory off/on。
|
- [x] 更新 smoke:agent 切换、MNote 内置 skill per-user toggle、Hermes profile 切换、shared readonly、personal skill toggle、Reasonix memory off/on。
|
||||||
- [ ] 运行 `node --check` 覆盖相关 browser runtime / smoke。
|
- [x] 运行 `node --check` 覆盖相关 browser runtime / smoke。
|
||||||
- [ ] 运行 Rust 定点测试覆盖 SQLite profile policy 与 Hermes skill toggle 权限。
|
- [x] 运行 Rust 定点测试覆盖 SQLite profile policy 与 Hermes skill toggle 权限。
|
||||||
- [ ] 运行真实浏览器验证并截图。
|
- [x] 运行真实浏览器验证并截图。
|
||||||
- [ ] 运行 `git diff --check`。
|
- [x] 运行 `git diff --check`。
|
||||||
- [ ] 涉及代码图后运行 `codegraph sync .`。
|
- [x] 涉及代码图后运行 `codegraph sync .`。
|
||||||
- [ ] 完成后将本 checklist 移动到 `done/` 或标记为 `done`。
|
- [x] 完成后将本 checklist 移动到 `done/` 或标记为 `done`。
|
||||||
|
- 状态已标记为 `done`;2026-06-01 已迁入 `design/07-ai/done/`。
|
||||||
|
|
||||||
## 8. 验收口径
|
## 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`
|
||||||
|
>
|
||||||
|
> Owner:Page 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 创建 run,payload 携带 `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/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/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/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`
|
> - `/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 A0:target selection 与 UI
|
### Phase A0:target selection 与 UI
|
||||||
|
|
||||||
- [ ] Page AI composer 可见 target chip,显示 workspace、target title、resourceKind、scope、readonly / dirty 状态。
|
- [x] Page AI composer 可见 target chip,显示 workspace、target title、resourceKind、scope、readonly / dirty 状态。
|
||||||
- [ ] 点击 target chip 打开 target picker,可在当前焦点、打开 tabs、树选中项之间选择。
|
- [x] 点击 target chip 打开 target picker,可在当前焦点、打开 tabs、树选中项之间选择。
|
||||||
- [ ] 默认 target 来自 last focused editor / resource tab,不来自第一个 tab、URL documentId 或树选中项。
|
- [x] 默认 target 来自 last focused editor / resource tab,不来自第一个 tab、URL documentId 或树选中项。
|
||||||
- [ ] mindmap / office / raw file target 显示真实 resourceKind,不伪装成 markdown page。
|
- [x] mindmap / office / raw file target 显示真实 resourceKind,不伪装成 markdown page。
|
||||||
- [ ] 多 tab / 跨 workspace / dirty write 时发送前需要显式确认。
|
- [ ] 多 tab / 跨 workspace / dirty write 时发送前需要显式确认。
|
||||||
- [ ] 发送后消息记录冻结 target 摘要,切换 tab 不影响已启动 run。
|
- [ ] 发送后消息记录冻结 target 摘要,切换 tab 不影响已启动 run。
|
||||||
|
|
||||||
@@ -291,6 +291,8 @@ Rust SQLite control-plane 是默认控制面,负责:
|
|||||||
- [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。
|
- [ ] allowed roots 外文件写入被 agent runtime 或 MNote 审计层拒绝。
|
||||||
- [ ] readonly 页面不会启动可写 run,或只启动只读问答 run。
|
- [ ] 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:写入回收与前台同步
|
### Phase B:写入回收与前台同步
|
||||||
|
|
||||||
- [ ] agent 修改当前 `.md` 后,MNote 能回收 changed files / diff。
|
- [ ] 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
|
||||||
|
>
|
||||||
|
> Owner:07-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。
|
||||||
+5
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
- 状态:process
|
- 状态:reference
|
||||||
- Owner:AI runtime / browser verification
|
- Owner:AI runtime / browser verification
|
||||||
- 背景:Reasonix 已能作为独立辅助 agent 执行只读审计,但浏览器验收若只输出自然语言结论,主控仍需重测,无法稳定节省 token。
|
- 背景:Reasonix 已能作为独立辅助 agent 执行只读审计,但浏览器验收若只输出自然语言结论,主控仍需重测,无法稳定节省 token。
|
||||||
|
|
||||||
@@ -144,3 +144,7 @@ Codex/Hermes 只有在以下条件满足时,才能把 Reasonix 浏览器结果
|
|||||||
- [ ] 使用 `bugs/0524.md` 中至少 1 个 bug 进行 Reasonix 浏览器试跑。
|
- [ ] 使用 `bugs/0524.md` 中至少 1 个 bug 进行 Reasonix 浏览器试跑。
|
||||||
- [ ] 试跑后记录 accepted findings / false leads / retest required。
|
- [ ] 试跑后记录 accepted findings / false leads / retest required。
|
||||||
- [ ] 根据试跑结果更新本合同或 Reasonix skill。
|
- [ ] 根据试跑结果更新本合同或 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 维护流程或独立复盘文档中跟踪。
|
||||||
+5
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
- 状态:process
|
- 状态:reference
|
||||||
- Owner:AI runtime / multi-agent collaboration
|
- Owner:AI runtime / multi-agent collaboration
|
||||||
- 背景:Hindsight 解决了 Reasonix 过程可追溯问题,但不能自动让 Reasonix 下一次更好。需要一个主控驱动的最小自进化机制,由 Codex/Hermes 根据真实 run 修改 Reasonix skill、任务模板和 handoff 合同。
|
- 背景:Hindsight 解决了 Reasonix 过程可追溯问题,但不能自动让 Reasonix 下一次更好。需要一个主控驱动的最小自进化机制,由 Codex/Hermes 根据真实 run 修改 Reasonix skill、任务模板和 handoff 合同。
|
||||||
|
|
||||||
@@ -144,3 +144,7 @@ Reasonix 协同复盘:
|
|||||||
- [ ] 用 `bugs/0524.md` 至少跑一轮真实 Reasonix 协同测试。
|
- [ ] 用 `bugs/0524.md` 至少跑一轮真实 Reasonix 协同测试。
|
||||||
- [ ] 根据真实结果更新 skill 或明确“不需要修改”。
|
- [ ] 根据真实结果更新 skill 或明确“不需要修改”。
|
||||||
- [ ] 在 Hindsight / MemPalace 中保留协作复盘摘要。
|
- [ ] 在 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
|
> 更新时间:2026-05-11
|
||||||
>
|
>
|
||||||
> 当前状态:`reference`。本文只保留思源参考边界与可借鉴能力,不覆盖 `01-05` 当前优先级,也不作为当前执行 checklist。
|
> 当前状态:`reference`。本文只保留思源参考边界与可借鉴能力,不覆盖 `CURRENT_ARCHITECTURE.md` / `1-8` 当前口径,也不作为当前执行 checklist。
|
||||||
>
|
>
|
||||||
> 上位依据:
|
> 上位依据:
|
||||||
> - `/mnt/Data1T/mnote/ARCHITECTURE.md`
|
> - `/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/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/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`
|
> - `/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. 归档说明
|
## 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-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)
|
- [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. 本轮结论
|
## 1. 本轮结论
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
> 执行状态:`done`
|
> 执行状态:`done`
|
||||||
>
|
>
|
||||||
> 范围:当前主线中 Page Aggregate 单一真源、页面 AI 快速编辑、`mnote.doc.markdown_edit` 与 ACP / Hermes runtime 的源码级定向审查。
|
> 范围:当前主线中 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. 本轮结论
|
## 1. 本轮结论
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
## 1. 结论
|
## 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 / 内核优先缺陷已完成代码修复、文档迁移和定向验证。
|
当前 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 Resolver:target 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 Bridge:P0/P1 安全主路径与 P2 recipe 矩阵已收口,后续新增 recipe 按矩阵逐项验证。
|
||||||
|
- [x] `7-44` ChatOnly Doubao binding:MNote 侧 binding 与 Doubao provider 单测已完成。
|
||||||
|
- [ ] `7-49` ChatOnly OpenClaw provider:DeepSeek / 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 reference;OnlyOffice 真实 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 只读 worker:design governance audit、bug hunt candidate audit。
|
||||||
|
|
||||||
|
注意:当前工作区有大量未提交改动,且 CodeGraph 显示新增文件 pending。本轮只对本轮新增/修改的文档负责,不回滚、不移动用户已有改动。
|
||||||
+6
-5
@@ -23,12 +23,13 @@
|
|||||||
|
|
||||||
## 当前优先级入口
|
## 当前优先级入口
|
||||||
|
|
||||||
- `01-05` 当前有效主线与优先级,请先看:
|
- 当前有效架构口径,请先看:
|
||||||
`/mnt/Data1T/mnote/design/01-05-current-priority-overview.md`
|
`/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md`
|
||||||
- Runtime 模块级拆分完成态,请看:
|
|
||||||
`/mnt/Data1T/mnote/design/10-review/done/16-mnote-web-runtime-module-maintainability-checklist-v1.md`
|
|
||||||
- MVP 后阶段剩余 `process/` 的执行顺序,请看:
|
- MVP 后阶段剩余 `process/` 的执行顺序,请看:
|
||||||
`/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/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/`
|
- `90-reference/`
|
||||||
- 跨域生态参考资料,不参与 `[done]/[process]/[draft]/[reference]/[recycle]` 状态判断
|
- 跨域生态参考资料,不参与 `[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/`
|
- `old/`
|
||||||
- 已废弃或被替代的历史稿件,标题统一标记 `[recycle]`
|
- 已废弃或被替代的历史稿件,标题统一标记 `[recycle]`
|
||||||
- 每个大类继续按 `process/` 与 `done/` 分层
|
- 每个大类继续按 `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 `[OCR:photo.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.
|
||||||
@@ -2,7 +2,7 @@ version: "3.9"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
onlyoffice-documentserver:
|
onlyoffice-documentserver:
|
||||||
image: onlyoffice/documentserver:9.3.1
|
image: onlyoffice/documentserver:9.4.0
|
||||||
container_name: mnote-onlyoffice-documentserver
|
container_name: mnote-onlyoffice-documentserver
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Generated
+280
@@ -14,6 +14,23 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -99,6 +116,15 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "async-lock"
|
name = "async-lock"
|
||||||
version = "3.4.2"
|
version = "3.4.2"
|
||||||
@@ -273,12 +299,37 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
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]]
|
[[package]]
|
||||||
name = "camino"
|
name = "camino"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -301,6 +352,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -330,6 +383,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -475,6 +538,12 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant_time_eq"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "control-plane"
|
name = "control-plane"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -543,6 +612,30 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-utils"
|
name = "crossbeam-utils"
|
||||||
version = "0.8.21"
|
version = "0.8.21"
|
||||||
@@ -565,6 +658,12 @@ version = "2.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deflate64"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -585,6 +684,17 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -724,6 +834,16 @@ version = "1.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
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]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1277,6 +1397,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "interpolator"
|
name = "interpolator"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1335,6 +1464,16 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.95"
|
version = "0.3.95"
|
||||||
@@ -1558,6 +1697,27 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
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]]
|
[[package]]
|
||||||
name = "manyhow"
|
name = "manyhow"
|
||||||
version = "0.11.4"
|
version = "0.11.4"
|
||||||
@@ -1599,6 +1759,16 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -1659,6 +1829,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1790,6 +1961,16 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -2540,6 +2721,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd-adler32"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3707,6 +3894,15 @@ version = "0.8.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
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]]
|
[[package]]
|
||||||
name = "yansi"
|
name = "yansi"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -3782,6 +3978,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
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]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
@@ -3816,8 +4026,78 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
|
|||||||
Ok("documents:updateContent")
|
Ok("documents:updateContent")
|
||||||
}
|
}
|
||||||
"mindmaps.put" => Ok("mindmaps:put"),
|
"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()),
|
_ => 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));
|
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)
|
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]
|
#[test]
|
||||||
fn page_aggregate_get_prefers_editor_document_over_legacy_content() {
|
fn page_aggregate_get_prefers_editor_document_over_legacy_content() {
|
||||||
let result = execute_runtime_query(RuntimeInput::Query {
|
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",
|
"v6-navigation-recent",
|
||||||
include_str!("../migrations/006-navigation-recent.sql"),
|
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.
|
/// Create the `_migrations` meta-table if it does not exist.
|
||||||
@@ -113,6 +121,9 @@ mod tests {
|
|||||||
"sidebar_shortcuts",
|
"sidebar_shortcuts",
|
||||||
"user_ui_preferences",
|
"user_ui_preferences",
|
||||||
"user_navigation_recent",
|
"user_navigation_recent",
|
||||||
|
"ai_agent_profiles",
|
||||||
|
"ai_agent_profile_grants",
|
||||||
|
"ai_external_conversation_bindings",
|
||||||
] {
|
] {
|
||||||
assert!(table_exists(&conn, table), "table {table} should exist");
|
assert!(table_exists(&conn, table), "table {table} should exist");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,41 @@ pub struct UpsertUserUiPreferenceInput {
|
|||||||
pub value_json: String,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct NavigationRecentRecord {
|
pub struct NavigationRecentRecord {
|
||||||
pub id: EntityId,
|
pub id: EntityId,
|
||||||
@@ -393,6 +428,42 @@ pub struct AppendAiRuntimeEventInput {
|
|||||||
pub payload_json: String,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ShareLinkRecord {
|
pub struct ShareLinkRecord {
|
||||||
pub id: EntityId,
|
pub id: EntityId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! SQLite-backed control-plane store.
|
//! SQLite-backed control-plane store.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
@@ -7,15 +8,17 @@ use rusqlite::{params, Connection, OptionalExtension};
|
|||||||
use crate::error::ControlPlaneError;
|
use crate::error::ControlPlaneError;
|
||||||
use crate::migrations;
|
use crate::migrations;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
password_hash_v1, share_token_hash_v1, AiPolicyRecord, AiRuntimeEventRecord,
|
password_hash_v1, share_token_hash_v1, AiAgentProfileAccessRecord, AiAgentProfileGrantRecord,
|
||||||
AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput, AuditLogRecord,
|
AiAgentProfileRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
|
||||||
AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput, CreateSessionInput,
|
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
|
||||||
CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput, DirectoryGrantLookup,
|
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||||
DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput, OutboxEventRecord,
|
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
|
||||||
ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord, SyncStateRecord,
|
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
|
||||||
UpsertAiPolicyInput, UpsertAiRuntimeRunInput, UpsertNavigationRecentInput,
|
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||||
UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput,
|
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
|
||||||
UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
|
||||||
|
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||||
|
UserUiPreferenceRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
use crate::store::ControlPlaneStore;
|
use crate::store::ControlPlaneStore;
|
||||||
|
|
||||||
@@ -214,6 +217,66 @@ fn option_to_stored_text(value: Option<String>) -> String {
|
|||||||
.unwrap_or_default()
|
.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> {
|
fn row_to_user_ui_preference(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserUiPreferenceRecord> {
|
||||||
Ok(UserUiPreferenceRecord {
|
Ok(UserUiPreferenceRecord {
|
||||||
id: row.get(0)?,
|
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> {
|
fn row_to_ai_policy(row: &rusqlite::Row<'_>) -> rusqlite::Result<AiPolicyRecord> {
|
||||||
Ok(AiPolicyRecord {
|
Ok(AiPolicyRecord {
|
||||||
id: row.get(0)?,
|
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 {
|
fn derive_ai_runtime_title(payload_json: &str, fallback: &str) -> String {
|
||||||
let title = serde_json::from_str::<serde_json::Value>(payload_json)
|
let title = serde_json::from_str::<serde_json::Value>(payload_json)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -391,6 +490,50 @@ fn prune_navigation_recent_for_kind(
|
|||||||
Ok(())
|
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 {
|
impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||||
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
|
fn upsert_user(&self, input: UpsertUserInput) -> Result<UserRecord, ControlPlaneError> {
|
||||||
if input.username.trim().is_empty() {
|
if input.username.trim().is_empty() {
|
||||||
@@ -1461,6 +1604,168 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
|||||||
Ok(rows)
|
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(
|
fn upsert_navigation_recent(
|
||||||
&self,
|
&self,
|
||||||
input: UpsertNavigationRecentInput,
|
input: UpsertNavigationRecentInput,
|
||||||
@@ -2047,6 +2352,207 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
|||||||
Ok(rows)
|
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(
|
fn rename_ai_runtime_session(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
@@ -2195,9 +2701,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
|
password_hash_v1, session_token_hash, AppendAiRuntimeEventInput, AuthenticatePasswordInput,
|
||||||
CreatePasswordIdentityInput, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
CreatePasswordIdentityInput, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
|
||||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput,
|
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
|
||||||
UpsertUserUiPreferenceInput,
|
UpsertSyncStateInput, UpsertUserUiPreferenceInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn store() -> SqliteControlPlaneStore {
|
fn store() -> SqliteControlPlaneStore {
|
||||||
@@ -2716,6 +3222,95 @@ mod tests {
|
|||||||
assert!(bob_sidebar_preferences.is_empty());
|
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]
|
#[test]
|
||||||
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
|
fn navigation_recent_is_user_scoped_upserted_and_limited_by_kind() {
|
||||||
let store = store();
|
let store = store();
|
||||||
@@ -3025,6 +3620,79 @@ mod tests {
|
|||||||
assert!(runs.is_empty());
|
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]
|
#[test]
|
||||||
fn session_lookup_resolves_active_user_by_token_hash() {
|
fn session_lookup_resolves_active_user_by_token_hash() {
|
||||||
let store = store();
|
let store = store();
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
use crate::error::ControlPlaneError;
|
use crate::error::ControlPlaneError;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
AiPolicyRecord, AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput,
|
AiAgentProfileAccessRecord, AiExternalConversationBindingRecord, AiPolicyRecord,
|
||||||
AppendAuditInput, AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput,
|
AiRuntimeEventRecord, AiRuntimeRunRecord, AppendAiRuntimeEventInput, AppendAuditInput,
|
||||||
CreatePasswordIdentityInput, CreateSessionInput, CreateShareLinkInput, CreatedShareLink,
|
AuditLogRecord, AuthSessionRecord, AuthenticatePasswordInput, CreatePasswordIdentityInput,
|
||||||
DirectoryGrantInput, DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord,
|
CreateSessionInput, CreateShareLinkInput, CreatedShareLink, DirectoryGrantInput,
|
||||||
OutboxEventInput, OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord,
|
DirectoryGrantLookup, DirectoryGrantRecord, NavigationRecentRecord, OutboxEventInput,
|
||||||
SidebarShortcutRecord, SyncStateRecord, UpsertAiPolicyInput, UpsertAiRuntimeRunInput,
|
OutboxEventRecord, ResolvedAccess, ResolvedAuthSession, ShareLinkRecord, SidebarShortcutRecord,
|
||||||
UpsertNavigationRecentInput, UpsertSidebarShortcutInput, UpsertSyncStateInput, UpsertUserInput,
|
SyncStateRecord, UpsertAiExternalConversationBindingInput, UpsertAiPolicyInput,
|
||||||
UpsertUserUiPreferenceInput, UserRecord, UserUiPreferenceRecord, WorkspaceRecord,
|
UpsertAiRuntimeRunInput, UpsertNavigationRecentInput, UpsertSidebarShortcutInput,
|
||||||
|
UpsertSyncStateInput, UpsertUserInput, UpsertUserUiPreferenceInput, UserRecord,
|
||||||
|
UserUiPreferenceRecord, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub trait ControlPlaneStore: Send + Sync {
|
pub trait ControlPlaneStore: Send + Sync {
|
||||||
@@ -136,6 +138,19 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
source_kind: Option<&str>,
|
source_kind: Option<&str>,
|
||||||
) -> Result<Vec<UserUiPreferenceRecord>, ControlPlaneError>;
|
) -> 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(
|
fn upsert_navigation_recent(
|
||||||
&self,
|
&self,
|
||||||
input: UpsertNavigationRecentInput,
|
input: UpsertNavigationRecentInput,
|
||||||
@@ -196,6 +211,29 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<AiRuntimeEventRecord>, ControlPlaneError>;
|
) -> 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(
|
fn rename_ai_runtime_session(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|||||||
@@ -264,10 +264,18 @@ impl DocumentBuffer {
|
|||||||
self.base_content_hash = Some(content_hash);
|
self.base_content_hash = Some(content_hash);
|
||||||
self.current_content_hash = None;
|
self.current_content_hash = None;
|
||||||
self.dirty_state = DocBufferDirtyState::Clean;
|
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;
|
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_save_operation_id = save_operation_id;
|
||||||
}
|
}
|
||||||
self.last_saved_at = Some(
|
self.last_saved_at = Some(
|
||||||
@@ -922,7 +930,10 @@ mod tests {
|
|||||||
request.expected_file_version.as_deref(),
|
request.expected_file_version.as_deref(),
|
||||||
Some("local-md:local-md:README.md:1:2:hash")
|
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.save_operation_id.as_deref(), Some("save:op:abc"));
|
||||||
assert_eq!(request.content_format, "editorBlocks");
|
assert_eq!(request.content_format, "editorBlocks");
|
||||||
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
|
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ comrak = { version = "0.52", default-features = false }
|
|||||||
notify = "8.2.0"
|
notify = "8.2.0"
|
||||||
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
time = { version = "0.3", features = ["formatting", "local-offset"] }
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
zip = "2"
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const resourceTabMru = { primary: [], secondary: [] };
|
const resourceTabMru = { primary: [], secondary: [] };
|
||||||
const resourceTabMruMax = 20;
|
const resourceTabMruMax = 20;
|
||||||
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
|
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
|
||||||
|
let onlyofficeBridgeReadyListenerBound = false;
|
||||||
|
|
||||||
const normalizePaneRole = (paneRole) => String(paneRole || '').trim() === 'secondary' ? 'secondary' : 'primary';
|
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 currentWebShellDocumentId = () => {
|
||||||
const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : '';
|
const explicit = typeof currentDocumentId === 'function' ? String(currentDocumentId() || '').trim() : '';
|
||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
@@ -157,17 +182,23 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
|
|
||||||
const currentWebShellSourceKind = () => {
|
const currentWebShellSourceKind = () => {
|
||||||
try {
|
try {
|
||||||
return currentUrl().searchParams.get('sourceKind') || '';
|
return currentUrl().searchParams.get('sourceKind')
|
||||||
|
|| document.body?.dataset?.mnoteSourceKind
|
||||||
|
|| currentFileTreeWorkspacePath()?.sourceKind
|
||||||
|
|| '';
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return '';
|
return document.body?.dataset?.mnoteSourceKind || currentFileTreeWorkspacePath()?.sourceKind || '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentWebShellRootUri = () => {
|
const currentWebShellRootUri = () => {
|
||||||
try {
|
try {
|
||||||
return currentUrl().searchParams.get('rootUri') || '';
|
return currentUrl().searchParams.get('rootUri')
|
||||||
|
|| document.body?.dataset?.mnoteRootUri
|
||||||
|
|| currentFileTreeWorkspacePath()?.rootUri
|
||||||
|
|| '';
|
||||||
} catch (_) {
|
} 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(),
|
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;
|
if (!id) return null;
|
||||||
return {
|
return {
|
||||||
schema: 'mnote.workspace_path.v1',
|
schema: 'mnote.workspace_path.v1',
|
||||||
@@ -205,7 +238,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
rootUri: String(rootUri || '').trim(),
|
rootUri: String(rootUri || '').trim(),
|
||||||
relativePath: String(relativePath || '').trim(),
|
relativePath: String(relativePath || '').trim(),
|
||||||
documentId: String(documentId || '').trim(),
|
documentId: String(documentId || '').trim(),
|
||||||
objectIdentity: String(objectIdentity || '').trim(),
|
objectIdentity: structuredObjectIdentity || objectIdentityText,
|
||||||
assetId: String(assetId || '').trim(),
|
assetId: String(assetId || '').trim(),
|
||||||
resourceKind: String(resourceKind || '').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 openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => {
|
||||||
const active = entry?.tab instanceof HTMLElement
|
const active = entry?.tab instanceof HTMLElement
|
||||||
? entry.tab.getAttribute('aria-selected') === 'true'
|
? entry.tab.getAttribute('aria-selected') === 'true'
|
||||||
@@ -384,6 +429,13 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
|
const assetId = String(entry?.assetId || entry?.session?.assetId || '').trim();
|
||||||
const kind = normalizeResourceTabKind(entry);
|
const kind = normalizeResourceTabKind(entry);
|
||||||
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
const dirtyState = resourceTabCloseGuardReason(entry?.session);
|
||||||
|
const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null;
|
||||||
|
const onlyofficeSessionId = String(
|
||||||
|
officeBridgeDebug?.bridgeSessionId
|
||||||
|
|| entry?.onlyofficeSessionId
|
||||||
|
|| entry?.bridgeSessionId
|
||||||
|
|| '',
|
||||||
|
).trim();
|
||||||
return {
|
return {
|
||||||
objectIdentity,
|
objectIdentity,
|
||||||
workspacePath: buildWorkspacePath({
|
workspacePath: buildWorkspacePath({
|
||||||
@@ -411,6 +463,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
dirtyGuard: dirtyState,
|
dirtyGuard: dirtyState,
|
||||||
assetId,
|
assetId,
|
||||||
path: relativePath,
|
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),
|
lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0),
|
||||||
preview: false,
|
preview: false,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
@@ -427,6 +484,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const rootUri = currentWebShellRootUri();
|
const rootUri = currentWebShellRootUri();
|
||||||
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
|
const relativePath = sourceKind === 'local_folder' ? localMarkdownRelativePathFromDocumentId(documentId) : '';
|
||||||
const objectIdentity = `page:${paneRole}`;
|
const objectIdentity = `page:${paneRole}`;
|
||||||
|
const workspaceObjectIdentity = {
|
||||||
|
objectKind: 'page',
|
||||||
|
documentId,
|
||||||
|
blockId: null,
|
||||||
|
assetId: null,
|
||||||
|
};
|
||||||
const active = nodes.pageTab instanceof HTMLElement
|
const active = nodes.pageTab instanceof HTMLElement
|
||||||
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
? nodes.pageTab.getAttribute('aria-selected') === 'true'
|
||||||
: false;
|
: false;
|
||||||
@@ -443,7 +506,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
rootUri,
|
rootUri,
|
||||||
relativePath,
|
relativePath,
|
||||||
documentId,
|
documentId,
|
||||||
objectIdentity,
|
objectIdentity: workspaceObjectIdentity,
|
||||||
assetId: '',
|
assetId: '',
|
||||||
resourceKind: 'page',
|
resourceKind: 'page',
|
||||||
}),
|
}),
|
||||||
@@ -1035,6 +1098,17 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
markIntendedSlashRoot(entry);
|
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 openPassiveResourceTab = (entry, input) => {
|
||||||
const href = String(input.officeUrl || input.href || '').trim();
|
const href = String(input.officeUrl || input.href || '').trim();
|
||||||
if (entry.kind === 'image') {
|
if (entry.kind === 'image') {
|
||||||
@@ -1051,6 +1125,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
|
|||||||
const frame = entry.panel.querySelector('iframe');
|
const frame = entry.panel.querySelector('iframe');
|
||||||
if (frame instanceof HTMLIFrameElement) {
|
if (frame instanceof HTMLIFrameElement) {
|
||||||
frame.title = entry.title;
|
frame.title = entry.title;
|
||||||
|
if (entry.kind === 'office') {
|
||||||
|
ensureOnlyofficeBridgeReadyListener();
|
||||||
|
frame.addEventListener('load', () => {
|
||||||
|
syncOpenEditorsSnapshot();
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
frame.src = href;
|
frame.src = href;
|
||||||
}
|
}
|
||||||
installPassiveResourceWatch(entry);
|
installPassiveResourceWatch(entry);
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ export const firstNonEmptyText = (...values) => {
|
|||||||
return '';
|
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 转换函数组。
|
// 过渡适配(TODO step-4):legacy→Tiptap inline marks 转换函数组。
|
||||||
// AST/block 迁移 complete 后,前端应直接消费 block document 中的
|
// AST/block 迁移 complete 后,前端应直接消费 block document 中的
|
||||||
// tiptap 格式 marks(已由 Rust 侧 local_markdown_parser 输出),
|
// 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 === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
|
||||||
if (type === 'mindmap') {
|
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 data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null;
|
||||||
const mindmapId = firstNonEmptyText(
|
const mindmapId = firstNonEmptyText(
|
||||||
block?.props?.mindmapId,
|
block?.props?.mindmapId,
|
||||||
block?.props?.mindmap_id,
|
block?.props?.mindmap_id,
|
||||||
block?.props?.sourcePath,
|
block?.props?.sourcePath,
|
||||||
block?.props?.source_path,
|
block?.props?.source_path,
|
||||||
|
attrs?.mindmapId,
|
||||||
|
attrs?.mindmap_id,
|
||||||
|
attrs?.sourcePath,
|
||||||
|
attrs?.source_path,
|
||||||
block?.mindmapId,
|
block?.mindmapId,
|
||||||
block?.mindmap_id,
|
block?.mindmap_id,
|
||||||
data?.mindmapId,
|
data?.mindmapId,
|
||||||
data?.mindmap_id,
|
data?.mindmap_id,
|
||||||
data?.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 {
|
return {
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
attrs: withTextAlign({
|
attrs: withTextAlign({
|
||||||
@@ -145,6 +164,8 @@ export const legacyBlockToTiptap = (block, index = 0) => {
|
|||||||
mnoteBlockType: 'mindmap',
|
mnoteBlockType: 'mindmap',
|
||||||
mindmapId,
|
mindmapId,
|
||||||
rootNodeId,
|
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()
|
const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim()
|
||||||
? node.dataset.mnoteRootNodeId.trim()
|
? node.dataset.mnoteRootNodeId.trim()
|
||||||
: 'root';
|
: '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()) {
|
if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) {
|
||||||
node.attrs.rootNodeId = descriptor.rootNodeId;
|
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;
|
return tiptapDocument;
|
||||||
};
|
};
|
||||||
@@ -445,20 +474,17 @@ export const localizeTiptapAssetUrls = (node, context) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => {
|
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) {
|
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
|
||||||
return 'local_markdown.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';
|
if (body?.content) return 'compat.legacy_content';
|
||||||
return fallbackText ? 'degraded.fallback_text' : 'empty';
|
return fallbackText ? 'degraded.fallback_text' : 'empty';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
|
||||||
const projectionContext = { ...(context || {}), body };
|
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;
|
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
|
||||||
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext);
|
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext);
|
||||||
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, 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 rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
|
||||||
const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version);
|
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 {
|
return {
|
||||||
mindmapId,
|
mindmapId,
|
||||||
rootNodeId,
|
rootNodeId,
|
||||||
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
|
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
|
||||||
|
...(mindmapWidth !== null ? { mindmapWidth } : {}),
|
||||||
|
...(mindmapHeight !== null ? { mindmapHeight } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ function fileTreeRowKind(row) {
|
|||||||
|
|
||||||
function fileTreeRowDocumentId(row) {
|
function fileTreeRowDocumentId(row) {
|
||||||
if (!(row instanceof HTMLElement)) return '';
|
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) {
|
function fileTreeRowAssetId(row, deps) {
|
||||||
@@ -98,6 +103,11 @@ function readWorkspacePathFromRow(row, deps) {
|
|||||||
var documentId = fileTreeRowDocumentId(row);
|
var documentId = fileTreeRowDocumentId(row);
|
||||||
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
var rowId = String(row.getAttribute('data-row-id') || '').trim();
|
||||||
var rowKind = fileTreeRowKind(row);
|
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 sourceKind = String(row.getAttribute('data-source-kind') || currentSourceKind() || '').trim();
|
||||||
var rootUri = String(row.getAttribute('data-root-uri') || currentRootUri() || '').trim();
|
var rootUri = String(row.getAttribute('data-root-uri') || currentRootUri() || '').trim();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
|
|||||||
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
navigateToMindmapObject(String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, String(detail.workspaceId || '').trim());
|
||||||
return;
|
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);
|
var localOfficeUrl = buildLocalOnlyOfficeOpenUrl(localFilePath, localFileName, String(detail && detail.documentId || currentDocumentId() || '').trim(), assetId, requestedOfficeMode);
|
||||||
if (localOfficeUrl) {
|
if (localOfficeUrl) {
|
||||||
if (!forceNewWindow && await openLocalResourceInActiveTab({
|
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 协议直连 Reasonix(DeepSeek 缓存优先)',
|
||||||
|
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 shell = document.querySelector('.document-shell');
|
||||||
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
var editorRoot = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
|
||||||
var editorSurface = editorRoot && editorRoot.querySelector('.editor-surface');
|
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) {
|
if (shell instanceof HTMLElement) {
|
||||||
var widthPreference = activeResourceWidthPreference(options);
|
var widthPreference = activeResourceWidthPreference(options);
|
||||||
var cssMaxWidth = String(widthPreference.cssMaxWidth || pageWidthCssMaxWidth(widthPreference.resolvedMode));
|
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.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||||
shell.style.width = '100%';
|
shell.style.width = '100%';
|
||||||
shell.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
|
shell.style.maxWidth = cssMaxWidth === 'none' ? 'none' : cssMaxWidth;
|
||||||
|
shell.style.setProperty('--mnote-mindmap-block-max-width', mindmapMaxWidthValue);
|
||||||
}
|
}
|
||||||
if (editorRoot instanceof HTMLElement) {
|
if (editorRoot instanceof HTMLElement) {
|
||||||
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
editorRoot.setAttribute('data-page-wide-layout', String(Boolean(options.wideLayout)));
|
||||||
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
editorRoot.setAttribute('data-page-small-text', String(Boolean(options.smallText)));
|
||||||
editorRoot.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
editorRoot.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||||
editorRoot.setAttribute('data-page-embed-default-block-id', options.embedDefaultBlockId == null ? '' : String(options.embedDefaultBlockId));
|
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) {
|
if (editorSurface instanceof HTMLElement) {
|
||||||
editorSurface.setAttribute('data-layout-density', String(options.layoutDensity || 'normal'));
|
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-global-show-heading-numbers', String(globalShowHeadingNumbers));
|
||||||
document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers));
|
||||||
document.documentElement.setAttribute('data-page-hide-title-header', String(Boolean(options.hideTitleHeader)));
|
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');
|
var titleHeader = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header') || document.querySelector('.document-shell-header');
|
||||||
if (titleHeader instanceof HTMLElement) {
|
if (titleHeader instanceof HTMLElement) {
|
||||||
var hidden = Boolean(options.hideTitleHeader);
|
var hidden = Boolean(options.hideTitleHeader);
|
||||||
|
|||||||
@@ -32,46 +32,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
pageWidthPreferences: null,
|
pageWidthPreferences: null,
|
||||||
historySnapshots: [],
|
historySnapshots: [],
|
||||||
pageSettingsOpen: false,
|
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: {
|
localIndexSummary: {
|
||||||
scopeKey: '',
|
scopeKey: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -928,6 +888,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
function fileTreeRuntimeDeps() {
|
function fileTreeRuntimeDeps() {
|
||||||
return {
|
return {
|
||||||
currentSourceKind: currentSourceKind,
|
currentSourceKind: currentSourceKind,
|
||||||
|
currentRootUri: currentRootUri,
|
||||||
currentDocumentId: currentDocumentId,
|
currentDocumentId: currentDocumentId,
|
||||||
localFilePathFromAssetId: localFilePathFromAssetId,
|
localFilePathFromAssetId: localFilePathFromAssetId,
|
||||||
rowTitle: rowTitle,
|
rowTitle: rowTitle,
|
||||||
@@ -2382,12 +2343,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
|
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
|
||||||
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
|
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
|
||||||
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
|
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
|
||||||
|
const pageAiSetSkillSource = (...args) => sidebarPageAi.pageAiSetSkillSource(...args);
|
||||||
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
|
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
|
||||||
|
const pageAiSetReasonixMemoryEnabled = (...args) => sidebarPageAi.pageAiSetReasonixMemoryEnabled(...args);
|
||||||
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
|
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
|
||||||
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
|
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
|
||||||
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
|
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
|
||||||
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
|
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
|
||||||
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...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 updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
|
||||||
|
|
||||||
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
|
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
|
||||||
@@ -2532,255 +2497,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
return;
|
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"]');
|
var searchTrigger = closestAction(e.target, '[data-mnote-action="open-search-modal"]');
|
||||||
if (searchTrigger) {
|
if (searchTrigger) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3112,79 +2828,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
closeSearchModal();
|
closeSearchModal();
|
||||||
closeTreeContextMenu();
|
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) {
|
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"]');
|
var globalCheckbox = closestAction(event.target, '[data-global-option-checkbox="showHeadingNumbers"]');
|
||||||
if (globalCheckbox instanceof HTMLInputElement) {
|
if (globalCheckbox instanceof HTMLInputElement) {
|
||||||
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
|
writeGlobalShowHeadingNumbers(globalCheckbox.checked);
|
||||||
@@ -3218,6 +2864,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
void persistPageWidthPreference(pageWidthType, pageWidthSelect.value);
|
void persistPageWidthPreference(pageWidthType, pageWidthSelect.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
sidebarPageAi.installPageAiDelegates();
|
||||||
|
|
||||||
function initializePageUiSurfaces() {
|
function initializePageUiSurfaces() {
|
||||||
pageUiState.pageOptions = null;
|
pageUiState.pageOptions = null;
|
||||||
@@ -3243,6 +2890,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
}
|
}
|
||||||
scheduleInitializePageUiSurfaces();
|
scheduleInitializePageUiSurfaces();
|
||||||
|
|
||||||
|
function mnoteDevHotReloadEnabled() {
|
||||||
|
try {
|
||||||
|
return new URL(import.meta.url).searchParams.has('devHot');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function installMnoteDevHotReload() {
|
function installMnoteDevHotReload() {
|
||||||
var bootId = '';
|
var bootId = '';
|
||||||
var failedOnce = false;
|
var failedOnce = false;
|
||||||
@@ -3271,7 +2926,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
|
|||||||
tick();
|
tick();
|
||||||
timer = window.setInterval(tick, 1000);
|
timer = window.setInterval(tick, 1000);
|
||||||
}
|
}
|
||||||
installMnoteDevHotReload();
|
if (mnoteDevHotReloadEnabled()) {
|
||||||
|
installMnoteDevHotReload();
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('dragstart', function(event) {
|
document.addEventListener('dragstart', function(event) {
|
||||||
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
|
var pageRow = closestAction(event.target, '.tree-row[data-shell-mode="page"][draggable="true"]');
|
||||||
|
|||||||
@@ -260,6 +260,20 @@ pub fn acp_event_to_sse(event: AcpSessionEvent) -> Option<SseEvent> {
|
|||||||
event: "session.info.updated".into(),
|
event: "session.info.updated".into(),
|
||||||
data: json!({ "title": title }),
|
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 {
|
AcpSessionEvent::PlanUpdate { entries } => Some(SseEvent {
|
||||||
event: "plan.updated".into(),
|
event: "plan.updated".into(),
|
||||||
data: json!({ "entries": entries }),
|
data: json!({ "entries": entries }),
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ pub enum AcpSessionEvent {
|
|||||||
},
|
},
|
||||||
/// 会话元数据更新,例如自动标题。
|
/// 会话元数据更新,例如自动标题。
|
||||||
SessionInfoUpdate { title: String },
|
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> },
|
PlanUpdate { entries: Vec<String> },
|
||||||
/// 连接关闭或异常。
|
/// 连接关闭或异常。
|
||||||
@@ -765,6 +772,46 @@ impl AcpSessionManager {
|
|||||||
Some(AcpSessionEvent::PlanUpdate { entries: summaries })
|
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 { .. } => {
|
SessionUpdate::Unknown { .. } => {
|
||||||
warn!("ACP unknown session/update variant");
|
warn!("ACP unknown session/update variant");
|
||||||
None
|
None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ pub mod block;
|
|||||||
pub mod context_tools;
|
pub mod context_tools;
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
|
pub mod onlyoffice_live;
|
||||||
pub mod page;
|
pub mod page;
|
||||||
pub mod resource;
|
pub mod resource;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,17 @@ pub async fn mindmap_fetch(
|
|||||||
})?;
|
})?;
|
||||||
let data =
|
let data =
|
||||||
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
|
serde_json::from_str::<Value>(&content).unwrap_or_else(|_| json!({ "raw": content }));
|
||||||
let nodes = collect_mindmap_nodes(&data);
|
|
||||||
let scope = input
|
let scope = input
|
||||||
.arg_string("scope")
|
.arg_string("scope")
|
||||||
.unwrap_or_else(|| "tree".into())
|
.unwrap_or_else(|| "tree".into())
|
||||||
.to_ascii_lowercase();
|
.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!({
|
Ok(json!({
|
||||||
"objectIdentity": target.object_identity,
|
"objectIdentity": target.object_identity,
|
||||||
"resourceKind": "mindmap",
|
"resourceKind": "mindmap",
|
||||||
@@ -35,6 +41,8 @@ pub async fn mindmap_fetch(
|
|||||||
"mindmapId": target.resource_id,
|
"mindmapId": target.resource_id,
|
||||||
"resourcePath": target.resource_path,
|
"resourcePath": target.resource_path,
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
|
"root": root,
|
||||||
|
"envelope": envelope,
|
||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"edges": [],
|
"edges": [],
|
||||||
"markdownSummary": mindmap_markdown_summary(&nodes),
|
"markdownSummary": mindmap_markdown_summary(&nodes),
|
||||||
@@ -67,14 +75,150 @@ pub async fn mindmap_apply_ops(
|
|||||||
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
|
"diff": [{"op": "mindmap.apply_ops", "ops": ops}]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Err(WebError::bad_request_code(
|
ensure_mindmap_revision_precondition(context, input, &path)?;
|
||||||
"mnote_resource_native_patch_required",
|
let content = fs::read_to_string(&path).map_err(|error| {
|
||||||
format!(
|
WebError::bad_request_code(
|
||||||
"本地 mindmap resource 写入请使用 agent 原生 patch 编辑授权文件;已校验可写资源路径 {}",
|
"mnote_resource_read_failed",
|
||||||
path.display()
|
format!("无法读取 mindmap resource: {error}"),
|
||||||
),
|
)
|
||||||
)
|
.with_context(context)
|
||||||
.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(
|
pub async fn office_fetch_summary(
|
||||||
@@ -235,6 +379,45 @@ impl ResourceToolTarget {
|
|||||||
Ok(canonical)
|
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 {
|
fn relative_path(&self) -> String {
|
||||||
self.resource_path
|
self.resource_path
|
||||||
.as_deref()
|
.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(¤t).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> {
|
fn collect_mindmap_nodes(value: &Value) -> Vec<Value> {
|
||||||
let mut nodes = Vec::new();
|
let mut nodes = Vec::new();
|
||||||
collect_mindmap_nodes_inner(value, &mut nodes);
|
collect_mindmap_nodes_inner(mindmap_root_value(value), &mut nodes);
|
||||||
nodes
|
nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,68 @@ const SKILLS: &[MnoteSkill] = &[
|
|||||||
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
|
||||||
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
|
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 {
|
MnoteSkill {
|
||||||
id: "mnote-chat-only",
|
id: "mnote-chat-only",
|
||||||
title: "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("mnote-local-file", Some("chat_only")).is_none());
|
||||||
assert!(find_skill("missing", Some("reasonix")).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 {
|
Self {
|
||||||
document_id: String::new(),
|
document_id: String::new(),
|
||||||
workspace_id: "default".to_string(),
|
workspace_id: "default".to_string(),
|
||||||
source: PageAggregateSource::CompatMetaContentJoin,
|
source: PageAggregateSource::KernelProjection,
|
||||||
parent_id: None,
|
parent_id: None,
|
||||||
path: Vec::new(),
|
path: Vec::new(),
|
||||||
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
|
sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()],
|
||||||
@@ -372,3 +372,29 @@ impl Default for PageAggregateBuilder {
|
|||||||
Self::new()
|
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
Reference in New Issue
Block a user