# 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 成功路径测试；resource tab 图片/PDF OCR 工具条、附件/File Tree 资源菜单入口和 `task526` UI smoke 已补。active job store、`local_ocr.job.updated` realtime event、全局任务抽屉和 AI 资源上下文读取 OCR sidecar 尚未落地。本文继续保持 `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 模式）
- [x] 后端：新增 OCR active job store，记录 queued/uploading/mineru_processing/downloading/writing_sidecar/done/failed/interrupted/stale。
- [x] 后端：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。
- [x] UI：在附件右键菜单、File Tree 资源菜单和资源 tab 增加 OCR 识别入口。
- [x] UI：展示 OCR 状态、stale、失败和重新识别动作。
- [x] UI：新增全局 OCR 任务栏 / 任务抽屉，显示任务阶段、所属页面、打开 OCR、重试。
- [x] UI/API：增加“插入 OCR 链接到正文”动作。（可选“插入 OCR 正文段落”不作为本阶段验收项，后续需要时另拆增强稿）
- [x] AI：让 image/PDF 资源上下文优先读取已有 OCR sidecar。
- [x] 测试：Rust 单测覆盖路径规划、frontmatter、索引读写、stale、越界拒绝。
- [x] 测试：Rust route 测试覆盖无 token、真实 MinerU HTTP mock 成功、模拟失败和脱敏错误。
- [x] 测试：local search 测试覆盖 OCR 命中返回 owner page，OCR sidecar 不作为普通页面。
- [x] Smoke：浏览器覆盖图片/PDF 手动 OCR、全局任务栏状态、打开 OCR resource tab、搜索 OCR 命中、插入 OCR 链接。
- [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`

2026-06-01 任务链与 AI context 收口：

- 后端在 `AppState` 增加 OCR active job store 和 `local_ocr_job_tx` broadcast；`POST /api/local-folder/ocr/jobs` 会在 queued / uploading / mineru_processing / downloading / writing_sidecar / done / failed 阶段更新 `.mnote/ocr-index.json`，并通过 `/api/local-folder/events` 发出 `local_ocr.job.updated`。
- 资源 tab 增加全局 OCR 任务 dock / drawer；任务抽屉从 jobs API 恢复历史状态，并通过 `local_ocr.job.updated` 事件更新，支持打开 OCR 和失败/stale 重试。
- Page AI active image/PDF resource target 会在发 run 前读取已有 OCR sidecar，把 `mnote.local_ocr_context.v1` 放入 active_editor contextRef、targetPackage、currentFile 和 primary target。
- 已通过：
  - `node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js`
  - `node --check rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`
  - `node --check rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js`
  - `node --check scripts/task526-local-folder-ocr-api-smoke.js`
  - `node --check scripts/task520-page-ai-raw-resource-target-smoke.js`
  - `cargo fmt --check --all`
  - `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`
  - `MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task526-local-folder-ocr-api-smoke.js`
  - `TASK520_OCR_CONTEXT=1 MNOTE_UI_BASE_URL=http://127.0.0.1:3000 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3000 node scripts/task520-page-ai-raw-resource-target-smoke.js`
