diff --git a/src/main/index.ts b/src/main/index.ts
index a56f313..9a51f66 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -150,6 +150,8 @@ function nextGetMessagesRequestId(): string {
import type { AppLogEntry } from '../shared/app-log'
import { appUpdateService } from './services/app-update-service'
import { clearCache, getCacheSummary, openKnowledgeDirectory } from './services/cache-service'
+import { imageTextIndexService } from './services/image-text-index-service'
+import type { ImageTextIndexStartOptions } from '../shared/image-text-index'
import type { CacheClearScope } from './services/cache-service'
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
import { VideoAssetService } from './video-asset-service'
@@ -637,8 +639,94 @@ app.whenReady().then(async () => {
voiceRecognition.onTranscriptUpdate((update) =>
knowledgeSearchService?.indexVoiceTranscript(update)
)
+
+ /**
+ * 确保图片解密服务可用(按需创建,与 `db:getImage` 冷路径同一套构造方式)。
+ *
+ * 提取成显式入口是因为原来它只存在于 `db:getImage` 的闭包里,
+ * 别的需要解密的路径(图片文字索引回填)拿不到、只能拿到 `null`。
+ */
+ function ensureImageDecryptService(): ImageDecryptService | null {
+ if (imageDecryptService) return imageDecryptService
+ const { xorKey, aesKey } = getConfiguredImageKeys()
+ if (!aesKey) return null
+ imageDecryptService = new ImageDecryptService(
+ xorKey,
+ aesKey,
+ chat.getChatDb()?.getWcdb4Client(),
+ loadSettings().dbRoot
+ )
+ return imageDecryptService
+ }
+
+ // 图片文字索引(本地 System OCR 派生文本)。
+ // 与语音转写完全同构:派生文本在 main 进程解析后贴到消息上,Knowledge 侧只消费结果。
+ knowledgeSearchService.setImageOcrResolver((conversationId, messageId) =>
+ imageTextIndexService.getConversationOcr(conversationId).get(messageId)
+ )
+ /**
+ * 图片文字索引需要解密图片。
+ *
+ * 原先这个依赖直接读 `imageDecryptService`,而它**只在 `db:getImage`(用户点开某张图)
+ * 里才懒加载** —— 于是全量回填在用户没点开过任何图片时拿到 `null`,
+ * 45,479 张图片全部被记成 `decrypt_failed`(见事故报告)。
+ * 这里改成显式的"按需确保",凡是需要解密的路径都能自己把它建起来。
+ */
+ imageTextIndexService.bind({
+ databaseRoot: join(app.getPath('userData'), 'image-text-index'),
+ resolveAccountId: () =>
+ chat.isReady()
+ ? String(chat.getSelfAccountInfo()?.wxid || chat.getCurrentAccountRoot() || '')
+ : '',
+ resolveAccountRoot: () => chat.getCurrentAccountRoot() || loadSettings().dbRoot || '',
+ listContacts: async () => {
+ const contacts = await chat.listContactsAsync()
+ return contacts.map((contact) => ({
+ md5: contact.md5,
+ m_nsUsrName: contact.m_nsUsrName,
+ type: contact.type
+ }))
+ },
+ listMessages: (conversationId) => chat.listMessagesAsync(conversationId),
+ countConversationImages: (conversationId, sinceMs) =>
+ chat.countImageMessagesAsync(conversationId, sinceMs),
+ imageWatermark: (conversationId, sinceMs) =>
+ chat.imageConversationWatermarkAsync(conversationId, sinceMs),
+ decryptService: () => ensureImageDecryptService(),
+ capability: () => systemOcrService.getCapability(),
+ recognize: async (imageDataUrl) => {
+ const result = await systemOcrService.recognize({ imageDataUrl })
+ return {
+ success: result.success,
+ text: result.text,
+ language: result.language,
+ ...(result.errorCode ? { errorCode: result.errorCode } : {})
+ }
+ },
+ // 会话图片全部处理完 → 重建该会话索引,OCR 文本才可被 search_messages 检索。
+ onConversationIndexed: (conversationId) =>
+ knowledgeSearchService?.indexImageOcr(conversationId) ?? Promise.resolve()
+ })
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
localQueryApiService = new LocalQueryApiService(knowledgeSearchService)
+ // 图片文字索引覆盖度是**独立覆盖维度**:接到 search_messages 的 tool result 上,
+ // 让 Query Agent 在图片索引没做完时不能凭 0 条证据断言"没有"。
+ localQueryApiService.setImageTextCoverageProvider(() =>
+ imageTextIndexService.getCoverageSnapshot()
+ )
+ /**
+ * 单条图片消息的 OCR 派生文本也要接到精确读消息路径上。
+ *
+ * 与覆盖度是**两件不同的事**:覆盖度回答"索引建了多少",这里回答
+ * "这一条图片已经识别出的文字是什么"。只接前者的话,图片索引建好了模型也读不到正文,
+ * 只能看到一个空的 `attachment` —— 真机上就是这么把"图片里有 ChatGPT 价格"
+ * 答成"没有取得 OCR 文字"的。
+ *
+ * 只读派生库,**不触发 OCR / 解密 / 读原图**。
+ */
+ localQueryApiService.setImageOcrEntryProvider((conversationId, messageId) =>
+ imageTextIndexService.getConversationOcr(conversationId).get(messageId)
+ )
setLocalQueryApiService(localQueryApiService)
// Query Agent:生产 Runtime 只在这里实例化一次,桌面问问微信与 Agent Hub 共用同一个实例。
queryAgentService = new QueryAgentService(
@@ -660,6 +748,11 @@ app.whenReady().then(async () => {
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
}
})
+ imageTextIndexService.onStatusChange((status) => {
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) window.webContents.send('image-text-index:status', status)
+ }
+ })
voiceRecognition.modelManager.setProgressListener((status) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
@@ -748,12 +841,25 @@ app.whenReady().then(async () => {
ipcMain.handle('cache:getSummary', () => getCacheSummary())
ipcMain.handle('cache:openKnowledgeDirectory', () => openKnowledgeDirectory())
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
- const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
+ const allowedScopes: CacheClearScope[] = [
+ 'bootstrap',
+ 'electron',
+ 'knowledge',
+ 'image-text-index',
+ 'all'
+ ]
if (!allowedScopes.includes(scope)) return getCacheSummary()
imageDecryptService = null
+ // 这里刻意**不再**提前 resetAccount():清理钩子需要先读到派生库里的
+ // "哪些会话有 OCR 派生文本",才能把这些会话的 Knowledge 索引一起失效。
+ // 句柄由 beforeClearImageTextIndex 内部的 clear() 自己关闭(删文件前)。
return clearCache(scope, {
beforeClearKnowledge: () =>
- knowledgeSearchService?.prepareForCacheClear() || Promise.resolve()
+ knowledgeSearchService?.prepareForCacheClear() || Promise.resolve(),
+ beforeClearImageTextIndex: async () => {
+ await imageTextIndexService.prepareForCacheClear()
+ imageTextIndexService.resetAccount()
+ }
})
})
@@ -841,6 +947,8 @@ app.whenReady().then(async () => {
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
}
imageDecryptService = null
+ // 派生库按 accountId 分目录,切账号必须换句柄,否则会串账号。
+ imageTextIndexService.resetAccount()
console.log(
`[WCDB4] db:init ready sessions=${sessions.length} monitoring=${monitoring} cost=${Date.now() - startedAt}ms`
)
@@ -1020,6 +1128,8 @@ app.whenReady().then(async () => {
aesKey: result.aesKey
})
if (saved.success) imageDecryptService = null
+ // 派生库按 accountId 分目录,切账号必须换句柄,否则会串账号。
+ imageTextIndexService.resetAccount()
return {
...result,
success: saved.success,
@@ -1040,6 +1150,8 @@ app.whenReady().then(async () => {
ipcMain.handle('image:saveConfig', (_, request: SaveImageKeyRequest) => {
const result = imageKeyConfigService.save(request)
if (result.success) imageDecryptService = null
+ // 派生库按 accountId 分目录,切账号必须换句柄,否则会串账号。
+ imageTextIndexService.resetAccount()
return result
})
@@ -1050,6 +1162,8 @@ app.whenReady().then(async () => {
ipcMain.handle('image:clearConfig', () => {
const result = imageKeyConfigService.clear()
if (result.success) imageDecryptService = null
+ // 派生库按 accountId 分目录,切账号必须换句柄,否则会串账号。
+ imageTextIndexService.resetAccount()
return result
})
@@ -1338,6 +1452,32 @@ app.whenReady().then(async () => {
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
return knowledgeSearchService.cancelCurrentAccountIndex()
})
+ // ---- 图片文字索引(本地 System OCR 派生文本,非 AI Provider)----
+ ipcMain.handle('image-text-index:getStatus', () => imageTextIndexService.getStatus())
+ /**
+ * 点击索引前的快速统计:纯 SQL COUNT,**不解密任何图片**。
+ * 这是「先告诉用户有多少张图片再决定是否开始」能足够快的前提。
+ */
+ ipcMain.handle('image-text-index:count', (_, sinceMs?: number) =>
+ imageTextIndexService.countImageMessages(sinceMs)
+ )
+ ipcMain.handle(
+ 'image-text-index:start',
+ (_, options?: ImageTextIndexStartOptions) => imageTextIndexService.startPass(options ?? {})
+ )
+ ipcMain.handle('image-text-index:pause', () => imageTextIndexService.pause())
+ ipcMain.handle(
+ 'image-text-index:resume',
+ (_, options?: ImageTextIndexStartOptions) => imageTextIndexService.resume(options ?? {})
+ )
+ ipcMain.handle('image-text-index:cancel', () => imageTextIndexService.cancel())
+ ipcMain.handle('image-text-index:clear', () => imageTextIndexService.clear())
+ // 只重置失败记录(成功记录与其它数据一律不动),供"修好代码后重跑"使用。
+ ipcMain.handle('image-text-index:resetFailures', () =>
+ imageTextIndexService.resetRetriableFailures()
+ )
+ // 派生索引修复:只重建 Knowledge 里的图片派生条目(L3),**不重新 OCR**(L1 不动)。
+ ipcMain.handle('image-text-index:repair', () => imageTextIndexService.repairKnowledgeIndex())
ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
return aiSearchPipelineService.run(request, (progress) => {
@@ -1963,6 +2103,8 @@ app.whenReady().then(async () => {
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
else imageKeyConfigService.clear()
imageDecryptService = null
+ // 派生库按 accountId 分目录,切账号必须换句柄,否则会串账号。
+ imageTextIndexService.resetAccount()
}
if ('recallProtectionEnabled' in patch && chat.isReady()) {
const currentDb = chat.getChatDb()
diff --git a/src/main/knowledge/knowledge-search-service.ts b/src/main/knowledge/knowledge-search-service.ts
index ced5755..7514bea 100644
--- a/src/main/knowledge/knowledge-search-service.ts
+++ b/src/main/knowledge/knowledge-search-service.ts
@@ -1,6 +1,7 @@
import { monitorEventLoopDelay } from 'perf_hooks'
import * as chat from '../services/chat-service'
import type {
+ KnowledgeImageOcrState,
KnowledgeAttachmentMetadata,
KnowledgeEvidence,
KnowledgeMessageKind,
@@ -24,6 +25,7 @@ import {
emptyKnowledgeSearchTimings
} from '../../shared/knowledge'
import { KnowledgeService } from './knowledge-service'
+import { sourceMessageId } from './message-identity'
import {
voiceAccountIdentity,
voiceMessageIdentity
@@ -118,11 +120,6 @@ function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]):
)
}
-function sourceMessageId(message: chat.FormattedMessage): string {
- if (message.localId) return `local:${message.localId}`
- if (message.id) return String(message.id)
- return `${message.createTime || 0}:${message.serverId || message.content}`
-}
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
if (message.voiceTranscript || message.type === '语音') return 'voice'
@@ -130,6 +127,16 @@ function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
return message.exportMediaType
}
if (message.exportMediaType === 'file') return 'file'
+ // 索引路径上 `exportMediaType` **不会被赋值**(只有 export-service 会设它),
+ // 所以图片/视频/表情包必须从 contentData.type 判定,否则图片会静默落成 'other',
+ // 进而让"图片文字索引"的 Evidence 丢掉真正的来源类型。
+ if (
+ message.contentData?.type === 'image' ||
+ message.contentData?.type === 'video' ||
+ message.contentData?.type === 'sticker'
+ ) {
+ return message.contentData.type
+ }
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
? 'file'
@@ -201,12 +208,16 @@ function toSourceMessage(
accountId: string,
conversationId: string,
message: chat.FormattedMessage,
- transcriptOverride?: string
+ transcriptOverride?: string,
+ imageOcr?: { state: KnowledgeImageOcrState; text: string }
): KnowledgeSourceMessage | null {
if (!message.createTime) return null
const extracted = sourceTextAndAttachment(message)
const voiceTranscript = transcriptOverride?.trim() || message.voiceTranscript?.trim() || undefined
- if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
+ // 图片 OCR 文本走与语音转写完全相同的派生通道:有文本才入库,
+ // 没有文字的图片(表情包/风景)不会污染索引。
+ const imageOcrText = imageOcr?.text?.trim() || undefined
+ if (!extracted.text && !extracted.attachment && !voiceTranscript && !imageOcrText) return null
return {
accountId,
conversationId,
@@ -218,7 +229,9 @@ function toSourceMessage(
kind: sourceKind(message),
text: extracted.text,
attachment: extracted.attachment,
- voiceTranscript
+ voiceTranscript,
+ ...(imageOcrText ? { imageOcrText } : {}),
+ ...(imageOcrText && imageOcr?.state ? { imageOcrState: imageOcr.state } : {})
}
}
@@ -256,6 +269,14 @@ export class KnowledgeSearchService {
private interactiveIdleResolve: (() => void) | null = null
private wcdbQueueMsTotal = 0
private wcdbExecutionMsTotal = 0
+ /**
+ * 图片 OCR 文本解析器(由 main 注入)。
+ *
+ * 与语音同构:派生文本在**主进程**解析后贴到消息上,派生库不进 worker。
+ */
+ private imageOcrResolver:
+ | ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
+ | undefined
private voiceTranscriptResolver:
| ((reference: VoiceMessageReference) => VoiceTranscriptSnapshot)
| undefined
@@ -372,6 +393,15 @@ export class KnowledgeSearchService {
this.voiceTranscriptResolver = resolver
}
+ /** 注入图片 OCR 文本解析器(本地 System OCR 的派生结果)。 */
+ setImageOcrResolver(
+ resolver:
+ | ((conversationId: string, messageId: string) => { state: KnowledgeImageOcrState; text: string } | undefined)
+ | undefined
+ ): void {
+ this.imageOcrResolver = resolver
+ }
+
/**
* A successful recognition updates its source conversation. Consecutive
* updates for the same conversation are coalesced because a complete
@@ -1074,8 +1104,16 @@ export class KnowledgeSearchService {
const reference = this.voiceReferenceFromMessage(message)
const snapshot = reference ? this.voiceTranscriptResolver?.(reference) : undefined
const hydrated = this.withVoiceTranscript(message)
- const source = toSourceMessage(accountId, conversationId, hydrated, transcriptOverride)
- if (!source || source.kind !== 'voice') return source
+ const imageOcr = this.imageOcrResolver?.(conversationId, sourceMessageId(message))
+ const source = toSourceMessage(
+ accountId,
+ conversationId,
+ hydrated,
+ transcriptOverride,
+ imageOcr
+ )
+ if (!source) return source
+ if (source.kind !== 'image' && source.kind !== 'voice') return source
return {
...source,
voiceTranscriptState:
@@ -1105,6 +1143,38 @@ export class KnowledgeSearchService {
}
}
+ /**
+ * 某个会话的图片 OCR 处理完成 → 重建该会话的索引。
+ *
+ * 与"语音转写完成后单会话重索引"完全同构:整会话重读 + completeSnapshot 重建,
+ * 让 OCR 派生文本进入 chunks/FTS,从而可被 search_messages 检索。
+ * 原图片消息仍然是 authoritative source —— 这里只是让它多了一段派生文本,
+ * 不产生任何"OCR 消息"。
+ */
+ async indexImageOcr(conversationId: string): Promise {
+ if (!chat.isReady()) return
+ const accountId = this.currentAccountId()
+ if (!accountId) return
+ const activeIndex = this.indexing.get(accountId)
+ if (activeIndex) await activeIndex
+ const contacts = await this.listContacts()
+ const contact = contacts.find((item) => item.md5 === conversationId)
+ if (!contact) return
+ const messages = await this.listMessages(contact.md5, undefined, undefined, 'background')
+ const sourceMessages = messages
+ .map((message) => this.toSourceMessage(accountId, contact.md5, message))
+ .filter((message): message is KnowledgeSourceMessage => Boolean(message))
+ await this.service.index({
+ accountId,
+ conversations: [
+ { conversationId: contact.md5, completeSnapshot: true, messages: sourceMessages }
+ ],
+ chunker: DEFAULT_KNOWLEDGE_CHUNKER,
+ fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
+ })
+ await this.refreshStatus(accountId)
+ }
+
private async indexVoiceTranscriptNow(update: VoiceTranscriptUpdate): Promise {
if (!chat.isReady()) return
if (update.state === 'transcribed' && !update.transcript?.trim()) return
diff --git a/src/main/knowledge/knowledge-store.ts b/src/main/knowledge/knowledge-store.ts
index 9f24b85..1c283f8 100644
--- a/src/main/knowledge/knowledge-store.ts
+++ b/src/main/knowledge/knowledge-store.ts
@@ -19,7 +19,11 @@ import type {
KnowledgeSearchTimings,
KnowledgeSearchResult
} from '../../shared/knowledge'
-import { emptyKnowledgeSearchTimings, KNOWLEDGE_SCHEMA_VERSION } from '../../shared/knowledge'
+import {
+ emptyKnowledgeSearchTimings,
+ KNOWLEDGE_SCHEMA_VERSION,
+ toEvidenceDisplayText
+} from '../../shared/knowledge'
import { chunkConversation } from './chunker'
import { normalizeKnowledgeMessage } from './normalizer'
@@ -789,7 +793,11 @@ export class KnowledgeStore {
timestamp: Number(row.create_time),
messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId],
sourceKind: String(row.kind) as KnowledgeEvidence['sourceKind'],
- text: String(row.searchable_text),
+ // 内部前缀(`图片文字:`)绝不能进 Evidence:面向用户与模型的是可读文本,
+ // 来源信息由下面的结构化字段表达。
+ text: toEvidenceDisplayText(String(row.searchable_text)),
+ ...(row.image_ocr_text ? { imageOcrText: String(row.image_ocr_text) } : {}),
+ ...(row.image_ocr_text ? { derivedSource: 'image_ocr' as const } : {}),
score: String(row.kind) === 'system' ? 1 : 0
}
}
@@ -899,6 +907,7 @@ export class KnowledgeStore {
attachment_json TEXT,
voice_transcript TEXT,
voice_transcript_state TEXT,
+ image_ocr_text TEXT,
PRIMARY KEY (conversation_id, message_id)
) STRICT;
CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time
@@ -957,6 +966,14 @@ export class KnowledgeStore {
if (!messageColumns.has('voice_transcript_state')) {
this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN voice_transcript_state TEXT')
}
+ // 图片 OCR 派生文本单独留一列(不只是埋进 searchable_text)。
+ //
+ // 为什么必须落列而不是从 searchable_text 里截字符串:Evidence 需要回答
+ // "这条结果是不是来自图片里的文字",并按此给出来源标记与 OCR 片段。
+ // 靠解析前缀来判来源,一旦前缀格式调整就会静默失效。
+ if (!messageColumns.has('image_ocr_text')) {
+ this.database.exec('ALTER TABLE knowledge_messages ADD COLUMN image_ocr_text TEXT')
+ }
this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION))
const storedAccount = this.readMeta('account_id')
if (storedAccount && storedAccount !== this.accountId) {
@@ -1137,8 +1154,9 @@ export class KnowledgeStore {
const upsert = this.database.prepare(
`INSERT INTO knowledge_messages (
account_id, conversation_id, message_id, create_time, content_hash, searchable_text,
- kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ kind, sender_id, sender_name, attachment_json, voice_transcript, voice_transcript_state,
+ image_ocr_text
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(conversation_id, message_id) DO UPDATE SET
create_time = excluded.create_time,
content_hash = excluded.content_hash,
@@ -1148,7 +1166,8 @@ export class KnowledgeStore {
sender_name = excluded.sender_name,
attachment_json = excluded.attachment_json,
voice_transcript = excluded.voice_transcript,
- voice_transcript_state = excluded.voice_transcript_state`
+ voice_transcript_state = excluded.voice_transcript_state,
+ image_ocr_text = excluded.image_ocr_text`
)
for (let index = 0; index < messages.length; index += 1) {
this.assertNotAborted(signal)
@@ -1165,7 +1184,8 @@ export class KnowledgeStore {
message.senderName ?? null,
message.attachment ? encodedJson(message.attachment) : null,
message.voiceTranscript ?? null,
- message.voiceTranscriptState ?? null
+ message.voiceTranscriptState ?? null,
+ message.imageOcrText ?? null
)
if (index % YIELD_EVERY === 0) {
onProgress(index + 1, 0)
diff --git a/src/main/knowledge/message-identity.ts b/src/main/knowledge/message-identity.ts
new file mode 100644
index 0000000..a715d65
--- /dev/null
+++ b/src/main/knowledge/message-identity.ts
@@ -0,0 +1,24 @@
+/**
+ * 消息身份的**唯一真源**。
+ *
+ * 这个规则同时被三处需要:
+ * - Knowledge 索引写入 `knowledge_messages.message_id`
+ * - 图片文字索引的 binding(必须与 Knowledge 里的 message_id 完全一致,否则 OCR 文本贴不到消息上)
+ * - Evidence → 档案跳转的 messageRef
+ *
+ * 任何一处各自复制一份,都会在 `local:` 前缀上静默失配(项目里已经有这个坑的历史注释),
+ * 所以抽成一个模块,谁都不许再抄。
+ */
+import type * as chat from '../services/chat-service'
+
+/**
+ * 源消息 → 稳定消息 id。
+ *
+ * 降级顺序刻意保守:`localId` 是 WCDB 行内最稳的本地 id;其次用消息自带 id;
+ * 最后才退化成「时间 + 服务端 id / 内容」的组合(仅在极端缺字段时命中)。
+ */
+export function sourceMessageId(message: chat.FormattedMessage): string {
+ if (message.localId) return `local:${message.localId}`
+ if (message.id) return String(message.id)
+ return `${message.createTime || 0}:${message.serverId || message.content}`
+}
diff --git a/src/main/knowledge/normalizer.ts b/src/main/knowledge/normalizer.ts
index 799cfba..213a16e 100644
--- a/src/main/knowledge/normalizer.ts
+++ b/src/main/knowledge/normalizer.ts
@@ -24,6 +24,10 @@ export function normalizeKnowledgeMessage(
const transcript = compact(source.voiceTranscript)
if (transcript) sections.push(`语音转写:${transcript}`)
+ // 图片 OCR 文本:与语音同样的"固定前缀"约定,让检索与展示都能识别这是派生内容。
+ const imageText = compact(source.imageOcrText)
+ if (imageText) sections.push(`图片文字:${imageText}`)
+
const attachmentName = compact(source.attachment?.name)
if (attachmentName) {
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
@@ -45,6 +49,7 @@ export function normalizeKnowledgeMessage(
senderId: source.senderId || '',
kind: source.kind,
voiceTranscriptState: source.voiceTranscriptState || '',
+ imageOcrState: source.imageOcrState || '',
searchableText
})
)
diff --git a/src/main/services/ask-wechat-service.ts b/src/main/services/ask-wechat-service.ts
index 9b361cb..f7909ce 100644
--- a/src/main/services/ask-wechat-service.ts
+++ b/src/main/services/ask-wechat-service.ts
@@ -86,7 +86,8 @@ export class AskWechatService {
const diagnostics = this.diagnostics(
{ provider: '', model: '', modelCallCount: 0, toolCallCount: 0, traces: [] },
startedAt,
- 'runtime_error'
+ 'runtime_error',
+ { question }
)
this.writeLog('error', `Query Agent Runtime 异常(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
@@ -97,13 +98,13 @@ export class AskWechatService {
engine: 'query-agent',
status: 'error',
message: EMPTY_QUESTION_MESSAGE,
- diagnostics: this.diagnostics(result, startedAt, 'invalid_question')
+ diagnostics: this.diagnostics(result, startedAt, 'invalid_question', { question })
}
}
if (result.errorKind === 'provider_unavailable' || result.errorKind === 'provider_failure') {
const outcome: AskWechatOutcome = result.errorKind
- const diagnostics = this.diagnostics(result, startedAt, outcome)
+ const diagnostics = this.diagnostics(result, startedAt, outcome, { question })
this.writeLog('warn', `查询 Provider 不可用(${this.options.entry})`, diagnostics)
return {
engine: 'query-agent',
@@ -114,13 +115,13 @@ export class AskWechatService {
}
if (result.errorKind === 'tool_limit') {
- const diagnostics = this.diagnostics(result, startedAt, 'tool_limit')
+ const diagnostics = this.diagnostics(result, startedAt, 'tool_limit', { question })
this.writeLog('warn', `查询超出工具调用上限(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
if (!result.answer?.trim()) {
- const diagnostics = this.diagnostics(result, startedAt, 'runtime_error')
+ const diagnostics = this.diagnostics(result, startedAt, 'runtime_error', { question })
this.writeLog('warn', `Query Agent 未返回回答(${this.options.entry})`, diagnostics)
return this.fallback(request, 'runtime_error', diagnostics)
}
@@ -128,7 +129,7 @@ export class AskWechatService {
const answer = result.answer.trim()
// 澄清回答也记录:下一句("是 BOBO")需要接得上上文。
this.memory.record(conversationKey, question, answer)
- const diagnostics = this.diagnostics(result, startedAt, 'answered')
+ const diagnostics = this.diagnostics(result, startedAt, 'answered', { question, answer })
this.writeLog('info', `Query Agent 回答完成(${this.options.entry})`, diagnostics)
return {
engine: 'query-agent',
@@ -187,17 +188,38 @@ export class AskWechatService {
> &
Partial>,
startedAt: number,
- outcome: AskWechatOutcome
+ outcome: AskWechatOutcome,
+ /**
+ * 问答原文(可选)。只在本地应用日志里用,不上传、不进遥测。
+ *
+ * 排查这类"同一问题时对时错"的故障,光有工具名与次数是不够的 ——
+ * 必须能对着"问题 + 模型回答"回放,否则无法判断是理解错了、链路断了,还是索引没建。
+ */
+ content?: { question?: string; answer?: string }
): QueryAgentDiagnostics {
+ const traces = result.traces || []
+ // 图片 OCR 的两条结构化事实:不回读正文,只统计"取到了几条"与"当时覆盖度是多少"。
+ const imageOcrTextCount = traces.reduce(
+ (sum, trace) => sum + (trace.imageOcrTextCount || 0),
+ 0
+ )
+ const coverageState = traces
+ .map((trace) => trace.imageOcrCoverageState)
+ .filter((value): value is string => typeof value === 'string')
+ .at(-1)
return {
entry: this.options.entry,
provider: result.provider,
model: result.model,
modelCallCount: result.modelCallCount,
toolCallCount: result.toolCallCount,
- tools: (result.traces || []).map((trace) => trace.toolName),
+ tools: traces.map((trace) => trace.toolName),
totalMs: result.totalMs || Date.now() - startedAt,
- outcome
+ outcome,
+ ...(imageOcrTextCount > 0 ? { imageOcrTextCount } : {}),
+ ...(coverageState ? { imageOcrCoverageState: coverageState } : {}),
+ ...(content?.question ? { question: content.question } : {}),
+ ...(content?.answer ? { answer: content.answer } : {})
}
}
diff --git a/src/main/services/cache-service.ts b/src/main/services/cache-service.ts
index d8ff7a5..956d147 100644
--- a/src/main/services/cache-service.ts
+++ b/src/main/services/cache-service.ts
@@ -8,9 +8,12 @@ export type { CacheClearScope } from '../../shared/cache'
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
const KNOWLEDGE_CACHE_DIR = path.join(app.getPath('userData'), 'knowledge')
+const IMAGE_TEXT_INDEX_CACHE_DIR = path.join(app.getPath('userData'), 'image-text-index')
export interface CacheClearOptions {
beforeClearKnowledge?: () => Promise
+ /** 清理图片文字索引前调用:停任务 + 关闭派生库句柄。 */
+ beforeClearImageTextIndex?: () => Promise
}
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
@@ -46,6 +49,7 @@ export function getCacheSummary(): CacheSummary {
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR)
+ const imageTextIndex = inspectDirectory(IMAGE_TEXT_INDEX_CACHE_DIR)
const items: CacheSummaryItem[] = [
{
id: 'bootstrap',
@@ -65,6 +69,13 @@ export function getCacheSummary(): CacheSummary {
description:
'为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
...knowledge
+ },
+ {
+ id: 'image-text-index',
+ label: '图片文字索引',
+ description:
+ '本机从微信图片里识别出的文字及其检索索引。清理后无法搜索图片中的文字,可重新建立;不影响微信原始图片与聊天记录。',
+ ...imageTextIndex
}
]
return {
@@ -89,6 +100,11 @@ export async function clearCache(
await options.beforeClearKnowledge?.()
await fs.remove(KNOWLEDGE_CACHE_DIR)
}
+ if (scope === 'image-text-index' || scope === 'all') {
+ // 先停下任务再删库,避免"边写边删"。
+ await options.beforeClearImageTextIndex?.()
+ await fs.remove(IMAGE_TEXT_INDEX_CACHE_DIR)
+ }
return getCacheSummary()
}
diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts
index eb57569..bf7ed00 100644
--- a/src/main/services/chat-service.ts
+++ b/src/main/services/chat-service.ts
@@ -16,6 +16,7 @@ import {
} from '../../shared/windows-runtime'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
+import type { ImageMessageCountProbe } from '../../shared/image-text-index'
import { wcdbDebugLog } from '../wcdb-debug'
import {
buildContactSearchIndex,
@@ -717,6 +718,34 @@ export async function listMessagesForExport(
* batch-selection view, where loading every conversation would make opening
* Settings noticeably slow.
*/
+/**
+ * 图片消息计数探针(SQL 统计,不解密)。
+ *
+ * 返回 `count: null` 表示**统计失败**,不是 0 张。调用方必须区分这两件事 ——
+ * 否则"数不出来"会被显示成"账号里没有图片",用户会因此放弃建立索引。
+ */
+export async function countImageMessagesAsync(
+ userMd5: string,
+ sinceMs?: number
+): Promise {
+ if (!dbRef) return { count: null, typeColumn: null, error: '微信数据库尚未就绪' }
+ return dbRef.getWcdb4Client().countImageMessagesAsync(userMd5, sinceMs)
+}
+
+/**
+ * 图片消息的增量水位(条数 + 最大插入序)。
+ *
+ * 增量索引**不能只比条数**:召回一张旧图的同时新增一张新图,条数不变但集合变了。
+ * 返回 null = 当前数据库不支持该统计(调用方须退化成"每轮重扫",宁可慢也不可漏)。
+ */
+export async function imageConversationWatermarkAsync(
+ userMd5: string,
+ sinceMs?: number
+): Promise<{ count: number; maxLocalId: number } | null> {
+ if (!dbRef) return null
+ return dbRef.getWcdb4Client().imageConversationWatermarkAsync(userMd5, sinceMs)
+}
+
export async function countVoiceMessagesAsync(
userMd5: string,
startTime?: number,
diff --git a/src/main/services/image-text-index-service.ts b/src/main/services/image-text-index-service.ts
new file mode 100644
index 0000000..ae879e7
--- /dev/null
+++ b/src/main/services/image-text-index-service.ts
@@ -0,0 +1,970 @@
+/**
+ * 图片文字索引编排服务。
+ *
+ * 职责边界(刻意保持单一):
+ * - 快速统计图片消息数(SQL,**绝不解密**)
+ * - 按会话 + 批次驱动 OCR;**严格串行(concurrency = 1)**:循环体内只有一次
+ * `await`,不存在 Promise.all 扇出,且每批之间让出 event loop
+ * - 维护 checkpoint(可暂停 / 继续 / 取消 / 重启后恢复)
+ * - 把结果写进派生库,并在**会话完成时**回调,让 Knowledge 重建该会话的索引
+ *
+ * 明确不做:
+ * - 不修改 WCDB / 不写回原始消息 / 不产生任何"OCR 消息"
+ * - 不实现图片搜索 Agent(检索继续走既有 Query Agent + Knowledge)
+ * - 不在日志里写 OCR 正文 / 真实图片路径 / wxid / 群名
+ */
+import { createHash } from 'node:crypto'
+import { existsSync } from 'node:fs'
+import {
+ IMAGE_TEXT_INDEX_BATCH_SIZE,
+ IMAGE_TEXT_INDEX_ENGINE,
+ IMAGE_OCR_RETRIABLE_FAILURE_STATES,
+ buildImageOcrArtifactKey,
+ imageTextProcessedPercent,
+ isTerminalImageOcrState,
+ type ImageMessageCountProbe,
+ type ImageMessageWatermark,
+ type ImageOcrPersistedState,
+ type ImageOcrProvenance,
+ type ImageTextIndexCountResult,
+ type ImageTextIndexCoverage,
+ type ImageTextIndexProgress,
+ type ImageTextIndexRepairResult,
+ type ImageTextIndexRunState,
+ type ImageTextIndexStartOptions,
+ type ImageTextIndexStatus,
+ type ImageTextIndexStorageStats
+} from '../../shared/image-text-index'
+import { detectSystemOcrImageFormat, type SystemOcrCapability } from '../../shared/system-ocr'
+import type * as chat from './chat-service'
+/**
+ * 消息 id 必须与 Knowledge 写入的 `knowledge_messages.message_id` 完全一致,
+ * 否则 OCR 文本贴不到消息上、Evidence 也回不到原图。真源见 knowledge/message-identity。
+ */
+import { sourceMessageId } from '../knowledge/message-identity'
+import type { ImageDecryptService } from '../image-decrypt-service'
+import {
+ ImageTextIndexStore,
+ getImageTextIndexDatabasePath,
+ removeImageTextIndexDatabase,
+ type ConversationImageOcrEntry
+} from './image-text-index-store'
+
+/** 图片消息的数据 URL 前缀。 */
+const MIME_BY_FORMAT: Record = {
+ png: 'image/png',
+ jpeg: 'image/jpeg',
+ gif: 'image/gif',
+ bmp: 'image/bmp',
+ webp: 'image/webp',
+ tiff: 'image/tiff'
+}
+
+export interface ImageTextIndexServiceDeps {
+ /** `/image-text-index`。 */
+ databaseRoot?: string
+ /** 当前账号(wxid 优先,退回 accountRoot)。空串 = 微信未就绪。 */
+ resolveAccountId?: () => string
+ /** 当前微信数据根目录。 */
+ resolveAccountRoot?: () => string
+ listContacts?: () => Promise>
+ listMessages?: (conversationId: string) => Promise
+ /**
+ * 单个会话的图片消息计数探针(SQL 统计,不解密)。
+ *
+ * `count: null` = 统计失败,**不等于 0 张**;调用方必须区分。
+ */
+ countConversationImages?: (
+ conversationId: string,
+ sinceMs?: number
+ ) => Promise
+ /**
+ * 单个会话的图片消息增量水位(条数 + 最大插入序),SQL 聚合,不解密。
+ *
+ * 返回 null = 当前数据库不支持(调用方必须退化成"每轮重扫",宁可慢也不可漏)。
+ */
+ imageWatermark?: (conversationId: string, sinceMs?: number) => Promise
+ decryptService?: () => ImageDecryptService | null
+ /** 本地 OCR。 */
+ recognize?: (imageDataUrl: string) => Promise<{
+ success: boolean
+ text: string
+ language: string | null
+ errorCode?: string
+ }>
+ capability?: () => Promise
+ /** 会话图片全部处理完后回调,用于把 OCR 文本灌进 Knowledge 索引。 */
+ onConversationIndexed?: (conversationId: string) => Promise
+ /** 交互查询让路钩子。 */
+ interactiveIdle?: () => Promise
+ now?: () => number
+}
+
+function sha256Short(value: Uint8Array | string): string {
+ return createHash('sha256').update(value).digest('hex').slice(0, 32)
+}
+
+/** 图片消息判定:与 chat-service 的 `contentData.type === 'image'` 对齐。 */
+export function isImageMessage(message: chat.FormattedMessage): boolean {
+ return message.contentData?.type === 'image'
+}
+
+export class ImageTextIndexService {
+ private deps: ImageTextIndexServiceDeps = {}
+ private store: ImageTextIndexStore | null = null
+ private storeAccountId = ''
+ private accountKey = ''
+ private running = false
+ private cancelRequested = false
+ private pauseRequested = false
+ private passPromise: Promise | null = null
+ private counting = false
+ private listeners = new Set<(status: ImageTextIndexStatus) => void>()
+ private lastError: string | undefined
+ private startedAt: number | undefined
+ /** 上一次清理实际重建(失效)了多少个会话的 Knowledge 索引;用于诊断与测试。 */
+ lastInvalidatedConversations = 0
+ /**
+ * 会话级 OCR 文本缓存。
+ *
+ * Knowledge 重建一个会话时会对每条消息问一次 resolver;不加缓存就是每条消息一次 SQL。
+ * 写入 binding 时精确失效该会话,保证不会读到旧结果。
+ */
+ private conversationOcrCache = new Map>()
+
+ /** 本轮 pass 的计数器(内存态;真实来源始终是派生库)。 */
+ private counters = {
+ totalImageMessages: 0,
+ processedThisPass: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0
+ }
+
+ private runState: ImageTextIndexRunState = 'idle'
+
+ bind(deps: ImageTextIndexServiceDeps): void {
+ this.deps = { ...this.deps, ...deps }
+ }
+
+ private now(): number {
+ return this.deps.now ? this.deps.now() : Date.now()
+ }
+
+ // ------------------------------------------------------------- store 生命周期
+
+ private resolveAccountId(): string {
+ return this.deps.resolveAccountId?.() || ''
+ }
+
+ private ensureStore(): ImageTextIndexStore | null {
+ const root = this.deps.databaseRoot
+ const accountId = this.resolveAccountId()
+ if (!root || !accountId) return null
+ if (this.store && this.storeAccountId === accountId) return this.store
+ this.store?.close()
+ const key = getImageTextIndexDatabasePath(root, accountId)
+ this.store = new ImageTextIndexStore(key, accountId)
+ this.storeAccountId = accountId
+ this.accountKey = key
+ return this.store
+ }
+
+ /**
+ * 账号切换 / 数据库切换时丢弃句柄。
+ *
+ * 派生库按 accountId 分目录,句柄必须跟着换;否则会把 A 账号的 OCR
+ * 写到 B 账号,或让新库读到旧账号的 coverage。
+ */
+ resetAccount(): void {
+ this.conversationOcrCache.clear()
+ this.store?.close()
+ this.store = null
+ this.storeAccountId = ''
+ this.accountKey = ''
+ this.counters = {
+ totalImageMessages: 0,
+ processedThisPass: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0
+ }
+ this.runState = 'idle'
+ this.cancelRequested = false
+ this.pauseRequested = false
+ this.lastError = undefined
+ }
+
+ // ------------------------------------------------------------------- 只读接口
+
+ /** Knowledge 索引时用:把某会话的 OCR 文本贴到消息上(与语音 resolver 同构)。 */
+ getConversationOcr(conversationId: string): Map {
+ const cached = this.conversationOcrCache.get(conversationId)
+ if (cached) return cached
+ const store = this.ensureStore()
+ if (!store) return new Map()
+ const result = store.getConversationOcr(conversationId)
+ this.conversationOcrCache.set(conversationId, result)
+ return result
+ }
+
+ /**
+ * 覆盖度。
+ *
+ * **分母只能来自落盘的 SQL 统计**,不能从派生库自己推:派生库只知道自己处理过什么。
+ * 如果按 `processed + pending` 反推 total,应用重启后 pending 无处可来,
+ * total 就会退化成 processed —— 30% 的部分索引会被谎报成"已覆盖全部"。
+ * 这正是 §18 禁止的"把 partial coverage 当 complete"。
+ */
+ private coverageFromCounts(counts: Record): ImageTextIndexCoverage {
+ const indexed = counts['indexed'] ?? 0
+ const empty = counts['empty'] ?? 0
+ const missing = (counts['image_missing'] ?? 0) + (counts['metadata_missing'] ?? 0)
+ const failed =
+ (counts['decrypt_failed'] ?? 0) +
+ (counts['decode_failed'] ?? 0) +
+ (counts['ocr_failed'] ?? 0) +
+ (counts['cancelled'] ?? 0)
+ const runtimeUnavailable = counts['decrypt_unavailable'] ?? 0
+ // 运行时不可用**不计入 processed**:它不是"这条图片已经处理过了"。
+ const processed = indexed + empty + missing + failed
+ const counted = this.store?.readCountedTotal() ?? null
+ // 内存计数器只在本轮 pass 内比落盘值更新(刚统计完、尚未落盘的窗口)。
+ const total =
+ this.counters.totalImageMessages || counted?.total || processed + runtimeUnavailable
+ /**
+ * 系统性失败:处理过一批,但一条都没能给出确定结果。
+ *
+ * 这正是本次事故的形态(45,479 张全部失败,成功 / 无文字 / 缺失都是 0)。
+ * 它必须阻断 `complete` —— 否则 Query Agent 会拿着"覆盖完整"去回答"没有"。
+ */
+ const systemicFailure = processed > 0 && indexed === 0 && empty === 0 && missing === 0
+ return {
+ totalImageMessages: total,
+ processed,
+ indexed,
+ empty,
+ missing,
+ failed,
+ runtimeUnavailable,
+ pending: Math.max(0, total - processed - runtimeUnavailable),
+ // 从未统计过总数 → 不算"已建立":不知道分母就不允许声称覆盖。
+ established: counted !== null && (processed > 0 || runtimeUnavailable > 0),
+ // 分母不完整、有 pending、有运行时不可用、或"全军覆没" → 都不算 complete。
+ complete:
+ counted !== null &&
+ counted.complete &&
+ total > 0 &&
+ runtimeUnavailable === 0 &&
+ !systemicFailure &&
+ processed >= total,
+ systemicFailure,
+ countedAt: counted?.countedAt ?? null
+ }
+ }
+
+ /**
+ * 覆盖度快照(只读、同步),供 Query Agent 在工具结果里携带图片覆盖度。
+ *
+ * 刻意**不建库**:只因为用户问了一句话就凭空创建一个派生库是没道理的。
+ * 库不存在 = 从未建立过索引 = `not_built`。
+ */
+ getCoverageSnapshot(): ImageTextIndexCoverage | null {
+ const root = this.deps.databaseRoot
+ const accountId = this.resolveAccountId()
+ if (!root || !accountId) return null
+ if (!this.store && !existsSync(getImageTextIndexDatabasePath(root, accountId))) {
+ return null
+ }
+ const store = this.ensureStore()
+ if (!store) return null
+ return this.coverageFromCounts(store.countByState())
+ }
+
+ private progressFromCounts(counts: Record): ImageTextIndexProgress {
+ const coverage = this.coverageFromCounts(counts)
+ const total = coverage.totalImageMessages
+ const percent = imageTextProcessedPercent(coverage.processed, total)
+ return {
+ state: this.runState,
+ totalImageMessages: total,
+ processed: coverage.processed,
+ indexed: coverage.indexed,
+ empty: coverage.empty,
+ missing: coverage.missing,
+ failed: coverage.failed,
+ runtimeUnavailable: coverage.runtimeUnavailable,
+ systemicFailure: coverage.systemicFailure,
+ pending: coverage.pending,
+ percent,
+ processedPercent: percent,
+ ...(this.startedAt ? { startedAt: this.startedAt } : {}),
+ updatedAt: this.now(),
+ cancellable: this.running,
+ paused: this.runState === 'paused',
+ ...(this.lastError ? { lastError: this.lastError } : {})
+ }
+ }
+
+ private emptyStorage(): ImageTextIndexStorageStats {
+ return { indexedImages: 0, ocrTextCount: 0, totalBytes: 0, updatedAt: null }
+ }
+
+ async getStatus(): Promise {
+ const store = this.ensureStore()
+ if (!store) {
+ return {
+ progress: {
+ state: this.runState,
+ totalImageMessages: this.counters.totalImageMessages,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ runtimeUnavailable: 0,
+ systemicFailure: false,
+ pending: 0,
+ percent: 0,
+ processedPercent: 0,
+ updatedAt: this.now(),
+ cancellable: false,
+ paused: false
+ },
+ coverage: {
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ runtimeUnavailable: 0,
+ pending: 0,
+ established: false,
+ complete: false,
+ systemicFailure: false,
+ countedAt: null
+ },
+ storage: this.emptyStorage(),
+ counting: this.counting
+ }
+ }
+ const counts = store.countByState()
+ return {
+ progress: this.progressFromCounts(counts),
+ coverage: this.coverageFromCounts(counts),
+ storage: store.storageStats(),
+ counting: this.counting
+ }
+ }
+
+ onStatusChange(listener: (status: ImageTextIndexStatus) => void): () => void {
+ this.listeners.add(listener)
+ return () => this.listeners.delete(listener)
+ }
+
+ private async emit(): Promise {
+ if (!this.listeners.size) return
+ const status = await this.getStatus()
+ for (const listener of this.listeners) {
+ try {
+ listener(status)
+ } catch {
+ // 监听器异常不得影响索引。
+ }
+ }
+ }
+
+ // --------------------------------------------------------------------- 统计
+
+ /**
+ * 快速统计当前账号的图片消息数。
+ *
+ * 走 SQL COUNT(`local_type & 65535 = 3`),**不解密任何图片** —— 这是
+ * 「点击索引前先告诉用户有多少张」能够足够快的前提。
+ */
+ async countImageMessages(sinceMs?: number): Promise {
+ const startedAt = this.now()
+ const contacts = await (this.deps.listContacts?.() ?? Promise.resolve([]))
+ let total = 0
+ let scanned = 0
+ let failed = 0
+ let typeColumn: string | null = null
+ let firstError: string | undefined
+ for (const contact of contacts) {
+ const probe = await (this.deps.countConversationImages?.(contact.md5, sinceMs) ??
+ Promise.resolve({
+ count: null,
+ typeColumn: null,
+ error: '未接入图片消息统计能力'
+ }))
+ if (probe.typeColumn && !typeColumn) typeColumn = probe.typeColumn
+ if (probe.count === null) {
+ // **统计失败不是 0 张**:必须单独计数,否则 UI 会把"数不出来"说成"没有图片"。
+ failed += 1
+ if (!firstError) firstError = probe.error
+ continue
+ }
+ scanned += 1
+ total += probe.count
+ }
+ this.counters.totalImageMessages = total
+ // 落盘:coverage 的分母必须能被重启后读到(见 coverageFromCounts)。
+ // 只要有一个会话没数上,分母就是偏小的 → 标记为不完整,coverage 拿不到 complete。
+ this.ensureStore()?.writeCountedTotal({
+ total,
+ countedAt: this.now(),
+ complete: contacts.length > 0 && failed === 0
+ })
+ return {
+ totalImageMessages: total,
+ scannedConversations: scanned,
+ failedConversations: failed,
+ typeColumn,
+ ...(firstError ? { error: firstError } : {}),
+ durationMs: this.now() - startedAt
+ }
+ }
+
+ // ------------------------------------------------------------------ 单张处理
+
+ private async processOne(
+ message: chat.FormattedMessage,
+ conversationId: string,
+ provenance: ImageOcrProvenance
+ ): Promise<{ state: ImageOcrPersistedState; text: string; imageIdentity: string | null }> {
+ const imageContent =
+ message.contentData?.type === 'image'
+ ? (message.contentData as { md5?: string; datName?: string })
+ : undefined
+
+ const decrypt = this.deps.decryptService?.() ?? null
+ /**
+ * 解密服务缺失是**运行时**问题,不是这张图片的问题。
+ *
+ * 本次事故就是它:`imageDecryptService` 只在用户点开某张图时才懒加载,
+ * 于是全量回填 45,479 张全部落成 `decrypt_failed` —— 数字看着像"图片坏了",
+ * 实际是流水线前置依赖没接上。这里必须用独立状态,绝不能与真正的解密失败混为一谈。
+ */
+ if (!decrypt) return { state: 'decrypt_unavailable', text: '', imageIdentity: null }
+
+ // 图片消息缺少定位字段:连"去哪找文件"都不知道,属消息侧缺失而非 OCR 失败。
+ if (!imageContent?.md5 && !imageContent?.datName) {
+ return { state: 'metadata_missing', text: '', imageIdentity: null }
+ }
+
+ let datPath: string | null = null
+ try {
+ datPath = decrypt.findImageFile(imageContent?.md5, imageContent?.datName, {
+ accountDir: this.deps.resolveAccountRoot?.() || undefined,
+ sessionMd5: conversationId,
+ createTime: message.createTime,
+ allowThumbnail: true,
+ preferThumbnail: true
+ })
+ } catch {
+ datPath = null
+ }
+ // 微信清理过原图与缩略图 —— 这是正常情况,不是任务级错误。
+ if (!datPath) return { state: 'image_missing', text: '', imageIdentity: null }
+
+ let bytes: Buffer | null = null
+ try {
+ bytes = decrypt.decryptImage(datPath)
+ } catch {
+ bytes = null
+ }
+ if (!bytes || bytes.length === 0) {
+ return { state: 'decrypt_failed', text: '', imageIdentity: null }
+ }
+
+ const imageIdentity = `sha256:${sha256Short(bytes)}`
+ const format = detectSystemOcrImageFormat(bytes)
+ // 解密"没抛错"但产出不是图片 → 解码失败,不是 OCR 失败。
+ if (!format) return { state: 'decode_failed', text: '', imageIdentity }
+
+ const store = this.ensureStore()
+ const artifactKey = buildImageOcrArtifactKey({ imageIdentity, provenance })
+
+ // 同一张图(可能被转发到多个会话)已经算过 → 直接复用,绝不重复 OCR。
+ const cached = store?.getArtifact(artifactKey) ?? null
+ if (cached && isTerminalImageOcrState(cached.state)) {
+ return { state: cached.state, text: cached.text, imageIdentity }
+ }
+
+ const mime = MIME_BY_FORMAT[format] ?? 'image/png'
+ let state: ImageOcrPersistedState = 'ocr_failed'
+ let text = ''
+ let errorCode: string | undefined
+ try {
+ const result = await (this.deps.recognize?.(`data:${mime};base64,${bytes.toString('base64')}`) ??
+ Promise.resolve({ success: false, text: '', language: null, errorCode: 'OCR_FAILED' }))
+ if (result.success && result.text.trim()) {
+ state = 'indexed'
+ text = result.text
+ } else if (result.success || result.errorCode === 'OCR_EMPTY_RESULT') {
+ // 表情包 / 风景 / 头像 —— 没有文字是**正常终态**,不重试。
+ state = 'empty'
+ } else {
+ state = 'ocr_failed'
+ errorCode = result.errorCode
+ }
+ } catch {
+ state = 'ocr_failed'
+ }
+
+ const now = this.now()
+ if (store) {
+ store.putArtifact({
+ accountId: this.storeAccountId,
+ artifactKey,
+ imageIdentity,
+ state,
+ text,
+ charCount: text.length,
+ engine: provenance.engine,
+ platform: provenance.platform,
+ runtimeVersion: provenance.runtimeVersion,
+ language: provenance.language,
+ ...(errorCode ? { errorCode } : {}),
+ createdAt: now,
+ updatedAt: now
+ })
+ }
+ return { state, text, imageIdentity }
+ }
+
+ // --------------------------------------------------------------------- pass
+
+ /**
+ * 启动一次 pass。
+ *
+ * 「暂停 / 继续」刻意实现为「停止 + 重新跑一次 pass」而不是原地挂起:
+ * - checkpoint(scan_state)与 artifact 缓存都在库里,重跑会跳过已完成会话、
+ * 并且命中 artifact 缓存不再重复 OCR,所以恢复成本很低;
+ * - 与项目既有的「中断后重跑、靠 checkpoint 续做」语义一致,不引入新的挂起状态机。
+ */
+ startPass(options: ImageTextIndexStartOptions = {}): { started: boolean; state: ImageTextIndexRunState } {
+ if (this.running) return { started: false, state: this.runState }
+ this.cancelRequested = false
+ this.pauseRequested = false
+ this.lastError = undefined
+ this.startedAt = this.now()
+ this.runState = 'running'
+ this.passPromise = this.runPass(options)
+ .catch((error) => {
+ this.lastError = error instanceof Error ? error.message : String(error)
+ this.runState = 'error'
+ })
+ .finally(() => {
+ this.running = false
+ this.passPromise = null
+ void this.emit()
+ })
+ void this.emit()
+ return { started: true, state: this.runState }
+ }
+
+ private async runPass(options: ImageTextIndexStartOptions): Promise {
+ const store = this.ensureStore()
+ if (!store) {
+ this.lastError = '微信数据尚未就绪'
+ this.runState = 'error'
+ return
+ }
+ this.running = true
+
+ const capability = (await this.deps.capability?.()) ?? null
+ if (capability && !capability.available) {
+ this.lastError = '当前系统不支持本地图片文字识别'
+ this.runState = 'error'
+ return
+ }
+ /**
+ * **前置依赖自检(本次事故的根因防线)**:解密服务必须可用。
+ *
+ * 没有它,每张图片都会在 `processOne` 的第一步失败。原实现会把"整条流水线
+ * 根本跑不起来"这件事落成 45,479 条 `decrypt_failed` —— 既污染派生库,
+ * 又让用户以为自己的图片坏了,还让 coverage 看起来"都处理完了"。
+ *
+ * 所以必须在**写任何一条记录之前**停下来:宁可一次都不跑,也不要写一堆假失败。
+ */
+ if (!this.deps.decryptService?.()) {
+ this.lastError = '图片解密服务尚未就绪,无法读取微信图片;本次未写入任何记录。'
+ this.runState = 'error'
+ return
+ }
+ const provenance: ImageOcrProvenance = {
+ engine: capability?.engine ?? IMAGE_TEXT_INDEX_ENGINE,
+ platform: capability?.platform ?? process.platform,
+ runtimeVersion: capability?.runtimeVersion ?? null,
+ language: capability?.language ?? null
+ }
+
+ // 统计一次总数(SQL),进度百分比才有真实分母。
+ this.counting = true
+ try {
+ await this.countImageMessages(options.sinceMs)
+ } finally {
+ this.counting = false
+ }
+
+ let contacts = await (this.deps.listContacts?.() ?? Promise.resolve([]))
+ if (options.conversationLimit && options.conversationLimit > 0) {
+ contacts = contacts.slice(0, options.conversationLimit)
+ }
+
+ const scanState = store.readScanState()
+ let budget = options.messageLimit && options.messageLimit > 0 ? options.messageLimit : Infinity
+
+ for (const contact of contacts) {
+ if (this.cancelRequested || this.pauseRequested) break
+ if (budget <= 0) break
+
+ const conversationId = contact.md5
+ /**
+ * 是否只处理一个时间窗口(用于小样本验证)。
+ *
+ * 带窗口时**不做增量跳过**:checkpoint 是围绕全量集合建立的,
+ * 窗口内的图片可能从未被处理过,继续按"该会话已完成"跳过会让窗口形同虚设。
+ */
+ const windowed = Boolean(options.sinceMs && options.sinceMs > 0)
+ // 增量水位 = 条数 + 最大插入序(§2)。只比条数会漏掉「撤回一张旧图 +
+ // 新增一张新图」这种总数不变、集合却变了的会话。
+ const watermark = await (this.deps.imageWatermark?.(conversationId, options.sinceMs) ??
+ Promise.resolve(null))
+ const imageTotal =
+ watermark?.count ??
+ (await this.deps.countConversationImages?.(conversationId, options.sinceMs))?.count ??
+ 0
+ if (imageTotal === 0) {
+ store.writeScanState({
+ conversationId,
+ state: 'done',
+ imageTotal: 0,
+ imageProcessed: 0,
+ maxLocalId: watermark?.maxLocalId ?? 0
+ })
+ continue
+ }
+
+ // 增量:会话已完成且**水位完全未变** → 不读 WCDB、不 OCR。
+ // 水位不可用时(数据库不支持该聚合)一律重扫:宁可慢,不可漏。
+ const previous = scanState.get(conversationId)
+ if (
+ !windowed &&
+ watermark &&
+ previous &&
+ previous.state === 'done' &&
+ previous.imageTotal === watermark.count &&
+ previous.maxLocalId === watermark.maxLocalId
+ ) {
+ continue
+ }
+
+ await this.deps.interactiveIdle?.()
+
+ let messages: chat.FormattedMessage[] = []
+ try {
+ messages = await (this.deps.listMessages?.(conversationId) ?? Promise.resolve([]))
+ } catch {
+ messages = []
+ }
+ const imageMessages = messages
+ .filter(isImageMessage)
+ // 时间窗过滤:小样本验证时只看窗口内的图片,不然还是在跑全量。
+ .filter((message) =>
+ windowed ? (message.createTime || 0) * 1000 >= (options.sinceMs as number) : true
+ )
+ if (!imageMessages.length) {
+ store.writeScanState({
+ conversationId,
+ state: 'done',
+ imageTotal: 0,
+ imageProcessed: 0,
+ maxLocalId: 0
+ })
+ continue
+ }
+ // 水位取**实际读到的**消息里最大的 local_id,而不是源侧水位:
+ // 万一在我们查水位之后、读消息之前又落了一条新图,用观测值会让下一轮
+ // 发现"源水位更高"从而重扫(安全);用源侧水位则会把它永久跳过(漏索引)。
+ const observedMaxLocalId = imageMessages.reduce(
+ (max, message) => Math.max(max, Number(message.localId) || 0),
+ 0
+ )
+
+ const ocrByMessage = store.getConversationOcr(conversationId)
+ let processedInConversation = 0
+ let interrupted = false
+
+ for (let index = 0; index < imageMessages.length; index += IMAGE_TEXT_INDEX_BATCH_SIZE) {
+ if (this.cancelRequested || this.pauseRequested) {
+ interrupted = true
+ break
+ }
+ const batch = imageMessages.slice(index, index + IMAGE_TEXT_INDEX_BATCH_SIZE)
+
+ for (const message of batch) {
+ if (budget <= 0) break
+ const messageId = sourceMessageId(message)
+
+ // 派生库里已有终态结果 → 复用(含"无文字"/"图片缺失"),不重复劳动。
+ const known = ocrByMessage.get(messageId)
+ if (known && isTerminalImageOcrState(known.state)) {
+ processedInConversation += 1
+ continue
+ }
+
+ const outcome = await this.processOne(message, conversationId, provenance)
+ const now = this.now()
+ store.putBinding({
+ accountId: this.storeAccountId,
+ conversationId,
+ messageId,
+ createTime: (message.createTime || 0) * 1000,
+ ...(message.senderId || message.from ? { senderId: message.senderId || message.from } : {}),
+ ...(message.isSender ? { senderName: '我' } : message.name ? { senderName: message.name } : {}),
+ imageIdentity: outcome.imageIdentity ?? '',
+ artifactKey: outcome.imageIdentity
+ ? buildImageOcrArtifactKey({ imageIdentity: outcome.imageIdentity, provenance })
+ : buildImageOcrArtifactKey({ imageIdentity: 'unavailable', provenance }),
+ state: outcome.state,
+ updatedAt: now
+ })
+
+ this.conversationOcrCache.delete(conversationId)
+ processedInConversation += 1
+ this.counters.processedThisPass += 1
+ if (outcome.state === 'indexed') this.counters.indexed += 1
+ else if (outcome.state === 'empty') this.counters.empty += 1
+ else if (outcome.state === 'image_missing') this.counters.missing += 1
+ else this.counters.failed += 1
+ budget -= 1
+ }
+
+ // 批次之间让出 event loop:交互查询 / UI 永远优先于后台历史 OCR。
+ await new Promise((resolve) => setImmediate(resolve))
+ await this.emit()
+ }
+
+ if (interrupted) {
+ store.writeScanState({
+ conversationId,
+ state: 'partial',
+ imageTotal: imageMessages.length,
+ imageProcessed: processedInConversation,
+ maxLocalId: observedMaxLocalId
+ })
+ break
+ }
+
+ store.writeScanState({
+ conversationId,
+ state: 'done',
+ imageTotal: imageMessages.length,
+ imageProcessed: processedInConversation,
+ maxLocalId: observedMaxLocalId
+ })
+
+ // 会话的图片都处理完了 → 让 Knowledge 重建这个会话,OCR 文本才可被搜索。
+ try {
+ await this.deps.onConversationIndexed?.(conversationId)
+ } catch {
+ // 索引回调失败不应中断 OCR:派生文本已经落库,下一遍还会再灌。
+ }
+ await this.emit()
+ }
+
+ if (this.cancelRequested) this.runState = 'cancelled'
+ else if (this.pauseRequested) this.runState = 'paused'
+ else this.runState = 'completed'
+ this.running = false
+ await this.emit()
+ }
+
+ // --------------------------------------------------------------- 控制接口
+
+ pause(): { paused: boolean; state: ImageTextIndexRunState } {
+ if (!this.running) return { paused: false, state: this.runState }
+ this.pauseRequested = true
+ return { paused: true, state: 'paused' }
+ }
+
+ resume(options: ImageTextIndexStartOptions = {}): { started: boolean; state: ImageTextIndexRunState } {
+ if (this.running) return { started: false, state: this.runState }
+ return this.startPass(options)
+ }
+
+ async cancel(): Promise<{ cancellable: boolean; cancelled: boolean }> {
+ if (!this.running) return { cancellable: false, cancelled: false }
+ this.cancelRequested = true
+ const pending = this.passPromise
+ if (pending) await pending.catch(() => undefined)
+ return { cancellable: true, cancelled: true }
+ }
+
+ isRunning(): boolean {
+ return this.running
+ }
+
+ // ------------------------------------------------------------------- 清理
+
+ /**
+ * 清理「图片文字索引能力」的全部派生数据。
+ *
+ * 只删本能力自己生成的东西:artifact(OCR 文本)、binding、checkpoint、库文件。
+ * 明确**不碰**:WCDB、微信图片、图片解密密钥、普通文字知识库、语音转写、聊天消息、Agent 配置。
+ */
+ async clear(): Promise<{ removed: boolean; removedBytes: number }> {
+ await this.cancel()
+ this.conversationOcrCache.clear()
+ const store = this.ensureStore()
+ /**
+ * **必须在删之前**记下受影响会话。
+ *
+ * OCR 派生文本已经通过 normalizer 进了 Knowledge 的 chunks / FTS。
+ * 只删派生库、不做这一步,用户执行「清理图片文字索引」之后**仍然能搜到图片里的文字** ——
+ * 那就等于"清理成功"是假的。硬条件:清理图片文字索引 ≠ 只删 OCR SQLite。
+ */
+ const affectedConversations = store?.conversationIdsWithOcr() ?? []
+ let removedBytes = 0
+ if (store) {
+ removedBytes = store.storageStats().totalBytes
+ store.clearDerivedData()
+ // 先折 WAL 再关连接,然后才允许删文件(见 store.close / removeImageTextIndexDatabase)。
+ store.close()
+ this.store = null
+ this.storeAccountId = ''
+ }
+ const databasePath = this.accountKey
+ this.accountKey = ''
+ const removal = databasePath
+ ? removeImageTextIndexDatabase(databasePath)
+ : { removed: true, leftovers: [] as string[] }
+ // 总数统计也一并作废:下次回到「未建立」时重新 COUNT(*),
+ // 否则 UI 会拿着一个已经没有任何派生数据支撑的旧分母。
+ this.counters = {
+ totalImageMessages: 0,
+ processedThisPass: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0
+ }
+ this.runState = 'idle'
+ this.startedAt = undefined
+ await this.emit()
+
+ /**
+ * 逐个重建**受影响的会话**,让 OCR 派生文本从 Knowledge 里消失。
+ *
+ * 刻意不做两件更省事但更糟的事:
+ * - 不清空整个 Knowledge(那会连普通文字消息的索引一起丢掉);
+ * - 不假装"删了文件就等于清理完成"(chunks/FTS 里还留着旧文字)。
+ *
+ * rebuilt 这里已经返回空(派生库已删、resolver 拿不到 OCR),所以重建出来的
+ * 会话副本天然不含 OCR 文本;`completeSnapshot` 会把旧的 chunk 一起替换掉。
+ */
+ let invalidatedConversations = 0
+ for (const conversationId of affectedConversations) {
+ try {
+ await this.deps.onConversationIndexed?.(conversationId)
+ invalidatedConversations += 1
+ } catch {
+ // 单个会话重建失败不应让清理整体失败:派生数据已经删了,
+ // 下一遍索引也会因为 resolver 返回空而自然收敛。
+ }
+ }
+ this.lastInvalidatedConversations = invalidatedConversations
+ /**
+ * 重建过程中 Knowledge 会通过 resolver 调 `getConversationOcr`,
+ * 那会把派生库**重新打开**(`ensureStore`)。清理完必须再收干净:
+ * 否则「清理成功」之后还留着一个空库句柄,Windows 上也会妨碍目录删除。
+ */
+ this.store?.close()
+ this.store = null
+ this.storeAccountId = ''
+ this.accountKey = ''
+ // `removed: false` = 文件仍被占用没删掉,必须如实上报,不能假装清理成功。
+ return { removed: removal.removed, removedBytes }
+ }
+
+ /**
+ * 缓存清理前先停下任务,并**把 Knowledge 里的 OCR 派生文本一起失效**。
+ *
+ * 直接复用 `clear()` 而不是只 `cancel()`:缓存清理(含"清理全部")同样会删掉派生库,
+ * 如果这里不顺手重建 Knowledge,用户会得到一个自相矛盾的状态 ——
+ * 「问问微信」里搜得到图片文字,但派生库明明已经没了。
+ */
+ async prepareForCacheClear(): Promise {
+ await this.clear()
+ }
+
+ /**
+ * 派生索引修复(Derived Index Repair)。
+ *
+ * 只重建 **L3(Knowledge 派生条目 / chunks / FTS)**,数据来源是已有的
+ * L2 binding + L1 artifact。**绝不**读原图、解密或调用 OCR 引擎 ——
+ * L1 是几万张图片堆出来的昂贵产物,修一个索引问题不该让它重算一遍。
+ *
+ * `ocrExecutions: 0` 不是"期望",而是这条路径的定义:类型上写死成字面量 0,
+ * 任何让它变成非 0 的改动都会直接编译失败。
+ */
+ async repairKnowledgeIndex(
+ options: { conversationLimit?: number } = {}
+ ): Promise {
+ const startedAt = this.now()
+ // 运行中不并发重建:pass 正在写 binding,同时重建会让 Knowledge 读到半程状态。
+ if (this.running) {
+ return { conversations: 0, ocrExecutions: 0, durationMs: 0, skipped: true }
+ }
+ const store = this.ensureStore()
+ if (!store) {
+ return { conversations: 0, ocrExecutions: 0, durationMs: this.now() - startedAt, skipped: false }
+ }
+ const limit = options.conversationLimit && options.conversationLimit > 0 ? options.conversationLimit : undefined
+ const conversationIds = limit
+ ? store.conversationIdsWithIndexedOcr().slice(0, limit)
+ : store.conversationIdsWithIndexedOcr()
+
+ let conversations = 0
+ for (const conversationId of conversationIds) {
+ try {
+ await this.deps.onConversationIndexed?.(conversationId)
+ conversations += 1
+ } catch {
+ // 单个会话重建失败不影响其余:修索引是尽力而为的廉价操作,可重试。
+ }
+ }
+ return {
+ conversations,
+ ocrExecutions: 0,
+ durationMs: this.now() - startedAt,
+ skipped: false
+ }
+ }
+
+ /**
+ * 重置**可重试的失败记录**(代码修好之后重跑用)。
+ *
+ * 刻意不做成"清空整个派生库":那会连已经成功的 OCR 记录一起丢掉,
+ * 用户要为此重新跑几万张图片。这里只删失败绑定 + 它们的 checkpoint,
+ * `indexed` / `empty` 一条不动,下一轮 pass 自然接上。
+ */
+ async resetRetriableFailures(): Promise<{ reset: number }> {
+ await this.cancel()
+ this.conversationOcrCache.clear()
+ const store = this.ensureStore()
+ if (!store) return { reset: 0 }
+ const reset = store.resetFailures([...IMAGE_OCR_RETRIABLE_FAILURE_STATES])
+ await this.emit()
+ return { reset }
+ }
+
+ /** 缓存清理前先停下任务,避免边删边写。 */
+}
+
+export const imageTextIndexService = new ImageTextIndexService()
diff --git a/src/main/services/image-text-index-store.ts b/src/main/services/image-text-index-store.ts
new file mode 100644
index 0000000..013b15f
--- /dev/null
+++ b/src/main/services/image-text-index-store.ts
@@ -0,0 +1,572 @@
+/**
+ * 图片文字索引的**派生存储**(与 Knowledge 派生库物理分离)。
+ *
+ * 为什么单独一个库而不是往 knowledge.sqlite 里加表:
+ * - 清理语义干净:整个能力 = 三个文件(.sqlite/-wal/-shm),删掉即可,不留残渣。
+ * - 零迁移风险:不动已发布的 knowledge schema(§26 要求升级不破坏既有派生库)。
+ * - 去重语义天然:artifact 按「图片内容 + OCR 运行时指纹」唯一,binding 承担多来源。
+ *
+ * 账号隔离与 Knowledge 一致:路径按 accountId 摘要分目录 + 库内 account_id 自证。
+ */
+import { createHash } from 'node:crypto'
+import { mkdirSync, rmSync, statSync, existsSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import {
+ IMAGE_TEXT_INDEX_SCHEMA_VERSION,
+ type ImageOcrArtifact,
+ type ImageOcrBinding,
+ type ImageOcrPersistedState,
+ type ImageTextIndexStorageStats
+} from '../../shared/image-text-index'
+
+const MAX_SAFE_ACCOUNT_SEGMENT = /^[a-f0-9]{32}$/
+
+function sha256Hex(value: string): string {
+ return createHash('sha256').update(value).digest('hex')
+}
+
+/** 派生库目录名不直接暴露 accountId。 */
+export function imageTextIndexAccountKey(accountId: string): string {
+ return sha256Hex(`image-text-index-account-v1:${accountId}`).slice(0, 32)
+}
+
+export function getImageTextIndexDatabasePath(databaseRoot: string, accountId: string): string {
+ const accountKey = imageTextIndexAccountKey(accountId)
+ if (!MAX_SAFE_ACCOUNT_SEGMENT.test(accountKey)) {
+ throw new Error('Invalid image text index account key')
+ }
+ return join(resolve(databaseRoot), accountKey, 'image-text-index.sqlite')
+}
+
+/**
+ * 精确删除三件套(与 Knowledge 的 removeKnowledgeDatabase 同构)。
+ *
+ * **删除后必须回验**:Windows 上只要还有句柄(WAL/SHM 未关干净、别的进程打开了库),
+ * `rmSync` 可能不报错却没真的删掉 —— 那就是"看似清理成功,实际没删"。
+ * 这里把没删掉的路径返回给调用方,让上层能如实报告失败,而不是假装成功。
+ */
+export function removeImageTextIndexDatabase(databasePath: string): {
+ removed: boolean
+ leftovers: string[]
+} {
+ const leftovers: string[] = []
+ for (const suffix of ['', '-wal', '-shm']) {
+ const target = `${databasePath}${suffix}`
+ if (!existsSync(target)) continue
+ try {
+ rmSync(target, { force: true })
+ } catch {
+ // 删除失败(典型原因是文件仍被占用)→ 由下面的回验兜住。
+ }
+ if (existsSync(target)) leftovers.push(target)
+ }
+ return { removed: leftovers.length === 0, leftovers }
+}
+
+function asRows(value: unknown): Record[] {
+ return Array.isArray(value) ? (value as Record[]) : []
+}
+
+function artifactFromRow(row: Record): ImageOcrArtifact {
+ return {
+ accountId: String(row.account_id),
+ artifactKey: String(row.artifact_key),
+ imageIdentity: String(row.image_identity),
+ state: String(row.state) as ImageOcrPersistedState,
+ text: String(row.text ?? ''),
+ charCount: Number(row.char_count ?? 0),
+ engine: String(row.engine),
+ platform: String(row.platform),
+ runtimeVersion: row.runtime_version ? String(row.runtime_version) : null,
+ language: row.language ? String(row.language) : null,
+ ...(row.error_code ? { errorCode: String(row.error_code) } : {}),
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at)
+ }
+}
+
+/** 会话内「消息 → OCR 文本」,供 Knowledge 索引时解析(与语音 resolver 同构)。 */
+export interface ConversationImageOcrEntry {
+ state: ImageOcrPersistedState
+ text: string
+}
+
+export class ImageTextIndexStore {
+ private readonly database: DatabaseSync
+
+ constructor(
+ private readonly databasePath: string,
+ private readonly accountId: string
+ ) {
+ mkdirSync(dirname(databasePath), { recursive: true })
+ this.database = new DatabaseSync(databasePath)
+ this.initialize()
+ }
+
+ private initialize(): void {
+ this.database.exec(`
+ PRAGMA journal_mode = WAL;
+ PRAGMA synchronous = NORMAL;
+ PRAGMA busy_timeout = 5000;
+ CREATE TABLE IF NOT EXISTS image_ocr_meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ ) STRICT;
+ CREATE TABLE IF NOT EXISTS image_ocr_artifacts (
+ artifact_key TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL,
+ image_identity TEXT NOT NULL,
+ state TEXT NOT NULL,
+ text TEXT NOT NULL,
+ char_count INTEGER NOT NULL DEFAULT 0,
+ engine TEXT NOT NULL,
+ platform TEXT NOT NULL,
+ runtime_version TEXT,
+ language TEXT,
+ error_code TEXT,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS image_ocr_artifacts_identity
+ ON image_ocr_artifacts (image_identity);
+ CREATE TABLE IF NOT EXISTS image_ocr_bindings (
+ conversation_id TEXT NOT NULL,
+ message_id TEXT NOT NULL,
+ account_id TEXT NOT NULL,
+ create_time INTEGER NOT NULL,
+ sender_id TEXT,
+ sender_name TEXT,
+ image_identity TEXT NOT NULL,
+ artifact_key TEXT NOT NULL,
+ state TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (conversation_id, message_id)
+ ) STRICT;
+ CREATE INDEX IF NOT EXISTS image_ocr_bindings_identity
+ ON image_ocr_bindings (image_identity);
+ CREATE INDEX IF NOT EXISTS image_ocr_bindings_state
+ ON image_ocr_bindings (state);
+ -- 每个会话的扫描 checkpoint:重启后据此跳过已完成的会话。
+ CREATE TABLE IF NOT EXISTS image_ocr_scan_state (
+ conversation_id TEXT PRIMARY KEY,
+ account_id TEXT NOT NULL,
+ state TEXT NOT NULL,
+ image_total INTEGER NOT NULL DEFAULT 0,
+ image_processed INTEGER NOT NULL DEFAULT 0,
+ image_max_local_id INTEGER NOT NULL DEFAULT 0,
+ updated_at INTEGER NOT NULL
+ ) STRICT;
+ `)
+
+ // 探测式加列(与 Knowledge 一致):旧库缺列时补上,不做版本号比较。
+ const bindingColumns = new Set(
+ asRows(this.database.prepare('PRAGMA table_info(image_ocr_bindings)').all()).map((row) =>
+ String(row.name)
+ )
+ )
+ if (!bindingColumns.has('artifact_key')) {
+ this.database.exec(
+ "ALTER TABLE image_ocr_bindings ADD COLUMN artifact_key TEXT NOT NULL DEFAULT ''"
+ )
+ }
+
+ const scanColumns = new Set(
+ asRows(this.database.prepare('PRAGMA table_info(image_ocr_scan_state)').all()).map((row) =>
+ String(row.name)
+ )
+ )
+ if (!scanColumns.has('image_max_local_id')) {
+ // 旧库补列后默认 0:等于「水位未知」,下一次 pass 会重扫该会话并写入真实水位。
+ this.database.exec(
+ 'ALTER TABLE image_ocr_scan_state ADD COLUMN image_max_local_id INTEGER NOT NULL DEFAULT 0'
+ )
+ }
+
+ const storedAccount = this.readMeta('account_id')
+ if (storedAccount && storedAccount !== this.accountId) {
+ throw new Error('Image text index account isolation check failed')
+ }
+ if (!storedAccount) this.writeMeta('account_id', this.accountId)
+ if (!this.readMeta('schema_version')) {
+ this.writeMeta('schema_version', String(IMAGE_TEXT_INDEX_SCHEMA_VERSION))
+ }
+ }
+
+ private readMeta(key: string): string | null {
+ const row = this.database
+ .prepare('SELECT value FROM image_ocr_meta WHERE key = ?')
+ .get(key) as Record | undefined
+ return row ? String(row.value) : null
+ }
+
+ private writeMeta(key: string, value: string): void {
+ this.database
+ .prepare(
+ 'INSERT INTO image_ocr_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
+ )
+ .run(key, value)
+ }
+
+ // ---------------------------------------------------------------- artifacts
+
+ getArtifact(artifactKey: string): ImageOcrArtifact | null {
+ const row = this.database
+ .prepare('SELECT * FROM image_ocr_artifacts WHERE artifact_key = ?')
+ .get(artifactKey) as Record | undefined
+ return row ? artifactFromRow(row) : null
+ }
+
+ putArtifact(artifact: ImageOcrArtifact): void {
+ if (artifact.accountId !== this.accountId) {
+ throw new Error('Image OCR artifact account does not match database')
+ }
+ const existing = this.getArtifact(artifact.artifactKey)
+ this.database
+ .prepare(
+ `INSERT INTO image_ocr_artifacts (
+ artifact_key, account_id, image_identity, state, text, char_count,
+ engine, platform, runtime_version, language, error_code, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(artifact_key) DO UPDATE SET
+ state = excluded.state,
+ text = excluded.text,
+ char_count = excluded.char_count,
+ runtime_version = excluded.runtime_version,
+ language = excluded.language,
+ error_code = excluded.error_code,
+ updated_at = excluded.updated_at`
+ )
+ .run(
+ artifact.artifactKey,
+ artifact.accountId,
+ artifact.imageIdentity,
+ artifact.state,
+ artifact.text,
+ artifact.charCount,
+ artifact.engine,
+ artifact.platform,
+ artifact.runtimeVersion,
+ artifact.language,
+ artifact.errorCode ?? null,
+ existing?.createdAt ?? artifact.createdAt,
+ artifact.updatedAt
+ )
+ }
+
+ // ----------------------------------------------------------------- bindings
+
+ /** 写入绑定;同一 OCR 结果可被多个会话/消息引用。 */
+ putBinding(binding: ImageOcrBinding): void {
+ if (binding.accountId !== this.accountId) {
+ throw new Error('Image OCR binding account does not match database')
+ }
+ this.database
+ .prepare(
+ `INSERT INTO image_ocr_bindings (
+ conversation_id, message_id, account_id, create_time, sender_id, sender_name,
+ image_identity, artifact_key, state, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(conversation_id, message_id) DO UPDATE SET
+ create_time = excluded.create_time,
+ sender_id = excluded.sender_id,
+ sender_name = excluded.sender_name,
+ image_identity = excluded.image_identity,
+ artifact_key = excluded.artifact_key,
+ state = excluded.state,
+ updated_at = excluded.updated_at`
+ )
+ .run(
+ binding.conversationId,
+ binding.messageId,
+ binding.accountId,
+ binding.createTime,
+ binding.senderId ?? null,
+ binding.senderName ?? null,
+ binding.imageIdentity,
+ binding.artifactKey,
+ binding.state,
+ binding.updatedAt
+ )
+ }
+
+ /**
+ * 某会话的「消息 → OCR 文本」映射。
+ *
+ * 与语音的 `withVoiceTranscript` 同构:在**主进程**把派生文本贴到消息上,
+ * 再交给 Knowledge 索引,派生库不需要被 worker 打开。
+ */
+ getConversationOcr(
+ conversationId: string
+ ): Map {
+ const rows = asRows(
+ this.database
+ .prepare(
+ `SELECT b.message_id AS message_id, b.state AS binding_state,
+ a.state AS artifact_state, a.text AS text
+ FROM image_ocr_bindings b
+ LEFT JOIN image_ocr_artifacts a ON a.artifact_key = b.artifact_key
+ WHERE b.conversation_id = ?`
+ )
+ .all(conversationId)
+ )
+ const result = new Map()
+ for (const row of rows) {
+ result.set(String(row.message_id), {
+ state: String(row.artifact_state || row.binding_state) as ImageOcrPersistedState,
+ text: String(row.text ?? '')
+ })
+ }
+ return result
+ }
+
+ // -------------------------------------------------------------- checkpoint
+
+ /**
+ * 已持久化的图片消息总数统计。
+ *
+ * 必须落盘:派生库只知道自己**处理过**什么,不知道源数据里**一共**有多少图片。
+ * 一旦把这个 total 只放在内存里,应用重启后 coverage 就会退化成
+ * 「processed / processed」→ 把 30% 的部分索引谎报成 100% 完整覆盖。
+ */
+ readCountedTotal(): { total: number; countedAt: number; complete: boolean } | null {
+ const total = this.readMeta('total_image_messages')
+ const countedAt = this.readMeta('total_image_counted_at')
+ if (total === null || countedAt === null) return null
+ const parsedTotal = Number(total)
+ const parsedCountedAt = Number(countedAt)
+ if (!Number.isFinite(parsedTotal) || !Number.isFinite(parsedCountedAt)) return null
+ return {
+ total: parsedTotal,
+ countedAt: parsedCountedAt,
+ // 统计时若有会话没数上(数据库不支持该统计),分母就是偏小的 →
+ // 绝不能据此声称"已覆盖全部",否则少数的那些会话会被静默算进"已覆盖"。
+ complete: this.readMeta('total_image_messages_complete') === '1'
+ }
+ }
+
+ writeCountedTotal(input: { total: number; countedAt: number; complete: boolean }): void {
+ this.writeMeta('total_image_messages', String(input.total))
+ this.writeMeta('total_image_counted_at', String(input.countedAt))
+ this.writeMeta('total_image_messages_complete', input.complete ? '1' : '0')
+ }
+
+ readScanState(): Map<
+ string,
+ { state: string; imageTotal: number; processed: number; maxLocalId: number }
+ > {
+ const rows = asRows(
+ this.database
+ .prepare(
+ 'SELECT conversation_id, state, image_total, image_processed, image_max_local_id FROM image_ocr_scan_state'
+ )
+ .all()
+ )
+ const map = new Map<
+ string,
+ { state: string; imageTotal: number; processed: number; maxLocalId: number }
+ >()
+ for (const row of rows) {
+ map.set(String(row.conversation_id), {
+ state: String(row.state),
+ imageTotal: Number(row.image_total ?? 0),
+ processed: Number(row.image_processed ?? 0),
+ maxLocalId: Number(row.image_max_local_id ?? 0)
+ })
+ }
+ return map
+ }
+
+ writeScanState(input: {
+ conversationId: string
+ state: 'done' | 'partial'
+ imageTotal: number
+ imageProcessed: number
+ /** 本会话图片消息的最大插入序(增量水位)。 */
+ maxLocalId: number
+ }): void {
+ this.database
+ .prepare(
+ `INSERT INTO image_ocr_scan_state (
+ conversation_id, account_id, state, image_total, image_processed,
+ image_max_local_id, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(conversation_id) DO UPDATE SET
+ state = excluded.state,
+ image_total = excluded.image_total,
+ image_processed = excluded.image_processed,
+ image_max_local_id = excluded.image_max_local_id,
+ updated_at = excluded.updated_at`
+ )
+ .run(
+ input.conversationId,
+ this.accountId,
+ input.state,
+ input.imageTotal,
+ input.imageProcessed,
+ input.maxLocalId,
+ Date.now()
+ )
+ }
+
+ // ------------------------------------------------------------------ 统计
+
+ /**
+ * 有 OCR 派生绑定的会话集合。
+ *
+ * 清理时**必须**先拿到它:OCR 文本早已被灌进 Knowledge 的 chunks / FTS,
+ * 只删派生病不会让那些派生文字失效 —— 用户仍会从旧索引里搜到图片里的文字。
+ */
+ conversationIdsWithOcr(): string[] {
+ const rows = asRows(
+ this.database.prepare('SELECT DISTINCT conversation_id FROM image_ocr_bindings').all()
+ )
+ return rows.map((row) => String(row.conversation_id))
+ }
+
+ /**
+ * **有 OCR 派生文本**(artifact 里 char_count > 0)的会话集合。
+ *
+ * 派生索引修复只需要重建它们:只有这些会话的 Knowledge 里"应该"存在图片派生文字。
+ * 全是 `empty` 的会话本来就没有派生文字可修,重建它们只是白读一遍 WCDB。
+ */
+ conversationIdsWithIndexedOcr(): string[] {
+ const rows = asRows(
+ this.database
+ .prepare(
+ `SELECT DISTINCT b.conversation_id AS conversation_id
+ FROM image_ocr_bindings b
+ JOIN image_ocr_artifacts a ON a.artifact_key = b.artifact_key
+ WHERE a.char_count > 0`
+ )
+ .all()
+ )
+ return rows.map((row) => String(row.conversation_id))
+ }
+
+ /**
+ * 重置指定状态的失败记录,让它们可以被下一轮 pass 重新处理。
+ *
+ * 用途:**代码修好后**,把上一次 bug 造成的假失败(例如整批 `decrypt_failed`)
+ * 变成可重试状态,而不是要求用户删掉整个派生库 —— 那会连已经成功的记录一起丢掉。
+ *
+ * 三件事一起做,缺一不可:
+ * 1. 删掉这些失败绑定;
+ * 2. 删掉它们所在会话的 checkpoint —— 否则 pass 会以"该会话已完成"直接跳过,
+ * 表现为"点了重试但什么都没发生";
+ * 3. 删掉因此变成孤儿的 artifact(**没有任何绑定再引用的**才删,成功记录一条不动)。
+ */
+ resetFailures(states: ImageOcrPersistedState[]): number {
+ if (!states.length) return 0
+ const placeholders = states.map(() => '?').join(', ')
+ const affected = asRows(
+ this.database
+ .prepare(
+ `SELECT DISTINCT conversation_id FROM image_ocr_bindings WHERE state IN (${placeholders})`
+ )
+ .all(...states)
+ ).map((row) => String(row.conversation_id))
+ const artifactKeys = asRows(
+ this.database
+ .prepare(
+ `SELECT DISTINCT artifact_key FROM image_ocr_bindings WHERE state IN (${placeholders})`
+ )
+ .all(...states)
+ ).map((row) => String(row.artifact_key))
+
+ const info = this.database
+ .prepare(`DELETE FROM image_ocr_bindings WHERE state IN (${placeholders})`)
+ .run(...states)
+
+ const clearScan = this.database.prepare(
+ 'DELETE FROM image_ocr_scan_state WHERE conversation_id = ?'
+ )
+ for (const conversationId of affected) clearScan.run(conversationId)
+
+ const dropOrphan = this.database.prepare(
+ `DELETE FROM image_ocr_artifacts
+ WHERE artifact_key = ?
+ AND NOT EXISTS (SELECT 1 FROM image_ocr_bindings b WHERE b.artifact_key = ?)`
+ )
+ for (const artifactKey of artifactKeys) dropOrphan.run(artifactKey, artifactKey)
+
+ return Number(info.changes ?? 0)
+ }
+
+ /** 按状态聚合绑定数 —— 覆盖度与进度都从这里取,保证与库内真实一致。 */
+ countByState(): Record { const rows = asRows(
+ this.database
+ .prepare('SELECT state, COUNT(*) AS total FROM image_ocr_bindings GROUP BY state')
+ .all()
+ )
+ const counts: Record = {}
+ for (const row of rows) counts[String(row.state)] = Number(row.total ?? 0)
+ return counts
+ }
+
+ storageStats(): ImageTextIndexStorageStats {
+ const counts = this.countByState()
+ const textRow = this.database
+ .prepare(
+ "SELECT COUNT(*) AS total FROM image_ocr_artifacts WHERE state = 'indexed' AND length(text) > 0"
+ )
+ .get() as Record | undefined
+ const updatedRow = this.database
+ .prepare('SELECT MAX(updated_at) AS latest FROM image_ocr_bindings')
+ .get() as Record | undefined
+ let totalBytes = 0
+ for (const suffix of ['', '-wal', '-shm']) {
+ try {
+ totalBytes += statSync(`${this.databasePath}${suffix}`).size
+ } catch {
+ // 文件可能尚未创建;忽略。
+ }
+ }
+ const latest = updatedRow?.latest
+ return {
+ indexedImages: counts['indexed'] ?? 0,
+ ocrTextCount: Number(textRow?.total ?? 0),
+ totalBytes,
+ updatedAt: latest === null || latest === undefined ? null : Number(latest)
+ }
+ }
+
+ /** 清空全部派生数据(表级清空;文件级删除由 service 负责)。 */
+ clearAll(): void {
+ this.database.exec(`
+ DELETE FROM image_ocr_bindings;
+ DELETE FROM image_ocr_artifacts;
+ DELETE FROM image_ocr_scan_state;
+ DELETE FROM image_ocr_meta WHERE key IN (
+ 'total_image_messages',
+ 'total_image_counted_at',
+ 'total_image_messages_complete'
+ );
+ `)
+ }
+
+ /**
+ * 清空并重置检查点。
+ *
+ * 注意:必须同时清 `scan_state`,否则清理后再次索引会因为「会话已完成」
+ * 而直接跳过 —— UI 会停在「未建立」但实际再也不跑。
+ */
+ clearDerivedData(): void {
+ this.clearAll()
+ }
+
+ close(): void {
+ try {
+ // 先折叠 WAL 再关连接:否则 -wal / -shm 可能仍被持有,
+ // Windows 上会导致后续 rmSync 静默失败("清理成功"但文件还在)。
+ this.database.exec('PRAGMA wal_checkpoint(TRUNCATE);')
+ } catch {
+ // 库可能已经处于不可写状态;关闭仍然要做。
+ }
+ try {
+ this.database.close()
+ } catch {
+ // best effort
+ }
+ }
+}
diff --git a/src/main/services/local-query-api-service.ts b/src/main/services/local-query-api-service.ts
index 015361b..156e7a2 100644
--- a/src/main/services/local-query-api-service.ts
+++ b/src/main/services/local-query-api-service.ts
@@ -1,5 +1,6 @@
import { listContactsAsync, listMessagesAsync, isReady, type FormattedContact, type FormattedMessage } from './chat-service'
import { resolveContact } from './contact-resolution-service'
+import { sourceMessageId } from '../knowledge/message-identity'
import type { KnowledgeSearchService } from '../knowledge/knowledge-search-service'
import { inferAiSearchTimeRange } from '../../shared/ai-search'
import { KNOWLEDGE_FRESHNESS_TOLERANCE_MS } from '../../shared/knowledge'
@@ -13,6 +14,7 @@ import type {
ResolvedCorpusScope,
ResolvedTimeRange,
QueryIndexCoverage,
+ QueryImageTextCoverage,
QuerySearchTimings,
QueryMessagesRequest,
SearchMessagesRequest,
@@ -26,6 +28,12 @@ import {
decodeMessageRef as fromRef,
normalizeMessageIdentity
} from '../../shared/local-query-api'
+import {
+ describeImageTextCoverage,
+ imageTextCoverageState,
+ type ImageTextIndexCoverage
+} from '../../shared/image-text-index'
+import { toEvidenceDisplayText } from '../../shared/knowledge'
const LIMIT_MAX = 200
const CONTEXT_MAX = 50
@@ -112,6 +120,47 @@ function buildIndexCoverage(
}
}
+/**
+ * 图片文字索引未完成时,必须附加的零结果诚实性约束。
+ *
+ * 图片 OCR 是**独立的**覆盖维度:它可能是"未建立"或"只做了 30%"。
+ * 此时 0 条图片证据只是**索引缺口**,不是**事实空缺**。
+ */
+const IMAGE_OCR_ZERO_RESULT_CAUTION =
+ '涉及图片、截图、海报里的文字的问题,当前不能因为没搜到就回答"没有"。'
+
+/**
+ * 图片文字索引覆盖度 → 可直接引用的结论句。
+ *
+ * 与 `buildIndexCoverage` 同思路:只给结构化数字,模型会自己换算、甚至反过来
+ * 宣称"覆盖完整"。这里由 Engine 给出结论句,模型只需引用。
+ */
+export function buildImageOcrCoverage(
+ coverage: ImageTextIndexCoverage | null
+): QueryImageTextCoverage | undefined {
+ if (!coverage) return undefined
+ const state = imageTextCoverageState(coverage)
+ const countedNote = coverage.countedAt
+ ? `(图片数量统计于 ${formatLocalMinute(coverage.countedAt)})`
+ : ''
+ const base = describeImageTextCoverage(coverage)
+ return {
+ state,
+ totalImageMessages: coverage.totalImageMessages,
+ processed: coverage.processed,
+ indexed: coverage.indexed,
+ empty: coverage.empty,
+ missing: coverage.missing,
+ failed: coverage.failed,
+ pending: coverage.pending,
+ ...(coverage.countedAt ? { countedAtLabel: formatLocalMinute(coverage.countedAt) } : {}),
+ summary:
+ state === 'complete'
+ ? `${base}${countedNote}`
+ : `${base}${countedNote}${IMAGE_OCR_ZERO_RESULT_CAUTION}`
+ }
+}
+
const KIND_LABELS: Record = {
text: '文本',
image: '图片',
@@ -149,7 +198,19 @@ function resolvedTimeRange(input: QueryTimeRange, now = new Date()): ResolvedTim
const range = inferAiSearchTimeRange(phrase[input.kind], map[input.kind], now)
return { kind: input.kind, startTime: range.startTime, endTime: range.endTime, label: range.label }
}
-function toQueryMessage(conversationId: string, message: FormattedMessage, target: FormattedContact): QueryMessage {
+/**
+ * 一条消息的展示形态。
+ *
+ * `imageOcr` 是可选的**派生文本**(来自本地图片文字索引,只读、不触发 OCR)。
+ * 图片消息的正文永远是空的 —— 识别出的文字必须走独立字段,
+ * 否则"图片里的文字"会被伪装成"群友发的文字消息"。
+ */
+function toQueryMessage(
+ conversationId: string,
+ message: FormattedMessage,
+ target: FormattedContact,
+ imageOcr?: { state: string; text: string }
+): QueryMessage {
const kind = kindOf(message)
const content = message.contentData
const attachment =
@@ -163,7 +224,16 @@ function toQueryMessage(conversationId: string, message: FormattedMessage, targe
? { kind: 'file' as const, name: message.exportMediaName || (content?.type === 'share' ? content.title : undefined), url: content?.type === 'share' ? content.url : undefined }
: undefined
const text = message.content?.trim() || message.voiceTranscript?.trim() || undefined
- return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}) }
+ const derived = kind === 'image' ? imageOcr : undefined
+ const ocrText = derived && derived.state === 'indexed' ? derived.text.trim() : ''
+ const imageTextState: QueryMessage['imageTextState'] = !derived
+ ? 'not_indexed'
+ : ocrText
+ ? 'indexed'
+ : derived.state === 'empty'
+ ? 'empty'
+ : 'not_indexed'
+ return { messageRef: toRef(conversationId, message.id), timestamp: (message.createTime || 0) * 1000, datetime: message.datetime, sender: message.isSender ? '我' : (message.name || target.m_nsNickName), direction: message.isSender ? 'to_target' : 'from_target', messageType: kind, sourceKind: kind, ...(attachment ? { attachment } : {}), ...(text ? { text } : {}), ...(ocrText ? { imageOcrText: ocrText, derivedSource: 'image_ocr' as const } : {}), ...(kind === 'image' ? { imageTextState } : {}) }
}
/**
@@ -216,7 +286,49 @@ function outsideScopeError(scope: ResolvedCorpusScope, actual: string): { status
}
export class LocalQueryApiService {
- constructor(private readonly knowledge?: KnowledgeSearchService, private readonly nowProvider: () => Date = () => new Date()) {}
+ /**
+ * 图片文字索引覆盖度提供者(同步、只读)。
+ *
+ * 刻意不在 Knowledge worker 里算:OCR 派生库(`image-text-index.sqlite`)与
+ * `knowledge.sqlite` 物理分离,worker 不该为了一个覆盖度数字去开它。
+ */
+ private imageTextCoverage: () => ImageTextIndexCoverage | null = () => null
+
+ /**
+ * 单条图片消息的 OCR 派生文本提供者(同步、只读)。
+ *
+ * L4(查询层)**只读** L1(OCR artifact)—— 这里绝不允许触发 OCR、解密或读原图。
+ * 之前 `query_messages` 缺这一环,导致"图片已经识别出文字"这件事在精确读消息
+ * 这条路径上完全不可见:模型只拿到一个空的 `attachment`,于是把"索引缺口"
+ * 说成"图片里没有文字",甚至反过来建议用户去建立已经建好的索引。
+ */
+ private imageOcrEntry:
+ | ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
+ | undefined
+
+ constructor(
+ private readonly knowledge?: KnowledgeSearchService,
+ private readonly nowProvider: () => Date = () => new Date()
+ ) {}
+
+ /**
+ * 注入图片文字索引覆盖度提供者。
+ *
+ * 用 setter 而不是构造参数:避免给第二个带默认值的参数写 `undefined` 占位,
+ * 也让测试可以直接注入假的覆盖度。
+ */
+ setImageTextCoverageProvider(provider: () => ImageTextIndexCoverage | null): void {
+ this.imageTextCoverage = provider
+ }
+
+ /** 注入单条图片消息的 OCR 派生文本解析器(只读;见 `imageOcrEntry` 的约束)。 */
+ setImageOcrEntryProvider(
+ provider:
+ | ((conversationId: string, messageId: string) => { state: string; text: string } | undefined)
+ | undefined
+ ): void {
+ this.imageOcrEntry = provider
+ }
capabilities(): QueryCapabilitiesResponse {
return { version: 1, tools: { query_messages: { operation: '读取指定联系人的确定性消息', directions: ['any', 'from_target', 'to_target'], messageTypes: kinds, timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, search_messages: { operation: '受限 Knowledge 关键词检索', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX }, message_context: { operation: '读取消息前后文', timeRanges: ['all'], limitMax: CONTEXT_MAX }, conversation_overview: { operation: '按会话时间片提取概览证据', timeRanges: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'], limitMax: LIMIT_MAX } } }
}
@@ -282,7 +394,18 @@ export class LocalQueryApiService {
const raw = await listMessagesAsync(contact.md5, range.startTime, range.endTime)
const direction = request.direction || 'any'; const allowed = new Set(request.messageTypes || kinds)
const filtered = raw.filter((message) => !(request.excludeSystem !== false && kindOf(message) === 'system')).filter((message) => allowed.has(kindOf(message))).filter((message) => direction === 'any' || (direction === 'to_target' ? message.isSender : !message.isSender)).sort((a, b) => ((a.createTime || 0) - (b.createTime || 0)) * ((request.order || 'asc') === 'asc' ? 1 : -1)).slice(0, Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)))
- return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages: filtered.map((message) => toQueryMessage(contact.md5, message, contact)), scope: corpus.scope }
+ // 图片 OCR 派生文本:L4 只读 L1,**不触发 OCR / 解密 / 读原图**。
+ // 键必须用 `sourceMessageId(message)`(binding 主键就是它),不能用裸 `message.id`,
+ // 否则 `local:` 前缀会让查表静默失配 —— 与 Knowledge 用同一条身份规则。
+ const messages = filtered.map((message) => {
+ const ocr =
+ kindOf(message) === 'image'
+ ? this.imageOcrEntry?.(contact.md5, sourceMessageId(message))
+ : undefined
+ return toQueryMessage(contact.md5, message, contact, ocr)
+ })
+ const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
+ return { status: 'completed' as const, target: contactView(contact), query: { direction, messageTypes: request.messageTypes || [], order: request.order || 'asc', limit: Math.min(LIMIT_MAX, Math.max(1, request.limit || 20)), excludeSystem: request.excludeSystem !== false, resolvedTimeRange: range }, coverage: { state: 'complete' as const }, returnedCount: filtered.length, messages, scope: corpus.scope, ...(imageOcrCoverage ? { imageOcrCoverage } : {}) }
}
async search(request: SearchMessagesRequest) {
const requestStartedAt = Date.now()
@@ -359,6 +482,8 @@ export class LocalQueryApiService {
const covered = indexCovers(found.indexLatestAt, requestedEnd, found.sourceLatestAt)
const indexCoverage = buildIndexCoverage(found.indexLatestAt, found.sourceLatestAt, covered)
+ // 图片文字索引是**独立覆盖维度**:文字索引再完整也不代表图片里的文字搜得到。
+ const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
const timings: QuerySearchTimings = {
totalMs: Date.now() - requestStartedAt,
scopeMs,
@@ -381,6 +506,7 @@ export class LocalQueryApiService {
sourceLatestAt: found.sourceLatestAt,
freshness: { catchUp: freshness.catchUp },
...(indexCoverage ? { indexCoverage } : {}),
+ ...(imageOcrCoverage ? { imageOcrCoverage } : {}),
timings
}
}
@@ -454,7 +580,11 @@ export class LocalQueryApiService {
timestamp: item.timestamp,
sender: item.sender,
sourceKind: item.sourceKind,
- text: item.text,
+ // 兜底再剥一次:不管 Knowledge 侧哪条检索路径产出的文本,
+ // 面向用户与模型的都不允许出现 `图片文字:` 这类引擎内部标签。
+ text: toEvidenceDisplayText(item.text),
+ ...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
+ ...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
conversationName: owner ? contactView(owner).displayName : undefined,
conversationType: owner?.type
} satisfies QueryEvidenceItem
@@ -592,6 +722,7 @@ export class LocalQueryApiService {
const truncated = raw.length > OVERVIEW_SOURCE_CAP
const evidence = selectTemporalCoverageEvidence(contact, messages, OVERVIEW_EVIDENCE_TARGET)
const state: 'complete' | 'partial' = truncated ? 'partial' : 'complete'
+ const imageOcrCoverage = buildImageOcrCoverage(this.imageTextCoverage())
return {
status: 'completed' as const,
target: contactView(contact),
@@ -603,6 +734,7 @@ export class LocalQueryApiService {
selection: { mode: 'temporal_coverage' as const, selectedEvidenceCount: evidence.length, sampled: truncated || evidence.length < messages.length },
evidence,
scope: corpus.scope,
+ ...(imageOcrCoverage ? { imageOcrCoverage } : {}),
origin: 'wcdb' as const
}
}
diff --git a/src/main/services/query-agent-service.ts b/src/main/services/query-agent-service.ts
index ece0e4c..227c262 100644
--- a/src/main/services/query-agent-service.ts
+++ b/src/main/services/query-agent-service.ts
@@ -106,6 +106,17 @@ export interface QueryAgentTraceItem {
* 会剥离)。用途:把不透明的 Tool 总耗时拆成 scope / freshness / 每个 probe / 合并 / 证据补全。
*/
searchTimings?: QuerySearchTimings
+ /**
+ * 本次 Tool Result 里携带 OCR 派生文本的图片消息/证据条数(诊断用,不进模型上下文)。
+ *
+ * 存在的意义是让"图片已经识别出文字、但模型没拿到"这类**链路断点**可以被直接观测:
+ * 真机上曾经出现过 `query_messages` 返回了图片消息却只带 `attachment`、
+ * 模型因此回答"没有取得 OCR 文字"。当时从回答文本无法判断是"索引没建"还是"没接上",
+ * 因为这两件事在日志里长得一模一样。有了这个数字就能一眼分开。
+ */
+ imageOcrTextCount?: number
+ /** 本次 Tool Result 里图片文字索引的覆盖度状态(`not_built` / `partial` / `complete` / `failed`)。 */
+ imageOcrCoverageState?: string
}
export interface QueryAgentModelCallDiagnostic {
@@ -207,6 +218,12 @@ const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使
规划原则:
- 先判断问题需要哪种证据,再调用最少的 Tool。每次收到 Tool Result 后都判断“当前 Evidence 是否已经足以给出有边界的回答”;足够就立即回答,不为追求绝对完整继续调查。
- query_messages 是精确事实查询,适用于能用联系人、时间、方向、消息类型、顺序等结构条件表达的问题。earliest/latest 等时间边界也是结构条件,必须使用 order 与 limit 精确查询,不能使用抽样 overview。每次调用都必须如实声明 temporalBasis。结果已经回答问题时,不要追加 conversation_overview。
+方向(direction)必须按**说话人是谁**来定,不要按语序猜:
+- direction 的参照物是“目标会话”:to_target = **我发出**的(说话人是我自己),from_target = **对方发来**的。没有 other 取值,拿不准就用 any。
+- 说话人是我 → to_target:“我给张三发了什么”“我发给张三的”“我发给他的文件”“我之前给他发过什么”“我发出去的图片”“我在这个群里发过什么”。
+- 说话人是对方 → from_target:“张三给我发了什么”“他之前给我的图片”“张三发给我的文件”。
+- **不许**因为“我”出现在句首就选 from_target;也不要凭昵称是否叫“我”来判断说话人,自我身份以消息自身的发送者标记为准。
+- 一旦某次 query_messages 返回 0 条,先回头核对 direction 是否与问题的说话人**冲突**;冲突就属于允许的 substantively different retry,必须直接换方向再查一次,**不要**问用户“是不是方向搞错了/要不要换个方向”,用户已经把话说清楚了。
- 需要绝对时间范围时,startTime/endTime 必须使用带时区偏移的 ISO-8601 字符串(例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z)。不要传 epoch 数字,也不要传没有时区的裸本地时间。
- search_messages 是关键词检索,适用于结构条件无法确定答案的问题。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话塞进同一项。首次最多 4 项。检索到 Evidence 后直接判断;只有本次完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。
- conversation_overview 只用于真正需要理解一个时间范围内整体聊了什么、主要话题或整体互动的 broad summary。它返回 temporal coverage sample,不代表完整聊天,也不是检索不足时的默认 fallback。
@@ -219,6 +236,20 @@ const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使
- indexCoverage.covered 为 false 时,说明这段时间还没进索引:此时即使结果为 0 也只能说"索引尚未覆盖这段时间,暂时无法确认",**绝不能**说成"没有"。必须如实引用结论里的索引更新时间。
- 已经检索到 Evidence 时,只有当这个覆盖边界真的会影响结论时才补一句说明,不要机械附加警告。
- 只有 coverage.state 为 complete(indexCoverage.covered 为 true)且结果为 0,才可以下"没有找到"的结论。不要自己把 partial 说成 complete。
+图片文字索引:search_messages 的 imageOcrCoverage 是**独立于文字索引**的覆盖维度,只针对“图片里的文字”(截图、报价图、公告截图、海报)。规则:
+- 文字消息索引完整**不代表**图片里的文字搜得到。不要把这两个维度混着说。
+- 问题涉及图片里的文字、而 imageOcrCoverage.state 不是 complete 时:即使图片证据为 0,也**绝不能**回答“没有”或“没找到”。必须如实引用 imageOcrCoverage.summary,说明图片文字索引尚未完成、当前无法确认全部历史图片。
+- imageOcrCoverage.state 为 not_built 时,明确告诉用户图片文字索引还没建立,图片里的文字目前搜不到,并提示可以在「问问微信」里建立。
+- 只有 imageOcrCoverage.state 为 complete 且图片证据为 0,才可以下“没有找到”的结论。
+图片消息的文字(query_messages 与 search_messages 都适用):
+- 图片消息可能带 imageOcrText / derivedSource=image_ocr —— 那是**这张图片里识别出的文字**(本地 OCR 派生),可以直接用它回答“图片里写了什么”。问法可能是“我今早发的那张图片里写了什么”“那张 ChatGPT 价格截图是什么内容”。
+- imageOcrText 是派生内容,**证据永远是那条原始图片消息**:messageRef、sender、conversation、时间都只能用原始图片消息的。描述时说“图片里的文字是…”,**不许**把它说成某人发的一条文字消息,**不许**为了它编造任何不存在的消息。
+- imageTextState 是**结构化事实**,三种取值含义不同,不要互相替代:
+ - indexed:已识别出文字(同时有 imageOcrText)。
+ - empty:本地识别过,这张图里确实没有文字。此时**只能**回答图片本身,**绝不许**根据 OCR 去猜人物、场景、物体或表情包含义(OCR 不是看图,没有 Vision 能力就不要假装有)。
+ - not_indexed:这条图片还没进图片文字索引。**不许**把“还没索引”说成“图片里没有文字”;若 imageOcrCoverage 不是 complete,必须说明当前无法确认。
+- 图片文字索引状态一律以 Tool Result 的结构化字段为准。**不要**在回答里凭空建议“可以先建立图片文字索引再查”——只有 imageOcrCoverage.state 确实是 not_built 时才可以这么说。
+- 区分「图片里确实没有文字」(OCR 结果为空,属于已处理的正常终态)与「图片还没被索引」(覆盖缺口):前者是事实,后者不能当成事实。
缺少必要信息时用自然语言澄清;超出工具能力时说明不能可靠完成,并给出当前工具可以执行的替代方向。`
function toolDefinitions(): AIChatToolDefinition[] {
@@ -545,7 +576,7 @@ function retryNote(name: string, result: QueryAgentToolResult, state: ZeroResult
if (result.status !== 'completed') return undefined
const counts = resultCount(result)
if (name === 'search_messages' && !counts.evidenceCount && state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次检索没有任何 Evidence。允许再执行一次 search_messages,但每一项都必须与上一次实质不同;完全相同的检索会被拒绝。'
- if (name === 'query_messages' && counts.resultCount === 0 && !result.fallbackLookup && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次精确查询返回 0 条。允许再执行一次 query_messages,用于放宽 direction 或 messageTypes 等非时间条件;改变时间范围会被拒绝。'
+ if (name === 'query_messages' && counts.resultCount === 0 && !result.fallbackLookup && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次精确查询返回 0 条。只允许放宽 direction 或 messageTypes 等非时间条件(改变时间范围会被拒绝)。**特别注意方向选反这种情况**:如果问题是“我给 X 发 / 我发给 X 的”,而本次用的是 from_target(对方发来),那是方向选反了 —— 直接改用 to_target 重查一次,这属于允许的实质不同重试。不要因为有 0 条就收尾,也不要问用户“是不是方向搞错了 / 要不要换个方向”,用户已经把说话人讲清楚了。'
return undefined
}
@@ -583,10 +614,52 @@ function messageRecordForModel(value: unknown): unknown {
return record.messageType || !record.sourceKind ? record : { ...record, messageType: record.sourceKind }
}
-function toolResultForModel(name: string, result: QueryAgentToolResult, callsUsed: number, nextTools: AIChatToolDefinition[], note?: string): QueryAgentToolResult {
- const visible: QueryAgentToolResult = { ...result }
+/**
+ * 从 Tool Result 里读出图片 OCR 的两条**结构化事实**(诊断 / 日志用)。
+ *
+ * 只看字段存在与否与数量,**不读文本内容**:排查链路断点不需要正文,
+ * 日志里也不该多留一份聊天内容。
+ */
+function imageOcrDiagnostics(result: QueryAgentToolResult): {
+ imageOcrTextCount?: number
+ imageOcrCoverageState?: string
+} {
+ let count = 0
+ const collect = (value: unknown): void => {
+ if (!Array.isArray(value)) return
+ for (const item of value) {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue
+ const text = (item as Record).imageOcrText
+ if (typeof text === 'string' && text.trim()) count += 1
+ }
+ }
+ collect(result.messages)
+ collect(result.evidence)
+ const coverage = result.imageOcrCoverage
+ const state =
+ coverage && typeof coverage === 'object' && !Array.isArray(coverage)
+ ? (coverage as Record).state
+ : undefined
+ return {
+ ...(count > 0 ? { imageOcrTextCount: count } : {}),
+ ...(typeof state === 'string' ? { imageOcrCoverageState: state } : {})
+ }
+}
+
+function toolResultForModel(name: string, result: QueryAgentToolResult, callsUsed: number, nextTools: AIChatToolDefinition[], note?: string): QueryAgentToolResult { const visible: QueryAgentToolResult = { ...result }
if (Array.isArray(result.messages)) visible.messages = result.messages.map(messageRecordForModel)
- if (Array.isArray(result.evidence)) visible.evidence = result.evidence.map(messageRecordForModel)
+ if (Array.isArray(result.evidence)) {
+ visible.evidence = result.evidence.map((item) => {
+ const record = messageRecordForModel(item)
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return record
+ // `imageOcrText` 是给 Evidence UI 做"命中解释"的片段;它的内容已经在 `text` 里,
+ // 再原样带一份进模型上下文是纯重复。模型侧保留 `derivedSource` 这个语义标记即可,
+ // 由此知道"这条命中的是图片里的文字"。
+ const trimmed = { ...(record as Record) }
+ delete trimmed.imageOcrText
+ return trimmed
+ })
+ }
if (result.anchor) visible.anchor = messageRecordForModel(result.anchor)
if (Array.isArray(result.before)) visible.before = result.before.map(messageRecordForModel)
if (Array.isArray(result.after)) visible.after = result.after.map(messageRecordForModel)
@@ -616,7 +689,7 @@ function toolResultForModel(name: string, result: QueryAgentToolResult, callsUse
return visible
}
-function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState, rangeWasAll = false): AIChatToolDefinition[] {
+function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState): AIChatToolDefinition[] {
// 重复重试已被拒绝,不再开放工具,避免用有限的 tool budget 反复试同一条件。
if (result.constraint === 'duplicate_retry') return []
if (result.status === 'invalid_tool_arguments') return toolDefinition(name)
@@ -631,10 +704,24 @@ function nextToolDefinitions(name: string, result: QueryAgentToolResult, state:
if (name === 'query_messages') {
// Host 已经自动执行过一次扩大查询:不再开放 retry,避免出现第三次查询。
if (result.fallbackLookup) return []
- // 已经查了全部历史且 0 结果:再换时间范围毫无意义(更窄只会更少)。
- if (rangeWasAll && counts.resultCount === 0) return []
- // 只有 0 结果才开放一次重试;有结果时保持原有 stopping。
- return counts.resultCount === 0 && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT ? toolDefinition('query_messages') : []
+ /**
+ * 这里**不能**因为"时间范围已经是全部"就关掉重试。
+ *
+ * 原实现是 `if (rangeWasAll && resultCount === 0) return []`,依据是"时间不能再放宽了、
+ * 更窄只会更少"。但 0 结果的重试本来就不是为了改时间 —— 它是为了放宽
+ * **direction / messageTypes**:「我给 X 发了什么图片」被错判成 `from_target` 时,
+ * 换成 `to_target` 会从 0 条变成有结果。
+ *
+ * 这个守卫的后果正是真机那个回归:工具没发出去 → 第二次调用被
+ * `tool_availability` 拒掉 → 模型想改向也调不动 → 只能回头问用户"是不是方向搞错了"。
+ *
+ * 时间范围不可变由 `constraint_time_range_immutable` 单独把关,
+ * 完全相同的重试由 `duplicate_retry` 拦下,次数由 ZERO_RESULT_RETRY_LIMIT 限制,
+ * 所以这里放开是安全的。
+ */
+ return counts.resultCount === 0 && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT
+ ? toolDefinition('query_messages')
+ : []
}
return []
}
@@ -772,6 +859,12 @@ class EvidenceCollector {
? { messageType: record.sourceKind }
: {}),
...(typeof record.text === 'string' && record.text ? { text: record.text } : {}),
+ // 「靠图片里的文字命中」这个来源语义必须带到 UI:用户要能看出这条答案来自
+ // 图片 OCR,而不是群友真发了一条文字消息。messageRef 仍然指向原始图片消息。
+ ...(record.derivedSource === 'image_ocr' ? { derivedSource: 'image_ocr' as const } : {}),
+ ...(typeof record.imageOcrText === 'string' && record.imageOcrText
+ ? { imageOcrText: record.imageOcrText }
+ : {}),
...(attachmentView && Object.keys(attachmentView).length ? { attachment: attachmentView } : {}),
source
}
@@ -967,10 +1060,10 @@ export class QueryAgentService {
rawTimings && typeof rawTimings === 'object' && !Array.isArray(rawTimings)
? (rawTimings as QuerySearchTimings)
: undefined
- result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) })
+ result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...imageOcrDiagnostics(completedToolResult), ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) })
const nextTools = completedToolResult.constraint === 'tool_availability'
? tools
- : nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all')
+ : nextToolDefinitions(call.name, completedToolResult, retry)
const note = retryNote(call.name, completedToolResult, retry)
messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: JSON.stringify(toolResultForModel(call.name, completedToolResult, result.toolCallCount, nextTools, note)) })
tools = nextTools
diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts
index f75a6c2..cbf9d27 100644
--- a/src/main/wcdb4-client.ts
+++ b/src/main/wcdb4-client.ts
@@ -6,6 +6,7 @@ import { createRequire } from 'module'
import { createConnection, Socket } from 'net'
import { getResourceRoots } from './resource-paths'
import { wcdbDebugLog } from './wcdb-debug'
+import type { ImageMessageCountProbe } from '../shared/image-text-index'
export interface Wcdb4Session {
username: string
@@ -1232,11 +1233,15 @@ export class Wcdb4Client {
}
async countVoiceMessagesAsync(
- username: string,
+ md5OrUsername: string,
startTime?: number,
endTime?: number
): Promise {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return null
+ // 与图片计数同因的修正:同一个 md5/username 混淆在这里也存在,
+ // 而且它更隐蔽 —— 匹配不到表时循环不执行,函数会**返回 0 而不是报错**。
+ const username = this.resolveMessageUsername(md5OrUsername)
+ if (!username) return null
let tables: Wcdb4MessageStore[]
try {
@@ -1276,6 +1281,203 @@ export class Wcdb4Client {
return total
}
+ /**
+ * 消息类型列的可能名字(按顺序探测,命中即用)。
+ *
+ * 为什么不能直接硬编码 `"local_type"`:`pickValue(row, [...别名])` 那套别名列表只作用于
+ * **已经读出来的行**;一旦把列名写进 WHERE,列名不同的库会当场抛错,再被 catch 吞成
+ * `null` —— 表现就是"检测到 0 张图片"。所以必须先探测真实列名。
+ */
+ private readonly messageTypeColumnCandidates = [
+ 'local_type',
+ 'localType',
+ 'msg_type',
+ 'msgType',
+ 'message_type',
+ 'messageType',
+ 'type',
+ 'WCDB_CT_local_type'
+ ]
+
+ /** 每个消息分片的真实类型列名;探测一次即缓存,避免每个会话都跑一次 PRAGMA。 */
+ private readonly messageTypeColumnCache = new Map()
+
+ private resolveMessageTypeColumn(store: Wcdb4MessageStore): string | null {
+ const cacheKey = `${store.dbPath}\u0000${store.tableName}`
+ const cached = this.messageTypeColumnCache.get(cacheKey)
+ if (cached !== undefined) return cached
+ let resolved: string | null = null
+ try {
+ const columns = this.readMessageColumns(store).map((column) => column.name)
+ for (const candidate of this.messageTypeColumnCandidates) {
+ const hit = columns.find((name) => name.toLowerCase() === candidate.toLowerCase())
+ if (hit) {
+ resolved = hit
+ break
+ }
+ }
+ } catch {
+ resolved = null
+ }
+ this.messageTypeColumnCache.set(cacheKey, resolved)
+ return resolved
+ }
+
+ /**
+ * 把「会话 md5」解析成原生接口真正需要的 username。
+ *
+ * `contact.md5` 是 `md5(wxid)` 的**哈希**(见 chat-service 的 `dbRef.md5(user.m_nsUsrName)`),
+ * 而 `wcdbGetMessageTableStats` / `wcdbGetMessages` 这些原生接口要的是**原始 username**。
+ * 直接把 md5 当 username 传,原生侧匹配不到任何表 —— 表现为"未找到该会话的消息表",
+ * 而按表统计的计数会静默变成 0。
+ *
+ * 既有读消息路径一直做了这层转换(`listSourceMessages` 里的 `getUsernameByMd5`),
+ * **统计/水位路径漏了**,所以这里统一补上。
+ *
+ * 解析不到时原样返回:调用方本来就传 username 的路径仍然可用。
+ */
+ private resolveMessageUsername(md5OrUsername: string): string {
+ const value = String(md5OrUsername || '').trim()
+ if (!value) return value
+ const bySession = this.getUsernameByMd5(value)
+ if (bySession) return bySession
+ // 有些群只以 `Chat_` 表存在、不在 session 列表里;退回按聊天表映射解析
+ // (与 wechat-db 的 `chatMd5ToUsername` 同一套依据)。
+ try {
+ const byChatTable = this.getChatTables().find((table) => table.name === `Chat_${value}`)
+ if (byChatTable?.db_number) return byChatTable.db_number
+ } catch {
+ // 映射不可用时退回原值。
+ }
+ return value
+ }
+
+ /** 图片消息的 WHERE 片段;`sinceMs` 用于只统计某个时间点之后的消息(测试小窗口)。 */
+ private imageMessageWhere(column: string, sinceMs?: number): string {
+ const clauses = [`(${this.quoteSqlIdentifier(column)} & 65535) = 3`]
+ // 微信的 create_time 是**秒**,调用方给的是毫秒。
+ if (sinceMs && Number.isFinite(sinceMs) && sinceMs > 0) {
+ clauses.push(`"create_time" >= ${Math.floor(sinceMs / 1000)}`)
+ }
+ return clauses.join(' AND ')
+ }
+
+ /**
+ * 统计图片消息条数。
+ *
+ * 与 `countVoiceMessagesAsync` 同构:纯 SQL COUNT,**不解密任何图片** ——
+ * 这是「点击索引前先告诉用户有多少张图片」能足够快的前提。
+ *
+ * 与语音版本的关键差别:这里**必须区分「0 张」与「统计失败」**。
+ * `count: null` 表示没数成,调用方绝不能把它当成 0。
+ */
+ async countImageMessagesAsync(
+ md5OrUsername: string,
+ sinceMs?: number
+ ): Promise {
+ if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) {
+ return { count: null, typeColumn: null, error: '当前数据服务不支持消息表统计' }
+ }
+ const username = this.resolveMessageUsername(md5OrUsername)
+ if (!username) {
+ return { count: null, typeColumn: null, error: '无法解析该会话的标识' }
+ }
+
+ let tables: Wcdb4MessageStore[]
+ try {
+ tables = await this.listMessageStoresAsync(username)
+ } catch {
+ return { count: null, typeColumn: null, error: '读取消息分片失败' }
+ }
+ if (!tables.length) {
+ return { count: null, typeColumn: null, error: '未找到该会话的消息表' }
+ }
+
+ let total = 0
+ let typeColumn: string | null = null
+ for (const table of tables) {
+ const column = this.resolveMessageTypeColumn(table)
+ if (!column) {
+ return { count: null, typeColumn: null, error: '消息表缺少可识别的消息类型列' }
+ }
+ if (!typeColumn) typeColumn = column
+ try {
+ const rows = await this.callJsonAsync[]>(
+ this.wcdbExecQuery as unknown as KoffiAsyncFunction,
+ 'message',
+ table.dbPath,
+ `SELECT COUNT(*) AS "image_count" FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${this.imageMessageWhere(column, sinceMs)}`
+ )
+ const value = Number(this.pickValue(rows[0] || {}, ['image_count', 'count', 'COUNT(*)']))
+ if (Number.isFinite(value)) total += value
+ } catch {
+ return { count: null, typeColumn: null, error: '图片消息统计查询失败' }
+ }
+ }
+ return { count: total, typeColumn }
+ }
+
+ /**
+ * 图片消息的增量水位:`count` + `max(local_id)`。
+ *
+ * 为什么不能只靠 `countImageMessagesAsync`:
+ * 图片总数相同**不代表**图片集合没变。撤回一张旧图 + 新增一张新图,count 不变,
+ * 但新图的 `local_id` 更大。只看 count 会静默跳过该会话,新图片永远搜不到。
+ *
+ * `local_id` 是 WCDB 每张消息表内的插入序(自增),所以:
+ * - 任何 append → `max_local_id` 严格变大;
+ * - 「删旧 + 增新」且总数不变 → `max_local_id` 也变大,照样被发现;
+ * - 只有「删掉非最大的那张且不新增」才不变,而此时集合缩小、无需重扫。
+ *
+ * 仍然是一条 SQL 聚合,**不解密任何图片**,成本与 count 同量级。
+ */
+ async imageConversationWatermarkAsync(
+ md5OrUsername: string,
+ sinceMs?: number
+ ): Promise<{ count: number; maxLocalId: number } | null> {
+ if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return null
+ // 与计数同因:必须先把会话 md5 解析成原生接口要的 username,否则永远匹配不到消息表。
+ const username = this.resolveMessageUsername(md5OrUsername)
+ if (!username) return null
+
+ let tables: Wcdb4MessageStore[]
+ try {
+ tables = await this.listMessageStoresAsync(username)
+ } catch {
+ return null
+ }
+
+ let count = 0
+ let maxLocalId = 0
+ for (const table of tables) {
+ // 同样探测真实列名:硬编码列名会让水位查询静默失败,进而退化成"永远重扫"或"永远跳过"。
+ const column = this.resolveMessageTypeColumn(table)
+ if (!column) return null
+ try {
+ const rows = await this.callJsonAsync[]>(
+ this.wcdbExecQuery as unknown as KoffiAsyncFunction,
+ 'message',
+ table.dbPath,
+ `SELECT COUNT(*) AS "image_count", MAX("local_id") AS "image_max_local_id" FROM ${this.quoteSqlIdentifier(table.tableName)} WHERE ${this.imageMessageWhere(column, sinceMs)}`
+ )
+ const row = rows[0] || {}
+ const tableCount = Number(this.pickValue(row, ['image_count', 'count', 'COUNT(*)']))
+ const tableMax = Number(
+ this.pickValue(row, ['image_max_local_id', 'max_local_id', 'MAX("local_id")'])
+ )
+ if (Number.isFinite(tableCount)) count += tableCount
+ if (Number.isFinite(tableMax) && tableMax > maxLocalId) maxLocalId = tableMax
+ } catch (error) {
+ console.warn(
+ `[WCDB4] image watermark failed username=${username} db=${table.dbPath} table=${table.tableName}:`,
+ error
+ )
+ return null
+ }
+ }
+ return { count, maxLocalId }
+ }
+
private readSessionRows(): Record[] {
if (!this.wcdbGetSessions) return []
const rows = this.callJson[]>((handle, outJson) =>
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 7e8bd3a..02cc581 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -58,6 +58,12 @@ import type {
ImageInsight
} from '../shared/image-insight'
import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../shared/system-ocr'
+import type {
+ ImageTextIndexCountResult,
+ ImageTextIndexRepairResult,
+ ImageTextIndexStartOptions,
+ ImageTextIndexStatus
+} from '../shared/image-text-index'
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type {
PersonalWechatGeneratedTtsVoiceRequest,
@@ -95,7 +101,7 @@ import type {
AppUpdateOpenDownloadPageResult,
AppUpdateState
} from '../shared/app-update'
-import type { CacheSummary } from '../shared/cache'
+import type { CacheClearScope, CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
import type {
VoiceBatchPreflight,
@@ -211,7 +217,7 @@ declare global {
openAppUpdateDownloadPage: () => Promise
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
getCacheSummary: () => Promise
- clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise
+ clearCache: (scope: CacheClearScope) => Promise
openKnowledgeDirectory: () => Promise<{ success: boolean; error?: string }>
initDb: (key: string, accountRoot: string) => Promise
discoverAccounts: (inputPath: string) => Promise
@@ -664,6 +670,16 @@ declare global {
// 本地图片文字识别(System OCR,本地 Runtime,非 AI Provider)
getSystemOcrCapability: () => Promise
recognizeLocalImageText: (request: SystemOcrRequest) => Promise
+ getImageTextIndexStatus: () => Promise
+ countImageMessages: (sinceMs?: number) => Promise
+ startImageTextIndex: (options?: ImageTextIndexStartOptions) => Promise<{ started: boolean; state: string }>
+ pauseImageTextIndex: () => Promise<{ paused: boolean; state: string }>
+ resumeImageTextIndex: (options?: ImageTextIndexStartOptions) => Promise<{ started: boolean; state: string }>
+ cancelImageTextIndex: () => Promise<{ cancellable: boolean; cancelled: boolean }>
+ clearImageTextIndex: () => Promise<{ removed: boolean; removedBytes: number }>
+ resetImageTextIndexFailures: () => Promise<{ reset: number }>
+ repairImageTextIndex: () => Promise
+ onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => () => void
getPersonalWechatSenderStatus: () => Promise
getPersonalWechatSendCapability: () => Promise
getPersonalWechatKeepOneBotProcess: () => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 1f17ae3..77a0afa 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -31,6 +31,12 @@ import type {
ImageInsight
} from '../shared/image-insight'
import type { SystemOcrCapability, SystemOcrRequest, SystemOcrResult } from '../shared/system-ocr'
+import type {
+ ImageTextIndexCountResult,
+ ImageTextIndexRepairResult,
+ ImageTextIndexStartOptions,
+ ImageTextIndexStatus
+} from '../shared/image-text-index'
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
import type {
PersonalWechatGeneratedTtsVoiceRequest,
@@ -63,7 +69,7 @@ import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { ActionLogEntry } from '../shared/action-log'
-import type { CacheSummary } from '../shared/cache'
+import type { CacheClearScope, CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
import type { AccountDiscoveryResult } from '../shared/database-key'
@@ -123,7 +129,7 @@ const api = {
return () => ipcRenderer.removeListener('app-update:state', listener)
},
getCacheSummary: (): Promise => ipcRenderer.invoke('cache:getSummary'),
- clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise =>
+ clearCache: (scope: CacheClearScope): Promise =>
ipcRenderer.invoke('cache:clear', scope),
openKnowledgeDirectory: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('cache:openKnowledgeDirectory'),
@@ -468,6 +474,39 @@ const api = {
ipcRenderer.invoke('system-ocr:getCapability'),
recognizeLocalImageText: (request: SystemOcrRequest): Promise =>
ipcRenderer.invoke('system-ocr:recognize', request),
+
+ // 图片文字索引(微信图片 → 本地解密 → System OCR → 派生文本 → Knowledge)
+ getImageTextIndexStatus: (): Promise =>
+ ipcRenderer.invoke('image-text-index:getStatus'),
+ /** 点击索引前的快速统计(SQL COUNT,不解密图片)。 */
+ countImageMessages: (sinceMs?: number): Promise =>
+ ipcRenderer.invoke('image-text-index:count', sinceMs),
+ startImageTextIndex: (options?: ImageTextIndexStartOptions): Promise<{ started: boolean; state: string }> =>
+ ipcRenderer.invoke('image-text-index:start', options),
+ pauseImageTextIndex: (): Promise<{ paused: boolean; state: string }> =>
+ ipcRenderer.invoke('image-text-index:pause'),
+ resumeImageTextIndex: (options?: ImageTextIndexStartOptions): Promise<{ started: boolean; state: string }> =>
+ ipcRenderer.invoke('image-text-index:resume', options),
+ cancelImageTextIndex: (): Promise<{ cancellable: boolean; cancelled: boolean }> =>
+ ipcRenderer.invoke('image-text-index:cancel'),
+ clearImageTextIndex: (): Promise<{ removed: boolean; removedBytes: number }> =>
+ ipcRenderer.invoke('image-text-index:clear'),
+ /** 只重置失败记录(成功记录与其它数据不动),供"修好代码后重跑"。 */
+ resetImageTextIndexFailures: (): Promise<{ reset: number }> =>
+ ipcRenderer.invoke('image-text-index:resetFailures'),
+ /**
+ * 派生索引修复:只重建 Knowledge 里的图片派生条目与 FTS。
+ *
+ * 已有的 OCR 结果(L1)一条都不动 —— 修复索引问题永远不该让几万张图片重算。
+ */
+ repairImageTextIndex: (): Promise =>
+ ipcRenderer.invoke('image-text-index:repair'),
+ onImageTextIndexStatus: (callback: (status: ImageTextIndexStatus) => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, status: ImageTextIndexStatus): void =>
+ callback(status)
+ ipcRenderer.on('image-text-index:status', listener)
+ return () => ipcRenderer.removeListener('image-text-index:status', listener)
+ },
getPersonalWechatSenderStatus: (): Promise =>
ipcRenderer.invoke('wechat-personal:getStatus'),
getPersonalWechatSendCapability: (): Promise =>
diff --git a/src/renderer/src/components/search/AISearchEvidencePanel.tsx b/src/renderer/src/components/search/AISearchEvidencePanel.tsx
index 53ca656..b3177c6 100644
--- a/src/renderer/src/components/search/AISearchEvidencePanel.tsx
+++ b/src/renderer/src/components/search/AISearchEvidencePanel.tsx
@@ -77,9 +77,27 @@ export function AISearchEvidencePanel({
{item.sourceKind === 'voice' && (
语音转写
)}
+ {item.derivedSource === 'image_ocr' && (
+
+ 图片文字
+
+ )}
{messageText(item.message)}
+ {/* 命中解释:明确告诉用户"命中的是图里的这段文字",
+ 避免被读成群友真的发过一条这样的文字消息。 */}
+ {item.derivedSource === 'image_ocr' && item.imageOcrText && (
+
+ “{item.imageOcrText}”
+
+ )}
+ {/* 图片文字索引:与 Knowledge 卡片平级、但**独立的一维能力**。
+ 文字消息索引完整不代表图片里的文字搜得到,所以两个入口必须并列可见。 */}
+
diff --git a/src/renderer/src/components/search/ImageTextIndexCard.tsx b/src/renderer/src/components/search/ImageTextIndexCard.tsx
new file mode 100644
index 0000000..97e5c0e
--- /dev/null
+++ b/src/renderer/src/components/search/ImageTextIndexCard.tsx
@@ -0,0 +1,427 @@
+import { useEffect, useMemo, useState, type ReactElement } from 'react'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '../ui'
+import {
+ describeImageTextCoverage,
+ imageTextCoverageState,
+ imageTextProcessedPercent
+} from '../../../../shared/image-text-index'
+import { useImageTextIndexStatus } from './hooks/useImageTextIndexStatus'
+
+type ImageTextIndexCardProps = {
+ /** 微信数据是否就绪。 */
+ dbReady: boolean
+ onNotice: (message: string) => void
+}
+
+/**
+ * 「图片文字索引」卡片。
+ *
+ * 与 Knowledge 卡片**平级并列**(同一组索引入口),但刻意是**独立的一维能力**:
+ * 文字消息索引完整不代表图片里的文字搜得到。
+ *
+ * 文案遵从严禁混淆的语义(§9):这里做的是「识别图片中文字」,不是
+ * 「本地识图模型 / 本地 Vision / AI OCR」,也不能暗示能理解场景或表情包。
+ */
+export function ImageTextIndexCard({ dbReady, onNotice }: ImageTextIndexCardProps): ReactElement {
+ const {
+ status,
+ count,
+ counting,
+ pending,
+ running,
+ paused,
+ established,
+ refreshCount,
+ start,
+ pause,
+ resume,
+ cancel,
+ resetFailures,
+ repair
+ } = useImageTextIndexStatus({ dbReady, onNotice })
+ const [confirming, setConfirming] = useState(false)
+ const [confirmCount, setConfirmCount] = useState
(null)
+ /**
+ * 处理时间范围(天)。
+ *
+ * 存在的意义是**可验证性**:几万张图片的全量回填没法拿来排查问题,
+ * 先跑"最近 1 天"这种小窗口才能证明链路真的通了。`0` = 全部历史。
+ */
+ const [rangeDays, setRangeDays] = useState('0')
+ const sinceMs = useMemo(() => {
+ const days = Number(rangeDays)
+ return Number.isFinite(days) && days > 0 ? Date.now() - days * 24 * 60 * 60 * 1000 : undefined
+ }, [rangeDays])
+
+ // 进页面 / 切换范围时统计一次(数字必须与当前窗口一致,否则确认弹窗会说谎)。
+ useEffect(() => {
+ if (!dbReady) return
+ void refreshCount(sinceMs)
+ }, [dbReady, sinceMs, refreshCount])
+
+ const coverage = status?.coverage ?? null
+ const progress = status?.progress ?? null
+ const coverageState = coverage ? imageTextCoverageState(coverage) : 'not_built'
+ /**
+ * 处理进度百分比。
+ *
+ * 刻意不在这里做 `Math.round(x * 100)` —— `45479 / 45707` 会被四舍五入成 `100`,
+ * 于是出现了"已建立 · 仅完成 100%"这种自相矛盾的显示。未完成时封顶 99.9%。
+ */
+ const percent = coverage
+ ? imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
+ : 0
+ const systemicFailure = coverage?.systemicFailure === true
+ const visualState =
+ progress?.state === 'error' || coverageState === 'failed'
+ ? 'error'
+ : running
+ ? 'syncing'
+ : paused
+ ? 'cancelled'
+ : !established
+ ? 'unavailable'
+ : coverageState === 'complete'
+ ? 'ready'
+ : 'building'
+
+ const detectedImages = count?.totalImageMessages ?? coverage?.totalImageMessages ?? null
+ const countFailed = count !== null && count.failedConversations > 0
+ /**
+ * 一个会话都没数成。
+ *
+ * 这时**绝不能显示 0** —— 那会让用户以为账号里没有图片,从而放弃建立索引。
+ * 数不出来和确实没有是两件事。
+ */
+ const nothingCounted =
+ count !== null && count.scannedConversations === 0 && count.failedConversations > 0
+
+ const stateLabel = (() => {
+ if (progress?.state === 'error') return '建立失败'
+ if (running) return `建立中 · ${percent}%`
+ if (paused) return `已暂停 · ${percent}%`
+ if (!established) return '未建立'
+ // 「已建立」不能等于「全失败」:处理过但一条都没成功时必须叫异常。
+ if (coverageState === 'failed') return '图片文字索引异常'
+ if (coverageState === 'complete') return '已完成'
+ return `部分完成 · ${percent}%`
+ })()
+
+ /**
+ * 点「建立图片文字索引」:**先重新统计、再弹确认**。
+ *
+ * 确认弹窗里的数字必须新鲜——用户可能刚在微信里收了一批图片。
+ * 统计是纯 SQL COUNT,不解密任何图片,所以这一步够快。
+ */
+ const requestStart = async (): Promise => {
+ const fresh = await refreshCount(sinceMs)
+ setConfirmCount(fresh?.totalImageMessages ?? detectedImages)
+ setConfirming(true)
+ }
+
+ const confirmStart = async (): Promise => {
+ setConfirming(false)
+ await start({ ...(sinceMs ? { sinceMs } : {}) })
+ }
+
+ return (
+ <>
+
+
+
+ IMAGE TEXT INDEX
+
+ {stateLabel}
+
+
+
+
+
+
+ 让「问问微信」也能搜索微信图片中的文字(截图、报价图、公告截图等)。
+ 识别在本机进行,原始图片无需发送给 AI Provider。
+
+
+ {/* 未建立:先告诉用户这个账号大概有多少图片,再让他决定要不要跑。 */}
+ {!established && !running && (
+ <>
+
+
+ 检测到的图片消息
+
+ {counting
+ ? '统计中…'
+ : nothingCounted
+ ? '无法统计'
+ : detectedImages === null
+ ? '—'
+ : detectedImages.toLocaleString()}
+
+
+
+
+ 处理范围
+
+
+ {countFailed && (
+
+ {nothingCounted
+ ? `无法统计本账号的图片消息(${count?.error || '读取消息表失败'})。这不代表账号里没有图片,可以点「重新统计」再试一次。`
+ : `有 ${count?.failedConversations.toLocaleString()} 个会话未能统计,上面的数字可能偏小。`}
+
+ )}
+ >
+ )}
+
+ {/* 进度:只给真实数字,绝不显示 native handle / hash / HRESULT。 */}
+ {(running || paused) && progress && (
+
+
+
+ 已处理
+
+ {`${progress.processed.toLocaleString()} / ${progress.totalImageMessages.toLocaleString()}`}
+
+
+
+
+ 0
+ ? {}
+ : { animation: 'ai-search-indeterminate 1.4s ease-in-out infinite' })
+ }}
+ />
+
+
+ {`${progress.percent}% · 识别出文字 ${progress.indexed.toLocaleString()} · 没有文字 ${progress.empty.toLocaleString()} · 图片已清理 ${progress.missing.toLocaleString()} · 失败 ${progress.failed.toLocaleString()}`}
+
+
+ )}
+
+ {/* 已建立:给一份可核对的明细。 */}
+ {established && !running && !paused && coverage && (
+
+
+ 已识别出文字
+
+ {coverage.indexed.toLocaleString()}
+
+
+
+ 没有文字
+ {coverage.empty.toLocaleString()}
+
+
+ 图片已清理
+
+ {coverage.missing.toLocaleString()}
+
+
+ {coverage.failed > 0 && (
+
+ 识别失败
+
+ {coverage.failed.toLocaleString()}
+
+
+ )}
+
{describeImageTextCoverage(coverage)}
+
+ )}
+
+ {progress?.state === 'error' && (
+
+ {progress.lastError || '图片文字索引建立失败,可以稍后重试。'}
+
+ )}
+ {/* 「处理过但一条都没成功」= 索引异常,绝不能显示成"已建立"。 */}
+ {systemicFailure && (
+
+ {`${(coverage?.failed ?? 0).toLocaleString()} 条处理失败,成功识别 0 条 —— 当前无法搜索图片中的文字。`}
+
+ )}
+ {paused && (
+
+ 已暂停。已经识别出的结果都保留了,点「继续」会从断点接着做,不会从第一张重新开始。
+
+ )}
+ {!dbReady && (
+ 请先连接微信数据,然后再建立图片文字索引。
+ )}
+
+
+ {!running && !paused && (
+
+ )}
+ {!running && !paused && countFailed && (
+
+ )}
+ {/* 修好之后重跑:只重置失败记录,成功记录与其它数据一律不动。 */}
+ {!running && !paused && systemicFailure && (
+
+ )}
+ {/* 派生索引修复:只重建 Knowledge 里的图片搜索索引,**不重新识别任何图片**。
+ 存在的意义就是"别为修一个索引问题重跑几万张图"。 */}
+ {!running && !paused && established && (
+
+ )}
+ {running && (
+ <>
+
+
+ >
+ )}
+ {paused && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ 建立图片文字索引
+
+
+
+ 当前账号检测到约{' '}
+
+ {confirmCount === null ? '未知数量' : confirmCount.toLocaleString()} 条图片消息
+
+ 。
+
+
+ 建立后,TraceMemo 会在本机读取这些图片中的文字,以后可以在「问问微信」里搜索截图、
+ 报价图、公告截图等图片里的文字,并按结果回到对应的原始图片消息。
+
+
识别过程:
+
+ - 仅在本机进行识别,原始图片不会因为本地识别而自动上传
+ - 可能需要较长时间,可以暂停并稍后继续
+ - 图片已被微信清理或无法解密时会自动跳过
+ - 实际可识别的数量取决于本地图片文件是否仍然存在
+
+
不会修改或删除微信原始图片与聊天记录。
+
+
+ 取消
+ void confirmStart()}
+ >
+ 开始索引
+
+
+
+
+ >
+ )
+}
diff --git a/src/renderer/src/components/search/askWechatPresentation.ts b/src/renderer/src/components/search/askWechatPresentation.ts
index e1515a1..70080b3 100644
--- a/src/renderer/src/components/search/askWechatPresentation.ts
+++ b/src/renderer/src/components/search/askWechatPresentation.ts
@@ -52,6 +52,10 @@ export function mapAskWechatEvidence(items: AskWechatEvidenceItem[]): EvidenceIt
return {
evidenceId: `E${index + 1}`,
sourceKind: item.messageType as EvidenceItem['sourceKind'],
+ // 「靠图片里的文字命中」是来源语义,必须原样带到 UI;
+ // 但 authoritative source 仍然是原始图片消息(messageRef 已指向它)。
+ ...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
+ ...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
contact: evidenceContact(item, anchor),
messageRef: item.messageRef,
message: {
diff --git a/src/renderer/src/components/search/hooks/useImageTextIndexStatus.ts b/src/renderer/src/components/search/hooks/useImageTextIndexStatus.ts
new file mode 100644
index 0000000..885d7aa
--- /dev/null
+++ b/src/renderer/src/components/search/hooks/useImageTextIndexStatus.ts
@@ -0,0 +1,235 @@
+import { useCallback, useEffect, useState } from 'react'
+import type {
+ ImageTextIndexCountResult,
+ ImageTextIndexStartOptions,
+ ImageTextIndexStatus
+} from '../../../../../shared/image-text-index'
+
+type UseImageTextIndexStatusOptions = {
+ /** 微信数据是否已就绪。未就绪时既不统计也不允许建立索引。 */
+ dbReady: boolean
+ onNotice: (message: string) => void
+}
+
+export type ImageTextIndexAction = 'start' | 'pause' | 'resume' | 'cancel' | 'reset' | 'repair'
+
+/**
+ * 「图片文字索引」的 renderer 侧状态。
+ *
+ * 三条不能省的语义:
+ * 1. **重启后进度是真的**:进度与覆盖度全部来自主进程的派生库快照,
+ * renderer 不自己累加、也不缓存百分比。应用重启后重新拉一次即可恢复真实进度。
+ * 2. **数量统计是显式动作**:COUNT(*) 要走一遍会话列表,不在每次渲染时触发;
+ * 只在「未建立」时拉一次、以及点击建立前重新拉一次(确认弹窗里的数字必须新鲜)。
+ * 3. **暂停 / 继续 / 取消都是待确认操作**:主进程返回 started/paused/cancelled
+ * 才提示成功;例如 `started: false` 表示已经有任务在跑,此时说"已开始"是假话。
+ */
+export function useImageTextIndexStatus({
+ dbReady,
+ onNotice
+}: UseImageTextIndexStatusOptions): {
+ status: ImageTextIndexStatus | null
+ count: ImageTextIndexCountResult | null
+ counting: boolean
+ pending: ImageTextIndexAction | null
+ running: boolean
+ paused: boolean
+ established: boolean
+ refreshCount: (sinceMs?: number) => Promise
+ start: (options?: ImageTextIndexStartOptions) => Promise
+ pause: () => Promise
+ resume: () => Promise
+ cancel: () => Promise
+ resetFailures: () => Promise
+ repair: () => Promise
+} {
+ const [status, setStatus] = useState(null)
+ const [count, setCount] = useState(null)
+ const [counting, setCounting] = useState(false)
+ const [pending, setPending] = useState(null)
+
+ useEffect(() => {
+ // 这是一个**次要侧栏能力**:桥接缺失(旧 preload / 测试里手写的 window.api)
+ // 或推送异常,都不允许把整个「问问微信」拖垮。缺少桥接时按「未建立」降级即可。
+ const bridge = window.api as unknown as {
+ getImageTextIndexStatus?: () => Promise
+ onImageTextIndexStatus?: (
+ callback: (status: ImageTextIndexStatus) => void
+ ) => (() => void) | undefined
+ }
+ const loadStatus = bridge.getImageTextIndexStatus
+ const subscribe = bridge.onImageTextIndexStatus
+ if (typeof loadStatus !== 'function' || typeof subscribe !== 'function') return
+
+ let active = true
+ void loadStatus
+ .call(bridge)
+ .then((snapshot) => {
+ if (active) setStatus(snapshot)
+ })
+ .catch(() => undefined)
+ const unsubscribe = subscribe((snapshot) => {
+ if (active) setStatus(snapshot)
+ })
+ return () => {
+ active = false
+ if (typeof unsubscribe === 'function') unsubscribe()
+ }
+ }, [])
+
+ const refreshCount = useCallback(
+ async (sinceMs?: number): Promise => {
+ if (!dbReady) return null
+ setCounting(true)
+ try {
+ const result = await window.api.countImageMessages(sinceMs)
+ setCount(result)
+ return result
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '统计图片消息数量失败')
+ return null
+ } finally {
+ setCounting(false)
+ }
+ },
+ [dbReady, onNotice]
+ )
+
+ const established = status?.coverage.established ?? false
+ void established
+
+ const start = useCallback(
+ async (options?: ImageTextIndexStartOptions): Promise => {
+ if (!dbReady) {
+ onNotice('请先连接微信数据后再建立图片文字索引')
+ return
+ }
+ setPending('start')
+ try {
+ const result = await window.api.startImageTextIndex(options)
+ if (!result.started) {
+ onNotice('图片文字索引已经在进行中')
+ return
+ }
+ onNotice('已开始建立图片文字索引,可以继续使用软件')
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '启动图片文字索引失败')
+ } finally {
+ setPending(null)
+ }
+ },
+ [dbReady, onNotice]
+ )
+
+ const pause = useCallback(async (): Promise => {
+ setPending('pause')
+ try {
+ const result = await window.api.pauseImageTextIndex()
+ onNotice(result.paused ? '已暂停,已完成的识别结果会保留' : '当前没有正在进行的索引')
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '暂停失败')
+ } finally {
+ setPending(null)
+ }
+ }, [onNotice])
+
+ const resume = useCallback(
+ async (options?: ImageTextIndexStartOptions): Promise => {
+ setPending('resume')
+ try {
+ const result = await window.api.resumeImageTextIndex(options)
+ onNotice(result.started ? '已继续建立图片文字索引' : '索引已经在进行中')
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '继续失败')
+ } finally {
+ setPending(null)
+ }
+ },
+ [onNotice]
+ )
+
+ const cancel = useCallback(async (): Promise => {
+ setPending('cancel')
+ try {
+ const result = await window.api.cancelImageTextIndex()
+ if (!result.cancellable) {
+ onNotice('当前没有正在进行的索引')
+ return
+ }
+ if (!result.cancelled) {
+ onNotice('索引刚刚已经结束,无需取消')
+ return
+ }
+ onNotice('已取消,已识别的结果会保留,下次可从中断处继续')
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '取消失败')
+ } finally {
+ setPending(null)
+ }
+ }, [onNotice])
+
+ /**
+ * 重置失败记录(代码修好后重跑)。
+ *
+ * 只说"已重置 N 条"是不够的 —— 必须同时讲清楚**成功记录没有被删**,
+ * 否则用户会以为刚才把已经跑好的结果也清掉了。
+ */
+ const resetFailures = useCallback(async (): Promise => {
+ setPending('reset')
+ try {
+ const result = await window.api.resetImageTextIndexFailures()
+ onNotice(
+ result.reset > 0
+ ? `已把 ${result.reset.toLocaleString()} 条失败记录重置为待处理;已成功识别的记录保持不变。可以点「更新图片文字索引」重新处理这些图片`
+ : '没有需要重置的失败记录'
+ )
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '重置失败记录失败')
+ } finally {
+ setPending(null)
+ }
+ }, [onNotice])
+
+ /**
+ * 派生索引修复:只重建 Knowledge 里的图片派生条目(L3),**不重新 OCR**(L1 不动)。
+ *
+ * 措辞必须讲清楚"没有重新识别":否则用户会以为又要等一小时,
+ * 从而不敢点这个按钮 —— 而这个按钮存在的全部意义就是"别重跑几万张图"。
+ */
+ const repair = useCallback(async (): Promise => {
+ setPending('repair')
+ try {
+ const result = await window.api.repairImageTextIndex()
+ if (result.skipped) {
+ onNotice('索引任务正在进行中,请等它结束后再修复搜索索引')
+ return
+ }
+ onNotice(
+ result.conversations > 0
+ ? `已重建 ${result.conversations} 个会话的图片搜索索引;没有重新识别任何图片(已识别结果全部复用)`
+ : '没有需要重建的图片搜索索引'
+ )
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '修复图片搜索索引失败')
+ } finally {
+ setPending(null)
+ }
+ }, [onNotice])
+
+ return {
+ status,
+ count,
+ counting,
+ pending,
+ running: status?.progress.state === 'running',
+ paused: status?.progress.state === 'paused',
+ established,
+ refreshCount,
+ start,
+ pause,
+ resume,
+ cancel,
+ resetFailures,
+ repair
+ }
+}
diff --git a/src/renderer/src/components/search/searchMappers.ts b/src/renderer/src/components/search/searchMappers.ts
index f6cc9b0..674325f 100644
--- a/src/renderer/src/components/search/searchMappers.ts
+++ b/src/renderer/src/components/search/searchMappers.ts
@@ -24,6 +24,10 @@ export const mapPipelineEvidenceItem = (
return {
evidenceId: item.id,
sourceKind: item.sourceKind,
+ // 「靠图片里的文字命中」的来源语义与 OCR 片段同样要带到 UI,
+ // 否则 Legacy 检索路径下用户看不到「图片文字」标记(两条路径表现会不一致)。
+ ...(item.derivedSource ? { derivedSource: item.derivedSource } : {}),
+ ...(item.imageOcrText ? { imageOcrText: item.imageOcrText } : {}),
contact,
// 这条路径本来就同时知道真实会话 id 与消息 id,顺手补上稳定引用,
// 让 Legacy / ai-search 证据也能被精确定位(而不是只有 Query Agent 路径能跳准)。
diff --git a/src/renderer/src/components/search/searchTypes.ts b/src/renderer/src/components/search/searchTypes.ts
index 5a6f08b..85e9247 100644
--- a/src/renderer/src/components/search/searchTypes.ts
+++ b/src/renderer/src/components/search/searchTypes.ts
@@ -34,6 +34,15 @@ export interface EvidenceItem {
/** Program-owned Final Evidence ID. Cached legacy records may omit it. */
evidenceId?: string
sourceKind?: KnowledgeMessageKind
+ /**
+ * 命中所依赖的派生来源。
+ *
+ * `image_ocr` = 这条结果靠**图片里的文字**命中,而不是群友真的发了一条文字消息。
+ * 有值时 Evidence 卡片显示轻量来源标记(「图片文字」)。
+ */
+ derivedSource?: 'image_ocr'
+ /** 「从图片里读出来的文字」片段,只作命中解释。 */
+ imageOcrText?: string
contact: Contact
message: Message
/**
diff --git a/src/renderer/src/features/settings/pages/CacheCleanupPage.tsx b/src/renderer/src/features/settings/pages/CacheCleanupPage.tsx
index 72c471c..3bb84d0 100644
--- a/src/renderer/src/features/settings/pages/CacheCleanupPage.tsx
+++ b/src/renderer/src/features/settings/pages/CacheCleanupPage.tsx
@@ -1,6 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
-import type { CacheSummary } from '../../../../../shared/cache'
-import { Button } from '../../../components/ui'
+import type { CacheSummary, CacheClearScope } from '../../../../../shared/cache'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button
+} from '../../../components/ui'
const SEARCH_CACHE_KEYS = [
'wxe_ai_search_cache_v8',
@@ -25,9 +35,9 @@ export function CacheCleanupPage({
onNotice: (message: string) => void
}): React.ReactElement {
const [summary, setSummary] = useState(null)
- const [busyScope, setBusyScope] = useState<
- 'bootstrap' | 'electron' | 'knowledge' | 'knowledge-directory' | 'all' | 'local' | null
- >(null)
+ const [busyScope, setBusyScope] = useState(null)
+ /** 需要二次确认的清理范围(目前只有图片文字索引)。 */
+ const [confirmingScope, setConfirmingScope] = useState(null)
const refresh = useCallback(async (): Promise => {
setSummary(await window.api.getCacheSummary())
@@ -44,7 +54,7 @@ export function CacheCleanupPage({
onNotice('已清理检索和导出本地缓存')
}
- const clear = async (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise => {
+ const clear = async (scope: CacheClearScope): Promise => {
setBusyScope(scope)
try {
setSummary(await window.api.clearCache(scope))
@@ -54,9 +64,11 @@ export function CacheCleanupPage({
onNotice(
scope === 'knowledge'
? '已清理所有账号的本地知识库索引,需要时可在问问微信中重新建立'
- : scope === 'all'
- ? '已清理全部可恢复缓存和检索记录'
- : '缓存已清理'
+ : scope === 'image-text-index'
+ ? '已清理图片文字索引,微信原始图片与聊天记录未受影响;需要时可在问问微信中重新建立'
+ : scope === 'all'
+ ? '已清理全部可恢复缓存和检索记录'
+ : '缓存已清理'
)
} catch (error) {
onNotice(error instanceof Error ? error.message : '清理缓存失败')
@@ -65,6 +77,33 @@ export function CacheCleanupPage({
}
}
+ /**
+ * 清理图片文字索引。两步各司其职,不能省成一步:
+ *
+ * 1. `clearImageTextIndex()` —— 主进程先停任务、折叠 WAL、关连接、删三件套,
+ * 并**回验文件是否真的删掉**(Windows 上文件被占用时 rmSync 会静默失败)。
+ * 2. `clearCache('image-text-index')` —— 再扫掉整个派生目录(含其它账号的派生库),
+ * 并返回刷新后的占用摘要。
+ *
+ * 只要第 1 步回验失败,就必须如实报告,不能说"已清理"。
+ */
+ const clearImageTextIndex = async (): Promise => {
+ setBusyScope('image-text-index')
+ try {
+ const result = await window.api.clearImageTextIndex()
+ setSummary(await window.api.clearCache('image-text-index'))
+ onNotice(
+ result.removed
+ ? '已清理图片文字索引;微信原始图片、聊天记录和普通文字知识库都未受影响。需要时可在「问问微信」里重新建立'
+ : '图片文字索引的数据文件仍被占用,没能完全删除。请重启 TraceMemo 后再试一次'
+ )
+ } catch (error) {
+ onNotice(error instanceof Error ? error.message : '清理图片文字索引失败')
+ } finally {
+ setBusyScope(null)
+ }
+ }
+
const openKnowledge = async (): Promise => {
setBusyScope('knowledge-directory')
try {
@@ -134,9 +173,14 @@ export function CacheCleanupPage({
@@ -169,6 +213,41 @@ export function CacheCleanupPage({
+
+ {/* 图片文字索引是「重新建立成本很高」的派生数据,必须二次确认并写清不可逆的范围。 */}
+ setConfirmingScope(open ? 'image-text-index' : null)}
+ >
+
+
+ 清理图片文字索引?
+
+ 将删除 TraceMemo 本地生成的图片 OCR 文本和对应搜索索引。
+
+
+
+
不会删除:
+
+ - 微信原始图片
+ - 微信聊天记录
+ - 普通文字知识库
+ - 微信数据库
+
+
清理后,「问问微信」将无法搜索图片中的文字;之后可以重新建立。
+
+
+ 取消
+ void clearImageTextIndex()}
+ >
+ 确认清理
+
+
+
+
)
}
diff --git a/src/renderer/src/styles/search.scss b/src/renderer/src/styles/search.scss
index 1df01da..f1f8e93 100644
--- a/src/renderer/src/styles/search.scss
+++ b/src/renderer/src/styles/search.scss
@@ -278,6 +278,33 @@
line-height: 15px;
}
+/* 「建立图片文字索引」确认弹窗的正文:侧栏卡片用的 10px 在弹窗里太挤,
+ 这里单独给一档更大的字号,并保持与卡片一致的次要文字色。 */
+.ai-search-knowledge-confirm {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: var(--wxex-text-muted);
+ font-size: 12px;
+ line-height: 19px;
+
+ p {
+ margin: 0;
+ }
+
+ strong {
+ color: var(--wxex-text-primary);
+ }
+
+ ul {
+ margin: 0;
+ padding-left: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ }
+}
+
.ai-search-knowledge-error {
color: var(--wxex-warning);
}
diff --git a/src/renderer/src/styles/settings-preferences.scss b/src/renderer/src/styles/settings-preferences.scss
index d593fe5..7efccc6 100644
--- a/src/renderer/src/styles/settings-preferences.scss
+++ b/src/renderer/src/styles/settings-preferences.scss
@@ -79,6 +79,29 @@
}
}
+/* 清理类确认弹窗的正文(「不会删除……」清单)。
+ 侧栏卡片那种 10px 在弹窗里太小,这里单独给一档。 */
+.settings-confirm-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ color: var(--wxex-text-muted);
+ font-size: 12px;
+ line-height: 19px;
+
+ p {
+ margin: 0;
+ }
+
+ ul {
+ margin: 0;
+ padding-left: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ }
+}
+
.voice-runtime-card dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
diff --git a/src/shared/cache.ts b/src/shared/cache.ts
index 4d61098..ede3f79 100644
--- a/src/shared/cache.ts
+++ b/src/shared/cache.ts
@@ -1,7 +1,12 @@
-export type CacheClearScope = 'bootstrap' | 'electron' | 'knowledge' | 'all'
+export type CacheClearScope =
+ | 'bootstrap'
+ | 'electron'
+ | 'knowledge'
+ | 'image-text-index'
+ | 'all'
export interface CacheSummaryItem {
- id: 'bootstrap' | 'electron' | 'knowledge'
+ id: 'bootstrap' | 'electron' | 'knowledge' | 'image-text-index'
label: string
description: string
sizeBytes: number
diff --git a/src/shared/image-text-index.ts b/src/shared/image-text-index.ts
new file mode 100644
index 0000000..7b1ef39
--- /dev/null
+++ b/src/shared/image-text-index.ts
@@ -0,0 +1,427 @@
+/**
+ * 图片文字索引(Image OCR Derived Text)契约。
+ *
+ * 硬规则(与语音转写同源的设计约束):
+ * - 微信图片消息是 **authoritative source**,OCR 文本是 **derived content**。
+ * - OCR 文本绝不写回原始消息、绝不修改 WCDB、绝不伪装成用户发送的文字消息。
+ * - OCR 命中时 Evidence 必须回到**原始图片消息**,而不是一条虚构的 OCR 消息。
+ *
+ * 因此这里刻意分成两层:
+ * 1. `ImageOcrArtifact` —— 按「图片内容 + OCR 运行时指纹」去重的派生文本(可能一张图被转发到多个会话)。
+ * 2. `ImageOcrBinding` —— 「某个会话里的某条图片消息 → 某个 artifact」的绑定,保证去重不丢来源。
+ */
+
+/** 派生文本的引擎标识;与 System OCR 的引擎常量保持一致。 */
+export const IMAGE_TEXT_INDEX_ENGINE = 'windows-system-ocr'
+
+/** 派生库自身的 schema 版本(与 Knowledge 的 schema 相互独立)。 */
+export const IMAGE_TEXT_INDEX_SCHEMA_VERSION = 1
+
+/**
+ * OCR 并发上限。
+ *
+ * 当前实现**严格串行**(循环体内只有一次 await,无 Promise.all 扇出),等价于 1。
+ * 这个常量是后续调高的唯一入口:Windows OCR 是进程内 WinRT 调用,实测单张
+ * 20–40ms,串行已足够;调高只会和 Query Agent 抢 CPU。
+ */
+export const DEFAULT_IMAGE_TEXT_OCR_CONCURRENCY = 1
+
+/** 每个批次的图片条数;批间让出 event loop,保证 UI / 查询不被卡住。 */
+export const IMAGE_TEXT_INDEX_BATCH_SIZE = 12
+
+/** 已完成一批之后、回到会话循环前的让出时间。 */
+export const IMAGE_TEXT_INDEX_YIELD_MS = 0
+
+/** 单张图片的 OCR 结果状态。 */
+export type ImageOcrState =
+ /** 尚未处理 */
+ | 'pending'
+ /** 正在处理(进程中断后会回到 pending) */
+ | 'processing'
+ /** 成功识别出文字 */
+ | 'indexed'
+ /** 成功识别,但图片里没有文字(表情包 / 风景 / 头像…)——这是**正常终态**,不重试 */
+ | 'empty'
+ /** 图片消息本身缺少定位字段(md5 / datName),无法找到文件 */
+ | 'metadata_missing'
+ /** 图片文件已不存在(微信清理过原图与缩略图)——**正常终态**,不是 OCR 失败 */
+ | 'image_missing'
+ /**
+ * 解密服务不可用(运行时环境问题)。
+ *
+ * **这不是单张图片的失败** —— 它意味着整条流水线的前置依赖缺失。
+ * 它的存在会阻断 `complete`,并且正常流程应该在 preflight 就拦下、根本不写这种状态。
+ */
+ | 'decrypt_unavailable'
+ /** 找到了文件,但解密失败(密钥/账号上下文不对,或文件损坏) */
+ | 'decrypt_failed'
+ /** 解密产出无法识别为图片格式(解码失败) */
+ | 'decode_failed'
+ /** 解码成功,但 OCR 执行失败 */
+ | 'ocr_failed'
+ /** 用户取消时正在处理 */
+ | 'cancelled'
+
+/**
+ * 可以落库的状态。
+ *
+ * `processing` 是瞬态的(只存在于一次 pass 的内存里):进程崩溃后它没有任何意义,
+ * 而且它绝不允许进入 Knowledge 索引 —— Knowledge 只应该看到"已定态"。
+ */
+export type ImageOcrPersistedState = Exclude
+
+/** 终态集合:落在这里的状态不会在下次 pass 被自动重试。 */
+export const IMAGE_OCR_TERMINAL_STATES: readonly ImageOcrState[] = [
+ 'indexed',
+ 'empty',
+ 'metadata_missing',
+ 'image_missing',
+ 'decrypt_failed',
+ 'decode_failed',
+ 'ocr_failed',
+ 'cancelled'
+]
+
+/**
+ * 运行时不可用态:**不是**单张图片的终态。
+ *
+ * 它与终态分开,是为了让「这 4.5 万张都失败了」永远不能被当成"该条已处理"。
+ */
+export const IMAGE_OCR_RUNTIME_UNAVAILABLE_STATES: readonly ImageOcrState[] = [
+ 'decrypt_unavailable'
+]
+
+export function isTerminalImageOcrState(state: ImageOcrState): boolean {
+ return IMAGE_OCR_TERMINAL_STATES.includes(state)
+}
+
+export function isRuntimeUnavailableImageOcrState(state: ImageOcrState): boolean {
+ return IMAGE_OCR_RUNTIME_UNAVAILABLE_STATES.includes(state)
+}
+
+/**
+ * **可重试的失败态**。
+ *
+ * 代码修好之后,这些状态的记录可以安全地重跑 —— 它们要么是运行时依赖缺失,
+ * 要么是"当时环境不对"造成的失败。重置只删这些绑定与它们的 checkpoint,
+ * 成功记录(indexed / empty)一条都不动。
+ */
+export const IMAGE_OCR_RETRIABLE_FAILURE_STATES: readonly ImageOcrPersistedState[] = [
+ 'decrypt_unavailable',
+ 'decrypt_failed',
+ 'decode_failed',
+ 'ocr_failed'
+]
+
+/**
+ * OCR 运行时指纹。
+ *
+ * 缓存身份**不能只是图片 hash**:换 OCR 引擎 / 升级运行时 / 换语言配置之后
+ * 必须允许重新识别,否则用户会永远拿到旧引擎的结果。
+ */
+export interface ImageOcrProvenance {
+ engine: string
+ platform: string
+ runtimeVersion: string | null
+ /** 实际使用的 OCR 语言标签;null 表示由系统用户语言决定。 */
+ language: string | null
+}
+
+/**
+ * artifact 去重键 = 图片内容身份 + OCR 运行时指纹。
+ *
+ * 刻意不包含 conversationId / messageId —— 同一张图片被转发到多个会话时,
+ * OCR 只算一次,但会有多条 binding 指向同一个 artifact。
+ */
+export function buildImageOcrArtifactKey(input: {
+ imageIdentity: string
+ provenance: ImageOcrProvenance
+}): string {
+ const { imageIdentity, provenance } = input
+ return [
+ imageIdentity,
+ provenance.engine,
+ provenance.platform,
+ provenance.runtimeVersion ?? 'unknown',
+ provenance.language ?? 'auto'
+ ].join('|')
+}
+
+/** 派生文本记录(按 artifact key 唯一)。 */
+export interface ImageOcrArtifact {
+ accountId: string
+ artifactKey: string
+ imageIdentity: string
+ state: ImageOcrPersistedState
+ /** OCR 正文;`empty` 状态为空串。 */
+ text: string
+ charCount: number
+ engine: string
+ platform: string
+ runtimeVersion: string | null
+ language: string | null
+ /** 只在失败时写入;用于诊断,绝不包含 OCR 正文。 */
+ errorCode?: string
+ createdAt: number
+ updatedAt: number
+}
+
+/** 「某会话的某条图片消息」到 artifact 的绑定。 */
+export interface ImageOcrBinding {
+ accountId: string
+ conversationId: string
+ messageId: string
+ /** Unix epoch **毫秒**(Knowledge 契约统一用毫秒)。 */
+ createTime: number
+ senderId?: string
+ senderName?: string
+ /** 图片内容身份(去重维度 1)。 */
+ imageIdentity: string
+ /**
+ * 指向的 artifact(内容身份 + OCR 运行时指纹)。
+ *
+ * 必须携带完整 artifact key 而不是只存 imageIdentity:换了 OCR 引擎/运行时之后
+ * 同一张图会有多个 artifact,绑定必须能精确指到"这次用哪个指纹算出来的文本"。
+ */
+ artifactKey: string
+ state: ImageOcrPersistedState
+ updatedAt: number
+}
+
+/** 索引任务运行态。 */
+export type ImageTextIndexRunState =
+ | 'idle'
+ | 'counting'
+ | 'running'
+ | 'paused'
+ | 'completed'
+ | 'cancelled'
+ | 'error'
+
+/** 进度(面向 UI;只含数字与状态,绝不含 OCR 正文 / 路径 / wxid)。 */
+export interface ImageTextIndexProgress {
+ state: ImageTextIndexRunState
+ /** 检测到的图片消息总数(SQL 统计,未解密)。 */
+ totalImageMessages: number
+ processed: number
+ indexed: number
+ empty: number
+ missing: number
+ failed: number
+ /** 运行时不可用(如解密服务缺失);不计入 processed,且会阻断 complete。 */
+ runtimeUnavailable: number
+ /** 系统性失败(处理过但一条都没成功)——UI 必须显示"异常"而不是"已建立"。 */
+ systemicFailure: boolean
+ pending: number
+ /** 0–100,保留 1 位小数;未完成时封顶 99.9。 */
+ percent: number
+ /** 处理进度百分比(与 percent 同源,语义化别名)。 */
+ processedPercent: number
+ startedAt?: number
+ updatedAt: number
+ cancellable: boolean
+ paused: boolean
+ lastError?: string
+}
+
+/**
+ * 图片文字索引的覆盖度 —— **独立的覆盖维度**。
+ *
+ * 文字消息索引 100% 不代表图片文字可用;Query Agent 必须能单独看到这一维。
+ */
+export interface ImageTextIndexCoverage {
+ totalImageMessages: number
+ /** 已进入**非运行时**终态的条数(indexed + empty + missing + failed)。 */
+ processed: number
+ indexed: number
+ empty: number
+ missing: number
+ failed: number
+ /**
+ * 运行时不可用(如解密服务缺失)的条数。
+ *
+ * 单独一列、**不计入 processed**:它代表"流水线前置依赖缺失",
+ * 绝不能与"这条图片已经处理过了"混为一谈。
+ */
+ runtimeUnavailable: number
+ pending: number
+ /** 是否建立过(有落盘统计且处理过)。 */
+ established: boolean
+ /** 是否**真正**覆盖完整(分母可信 + 无 pending + 无运行时不可用 + 不是"全军覆没")。 */
+ complete: boolean
+ /**
+ * 系统性失败:处理过一批,但 indexed / empty / missing 全为 0、失败却不为 0。
+ *
+ * 这就是"4.5 万张全部失败、却告诉用户已建立"那种情况的判据 ——
+ * 它必须阻断 `complete`,并让 UI 显示"异常"。
+ */
+ systemicFailure: boolean
+ /**
+ * `totalImageMessages` 的统计时刻(epoch ms);null = 从未统计过。
+ *
+ * 必须有这个时间戳:total 是**某一时刻**的 SQL 统计,之后微信里新增的图片
+ * 还没进索引。只说"已覆盖全部 N 条"而不给统计时刻,就是在把「当时完整」
+ * 冒充成「现在完整」。
+ */
+ countedAt: number | null
+}
+
+/** 覆盖度状态(外加"未建立")。UI 与 Query Agent 共用同一判据,避免两处各推一套口径漂移。 */
+export type ImageTextCoverageState = 'not_built' | 'partial' | 'complete' | 'failed'
+
+export function imageTextCoverageState(coverage: ImageTextIndexCoverage): ImageTextCoverageState {
+ if (!coverage.established) return 'not_built'
+ if (coverage.systemicFailure) return 'failed'
+ return coverage.complete ? 'complete' : 'partial'
+}
+
+/**
+ * 处理进度百分比。
+ *
+ * 保留 1 位小数,且**未完成时封顶 99.9%**:
+ * `Math.round(45479 / 45707 * 100)` 会得到 `100`,于是出现了"已建立 · 仅完成 100%"
+ * 这种自相矛盾的显示。进度条可以近似,结论句不行。
+ */
+export function imageTextProcessedPercent(processed: number, total: number): number {
+ if (!(total > 0)) return 0
+ const raw = (processed / total) * 100
+ if (raw >= 100) return 100
+ return Math.min(99.9, Math.round(raw * 10) / 10)
+}
+
+/** 覆盖度的人话结论,供 Query Agent / UI 直接引用。 */
+export function describeImageTextCoverage(coverage: ImageTextIndexCoverage): string {
+ if (!coverage.established) {
+ return '图片文字索引尚未建立:目前只能搜索文字消息,图片里的文字还搜不到。'
+ }
+ if (coverage.systemicFailure) {
+ return `图片文字索引当前异常:已处理的 ${coverage.processed.toLocaleString()} 条图片消息全部失败(成功识别 0 条、无文字 0 条、图片缺失 0 条)。当前无法搜索图片中的文字。`
+ }
+ if (coverage.complete) {
+ return `图片文字索引已覆盖全部 ${coverage.totalImageMessages.toLocaleString()} 条图片消息。`
+ }
+ const percent = imageTextProcessedPercent(coverage.processed, coverage.totalImageMessages)
+ return `图片文字索引只完成 ${percent}%(${coverage.processed.toLocaleString()} / ${coverage.totalImageMessages.toLocaleString()} 条图片消息),当前图片搜索结果可能不完整。`
+}
+
+/** 快速统计结果(不含解密)。 */
+export interface ImageTextIndexCountResult {
+ totalImageMessages: number
+ scannedConversations: number
+ /**
+ * 统计失败(拿不到数)的会话数。
+ *
+ * 必须与 `totalImageMessages = 0` 区分开:**"一张图片都没有"和"根本没数成"是两件事**。
+ * 把后者显示成 0 会让用户以为账号里没有图片,从而放弃建立索引 —— 这正是本功能
+ * 一直在避免的那类谎话。
+ */
+ failedConversations: number
+ /** 实际用于判定"这是图片消息"的列名;null = 一个会话都没探测到。 */
+ typeColumn: string | null
+ /** 失败原因摘要(仅供诊断,不含用户数据)。 */
+ error?: string
+ durationMs: number
+}
+
+/**
+ * 单个会话的图片消息计数探针。
+ *
+ * `count: null` = **统计失败**,不等于 0 张。调用方必须区分处理。
+ */
+export interface ImageMessageCountProbe {
+ count: number | null
+ /** 实际用于判定图片消息的类型列名。 */
+ typeColumn: string | null
+ /** 失败原因摘要(不含任何用户内容)。 */
+ error?: string
+}
+
+/**
+ * 会话级增量水位。
+ *
+ * 刻意用 **两个** 判据而不是只比 count:
+ * - `count` 能发现大多数增删;
+ * - `maxLocalId`(消息插入序的最大值)能发现「总数相同但集合变了」——
+ * 例如撤回一张旧图的同时新增一张新图,count 不变但新图的 local_id 更大。
+ *
+ * 只用 count 会静默漏掉新图片;只用 create_time 会被「后到的旧时间消息」
+ * (网络延迟 / 消息恢复 / 合并转发回填)骗过。`local_id` 是 WCDB 行内单调的
+ * 插入序,对 append 与「等量替换」两种情况都成立。
+ */
+export interface ImageMessageWatermark {
+ count: number
+ /** 该会话图片消息的最大插入序;没有图片时为 0。 */
+ maxLocalId: number
+}
+
+/**
+ * 派生索引修复的结果。
+ *
+ * 分层前提(任何一层都不许越界去动上一层):
+ * - L1 Image OCR Artifact —— 昂贵,持久化,**尽量永不重复计算**
+ * - L2 Message Binding —— 便宜,可修复
+ * - L3 Knowledge Derived Entry / FTS —— 便宜,可重建
+ * - L4 Query Agent / Evidence —— 查询层,只读
+ *
+ * 修 L2/L3/L4 **绝不能**自动清 L1。`ocrExecutions` 因此被写死成字面量 `0`:
+ * 修复路径一旦开始调 OCR,类型就不再成立,编译期就会拦下来。
+ */
+export interface ImageTextIndexRepairResult {
+ /** 实际重建了派生索引的会话数。 */
+ conversations: number
+ /** 永远是 0 —— 修复路径禁止触发 OCR(这一条是契约,不是观察值)。 */
+ ocrExecutions: 0
+ durationMs: number
+ /** 索引任务正在运行时拒绝并发修复(避免读到半程 binding)。 */
+ skipped: boolean
+}
+
+/** 派生数据占用(设置 → 缓存与清理)。 */
+export interface ImageTextIndexStorageStats {
+ indexedImages: number
+ ocrTextCount: number
+ totalBytes: number
+ updatedAt: number | null
+}
+
+/** 索引过程中用于写入派生库的单条结果。 */
+export interface ImageOcrWriteInput {
+ accountId: string
+ conversationId: string
+ messageId: string
+ createTime: number
+ senderId?: string
+ senderName?: string
+ /** 已解密的图片内容身份;取不到图片时为 null。 */
+ imageIdentity: string | null
+ state: ImageOcrPersistedState
+ text: string
+ provenance: ImageOcrProvenance
+ errorCode?: string
+}
+
+/** 索引服务的启动参数。 */
+export interface ImageTextIndexStartOptions {
+ /** 只处理前 N 个会话,用于受控 smoke;不传 = 全量。 */
+ conversationLimit?: number
+ /** 只处理前 N 条图片消息,用于受控 smoke。 */
+ messageLimit?: number
+ /**
+ * 只处理这个时刻(epoch ms)**之后**的图片消息;不传 = 全部历史。
+ *
+ * 存在的意义是**可验证性**:几万张图片的全量回填没法用来排查问题,
+ * 先跑"最近一天"这种小窗口才能证明链路是通的。
+ * 带窗口运行时会**跳过增量跳过逻辑**(每次都重扫窗口内的消息),
+ * 因为 checkpoint 是围绕全量集合建立的,混用会让"跳过"变得不可解释。
+ */
+ sinceMs?: number
+}
+
+/** 对外状态快照(问问微信卡片 / 设置清理页共用同一份)。 */
+export interface ImageTextIndexStatus {
+ progress: ImageTextIndexProgress
+ coverage: ImageTextIndexCoverage
+ storage: ImageTextIndexStorageStats
+ /** 正在做「检测到多少条图片消息」的 SQL 统计。 */
+ counting: boolean
+}
diff --git a/src/shared/knowledge.ts b/src/shared/knowledge.ts
index e9539a9..cfdedcf 100644
--- a/src/shared/knowledge.ts
+++ b/src/shared/knowledge.ts
@@ -42,8 +42,30 @@ export interface KnowledgeSourceMessage {
voiceTranscript?: string
/** Local coverage state only. Error text is never copied into the index. */
voiceTranscriptState?: 'pending' | 'transcribed' | 'failed'
+ /**
+ * 图片里的文字(本地 System OCR 的派生结果)。
+ *
+ * 与 voiceTranscript 同构:这是 **derived content**,原图片消息仍然是
+ * authoritative source。它绝不写回 message.body,也绝不产生"OCR 消息"。
+ */
+ imageOcrText?: string
+ /** 图片 OCR 的本地状态;与 voiceTranscriptState 一样不含错误正文。 */
+ imageOcrState?: KnowledgeImageOcrState
}
+/** 图片 OCR 的本地覆盖状态(错误详情绝不进索引)。 */
+export type KnowledgeImageOcrState =
+ | 'pending'
+ | 'indexed'
+ | 'empty'
+ | 'metadata_missing'
+ | 'image_missing'
+ | 'decrypt_unavailable'
+ | 'decrypt_failed'
+ | 'decode_failed'
+ | 'ocr_failed'
+ | 'cancelled'
+
export interface KnowledgeNormalizedMessage extends KnowledgeSourceMessage {
searchableText: string
contentHash: string
@@ -194,9 +216,38 @@ export interface KnowledgeEvidence {
/** The source type belongs to the original message, not the retrieval method. */
sourceKind: KnowledgeMessageKind
text: string
+ /**
+ * 这条证据里「从图片里读出来的文字」(本地 System OCR 的派生结果)。
+ *
+ * 只用于**来源解释**:让用户/模型知道这段内容来自图片,而不是群友真的发了一条文字消息。
+ * authoritative source 始终是原始图片消息 —— 这里不产生任何"OCR 消息"。
+ */
+ imageOcrText?: string
+ /**
+ * 命中所依赖的**派生来源**。
+ *
+ * 有值 = 这条结果依赖本地派生内容才能命中(而不是原始消息本身的文字)。
+ * 与 `sourceKind` 正交:`sourceKind` 说的是原始消息是什么,这里说的是"靠什么搜到的"。
+ */
+ derivedSource?: 'image_ocr'
score?: number
}
+/**
+ * 证据文本面向用户 / 模型时的可读化处理。
+ *
+ * `searchableText` 里的 `图片文字:` 只是索引期用来区分派生内容的内部标签,
+ * 它**绝不能出现在 Evidence 里**:用户不该看到引擎内部前缀,
+ * 而且"这段文字来自图片"应该由结构化的来源标记表达,而不是靠一个冒号前缀。
+ */
+export function toEvidenceDisplayText(searchableText: string): string {
+ return searchableText
+ .split('\n')
+ .map((line) => line.replace(/^\s*(?:图片文字|OCR|system-ocr)\s*[::]\s*/i, ''))
+ .join('\n')
+ .trim()
+}
+
export interface KnowledgeVoiceCoverage {
voiceMessageCount: number
transcribedVoiceCount: number
diff --git a/src/shared/local-query-api.ts b/src/shared/local-query-api.ts
index 0a51bb3..255aa0b 100644
--- a/src/shared/local-query-api.ts
+++ b/src/shared/local-query-api.ts
@@ -112,6 +112,21 @@ export interface QueryEvidenceItem
/** 该证据所属会话的展示名(群名 / 联系人名)。 */
conversationName?: string
conversationType?: 'user' | 'group'
+ /**
+ * 命中所依赖的**派生来源**(与 `sourceKind` 正交)。
+ *
+ * 有值时 Evidence UI 加一个轻量来源标记(如「图片文字」),
+ * 让用户知道这段内容来自**图片里的文字**,而不是群友真的发了一条文字消息。
+ * authoritative source 仍然是原始图片消息,`messageRef` 也仍然指向原图。
+ */
+ derivedSource?: 'image_ocr'
+ /**
+ * 「从图片里读出来的文字」片段,只用作命中解释。
+ *
+ * 刻意与 `text` 分开:`text` 是这条消息的内容,这里只回答"命中是因为图里的哪段文字"。
+ * 普通文字消息不会有这个字段。
+ */
+ imageOcrText?: string
}
/**
@@ -217,6 +232,28 @@ export interface QueryMessage {
url?: string
sizeBytes?: number
}
+ /**
+ * 图片 OCR 派生文本(**仅**图片消息、且本地已识别出文字时存在)。
+ *
+ * 它是 derived content,不是消息正文:这条消息的正文仍然是空的"图片附件",
+ * authoritative evidence 也仍然是**原始图片消息**(`messageRef` 指向它)。
+ * 之所以必须单独一个字段而不是塞进 `text`:一旦混进去,模型与 UI 就无法区分
+ * "群友发了一段文字"和"图片里识别出这段文字",而这正是本功能的诚实性前提。
+ */
+ imageOcrText?: string
+ /** 派生来源语义:`image_ocr` = 这段文字来自图片识别,而不是原始文字消息。 */
+ derivedSource?: 'image_ocr'
+ /**
+ * 这条图片消息在本地图片文字索引里的状态。
+ *
+ * - `indexed`:识别过且有文字(此时 `imageOcrText` 有值)
+ * - `empty`:识别过,但图里确实没有文字 —— 这是**已知结论**,不是"没索引"
+ * - `not_indexed`:尚未进入索引(未建立 / 还没处理到 / 已被清理)
+ *
+ * 区分这三者是硬要求:`not_indexed` 不允许被当成"图里没内容",
+ * `empty` 也不允许被当成"可以凭画面猜内容"(OCR 不是 Vision)。
+ */
+ imageTextState?: 'indexed' | 'empty' | 'not_indexed'
}
export interface QueryMessagesResponse {
status: string
@@ -228,6 +265,14 @@ export interface QueryMessagesResponse {
candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
/** 本次实际使用的语料边界。 */
scope?: ResolvedCorpusScope
+ /**
+ * 图片文字索引的覆盖度。
+ *
+ * 与 `search_messages` 同源同口径 —— 精确读消息这条路径同样必须知道
+ * "图片里的文字到底索引了多少",否则模型在图片文字尚未索引时
+ * 只能看到一个光秃秃的 `attachment`,进而把"索引缺口"说成"图片没有文字"。
+ */
+ imageOcrCoverage?: QueryImageTextCoverage
}
export interface SearchMessagesRequest {
target: QueryTarget
@@ -279,6 +324,13 @@ export interface SearchMessagesResponse {
* **本地时间**与结论,模型只需引用,不需要自己判断,也不需要输出 epoch 数字。
*/
indexCoverage?: QueryIndexCoverage
+ /**
+ * 图片文字索引覆盖度(**独立于**文字索引的维度)。
+ *
+ * `state !== 'complete'` 时,涉及图片/截图/海报的问题**不允许**因为 0 条证据
+ * 就回答"没有"——必须说明图片文字索引尚未完成、当前结果无法覆盖全部图片。
+ */
+ imageOcrCoverage?: QueryImageTextCoverage
/** 本次检索的真实耗时分解(ADDITIVE,用于诊断与 UI 展示;不进入模型上下文)。 */
timings?: QuerySearchTimings
}
@@ -294,6 +346,33 @@ export interface QueryIndexCoverage {
summary: string
}
+/**
+ * 图片文字索引(本地 OCR 派生文本)的覆盖度 —— 与文字索引覆盖度**互相独立**。
+ *
+ * 为什么必须单独一个维度:文字消息索引 100% 不代表图片里的文字可被搜索。
+ * 图片 OCR 是用户确认后才建立的重活,可能"未建立",也可能"只做了 30%"。
+ * 这时如果模型因为 0 条证据就回答"没有",就是把**索引缺口**说成了**事实空缺**。
+ */
+export interface QueryImageTextCoverage {
+ /**
+ * `failed` = 索引**当前异常**(处理过一批但一条都没成功,或运行时依赖缺失)。
+ *
+ * 它与 `partial` 都必须让 Query Agent 拒绝凭零结果下"没有"的结论。
+ */
+ state: 'not_built' | 'partial' | 'complete' | 'failed'
+ totalImageMessages: number
+ processed: number
+ indexed: number
+ empty: number
+ missing: number
+ failed: number
+ pending: number
+ /** 图片数量统计时刻(本地时间 `MM-DD HH:mm`);从未统计时为 undefined。 */
+ countedAtLabel?: string
+ /** 可直接引用的结论句;模型只引用,不要自己换算或推断。 */
+ summary: string
+}
+
/**
* `search_messages` 的真实耗时分解(ADDITIVE 诊断字段)。
*
@@ -346,6 +425,13 @@ export interface ConversationOverviewResponse {
evidence?: QueryEvidenceItem[]
candidates?: Array<{ displayName: string; type: 'user' | 'group' }>
scope?: ResolvedCorpusScope
+ /**
+ * 图片文字索引覆盖度(**独立维度**,与 `voiceCoverage` 平级)。
+ *
+ * 会话概览以源数据为准,所以能如实反映"这段时间聊了什么";但"图片里的文字"
+ * 只存在于本地 OCR 派生索引里,概览的完整性**不覆盖**这一维。
+ */
+ imageOcrCoverage?: QueryImageTextCoverage
/**
* 证据来源:`wcdb` = 直接读源数据(会话概览的事实来源);`knowledge` = 派生索引。
* 派生索引可能滞后,故概览以源数据为准。
diff --git a/src/shared/query-agent.ts b/src/shared/query-agent.ts
index 99543ff..079b566 100644
--- a/src/shared/query-agent.ts
+++ b/src/shared/query-agent.ts
@@ -33,6 +33,15 @@ export interface AskWechatEvidenceItem {
timestamp?: number
messageType?: string
text?: string
+ /**
+ * 命中所依赖的派生来源(与 `messageType` 正交)。
+ *
+ * `image_ocr` = 这条结果靠**图片里的文字**命中,而不是群友真的发了一条文字消息。
+ * Evidence UI 会据此显示轻量来源标记。authoritative source 仍是原始图片消息。
+ */
+ derivedSource?: 'image_ocr'
+ /** 「从图片里读出来的文字」片段,只作命中解释(普通文字消息不会有)。 */
+ imageOcrText?: string
attachment?: { kind?: string; name?: string; url?: string; sizeBytes?: number }
/** 产生这条证据的 Tool(诊断 / 分组)。 */
source: string
@@ -133,6 +142,24 @@ export interface QueryAgentDiagnostics {
tools: string[]
totalMs: number
outcome: AskWechatOutcome
+ /**
+ * 本次查询里**实际取到 OCR 派生文本**的图片消息/证据条数(诊断,不含正文)。
+ *
+ * `0` 配合 `tools` 就能区分两种完全不同的故障:
+ * 图片文字索引没建(索引问题),还是建好了但查询路径没接上(链路问题)。
+ */
+ imageOcrTextCount?: number
+ /** 本次查询里图片文字索引的覆盖度状态(`not_built` / `partial` / `complete` / `failed`)。 */
+ imageOcrCoverageState?: string
+ /**
+ * 用户问题原文。
+ *
+ * 这一条**刻意**包含聊天内容:排查"同一个问题为什么这次答对上次答错"必须知道问的是什么。
+ * 日志只写在用户本机的应用日志目录(设置 → 检索诊断里可查看 / 清空),不上传、不进遥测。
+ */
+ question?: string
+ /** 模型最终回答原文(同上,仅本地日志,用于排查)。 */
+ answer?: string
}
export type AskWechatOutcome =
diff --git a/tests/component/ai-search-cache-consent.test.tsx b/tests/component/ai-search-cache-consent.test.tsx
index 052c5db..4e51a48 100644
--- a/tests/component/ai-search-cache-consent.test.tsx
+++ b/tests/component/ai-search-cache-consent.test.tsx
@@ -7,6 +7,7 @@ import {
buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils'
import { makePipelineEvidence, makeSearchResult } from './support/ai-search-fixtures'
+import { makeImageTextIndexApi } from './support/image-text-index-api'
const api = {
getSettings: vi.fn(),
@@ -17,7 +18,9 @@ const api = {
getAiSearchProviderStatus: vi.fn(),
authorizeAiSearchExternalProvider: vi.fn(),
runAiSearch: vi.fn(),
- cancelAiSearch: vi.fn()
+ cancelAiSearch: vi.fn(),
+ // 侧栏新增的「图片文字索引」卡片会读这些桥接。
+ ...makeImageTextIndexApi()
}
describe('AISearchWorkspace cache privacy boundary', () => {
diff --git a/tests/component/ai-search-query-agent.test.tsx b/tests/component/ai-search-query-agent.test.tsx
index 2d7dc46..09d0d86 100644
--- a/tests/component/ai-search-query-agent.test.tsx
+++ b/tests/component/ai-search-query-agent.test.tsx
@@ -3,6 +3,7 @@ import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace'
import { aiSearchContact, aiSearchGroup, makeSearchResult } from './support/ai-search-fixtures'
+import { makeImageTextIndexApi } from './support/image-text-index-api'
import type { AskWechatQueryResult, AskWechatStats, QueryAgentProgressEvent } from '../../src/shared/query-agent'
type AnsweredResult = Extract
@@ -25,7 +26,9 @@ const api = {
runAskWechatQuery: vi.fn(),
forgetAskWechatConversation: vi.fn(),
onAskWechatProgress: vi.fn(),
- cancelKnowledgeIndex: vi.fn()
+ cancelKnowledgeIndex: vi.fn(),
+ // 侧栏新增的「图片文字索引」卡片会读这些桥接。
+ ...makeImageTextIndexApi()
}
const indexLatestAt = new Date('2026-09-11T11:57:24+08:00').getTime()
diff --git a/tests/component/ai-search-workspace-regression.test.tsx b/tests/component/ai-search-workspace-regression.test.tsx
index 6a0159d..4f82a79 100644
--- a/tests/component/ai-search-workspace-regression.test.tsx
+++ b/tests/component/ai-search-workspace-regression.test.tsx
@@ -14,6 +14,7 @@ import {
makePipelineEvidence,
makeSearchResult
} from './support/ai-search-fixtures'
+import { makeImageTextIndexApi } from './support/image-text-index-api'
const api = {
getSettings: vi.fn(),
@@ -28,7 +29,9 @@ const api = {
startKnowledgeIndex: vi.fn(),
writeAppLog: vi.fn(),
revealAppLog: vi.fn(),
- copyText: vi.fn()
+ copyText: vi.fn(),
+ // 侧栏新增的「图片文字索引」卡片会读这些桥接;漏掉任何一个都会让卡片挂载即抛错。
+ ...makeImageTextIndexApi()
}
const readyKnowledgeStatus = {
diff --git a/tests/component/evidence-image-ocr-source.test.tsx b/tests/component/evidence-image-ocr-source.test.tsx
new file mode 100644
index 0000000..07760cd
--- /dev/null
+++ b/tests/component/evidence-image-ocr-source.test.tsx
@@ -0,0 +1,106 @@
+/**
+ * §1 / §17:Evidence 的「图片文字」来源语义。
+ *
+ * 三条不能退让的约束:
+ * 1. 来自图片 OCR 的命中,UI 必须有轻量来源标记(「图片文字」),
+ * 让用户知道这段内容来自图片,而不是群友真的发了一条文字消息;
+ * 2. authoritative source 仍然是**原始图片消息** —— messageRef 不变,跳转目标就是原图;
+ * 3. 引擎内部前缀(`图片文字:` / `OCR:` / `system-ocr`)绝不允许出现在用户可见文本里;
+ * 4. 普通文字消息的 Evidence 完全不受影响(不该凭空多出一个标记)。
+ */
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
+import { mapAskWechatEvidence } from '../../src/renderer/src/components/search/askWechatPresentation'
+import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
+import type { AskWechatEvidenceItem } from '../../src/shared/query-agent'
+import { encodeMessageRef } from '../../src/shared/local-query-api'
+
+const OCR_TEXT = 'OpenAI ChatGPT Plus $20 Pro $200'
+const IMAGE_REF = encodeMessageRef('md5-tech-group', '9001')
+const TEXT_REF = encodeMessageRef('md5-tech-group', '9002')
+
+/** Query Agent 交给渲染层的证据(图片 OCR 命中)。 */
+const imageOcrEvidence: AskWechatEvidenceItem = {
+ messageRef: IMAGE_REF,
+ conversationName: '技术交流群',
+ conversationType: 'group',
+ sender: '张三',
+ timestamp: Date.parse('2026-09-03T14:32:00+08:00'),
+ messageType: 'image',
+ // 已经由 main 侧剥掉内部前缀的可读文本
+ text: OCR_TEXT,
+ derivedSource: 'image_ocr',
+ imageOcrText: OCR_TEXT,
+ source: 'search_messages'
+}
+
+/** 普通文字消息证据(对照组)。 */
+const plainEvidence: AskWechatEvidenceItem = {
+ messageRef: TEXT_REF,
+ conversationName: '技术交流群',
+ conversationType: 'group',
+ sender: '张三',
+ timestamp: Date.parse('2026-09-03T14:30:00+08:00'),
+ messageType: 'text',
+ text: '今天正常讨论一下 API',
+ source: 'search_messages'
+}
+
+function renderPanel(evidence: EvidenceItem[]) {
+ const props: React.ComponentProps = {
+ evidence,
+ collectionCount: evidence.length,
+ selectedEvidence: 0,
+ evidenceFlash: { index: -1, nonce: 0 },
+ senderNames: {},
+ hasMoreEvidence: false,
+ onFocusEvidence: vi.fn(),
+ onJumpToEvidence: vi.fn(),
+ onLoadMoreEvidence: vi.fn(),
+ setEvidenceCardRef: vi.fn()
+ }
+ render()
+ return props
+}
+
+describe('图片文字 Evidence 的来源语义', () => {
+ it('映射层保留派生来源与 OCR 片段,且跳转目标仍是原始图片消息', () => {
+ const [mapped] = mapAskWechatEvidence([imageOcrEvidence])
+
+ expect(mapped.derivedSource).toBe('image_ocr')
+ expect(mapped.imageOcrText).toBe(OCR_TEXT)
+ // authoritative source = 原始图片消息:引用不变
+ expect(mapped.messageRef).toBe(IMAGE_REF)
+ expect(mapped.message.id).toBe('9001')
+ expect(mapped.contact.m_nsNickName).toBe('技术交流群')
+ expect(mapped.sourceKind).toBe('image')
+ })
+
+ it('图片 OCR 命中显示「图片文字」标记与命中解释,不泄露内部前缀', () => {
+ const evidence = mapAskWechatEvidence([imageOcrEvidence])
+ renderPanel(evidence)
+
+ const badge = screen.getByTestId('evidence-image-ocr-badge')
+ expect(badge).toBeVisible()
+ expect(badge.textContent).toBe('图片文字')
+
+ const snippet = screen.getByTestId('evidence-image-ocr-snippet')
+ expect(snippet.textContent).toContain(OCR_TEXT)
+
+ // 内部前缀绝不能出现在用户可见文本里
+ const panelText = document.body.textContent || ''
+ expect(panelText).not.toContain('图片文字:')
+ expect(panelText).not.toContain('OCR:')
+ expect(panelText).not.toContain('system-ocr')
+ })
+
+ it('普通文字消息的 Evidence 不受影响:没有来源标记,也没有 OCR 片段', () => {
+ const evidence = mapAskWechatEvidence([plainEvidence])
+ renderPanel(evidence)
+
+ expect(screen.queryByTestId('evidence-image-ocr-badge')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('evidence-image-ocr-snippet')).not.toBeInTheDocument()
+ expect(screen.getByText('今天正常讨论一下 API')).toBeVisible()
+ })
+})
diff --git a/tests/component/image-text-index-card.test.tsx b/tests/component/image-text-index-card.test.tsx
new file mode 100644
index 0000000..3bfdfcd
--- /dev/null
+++ b/tests/component/image-text-index-card.test.tsx
@@ -0,0 +1,332 @@
+/**
+ * §3 / §4:「图片文字索引」卡片的用户可见行为。
+ *
+ * 这些断言对应的是产品需求里**写死的**交互契约,不是实现细节:
+ * - 未建立时先给出检测到的图片消息数量,而不是一个空洞的按钮;
+ * - 点击建立**必须先弹确认**,不允许立刻全量开跑;
+ * - 确认弹窗要写清本机执行、原图不会因识别而自动上传、可暂停、实际可识别数量取决于本地文件;
+ * - 进度只给真实数字(processed/total、识别出文字、没有文字、图片已清理、失败、百分比);
+ * - 暂停 / 继续 / 取消三个动作都在,且暂停后能继续;
+ * - 重启后进度来自主进程快照(这里用「首帧就是 paused 快照」模拟)。
+ */
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ImageTextIndexCard } from '../../src/renderer/src/components/search/ImageTextIndexCard'
+import type { ImageTextIndexStatus } from '../../src/shared/image-text-index'
+
+function status(overrides: Partial = {}): ImageTextIndexStatus {
+ return {
+ progress: {
+ state: 'idle',
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ pending: 0,
+ percent: 0,
+ updatedAt: 0,
+ cancellable: false,
+ paused: false
+ },
+ coverage: {
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ pending: 0,
+ established: false,
+ complete: false,
+ countedAt: null
+ },
+ storage: { indexedImages: 0, ocrTextCount: 0, totalBytes: 0, updatedAt: null },
+ counting: false,
+ ...overrides
+ }
+}
+
+const notBuilt = status()
+
+const running = status({
+ progress: {
+ state: 'running',
+ totalImageMessages: 12_483,
+ processed: 3842,
+ indexed: 2917,
+ empty: 412,
+ missing: 378,
+ failed: 135,
+ pending: 8641,
+ percent: 30.8,
+ updatedAt: 1,
+ cancellable: true,
+ paused: false
+ },
+ coverage: {
+ totalImageMessages: 12_483,
+ processed: 3842,
+ indexed: 2917,
+ empty: 412,
+ missing: 378,
+ failed: 135,
+ pending: 8641,
+ established: true,
+ complete: false,
+ countedAt: 1
+ }
+})
+
+const paused = status({
+ ...running,
+ progress: { ...running.progress, state: 'paused', cancellable: false, paused: true }
+})
+
+const api = {
+ getImageTextIndexStatus: vi.fn(),
+ countImageMessages: vi.fn(),
+ startImageTextIndex: vi.fn(),
+ pauseImageTextIndex: vi.fn(),
+ resumeImageTextIndex: vi.fn(),
+ cancelImageTextIndex: vi.fn(),
+ resetImageTextIndexFailures: vi.fn(),
+ repairImageTextIndex: vi.fn(),
+ onImageTextIndexStatus: vi.fn(() => () => undefined)
+}
+
+let pushStatus: ((next: ImageTextIndexStatus) => void) | undefined
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ pushStatus = undefined
+ api.getImageTextIndexStatus.mockResolvedValue(notBuilt)
+ api.countImageMessages.mockResolvedValue({
+ totalImageMessages: 12_483,
+ scannedConversations: 42,
+ durationMs: 30
+ })
+ api.startImageTextIndex.mockResolvedValue({ started: true, state: 'running' })
+ api.pauseImageTextIndex.mockResolvedValue({ paused: true, state: 'paused' })
+ api.resumeImageTextIndex.mockResolvedValue({ started: true, state: 'running' })
+ api.cancelImageTextIndex.mockResolvedValue({ cancellable: true, cancelled: true })
+ api.onImageTextIndexStatus.mockImplementation((callback: (next: ImageTextIndexStatus) => void) => {
+ pushStatus = callback
+ return () => undefined
+ })
+ Object.defineProperty(window, 'api', { configurable: true, value: api })
+})
+
+async function renderCard(): Promise<{ onNotice: ReturnType }> {
+ const onNotice = vi.fn()
+ await act(async () => {
+ render()
+ })
+ return { onNotice }
+}
+
+describe('图片文字索引卡片', () => {
+ it('未建立时给出检测到的图片消息数量,而不是一个空洞的按钮', async () => {
+ await renderCard()
+ expect(screen.getByTestId('image-text-index-state').textContent).toBe('未建立')
+ expect(screen.getByTestId('image-text-index-count').textContent).toBe('12,483')
+ expect(screen.getByTestId('image-text-index-start').textContent).toBe('建立图片文字索引')
+ })
+
+ it('点击建立先弹确认,并把范围、隐私与不可控因素写清楚,确认前不启动', async () => {
+ await renderCard()
+ await userEvent.click(screen.getByTestId('image-text-index-start'))
+
+ // Radix 的 AlertDialog 用的是 role="alertdialog"(不是 "dialog")。
+ const dialog = await screen.findByRole('alertdialog')
+ expect(dialog.textContent).toContain('12,483')
+ expect(dialog.textContent).toContain('仅在本机进行识别')
+ expect(dialog.textContent).toContain('不会因为本地识别而自动上传')
+ expect(dialog.textContent).toContain('可以暂停并稍后继续')
+ expect(dialog.textContent).toContain('取决于本地图片文件是否仍然存在')
+ // 确认之前绝不允许开跑。
+ expect(api.startImageTextIndex).not.toHaveBeenCalled()
+
+ await userEvent.click(screen.getByTestId('image-text-index-confirm'))
+ await vi.waitFor(() => expect(api.startImageTextIndex).toHaveBeenCalledTimes(1))
+ })
+
+ it('索引进度只给真实数字,并提供暂停与取消', async () => {
+ api.getImageTextIndexStatus.mockResolvedValue(running)
+ await renderCard()
+
+ expect(screen.getByTestId('image-text-index-state').textContent).toBe('建立中 · 30.8%')
+ expect(screen.getByTestId('image-text-index-progress').textContent).toBe('3,842 / 12,483')
+ const passLine = screen.getByText(/识别出文字 2,917/)
+ expect(passLine.textContent).toContain('识别出文字 2,917')
+ expect(passLine.textContent).toContain('没有文字 412')
+ expect(passLine.textContent).toContain('图片已清理 378')
+ expect(passLine.textContent).toContain('失败 135')
+ // 底层实现细节绝不外泄。
+ expect(document.body.textContent).not.toMatch(/HRESULT|0x[0-9A-Fa-f]{8}/)
+
+ await userEvent.click(screen.getByTestId('image-text-index-pause'))
+ expect(api.pauseImageTextIndex).toHaveBeenCalledTimes(1)
+
+ await userEvent.click(screen.getByTestId('image-text-index-cancel'))
+ expect(api.cancelImageTextIndex).toHaveBeenCalledTimes(1)
+ })
+
+ it('暂停后可以继续,进度仍来自主进程快照', async () => {
+ api.getImageTextIndexStatus.mockResolvedValue(paused)
+ await renderCard()
+
+ expect(screen.getByTestId('image-text-index-state').textContent).toBe('已暂停 · 30.8%')
+ await userEvent.click(screen.getByTestId('image-text-index-resume'))
+ expect(api.resumeImageTextIndex).toHaveBeenCalledTimes(1)
+ })
+
+ it('主进程推送真实进度后,卡片跟着更新(重启后恢复的进度同一条路径)', async () => {
+ await renderCard()
+ expect(screen.getByTestId('image-text-index-state').textContent).toBe('未建立')
+
+ await act(async () => {
+ pushStatus?.(running)
+ })
+ expect(screen.getByTestId('image-text-index-state').textContent).toBe('建立中 · 30.8%')
+ expect(screen.getByTestId('image-text-index-progress').textContent).toBe('3,842 / 12,483')
+ })
+
+ it('统计失败时显示「无法统计」而不是 0,并给出原因与重新统计入口', async () => {
+ api.countImageMessages.mockResolvedValue({
+ totalImageMessages: 0,
+ scannedConversations: 0,
+ failedConversations: 7,
+ typeColumn: null,
+ error: '读取消息分片失败',
+ durationMs: 5
+ })
+ await renderCard()
+
+ const value = screen.getByTestId('image-text-index-count')
+ expect(value.textContent).toBe('无法统计')
+ // 这是最关键的一条:绝不能把"数不出来"显示成 0。
+ expect(value.textContent).not.toBe('0')
+
+ const error = screen.getByTestId('image-text-index-count-error')
+ expect(error.textContent).toContain('读取消息分片失败')
+ expect(error.textContent).toContain('不代表账号里没有图片')
+ expect(screen.getByTestId('image-text-index-recount')).toBeVisible()
+ })
+
+ it('部分会话统计失败时给出真实数字并提示偏小', async () => {
+ api.countImageMessages.mockResolvedValue({
+ totalImageMessages: 420,
+ scannedConversations: 30,
+ failedConversations: 2,
+ typeColumn: 'local_type',
+ durationMs: 9
+ })
+ await renderCard()
+
+ expect(screen.getByTestId('image-text-index-count').textContent).toBe('420')
+ expect(screen.getByTestId('image-text-index-count-error').textContent).toContain(
+ '2 个会话未能统计'
+ )
+ })
+
+ it('真的没有图片时才显示 0,且不出现失败提示', async () => {
+ api.countImageMessages.mockResolvedValue({
+ totalImageMessages: 0,
+ scannedConversations: 12,
+ failedConversations: 0,
+ typeColumn: 'local_type',
+ durationMs: 4
+ })
+ await renderCard()
+
+ expect(screen.getByTestId('image-text-index-count').textContent).toBe('0')
+ expect(screen.queryByTestId('image-text-index-count-error')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('image-text-index-recount')).not.toBeInTheDocument()
+ })
+})
+
+/**
+ * 派生索引修复按钮的存在意义就是"别为修一个索引问题重跑几万张图"。
+ *
+ * 因此这里断言的重点是**措辞**:用户看到"修复"两个字必须能确信
+ * 不会又要等一小时 —— 否则这个按钮没人敢点,功能等于不存在。
+ */
+describe('图片文字索引卡片 — 修复图片搜索索引', () => {
+ const established = status({
+ progress: {
+ state: 'idle',
+ totalImageMessages: 45_740,
+ processed: 45_508,
+ indexed: 14_342,
+ empty: 31_126,
+ missing: 38,
+ failed: 2,
+ pending: 232,
+ percent: 99.5,
+ updatedAt: 1,
+ cancellable: false,
+ paused: false
+ },
+ coverage: {
+ totalImageMessages: 45_740,
+ processed: 45_508,
+ indexed: 14_342,
+ empty: 31_126,
+ missing: 38,
+ failed: 2,
+ pending: 232,
+ established: true,
+ complete: false,
+ countedAt: 1,
+ runtimeUnavailable: 0,
+ systemicFailure: false
+ }
+ } as Partial)
+
+ it('已建立且空闲时提供修复入口,只在点击后调用主进程', async () => {
+ api.getImageTextIndexStatus.mockResolvedValue(established)
+ api.repairImageTextIndex.mockResolvedValue({
+ conversations: 12,
+ ocrExecutions: 0,
+ durationMs: 800,
+ skipped: false
+ })
+ const { onNotice } = await renderCard()
+
+ const button = screen.getByTestId('image-text-index-repair')
+ expect(button.textContent).toBe('修复图片搜索索引')
+ expect(api.repairImageTextIndex).not.toHaveBeenCalled()
+
+ await userEvent.click(button)
+
+ expect(api.repairImageTextIndex).toHaveBeenCalledTimes(1)
+ // 提示语必须讲清楚"没有重新识别",否则用户会以为又要跑几万张图。
+ expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('没有重新识别任何图片')
+ expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('12')
+ })
+
+ it('索引正在跑时不提供修复入口(并发重建会读到半程状态)', async () => {
+ api.getImageTextIndexStatus.mockResolvedValue(running)
+ await renderCard()
+
+ expect(screen.queryByTestId('image-text-index-repair')).not.toBeInTheDocument()
+ })
+
+ it('主进程拒绝并发修复时如实告知,不谎称已修复', async () => {
+ api.getImageTextIndexStatus.mockResolvedValue(established)
+ api.repairImageTextIndex.mockResolvedValue({
+ conversations: 0,
+ ocrExecutions: 0,
+ durationMs: 0,
+ skipped: true
+ })
+ const { onNotice } = await renderCard()
+
+ await userEvent.click(screen.getByTestId('image-text-index-repair'))
+
+ expect(String(onNotice.mock.calls.at(-1)?.[0])).toContain('正在进行中')
+ })
+})
diff --git a/tests/component/support/image-text-index-api.ts b/tests/component/support/image-text-index-api.ts
new file mode 100644
index 0000000..d210cf5
--- /dev/null
+++ b/tests/component/support/image-text-index-api.ts
@@ -0,0 +1,70 @@
+import { vi } from 'vitest'
+import type { ImageTextIndexStatus } from '../../../src/shared/image-text-index'
+
+/**
+ * 默认的「未建立」快照。
+ *
+ * 与主进程 `getStatus()` 在派生库不存在时返回的形状逐字段一致 ——
+ * 测试 fake 要是自己编一个形状,就测不出真实的字段缺失。
+ */
+export function notBuiltImageTextIndexStatus(): ImageTextIndexStatus {
+ return {
+ progress: {
+ state: 'idle',
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ pending: 0,
+ percent: 0,
+ updatedAt: 0,
+ cancellable: false,
+ paused: false
+ },
+ coverage: {
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ pending: 0,
+ established: false,
+ complete: false,
+ countedAt: null
+ },
+ storage: { indexedImages: 0, ocrTextCount: 0, totalBytes: 0, updatedAt: null },
+ counting: false
+ }
+}
+
+/**
+ * Ask-WeChat 相关工作区测试用的「图片文字索引」桥接 fake。
+ *
+ * 真实 preload 一定暴露这些方法;测试里的 `window.api` 是手写对象字面量,
+ * 漏掉任何一个都会让卡片在挂载期抛错,并连带**整个工作区**渲染失败
+ * (一个次要侧栏卡片不该有能力搞挂主界面)。
+ */
+export function makeImageTextIndexApi(overrides: Record = {}): Record {
+ return {
+ getImageTextIndexStatus: vi.fn().mockResolvedValue(notBuiltImageTextIndexStatus()),
+ countImageMessages: vi.fn().mockResolvedValue({
+ totalImageMessages: 0,
+ scannedConversations: 0,
+ durationMs: 0
+ }),
+ startImageTextIndex: vi.fn().mockResolvedValue({ started: true, state: 'running' }),
+ pauseImageTextIndex: vi.fn().mockResolvedValue({ paused: true, state: 'paused' }),
+ resumeImageTextIndex: vi.fn().mockResolvedValue({ started: true, state: 'running' }),
+ cancelImageTextIndex: vi.fn().mockResolvedValue({ cancellable: true, cancelled: true }),
+ clearImageTextIndex: vi.fn().mockResolvedValue({ removed: true, removedBytes: 0 }),
+ resetImageTextIndexFailures: vi.fn().mockResolvedValue({ reset: 0 }),
+ repairImageTextIndex: vi
+ .fn()
+ .mockResolvedValue({ conversations: 0, ocrExecutions: 0, durationMs: 0, skipped: false }),
+ onImageTextIndexStatus: vi.fn(() => () => undefined),
+ ...overrides
+ }
+}
diff --git a/tests/integration/image-text-index-incident.test.ts b/tests/integration/image-text-index-incident.test.ts
new file mode 100644
index 0000000..1b6a2a3
--- /dev/null
+++ b/tests/integration/image-text-index-incident.test.ts
@@ -0,0 +1,283 @@
+/**
+ * 事故回归:**"4.5 万张全部失败,UI 却说已建立"** 这一整套语义。
+ *
+ * 真机现场(派生库实测):
+ * total = 45,707 / 全部 binding = decrypt_failed 45,479 / artifacts = 0 行
+ * 根因是解密服务在回填时不存在(只在 db:getImage 里懒加载),每张图都在
+ * `processOne` 第一步就失败。这里把"不许再发生"的四件事钉死:
+ * 1. 前置依赖缺失时必须**一条记录都不写**(preflight);
+ * 2. 处理过但一条没成功 = **异常**,不是"已建立";
+ * 3. 百分比不许四舍五入到 100(45,479 / 45,707);
+ * 4. 重置失败记录**不能**动已经成功的记录。
+ */
+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 {
+ ImageTextIndexStore,
+ getImageTextIndexDatabasePath
+} from '../../src/main/services/image-text-index-store'
+import {
+ describeImageTextCoverage,
+ imageTextCoverageState,
+ imageTextProcessedPercent,
+ type ImageTextIndexCoverage
+} from '../../src/shared/image-text-index'
+
+const ACCOUNT = 'wxid_incident_fixture'
+const CONVERSATION = 'md5-incident'
+const roots: string[] = []
+
+function makeRoot(): string {
+ const root = mkdtempSync(join(tmpdir(), 'tm-image-incident-'))
+ roots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+function imageMessage(localId: number): chat.FormattedMessage {
+ return {
+ localId: String(localId),
+ createTime: 1_700_000_000 + localId,
+ content: '[图片]',
+ contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` }
+ } as unknown as chat.FormattedMessage
+}
+
+function coverageOf(overrides: Partial): ImageTextIndexCoverage {
+ return {
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ runtimeUnavailable: 0,
+ pending: 0,
+ established: false,
+ complete: false,
+ systemicFailure: false,
+ countedAt: null,
+ ...overrides
+ }
+}
+
+describe('事故语义:全失败不能叫"已建立"', () => {
+ it('indexed/empty/missing 全为 0 而 failed 不为 0 → 异常,且 complete 必为 false', () => {
+ const coverage = coverageOf({
+ totalImageMessages: 45_707,
+ processed: 45_479,
+ failed: 45_479,
+ pending: 228,
+ established: true,
+ countedAt: 1_789_516_520_246,
+ complete: false, // 服务侧已经算出 false;这里验证状态与文案
+ systemicFailure: true
+ })
+
+ expect(imageTextCoverageState(coverage)).toBe('failed')
+ expect(describeImageTextCoverage(coverage)).toContain('当前异常')
+ expect(describeImageTextCoverage(coverage)).not.toContain('已覆盖全部')
+ })
+
+ it('45,479 / 45,707 不能显示成 100%', () => {
+ // Math.round(45479 / 45707 * 100) === 100 —— 这正是"仅完成 100%"的来源。
+ expect(Math.round((45_479 / 45_707) * 100)).toBe(100)
+ // 正确口径:保留 1 位小数,未完成时封顶 99.9。
+ expect(imageTextProcessedPercent(45_479, 45_707)).toBe(99.5)
+ expect(imageTextProcessedPercent(45_707, 45_707)).toBe(100)
+ expect(imageTextProcessedPercent(0, 0)).toBe(0)
+ })
+
+ it('服务侧:一条都没成功时不给 complete,并把状态判成 failed', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ store.writeCountedTotal({ total: 100, countedAt: 1, complete: true })
+ for (let index = 1; index <= 30; index += 1) {
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: `local:${index}`,
+ createTime: index,
+ imageIdentity: '',
+ artifactKey: `unavailable|${index}`,
+ state: 'decrypt_failed',
+ updatedAt: index
+ })
+ }
+ store.close()
+
+ const service = new ImageTextIndexService()
+ service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
+ const status = await service.getStatus()
+
+ expect(status.coverage.processed).toBe(30)
+ expect(status.coverage.failed).toBe(30)
+ expect(status.coverage.systemicFailure).toBe(true)
+ expect(status.coverage.complete).toBe(false)
+ expect(imageTextCoverageState(status.coverage)).toBe('failed')
+ // 收干净句柄:Windows 上没关连接会让临时目录清理 EBUSY。
+ service.resetAccount()
+ })
+
+ it('运行时不可用不计入 processed,并且阻断 complete', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ store.writeCountedTotal({ total: 10, countedAt: 1, complete: true })
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: 'local:1',
+ createTime: 1,
+ imageIdentity: '',
+ artifactKey: 'unavailable|1',
+ state: 'decrypt_unavailable',
+ updatedAt: 1
+ })
+ store.close()
+
+ const service = new ImageTextIndexService()
+ service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
+ const status = await service.getStatus()
+
+ expect(status.coverage.runtimeUnavailable).toBe(1)
+ expect(status.coverage.processed).toBe(0)
+ expect(status.coverage.complete).toBe(false)
+ service.resetAccount()
+ })
+
+ it('部分成功 + 部分图片缺失 → 仍然是正常的"部分完成"(不误判成异常)', () => {
+ const coverage = coverageOf({
+ totalImageMessages: 100,
+ processed: 100,
+ indexed: 60,
+ empty: 20,
+ missing: 20,
+ established: true,
+ countedAt: 1,
+ complete: true,
+ systemicFailure: false
+ })
+ expect(imageTextCoverageState(coverage)).toBe('complete')
+ expect(describeImageTextCoverage(coverage)).toContain('已覆盖全部')
+ })
+})
+
+describe('事故防线:前置依赖缺失时一条记录都不写', () => {
+ it('解密服务不可用 → pass 直接报错,不写任何 binding', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [
+ { md5: CONVERSATION, m_nsUsrName: 'incident', type: 'group' as const }
+ ],
+ listMessages: async () => [imageMessage(1)],
+ countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ count: 1, maxLocalId: 1 }),
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN'
+ }),
+ // 关键:没有解密服务(本次事故的根因形态)
+ decryptService: () => null
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+
+ const status = await service.getStatus()
+ // 这一条就是整场事故的防线:宁可一次都不跑,也不要写 45,479 条假失败。
+ expect(status.progress.state).toBe('error')
+ expect(status.progress.lastError).toContain('解密服务')
+ expect(status.coverage.processed).toBe(0)
+ expect(status.coverage.failed).toBe(0)
+ expect(status.coverage.runtimeUnavailable).toBe(0)
+
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ expect(store.countByState()).toEqual({})
+ store.close()
+ service.resetAccount()
+ })
+})
+
+describe('事故收尾:重置失败记录不能动成功记录', () => {
+ it('只删失败绑定与它们的 checkpoint,indexed 一条不动', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ store.writeCountedTotal({ total: 3, countedAt: 1, complete: true })
+ store.putArtifact({
+ accountId: ACCOUNT,
+ artifactKey: 'good|1',
+ imageIdentity: 'sha256:good',
+ state: 'indexed',
+ text: 'TRACE_KEEP_ME',
+ charCount: 13,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN',
+ createdAt: 1,
+ updatedAt: 1
+ })
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: 'local:1',
+ createTime: 1,
+ imageIdentity: 'sha256:good',
+ artifactKey: 'good|1',
+ state: 'indexed',
+ updatedAt: 1
+ })
+ for (const id of [2, 3]) {
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: `local:${id}`,
+ createTime: id,
+ imageIdentity: '',
+ artifactKey: `bad|${id}`,
+ state: 'decrypt_failed',
+ updatedAt: id
+ })
+ }
+ store.writeScanState({
+ conversationId: CONVERSATION,
+ state: 'done',
+ imageTotal: 3,
+ imageProcessed: 3,
+ maxLocalId: 3
+ })
+ store.close()
+
+ const service = new ImageTextIndexService()
+ service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
+ const result = await service.resetRetriableFailures()
+ expect(result.reset).toBe(2)
+
+ // 成功记录与它的 artifact 必须原封不动。
+ const after = new ImageTextIndexStore(databasePath, ACCOUNT)
+ expect(after.countByState()).toEqual({ indexed: 1 })
+ expect(after.getArtifact('good|1')?.text).toBe('TRACE_KEEP_ME')
+ // checkpoint 也要清掉,否则下一轮会以"该会话已完成"直接跳过(点了重试却没反应)。
+ expect(after.readScanState().size).toBe(0)
+ after.close()
+ service.resetAccount()
+ })
+})
diff --git a/tests/integration/image-text-index-incremental-repair.test.ts b/tests/integration/image-text-index-incremental-repair.test.ts
new file mode 100644
index 0000000..968d42c
--- /dev/null
+++ b/tests/integration/image-text-index-incremental-repair.test.ts
@@ -0,0 +1,312 @@
+/**
+ * 两条**成本**契约(都在真机上被踩过):
+ *
+ * 1. 「更新图片文字索引」必须是增量的。
+ * 真机已经跑了 1 小时、留下 14,342 条 OCR 文本 + 31,126 条 empty 终态。
+ * OCR 是昂贵产物,Knowledge / FTS / binding 才是可重建派生层 ——
+ * 更新时只能处理 new / pending / retryable,**已有终态一条都不许重算**。
+ *
+ * 2. 修索引问题不许重跑 OCR(Derived Index Repair)。
+ * 只重建 L3(Knowledge 派生条目 / FTS),数据来源是已有 L1/L2;
+ * `ocrExecutions: 0` 是写进类型字面量的契约,不是"期望值"。
+ */
+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 {
+ ImageTextIndexStore,
+ getImageTextIndexDatabasePath
+} from '../../src/main/services/image-text-index-store'
+
+const ACCOUNT = 'wxid_incremental_fixture'
+const CONVERSATION = 'md5-incremental'
+const roots: string[] = []
+
+function makeRoot(): string {
+ const root = mkdtempSync(join(tmpdir(), 'tm-image-incremental-'))
+ roots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+/** 合法的 PNG 头(`detectSystemOcrImageFormat` 只认前 4 字节),尾部塞一个唯一序号。 */
+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
+}
+
+/** 假的派生库预热:写入 `count` 条已完成的 OCR 记录(成功终态)。 */
+function seedTerminal(store: ImageTextIndexStore, count: number, state: 'indexed' | 'empty'): void {
+ for (let index = 1; index <= count; index += 1) {
+ const artifactKey = `seeded|${index}`
+ store.putArtifact({
+ accountId: ACCOUNT,
+ artifactKey,
+ imageIdentity: `sha256:seeded-${index}`,
+ state,
+ text: state === 'indexed' ? `TRACE_SEEDED_${index}` : '',
+ charCount: state === 'indexed' ? 16 : 0,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN',
+ createdAt: index,
+ updatedAt: index
+ })
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: `local:${index}`,
+ createTime: index,
+ imageIdentity: `sha256:seeded-${index}`,
+ artifactKey,
+ state,
+ updatedAt: index
+ })
+ }
+}
+
+describe('增量更新:已有终态直接复用,只对新增/未完成做 OCR', () => {
+ it('已有 100 条终态 + 新增 10 张 + 2 条未完成 → OCR 只跑 12 次', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ seedTerminal(store, 100, 'indexed')
+ // 2 条"未完成":binding 状态不是终态 → 允许重试。
+ for (const id of [111, 112]) {
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: `local:${id}`,
+ createTime: id,
+ imageIdentity: '',
+ artifactKey: `pending|${id}`,
+ state: 'pending',
+ updatedAt: id
+ })
+ }
+ // 旧 checkpoint:当时该会话只有 100 张、水位 100。
+ store.writeScanState({
+ conversationId: CONVERSATION,
+ state: 'done',
+ imageTotal: 100,
+ imageProcessed: 100,
+ maxLocalId: 100
+ })
+ store.close()
+
+ // 现在源数据变成 112 张(1..100 已有终态,101..110 全新,111..112 之前没跑完)。
+ const messages = Array.from({ length: 112 }, (_, index) => imageMessage(index + 1))
+ const recognize = vi.fn(async () => ({
+ success: true,
+ text: 'TRACE_FRESH_TEXT',
+ language: 'zh-Hans-CN'
+ }))
+ const decryptImage = vi.fn((path: string) => {
+ const seed = Number(String(path).replace(/\D/g, '')) || 0
+ return pngBytes(seed)
+ })
+
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [
+ { md5: CONVERSATION, m_nsUsrName: 'incremental', type: 'user' as const }
+ ],
+ listMessages: async () => messages,
+ countConversationImages: async () => ({ count: 112, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ count: 112, maxLocalId: 112 }),
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN'
+ }),
+ decryptService: () => ({ findImageFile: (md5) => `C:/fake/${md5}.dat`, decryptImage }) as never,
+ recognize
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+
+ // 关键断言:12 次,而不是 112 次。
+ expect(recognize).toHaveBeenCalledTimes(12)
+ // 已有终态那 100 条连解密都不该碰。
+ expect(decryptImage).toHaveBeenCalledTimes(12)
+
+ const after = new ImageTextIndexStore(databasePath, ACCOUNT)
+ // 100 条旧终态 + 100 个旧 artifact 一条不少,文本原样保留(不重算、不覆盖)。
+ expect(after.countByState().indexed).toBe(112)
+ expect(after.getArtifact('seeded|1')?.text).toBe('TRACE_SEEDED_1')
+ expect(after.getArtifact('seeded|100')?.text).toBe('TRACE_SEEDED_100')
+ after.close()
+ service.resetAccount()
+ })
+
+ it('水位完全没变 → 整个会话直接跳过,OCR 一次都不调', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ seedTerminal(store, 20, 'empty')
+ store.writeScanState({
+ conversationId: CONVERSATION,
+ state: 'done',
+ imageTotal: 20,
+ imageProcessed: 20,
+ maxLocalId: 20
+ })
+ store.close()
+
+ const recognize = vi.fn(async () => ({ success: true, text: 'X', language: null }))
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [
+ { md5: CONVERSATION, m_nsUsrName: 'incremental', type: 'user' as const }
+ ],
+ listMessages: async () => Array.from({ length: 20 }, (_, index) => imageMessage(index + 1)),
+ countConversationImages: async () => ({ count: 20, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ count: 20, maxLocalId: 20 }),
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: null
+ }),
+ decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
+ recognize
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+
+ expect(recognize).not.toHaveBeenCalled()
+ service.resetAccount()
+ })
+})
+
+describe('派生索引修复:只重建 L3,绝不重跑 OCR', () => {
+ it('只重建"有 OCR 文本"的会话,且 recognize 一次都不被调用', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ seedTerminal(store, 3, 'indexed')
+ // 另一个会话只有 empty(没有派生文本可修)→ 不该被重建,白读一遍 WCDB。
+ store.putArtifact({
+ accountId: ACCOUNT,
+ artifactKey: 'empty-only|1',
+ imageIdentity: 'sha256:empty-only',
+ state: 'empty',
+ text: '',
+ charCount: 0,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: null,
+ createdAt: 1,
+ updatedAt: 1
+ })
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: 'md5-empty-only',
+ messageId: 'local:1',
+ createTime: 1,
+ imageIdentity: 'sha256:empty-only',
+ artifactKey: 'empty-only|1',
+ state: 'empty',
+ updatedAt: 1
+ })
+ store.close()
+
+ const recognize = vi.fn(async () => ({ success: true, text: 'X', language: null }))
+ const onConversationIndexed = vi.fn(async () => undefined)
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ recognize,
+ onConversationIndexed
+ })
+
+ const result = await service.repairKnowledgeIndex()
+
+ expect(result.skipped).toBe(false)
+ expect(result.conversations).toBe(1)
+ // 契约:修复路径的定义就是"不调 OCR"。类型上写死成字面量 0。
+ expect(result.ocrExecutions).toBe(0)
+ expect(recognize).not.toHaveBeenCalled()
+ expect(onConversationIndexed).toHaveBeenCalledTimes(1)
+ expect(onConversationIndexed).toHaveBeenCalledWith(CONVERSATION)
+ service.resetAccount()
+ })
+
+ it('索引任务正在跑时拒绝并发修复(避免读到半程 binding)', async () => {
+ const databaseRoot = makeRoot()
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ seedTerminal(store, 1, 'indexed')
+ store.close()
+
+ let release: (() => void) | null = null
+ const gate = new Promise((resolve) => {
+ release = resolve
+ })
+
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [
+ { md5: CONVERSATION, m_nsUsrName: 'incremental', type: 'user' as const }
+ ],
+ listMessages: async () => [imageMessage(1)],
+ countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ count: 1, maxLocalId: 1 }),
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: null
+ }),
+ decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
+ // 卡住 pass,让 running 保持为真。
+ interactiveIdle: () => gate
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(true))
+
+ const during = await service.repairKnowledgeIndex()
+ expect(during.skipped).toBe(true)
+ expect(during.conversations).toBe(0)
+
+ release?.()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+ service.resetAccount()
+ })
+})
diff --git a/tests/integration/image-text-index-knowledge-invalidation.test.ts b/tests/integration/image-text-index-knowledge-invalidation.test.ts
new file mode 100644
index 0000000..da576a0
--- /dev/null
+++ b/tests/integration/image-text-index-knowledge-invalidation.test.ts
@@ -0,0 +1,283 @@
+/**
+ * §2 / §3 的硬条件:清理图片文字索引必须让 **Knowledge 里已经产生的 OCR 派生文字**一起失效。
+ *
+ * 背景:OCR 文本经 normalizer 的固定前缀 `图片文字:` 拼进 `searchableText`,
+ * 再进 chunks / FTS。所以"清理成功"不能只等于"派生 SQLite 删掉了" ——
+ * 用户执行设置里的「清理图片文字索引」之后,`search_messages` 必须搜不到那些图片文字,
+ * 同时**普通文字消息必须一条不少地留着**。
+ *
+ * 本文件分两部分:
+ * - A:Knowledge 侧的失效机制本身成立(内容变了 / 消息被移除都会被重建替换);
+ * - B:生产路径真的触发了它(`imageTextIndexService.clear()` 会逐会话重建)。
+ */
+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 {
+ DEFAULT_KNOWLEDGE_CHUNKER,
+ type KnowledgeFtsConfig,
+ type KnowledgeSourceMessage
+} from '../../src/shared/knowledge'
+import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
+import { ImageTextIndexService } from '../../src/main/services/image-text-index-service'
+import { getImageTextIndexDatabasePath } from '../../src/main/services/image-text-index-store'
+import type * as chat from '../../src/main/services/chat-service'
+
+const ACCOUNT = 'fixture-account-image-ocr'
+const CONVERSATION = 'conversation-image-ocr'
+const OCR_TOKEN = 'TRACE_IMAGE_OCR_UNIQUE_2026'
+const PLAIN_TEXT = '普通聊天内容保留'
+const IMAGE_MESSAGE_ID = 'local:9001'
+const TEXT_MESSAGE_ID = 'local:9002'
+
+const fts: KnowledgeFtsConfig = {
+ profileId: 'test-trigram-external-full',
+ tokenizer: 'trigram',
+ contentMode: 'external',
+ detail: 'full',
+ columnsize: 1
+}
+
+const roots: string[] = []
+
+function makeRoot(): string {
+ const root = mkdtempSync(join(tmpdir(), 'wxe-image-ocr-invalidation-'))
+ roots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+function textMessage(): KnowledgeSourceMessage {
+ return {
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: TEXT_MESSAGE_ID,
+ createTime: Date.UTC(2026, 8, 1, 10, 0),
+ senderId: 'fixture-member-1',
+ senderName: '张三',
+ kind: 'text',
+ text: PLAIN_TEXT
+ }
+}
+
+/** 带 OCR 派生文本的图片消息(这是 OCR 索引建立后的状态)。 */
+function imageMessageWithOcr(caption?: string): KnowledgeSourceMessage {
+ return {
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: IMAGE_MESSAGE_ID,
+ createTime: Date.UTC(2026, 8, 1, 10, 5),
+ senderId: 'fixture-member-2',
+ senderName: '李四',
+ kind: 'image',
+ ...(caption ? { text: caption } : {}),
+ imageOcrText: OCR_TOKEN,
+ imageOcrState: 'indexed'
+ }
+}
+
+/** 同一张图片,但 OCR 派生文本已经不存在(= 派生库被清掉后 resolver 拿不到东西)。 */
+function imageMessageWithoutOcr(caption?: string): KnowledgeSourceMessage {
+ return {
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: IMAGE_MESSAGE_ID,
+ createTime: Date.UTC(2026, 8, 1, 10, 5),
+ senderId: 'fixture-member-2',
+ senderName: '李四',
+ kind: 'image',
+ ...(caption ? { text: caption } : {})
+ }
+}
+
+function searchTokens(store: KnowledgeStore, text: string): string[] {
+ return store
+ .search({ accountId: ACCOUNT, text, limit: 20 })
+ .map((item) => item.messageId)
+}
+
+function evidenceFor(store: KnowledgeStore, text: string) {
+ return store.search({ accountId: ACCOUNT, text, limit: 20 })
+}
+
+async function indexConversation(
+ store: KnowledgeStore,
+ messages: KnowledgeSourceMessage[]
+): Promise {
+ await store.index({
+ conversations: [{ conversationId: CONVERSATION, completeSnapshot: true, messages }],
+ chunker: DEFAULT_KNOWLEDGE_CHUNKER
+ })
+}
+
+describe('§2-A Knowledge 侧的失效机制:OCR 派生文字必须能真的消失', () => {
+ it('图片消息仍然存在、只是 OCR 文本没了 → 旧 OCR 文字搜不到,普通文字不受影响', async () => {
+ const store = new KnowledgeStore(makeRoot(), ACCOUNT, fts)
+
+ // 1) 建立图片 OCR 派生记录 + 完成索引
+ await indexConversation(store, [textMessage(), imageMessageWithOcr()])
+
+ // 2) 必须能搜到,并且命中的是**原始图片消息**
+ const before = evidenceFor(store, OCR_TOKEN)
+ expect(before.length).toBeGreaterThan(0)
+ expect(before[0].messageId).toBe(IMAGE_MESSAGE_ID)
+ expect(before[0].sourceKind).toBe('image')
+
+ // 3) OCR 文本被清掉(模拟「清理图片文字索引」后重建)
+ await indexConversation(store, [textMessage(), imageMessageWithoutOcr()])
+
+ // 4) 旧 OCR 文字必须彻底搜不到
+ expect(searchTokens(store, OCR_TOKEN)).toEqual([])
+
+ // 5) 普通文字消息必须仍然命中 —— 不能清掉普通 Knowledge
+ expect(searchTokens(store, PLAIN_TEXT)).toContain(TEXT_MESSAGE_ID)
+
+ store.close()
+ })
+
+ it('无文字图片消息在 OCR 清掉后被整体移除 → 旧 OCR 文字同样搜不到', async () => {
+ const store = new KnowledgeStore(makeRoot(), ACCOUNT, fts)
+
+ // 这条图片消息除了 OCR 文本之外没有任何内容;OCR 一清,它就不该再进索引。
+ await indexConversation(store, [textMessage(), imageMessageWithOcr()])
+ expect(searchTokens(store, OCR_TOKEN).length).toBeGreaterThan(0)
+
+ await indexConversation(store, [textMessage()])
+
+ expect(searchTokens(store, OCR_TOKEN)).toEqual([])
+ expect(searchTokens(store, PLAIN_TEXT)).toContain(TEXT_MESSAGE_ID)
+
+ store.close()
+ })
+
+ it('§3:OCR 文本变化(state 仍是 indexed)也必须让旧文本失效', async () => {
+ const store = new KnowledgeStore(makeRoot(), ACCOUNT, fts)
+
+ await indexConversation(store, [textMessage(), imageMessageWithOcr()])
+ expect(searchTokens(store, OCR_TOKEN).length).toBeGreaterThan(0)
+
+ // 同一个 state(indexed),内容换成了另一段文字 —— 例如换了 OCR 运行时后重新识别。
+ const replaced = {
+ ...imageMessageWithOcr(),
+ imageOcrText: 'TRACE_IMAGE_OCR_REPLACED_2026'
+ }
+ await indexConversation(store, [textMessage(), replaced])
+
+ expect(searchTokens(store, OCR_TOKEN)).toEqual([])
+ expect(searchTokens(store, 'TRACE_IMAGE_OCR_REPLACED_2026')).toContain(IMAGE_MESSAGE_ID)
+
+ store.close()
+ })
+})
+
+describe('§2-B 生产路径:清理必须逐会话重建 Knowledge', () => {
+ function imageMessage(localId: number, conversationId: string): chat.FormattedMessage {
+ return {
+ localId: String(localId),
+ createTime: 1_700_000_000 + localId,
+ content: '[图片]',
+ contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` },
+ sessionId: conversationId
+ } as unknown as chat.FormattedMessage
+ }
+
+ it('clear() 对每个有 OCR 派生文本的会话都触发一次重建,而不是清空整个 Knowledge', async () => {
+ const databaseRoot = makeRoot()
+ const conversations = ['conv-alpha', 'conv-beta']
+ const onConversationIndexed = vi.fn(async () => undefined)
+
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () =>
+ conversations.map((md5) => ({ md5, m_nsUsrName: md5, type: 'group' as const })),
+ listMessages: async (conversationId) => [imageMessage(1, conversationId)],
+ countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ count: 1, maxLocalId: 1 }),
+ // 没有解密服务 → 图片判为 image_missing;这不影响"这条会话有没有 OCR 绑定"。
+ decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN'
+ }),
+ onConversationIndexed
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+
+ // 两个会话都真的产生了绑定。
+ const databasePath = getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ expect(onConversationIndexed).toHaveBeenCalledTimes(2)
+
+ onConversationIndexed.mockClear()
+ const result = await service.clear()
+
+ expect(result.removed).toBe(true)
+ // 关键:清理必须重建这两个会话,否则 Knowledge 里还留着图片文字。
+ expect(onConversationIndexed).toHaveBeenCalledTimes(2)
+ expect(onConversationIndexed.mock.calls.map((call) => call[0]).sort()).toEqual(
+ [...conversations].sort()
+ )
+ expect(service.lastInvalidatedConversations).toBe(2)
+ expect(databasePath).toBeTruthy()
+ })
+
+ it('prepareForCacheClear() 同样会做失效("清理全部"路径不能漏)', async () => {
+ const databaseRoot = makeRoot()
+ const onConversationIndexed = vi.fn(async () => undefined)
+
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [
+ { md5: CONVERSATION, m_nsUsrName: CONVERSATION, type: 'group' as const }
+ ],
+ listMessages: async () => [imageMessage(1, CONVERSATION)],
+ countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ count: 1, maxLocalId: 1 }),
+ decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN'
+ }),
+ onConversationIndexed
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+ onConversationIndexed.mockClear()
+
+ await service.prepareForCacheClear()
+
+ expect(onConversationIndexed).toHaveBeenCalledWith(CONVERSATION)
+ })
+
+ it('派生库里没有 OCR 绑定时,清理不触发任何无意义的重建', async () => {
+ const databaseRoot = makeRoot()
+ const onConversationIndexed = vi.fn(async () => undefined)
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ onConversationIndexed
+ })
+
+ const result = await service.clear()
+ expect(result.removed).toBe(true)
+ expect(onConversationIndexed).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/integration/image-text-index-p0.test.ts b/tests/integration/image-text-index-p0.test.ts
new file mode 100644
index 0000000..6f15fe4
--- /dev/null
+++ b/tests/integration/image-text-index-p0.test.ts
@@ -0,0 +1,409 @@
+/**
+ * 「图片文字索引」的 P0 语义测试。
+ *
+ * 这里覆盖的都是**不能用 UI 数字糊过去**的硬约束:
+ * - 覆盖度必须在重启后依然诚实(派生库只知道处理过什么,不知道源数据一共多少);
+ * - 增量判据必须能发现「总数没变但集合变了」;
+ * - 清理必须真的把文件删掉,删不掉要如实上报;
+ * - 涉及图片的问题在索引未完成时,答案语义里必须带"不能因为没搜到就说没有"。
+ */
+import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+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 {
+ ImageTextIndexStore,
+ getImageTextIndexDatabasePath
+} from '../../src/main/services/image-text-index-store'
+import { buildImageOcrCoverage } from '../../src/main/services/local-query-api-service'
+import type { ImageTextIndexCoverage } from '../../src/shared/image-text-index'
+
+const ACCOUNT = 'wxid_fixture_account'
+const CONVERSATION = 'conversation-md5-fixture'
+const roots: string[] = []
+
+function makeDatabaseRoot(): string {
+ const root = mkdtempSync(join(tmpdir(), 'tm-image-text-index-'))
+ roots.push(root)
+ return root
+}
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ try {
+ rmSync(root, { recursive: true, force: true })
+ } catch {
+ // 测试收尾尽力而为。
+ }
+ }
+})
+
+/** 只带图片索引需要的字段;其余字段与本测试无关。 */
+function imageMessage(localId: number, createTime: number): chat.FormattedMessage {
+ return {
+ localId: String(localId),
+ createTime,
+ content: '[图片]',
+ contentData: { type: 'image', md5: `md5-${localId}`, datName: `dat-${localId}` }
+ } as unknown as chat.FormattedMessage
+}
+
+type Harness = {
+ service: ImageTextIndexService
+ databaseRoot: string
+ listMessages: ReturnType
+ watermark: { count: number; maxLocalId: number }
+ databasePath: string
+}
+
+function makeHarness(options: { messages?: chat.FormattedMessage[] } = {}): Harness {
+ const databaseRoot = makeDatabaseRoot()
+ const listMessages = vi.fn(async () => options.messages ?? [])
+ const watermark = { count: 0, maxLocalId: 0 }
+ const service = new ImageTextIndexService()
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ resolveAccountRoot: () => 'C:/fixture/account',
+ listContacts: async () => [
+ { md5: CONVERSATION, m_nsUsrName: 'fixture', type: 'group' as const }
+ ],
+ listMessages,
+ countConversationImages: async () => ({ count: watermark.count, typeColumn: 'local_type' }),
+ imageWatermark: async () => ({ ...watermark }),
+ // 没有解密服务 → 每张图片都会被判成 image_missing。这样测试完全不碰真实图片。
+ decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN'
+ }),
+ recognize: async () => ({ success: true, text: '', language: 'zh-Hans-CN' })
+ })
+ return {
+ service,
+ databaseRoot,
+ listMessages,
+ watermark,
+ databasePath: getImageTextIndexDatabasePath(databaseRoot, ACCOUNT)
+ }
+}
+
+describe('§2 增量水位:只比条数会漏掉「等量替换」', () => {
+ it('水位(条数 + 最大插入序)都没变时才跳过,不读 WCDB', async () => {
+ const harness = makeHarness({ messages: [imageMessage(10, 1000), imageMessage(20, 2000)] })
+ harness.watermark.count = 2
+ harness.watermark.maxLocalId = 20
+
+ await harness.service.startPass()
+ // 走到完成态需要等内部 promise 收敛。
+ await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
+ expect(harness.listMessages).toHaveBeenCalledTimes(1)
+
+ // 第二遍:水位完全一致 → 跳过,不再读会话消息。
+ await harness.service.startPass()
+ await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
+ expect(harness.listMessages).toHaveBeenCalledTimes(1)
+ })
+
+ it('总数相同但最大插入序前进 → 必须重扫(撤回一张旧图 + 新增一张新图)', async () => {
+ const harness = makeHarness({ messages: [imageMessage(10, 1000), imageMessage(20, 2000)] })
+ harness.watermark.count = 2
+ harness.watermark.maxLocalId = 20
+
+ await harness.service.startPass()
+ await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
+ expect(harness.listMessages).toHaveBeenCalledTimes(1)
+
+ // 集合变了、条数没变:localId 10 被撤回,新增 localId 30。
+ harness.listMessages.mockImplementation(async () => [
+ imageMessage(20, 2000),
+ imageMessage(30, 3000)
+ ])
+ harness.watermark.maxLocalId = 30
+
+ await harness.service.startPass()
+ await vi.waitFor(() => expect(harness.service.isRunning()).toBe(false))
+ // 只看 count 的实现会在这里静默跳过 —— 那正是会漏掉新图片的洞。
+ expect(harness.listMessages).toHaveBeenCalledTimes(2)
+ })
+
+ it('水位不可用(数据库不支持该聚合)时一律重扫,宁可慢也不漏', async () => {
+ const harness = makeHarness({ messages: [imageMessage(10, 1000)] })
+ harness.watermark.count = 1
+ const service = new ImageTextIndexService()
+ const listMessages = vi.fn(async () => [imageMessage(10, 1000)])
+ service.bind({
+ databaseRoot: harness.databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [{ md5: CONVERSATION, m_nsUsrName: 'fixture', type: 'group' }],
+ listMessages,
+ countConversationImages: async () => ({ count: 1, typeColumn: 'local_type' }),
+ // 关键:不提供 imageWatermark
+ decryptService: () => ({ findImageFile: () => null, decryptImage: () => null }) as never,
+ capability: async () => ({
+ available: true,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: null,
+ language: null
+ })
+ })
+
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+ await service.startPass()
+ await vi.waitFor(() => expect(service.isRunning()).toBe(false))
+ expect(listMessages).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('§1 覆盖度诚实性', () => {
+ it('重启后仍是 partial:分母来自落盘统计,不会退化成 processed', async () => {
+ const { databaseRoot, databasePath } = makeHarness()
+ // 先按「已建立过索引」写库:总数 100,实际只处理了 30 条。
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ store.writeCountedTotal({ total: 100, countedAt: 1_700_000_000_000, complete: true })
+ for (let index = 0; index < 30; index += 1) {
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: `local:${index}`,
+ createTime: index,
+ imageIdentity: `sha256:${index}`,
+ artifactKey: `sha256:${index}|fake`,
+ state: 'indexed',
+ updatedAt: index
+ })
+ }
+ store.close()
+
+ // 全新 service 实例 = 模拟应用重启(内存计数器归零)。
+ const service = new ImageTextIndexService()
+ service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
+ const status = await service.getStatus()
+
+ expect(status.coverage.totalImageMessages).toBe(100)
+ expect(status.coverage.processed).toBe(30)
+ expect(status.coverage.established).toBe(true)
+ // 修复前这里会因为 total 退化成 processed 而变成 true(把 30% 谎报成 100%)。
+ expect(status.coverage.complete).toBe(false)
+ expect(status.coverage.countedAt).toBe(1_700_000_000_000)
+ })
+
+ it('统计时有会话没数上 → 分母不完整,不允许声称 complete', async () => {
+ const { databaseRoot, databasePath } = makeHarness()
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ store.writeCountedTotal({ total: 10, countedAt: 1, complete: false })
+ store.putBinding({
+ accountId: ACCOUNT,
+ conversationId: CONVERSATION,
+ messageId: 'local:1',
+ createTime: 1,
+ imageIdentity: 'sha256:1',
+ artifactKey: 'sha256:1|fake',
+ state: 'indexed',
+ updatedAt: 1
+ })
+ store.close()
+
+ const service = new ImageTextIndexService()
+ service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
+ const status = await service.getStatus()
+ expect(status.coverage.processed).toBe(1)
+ expect(status.coverage.complete).toBe(false)
+ })
+
+ it('从未统计过 → 不算已建立,且查询路径不为看覆盖度凭空建库', async () => {
+ const { databaseRoot, databasePath } = makeHarness()
+ const service = new ImageTextIndexService()
+ service.bind({ databaseRoot, resolveAccountId: () => ACCOUNT })
+ expect(service.getCoverageSnapshot()).toBeNull()
+ expect(existsSync(databasePath)).toBe(false)
+ })
+})
+
+describe('§5 清理:删得掉才算成功', () => {
+ it('清理后派生库文件消失,覆盖度回到未建立', async () => {
+ const { service, databasePath } = makeHarness()
+ // 建一份有内容的派生数据(建库 + 写 artifact/binding/水位 + 落盘总数)。
+ const store = new ImageTextIndexStore(databasePath, ACCOUNT)
+ store.writeCountedTotal({ total: 5, countedAt: 1, complete: true })
+ store.putArtifact({
+ accountId: ACCOUNT,
+ artifactKey: 'k',
+ imageIdentity: 'sha256:x',
+ state: 'indexed',
+ text: 'fixture',
+ charCount: 7,
+ engine: 'windows-system-ocr',
+ platform: 'win32',
+ runtimeVersion: '1.2.0',
+ language: 'zh-Hans-CN',
+ createdAt: 1,
+ updatedAt: 1
+ })
+ store.close()
+ expect(existsSync(databasePath)).toBe(true)
+
+ const result = await service.clear()
+ expect(result.removed).toBe(true)
+ expect(existsSync(databasePath)).toBe(false)
+
+ const status = await service.getStatus()
+ expect(status.coverage.established).toBe(false)
+ expect(status.coverage.totalImageMessages).toBe(0)
+ expect(status.coverage.countedAt).toBeNull()
+ })
+})
+
+describe('§1/§7 查询层:覆盖度必须是独立维度且带零结果诚实性', () => {
+ const coverageOf = (input: Partial): ImageTextIndexCoverage => ({
+ totalImageMessages: 0,
+ processed: 0,
+ indexed: 0,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ pending: 0,
+ established: false,
+ complete: false,
+ countedAt: null,
+ ...input
+ })
+
+ it('partial:必须明确「不能因为没搜到就回答没有」并给出真实比例', () => {
+ const built = buildImageOcrCoverage(
+ coverageOf({
+ totalImageMessages: 100,
+ processed: 30,
+ indexed: 28,
+ empty: 2,
+ established: true,
+ countedAt: 1_700_000_000_000
+ })
+ )
+ expect(built?.state).toBe('partial')
+ expect(built?.totalImageMessages).toBe(100)
+ expect(built?.processed).toBe(30)
+ expect(built?.summary).toContain('30')
+ expect(built?.summary).toContain('100')
+ expect(built?.summary).toContain('不能因为没搜到就回答')
+ })
+
+ it('complete:不附加零结果约束,但仍带上统计时刻', () => {
+ const built = buildImageOcrCoverage(
+ coverageOf({
+ totalImageMessages: 100,
+ processed: 100,
+ indexed: 90,
+ empty: 10,
+ established: true,
+ complete: true,
+ countedAt: 1_700_000_000_000
+ })
+ )
+ expect(built?.state).toBe('complete')
+ expect(built?.summary).not.toContain('不能因为没搜到就回答')
+ })
+
+ it('not_built:说明图片里的文字目前搜不到,且同样禁止凭零结果下"没有"', () => {
+ const built = buildImageOcrCoverage(coverageOf({}))
+ expect(built?.state).toBe('not_built')
+ expect(built?.summary).toContain('尚未建立')
+ expect(built?.summary).toContain('不能因为没搜到就回答')
+ })
+
+ it('没有覆盖度(库都不存在)时不下发该字段,不制造假维度', () => {
+ expect(buildImageOcrCoverage(null)).toBeUndefined()
+ })
+})
+
+describe('图片数量统计:必须区分「0 张」与「统计失败」', () => {
+ function bindCounting(
+ service: ImageTextIndexService,
+ databaseRoot: string,
+ probe: () => Promise<{ count: number | null; typeColumn: string | null; error?: string }>
+ ): void {
+ service.bind({
+ databaseRoot,
+ resolveAccountId: () => ACCOUNT,
+ listContacts: async () => [
+ { md5: 'conv-a', m_nsUsrName: 'a', type: 'group' as const },
+ { md5: 'conv-b', m_nsUsrName: 'b', type: 'group' as const }
+ ],
+ countConversationImages: probe
+ })
+ }
+
+ it('真的 0 张:scanned=2 / failed=0,可以放心说 0', async () => {
+ const service = new ImageTextIndexService()
+ bindCounting(service, makeDatabaseRoot(), async () => ({
+ count: 0,
+ typeColumn: 'local_type'
+ }))
+
+ const result = await service.countImageMessages()
+ expect(result.totalImageMessages).toBe(0)
+ expect(result.scannedConversations).toBe(2)
+ expect(result.failedConversations).toBe(0)
+ expect(result.typeColumn).toBe('local_type')
+ expect(result.error).toBeUndefined()
+ })
+
+ it('统计全部失败:不得表现为 0 张,且必须给出原因', async () => {
+ const service = new ImageTextIndexService()
+ bindCounting(service, makeDatabaseRoot(), async () => ({
+ count: null,
+ typeColumn: null,
+ error: '读取消息分片失败'
+ }))
+
+ const result = await service.countImageMessages()
+ expect(result.totalImageMessages).toBe(0)
+ // 关键区分:一个会话都没数成。
+ expect(result.scannedConversations).toBe(0)
+ expect(result.failedConversations).toBe(2)
+ expect(result.error).toBe('读取消息分片失败')
+ // 统计失败 → 分母不成立 → 不允许声称已建立覆盖。
+ const status = await service.getStatus()
+ expect(status.coverage.established).toBe(false)
+ expect(status.coverage.complete).toBe(false)
+ expect(status.coverage.countedAt).not.toBeNull()
+ })
+
+ it('部分失败:总数偏小,coverage 不允许声称 complete', async () => {
+ const service = new ImageTextIndexService()
+ let call = 0
+ bindCounting(service, makeDatabaseRoot(), async () => {
+ call += 1
+ return call === 1
+ ? { count: 10, typeColumn: 'local_type' }
+ : { count: null, typeColumn: null, error: '图片消息统计查询失败' }
+ })
+
+ const result = await service.countImageMessages()
+ expect(result.totalImageMessages).toBe(10)
+ expect(result.scannedConversations).toBe(1)
+ expect(result.failedConversations).toBe(1)
+
+ const status = await service.getStatus()
+ expect(status.coverage.totalImageMessages).toBe(10)
+ expect(status.coverage.complete).toBe(false)
+ })
+
+ it('探测到的类型列名会向上透出(列名不一致时是唯一线索)', async () => {
+ const service = new ImageTextIndexService()
+ bindCounting(service, makeDatabaseRoot(), async () => ({
+ count: 3,
+ typeColumn: 'msg_type'
+ }))
+
+ const result = await service.countImageMessages()
+ expect(result.typeColumn).toBe('msg_type')
+ })
+})
diff --git a/tests/integration/image-text-index-synthetic-e2e.test.ts b/tests/integration/image-text-index-synthetic-e2e.test.ts
new file mode 100644
index 0000000..bb08645
--- /dev/null
+++ b/tests/integration/image-text-index-synthetic-e2e.test.ts
@@ -0,0 +1,389 @@
+/**
+ * §2:图片 OCR 来源语义的 **deterministic synthetic E2E**。
+ *
+ * 硬要求是"不依赖真实线上 AI 模型也能 PASS",所以这里把两个外部边界**确定性**地固定住:
+ * - WCDB(chat-service)→ 用合成联系人 / 合成消息;
+ * - Knowledge 检索 → 用 fake 直接返回合成证据(形状与真实 `KnowledgeEvidence` 一致,
+ * 包括**未清理**的 `searchable_text`,用来验证内部前缀确实被剥掉)。
+ *
+ * 链路上真正的被测代码仍然是生产实现:
+ * LocalQueryApiService.search() ← 真实 scope 解析 / 证据映射 / 前缀剥离
+ * createLocalQueryToolExecutor() ← 真实 Tool 执行
+ * QueryAgentService.run() ← 真实 Agent 循环 / tool result 组装
+ *
+ * 断言的 10 项对应需求:FOUND=YES / sourceKind=image / derived source=image_ocr /
+ * conversation scope=技术交流群 / Evidence messageRef=原始图片消息 /
+ * Evidence UI=图片文字 / jump target=原始图片消息 / 不产生虚构 OCR 消息。
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { decodeMessageRef } from '../../src/shared/local-query-api'
+
+process.env.TZ = 'Asia/Shanghai'
+
+const GROUP_MD5 = 'md5-tech-group'
+const GROUP_NAME = '技术交流群'
+const IMAGE_MESSAGE_ID = 'local:9001'
+const TEXT_MESSAGE_ID = 'local:9002'
+const OCR_TEXT = 'OpenAI ChatGPT Plus $20 Pro $200'
+/** Knowledge 侧的原始 searchable_text:带内部标签,绝不该出现在 Evidence 里。 */
+const RAW_SEARCHABLE = `图片文字:${OCR_TEXT}`
+
+const fixture = vi.hoisted(() => {
+ const imageTimestamp = Date.parse('2026-09-03T14:32:00+08:00')
+ const textTimestamp = Date.parse('2026-09-03T14:30:00+08:00')
+ return {
+ imageTimestamp,
+ textTimestamp,
+ contacts: [
+ {
+ m_nsUsrName: 'wxid-tech-group',
+ m_nsNickName: '技术交流群',
+ md5: 'md5-tech-group',
+ type: 'group' as const
+ }
+ ],
+ messages: [
+ {
+ id: '9002',
+ localId: '9002',
+ from: 'user',
+ type: '文本',
+ datetime: '2026/9/3 14:30:00',
+ content: '今天正常讨论一下 API',
+ isSender: false,
+ name: '张三',
+ createTime: Math.floor(textTimestamp / 1000)
+ },
+ {
+ id: '9001',
+ localId: '9001',
+ from: 'user',
+ type: '图片',
+ datetime: '2026/9/3 14:32:00',
+ content: '',
+ contentData: { type: 'image', md5: 'image-md5-fixture', datName: 'dat-fixture' },
+ isSender: false,
+ name: '张三',
+ createTime: Math.floor(imageTimestamp / 1000)
+ }
+ ]
+ }
+})
+
+const IMAGE_TIMESTAMP = fixture.imageTimestamp
+
+vi.mock('../../src/main/services/chat-service', () => ({
+ isReady: () => true,
+ listContactsAsync: vi.fn(async () => fixture.contacts),
+ listMessagesAsync: vi.fn(async () => fixture.messages)
+}))
+
+import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
+import { createLocalQueryToolExecutor } from '../../src/main/services/local-query-tool-executor'
+import {
+ QueryAgentService,
+ type QueryAgentProvider
+} from '../../src/main/services/query-agent-service'
+
+/** 与真实 Knowledge 检索返回的证据形状一致(含原始未清理文本)。 */
+function syntheticKnowledgeEvidence() {
+ return [
+ {
+ chunkId: 'chunk-1',
+ conversationId: GROUP_MD5,
+ startTime: IMAGE_TIMESTAMP,
+ endTime: IMAGE_TIMESTAMP,
+ messageId: IMAGE_MESSAGE_ID,
+ senderId: 'fixture-member',
+ sender: '张三',
+ timestamp: IMAGE_TIMESTAMP,
+ messageIds: [IMAGE_MESSAGE_ID],
+ sourceKind: 'image' as const,
+ text: RAW_SEARCHABLE,
+ imageOcrText: OCR_TEXT,
+ derivedSource: 'image_ocr' as const
+ }
+ ]
+}
+
+function makeKnowledge() {
+ return {
+ search: vi.fn(async () => ({
+ state: 'ready',
+ evidence: syntheticKnowledgeEvidence(),
+ conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
+ voiceCoverage: undefined
+ })),
+ requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
+ waitForIndexingComplete: vi.fn(async () => false),
+ lastPassDurationMs: vi.fn(() => 0),
+ beginInteractiveQuery: vi.fn(),
+ endInteractiveQuery: vi.fn()
+ } as never
+}
+
+const NOW = new Date('2026-09-16T09:00:00+08:00')
+
+describe('§2 图片文字索引 synthetic E2E(确定性,不依赖真模型)', () => {
+ let knowledge: ReturnType
+ let service: LocalQueryApiService
+
+ beforeEach(() => {
+ knowledge = makeKnowledge()
+ service = new LocalQueryApiService(knowledge, () => NOW)
+ })
+
+ it('Question Tool 链路:命中图片文字的 Evidence 指向原始图片消息,且不泄露内部前缀', async () => {
+ const result = await service.search({
+ target: { query: GROUP_NAME },
+ timeRange: { kind: 'all' },
+ query: 'ChatGPT 价格',
+ variants: ['ChatGPT']
+ })
+
+ expect(result.status).toBe('completed')
+
+ // FOUND = YES
+ expect(result.evidenceCount).toBe(1)
+ expect(result.evidence).toHaveLength(1)
+ const evidence = result.evidence![0]
+
+ // sourceKind = image(原始消息是什么)
+ expect(evidence.sourceKind).toBe('image')
+ // derived source = image_ocr(靠什么搜到的)
+ expect(evidence.derivedSource).toBe('image_ocr')
+ // OCR 片段只作命中解释
+ expect(evidence.imageOcrText).toBe(OCR_TEXT)
+
+ // conversation scope = 技术交流群:target 把检索范围真正收敛到这一个会话
+ expect(result.target).toEqual({ displayName: GROUP_NAME, type: 'group' })
+ expect(evidence.conversationName).toBe(GROUP_NAME)
+ expect(evidence.conversationType).toBe('group')
+ expect(knowledge.search).toHaveBeenCalledTimes(2)
+ for (const call of knowledge.search.mock.calls) {
+ expect((call[0] as { conversationIds?: string[] }).conversationIds).toEqual([GROUP_MD5])
+ }
+
+ // sender / createTime 来自原始消息
+ expect(evidence.sender).toBe('张三')
+ expect(evidence.timestamp).toBe(IMAGE_TIMESTAMP)
+
+ // Evidence messageRef = 原始 image message(jump target 就是它)。
+ // 注意 `local:` 只是 WCDB 侧的本地 id 装饰,不属于身份本身,所以还原后是裸 id。
+ const identity = decodeMessageRef(evidence.messageRef)
+ expect(identity).toEqual({ conversationId: GROUP_MD5, messageId: '9001' })
+ // 不能产生"OCR 消息":证据集合里不存在任何非原始消息的身份
+ expect(result.evidence!.every((item) => decodeMessageRef(item.messageRef)?.messageId === '9001')).toBe(true)
+ expect(result.evidence!.some((item) => decodeMessageRef(item.messageRef)?.messageId === '9002')).toBe(false)
+
+ // 内部前缀绝不泄露给用户(模型侧与 UI 侧都不允许)
+ expect(evidence.text).not.toContain('图片文字:')
+ expect(evidence.text).not.toContain('OCR:')
+ expect(evidence.text).not.toContain('system-ocr')
+ expect(evidence.text).toContain(OCR_TEXT)
+ })
+
+ it('Query Agent 链路:来源语义进入 tool result,OCR 片段不进模型上下文', async () => {
+ const executor = createLocalQueryToolExecutor(service)
+ const responses: Array>> = [
+ {
+ success: true,
+ toolCalls: [
+ {
+ id: 'call-1',
+ name: 'search_messages',
+ arguments: JSON.stringify({
+ target: { query: GROUP_NAME },
+ timeRange: { kind: 'all' },
+ queries: ['ChatGPT 价格']
+ })
+ }
+ ]
+ },
+ { success: true, data: '找到了:技术交流群发过一张 ChatGPT 价格的图片。' }
+ ]
+ const provider: QueryAgentProvider = {
+ getRuntimeConfig: () => ({
+ configured: true,
+ providerName: 'Fixture Provider',
+ model: 'fixture-model',
+ modelName: 'Fixture Model'
+ }),
+ chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
+ }
+
+ const agentResult = await new QueryAgentService(provider, executor).run(
+ '技术交流群之前是不是发过 ChatGPT 价格的图片?'
+ )
+
+ // 模型实际看到的 tool result
+ const toolMessage = vi
+ .mocked(provider.chatWithTools)
+ .mock.calls[1]?.[0].find((message) => message.role === 'tool')
+ const presented = JSON.parse(String(toolMessage?.content)) as Record
+ const presentedEvidence = presented.evidence?.[0]
+
+ expect(presentedEvidence.sourceKind).toBe('image')
+ expect(presentedEvidence.derivedSource).toBe('image_ocr')
+ // 片段的内容已经在 text 里,不再重复塞进上下文(避免无谓 token)。
+ expect(presentedEvidence.imageOcrText).toBeUndefined()
+ expect(presentedEvidence.text).not.toContain('图片文字:')
+ // messageRef 指向原始图片消息(模型只拿到 opaque ref,看不到会话身份)。
+ expect(decodeMessageRef(presentedEvidence.messageRef)).toEqual({
+ conversationId: GROUP_MD5,
+ messageId: '9001'
+ })
+
+ // 暴露给 UI 的证据保留来源语义与片段
+ const uiEvidence = agentResult.evidence.find((item) => item.messageRef === presentedEvidence.messageRef)
+ expect(uiEvidence?.messageType).toBe('image')
+ expect(uiEvidence?.derivedSource).toBe('image_ocr')
+ expect(uiEvidence?.imageOcrText).toBe(OCR_TEXT)
+ expect(uiEvidence?.text).not.toContain('图片文字:')
+ expect(uiEvidence?.conversationName).toBe(GROUP_NAME)
+ })
+})
+
+describe('§3 partial coverage honesty(确定性,不依赖真模型)', () => {
+ const NOT_INDEXED_KEYWORD = 'TRACE_NOT_YET_INDEXED_IMAGE'
+
+ function partialImageCoverage() {
+ return {
+ totalImageMessages: 100,
+ processed: 30,
+ indexed: 28,
+ empty: 2,
+ missing: 0,
+ failed: 0,
+ pending: 70,
+ established: true,
+ complete: false,
+ countedAt: Date.parse('2026-09-16T08:00:00+08:00')
+ }
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('已处理的 30 张里搜不到关键词时,覆盖度必须带上"不能断言没有"的语义', async () => {
+ const knowledge = makeKnowledge()
+ // 关键:已建立的 30 张里确实没有这个关键词 → 检索结果为空。
+ knowledge.search.mockImplementation(async () => ({
+ state: 'ready',
+ evidence: [],
+ // 文字索引这一维是**完整**的(噪音):证明图片维度不会被文字维度"带过"。
+ indexLatestAt: NOW.getTime(),
+ sourceLatestAt: NOW.getTime(),
+ conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
+ voiceCoverage: undefined
+ }))
+ const service = new LocalQueryApiService(knowledge, () => NOW)
+ // 图片文字索引建立过,但只完成 30 / 100。
+ service.setImageTextCoverageProvider(() => partialImageCoverage())
+
+ const result = await service.search({
+ target: { query: GROUP_NAME },
+ timeRange: { kind: 'all' },
+ query: NOT_INDEXED_KEYWORD
+ })
+
+ expect(result.status).toBe('completed')
+ expect(result.evidenceCount).toBe(0)
+ // 文字索引这一维是完整的(噪音),图片这一维才是缺口。
+ expect(result.coverage).toEqual({ state: 'complete' })
+ expect(result.imageOcrCoverage).toMatchObject({
+ state: 'partial',
+ totalImageMessages: 100,
+ processed: 30,
+ pending: 70
+ })
+ const summary = result.imageOcrCoverage!.summary
+ expect(summary).toContain('30')
+ expect(summary).toContain('100')
+ expect(summary).toContain('不能因为没搜到就回答')
+
+ // 覆盖度必须真的进入 Query Agent 的上下文,而不是只留在 Engine 里。
+ const executor = createLocalQueryToolExecutor(service)
+ const responses: Array>> = [
+ {
+ success: true,
+ toolCalls: [
+ {
+ id: 'call-1',
+ name: 'search_messages',
+ arguments: JSON.stringify({
+ target: { query: GROUP_NAME },
+ timeRange: { kind: 'all' },
+ queries: [NOT_INDEXED_KEYWORD]
+ })
+ }
+ ]
+ },
+ {
+ success: true,
+ data: '图片文字索引目前只处理 30 / 100 条图片消息,当前结果不完整,无法确认全部历史图片。'
+ }
+ ]
+ const provider: QueryAgentProvider = {
+ getRuntimeConfig: () => ({
+ configured: true,
+ providerName: 'Fixture Provider',
+ model: 'fixture-model',
+ modelName: 'Fixture Model'
+ }),
+ chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
+ }
+ const agentResult = await new QueryAgentService(provider, executor).run(
+ `之前是不是有张图片写着 ${NOT_INDEXED_KEYWORD}?`
+ )
+
+ const calls = vi.mocked(provider.chatWithTools).mock.calls
+ // 提示词里写死了零结果诚实性规则(不能指望模型自己想到)。
+ expect(String(calls[0]?.[0]?.[0]?.content)).toContain('imageOcrCoverage')
+ const presented = JSON.parse(
+ String(calls[1]?.[0].find((message) => message.role === 'tool')?.content)
+ ) as Record
+ expect(presented.evidenceCount).toBe(0)
+ expect(presented.imageOcrCoverage).toMatchObject({
+ state: 'partial',
+ totalImageMessages: 100,
+ processed: 30,
+ pending: 70
+ })
+ expect(presented.imageOcrCoverage.summary).toContain('不能因为没搜到就回答')
+
+ // 最终回答本身必须是"覆盖不完整",不是"没有"。
+ expect(agentResult.answer).toContain('30')
+ expect(agentResult.answer).toContain('100')
+ expect(agentResult.answer).not.toBe('没有')
+ })
+
+ it('图片索引完整时不下发零结果约束(避免模型机械附加警告)', async () => {
+ const knowledge = makeKnowledge()
+ knowledge.search.mockImplementation(async () => ({
+ state: 'ready',
+ evidence: [],
+ conversationRetrieval: { totalMessages: 2, chunkCount: 1, complete: true },
+ voiceCoverage: undefined
+ }))
+ const service = new LocalQueryApiService(knowledge, () => NOW)
+ service.setImageTextCoverageProvider(() => ({
+ ...partialImageCoverage(),
+ processed: 100,
+ indexed: 98,
+ empty: 2,
+ pending: 0,
+ complete: true
+ }))
+
+ const result = await service.search({
+ target: { query: GROUP_NAME },
+ timeRange: { kind: 'all' },
+ query: NOT_INDEXED_KEYWORD
+ })
+
+ expect(result.imageOcrCoverage?.state).toBe('complete')
+ expect(result.imageOcrCoverage?.summary).not.toContain('不能因为没搜到就回答')
+ })
+})
diff --git a/tests/integration/query-agent-image-ocr-text.test.ts b/tests/integration/query-agent-image-ocr-text.test.ts
new file mode 100644
index 0000000..5c1173a
--- /dev/null
+++ b/tests/integration/query-agent-image-ocr-text.test.ts
@@ -0,0 +1,326 @@
+/**
+ * 精确读消息(`query_messages`)必须能读到**图片里识别出的文字**。
+ *
+ * 真机回归:问「我今早给文件传输助手发的那张图片里写了什么」,Query Agent 准确找到了
+ * 原始图片消息(2026/9/16 07:30:15、sender=self、type=image),却回答
+ * 「查询只返回图片附件,没有取得 OCR 文字」,甚至反过来建议用户"建立图片文字索引后再查"。
+ *
+ * 真机派生库 + Knowledge 实测结论(CASE A):
+ * L1 artifact state=indexed / char_count=17
+ * L2 binding state=indexed
+ * L3 Knowledge image_ocr_text 与 artifact 文本**逐字相同**,chunk 里也含该文本且指向原图 messageId
+ * —— 即"索引早就建好了,只是查询路径没把它接出来"。缺口在 L4,不在 L1/L2/L3。
+ *
+ * 这一组测试把 L4 的契约钉死:
+ * 1. 图片消息的 OCR 文本必须走 `imageOcrText` + `derivedSource=image_ocr` 独立字段;
+ * 2. 证据**永远是原始图片消息**,不许为了 OCR 文本编造一条文字消息;
+ * 3. `empty`(识别过没文字)与 `not_indexed`(还没索引)必须能被区分,
+ * 两者都不允许模型凭想象描述图片内容。
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { ImageTextIndexCoverage } from '../../src/shared/image-text-index'
+import { decodeMessageRef } from '../../src/shared/local-query-api'
+
+process.env.TZ = 'Asia/Shanghai'
+
+const fixture = vi.hoisted(() => {
+ const selfImageTime = Date.parse('2026-09-16T07:30:15+08:00')
+ const otherImageTime = Date.parse('2026-09-16T08:10:00+08:00')
+ return {
+ selfImageTime,
+ otherImageTime,
+ contacts: [
+ {
+ m_nsUsrName: 'filehelper',
+ m_nsNickName: '文件传输助手',
+ md5: 'md5-filehelper',
+ type: 'user' as const
+ }
+ ],
+ messages: [
+ {
+ id: '9001',
+ localId: '9001',
+ from: 'assistant',
+ // 我发出的那张图:isSender = true(自我身份来自 mesDes,不是昵称)
+ type: '图片',
+ datetime: '2026/9/16 07:30:15',
+ content: '',
+ contentData: { type: 'image', md5: 'md5-self-image', datName: 'dat-self' },
+ isSender: true,
+ name: '我',
+ createTime: Math.floor(selfImageTime / 1000)
+ },
+ {
+ id: '9002',
+ localId: '9002',
+ from: 'user',
+ type: '图片',
+ datetime: '2026/9/16 08:10:00',
+ content: '',
+ contentData: { type: 'image', md5: 'md5-other-image', datName: 'dat-other' },
+ isSender: false,
+ name: '文件传输助手',
+ createTime: Math.floor(otherImageTime / 1000)
+ }
+ ]
+ }
+})
+
+vi.mock('../../src/main/services/chat-service', () => ({
+ isReady: () => true,
+ listContactsAsync: vi.fn(async () => fixture.contacts),
+ listMessagesAsync: vi.fn(async () => fixture.messages)
+}))
+
+import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
+import { createLocalQueryToolExecutor } from '../../src/main/services/local-query-tool-executor'
+import {
+ QueryAgentService,
+ type QueryAgentProvider
+} from '../../src/main/services/query-agent-service'
+
+/** 与真实派生库同形:binding 主键 = `sourceMessageId(message)` = `local:`。 */
+const SELF_KEY = 'local:9001'
+const OTHER_KEY = 'local:9002'
+
+function coverage(overrides: Partial = {}): ImageTextIndexCoverage {
+ return {
+ totalImageMessages: 2,
+ processed: 2,
+ indexed: 2,
+ empty: 0,
+ missing: 0,
+ failed: 0,
+ runtimeUnavailable: 0,
+ pending: 0,
+ established: true,
+ complete: true,
+ systemicFailure: false,
+ countedAt: Date.parse('2026-09-16T09:00:00+08:00'),
+ ...overrides
+ }
+}
+
+type OcrFixture = Map
+
+function makeService(options: { ocr?: OcrFixture; coverage?: ImageTextIndexCoverage } = {}) {
+ const knowledge = {
+ search: vi.fn(async () => ({ state: 'ready', evidence: [] })),
+ requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
+ waitForIndexingComplete: vi.fn(async () => false),
+ lastPassDurationMs: vi.fn(() => 0),
+ beginInteractiveQuery: vi.fn(),
+ endInteractiveQuery: vi.fn()
+ } as never
+ const service = new LocalQueryApiService(knowledge, () => new Date('2026-09-16T09:30:00+08:00'))
+ const ocr = options.ocr ?? new Map([[SELF_KEY, { state: 'indexed', text: 'ChatGPT Plus $20' }]])
+ service.setImageOcrEntryProvider((_conversationId, messageId) => ocr.get(messageId))
+ if (options.coverage !== undefined) {
+ service.setImageTextCoverageProvider(() => options.coverage!)
+ }
+ return service
+}
+
+const askSelfImages = {
+ target: { query: '文件传输助手' },
+ timeRange: { kind: 'all' },
+ direction: 'to_target',
+ messageTypes: ['image']
+}
+
+describe('query_messages:图片消息必须携带 OCR 派生文本', () => {
+ let service: LocalQueryApiService
+ beforeEach(() => {
+ service = makeService()
+ })
+
+ it('我发出的图片带 OCR 文本时,走 imageOcrText + derivedSource,不混进 text', async () => {
+ const result = await service.messages(askSelfImages as never)
+
+ expect(result.status).toBe('completed')
+ expect(result.returnedCount).toBe(1)
+ const message = result.messages![0]
+
+ // 派生文本必须单独一个字段:混进 `text` 就无法与"群友发的文字消息"区分。
+ expect(message.imageOcrText).toBe('ChatGPT Plus $20')
+ expect(message.derivedSource).toBe('image_ocr')
+ expect(message.imageTextState).toBe('indexed')
+ expect(message.text).toBeUndefined()
+ expect(message.attachment).toEqual({ kind: 'image' })
+ })
+
+ it('对方的图片不会被贴错 OCR 文本(键必须按消息身份匹配)', async () => {
+ const result = await service.messages({
+ ...askSelfImages,
+ direction: 'from_target'
+ } as never)
+
+ expect(result.returnedCount).toBe(1)
+ // OTHER_KEY 在派生库里没有绑定 → 只能是 not_indexed,绝不能借用另一条消息的文本。
+ expect(result.messages![0].imageOcrText).toBeUndefined()
+ expect(result.messages![0].imageTextState).toBe('not_indexed')
+ })
+
+ it('识别过但图里没文字 → empty(已知结论),不是 not_indexed', async () => {
+ const empty = makeService({ ocr: new Map([[SELF_KEY, { state: 'empty', text: '' }]]) })
+ const result = await empty.messages(askSelfImages as never)
+
+ // `empty` 与 `not_indexed` 必须能分辨:前者是"已经知道没文字",
+ // 后者是"还不知道"。把两者混起来,模型就会在没索引时断言"图里没内容"。
+ expect(result.messages![0].imageTextState).toBe('empty')
+ expect(result.messages![0].imageOcrText).toBeUndefined()
+ })
+
+ it('图片文字索引未建立时,tool result 明确带上 not_built 覆盖度', async () => {
+ const notBuilt = makeService({
+ ocr: new Map(),
+ coverage: coverage({
+ processed: 0,
+ indexed: 0,
+ pending: 0,
+ established: false,
+ complete: false
+ })
+ })
+ const result = await notBuilt.messages(askSelfImages as never)
+
+ expect(result.messages![0].imageTextState).toBe('not_indexed')
+ expect(result.imageOcrCoverage?.state).toBe('not_built')
+ expect(result.imageOcrCoverage?.summary).toContain('尚未建立')
+ })
+
+ it('覆盖度部分完成时,summary 必须说明结果可能不完整(不许当 complete)', async () => {
+ const partial = makeService({
+ coverage: coverage({
+ totalImageMessages: 100,
+ processed: 30,
+ indexed: 30,
+ pending: 70,
+ complete: false
+ })
+ })
+ const result = await partial.messages(askSelfImages as never)
+
+ expect(result.imageOcrCoverage?.state).toBe('partial')
+ expect(result.imageOcrCoverage?.summary).toContain('30')
+ expect(result.imageOcrCoverage?.summary).toContain('100')
+ })
+
+ it('普通文字消息完全不受影响(对照组)', async () => {
+ const plain = makeService({ ocr: new Map() })
+ const result = await plain.messages({
+ target: { query: '文件传输助手' },
+ timeRange: { kind: 'all' },
+ direction: 'to_target',
+ messageTypes: ['text']
+ } as never)
+
+ // 图片那两条都是 image,文字查询必然是 0 条 —— 关键是**不能**因为接了 OCR 路径
+ // 就凭空多出消息。
+ expect(result.returnedCount).toBe(0)
+ })
+})
+
+describe('Query Agent:证据永远是原始图片消息', () => {
+ function provider(
+ responses: Array>>
+ ): QueryAgentProvider {
+ return {
+ getRuntimeConfig: () => ({
+ configured: true,
+ providerName: 'Fixture Provider',
+ model: 'fixture-model',
+ modelName: 'Fixture Model'
+ }),
+ chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
+ }
+ }
+
+ const selfImageArgs = JSON.stringify({
+ target: { query: '文件传输助手' },
+ timeRange: { kind: 'all' },
+ temporalBasis: { kind: 'none' },
+ direction: 'to_target',
+ messageTypes: ['image']
+ })
+
+ it('模型能在 Tool Result 里读到 imageOcrText,且证据仍指向原图 messageRef', async () => {
+ const configured = provider([
+ {
+ success: true,
+ toolCalls: [{ id: 'c1', name: 'query_messages', arguments: selfImageArgs }]
+ },
+ { success: true, data: '那张图片里的文字是 ChatGPT Plus $20。' }
+ ])
+ const service = makeService()
+ const result = await new QueryAgentService(
+ configured,
+ createLocalQueryToolExecutor(service)
+ ).run('我今早给文件传输助手发的那张图片里写了什么')
+
+ const calls = vi.mocked(configured.chatWithTools).mock.calls
+ const toolResult = JSON.parse(
+ String(calls[1]?.[0].find((message) => message.role === 'tool')?.content)
+ ) as Record
+
+ // 1) 模型确实拿到了派生文本(这正是真机上缺的那一环)
+ expect(toolResult.messages?.[0].imageOcrText).toBe('ChatGPT Plus $20')
+ expect(toolResult.messages?.[0].derivedSource).toBe('image_ocr')
+ expect(toolResult.messages?.[0].imageTextState).toBe('indexed')
+
+ // 2) 证据只有一条,且解出来就是**原始图片消息**(不是虚构的 OCR 文字消息)
+ expect(result.evidence).toHaveLength(1)
+ expect(decodeMessageRef(result.evidence![0].messageRef)).toEqual({
+ conversationId: 'md5-filehelper',
+ messageId: '9001'
+ })
+ expect(result.evidence![0].messageType).toBe('image')
+
+ // 3) UI 拿得到来源语义(「图片文字」标记),且 snippet 不进模型上下文之外的重复字段
+ expect(result.evidence![0].derivedSource).toBe('image_ocr')
+ expect(result.evidence![0].imageOcrText).toBe('ChatGPT Plus $20')
+ })
+
+ it('系统提示词把图片文字的三态语义写死,并禁止凭空建议建立索引', async () => {
+ const scripted = provider([{ success: true, data: 'ok' }])
+ const service = makeService()
+ void new QueryAgentService(scripted, createLocalQueryToolExecutor(service)).run(
+ '我今早给文件传输助手发的那张图片里写了什么'
+ )
+
+ const systemPrompt = String(vi.mocked(scripted.chatWithTools).mock.calls[0]?.[0]?.[0]?.content)
+ expect(systemPrompt).toContain('imageOcrText')
+ // 三态必须分别说清楚
+ expect(systemPrompt).toContain('indexed')
+ expect(systemPrompt).toContain('empty')
+ expect(systemPrompt).toContain('not_indexed')
+ // OCR 不是看图:empty 时不许猜画面
+ expect(systemPrompt).toContain('OCR 不是看图')
+ // 不许无条件建议"先建立图片文字索引再查"
+ expect(systemPrompt).toContain('建立图片文字索引')
+ })
+
+ it('索引已建好的情况下,模型不会拿到任何"还没建立"的误导信号', async () => {
+ const configured = provider([
+ {
+ success: true,
+ toolCalls: [{ id: 'c1', name: 'query_messages', arguments: selfImageArgs }]
+ },
+ { success: true, data: '那张图里有 ChatGPT Plus $20。' }
+ ])
+ const service = makeService({ coverage: coverage() })
+ await new QueryAgentService(configured, createLocalQueryToolExecutor(service)).run(
+ '我今早给文件传输助手发的那张图片里写了什么'
+ )
+
+ const calls = vi.mocked(configured.chatWithTools).mock.calls
+ const toolResult = JSON.parse(
+ String(calls[1]?.[0].find((message) => message.role === 'tool')?.content)
+ ) as Record
+
+ // 覆盖度是 complete 且带了派生文本 → 模型没有任何理由说"没有取得 OCR 文字"。
+ expect(toolResult.imageOcrCoverage?.state).toBe('complete')
+ expect(toolResult.messages?.[0].imageOcrText).toBe('ChatGPT Plus $20')
+ })
+})
diff --git a/tests/integration/query-agent-self-direction.test.ts b/tests/integration/query-agent-self-direction.test.ts
new file mode 100644
index 0000000..52e8c44
--- /dev/null
+++ b/tests/integration/query-agent-self-direction.test.ts
@@ -0,0 +1,228 @@
+/**
+ * Query Agent 的**说话人方向**语义(self sender)。
+ *
+ * 真机回归:问「我给文件传输助手发了什么图片」,planner 第一次用了 `from_target`
+ * (= 对方发来),拿到 0 条后**回头问用户"是不是方向搞错了"**,而不是自己改向重查。
+ *
+ * 代码事实:direction 的词表是**以目标会话为参照**的 ——
+ * `to_target` = 我发出的(self sender),`from_target` = 对方发来的。
+ * 所以这不是"缺一个方向取值",而是 planner 选错了值 + 0 结果后没有利用既有重试机制。
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+process.env.TZ = 'Asia/Shanghai'
+
+const fixture = vi.hoisted(() => {
+ const selfImageTime = Date.parse('2026-09-10T10:00:00+08:00')
+ const otherImageTime = Date.parse('2026-09-11T11:00:00+08:00')
+ return {
+ selfImageTime,
+ otherImageTime,
+ contacts: [
+ {
+ m_nsUsrName: 'filehelper',
+ m_nsNickName: '文件传输助手',
+ md5: 'md5-filehelper',
+ type: 'user' as const
+ }
+ ],
+ messages: [
+ {
+ id: '1001',
+ localId: '1001',
+ from: 'assistant',
+ // 我发出的图片:isSender = true(自我身份来自 mesDes,不是昵称)
+ type: '图片',
+ datetime: '2026/9/10 10:00:00',
+ content: '',
+ contentData: { type: 'image', md5: 'md5-self-image', datName: 'dat-self' },
+ isSender: true,
+ name: '我',
+ createTime: Math.floor(selfImageTime / 1000)
+ },
+ {
+ id: '1002',
+ localId: '1002',
+ from: 'user',
+ // 对方发来的图片
+ type: '图片',
+ datetime: '2026/9/11 11:00:00',
+ content: '',
+ contentData: { type: 'image', md5: 'md5-other-image', datName: 'dat-other' },
+ isSender: false,
+ name: '文件传输助手',
+ createTime: Math.floor(otherImageTime / 1000)
+ }
+ ]
+ }
+})
+
+vi.mock('../../src/main/services/chat-service', () => ({
+ isReady: () => true,
+ listContactsAsync: vi.fn(async () => fixture.contacts),
+ listMessagesAsync: vi.fn(async () => fixture.messages)
+}))
+
+import { LocalQueryApiService } from '../../src/main/services/local-query-api-service'
+import { createLocalQueryToolExecutor } from '../../src/main/services/local-query-tool-executor'
+import {
+ QueryAgentService,
+ type QueryAgentProvider
+} from '../../src/main/services/query-agent-service'
+
+function makeKnowledge() {
+ return {
+ search: vi.fn(async () => ({ state: 'ready', evidence: [] })),
+ requestCatchUp: vi.fn(() => ({ triggered: false, inProgress: false })),
+ waitForIndexingComplete: vi.fn(async () => false),
+ lastPassDurationMs: vi.fn(() => 0),
+ beginInteractiveQuery: vi.fn(),
+ endInteractiveQuery: vi.fn()
+ } as never
+}
+
+function makeService(): LocalQueryApiService {
+ return new LocalQueryApiService(makeKnowledge(), () => new Date('2026-09-16T09:00:00+08:00'))
+}
+
+const queryArgs = (direction: string): string =>
+ JSON.stringify({
+ target: { query: '文件传输助手' },
+ timeRange: { kind: 'all' },
+ temporalBasis: { kind: 'none' },
+ direction,
+ messageTypes: ['image']
+ })
+
+describe('direction 语义:to_target = 我发出的(self sender)', () => {
+ let service: LocalQueryApiService
+ beforeEach(() => {
+ service = makeService()
+ })
+
+ it('to_target 只返回我发出的图片,排除对方发来的', async () => {
+ const result = await service.messages({
+ target: { query: '文件传输助手' },
+ timeRange: { kind: 'all' },
+ direction: 'to_target',
+ messageTypes: ['image']
+ } as never)
+
+ expect(result.status).toBe('completed')
+ expect(result.messages?.map((message) => message.messageRef)).toHaveLength(1)
+ expect(result.messages?.[0].direction).toBe('to_target')
+ expect(result.messages?.[0].sender).toBe('我')
+ expect(result.query?.direction).toBe('to_target')
+ })
+
+ it('from_target 只返回对方发来的图片', async () => {
+ const result = await service.messages({
+ target: { query: '文件传输助手' },
+ timeRange: { kind: 'all' },
+ direction: 'from_target',
+ messageTypes: ['image']
+ } as never)
+
+ expect(result.messages).toHaveLength(1)
+ expect(result.messages?.[0].direction).toBe('from_target')
+ expect(result.messages?.[0].sender).toBe('文件传输助手')
+ })
+})
+
+describe('Query Agent:方向选反后必须自己改向重查,而不是问用户', () => {
+ function provider(
+ responses: Array>>
+ ): QueryAgentProvider {
+ return {
+ getRuntimeConfig: () => ({
+ configured: true,
+ providerName: 'Fixture Provider',
+ model: 'fixture-model',
+ modelName: 'Fixture Model'
+ }),
+ chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
+ }
+ }
+
+ it('系统提示词把「我给 X 发」明确映射到 to_target,并禁止因此反问用户', () => {
+ const scripted = provider([{ success: true, data: 'ok' }])
+ const service = makeService()
+ const executor = createLocalQueryToolExecutor(service)
+ void new QueryAgentService(scripted, executor).run('我给文件传输助手发了什么图片')
+
+ const systemPrompt = String(vi.mocked(scripted.chatWithTools).mock.calls[0]?.[0]?.[0]?.content)
+ expect(systemPrompt).toContain('说话人是我 → to_target')
+ expect(systemPrompt).toContain('说话人是对方 → from_target')
+ // 提示词里明确禁止"因为方向可能错就反问用户"
+ expect(systemPrompt).toContain('要不要换个方向')
+ })
+
+ it('第一次用错方向得到 0 条 → tool result 明确要求改向重查;第二次查对 → 命中我发出的图片', async () => {
+ /**
+ * 只保留"我发出的"那一张:这样用错方向(from_target = 对方发来)必然 0 条,
+ * 才能真实复现"第一次查反了"的场景。
+ */
+ const originalMessages = fixture.messages
+ fixture.messages = [originalMessages[0]] as typeof fixture.messages
+ const configured = provider([
+ // 第一次:方向选反(对方发来)
+ { success: true, toolCalls: [{ id: 'c1', name: 'query_messages', arguments: queryArgs('from_target') }] },
+ // 第二次:改向(我发出的)
+ { success: true, toolCalls: [{ id: 'c2', name: 'query_messages', arguments: queryArgs('to_target') }] },
+ { success: true, data: '你给文件传输助手发过 1 张图片。' }
+ ])
+ const service = makeService()
+ const executor = createLocalQueryToolExecutor(service)
+ const result = await new QueryAgentService(configured, executor).run(
+ '我给文件传输助手发了什么图片'
+ )
+
+ const calls = vi.mocked(configured.chatWithTools).mock.calls
+ const firstToolResult = JSON.parse(
+ String(calls[1]?.[0].find((message) => message.role === 'tool')?.content)
+ ) as Record
+
+ // 0 条确实发生了(说明 fixture 的方向过滤是真的在起作用)
+ expect(firstToolResult.returnedCount).toBe(0)
+ // 重试提示必须点明"方向选反"这件事,并且**禁止**反问用户
+ expect(String(firstToolResult._agent?.note)).toContain('方向选反')
+ expect(String(firstToolResult._agent?.note)).toContain('不要问用户')
+ // 重试通道仍然开放(这正是既有 zero-result retry 机制)
+ expect(firstToolResult._agent?.note).toContain('to_target')
+
+ // 取**最后一条** tool 消息:第三次调用的上下文里已经有两次 tool result。
+ const secondToolResult = JSON.parse(
+ String(
+ calls[2]?.[0].filter((message) => message.role === 'tool').at(-1)?.content
+ )
+ ) as Record
+ expect(secondToolResult.returnedCount).toBe(1)
+ expect(secondToolResult.messages?.[0].direction).toBe('to_target')
+
+ // 最终答案基于第二次(正确方向)的结果
+ expect(result.answer).toContain('发过')
+ expect(result.toolCallCount).toBe(2)
+
+ fixture.messages = originalMessages
+ })
+
+ it('正常情况下第一次就查对:一次 tool call 命中,不需要重试', async () => {
+ const configured = provider([
+ { success: true, toolCalls: [{ id: 'c1', name: 'query_messages', arguments: queryArgs('to_target') }] },
+ { success: true, data: '你给文件传输助手发过 1 张图片。' }
+ ])
+ const service = makeService()
+ const executor = createLocalQueryToolExecutor(service)
+ const result = await new QueryAgentService(configured, executor).run(
+ '我给文件传输助手发了什么图片'
+ )
+
+ expect(result.toolCallCount).toBe(1)
+ const calls = vi.mocked(configured.chatWithTools).mock.calls
+ const toolResult = JSON.parse(
+ String(calls[1]?.[0].find((message) => message.role === 'tool')?.content)
+ ) as Record
+ expect(toolResult.returnedCount).toBe(1)
+ expect(toolResult.messages?.[0].direction).toBe('to_target')
+ })
+})
diff --git a/tests/unit/ask-wechat-service.test.ts b/tests/unit/ask-wechat-service.test.ts
index 8225cc3..86faaa2 100644
--- a/tests/unit/ask-wechat-service.test.ts
+++ b/tests/unit/ask-wechat-service.test.ts
@@ -377,7 +377,7 @@ describe('AskWechatService — 桌面问问微信主路径', () => {
expect(result.diagnostics.outcome).toBe('invalid_question')
})
- it('生产日志只记录形态字段,不含回答内容', async () => {
+ it('本地日志必须包含问题与模型回答原文,方便回放排查', async () => {
const logs: AskWechatLogRecord[] = []
const { provider } = providerFactory([answer('BOBO 最近在准备搬家,提到了房租和押金。')])
const service = new AskWechatService(new QueryAgentService(provider, vi.fn()), {
@@ -388,17 +388,53 @@ describe('AskWechatService — 桌面问问微信主路径', () => {
await service.ask(request('BOBO 最近在忙什么'))
expect(logs).toHaveLength(1)
- expect(Object.keys(logs[0].details ?? {}).sort()).toEqual([
- 'entry',
- 'model',
- 'modelCallCount',
- 'outcome',
- 'provider',
- 'toolCallCount',
- 'tools',
- 'totalMs'
- ])
- expect(JSON.stringify(logs)).not.toContain('准备搬家')
+ // 形态字段仍然一个不少(排查时既要知道"问了什么",也要知道"跑了什么工具、多久")。
+ for (const key of ['entry', 'model', 'modelCallCount', 'outcome', 'provider', 'toolCallCount', 'tools', 'totalMs']) {
+ expect(logs[0].details).toHaveProperty(key)
+ }
+ // 措辞回归:真机上曾出现"图片已识别出文字却答'没有取得 OCR 文字'",
+ // 当时日志里只有工具名与次数,无法判断是索引没建还是链路没接上。
+ // 现在问答原文进入**本机**日志(不上传、不进遥测),可以直接回放。
+ expect(logs[0].details?.question).toBe('BOBO 最近在忙什么')
+ expect(String(logs[0].details?.answer)).toContain('准备搬家')
+ })
+
+ it('图片 OCR 的两条结构化事实进入诊断(不重复正文)', async () => {
+ const logs: AskWechatLogRecord[] = []
+ const { provider } = providerFactory([answer('那张图里有价格文字。')])
+ const runtime = new QueryAgentService(provider, vi.fn())
+ // 直接在 Runtime 结果里放一条带图片 OCR 的 trace,验证聚合口径。
+ vi.spyOn(runtime, 'run').mockResolvedValue({
+ question: '图里写了什么',
+ provider: 'Fixture',
+ model: 'fixture',
+ modelCallCount: 1,
+ toolCallCount: 1,
+ toolTotalMs: 5,
+ totalMs: 10,
+ modelDurationsMs: [],
+ modelDiagnostics: [],
+ answer: '那张图里有价格文字。',
+ traces: [
+ {
+ toolName: 'query_messages',
+ input: {},
+ durationMs: 5,
+ status: 'completed',
+ imageOcrTextCount: 3,
+ imageOcrCoverageState: 'partial'
+ }
+ ]
+ } as never)
+ const service = new AskWechatService(runtime, {
+ entry: 'desktop',
+ log: (record) => logs.push(record)
+ })
+
+ await service.ask(request('图里写了什么'))
+
+ expect(logs[0].details?.imageOcrTextCount).toBe(3)
+ expect(logs[0].details?.imageOcrCoverageState).toBe('partial')
})
it('forgetConversation 清掉指定会话的澄清上下文', async () => {
diff --git a/tests/unit/query-agent-image-ocr-coverage.test.ts b/tests/unit/query-agent-image-ocr-coverage.test.ts
new file mode 100644
index 0000000..2cb94e8
--- /dev/null
+++ b/tests/unit/query-agent-image-ocr-coverage.test.ts
@@ -0,0 +1,137 @@
+/**
+ * §6 / §7 / §18:图片文字索引覆盖度在 Query Agent 这一层的语义。
+ *
+ * 这里能确定性验证的是"覆盖度**真的进到了模型上下文**",而不是只做了个 UI 数字:
+ * - 系统提示词把 imageOcrCoverage 定义成**独立于文字索引**的维度,并禁止凭零结果下"没有";
+ * - tool result 透传里真的带着 imageOcrCoverage(模型能看见比例与结论句)。
+ *
+ * 真模型最终怎么说话不在本文件断言范围内(那需要真实模型与真实数据)。
+ */
+import { describe, expect, it, vi } from 'vitest'
+import {
+ QueryAgentService,
+ type QueryAgentProvider
+} from '../../src/main/services/query-agent-service'
+
+function provider(
+ responses: Array>>,
+ configured = true
+): QueryAgentProvider {
+ return {
+ getRuntimeConfig: () => ({
+ configured,
+ providerName: 'Fixture Provider',
+ model: 'fixture-model',
+ modelName: 'Fixture Model'
+ }),
+ chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
+ }
+}
+
+/** 与 `buildImageOcrCoverage` 在 partial 时产出的结论句保持一致。 */
+const PARTIAL_SUMMARY =
+ '图片文字索引只完成 30%(30 / 100 条图片消息),当前图片搜索结果可能不完整。涉及图片、截图、海报里的文字的问题,当前不能因为没搜到就回答"没有"。'
+
+function searchOnce(execute: ReturnType, finalAnswer: string): QueryAgentProvider {
+ return provider([
+ {
+ success: true,
+ toolCalls: [
+ {
+ id: 'call-1',
+ name: 'search_messages',
+ arguments: JSON.stringify({
+ target: { query: '技术交流群' },
+ timeRange: { kind: 'all' },
+ queries: ['ChatGPT 价格']
+ })
+ }
+ ]
+ },
+ { success: true, data: finalAnswer }
+ ])
+}
+
+describe('Query Agent 的图片文字索引覆盖度语义', () => {
+ it('系统提示词把图片覆盖度声明为独立维度,并禁止凭零结果断言"没有"', async () => {
+ const execute = vi.fn(async () => ({ status: 'completed', evidenceCount: 0, evidence: [] }))
+ const configured = searchOnce(execute, 'ok')
+ await new QueryAgentService(configured, execute).run('图片文字索引相关的问题')
+
+ const systemPrompt = String(vi.mocked(configured.chatWithTools).mock.calls[0]?.[0]?.[0]?.content)
+ expect(systemPrompt).toContain('imageOcrCoverage')
+ expect(systemPrompt).toContain('独立于文字索引')
+ expect(systemPrompt).toContain('not_built')
+ // 零结果诚实性必须写死在提示词里,不能指望模型自己想到。
+ expect(systemPrompt).toContain('绝不能')
+ })
+
+ it('partial 覆盖度随 tool result 进入模型上下文,而不是只留在 UI 里', async () => {
+ const execute = vi.fn(async () => ({
+ status: 'completed',
+ coverage: { state: 'partial' },
+ evidenceCount: 0,
+ evidence: [],
+ imageOcrCoverage: {
+ state: 'partial',
+ totalImageMessages: 100,
+ processed: 30,
+ indexed: 28,
+ empty: 2,
+ missing: 0,
+ failed: 0,
+ pending: 70,
+ countedAtLabel: '09-15 20:13',
+ summary: PARTIAL_SUMMARY
+ }
+ }))
+ const configured = searchOnce(execute, '图片文字索引只完成 30%,暂时无法确认。')
+ await new QueryAgentService(configured, execute).run(
+ '技术交流群之前是不是发过 ChatGPT 价格的图片?'
+ )
+
+ const toolMessage = vi
+ .mocked(configured.chatWithTools)
+ .mock.calls[1]?.[0].find((message) => message.role === 'tool')
+ const presented = JSON.parse(String(toolMessage?.content)) as Record
+
+ expect(presented.imageOcrCoverage).toMatchObject({
+ state: 'partial',
+ totalImageMessages: 100,
+ processed: 30,
+ indexed: 28,
+ empty: 2,
+ pending: 70
+ })
+ expect(presented.imageOcrCoverage.summary).toContain('不能因为没搜到就回答')
+ })
+
+ it('complete 覆盖度不下发零结果约束(避免模型机械附加警告)', async () => {
+ const execute = vi.fn(async () => ({
+ status: 'completed',
+ coverage: { state: 'complete' },
+ evidenceCount: 1,
+ evidence: [{ messageRef: 'opaque', timestamp: 1, sender: '张三', sourceKind: 'image', text: 'ChatGPT Plus $20' }],
+ imageOcrCoverage: {
+ state: 'complete',
+ totalImageMessages: 100,
+ processed: 100,
+ indexed: 90,
+ empty: 10,
+ missing: 0,
+ failed: 0,
+ pending: 0,
+ summary: '图片文字索引已覆盖所统计的全部 100 条图片消息。'
+ }
+ }))
+ const configured = searchOnce(execute, '找到了。')
+ await new QueryAgentService(configured, execute).run('技术交流群发过 ChatGPT 价格的图片吗')
+
+ const toolMessage = vi
+ .mocked(configured.chatWithTools)
+ .mock.calls[1]?.[0].find((message) => message.role === 'tool')
+ const presented = JSON.parse(String(toolMessage?.content)) as Record
+ expect(presented.imageOcrCoverage.state).toBe('complete')
+ expect(presented.imageOcrCoverage.summary).not.toContain('不能因为没搜到就回答')
+ })
+})
diff --git a/tests/unit/wcdb-image-message-count.test.ts b/tests/unit/wcdb-image-message-count.test.ts
new file mode 100644
index 0000000..92b7f1e
--- /dev/null
+++ b/tests/unit/wcdb-image-message-count.test.ts
@@ -0,0 +1,165 @@
+/**
+ * 图片消息计数 / 增量的**标识符与列名**回归测试。
+ *
+ * 真机反馈「检测到图片消息为 0 → 无法统计(未找到该会话的消息表)」,根因有两个,
+ * 都在这里钉死:
+ *
+ * 1. **标识符混淆**:`contact.md5` 是 `md5(wxid)` 的哈希,而原生接口
+ * (`wcdbGetMessageTableStats` / `wcdbGetMessages`)要的是**原始 username**。
+ * 把 md5 直接当 username 传,原生侧匹配不到任何表 —— 既有读消息路径一直有做这层转换
+ * (`wechat-db.chatMd5ToUsername`、`chat-service` 的 `getUsernameByMd5`),统计路径漏了。
+ * 2. **类型列名硬编码**:不同微信版本的列名不统一(项目读行时用的是别名列表),
+ * 把 `local_type` 写进 WHERE 会在列名不同的库上抛错。
+ */
+import { createHash } from 'node:crypto'
+import { describe, expect, it, vi } from 'vitest'
+import { Wcdb4Client } from '../../src/main/wcdb4-client'
+
+const USERNAME = 'wxid_real_user'
+const ROOM = '12345678@chatroom'
+const MD5_OF_USERNAME = createHash('md5').update(USERNAME).digest('hex')
+const MD5_OF_ROOM = createHash('md5').update(ROOM).digest('hex')
+
+type ClientOptions = {
+ sessions?: { username: string }[]
+ chatTables?: { name: string; db_number: string }[] | null
+ columns?: string[]
+ count?: number
+ maxLocalId?: number
+ identifierLog?: string[]
+}
+
+function makeClient(options: ClientOptions): Wcdb4Client {
+ const identifierLog = options.identifierLog ?? []
+ return Object.assign(Object.create(Wcdb4Client.prototype), {
+ cachedSessions: (options.sessions ?? []).map((session) => ({ ...session })),
+ cachedChatTables: options.chatTables ?? null,
+ messageTypeColumnCache: new Map(),
+ messageTypeColumnCandidates: [
+ 'local_type',
+ 'localType',
+ 'msg_type',
+ 'msgType',
+ 'message_type',
+ 'messageType',
+ 'type',
+ 'WCDB_CT_local_type'
+ ],
+ // 断言点:走到消息表查找时用的标识符必须是 username,不是 md5。
+ listMessageStoresAsync: vi.fn(async (identifier: string) => {
+ identifierLog.push(identifier)
+ return [{ tableName: 'Msg_fixture', dbPath: 'C:/fixture/message_0.db' }]
+ }),
+ // 聚合查询的返回(真实实现由 pickValue 解析)。
+ callJsonAsync: vi.fn(async () => [
+ { image_count: options.count ?? 0, image_max_local_id: options.maxLocalId ?? 0 }
+ ]),
+ readMessageColumns: vi.fn(() =>
+ (options.columns ?? []).map((name) => ({ name, declaration: 'INTEGER' }))
+ ),
+ wcdbGetMessageTableStats: vi.fn(async () => 0),
+ wcdbExecQuery: vi.fn(async () => 0)
+ }) as Wcdb4Client
+}
+
+describe('图片消息统计:会话标识符必须是 username 而不是 md5', () => {
+ it('从 session 能把 md5 反解成 username,并把它交给消息表查找', async () => {
+ const identifierLog: string[] = []
+ const client = makeClient({
+ sessions: [{ username: USERNAME }],
+ columns: ['local_type'],
+ count: 7,
+ identifierLog
+ })
+
+ const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
+
+ expect(result).toEqual({ count: 7, typeColumn: 'local_type' })
+ // ★ 核心回归断言:绝不能把 md5 当 username 传下去。
+ expect(identifierLog).toEqual([USERNAME])
+ expect(identifierLog).not.toContain(MD5_OF_USERNAME)
+ })
+
+ it('群聊同样走 md5 → username', async () => {
+ const identifierLog: string[] = []
+ const client = makeClient({
+ sessions: [{ username: ROOM }],
+ columns: ['local_type'],
+ count: 2,
+ identifierLog
+ })
+
+ await client.countImageMessagesAsync(MD5_OF_ROOM)
+ expect(identifierLog).toEqual([ROOM])
+ })
+
+ it('不在 session 列表、只以 Chat_ 表存在的会话也能反解', async () => {
+ const identifierLog: string[] = []
+ const client = makeClient({
+ sessions: [],
+ chatTables: [{ name: `Chat_${MD5_OF_ROOM}`, db_number: ROOM }],
+ columns: ['local_type'],
+ count: 3,
+ identifierLog
+ })
+
+ const result = await client.countImageMessagesAsync(MD5_OF_ROOM)
+ expect(result.count).toBe(3)
+ expect(identifierLog).toEqual([ROOM])
+ })
+
+ it('增量水位使用同一个标识符(否则水位永远拿不到、退化成每轮重扫)', async () => {
+ const identifierLog: string[] = []
+ const client = makeClient({
+ sessions: [{ username: USERNAME }],
+ columns: ['local_type'],
+ count: 7,
+ maxLocalId: 42,
+ identifierLog
+ })
+
+ const watermark = await client.imageConversationWatermarkAsync(MD5_OF_USERNAME)
+ expect(watermark).toEqual({ count: 7, maxLocalId: 42 })
+ expect(identifierLog).toEqual([USERNAME])
+ })
+})
+
+describe('图片消息统计:类型列名随版本变化', () => {
+ it('列名不是 local_type 时照样能统计,并把真实列名透出', async () => {
+ const client = makeClient({
+ sessions: [{ username: USERNAME }],
+ columns: ['msg_type'],
+ count: 5
+ })
+
+ const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
+ expect(result).toEqual({ count: 5, typeColumn: 'msg_type' })
+ })
+
+ it('探测不到类型列时明确失败,而不是静默返回 0', async () => {
+ const client = makeClient({ sessions: [{ username: USERNAME }], columns: [] })
+
+ const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
+ // "数不出来"必须是 null + 原因,不能是 0。
+ expect(result.count).toBeNull()
+ expect(result.error).toBe('消息表缺少可识别的消息类型列')
+ })
+
+ it('一张表都没匹配到时给出可诊断的原因', async () => {
+ const client = makeClient({ sessions: [{ username: USERNAME }], columns: ['local_type'] })
+ Object.assign(client, { listMessageStoresAsync: vi.fn(async () => []) })
+
+ const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
+ expect(result.count).toBeNull()
+ expect(result.error).toBe('未找到该会话的消息表')
+ })
+
+ it('原生统计通道不可用时说明是环境问题,与 0 张区分开', async () => {
+ const client = makeClient({ sessions: [{ username: USERNAME }], columns: ['local_type'] })
+ Object.assign(client, { wcdbExecQuery: null })
+
+ const result = await client.countImageMessagesAsync(MD5_OF_USERNAME)
+ expect(result.count).toBeNull()
+ expect(result.error).toBe('当前数据服务不支持消息表统计')
+ })
+})