diff --git a/bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md b/bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
index 54de1a19..9f2b5346 100644
--- a/bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
+++ b/bugs/03-rust-web/process/3-27-mineru-ocr-design-without-runtime-implementation-v1.md
@@ -8,35 +8,36 @@
## 现象
-P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真实 MinerU HTTP client、sidecar 写入、`.mnote/ocr-index.json`、`includeOcr=true` 搜索命中和浏览器 API smoke。但 active job store、realtime job event、前端入口/任务栏仍未落地。
+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 资源上下文仍未落地。
## 证据
- `rust/crates/mnote-web/src/routes/local_ocr.rs` 已注册 `jobs/status/read/insert`,`provider=mock` 能写入 `{pageStem}.ocr/*.ocr.md` 和 `.mnote/ocr-index.json`。
- `provider=mineru` 在有 token 时会走真实 HTTP client:申请上传 URL、PUT 上传、轮询结果、下载 zip、提取 Markdown,并写入 `{pageStem}.ocr/*.ocr.md` / `.mnote/ocr-index.json`。该路径已有本地 HTTP mock 单测覆盖,不访问外网。
-- `local_search_index` 已支持 `includeOcr=true` 命中 OCR owner page;`task526-local-folder-ocr-api-smoke.js` 已覆盖浏览器上下文调用 mock OCR API、搜索命中和显式 insert 链接。
+- `local_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` 和浏览器可见入口仍未完成。
## 影响
-- 用户无法从本地图片或图片型 PDF 通过 UI 手动触发 MinerU OCR。
-- 搜索的 `includeOcr=true` 已能命中 OCR sidecar,但真实 MinerU 结果仍缺 UI 触发链和任务状态可见性。
+- 用户已能从本地图片/PDF resource tab、附件菜单和 File Tree 资源菜单触发 OCR;但离开当前资源后的全局任务抽屉仍缺。
+- 搜索的 `includeOcr=true` 已能命中 OCR sidecar,`includeOcr=false` 不应命中普通 OCR sidecar;本轮补了增量索引过滤测试。
- AI 资源上下文无法优先读取已有 OCR Markdown。
## 下一步
1. 已完成后端 mock 最小闭环:路径规划、sidecar frontmatter、`.mnote/ocr-index.json`、mock OCR job route、status/read、Page Tree 过滤和 `includeOcr=true` 搜索 owner page。
2. 已补浏览器 API smoke:mock OCR、status/read/jobs、`includeOcr=true` 搜索 owner page 和显式 insert link。
-3. 下一步接长任务状态、`local_ocr.job.updated` realtime 事件、前端 OCR 入口/任务栏和 AI 资源上下文读取 OCR sidecar。
+3. 下一步接长任务状态、`local_ocr.job.updated` realtime 事件、全局 OCR 任务栏/任务抽屉和 AI 资源上下文读取 OCR sidecar。
## 验收
- [x] Rust route/helper 测试覆盖 sidecar 路径/frontmatter、index stale/error redaction、mock OCR job 写入、status/read、无 token、越界拒绝、非图片/PDF 拒绝、mock 失败 response 脱敏和显式 insert link。
-- [x] local search 测试覆盖 OCR 命中返回 owner page,OCR sidecar 不作为普通页面。
+- [x] local search 测试覆盖 OCR 命中返回 owner page,普通与 hash 后缀 OCR sidecar 不作为普通页面。
- [x] Page Tree 测试覆盖 OCR sidecar 可在 File Tree 作为资源文件可见,但不作为普通页面进入 Page Tree。
- [x] Rust route 测试覆盖无 token response 形态。
- [x] Rust route 测试覆盖 `provider=mineru` 后端成功路径:申请上传 URL、PUT 上传、轮询结果、下载 zip、提取 Markdown、写 sidecar/index。
-- [~] 浏览器 smoke 覆盖图片/PDF 手动 OCR、任务栏状态、打开 OCR、搜索 OCR 命中和插入 OCR 链接。(当前 `task526` 覆盖 mock OCR API、搜索命中、显式 insert link 和 OCR sidecar resource tab 打开;真实 UI 入口/任务栏待做)
+- [~] 浏览器 smoke 覆盖图片/PDF 手动 OCR、任务栏状态、打开 OCR、搜索 OCR 命中和插入 OCR 链接。(当前 `task526` 覆盖图片 resource tab 手动 OCR、工具条状态、mock OCR API、搜索命中、显式 insert link 和 OCR sidecar resource tab 打开;全局任务栏/任务抽屉待做)
## 2026-06-01 后端 mock 闭环记录
@@ -63,18 +64,18 @@ P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真
- mock 失败响应与 `.mnote/ocr-index.json` 失败条目统一脱敏为 `provider_error_redacted`
- 显式 `ocr/insert` link 模式把 `[OCR:photo.png](./Page.ocr/photo.png.ocr.md)` 追加进 owner Markdown;未调用 insert 时不改正文
-本文继续保持 `process`:前端 OCR 入口、任务栏和 active job / realtime event 尚未完成。
+本文继续保持 `process`:全局任务栏、active job store / realtime event 和 AI 资源上下文尚未完成。
2026-06-01 只读复核更新:
- Subagent 复核确认:route/mock/search 测试已落地,早期“没有实际 OCR route”的描述已经过时。
-- 本 bug 不归档的阻塞点更新为:真实 MinerU HTTP client、active job store / `local_ocr.job.updated`、前端入口/任务栏、AI 资源上下文读取 OCR sidecar 和完整 UI browser smoke。
+- 本 bug 不归档的阻塞点更新为:active job store / `local_ocr.job.updated`、全局任务栏/任务抽屉、AI 资源上下文读取 OCR sidecar 和完整 UI browser smoke。
2026-06-01 浏览器 API smoke 续补:
-- 新增 `scripts/task526-local-folder-ocr-api-smoke.js`,在真实浏览器登录态页面内通过 `fetch` 串起 `POST /api/local-folder/ocr/jobs`、`status`、`read`、`jobs`、`/api/search/documents includeOcr=true` 和 `POST /api/local-folder/ocr/insert`。
-- 已验证 `provider=mock` 会生成 `docs/Page.ocr/photo.png.ocr.md`,`includeOcr=false` 不命中 OCR 文本,`includeOcr=true` 返回 owner page 且带 `hasOcr/ocrEvidence`,显式 insert 才向 owner Markdown 追加 `[OCR:photo.png](...)`。
-- 续补后已验证 OCR sidecar 可作为 Markdown resource tab 打开,截图写入 `tmp/task526-local-folder-ocr-api-smoke/02-ocr-resource-tab.png`。
+- 新增 `scripts/task526-local-folder-ocr-api-smoke.js`,在真实浏览器登录态页面内通过 UI 和 `fetch` 串起图片 resource tab OCR 工具条、`POST /api/local-folder/ocr/jobs`、`status`、`read`、`jobs`、`/api/search/documents includeOcr=true` 和 `POST /api/local-folder/ocr/insert`。
+- 已验证 `provider=mock` 会生成 `docs/Page.ocr/photo.png.ocr.md`,resource tab 工具条显示 OCR 完成并可打开/插入;`includeOcr=false` 不命中 OCR 文本,`includeOcr=true` 返回 owner page 且带 `hasOcr/ocrEvidence`,显式 insert 才向 owner Markdown 追加 `[OCR:photo.png](...)`。
+- 续补后已验证 OCR sidecar 可作为 Markdown resource tab 打开,截图写入 `tmp/task526-local-folder-ocr-api-smoke/03-ocr-resource-tab.png`;OCR 工具条截图写入 `tmp/task526-local-folder-ocr-api-smoke/02-ocr-toolbar.png`。
- 已通过:
- `node --check scripts/task526-local-folder-ocr-api-smoke.js`
- `MNOTE_UI_BASE_URL=http://127.0.0.1:3301 MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3301 node scripts/task526-local-folder-ocr-api-smoke.js`
@@ -97,7 +98,6 @@ P2 目标中包含 MinerU OCR 能力收口。当前已补后端 mock route、真
本文继续保持 `process` 的剩余原因:
-- 前端本地文件 / 图片 / PDF 资源没有 OCR 操作入口。
- OCR jobs 仍是同步请求 + `.mnote/ocr-index.json` 历史状态,没有 active job store。
- 缺少 `local_ocr.job.updated` realtime event 与全局任务栏 UI。
- AI 资源上下文还没有优先读取 OCR sidecar。
diff --git a/bugs/07-ai/process/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md b/bugs/07-ai/done/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md
similarity index 80%
rename from bugs/07-ai/process/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md
rename to bugs/07-ai/done/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md
index 0848fe3b..26270364 100644
--- a/bugs/07-ai/process/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md
+++ b/bugs/07-ai/done/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md
@@ -2,7 +2,7 @@
## 状态
-- 状态:process
+- 状态:done
- Owner:07-ai / OpenClaw provider integration
- 发现时间:2026-06-01
@@ -96,3 +96,30 @@ Reasonix 只读复核(`reasonix-2026-06-01T00-32-52-539Z-c41a37a3`)结论与
- Gemini 当前只有 URL normalize、DOM 打开/复用对话和 `conversation_id: page.url()`,没有 provider 级远端删除 helper。
因此 MNote 侧 `7-44` / `task512` / `task513` 只能证明 MNote provider conversation binding,不足以替代外部 provider 仓库的 DeepSeek / Gemini 删除单测。
+
+## 2026-06-01 DeepSeek / Gemini 降级语义补齐
+
+本文已可归档到 `done`:外部 OpenClaw provider 仓库补齐了 DeepSeek / Gemini provider 级删除语义和单测,但没有伪造远端删除成功。
+
+外部仓库 `/home/lix/openclaw-zero-token-latest` 当前新增/更新:
+
+- `src/zero-token/providers/deepseek-web-client.ts`
+ - 新增 `deleteDeepSeekConversationUnsupported()`。
+ - `DeepSeekWebClient.deleteConversation()` 返回 `remote_delete_failed`、`providerDeleteMode=unsupported`、`attempted=false`。
+- `src/zero-token/providers/gemini-web-client-browser.ts`
+ - 新增 `deleteGeminiConversationUnsupported()`。
+ - `GeminiWebClientBrowser.deleteConversation()` 返回 `remote_delete_failed`、`providerDeleteMode=unsupported`、`attempted=false`。
+- `src/zero-token/providers/web-session-binding.test.ts`
+ - 覆盖 DeepSeek / Gemini 在缺少已验证远端删除 API 时不能返回 `remote_deleted`。
+
+已通过外部仓库验证:
+
+- `pnpm exec vitest run --config scripts/vitest.zero-token-web.config.ts src/zero-token/providers/web-session-binding.test.ts --reporter=dot`:1 file / 8 tests passed。
+- `pnpm exec vitest run --config scripts/vitest.zero-token-web.config.ts --reporter=dot`:3 files / 16 tests passed。
+- `git diff --check`
+
+归档口径:
+
+- Doubao 已有真实 provider 远端删除 helper 和成功/失败单测。
+- DeepSeek / Gemini 没有可靠远端删除 API 时,provider 级 `deleteConversation` 明确返回 `remote_delete_failed`,不会让 MNote 或 OpenClaw 上层误标 `remote_deleted`。
+- 后续若要实现 DeepSeek / Gemini 真实远端删除,需要先抓取和验证真实 provider API,再新增 capability,而不是重新打开本文的“单测缺口”。
diff --git a/design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md b/design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md
index 1c915ab8..860e3e86 100644
--- a/design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md
+++ b/design/03-rust-web/process/3-25-local-folder-mineru-ocr-sidecar-v1.md
@@ -4,7 +4,7 @@
> 状态:`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 收口误归档。
+> 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 收口误归档。
## 背景
@@ -353,15 +353,15 @@ stale
- [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、失败和重新识别动作。
+- [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 已完成;前端入口和 inlineSection 待做)
+- [~] UI/API:增加“插入 OCR 链接到正文”和可选“插入 OCR 正文段落”动作。(后端 link API 与 resource tab 前端入口已完成;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 待做)
+- [~] Smoke:浏览器覆盖图片/PDF 手动 OCR、全局任务栏状态、打开 OCR resource tab、搜索 OCR 命中、插入 OCR 链接。(当前 `task526` 覆盖图片 resource tab 手动 OCR、工具条状态、打开 OCR、插入链接、mock OCR API 和搜索命中;全局任务栏/任务抽屉待做)
- [x] 文档:补充 `scripts/TESTING_REFERENCE.md` 中 OCR smoke 基线。
## 验收标准
diff --git a/design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md b/design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md
index da8ac46a..1dae3e84 100644
--- a/design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md
+++ b/design/07-ai/done/7-44-chatonly-doubao-session-binding-v1.md
@@ -289,4 +289,4 @@ rust/crates/mnote-web/src/provider_conversations.rs
- [x] 补真实浏览器 smoke:`node scripts/task512-chatonly-doubao-sync-smoke.js` 覆盖远端 conversation id 绑定、豆包同会话 marker、session 删除、provider delete 和 SQLite binding `remote_deleted`。
- [x] 补跨 provider 真实浏览器 smoke:`node scripts/task513-chatonly-provider-sync-smoke.js deepseek` 与 `node scripts/task513-chatonly-provider-sync-smoke.js gemini`。
- [x] 补 MNote Rust 单测:control-plane binding、mnote-web provider conversation 注入、bound event 持久化和 session delete provider 状态。
-- [x] OpenClaw provider 源码不在本仓库,provider 单测缺口已转入 `bugs/07-ai/process/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md` 跟踪,不阻塞 MNote 侧设计归档。
+- [x] OpenClaw provider 源码不在本仓库,provider 单测缺口已归档到 `bugs/07-ai/done/7-49-chatonly-openclaw-provider-unit-test-gap-v1.md`;Doubao 有真实远端删除 helper,DeepSeek / Gemini 在缺少已验证远端删除 API 时明确返回 `remote_delete_failed`,不阻塞 MNote 侧设计归档。
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 27643b49..518a8df1 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
@@ -75,7 +75,7 @@ P2:
- [~] `3-25` MinerU OCR:后端真实 MinerU runtime 已补;任务状态事件、全局任务栏和 UI 入口仍待做。
- [x] `7-43` OnlyOffice Plugin Bridge:P0/P1 安全主路径与 P2 recipe 矩阵已收口,后续新增 recipe 按矩阵逐项验证。
- [x] `7-44` ChatOnly Doubao binding:MNote 侧 binding 与 Doubao provider 单测已完成。
-- [ ] `7-49` ChatOnly OpenClaw provider:DeepSeek / Gemini provider 级 delete helper 与单测仍在外部仓库阻塞,不能在 MNote 内归档。
+- [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`。
本轮已将 `task503`、`task504`、`task512`、`task513`、`task514`、`task515`、`task516` 补入 `scripts/TESTING_REFERENCE.md`;对应 smoke 已实跑,运行结果由 `bugs/10-review/done/10-20-*` 记录。
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 751360f3..c271892c 100644
--- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
+++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js
@@ -985,6 +985,173 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
};
+ const isLocalOcrSourceEntry = (entry) => {
+ if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
+ if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
+ return entry.kind === 'image' || entry.kind === 'pdf';
+ };
+
+ const localOcrProvider = () => {
+ const override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
+ return override === 'mock' ? 'mock' : 'mineru';
+ };
+
+ const setLocalOcrStatus = (entry, status, message, job) => {
+ if (!(entry?.panel instanceof HTMLElement)) return;
+ const normalizedStatus = String(status || '').trim() || 'unknown';
+ entry.localOcrJob = job && typeof job === 'object' ? job : entry.localOcrJob || null;
+ entry.panel.setAttribute('data-mnote-local-ocr-status', normalizedStatus);
+ if (entry.localOcrJob?.ocrRootRelativePath) {
+ entry.panel.setAttribute('data-mnote-local-ocr-path', String(entry.localOcrJob.ocrRootRelativePath));
+ }
+ const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]');
+ if (statusNode instanceof HTMLElement) {
+ statusNode.textContent = message || (
+ normalizedStatus === 'done' ? 'OCR 已完成'
+ : normalizedStatus === 'running' ? 'OCR 处理中'
+ : normalizedStatus === 'failed' ? 'OCR 失败'
+ : normalizedStatus === 'stale' ? 'OCR 需更新'
+ : 'OCR 未生成'
+ );
+ }
+ const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]');
+ if (openButton instanceof HTMLButtonElement) openButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
+ const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]');
+ if (insertButton instanceof HTMLButtonElement) insertButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
+ window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
+ detail: {
+ status: normalizedStatus,
+ job: entry.localOcrJob || null,
+ rootUri: entry.rootUri || '',
+ sourceRootRelativePath: entry.path || '',
+ },
+ }));
+ };
+
+ const readLocalOcrStatus = async (entry) => {
+ if (!isLocalOcrSourceEntry(entry)) return null;
+ const url = new URL('/api/local-folder/ocr/status', window.location.origin);
+ url.searchParams.set('rootUri', entry.rootUri);
+ url.searchParams.set('sourceRootRelativePath', entry.path);
+ 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 null;
+ return payload.job || null;
+ };
+
+ const openLocalOcrSidecar = async (entry, job) => {
+ const target = job || entry?.localOcrJob || null;
+ const ocrPath = String(target?.ocrRootRelativePath || '').trim();
+ if (!ocrPath || typeof openResourceInActiveTab !== 'function') return false;
+ const title = ocrPath.split('/').filter(Boolean).pop() || 'OCR';
+ return await openResourceInActiveTab({
+ kind: 'markdown',
+ title,
+ path: ocrPath,
+ objectIdentity: `local-ocr:${ocrPath}`,
+ assetId: `local-ocr:${ocrPath}`,
+ documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
+ ownerDocumentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
+ workspaceId: String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(),
+ sourceKind: 'local_folder',
+ rootUri: String(entry.rootUri || '').trim(),
+ resourceKind: 'markdown',
+ paneRole: normalizePaneRole(entry.paneRole),
+ });
+ };
+
+ const insertLocalOcrLink = async (entry, job) => {
+ const target = job || entry?.localOcrJob || null;
+ const ocrPath = String(target?.ocrRootRelativePath || '').trim();
+ if (!ocrPath) return false;
+ const response = await fetch('/api/local-folder/ocr/insert', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify({
+ rootUri: String(entry.rootUri || '').trim(),
+ documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
+ ocrRootRelativePath: ocrPath,
+ mode: 'link',
+ }),
+ });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok || !payload || payload.ok !== true) {
+ throw new Error(payload?.error?.message || `local_ocr_insert_failed_${response.status}`);
+ }
+ setLocalOcrStatus(entry, 'done', 'OCR 链接已插入正文', target);
+ return true;
+ };
+
+ const createLocalOcrJob = async (entry) => {
+ if (!isLocalOcrSourceEntry(entry)) return null;
+ setLocalOcrStatus(entry, 'running', 'OCR 处理中', entry.localOcrJob || null);
+ const provider = localOcrProvider();
+ const body = {
+ rootUri: String(entry.rootUri || '').trim(),
+ documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
+ sourceRootRelativePath: String(entry.path || '').trim(),
+ provider,
+ };
+ if (provider === 'mock') {
+ body.mockMarkdown = `# OCR Result\n\n${entry.title || entry.path} OCR UI smoke text`;
+ }
+ const response = await fetch('/api/local-folder/ocr/jobs', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const payload = await response.json().catch(() => null);
+ if (!response.ok || !payload || payload.ok !== true) {
+ const message = payload?.error?.message || `local_ocr_job_failed_${response.status}`;
+ setLocalOcrStatus(entry, 'failed', message, entry.localOcrJob || null);
+ throw new Error(message);
+ }
+ const job = payload.job || null;
+ setLocalOcrStatus(entry, String(job?.status || 'done'), job?.stale ? 'OCR 需更新' : 'OCR 已完成', job);
+ return job;
+ };
+
+ const renderLocalOcrToolbar = (entry) => {
+ if (!isLocalOcrSourceEntry(entry) || !(entry.panel instanceof HTMLElement)) return;
+ const toolbar = entry.panel.querySelector('[data-testid="mnote-local-ocr-toolbar"]');
+ if (!(toolbar instanceof HTMLElement)) return;
+ const runButton = toolbar.querySelector('[data-mnote-local-ocr-action="run"]');
+ const openButton = toolbar.querySelector('[data-mnote-local-ocr-action="open"]');
+ const insertButton = toolbar.querySelector('[data-mnote-local-ocr-action="insert"]');
+ if (runButton instanceof HTMLButtonElement) {
+ runButton.addEventListener('click', async () => {
+ runButton.disabled = true;
+ try {
+ await createLocalOcrJob(entry);
+ } catch (error) {
+ console.warn('mnote local OCR 生成失败', error);
+ } finally {
+ runButton.disabled = false;
+ }
+ });
+ }
+ if (openButton instanceof HTMLButtonElement) {
+ openButton.addEventListener('click', () => {
+ void openLocalOcrSidecar(entry, entry.localOcrJob).catch((error) => {
+ console.warn('mnote local OCR 打开失败', error);
+ });
+ });
+ }
+ if (insertButton instanceof HTMLButtonElement) {
+ insertButton.addEventListener('click', () => {
+ void insertLocalOcrLink(entry, entry.localOcrJob).catch((error) => {
+ console.warn('mnote local OCR 插入失败', error);
+ setLocalOcrStatus(entry, 'failed', error instanceof Error ? error.message : String(error), entry.localOcrJob || null);
+ });
+ });
+ }
+ setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
+ void readLocalOcrStatus(entry).then((job) => {
+ if (!job) return;
+ setLocalOcrStatus(entry, job.stale ? 'stale' : String(job.status || 'done'), job.stale ? 'OCR 需更新' : 'OCR 已完成', job);
+ }).catch(() => undefined);
+ };
+
const createResourceSession = (entry, input, readResult) => {
const resourcePath = String(input.path || '');
const tiptapDocument = localizeTiptapAssetUrls(
@@ -1112,16 +1279,21 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const openPassiveResourceTab = (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
if (entry.kind === 'image') {
- entry.panel.innerHTML = '
';
+ entry.panel.innerHTML = isLocalOcrSourceEntry(entry)
+ ? '
OCR 未生成
![]()
'
+ : '
';
const img = entry.panel.querySelector('img');
if (img instanceof HTMLImageElement) {
img.src = href;
img.alt = entry.title;
}
+ renderLocalOcrToolbar(entry);
installPassiveResourceWatch(entry);
return;
}
- entry.panel.innerHTML = '';
+ entry.panel.innerHTML = isLocalOcrSourceEntry(entry)
+ ? ''
+ : '';
const frame = entry.panel.querySelector('iframe');
if (frame instanceof HTMLIFrameElement) {
frame.title = entry.title;
@@ -1133,6 +1305,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
frame.src = href;
}
+ renderLocalOcrToolbar(entry);
installPassiveResourceWatch(entry);
};
diff --git a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js
index 68630df7..5a34b724 100644
--- a/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js
+++ b/rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js
@@ -23,6 +23,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
openEditorAttachmentDownload,
openEditorAttachmentEditTab,
openEditorAttachmentNewWindow,
+ openLocalResourceInActiveTab,
refreshLocalFolderSidebarSnapshot,
removeFileTreeAssetRow,
revealFileTreeResource,
@@ -379,9 +380,114 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
callback();
}
+ function isLocalOcrSourceFileName(fileName) {
+ return /\.(png|jpe?g|webp|gif|bmp|tiff?|pdf)$/i.test(String(fileName || '').trim());
+ }
+
+ function localOcrSourceRelativePath(detail) {
+ var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
+ var relativePath = String(
+ detail && detail.localRelativePath
+ || workspacePath && (workspacePath.relativePath || workspacePath.localRelativePath)
+ || ''
+ ).trim();
+ if (!relativePath && detail && detail.assetId) relativePath = localFilePathFromAssetId(detail.assetId);
+ return relativePath.replace(/^\/+/, '');
+ }
+
+ function localOcrRootUri(detail, trigger) {
+ var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
+ var rootUri = String(
+ detail && (detail.localRootUri || detail.rootUri)
+ || workspacePath && workspacePath.rootUri
+ || ''
+ ).trim();
+ if (!rootUri && trigger && typeof trigger.closest === 'function') {
+ var row = trigger.closest('.tree-row[data-shell-mode="filetree"]');
+ if (row instanceof HTMLElement) rootUri = String(row.getAttribute('data-root-uri') || '').trim();
+ }
+ return rootUri || currentRootUri() || '';
+ }
+
+ function supportsLocalOcr(detail) {
+ var path = localOcrSourceRelativePath(detail);
+ var title = String(detail && (detail.title || detail.fileName) || '').trim() || path.split('/').pop() || '';
+ return Boolean(path && localOcrRootUri(detail, null) && isLocalOcrSourceFileName(title || path));
+ }
+
+ function localOcrProvider() {
+ var override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
+ return override === 'mock' ? 'mock' : 'mineru';
+ }
+
+ async function runLocalOcrForDetail(detail, trigger) {
+ var sourceRootRelativePath = localOcrSourceRelativePath(detail);
+ var rootUri = localOcrRootUri(detail, trigger);
+ var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
+ if (!sourceRootRelativePath || !rootUri || !documentId) {
+ throw new Error('缺少 OCR 来源、rootUri 或 owner documentId');
+ }
+ if (!isLocalOcrSourceFileName(String(detail && (detail.title || detail.fileName) || sourceRootRelativePath))) {
+ throw new Error('OCR 仅支持图片和 PDF');
+ }
+ var provider = localOcrProvider();
+ var body = {
+ rootUri: rootUri,
+ documentId: documentId,
+ sourceRootRelativePath: sourceRootRelativePath,
+ provider: provider
+ };
+ if (provider === 'mock') {
+ body.mockMarkdown = '# OCR Result\n\n' + (detail.title || sourceRootRelativePath) + ' OCR menu smoke text';
+ }
+ document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running');
+ var response = await fetch('/api/local-folder/ocr/jobs', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
+ body: JSON.stringify(body)
+ });
+ var payload = await response.json().catch(function() { return null; });
+ if (!response.ok || !payload || payload.ok !== true) {
+ var message = payload && payload.error && payload.error.message ? payload.error.message : 'OCR 生成失败';
+ document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
+ throw new Error(message);
+ }
+ var job = payload.job || {};
+ document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', String(job.status || 'done'));
+ if (job.ocrRootRelativePath) {
+ document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', String(job.ocrRootRelativePath));
+ if (typeof openLocalResourceInActiveTab === 'function') {
+ await openLocalResourceInActiveTab({
+ path: String(job.ocrRootRelativePath),
+ title: String(job.ocrRootRelativePath).split('/').filter(Boolean).pop() || 'OCR',
+ kind: 'markdown',
+ assetId: 'local-ocr:' + String(job.ocrRootRelativePath),
+ documentId: documentId,
+ workspaceId: String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
+ rootUri: rootUri
+ });
+ }
+ }
+ window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
+ detail: { status: String(job.status || 'done'), job: job, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
+ }));
+ return job;
+ }
+
function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
+ if (action === 'local-ocr') {
+ recordFileTreeAction('local-ocr', detail);
+ recordFileTreeActionStatus('pending', detail);
+ void runLocalOcrForDetail(detail, trigger).then(function(job) {
+ recordFileTreeActionStatus(String(job && job.status || 'done'), detail);
+ }).catch(function(error) {
+ recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
+ window.alert(error && error.message ? error.message : 'OCR 生成失败');
+ });
+ return;
+ }
if (detail.contextKind === 'attachment') {
if (action === 'copy-link') {
void copyTreeContextValue(detail.href || '', 'attachment-copy-link');
@@ -841,6 +947,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
menu.setAttribute('data-command-context-target-kind', String(ctx['tree.targetResourceKind'] || ''));
menu.setAttribute('data-command-context-editor-dirty', String(ctx['editor.dirty'] === true));
menu.setAttribute('data-command-context-ai-can-write', String(ctx['ai.canWrite'] === true));
+ var localOcrSupported = supportsLocalOcr(detail);
var items = isAttachment ? [
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly' },
@@ -903,6 +1010,11 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
];
+ if (localOcrSupported) {
+ var ocrItem = { action: 'local-ocr', icon: 'auto_awesome', label: '生成 OCR', when: '!workspace.readonly' };
+ var insertAt = isAttachment ? 11 : isAsset ? 3 : -1;
+ if (insertAt >= 0) items.splice(insertAt, 0, ocrItem);
+ }
items.forEach(function(item) {
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
var reason = ctx['workspace.readonly'] === true
diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js
index 97afca92..5c4e81fb 100644
--- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js
+++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js
@@ -2019,6 +2019,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
openEditorAttachmentDownload: (...args) => openEditorAttachmentDownload(...args),
openEditorAttachmentEditTab: (...args) => openEditorAttachmentEditTab(...args),
openEditorAttachmentNewWindow: (...args) => openEditorAttachmentNewWindow(...args),
+ openLocalResourceInActiveTab: (...args) => openLocalResourceInActiveTab(...args),
refreshLocalFolderSidebarSnapshot,
removeFileTreeAssetRow,
revealFileTreeResource,
diff --git a/rust/crates/mnote-web/src/routes/local_ocr.rs b/rust/crates/mnote-web/src/routes/local_ocr.rs
index dca480bc..b28752de 100644
--- a/rust/crates/mnote-web/src/routes/local_ocr.rs
+++ b/rust/crates/mnote-web/src/routes/local_ocr.rs
@@ -199,8 +199,13 @@ pub(crate) async fn create_job(
match run_mineru_ocr(&root, &plan, token.unwrap_or_default()).await {
Ok(markdown) => markdown,
Err(error) => {
- let failed_entry =
- build_index_entry(&plan, "failed", now, "", Some(redact_error(error.message())));
+ let failed_entry = build_index_entry(
+ &plan,
+ "failed",
+ now,
+ "",
+ Some(redact_error(error.message())),
+ );
upsert_ocr_index_entry(&root, failed_entry)?;
return Err(error.with_context(&context));
}
@@ -457,7 +462,11 @@ pub(crate) fn strip_ocr_frontmatter(markdown: &str) -> &str {
trimmed[end + 4..].trim_start_matches('\n')
}
-async fn run_mineru_ocr(root: &Path, plan: &OcrSidecarPlan, token: String) -> Result {
+async fn run_mineru_ocr(
+ root: &Path,
+ plan: &OcrSidecarPlan,
+ token: String,
+) -> Result {
let config = MineruClientConfig {
api_base_url: mineru_api_base_url(),
token,
@@ -515,7 +524,10 @@ async fn create_mineru_upload_task(
.map_err(|error| {
WebError::bad_gateway_code(
"mineru_upload_task_failed",
- format!("MinerU 上传任务创建失败: {}", redact_error(&error.to_string())),
+ format!(
+ "MinerU 上传任务创建失败: {}",
+ redact_error(&error.to_string())
+ ),
)
})?;
let status = response.status();
@@ -527,8 +539,8 @@ async fn create_mineru_upload_task(
));
}
let value = parse_mineru_json(&body, "mineru_upload_task_json_invalid")?;
- let batch_id = find_json_string_by_keys(&value, &["batch_id", "batchId", "id"])
- .ok_or_else(|| {
+ let batch_id =
+ find_json_string_by_keys(&value, &["batch_id", "batchId", "id"]).ok_or_else(|| {
WebError::bad_gateway_code(
"mineru_upload_task_batch_missing",
"MinerU 上传任务响应缺少 batch_id",
@@ -559,7 +571,10 @@ async fn upload_mineru_source(
.map_err(|error| {
WebError::bad_gateway_code(
"mineru_source_upload_failed",
- format!("MinerU 源文件上传失败: {}", redact_error(&error.to_string())),
+ format!(
+ "MinerU 源文件上传失败: {}",
+ redact_error(&error.to_string())
+ ),
)
})?;
if !response.status().is_success() {
@@ -610,7 +625,14 @@ async fn poll_mineru_result_zip_url(
}
if let Some(zip_url) = find_json_string_by_keys(
&value,
- &["full_zip_url", "fullZipUrl", "zip_url", "zipUrl", "result_url", "resultUrl"],
+ &[
+ "full_zip_url",
+ "fullZipUrl",
+ "zip_url",
+ "zipUrl",
+ "result_url",
+ "resultUrl",
+ ],
) {
return Ok(zip_url);
}
@@ -629,7 +651,10 @@ async fn download_mineru_result_zip(
let response = client.get(zip_url).send().await.map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_download_failed",
- format!("MinerU 结果包下载失败: {}", redact_error(&error.to_string())),
+ format!(
+ "MinerU 结果包下载失败: {}",
+ redact_error(&error.to_string())
+ ),
)
})?;
if !response.status().is_success() {
@@ -638,12 +663,19 @@ async fn download_mineru_result_zip(
format!("MinerU 结果包下载失败: HTTP {}", response.status().as_u16()),
));
}
- response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|error| {
- WebError::bad_gateway_code(
- "mineru_result_download_failed",
- format!("MinerU 结果包读取失败: {}", redact_error(&error.to_string())),
- )
- })
+ response
+ .bytes()
+ .await
+ .map(|bytes| bytes.to_vec())
+ .map_err(|error| {
+ WebError::bad_gateway_code(
+ "mineru_result_download_failed",
+ format!(
+ "MinerU 结果包读取失败: {}",
+ redact_error(&error.to_string())
+ ),
+ )
+ })
}
fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result {
@@ -678,7 +710,8 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result {
candidates
.into_iter()
.max_by_key(|(name, markdown)| {
- let preferred = name.ends_with("/full.md") || name == "full.md" || name.ends_with("/result.md");
+ let preferred =
+ name.ends_with("/full.md") || name == "full.md" || name.ends_with("/result.md");
(preferred, markdown.len())
})
.map(|(_, markdown)| markdown)
@@ -1307,9 +1340,7 @@ mod tests {
writer
.start_file("full.md", zip::write::SimpleFileOptions::default())
.expect("zip start file");
- writer
- .write_all(markdown.as_bytes())
- .expect("zip markdown");
+ writer.write_all(markdown.as_bytes()).expect("zip markdown");
writer.finish().expect("zip finish");
}
bytes.into_inner()
@@ -1570,7 +1601,9 @@ mod tests {
}),
);
let mock_handle = tokio::spawn(async move {
- axum::serve(listener, mock_mineru).await.expect("mock mineru server");
+ axum::serve(listener, mock_mineru)
+ .await
+ .expect("mock mineru server");
});
let old_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
@@ -1643,12 +1676,9 @@ mod tests {
assert_eq!(payload["job"]["provider"].as_str(), Some("mineru"));
assert_eq!(upload_count.load(Ordering::SeqCst), 1);
assert_eq!(poll_count.load(Ordering::SeqCst), 1);
- let sidecar = fs::read_to_string(
- root.join("docs")
- .join("Page.ocr")
- .join("photo.png.ocr.md"),
- )
- .expect("sidecar");
+ let sidecar =
+ fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
+ .expect("sidecar");
assert!(sidecar.contains("provider: mineru"));
assert!(sidecar.contains("识别文本"));
diff --git a/rust/crates/mnote-web/src/routes/local_search_index.rs b/rust/crates/mnote-web/src/routes/local_search_index.rs
index 29be5f97..43d7edbb 100644
--- a/rust/crates/mnote-web/src/routes/local_search_index.rs
+++ b/rust/crates/mnote-web/src/routes/local_search_index.rs
@@ -185,6 +185,18 @@ pub(crate) fn refresh_local_search_index_for_path(
index
.resources
.retain(|resource| resource.path != relative_path);
+ if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
+ index.built_at = now_ms();
+ write_local_search_index(root_path, &index)?;
+ return Ok(json!({
+ "version": index.version,
+ "rootUri": index.root_uri,
+ "workspaceId": index.workspace_id,
+ "builtAt": index.built_at,
+ "documentCount": index.documents.len(),
+ "resourceCount": index.resources.len()
+ }));
+ }
let absolute_path = root_path.join(&relative_path);
if absolute_path.exists() && absolute_path.is_file() && is_markdown_path(&absolute_path) {
index
@@ -1168,6 +1180,11 @@ mod tests {
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token 识别正文\n",
)
.expect("ocr markdown");
+ fs::write(
+ root.join("docs").join("Page.ocr").join("photo.png-704905.ocr.md"),
+ "---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 2\nstatus: done\ncreated_at: 2\nupdated_at: 2\n---\n\nOCR-hash-token 识别正文\n",
+ )
+ .expect("hashed ocr markdown");
fs::create_dir_all(root.join(".mnote")).expect("mnote dir");
fs::write(
root.join(".mnote").join("ocr-index.json"),
@@ -1208,6 +1225,22 @@ mod tests {
)
.expect("without ocr");
assert_eq!(without_ocr["results"].as_array().map(Vec::len), Some(0));
+ let without_hashed_ocr = query_local_search_index(
+ &root,
+ &root_uri,
+ workspace_id,
+ "OCR-hash-token",
+ None,
+ 10,
+ false,
+ false,
+ false,
+ )
+ .expect("without hashed ocr");
+ assert_eq!(
+ without_hashed_ocr["results"].as_array().map(Vec::len),
+ Some(0)
+ );
let with_ocr = query_local_search_index(
&root,
@@ -1245,6 +1278,24 @@ mod tests {
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
+ assert!(!index
+ .documents
+ .iter()
+ .any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
+ refresh_local_search_index_for_path(
+ &root,
+ &root_uri,
+ workspace_id,
+ "docs/Page.ocr/photo.png-704905.ocr.md",
+ )
+ .expect("refresh hashed ocr sidecar");
+ let refreshed_index = read_local_search_index(&root)
+ .expect("read refreshed search index")
+ .expect("refreshed search index");
+ assert!(!refreshed_index
+ .documents
+ .iter()
+ .any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
let _ = fs::remove_dir_all(&root);
}
diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs
index cc3d3d74..1b2f3f91 100644
--- a/rust/crates/mnote-web/src/ssr/styles.rs
+++ b/rust/crates/mnote-web/src/ssr/styles.rs
@@ -2729,6 +2729,55 @@ body {
min-width: 0;
}
+.mnote-resource-tab-passive-shell {
+ min-height: calc(100vh - 80px);
+ background: #FFF;
+}
+
+.mnote-local-ocr-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 40px;
+ padding: 6px 12px;
+ border-bottom: 1px solid rgba(55, 53, 47, 0.12);
+ background: #FFF;
+ color: #37352f;
+ font-size: 13px;
+ box-sizing: border-box;
+}
+
+.mnote-local-ocr-status {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #787774;
+}
+
+.mnote-local-ocr-toolbar button {
+ flex: 0 0 auto;
+ height: 28px;
+ border: 1px solid rgba(55, 53, 47, 0.16);
+ border-radius: 4px;
+ padding: 0 9px;
+ background: #FFF;
+ color: #37352f;
+ font: inherit;
+ cursor: pointer;
+}
+
+.mnote-local-ocr-toolbar button:hover:not(:disabled) {
+ background: rgba(55, 53, 47, 0.06);
+}
+
+.mnote-local-ocr-toolbar button:disabled {
+ cursor: default;
+ color: #a8a29e;
+ background: #f7f6f3;
+}
+
.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 8ad29839..84390b99 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` 覆盖 mock OCR API、sidecar 落盘、jobs/status/read、`includeOcr=true` 搜索 owner page、显式插入 OCR 链接和 OCR sidecar Markdown resource tab 打开;它不代表真实 MinerU token/upload/zip runtime、前端 OCR 入口或全局 OCR 任务栏已完成。
+- 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 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/task526-local-folder-ocr-api-smoke.js b/scripts/task526-local-folder-ocr-api-smoke.js
index c86d2d7e..3da15835 100644
--- a/scripts/task526-local-folder-ocr-api-smoke.js
+++ b/scripts/task526-local-folder-ocr-api-smoke.js
@@ -96,6 +96,59 @@ async function main() {
screenshots.page = path.join(OUTPUT_DIR, "01-page.png");
await page.screenshot({ path: screenshots.page, fullPage: true });
+ await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
+ timeout: UI_TIMEOUT_MS,
+ });
+ await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId }) => {
+ window.__MNOTE_LOCAL_OCR_PROVIDER = "mock";
+ const title = sourceRootRelativePath.split("/").filter(Boolean).pop() || "photo.png";
+ const fileUrl = new URL("/api/local-folder/files/open", window.location.origin);
+ fileUrl.searchParams.set("rootUri", rootUri);
+ fileUrl.searchParams.set("path", sourceRootRelativePath);
+ const opened = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
+ objectIdentity: `local-file:${sourceRootRelativePath}`,
+ assetId: `local-file:${sourceRootRelativePath}`,
+ title,
+ fileName: title,
+ kind: "image",
+ rootUri,
+ path: sourceRootRelativePath,
+ href: fileUrl.toString(),
+ documentId,
+ ownerDocumentId: documentId,
+ workspaceId,
+ sourceKind: "local_folder",
+ });
+ if (!opened) throw new Error("OCR source image resource tab did not open");
+ }, {
+ rootUri: fileUrl(root),
+ documentId,
+ sourceRootRelativePath,
+ workspaceId,
+ });
+ await page.locator('[data-testid="mnote-local-ocr-toolbar"]').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ await page.getByTestId("mnote-local-ocr-run").click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(() => document.querySelector('[data-mnote-local-ocr-status="done"]'), null, {
+ 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");
+ await page.screenshot({ path: screenshots.ocrToolbar, fullPage: true });
+ await page.getByTestId("mnote-local-ocr-insert").click({ timeout: UI_TIMEOUT_MS });
+ await page.getByTestId("mnote-local-ocr-status").filter({ hasText: "OCR 链接已插入正文" }).waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+ await page.getByTestId("mnote-local-ocr-open").click({ timeout: UI_TIMEOUT_MS });
+ await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
+ state: "visible",
+ timeout: UI_TIMEOUT_MS,
+ });
+
const result = await page.evaluate(async ({ rootUri, documentId, sourceRootRelativePath, workspaceId, ocrToken }) => {
async function jsonFetch(pathname, init = {}) {
const response = await fetch(pathname, {
@@ -187,9 +240,6 @@ async function main() {
const ownerMarkdown = fs.readFileSync(path.join(root, relativePath), "utf8");
assert(ownerMarkdown.includes(`[OCR:photo.png](`), `owner markdown should include explicit OCR link:\n${ownerMarkdown}`);
- await page.waitForFunction(() => Boolean(window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab), null, {
- timeout: UI_TIMEOUT_MS,
- });
const openResourceResult = await page.evaluate(async ({ rootUri, documentId, ocrRootRelativePath, workspaceId }) => {
const runtime = window.__mnoteDocumentPaneRuntime;
if (!runtime || typeof runtime.openResourceInActiveTab !== "function") {
@@ -216,11 +266,11 @@ async function main() {
workspaceId,
});
assert.equal(openResourceResult, true, "OCR sidecar resource tab should open");
- await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"] [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
+ await page.locator('[data-mnote-resource-tab-panel][data-resource-kind="markdown"]:not([hidden]) [data-testid="mnote-leptos-tiptap-island-editor-root"]').first().waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
- screenshots.ocrResource = path.join(OUTPUT_DIR, "02-ocr-resource-tab.png");
+ screenshots.ocrResource = path.join(OUTPUT_DIR, "03-ocr-resource-tab.png");
await page.screenshot({ path: screenshots.ocrResource, fullPage: true });
await writeResult({