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}” + + )} + )} + {!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('当前数据服务不支持消息表统计') + }) +})