// src/main/services/image-insight-service.ts // WechatExplorer AI 图片理解基础设施 // // 设计原则: // 1. base64 不走 IPC,只在 main 内部流转(renderer 只看到 ImageInsight 结构化结果) // 2. 同图(imageHash)走缓存,绝不重复调 AI // 3. 失败不抛,日志记录 + 返回原状(不阻塞日报) // 4. 第一阶段:Top 3 热点图 + 缓存命中即返回,未命中并发调 AI import crypto from 'crypto' import { randomUUID } from 'crypto' import { imageInsightsStore } from '../db/image-insights-store' import { buildImageAnalysisUserText, IMAGE_ANALYSIS_SYSTEM_PROMPT, parseImageAnalysisResponse } from './image-insight-prompt' import type { ImageAnalysisRequest, ImageAnalysisResponse, ImageCandidate, ImageCandidateQuery, ImageInsight } from '../../shared/image-insight' /** * 单张图片的最小信息(由 renderer 从已加载的 messages 中提取并传入 main)。 * 这样可以避免 ImageInsightService 自己重新查询消息,且参数语义清晰。 */ export interface ImageCandidateInput { messageId: string md5?: string datName?: string sessionId: string sender: string sentAt: number /** 图片发出后 8 条消息内、不同发言人的回复数(由 renderer 计算) */ responseCount: number /** 表情/语音互动条数 */ interactionCount: number } interface ProviderServiceLike { list(): ProviderSummaryLike analyzeImage( messages: Array<{ role: string content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }> }>, options?: { providerId?: string; modelId?: string } ): Promise<{ success: boolean data?: string error?: string }> } interface DecryptServiceLike { findImageFile( md5?: string, imageDatName?: string, options?: { allowThumbnail?: boolean } ): string | null decryptImageToBase64(datPath: string): string | null } interface ProviderSummaryLike { providers: Array<{ id: string isDefault: boolean defaultModel: string models: Array<{ id: string; capabilities: { vision: boolean; ocr: boolean } }> }> defaultProviderId?: string } class ImageInsightService { private providerService: ProviderServiceLike | null = null private decryptService: DecryptServiceLike | null = null /** 最近一次实际使用的默认 AI provider/model,仅用于写入分析元数据 */ private runtimeProviderId: string | undefined = undefined private runtimeModelId: string | undefined = undefined /** 注入依赖(由 main/index.ts 在 app ready 后调用) */ bind(deps: { providerService: ProviderServiceLike & { list(): ProviderSummaryLike } decryptService: DecryptServiceLike }): void { this.providerService = deps.providerService this.decryptService = deps.decryptService console.log( '[ImageInsightService] bind ok, default provider=%s model=%s', this.runtimeProviderId, this.runtimeModelId ) // 读取默认 provider/model(后续 analyze 时使用) try { const list = deps.providerService.list() const provider = list.providers.find((p) => p.id === list.defaultProviderId) || list.providers[0] this.runtimeProviderId = provider?.id this.runtimeModelId = provider?.defaultModel console.log( '[ImageInsightService] bind loaded default provider=%s model=%s', this.runtimeProviderId, this.runtimeModelId ) } catch (error) { console.warn('[ImageInsightService] bind list failed:', error) } } /** * 计算图片缓存 key:imageHash。 * 策略:优先微信原始 md5,无 md5 才用 sha256(rawBytes).slice(0, 32) */ private async computeImageHash( md5: string | undefined, datName: string | undefined ): Promise { if (md5 && md5.trim()) return md5.trim().toLowerCase() if (!this.decryptService) return null const filePath = this.decryptService.findImageFile(undefined, datName, { allowThumbnail: true }) if (!filePath) return null // 一次性读盘 + sha256(只在没有 md5 时才付出 IO) try { const fs = await import('fs-extra') const buf = await fs.readFile(filePath) const sha = crypto.createHash('sha256').update(buf).digest('hex').slice(0, 32) return `sha256:${sha}` } catch (error) { console.warn('[ImageInsightService] computeImageHash failed:', error) return null } } /** 通过 hash 拿 Insight(只读缓存,无 AI 调用) */ getInsight(imageHash: string): ImageInsight | null { return imageInsightsStore.getByHash(imageHash) } /** * 主入口:分析一张图片。 * 1. 通过 imageHash 查缓存,命中即返回 * 2. 未命中:解密图片 → 调 AI → 解析响应 → 落库 → 返回 * 3. 任意步骤失败:记录日志,返回 success=false,**不抛** */ async analyze(request: ImageAnalysisRequest): Promise { try { if (!request.force) { const cached = imageInsightsStore.getByHash(request.imageHash) if (cached) { return { success: true, insight: cached, fromCache: true } } } if (!this.providerService) { return { success: false, error: 'AI Provider 未初始化' } } const messages = [ { role: 'system', content: IMAGE_ANALYSIS_SYSTEM_PROMPT }, { role: 'user', content: [ { type: 'text' as const, text: buildImageAnalysisUserText({ sender: request.sender, sentAt: request.sentAt, contextBefore: [], contextAfter: [] }) }, { type: 'image' as const, dataUrl: request.imageDataUrl } ] } ] const list = this.providerService.list() const provider = list.providers.find((item) => item.id === list.defaultProviderId) || list.providers[0] this.runtimeProviderId = provider?.id this.runtimeModelId = provider?.defaultModel const result = await this.providerService.analyzeImage(messages, { providerId: this.runtimeProviderId, modelId: this.runtimeModelId }) if (!result.success || !result.data) { console.warn('[ImageInsightService] analyze vision failed: %s', result.error || 'no data') return { success: false, error: result.error || 'AI 未返回内容' } } console.log('[ImageInsightService] analyze ok, description=%s', result.data.slice(0, 80)) const parsed = parseImageAnalysisResponse(result.data) const insight: ImageInsight = { id: randomUUID(), messageId: request.messageId, imageHash: request.imageHash, md5: undefined, datName: undefined, description: parsed.description, ocrText: parsed.ocrText || undefined, tags: parsed.tags, category: parsed.category, importance: parsed.importance, provider: this.runtimeProviderId || '', model: this.runtimeModelId || '', createdAt: Date.now(), updatedAt: Date.now(), sender: request.sender, sentAt: request.sentAt, sessionId: request.sessionId } imageInsightsStore.upsert(insight) return { success: true, insight, fromCache: false } } catch (error) { const message = error instanceof Error ? error.message : String(error) console.warn('[ImageInsightService] analyze failed:', message) return { success: false, error: message } } } /** * 日报入口:从 renderer 传入的图片消息候选中挑 Top N + 命中缓存的 Insight。 * * 设计:不自己查 chat-service(参数语义不清),而是由 renderer 从已加载的 messages 中 * 提取图片消息 + 计算热度后传入。这样既复用现有数据,又避免 userMd5/sessionId 混淆。 */ async listTopHotImages( query: ImageCandidateQuery, inputs: ImageCandidateInput[] = [] ): Promise { const limit = query.limit ?? 3 const candidates: ImageCandidate[] = [] console.log('[ImageInsightService] listTopHotImages received %d inputs', inputs.length) for (const input of inputs) { const hash = await this.computeImageHash(input.md5, input.datName) if (!hash) { console.log( '[ImageInsightService] skip %s: hash empty (md5=%s datName=%s)', input.messageId, input.md5, input.datName ) continue } const heatScore = input.responseCount * 3 + input.interactionCount * 2 + 1 const candidate: ImageCandidate = { messageId: input.messageId, imageHash: hash, md5: input.md5, datName: input.datName, sessionId: input.sessionId, sender: input.sender, sentAt: input.sentAt, heatScore } const cached = imageInsightsStore.getByHash(hash) if (cached) candidate.insight = cached candidates.push(candidate) } candidates.sort((a, b) => b.heatScore - a.heatScore) return candidates.slice(0, limit) } /** * 列出会话所有 insights(按时间倒序,供未来 UI 复用) */ listBySession(sessionId: string, limit?: number): ImageInsight[] { return imageInsightsStore.listBySession(sessionId, limit) } } export const imageInsightService = new ImageInsightService()