mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
feat: 完善群聊日报模板与图片理解
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
// 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<string | null> {
|
||||
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<ImageAnalysisResponse> {
|
||||
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<ImageCandidate[]> {
|
||||
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()
|
||||
Reference in New Issue
Block a user