diff --git a/bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md b/bugs/03-rust-web/done/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
similarity index 70%
rename from bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
rename to bugs/03-rust-web/done/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
index 9f2b5346..7de3e1ee 100644
--- a/bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
+++ b/bugs/03-rust-web/done/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
@@ -2,13 +2,13 @@
## 状态
-- 状态:process
+- 状态:done
- 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` 搜索命中、resource tab 图片/PDF OCR 工具条、附件/File Tree 资源菜单入口和浏览器 UI smoke。但 active job store、realtime job event、全局任务栏/任务抽屉和 AI 资源上下文仍未落地。
+P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真实 MinerU HTTP client、sidecar 写入、`.mnote/ocr-index.json`、`includeOcr=true` 搜索命中、resource tab 图片/PDF OCR 工具条、附件/File Tree 资源菜单入口、active job store、`local_ocr.job.updated` realtime event、全局任务栏/任务抽屉、AI OCR sidecar context 和浏览器 UI smoke。
## 证据
@@ -16,19 +16,19 @@ P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真
- `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;增量刷新也已排除 hash 后缀 OCR sidecar,不再把 `*.ocr.md` 当普通页面命中。
- `task526-local-folder-ocr-api-smoke.js` 已覆盖浏览器上下文调用 mock OCR API、图片 resource tab 手动 OCR、工具条状态、打开 OCR、搜索命中和显式 insert 链接。
-- 设计中的活动任务生命周期、`local_ocr.job.updated` 和浏览器可见入口仍未完成。
+- 活动任务生命周期、`local_ocr.job.updated`、全局任务抽屉和 AI OCR context 已完成并有测试/smoke 覆盖。
## 影响
-- 用户已能从本地图片/PDF resource tab、附件菜单和 File Tree 资源菜单触发 OCR;但离开当前资源后的全局任务抽屉仍缺。
+- 用户已能从本地图片/PDF resource tab、附件菜单和 File Tree 资源菜单触发 OCR;离开当前资源后可通过全局 OCR 任务抽屉查看状态、打开 OCR、对失败/stale 任务重试。
- 搜索的 `includeOcr=true` 已能命中 OCR sidecar,`includeOcr=false` 不应命中普通 OCR sidecar;本轮补了增量索引过滤测试。
-- AI 资源上下文无法优先读取已有 OCR Markdown。
+- AI image/PDF resource context 会优先读取已有 OCR Markdown,并把 OCR context 附到 active_editor contextRef 与 targetPackage。
## 下一步
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。
+3. 已接长任务状态、`local_ocr.job.updated` realtime 事件、全局 OCR 任务栏/任务抽屉和 AI 资源上下文读取 OCR sidecar。
## 验收
@@ -37,7 +37,7 @@ P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真
- [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` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、搜索命中、显式 insert link 和 OCR sidecar resource tab 打开;全局任务栏/任务抽屉待做)
+- [x] 浏览器 smoke 覆盖图片/PDF 手动 OCR、任务栏状态、打开 OCR、搜索 OCR 命中和插入 OCR 链接。
## 2026-06-01 后端 mock 闭环记录
@@ -96,8 +96,22 @@ P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真
- 已通过:
- `cargo test --manifest-path rust/Cargo.toml -p mnote-web local_ocr -- --test-threads=1`
-本文继续保持 `process` 的剩余原因:
+## 2026-06-01 OCR P2 收口记录
-- OCR jobs 仍是同步请求 + `.mnote/ocr-index.json` 历史状态,没有 active job store。
-- 缺少 `local_ocr.job.updated` realtime event 与全局任务栏 UI。
-- AI 资源上下文还没有优先读取 OCR sidecar。
+- `AppState` 新增 OCR active job store 与 `local_ocr_job_tx`;`POST /api/local-folder/ocr/jobs` 在 queued / uploading / mineru_processing / downloading / writing_sidecar / done / failed 阶段更新 index 并广播 `local_ocr.job.updated`。
+- `/api/local-folder/events` 会把同 root 的 OCR job update 作为 SSE 事件透出。
+- `document-resource-tab-runtime.js` 新增全局 OCR 任务 dock / drawer,从 jobs API 恢复状态,并通过 `local_ocr.job.updated` 更新;支持打开 OCR sidecar 与失败/stale 重试。
+- `sidebar-page-ai-target-runtime.js` / `sidebar-page-ai-runtime.js` 在 Page AI run 前读取 active image/PDF 资源已有 OCR sidecar,把 `mnote.local_ocr_context.v1` 放入 active_editor contextRef、targetPackage、currentFile 和 primary target。
+- `task526-local-folder-ocr-api-smoke.js` 覆盖全局 OCR 任务抽屉状态。
+- `TASK520_OCR_CONTEXT=1 node scripts/task520-page-ai-raw-resource-target-smoke.js` 覆盖 Page AI OCR sidecar context。
+- 已通过:
+ - `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`
diff --git a/design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md b/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md
similarity index 88%
rename from design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md
rename to design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md
index 860e3e86..200f5bdc 100644
--- a/design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md
+++ b/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md
@@ -346,22 +346,22 @@ stale
- [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 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 识别入口。
-- [~] UI:展示 OCR 状态、stale、失败和重新识别动作。(resource tab 已展示 idle/running/done/failed/stale 与打开/插入动作;全局任务抽屉重试链待做)
-- [ ] UI:新增全局 OCR 任务栏 / 任务抽屉,显示任务阶段、所属页面、打开 OCR、重试。
-- [~] UI/API:增加“插入 OCR 链接到正文”和可选“插入 OCR 正文段落”动作。(后端 link API 与 resource tab 前端入口已完成;inlineSection 待做)
-- [ ] AI:让 image/PDF 资源上下文优先读取已有 OCR sidecar。
+- [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 不作为普通页面。
-- [~] Smoke:浏览器覆盖图片/PDF 手动 OCR、全局任务栏状态、打开 OCR resource tab、搜索 OCR 命中、插入 OCR 链接。(当前 `task526` 覆盖图片 resource tab 手动 OCR、工具条状态、打开 OCR、插入链接、mock OCR API 和搜索命中;全局任务栏/任务抽屉待做)
+- [x] Smoke:浏览器覆盖图片/PDF 手动 OCR、全局任务栏状态、打开 OCR resource tab、搜索 OCR 命中、插入 OCR 链接。
- [x] 文档:补充 `scripts/TESTING_REFERENCE.md` 中 OCR smoke 基线。
## 验收标准
@@ -411,3 +411,20 @@ stale
- 新增 `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`
diff --git a/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md b/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md
index 518a8df1..bf0f84f4 100644
--- a/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md
+++ b/design/10-review/done/18-current-design-and-bug-hunt-review-v1.md
@@ -60,19 +60,19 @@ 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] `5-33` 已拆出 breadcrumb / fetch guard follow-up;本轮 P0 不再被其阻塞,后续增强继续由 `design/05-editor-mainline/process/5-35-navigation-breadcrumb-fetch-guard-followup-v1.md` 跟踪。
- [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] 继续推进 `7-18` Agent Target Resolver:target chip/picker、dirty guard、allowed roots/files、Page AI targetPackage 与 resource OCR context 已补到本轮可验收闭环;跨 workspace 多 target、真实 agent 写后 diff 回收等产品化尾项继续由 `7-18` 跟踪。
- [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] `3-25` MinerU OCR:后端真实 MinerU runtime、任务状态事件、全局任务栏 / 任务抽屉、UI 入口和 AI OCR sidecar context 已收口。
- [x] `7-43` OnlyOffice Plugin Bridge:P0/P1 安全主路径与 P2 recipe 矩阵已收口,后续新增 recipe 按矩阵逐项验证。
- [x] `7-44` ChatOnly Doubao binding:MNote 侧 binding 与 Doubao provider 单测已完成。
- [x] `7-49` ChatOnly OpenClaw provider:Doubao provider 真实远端删除 helper / 单测已完成;DeepSeek / Gemini provider 已补明确 unsupported 降级 helper 与单测,返回 `remote_delete_failed` 而不是伪造 `remote_deleted`,缺口已归档到 `bugs/07-ai/done/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md`。
diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
index c271892c..9951b6de 100644
--- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
+++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
@@ -1039,6 +1039,169 @@ export const createResourceTabRuntime = (dependencies = {}) => {
return payload.job || null;
};
+ const localOcrTaskState = {
+ rootUri: '',
+ jobsBySource: new Map(),
+ drawerOpen: false,
+ eventSource: null,
+ };
+
+ const localOcrJobKey = (job) => String(job?.sourceRootRelativePath || job?.jobId || '').trim();
+
+ const updateLocalOcrTaskState = (job) => {
+ const key = localOcrJobKey(job);
+ if (!key) return;
+ localOcrTaskState.jobsBySource.set(key, job);
+ renderLocalOcrTaskDock();
+ };
+
+ const statusTextForLocalOcrJob = (job) => {
+ const status = String(job?.status || '').trim();
+ if (job?.stageLabel) return String(job.stageLabel);
+ return status === 'done' ? '已识别'
+ : status === 'failed' ? '识别失败'
+ : status === 'stale' ? '来源已变化'
+ : status === 'running' ? '处理中'
+ : status || '未知';
+ };
+
+ const ensureLocalOcrTaskDock = () => {
+ let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]');
+ if (dock instanceof HTMLElement) return dock;
+ dock = document.createElement('section');
+ dock.className = 'mnote-local-ocr-task-dock';
+ dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock');
+ dock.innerHTML = '
';
+ document.body.appendChild(dock);
+ const toggle = dock.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
+ if (toggle instanceof HTMLButtonElement) {
+ toggle.addEventListener('click', () => {
+ localOcrTaskState.drawerOpen = !localOcrTaskState.drawerOpen;
+ renderLocalOcrTaskDock();
+ });
+ }
+ dock.addEventListener('click', (event) => {
+ const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null;
+ if (openButton instanceof HTMLElement) {
+ const sourcePath = openButton.getAttribute('data-mnote-local-ocr-task-open') || '';
+ const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
+ if (job) {
+ void openResourceInActiveTab({
+ kind: 'markdown',
+ title: String(job.ocrRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR',
+ path: job.ocrRootRelativePath,
+ objectIdentity: `local-ocr:${job.ocrRootRelativePath}`,
+ assetId: `local-ocr:${job.ocrRootRelativePath}`,
+ documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
+ ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
+ workspaceId: currentWebShellWorkspaceId() || '',
+ sourceKind: 'local_folder',
+ rootUri: localOcrTaskState.rootUri || '',
+ resourceKind: 'markdown',
+ }).catch((error) => console.warn('mnote local OCR 任务打开失败', error));
+ }
+ }
+ const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null;
+ if (retryButton instanceof HTMLElement) {
+ const sourcePath = retryButton.getAttribute('data-mnote-local-ocr-task-retry') || '';
+ const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
+ if (job) {
+ const entry = {
+ sourceKind: 'local_folder',
+ rootUri: localOcrTaskState.rootUri,
+ path: sourcePath,
+ kind: sourcePath.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image',
+ title: sourcePath.split('/').filter(Boolean).pop() || sourcePath,
+ documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
+ ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
+ workspaceId: currentWebShellWorkspaceId() || '',
+ localOcrJob: job,
+ };
+ void createLocalOcrJob(entry).catch((error) => console.warn('mnote local OCR 任务重试失败', error));
+ }
+ }
+ });
+ return dock;
+ };
+
+ const renderLocalOcrTaskDock = () => {
+ const dock = ensureLocalOcrTaskDock();
+ const jobs = Array.from(localOcrTaskState.jobsBySource.values())
+ .sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0));
+ const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length;
+ const toggle = dock.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
+ if (toggle instanceof HTMLButtonElement) {
+ toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
+ toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false');
+ }
+ const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
+ if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen;
+ const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]');
+ if (!(list instanceof HTMLElement)) return;
+ list.replaceChildren();
+ if (!jobs.length) {
+ const empty = document.createElement('div');
+ empty.className = 'mnote-local-ocr-task-empty';
+ empty.textContent = '暂无 OCR 任务';
+ list.appendChild(empty);
+ return;
+ }
+ jobs.forEach((job) => {
+ const row = document.createElement('div');
+ row.className = 'mnote-local-ocr-task-row';
+ row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || ''));
+ row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || ''));
+ const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR';
+ row.innerHTML = '
';
+ row.querySelector('strong').textContent = title;
+ row.querySelector('span').textContent = statusTextForLocalOcrJob(job);
+ const open = row.querySelector('[data-mnote-local-ocr-task-open]');
+ if (open instanceof HTMLButtonElement) {
+ open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
+ open.disabled = !job.ocrRootRelativePath;
+ }
+ const retry = row.querySelector('[data-mnote-local-ocr-task-retry]');
+ if (retry instanceof HTMLButtonElement) {
+ retry.setAttribute('data-mnote-local-ocr-task-retry', String(job.sourceRootRelativePath || ''));
+ retry.hidden = !['failed', 'stale'].includes(String(job.status || ''));
+ }
+ list.appendChild(row);
+ });
+ };
+
+ const loadLocalOcrJobs = async (rootUri) => {
+ const normalizedRoot = String(rootUri || '').trim();
+ if (!normalizedRoot) return;
+ localOcrTaskState.rootUri = normalizedRoot;
+ const url = new URL('/api/local-folder/ocr/jobs', window.location.origin);
+ url.searchParams.set('rootUri', normalizedRoot);
+ const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok || !payload || payload.ok !== true) return;
+ (Array.isArray(payload.jobs) ? payload.jobs : []).forEach(updateLocalOcrTaskState);
+ renderLocalOcrTaskDock();
+ };
+
+ const ensureLocalOcrTaskEvents = (rootUri) => {
+ const normalizedRoot = String(rootUri || '').trim();
+ if (!normalizedRoot || typeof window.EventSource !== 'function') return;
+ if (localOcrTaskState.eventSource && localOcrTaskState.rootUri === normalizedRoot) return;
+ if (localOcrTaskState.eventSource) {
+ try { localOcrTaskState.eventSource.close(); } catch (_) {}
+ localOcrTaskState.eventSource = null;
+ }
+ localOcrTaskState.rootUri = normalizedRoot;
+ const url = new URL('/api/local-folder/events', window.location.origin);
+ url.searchParams.set('rootUri', normalizedRoot);
+ const eventSource = new EventSource(url.toString());
+ localOcrTaskState.eventSource = eventSource;
+ eventSource.addEventListener('local_ocr.job.updated', (event) => {
+ let payload = null;
+ try { payload = JSON.parse(event.data || '{}'); } catch (_) {}
+ if (payload?.job) updateLocalOcrTaskState(payload.job);
+ });
+ };
+
const openLocalOcrSidecar = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
@@ -1108,6 +1271,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
const job = payload.job || null;
setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job);
+ if (job) updateLocalOcrTaskState(job);
return job;
};
@@ -1146,9 +1310,13 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
}
setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
+ ensureLocalOcrTaskDock();
+ ensureLocalOcrTaskEvents(entry.rootUri);
+ void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void readLocalOcrStatus(entry).then((job) => {
if (!job) return;
setLocalOcrStatus(entry, job.stale ? 'stale' : String(job.status || 'done'), job.stale ? 'OCR 需更新' : 'OCR 已完成', job);
+ updateLocalOcrTaskState(job);
}).catch(() => undefined);
};
diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js
index 6f6aa13c..e813c0ed 100644
--- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js
+++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js
@@ -463,6 +463,7 @@ export function createSidebarPageAiRuntime(context) {
const pageAiWorkspacePathForTarget = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForTarget(...args);
const pageAiBuildAllowedRoots = (...args) => pageAiTargetRuntime.pageAiBuildAllowedRoots(...args);
const pageAiBuildContextRefs = (...args) => pageAiTargetRuntime.pageAiBuildContextRefs(...args);
+ const pageAiEnrichOcrContextRefs = (...args) => pageAiTargetRuntime.pageAiEnrichOcrContextRefs(...args);
const pageAiBuildRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiBuildRunTargetSnapshot(...args);
const pageAiPageContextForRefs = (...args) => pageAiTargetRuntime.pageAiPageContextForRefs(...args);
const pageAiSetRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiSetRunTargetSnapshot(...args);
@@ -1828,6 +1829,11 @@ export function createSidebarPageAiRuntime(context) {
var contextRefs = pageAiBuildContextRefs(scopedContext, runTargetSnapshot);
var allowedRoots = pageAiBuildAllowedRoots();
var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot);
+ if (typeof pageAiEnrichOcrContextRefs === 'function') {
+ var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget);
+ contextRefs = ocrContext.contextRefs || contextRefs;
+ agentTargetPackage = ocrContext.agentTargetPackage || agentTargetPackage;
+ }
if (scopedContext.pageContext && scopedContext.pageContext.aiContext) {
scopedContext.pageContext.aiContext.runTargetSnapshot = runTargetSnapshot;
scopedContext.pageContext.aiContext.agentTargetPackage = agentTargetPackage;
diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js
index 9f6ed76b..8efbc321 100644
--- a/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js
+++ b/rust/crates/mnote-web/browser/sidebar-page-ai-target-runtime.js
@@ -412,6 +412,84 @@ export function createSidebarPageAiTargetRuntime(context) {
});
}
+ function pageAiOcrEligibleResourceKind(value) {
+ var normalized = String(value || '').trim().toLowerCase();
+ return normalized === 'image' || normalized === 'pdf' || normalized === 'attachment' || normalized === 'resource';
+ }
+
+ function pageAiOcrEligiblePath(value) {
+ var path = String(value || '').trim().toLowerCase();
+ return /\.(png|jpg|jpeg|webp|bmp|tif|tiff|pdf)$/.test(path);
+ }
+
+ function pageAiOcrBodyPreview(markdown) {
+ var body = String(markdown || '').replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
+ return body.slice(0, 1600);
+ }
+
+ async function fetchPageAiOcrSidecarContext(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 relativePath = String(workspacePath.relativePath || target.path || '').trim();
+ var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
+ if (!relativePath || !pageAiOcrEligiblePath(relativePath) || !pageAiOcrEligibleResourceKind(resourceKind)) return null;
+ var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
+ if (!rootUri) return null;
+ try {
+ var statusUrl = new URL('/api/local-folder/ocr/status', window.location.origin);
+ statusUrl.searchParams.set('rootUri', rootUri);
+ statusUrl.searchParams.set('sourceRootRelativePath', relativePath);
+ var statusResponse = await fetch(statusUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
+ var statusPayload = await statusResponse.json().catch(function() { return null; });
+ var job = statusResponse.ok && statusPayload && statusPayload.ok === true ? statusPayload.job : null;
+ var ocrPath = String(job && job.ocrRootRelativePath || '').trim();
+ if (!ocrPath || String(job.status || '') === 'failed') return null;
+ var readUrl = new URL('/api/local-folder/ocr/read', window.location.origin);
+ readUrl.searchParams.set('rootUri', rootUri);
+ readUrl.searchParams.set('ocrRootRelativePath', ocrPath);
+ var readResponse = await fetch(readUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
+ var readPayload = await readResponse.json().catch(function() { return null; });
+ if (!readResponse.ok || !readPayload || readPayload.ok !== true) return null;
+ return {
+ schema: 'mnote.local_ocr_context.v1',
+ source: 'local_ocr_sidecar',
+ sourceRootRelativePath: relativePath,
+ ocrRootRelativePath: ocrPath,
+ status: String(job.status || ''),
+ stale: job.stale === true,
+ provider: String(job.provider || ''),
+ modelVersion: String(job.modelVersion || ''),
+ plainTextPreview: pageAiOcrBodyPreview(readPayload.markdown || ''),
+ };
+ } catch (_) {
+ return null;
+ }
+ }
+
+ async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
+ var ocrContext = await fetchPageAiOcrSidecarContext(editorTarget);
+ if (!ocrContext) return { contextRefs, agentTargetPackage };
+ var nextRefs = pageAiNormalizeArray(contextRefs).map(function(ref) {
+ if (ref && ref.kind === 'active_editor') return Object.assign({}, ref, { ocrContext: ocrContext });
+ return ref;
+ });
+ var nextPackage = agentTargetPackage && typeof agentTargetPackage === 'object'
+ ? Object.assign({}, agentTargetPackage, { ocrContext: ocrContext })
+ : agentTargetPackage;
+ if (nextPackage && nextPackage.currentFile && typeof nextPackage.currentFile === 'object') {
+ nextPackage.currentFile = Object.assign({}, nextPackage.currentFile, { ocrRootRelativePath: ocrContext.ocrRootRelativePath });
+ }
+ if (nextPackage && Array.isArray(nextPackage.targets)) {
+ nextPackage.targets = nextPackage.targets.map(function(target, index) {
+ return index === 0 && target && typeof target === 'object'
+ ? Object.assign({}, target, { ocrContext: ocrContext })
+ : target;
+ });
+ }
+ return { contextRefs: nextRefs, agentTargetPackage: nextPackage };
+ }
+
function pageAiBuildAllowedRoots() {
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
return {
@@ -733,6 +811,7 @@ export function createSidebarPageAiTargetRuntime(context) {
pageAiBuildAgentTargetPackage,
pageAiBuildAllowedRoots,
pageAiBuildContextRefs,
+ pageAiEnrichOcrContextRefs,
pageAiBuildRunTargetSnapshot,
pageAiCloneJson,
pageAiContextKindsFromRefs,
diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs
index 5839e182..eb87f15e 100644
--- a/rust/crates/mnote-web/src/app.rs
+++ b/rust/crates/mnote-web/src/app.rs
@@ -9,9 +9,10 @@ use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
+use std::collections::BTreeMap;
use std::env;
use std::fs;
-use std::sync::Arc;
+use std::sync::{Arc, RwLock};
use tower_http::trace::TraceLayer;
use tracing::{error, warn};
@@ -139,6 +140,8 @@ pub struct AppState {
pub editor_actor: EditorRuntimeActor,
pub block_delta_tx: broadcast::Sender,
pub stream_delta_tx: broadcast::Sender,
+ pub local_ocr_job_tx: broadcast::Sender,
+ pub local_ocr_active_jobs: Arc>>,
pub acp_runtime: Arc,
pub buffer_store: BufferStore,
control_plane: Arc,
@@ -148,6 +151,7 @@ impl AppState {
pub fn new(config: AppConfig) -> Self {
let (block_delta_tx, _) = broadcast::channel(256);
let (stream_delta_tx, _) = broadcast::channel(256);
+ let (local_ocr_job_tx, _) = broadcast::channel(256);
let actor = EditorRuntimeActor::new();
actor.set_block_delta_tx(block_delta_tx.clone());
let buffer_store = BufferStore::new();
@@ -158,6 +162,8 @@ impl AppState {
editor_actor: actor,
block_delta_tx,
stream_delta_tx,
+ local_ocr_job_tx,
+ local_ocr_active_jobs: Arc::new(RwLock::new(BTreeMap::new())),
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
buffer_store,
control_plane,
diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs
index 1203fe97..1f839a74 100644
--- a/rust/crates/mnote-web/src/routes/local_folder_events.rs
+++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs
@@ -18,7 +18,7 @@ use std::convert::Infallible;
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
-use tokio::sync::broadcast::error::RecvError;
+use tokio::sync::broadcast::{error::RecvError, Receiver};
use tokio::time::timeout;
type BoxedEventStream =
@@ -80,6 +80,7 @@ async fn build_document_events_stream(
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
+ let local_ocr_job_rx = state.local_ocr_job_tx.subscribe();
let initial = json!({
"sourceKind": "local_folder",
@@ -89,29 +90,79 @@ async fn build_document_events_stream(
"revision": system_time_ms(SystemTime::now()),
});
let stream = stream::unfold(
- (Some(initial), subscription, document_relative_path),
- |(initial, mut subscription, document_relative_path)| async move {
+ (
+ Some(initial),
+ subscription,
+ document_relative_path,
+ query.root_uri.clone(),
+ local_ocr_job_rx,
+ ),
+ |(
+ initial,
+ mut subscription,
+ document_relative_path,
+ root_uri,
+ mut local_ocr_job_rx,
+ )| async move {
if let Some(payload) = initial {
return Some((
Ok(stream_event("ready", &payload)),
- (None, subscription, document_relative_path),
+ (
+ None,
+ subscription,
+ document_relative_path,
+ root_uri,
+ local_ocr_job_rx,
+ ),
));
}
loop {
- match subscription.receiver.recv().await {
- Ok(payload) => {
- if let Some(expected) = document_relative_path.as_deref() {
- if !document_event_targets_relative_path(&payload, expected) {
- continue;
+ tokio::select! {
+ watcher_result = subscription.receiver.recv() => {
+ match watcher_result {
+ Ok(payload) => {
+ if let Some(expected) = document_relative_path.as_deref() {
+ if !document_event_targets_relative_path(&payload, expected) {
+ continue;
+ }
+ }
+ return Some((
+ Ok(stream_event("change", &payload)),
+ (
+ None,
+ subscription,
+ document_relative_path,
+ root_uri,
+ local_ocr_job_rx,
+ ),
+ ));
}
+ Err(RecvError::Lagged(_)) => continue,
+ Err(RecvError::Closed) => return None,
+ }
+ }
+ ocr_result = recv_matching_ocr_event(&mut local_ocr_job_rx, &root_uri) => {
+ match ocr_result {
+ Some(payload) => {
+ if let Some(expected) = document_relative_path.as_deref() {
+ if !document_event_targets_relative_path(&payload, expected) {
+ continue;
+ }
+ }
+ return Some((
+ Ok(stream_event("local_ocr.job.updated", &payload)),
+ (
+ None,
+ subscription,
+ document_relative_path,
+ root_uri,
+ local_ocr_job_rx,
+ ),
+ ));
+ }
+ None => continue,
}
- return Some((
- Ok(stream_event("change", &payload)),
- (None, subscription, document_relative_path),
- ));
}
- Err(RecvError::Lagged(_)) => continue,
- Err(RecvError::Closed) => return None,
}
}
},
@@ -121,6 +172,20 @@ async fn build_document_events_stream(
Ok((HeaderMap::new(), stream))
}
+async fn recv_matching_ocr_event(rx: &mut Receiver, root_uri: &str) -> Option {
+ loop {
+ match rx.recv().await {
+ Ok(payload) => {
+ if payload.get("rootUri").and_then(Value::as_str) == Some(root_uri) {
+ return Some(payload);
+ }
+ }
+ Err(RecvError::Lagged(_)) => continue,
+ Err(RecvError::Closed) => return None,
+ }
+ }
+}
+
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
/// with full sidebar + file tree projections.
///
diff --git a/rust/crates/mnote-web/src/routes/local_ocr.rs b/rust/crates/mnote-web/src/routes/local_ocr.rs
index b28752de..e1980eb4 100644
--- a/rust/crates/mnote-web/src/routes/local_ocr.rs
+++ b/rust/crates/mnote-web/src/routes/local_ocr.rs
@@ -181,9 +181,11 @@ pub(crate) async fn create_job(
body.force,
)?;
let now = now_ms();
+ let mut entry = build_index_entry(&plan, "queued", now, "", None);
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
if let Some(error) = body.mock_error.as_deref() {
- let entry = build_index_entry(&plan, "failed", now, "", Some(redact_error(error)));
- upsert_ocr_index_entry(&root, entry.clone())?;
+ entry = advance_ocr_entry(&entry, "failed", now_ms(), "", Some(redact_error(error)));
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
return Ok(ok_json(
&context,
json!({
@@ -193,27 +195,44 @@ pub(crate) async fn create_job(
));
}
let markdown = if provider == "mock" {
+ entry = advance_ocr_entry(&entry, "writing_sidecar", now_ms(), "", None);
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
body.mock_markdown
.unwrap_or_else(|| format!("OCR mock result for {}", plan.source_root_relative_path))
} else {
- match run_mineru_ocr(&root, &plan, token.unwrap_or_default()).await {
+ entry = advance_ocr_entry(&entry, "uploading", now_ms(), "", None);
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
+ match run_mineru_ocr(
+ &state,
+ &root,
+ root_uri,
+ &plan,
+ token.unwrap_or_default(),
+ entry.clone(),
+ )
+ .await
+ {
Ok(markdown) => markdown,
Err(error) => {
- let failed_entry = build_index_entry(
- &plan,
+ let failed_entry = advance_ocr_entry(
+ &entry,
"failed",
- now,
+ now_ms(),
"",
Some(redact_error(error.message())),
);
- upsert_ocr_index_entry(&root, failed_entry)?;
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, failed_entry)?;
return Err(error.with_context(&context));
}
}
};
+ if entry.status != "writing_sidecar" {
+ entry = advance_ocr_entry(&entry, "writing_sidecar", now_ms(), "", None);
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
+ }
write_ocr_sidecar(&plan, &markdown, now)?;
- let entry = build_index_entry(&plan, "done", now, &markdown, None);
- upsert_ocr_index_entry(&root, entry.clone())?;
+ entry = advance_ocr_entry(&entry, "done", now_ms(), &markdown, None);
+ upsert_and_broadcast_ocr_index_entry(&state, &root, root_uri, entry.clone())?;
Ok(ok_json(
&context,
json!({
@@ -463,9 +482,12 @@ pub(crate) fn strip_ocr_frontmatter(markdown: &str) -> &str {
}
async fn run_mineru_ocr(
+ state: &AppState,
root: &Path,
+ root_uri: &str,
plan: &OcrSidecarPlan,
token: String,
+ mut entry: OcrIndexEntry,
) -> Result {
let config = MineruClientConfig {
api_base_url: mineru_api_base_url(),
@@ -491,7 +513,11 @@ async fn run_mineru_ocr(
let client = reqwest::Client::new();
let upload = create_mineru_upload_task(&client, &config, file_name).await?;
upload_mineru_source(&client, &upload.upload_url, bytes).await?;
+ entry = advance_ocr_entry(&entry, "mineru_processing", now_ms(), "", None);
+ upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry.clone())?;
let zip_url = poll_mineru_result_zip_url(&client, &config, &upload.batch_id).await?;
+ entry = advance_ocr_entry(&entry, "downloading", now_ms(), "", None);
+ upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry)?;
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
extract_mineru_markdown_from_zip(&zip_bytes)
}
@@ -953,6 +979,23 @@ fn build_index_entry(
}
}
+fn advance_ocr_entry(
+ previous: &OcrIndexEntry,
+ status: &str,
+ now: u128,
+ markdown: &str,
+ error: Option,
+) -> OcrIndexEntry {
+ let mut next = previous.clone();
+ next.status = status.to_string();
+ next.updated_at_ms = now;
+ if !markdown.is_empty() {
+ next.plain_text_preview = markdown.chars().take(240).collect();
+ }
+ next.error = error;
+ next
+}
+
fn read_ocr_index(root: &Path) -> Result {
let path = ocr_index_path(root);
if !path.exists() {
@@ -1011,6 +1054,42 @@ fn upsert_ocr_index_entry(root: &Path, entry: OcrIndexEntry) -> Result<(), WebEr
write_ocr_index(root, &index)
}
+fn upsert_and_broadcast_ocr_index_entry(
+ state: &AppState,
+ root: &Path,
+ root_uri: &str,
+ entry: OcrIndexEntry,
+) -> Result<(), WebError> {
+ upsert_ocr_index_entry(root, entry.clone())?;
+ broadcast_ocr_job_update(state, root, root_uri, &entry);
+ Ok(())
+}
+
+fn broadcast_ocr_job_update(state: &AppState, root: &Path, root_uri: &str, entry: &OcrIndexEntry) {
+ let job = ocr_job_payload(root, entry);
+ let key = format!("{root_uri}:{}", entry.source_root_relative_path);
+ if matches!(entry.status.as_str(), "done" | "failed" | "interrupted") {
+ if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
+ jobs.remove(&key);
+ }
+ } else if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
+ jobs.insert(key, job.clone());
+ }
+ let payload = json!({
+ "schema": "mnote.local_ocr.job.updated.v1",
+ "kind": "local_ocr_job_updated",
+ "eventType": "local_ocr.job.updated",
+ "sourceKind": "local_folder",
+ "rootUri": root_uri,
+ "relativePath": entry.source_root_relative_path,
+ "documentId": entry.owner_document_id,
+ "revision": entry.updated_at_ms.to_string(),
+ "job": job,
+ });
+ let _ = state.local_ocr_job_tx.send(payload.clone());
+ let _ = state.stream_delta_tx.send(payload);
+}
+
fn ocr_job_payload(root: &Path, entry: &OcrIndexEntry) -> Value {
let stale = source_is_stale(root, entry);
json!({
@@ -1267,8 +1346,8 @@ mod tests {
root
}
- fn app() -> axum::Router {
- build_app(AppState::new(AppConfig {
+ fn test_app_state() -> AppState {
+ AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
@@ -1287,7 +1366,15 @@ mod tests {
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
- }))
+ })
+ }
+
+ fn app() -> axum::Router {
+ build_app(test_app_state())
+ }
+
+ fn app_with_state(state: AppState) -> axum::Router {
+ build_app(state)
}
fn write_workspace_manifest(root: &Path) {
@@ -1501,6 +1588,70 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
+ #[tokio::test]
+ async fn local_ocr_jobs_route_broadcasts_stage_updates() {
+ let root = temp_root("mnote-local-ocr-events");
+ write_workspace_manifest(&root);
+ fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
+ fs::write(
+ root.join("docs").join("Page.assets").join("photo.png"),
+ b"png",
+ )
+ .expect("photo");
+ let root_uri = format!("file://{}", root.display());
+ let state = test_app_state();
+ let mut events = state.local_ocr_job_tx.subscribe();
+ let response = app_with_state(state.clone())
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/api/local-folder/ocr/jobs")
+ .header("content-type", "application/json")
+ .header("x-mnote-actor-id", "user_test")
+ .header("x-mnote-actor-type", "user")
+ .body(Body::from(
+ json!({
+ "rootUri": root_uri,
+ "documentId": "local-md:docs~2FPage.md",
+ "sourceRootRelativePath": "docs/Page.assets/photo.png",
+ "provider": "mock",
+ "mockMarkdown": "Event OCR Token"
+ })
+ .to_string(),
+ ))
+ .expect("request"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(response.status(), StatusCode::OK);
+
+ let mut statuses = Vec::new();
+ for _ in 0..3 {
+ let payload = tokio::time::timeout(Duration::from_secs(1), events.recv())
+ .await
+ .expect("ocr event timeout")
+ .expect("ocr event");
+ assert_eq!(payload["eventType"].as_str(), Some("local_ocr.job.updated"));
+ assert_eq!(
+ payload["job"]["sourceRootRelativePath"].as_str(),
+ Some("docs/Page.assets/photo.png")
+ );
+ statuses.push(
+ payload["job"]["status"]
+ .as_str()
+ .unwrap_or_default()
+ .to_string(),
+ );
+ }
+ assert_eq!(statuses, vec!["queued", "writing_sidecar", "done"]);
+ assert!(state
+ .local_ocr_active_jobs
+ .read()
+ .expect("active jobs lock")
+ .is_empty());
+ let _ = fs::remove_dir_all(root);
+ }
+
#[tokio::test]
async fn local_ocr_jobs_route_rejects_missing_mineru_token() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs
index 1b2f3f91..6e764689 100644
--- a/rust/crates/mnote-web/src/ssr/styles.rs
+++ b/rust/crates/mnote-web/src/ssr/styles.rs
@@ -2778,6 +2778,89 @@ body {
background: #f7f6f3;
}
+.mnote-local-ocr-task-dock {
+ position: fixed;
+ right: 18px;
+ bottom: 18px;
+ z-index: 80;
+ color: #37352f;
+ font-size: 13px;
+}
+
+.mnote-local-ocr-task-button {
+ height: 32px;
+ border: 1px solid rgba(55, 53, 47, 0.16);
+ border-radius: 4px;
+ padding: 0 11px;
+ background: #FFF;
+ color: #37352f;
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+ cursor: pointer;
+}
+
+.mnote-local-ocr-task-drawer {
+ position: absolute;
+ right: 0;
+ bottom: 40px;
+ width: min(360px, calc(100vw - 36px));
+ max-height: min(420px, calc(100vh - 120px));
+ overflow: auto;
+ border: 1px solid rgba(55, 53, 47, 0.14);
+ border-radius: 6px;
+ background: #FFF;
+ box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16);
+}
+
+.mnote-local-ocr-task-head {
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(55, 53, 47, 0.1);
+}
+
+.mnote-local-ocr-task-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(55, 53, 47, 0.08);
+}
+
+.mnote-local-ocr-task-main {
+ min-width: 0;
+}
+
+.mnote-local-ocr-task-main strong,
+.mnote-local-ocr-task-main span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mnote-local-ocr-task-main span,
+.mnote-local-ocr-task-empty {
+ color: #787774;
+}
+
+.mnote-local-ocr-task-empty {
+ padding: 12px;
+}
+
+.mnote-local-ocr-task-actions {
+ display: flex;
+ flex: 0 0 auto;
+ gap: 6px;
+}
+
+.mnote-local-ocr-task-actions button {
+ height: 26px;
+ border: 1px solid rgba(55, 53, 47, 0.14);
+ border-radius: 4px;
+ background: #FFF;
+ color: #37352f;
+ cursor: pointer;
+}
+
.mnote-resource-tab-frame,
.mnote-resource-tab-image,
.mnote-resource-tab-text-shell {
diff --git a/scripts/TESTING_REFERENCE.md b/scripts/TESTING_REFERENCE.md
index 84390b99..5f594ace 100644
--- a/scripts/TESTING_REFERENCE.md
+++ b/scripts/TESTING_REFERENCE.md
@@ -36,7 +36,7 @@ node scripts/task490-runtime-surfaces-smoke.js
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`
-- local-folder OCR:`task526-local-folder-ocr-api-smoke.js` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、sidecar 落盘、jobs/status/read、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;它不代表全局 OCR 任务栏 / 任务抽屉、active job store 或 `local_ocr.job.updated` 已完成。
+- local-folder OCR:`task526-local-folder-ocr-api-smoke.js` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、sidecar 落盘、jobs/status/read、全局 OCR 任务栏 / 任务抽屉、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;`TASK520_OCR_CONTEXT=1 node scripts/task520-page-ai-raw-resource-target-smoke.js` 覆盖 Page AI active image/PDF resource target 读取 OCR sidecar context。
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
diff --git a/scripts/task520-page-ai-raw-resource-target-smoke.js b/scripts/task520-page-ai-raw-resource-target-smoke.js
index 276b952b..ad2515c0 100644
--- a/scripts/task520-page-ai-raw-resource-target-smoke.js
+++ b/scripts/task520-page-ai-raw-resource-target-smoke.js
@@ -102,6 +102,17 @@ async function saveScreenshot(page, name) {
return target;
}
+function cleanupRoot(root) {
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
+ return;
+ } catch (error) {
+ if (attempt === 4) throw error;
+ }
+ }
+}
+
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const actorId = "mnote-e2e";
@@ -109,7 +120,9 @@ async function main() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task520-raw-target-"));
const rootUri = fileUrl(root);
const pagePath = "Page.md";
- const rawPath = "Page/notes.txt";
+ const expectOcrContext = process.env.TASK520_OCR_CONTEXT === "1";
+ const rawPath = expectOcrContext ? "Page/photo.png" : "Page/notes.txt";
+ const ocrPath = "Page.ocr/photo.png.ocr.md";
const documentId = localMdDocumentId(pagePath);
const captured = [];
let caughtError = null;
@@ -117,7 +130,41 @@ async function main() {
fs.mkdirSync(path.join(root, "Page"), { recursive: true });
writeWorkspaceManifest(root, actorId, workspaceId);
fs.writeFileSync(path.join(root, pagePath), "# Page\n\nTask520 page\n", "utf8");
- fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
+ if (expectOcrContext) {
+ fs.writeFileSync(path.join(root, rawPath), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]));
+ fs.mkdirSync(path.join(root, "Page.ocr"), { recursive: true });
+ fs.writeFileSync(
+ path.join(root, ocrPath),
+ "---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page/photo.png\nsource_root_relative_path: Page/photo.png\nsource_size: 6\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nTask520 OCR sidecar context text\n",
+ "utf8",
+ );
+ fs.writeFileSync(
+ path.join(root, ".mnote", "ocr-index.json"),
+ `${JSON.stringify({
+ version: 1,
+ entries: {
+ [rawPath]: {
+ jobId: "ocr_task520",
+ ownerDocumentId: documentId,
+ ownerDocumentPath: pagePath,
+ sourceRootRelativePath: rawPath,
+ ocrRootRelativePath: ocrPath,
+ provider: "mock",
+ modelVersion: "vlm",
+ status: "done",
+ sourceSize: 6,
+ sourceMtimeMs: 1,
+ createdAtMs: 1,
+ updatedAtMs: 1,
+ plainTextPreview: "Task520 OCR sidecar context text",
+ },
+ },
+ }, null, 2)}\n`,
+ "utf8",
+ );
+ } else {
+ fs.writeFileSync(path.join(root, rawPath), "Task520 raw resource target\n", "utf8");
+ }
const browser = await chromium.launch({
headless: process.env.HEADFUL !== "1",
@@ -243,7 +290,8 @@ async function main() {
const targetChip = page.locator("[data-page-ai-target-chip]");
await targetChip.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const chipText = (await targetChip.innerText({ timeout: UI_TIMEOUT_MS })).trim();
- assert(chipText.includes("notes.txt"), `raw resource 打开后 target chip 应指向 notes.txt: ${chipText}`);
+ const expectedName = expectOcrContext ? "photo.png" : "notes.txt";
+ assert(chipText.includes(expectedName), `raw resource 打开后 target chip 应指向 ${expectedName}: ${chipText}`);
await page.locator("[data-page-ai-input]").fill("Task520 raw target", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
@@ -268,19 +316,26 @@ async function main() {
assert.strictEqual(runBody.targetPackage?.currentFile?.relativePath, rawPath, `targetPackage currentFile 应指向 raw resource: ${JSON.stringify(runBody.targetPackage)}`);
assert.strictEqual(runBody.targetPackage?.currentFile?.objectIdentity, "resource:file:" + rootUri + ":" + rawPath, `currentFile objectIdentity 不应退化为 [object Object]: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
assert(runBody.targetPackage?.allowedFiles?.includes(rawPath), `allowedFiles 应只包含 raw resource: ${JSON.stringify(runBody.targetPackage?.allowedFiles)}`);
+ if (expectOcrContext) {
+ assert.strictEqual(activeEditorRef.ocrContext?.source, "local_ocr_sidecar", `active_editor 应携带 OCR sidecar context: ${JSON.stringify(activeEditorRef)}`);
+ assert.strictEqual(activeEditorRef.ocrContext?.ocrRootRelativePath, ocrPath, `active_editor OCR path 不正确: ${JSON.stringify(activeEditorRef.ocrContext)}`);
+ assert(activeEditorRef.ocrContext?.plainTextPreview?.includes("Task520 OCR sidecar context text"), `active_editor OCR preview 缺失: ${JSON.stringify(activeEditorRef.ocrContext)}`);
+ assert.strictEqual(runBody.targetPackage?.ocrContext?.ocrRootRelativePath, ocrPath, `targetPackage 应携带 OCR sidecar context: ${JSON.stringify(runBody.targetPackage)}`);
+ assert.strictEqual(runBody.targetPackage?.currentFile?.ocrRootRelativePath, ocrPath, `currentFile 应携带 OCR sidecar path: ${JSON.stringify(runBody.targetPackage?.currentFile)}`);
+ }
const screenshot = await saveScreenshot(page, "01-raw-resource-target");
- const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, screenshot, captured };
+ const result = { ok: true, task: TASK, baseUrl: BASE_URL, root, rootUri, documentId, rawPath, ocrPath: expectOcrContext ? ocrPath : "", screenshot, captured };
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} catch (error) {
caughtError = error;
await saveScreenshot(page, "failure").catch(() => undefined);
} finally {
- fs.rmSync(root, { recursive: true, force: true });
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
+ cleanupRoot(root);
}
if (caughtError) {
diff --git a/scripts/task526-local-folder-ocr-api-smoke.js b/scripts/task526-local-folder-ocr-api-smoke.js
index 3da15835..1f2b8474 100644
--- a/scripts/task526-local-folder-ocr-api-smoke.js
+++ b/scripts/task526-local-folder-ocr-api-smoke.js
@@ -134,6 +134,17 @@ async function main() {
await page.waitForFunction(() => document.querySelector('[data-mnote-local-ocr-status="done"]'), null, {
timeout: UI_TIMEOUT_MS,
});
+ await page.getByTestId("mnote-local-ocr-task-toggle").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ await page.getByTestId("mnote-local-ocr-task-toggle").click({ timeout: UI_TIMEOUT_MS });
+ await page.getByTestId("mnote-local-ocr-task-drawer").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ (sourcePath) => {
+ const row = document.querySelector(`[data-mnote-local-ocr-task-row="${CSS.escape(sourcePath)}"]`);
+ return row && row.getAttribute("data-mnote-local-ocr-task-status") === "done";
+ },
+ sourceRootRelativePath,
+ { timeout: UI_TIMEOUT_MS },
+ );
const uiOcrPath = await page.locator('[data-mnote-local-ocr-path]').first().getAttribute("data-mnote-local-ocr-path");
assert(uiOcrPath && uiOcrPath.endsWith(".ocr.md"), `UI OCR path invalid: ${uiOcrPath}`);
screenshots.ocrToolbar = path.join(OUTPUT_DIR, "02-ocr-toolbar.png");