feat: 新增mac ocr转文字,增加到问一问微信 图片索引优化速度

- 图片文字索引性能与进度诚实化
- 问问微信:证据卡区分「消息类型」与「派生来源」,派生命中内容自报来源
- 问问微信:回答规则禁止未真实执行的多轮承诺
- 本地图片文字识别:支持 macOS 系统 OCR(Apple Vision)
This commit is contained in:
Wxw-Gu
2026-09-17 13:11:20 +08:00
parent b8f08d54c8
commit 1337bcb7de
53 changed files with 4985 additions and 624 deletions
@@ -0,0 +1,240 @@
/**
* 图片文字索引的**进度通知节流**契约。
*
* 背景:后台按 `BATCH_SIZE = 12` 推进,但 UI 不该感知 batch 大小 ——
* 每批都推会让计数以「+12」的粒度跳动。
*
* 硬要求:
* 1. 正常运行态最多每 `progressNotifyIntervalMs` 推一次**最新权威快照**;
* 2. 状态变化(开始/暂停/继续/取消/完成/失败)**立即**推,不等窗口;
* 3. 完成必须立即给出最终值;
* 4. 同时最多一个 timer,pass 结束后不留残留定时器;
* 5. 推的是快照,不是把窗口内几十个 delta 重放给 Renderer。
*
* 为了避免时序脆弱的测试,最关键的一条用**把窗口设得极大**来表达:
* 如果节流正确,整遍 pass 里只应有「开始」和「完成」两次状态变化通知。
*/
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as chat from '../../src/main/services/chat-service'
import { ImageTextIndexService } from '../../src/main/services/image-text-index-service'
import type { ImageTextIndexStatus } from '../../src/shared/image-text-index'
const ACCOUNT = 'wxid_progress_notify_fixture'
const CONVERSATION = 'md5-progress-notify'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'tm-progress-notify-'))
roots.push(root)
return root
}
function pngBytes(seed: number): Buffer {
return Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
seed & 0xff,
(seed >> 8) & 0xff
])
}
function imageMessage(localId: number): chat.FormattedMessage {
return {
id: String(localId),
localId: String(localId),
from: 'user',
type: '图片',
content: '',
isSender: false,
name: '对方',
contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` },
createTime: 1_700_000_000 + localId
} as unknown as chat.FormattedMessage
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
/** 每张图 sleep 一点,让 pass 有可观测的持续时间。 */
function createService(options: { count: number; notifyIntervalMs: number; perImageMs?: number }): {
service: ImageTextIndexService
notifications: Array<{ at: number; processed: number; state: string }>
} {
const databaseRoot = makeRoot()
const messages = Array.from({ length: options.count }, (_, index) => imageMessage(index + 1))
const perImageMs = options.perImageMs ?? 0
const service = new ImageTextIndexService()
service.bind({
databaseRoot,
progressNotifyIntervalMs: options.notifyIntervalMs,
resolveAccountId: () => ACCOUNT,
ocrConcurrency: 2,
listContacts: async () => [{ md5: CONVERSATION, m_nsUsrName: 'notify', type: 'user' as const }],
listMessages: async () => messages,
countConversationImages: async () => ({ count: messages.length, typeColumn: 'local_type' }),
imageWatermark: async () => ({ count: messages.length, maxLocalId: messages.length }),
capability: async () => ({
available: true,
engine: 'windows-system-ocr',
platform: 'win32',
runtimeVersion: '1.2.0',
language: 'zh-Hans-CN'
}),
decryptService: () =>
({
findImageFile: (md5: string) => `C:/fake/${md5}.dat`,
decryptImage: (path: string) => pngBytes(Number(/(\d+)\.dat$/.exec(String(path))?.[1] ?? 1))
}) as never,
recognize: async () => {
if (perImageMs > 0) await sleep(perImageMs)
return { success: true, text: 'TEXT', language: null }
}
})
const notifications: Array<{ at: number; processed: number; state: string }> = []
service.onStatusChange((status: ImageTextIndexStatus) => {
notifications.push({
at: Date.now(),
processed: status.progress.processed,
state: status.progress.state
})
})
return { service, notifications }
}
const finish = async (service: ImageTextIndexService): Promise<void> => {
await vi.waitFor(() => expect(service.isRunning()).toBe(false))
}
describe('进度通知节流', () => {
it('窗口极大时,整遍 pass 只推「开始」和「完成」两次状态变化', async () => {
// 240 张 = 20 个 batch。若每批都推会有 20+ 次通知;节流正确则只有状态变化。
const { service, notifications } = createService({
count: 240,
notifyIntervalMs: 100_000,
perImageMs: 2
})
await service.startPass()
await finish(service)
expect(notifications.map((n) => n.state)).toEqual(['running', 'completed'])
// 完成必须带**最终值**,不能等下一次 5 秒 timer。
expect(notifications[notifications.length - 1].processed).toBe(240)
service.resetAccount()
})
it('窗口为 0 时不做节流(每次都推最新快照)', async () => {
const { service, notifications } = createService({
count: 240,
notifyIntervalMs: 0,
perImageMs: 2
})
await service.startPass()
await finish(service)
// 不节流 ⇒ 通知数应远多于「仅两次状态变化」,且处理量单调不减。
expect(notifications.length).toBeGreaterThan(4)
const processed = notifications.map((n) => n.processed)
expect([...processed].sort((a, b) => a - b)).toEqual(processed)
expect(processed[processed.length - 1]).toBe(240)
service.resetAccount()
})
it('暂停立即推送,不等窗口', async () => {
const { service, notifications } = createService({
count: 600,
notifyIntervalMs: 100_000,
perImageMs: 4
})
void service.startPass()
// 注意:窗口是 100 秒,**进度通知按设计不会来**,所以不能用通知当等待条件。
await vi.waitFor(async () => {
const status = await service.getStatus()
expect(status.progress.processed).toBeGreaterThan(0)
})
service.pause()
await finish(service)
const pausedAt = notifications.findIndex((n) => n.state === 'paused')
expect(pausedAt).toBeGreaterThanOrEqual(0)
// 暂停之后不应再有「运行中」的进度推送(窗口是 100 秒,等不到)。
expect(notifications.slice(pausedAt + 1).some((n) => n.state === 'running')).toBe(false)
service.resetAccount()
})
it('pass 结束后不留残留 timer:不再产生额外通知', async () => {
const { service, notifications } = createService({
count: 120,
notifyIntervalMs: 30,
perImageMs: 1
})
await service.startPass()
await finish(service)
const afterFinish = notifications.length
// 窗口只有 30ms,如果尾随 timer 没被清掉,这段时间里一定会再冒出通知。
await sleep(200)
expect(notifications.length).toBe(afterFinish)
expect(notifications[notifications.length - 1].state).toBe('completed')
service.resetAccount()
})
it('continue(重新 startPass)不会叠加出第二个 timer', async () => {
const { service, notifications } = createService({
count: 240,
notifyIntervalMs: 30,
perImageMs: 1
})
void service.startPass()
await vi.waitFor(async () => {
const status = await service.getStatus()
expect(status.progress.processed).toBeGreaterThan(0)
})
service.pause()
await finish(service)
const pausedCount = notifications.length
await service.resume()
await finish(service)
await sleep(200)
// 恢复后应重新开始推送,但不应因"两个 interval 并存"而翻倍:
// 第一遍以 paused 收尾、第二遍以 completed 收尾 ⇒ completed 恰好 1 次。
expect(notifications.length).toBeGreaterThan(pausedCount)
const completed = notifications.filter((n) => n.state === 'completed')
expect(completed).toHaveLength(1)
expect(notifications.filter((n) => n.state === 'paused')).toHaveLength(1)
service.resetAccount()
})
it('速度与 ETA 只在窗口样本足够时给出,否则为 null', async () => {
const { service } = createService({ count: 120, notifyIntervalMs: 0, perImageMs: 1 })
await service.startPass()
await finish(service)
const status = await service.getStatus()
// 这遍跑得太快,窗口跨度不足 ⇒ 必须如实为 null(UI 显示"计算中"),不许编数。
expect(status.progress.speedPerSec).toBeNull()
expect(status.progress.etaMs).toBeNull()
service.resetAccount()
})
})