# 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 识别文本... ``` ## 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`