2026-10-26 现状说明:本页保留实施前的动机、伪代码和当时接线顺序,不把旧行号、
“现在各跑一遍”或候选 API 当作当前接口。实现以
ocr_cache.ts及OCR 现状为准。
缓存路径设置现位于「常规 → WeQ 存储管理」;OCR 仍不参加默认缓存清理,但已可在该页
明确选择风险数据并退出清除,不能再依据下文“只能手动删目录”的旧设计操作。
状态:已落地(灯箱 / 导出 / MCP 三处都接上了;每个关键行为都有单测:
packages/service/test/ocr_cache.test.ts、ocr_export.test.ts、apps/desktop/test/ocr_mcp_tool.test.ts)。这份文档留作设计记录与取舍依据 —— 改动这里的行为前先看「需要你拍板」那几条。一句话:把识别结果按 图片内容 md5 落一份到
<缓存根>/ocr/, 灯箱与导出共用同一套读写(packages/service/src/common/ocr_cache.ts), 命中就把整条腿都省掉(在线请求、本地推理、连筛图都不用跑)。
| 入口 | 触发 | 链路 | 结果去哪 |
|---|---|---|---|
灯箱 account.ocrChatImage(main/ipc/routers/account.ts:3574) |
用户点「识别文字」 | 在线 OIDB 0xe07_0 优先,失败 / 不可用回退本地 PP-OCR(main/ocr/local.ts) |
只活在渲染层 state(ImageLightbox.tsx),关掉灯箱就没了 |
导出(export/ocr_export.ts,在写) |
勾选「自动 OCR」 | 筛图 → 在线优先(连败放弃)→ 本地兜底 | <bundle>/ocr.json,再由 ocr_rewrite 回写消息文件 |
MCP ocr_image_text(main/mcp/tools.ts:2637) |
外部客户端 / 内置助手调用 | 同一套策略 | 只回给调用者 |
三处互相看不见对方的成果,于是:
- 导出跑过的图,用户回头点开灯箱看,又跑一遍(本地那条腿是秒级的);
- 灯箱识别过的图,导出时再跑一遍;
- 同一个 bundle 重导一次、或者断点续跑重来一段,全量重跑。
量级参照 ocr-models.md 第四节的实测(40 行截图、8 线程 2.4–3.4 秒/张):
一次群导出几百上千张图就是小时级,而命中缓存是毫秒级。
缓存还能捞回一件本来会彻底丢掉的东西:CDN 直链只有约 9 天有效期(OCR_DOWNLOAD_ERROR,
见 packages/service/src/account/ocr_image.ts),本机又没落过盘的图,过期后在线那条腿必然失败、
本地那条腿没有字节也跑不了 —— 只有「当时识别过并留下来」的结果还能看。
| 候选 | 稳定吗 | 三个入口都拿得到吗 | 结论 |
|---|---|---|---|
| 元素 md5(45424 大写 hex / 45406 二进制 MD5,归一成小写 hex) | 内容寻址:同一张图转发、跨会话、跨账号、跨导出都是同一个键 | pic 元素必有(见 pic.md 第三节);但渲染视图现在把它丢了(msg_view.ts:678 mapPic 里被注释掉),要打开;path 那种入口没有 → 用字节 md5 兜 |
✅ 主键 |
fileName / stem |
多数情况就是 md5 + 扩展名(QQ 自己落盘就是 Pic/<yyyy-MM>/Ori/<md5>.<ext>,file_search.ts 也是按 stem 找的),但这是经验、不是契约 |
导出 / MCP 有,灯箱上下文里没有 | 退路(md5 缺失时)+ 记录里留一份便于人查 |
fileToken(45503) |
每次上传的下载凭据,与内容无必然对应,重传 / 过期会变 | 灯箱有,path 没有 |
❌ |
msgId + imageIndex |
同一张图发两次 = 两条缓存,灯箱也没法从灯箱上下文反推 | 都有(MCP 就是这么定图的) | ❌(太窄) |
| 本机文件字节 md5 | 同一张图的「原图 / 预览 / 缩略」是三个不同的 md5 | 要读盘才有 | 只当第二身份(path 入口 / 元素 md5 缺失时) |
还有一件必须跟着结果一起存的事:「识别的是哪一份像素」。
- 本地那条腿读的是灯箱正在显示的那张,走
media_protocol.handleMediaRequest: 本机Ori→Thumb→ chatpic → CDN 下载,可能是缩略图; - 在线那条腿拿的是元素里记的宽高(服务端再去取图);
blocks的坐标是喂进去那张图的像素坐标。
所以记录里必须带 frame { width, height, variant };命中时若和当前要显示的图尺寸不一致,
用 scaleOcrPoints / scaleOcrRect(@weq/protocol 已有)换算过去。
ImageLightbox.tsx 里 ocrSize 那段注释(「不能直接用 ocrContext 的宽高」)就是被这件事咬过的痕迹。
<cacheBase>/ocr/<md5 前两位>/<md5>.json
cacheBase = UserConfigService.cacheBaseDir()(默认 <appDataRoot>/cache,用户可在
「设置 → 账号信息 → 缓存路径」改),目录由 app 侧注入 —— 照 ModelStore、
MediaDownloadService、RoamMsgCacheDb 的做法,@weq/service 这一侧保持零原生(只用
node:fs / node:path / node:crypto)。
但不要把它加进 user_config.ts 的清理白名单 —— 生命周期那一条见下文。
- 一个键空间,两个入口都能用:灯箱在主进程、导出在
@weq/service,两边都拿得到这份目录 (app_context.ts建实例并注入导出依赖,灯箱那条走requireServices()),不需要新 IPC 通道。 - 跟
cache/的既有约定一致:目录自动跟随用户的缓存路径覆盖,清理入口已经在设置页 (GlobalSettingsSection.tsx:131),不用新增一套存储概念。 - 不写回 QQ 库(对照语音那条线):语音的转写结果能写回
nt_msg.db的 45923 (msg.ts:479 setPttTranscript),因为那是 QQ 自己的字段、它自己也认(写进去 QQ 也读); 而 pic 上没有已知的 OCR 字段 ——codec里picFlag45600那层嵌套结构至今是「保留为原始字节」 (见pic.md第四节),即便里面真有,也得先摸清语义才敢写。 往别人的库里塞未知 tag 风险高,还换不来「QQ 自己也能显示」这个收益。 - 不放到
<appDataRoot>/models/那种不会被清的地方:识别结果多数可再生成(图还在盘上, 重跑一次而已),只留「清理缓存」一个出口,符合仓库既有原则(「cache = 可再生成」)。
<cacheBase>/ocr/ 不加进 CLEARABLE_CACHE_CATEGORIES —— 与 roam-msg /
weq-assistant / export 同一档:都住在缓存根下,但「清理缓存」够不着它们
(user_config.ts:1369 起那段注释明确写了「不放 agentlab / weq-assistant / export」)。
理由:过期图的识别结果一旦被清掉就再也拿不回来(CDN 链接约 9 天就失效,本机没本体时 本地那条腿也没字节可跑),而体量又小(一张图几 KB),不值得为它冒丢数据的风险。 真要回收就手动删目录;设置页那几行「清理缓存」保持原样,不动。
<cacheBase>/ocr/ocr.db,走 native helper 的 executeSqlWrite(照
packages/service/src/account/roam_msg_cache.ts 的路子)。等真需要「在所有识别过的图片里
全文搜索」或「十万条级的 LRU / 统计」再换;换的时候只改 ocr_cache.ts 的实现,
记录里带 schemaVersion 供迁移。
一条记录 = 一个 md5 一个文件(顺便天然去重、天然可 cat 出来 debug):
要点:
variants的 key = 生产者身份(oidb/local:<modelId>/filter:<modelId>), 在线与本地两份并存不覆盖:离线时本地先跑过,之后在线可用还能拿在线那份(精度高一档); 换模型 / 改超参(pipeline版本)不会命中旧记录,而不是悄悄吃旧结果。- 空结果也算结果(
text: ""、hasText: false),避免反复跑;失败不进缓存, 唯一例外是「在线取不到图」这种确定性失败可以记一条online: { fail: 'download_error', at }当提示,下次导出直接跳过(否则又要白试一轮)。 - 读:
get(md5, { prefer: 'oidb' | 'local' | 'any', frame });frame不一致就把blocks换系后再给调用方。 - 写:临时文件 +
rename原子替换;同一 md5 的 in-flight 请求用Map<md5, Promise>合流 (灯箱连点两次、导出与灯箱同时跑同一张图),顺带当会话内缓存。 blocks过大(超长截图)可截断并标blocksOmitted,文字照留;坏 JSON 当未命中并删掉。
API 示意(common/ocr_cache.ts 就这么大):
class OcrResultCache {
constructor(root: string); // app 侧传 userConfig.cacheDir('ocr')
get(md5: string, opts?: { prefer?: string; frame?: { width: number; height: number } }): Promise<CachedOcrHit | null>;
put(md5: string, producer: string, entry: CachedOcrEntry, meta?: CachedOcrMeta): Promise<void>;
has(md5: string): Promise<boolean>; // 灯箱门禁用(见第五节)
size(): Promise<{ files: number; bytes: number }>;
prune(opts: { maxAgeMs?: number; maxBytes?: number }): Promise<{ removed: number }>;
}没有它就没有共享的键,别的都白搭:
msg_view.ts:678 mapPic:打开md5(el.md5归一成小写;没有就toHex(el.md5Bytes)兜底),RenderPicElement.data加这个字段;export/media_scan.ts:386的md5: ''换成渲染视图里的值(现在那里写着「render 视图没带 md5」);QqMedia.tsx:198那套「合法 32 hex → 小写」的校验已经写好了,接上就有值;- MCP 定图那步读的也是渲染元素,同样白拿;给
path时算md5(字节)。
ocrChatImage(input)
├─ md5 有 → cache.get(md5) 命中 → 直接回 { blocks, width, height, source, cached: true }
├─ 未命中 → 原逻辑(在线优先 / 本地兜底,见 account.ts:3589-3620)
└─ 拿到结果 → cache.put(md5, 'oidb' | `local:${modelId}`, …)
- 返回值加
cached?: true,OcrTextPanel副标题写「在线(缓存)」之类,一行改动; - 可选但很值:再加一个只读 query(如
ocrCacheHas({ md5 })),让「QQ 离线 + 没下模型 + 服务端副本已过期」但以前识别过的图,canOcr依然可点 —— 这正是缓存最实在的收益场景; put时的frame:本地腿用回包自带的imageWidth/Height(可能是缩略图),在线腿用元素宽高。
ocr_export.ts的ocrFoundImages开头先过一遍缓存:命中的直接写进results[fileName], 不进筛图队列、不进在线队列、不进本地队列;- 未命中的照旧走流水线,出结果后
put;筛图那条也put(filter:<modelId>); - 注入点:
task_manager.ts:2194-2217那一带加ocrCache,与app_context.ts:1349-1350/1977-1978注入ocrLocal/ocrFilter是同一种写法; - 统计口径:命中不要算进
OcrExportSummary.online/local(建议加cached字段), 否则日志里「在线 300 张」会让人以为又发了一轮请求; - 全命中时
ocrfilter阶段可以直接标完成(ocr阶段仍要跑:写ocr.json+ 回写)。
ocr_image_text 走同一份缓存(工具描述补一句「命中本机缓存直接返回」、返回值加 cached: true);
path 那条腿用字节 md5 当键。
- 筛图、在线连败放弃(
OCR_ONLINE_FAILURE_LIMIT)、失败列表(MediaFailure)、ocr.json、ocr_rewrite的语义都不动,缓存只是把「候选」变短。 - bundle 里的
ocr.json不是缓存:它是某次导出的产物(回写锚点、可复现、用户可能手改)。 两层职责分开:全局缓存 = 长期记忆,ocr.json= 本次产物,别互相污染。 - 断点续跑(
resume/touchStage)不用动:命中的图照样跑一遍ocr阶段,只是每张成本 变成一次读盘。
- ✅ Step 0:md5 接通 ——
msg_view.ts的mapPic输出md5(大写 hex 归一成小写)、media_scan.ts的ocr.md5填上。 - ✅
common/ocr_cache.ts+ 单测(读写 / 原子替换 / 同键并发 / 坏档容错 / 大 blocks 截断)。 - ✅ 目录建在缓存根下,不动清理白名单(见第三节)。
- ✅ 导出侧:候选先过一遍缓存,命中的一步都不跑(
OcrExportSummary.cached单独计一档); 在线 / 本地 / 筛图的结果都写回缓存。 - ✅ 灯箱侧:命中直接返回并标
cached(面板副标题写「(缓存)」、不再报「本地模型用时」); 新增只读 queryaccount.ocrCacheLookup({ md5 })当按钮门禁的第三条腿。 - ✅ MCP 侧:
ocr_image_text命中直接返回(带cached/cachedAt),engine明确要某条腿时不拿另一条腿的缓存顶替。 - ✅ 文档:本节 +
docs/develop/ocr-models.md第六节 + 导出 / 语音指南 + CHANGELOG。
{ "schemaVersion": 1, "md5": "3fa2c1…c9", // 主键(小写 hex) "ids": { // 便于人查 / 兜底寻址,不参与主键 "elementMd5": "3FA2C1…C9", // 元素 45424 原样(大写) "bytesMd5": "…", // 实际喂给模型的字节 md5(可能 ≠ elementMd5) "fileName": "3fa2c1….jpg" }, "frame": { "width": 1280, "height": 720, "variant": "original" }, // original | thumb | downloaded "firstSeenAt": 1767000000, "lastUsedAt": 1767000000, "variants": { "oidb": { "text": "…", "blocks": [ /* … */ ], "lang": "zh", "at": 1767…, "hits": 1 }, "local:ppocr-v5-mobile": { "text": "…", "blocks": [ /* … */ ], "pipeline": 3, "at": 1767…, "hits": 4 }, "filter:ppocr-v6-det-tiny": { "hasText": true, "boxes": 12, "at": 1767… } } }