收口 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:
@@ -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.
|
||||
Reference in New Issue
Block a user