mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 完善群聊日报模板与图片理解
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// src/main/db/image-insights-store.ts
|
||||
// 持久化 ImageInsight 到 JSON 文件(userData/image-insights.json)
|
||||
// 跟项目现有风格一致(ai-provider-service 用 ai-providers.json)
|
||||
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { ImageInsight } from '../../shared/image-insight'
|
||||
|
||||
interface ImageInsightsFile {
|
||||
version: 1
|
||||
/** imageHash -> ImageInsight 索引(缓存查询 O(1)) */
|
||||
byHash: Record<string, ImageInsight>
|
||||
/** messageId -> imageHash 反向索引(防止同一 message 重复入库) */
|
||||
byMessageId: Record<string, string>
|
||||
}
|
||||
|
||||
const EMPTY_FILE: ImageInsightsFile = {
|
||||
version: 1,
|
||||
byHash: {},
|
||||
byMessageId: {}
|
||||
}
|
||||
|
||||
class ImageInsightsStore {
|
||||
private cache: ImageInsightsFile | null = null
|
||||
|
||||
private get filePath(): string {
|
||||
return path.join(app.getPath('userData'), 'image-insights.json')
|
||||
}
|
||||
|
||||
private ensureLoaded(): ImageInsightsFile {
|
||||
if (this.cache) return this.cache
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
const raw = fs.readJsonSync(this.filePath) as Partial<ImageInsightsFile>
|
||||
this.cache = {
|
||||
version: 1,
|
||||
byHash: raw.byHash || {},
|
||||
byMessageId: raw.byMessageId || {}
|
||||
}
|
||||
return this.cache
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ImageInsightsStore] failed to load, fallback to empty:', error)
|
||||
}
|
||||
this.cache = { ...EMPTY_FILE }
|
||||
return this.cache
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
if (!this.cache) return
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(this.filePath))
|
||||
fs.writeJsonSync(this.filePath, this.cache, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.error('[ImageInsightsStore] failed to persist:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过 imageHash 查询缓存 */
|
||||
getByHash(imageHash: string): ImageInsight | null {
|
||||
return this.ensureLoaded().byHash[imageHash] || null
|
||||
}
|
||||
|
||||
/** 列出某会话的所有 insights(按时间倒序) */
|
||||
listBySession(sessionId: string, limit?: number): ImageInsight[] {
|
||||
const data = this.ensureLoaded()
|
||||
const items = Object.values(data.byHash)
|
||||
.filter((it) => it.sessionId === sessionId)
|
||||
.sort((a, b) => b.sentAt - a.sentAt)
|
||||
return typeof limit === 'number' ? items.slice(0, limit) : items
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入或更新 Insight。
|
||||
* - 同 imageHash 已存在:更新 description/ocrText/tags/category/importance/provider/model/updatedAt(保留 createdAt)
|
||||
* - 新 hash:插入
|
||||
* 同步维护 byMessageId 反向索引。
|
||||
*/
|
||||
upsert(insight: ImageInsight): void {
|
||||
const data = this.ensureLoaded()
|
||||
const existing = data.byHash[insight.imageHash]
|
||||
const now = Date.now()
|
||||
if (existing) {
|
||||
data.byHash[insight.imageHash] = {
|
||||
...existing,
|
||||
...insight,
|
||||
id: existing.id, // 保留 id
|
||||
createdAt: existing.createdAt, // 保留首次分析时间
|
||||
updatedAt: now
|
||||
}
|
||||
} else {
|
||||
data.byHash[insight.imageHash] = { ...insight, createdAt: now, updatedAt: now }
|
||||
data.byMessageId[insight.messageId] = insight.imageHash
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const imageInsightsStore = new ImageInsightsStore()
|
||||
@@ -6,11 +6,29 @@ import {
|
||||
GroupReportExportRequest,
|
||||
GroupReportExportResult,
|
||||
GroupReportMetadata,
|
||||
ReportHeat
|
||||
ReportHeat,
|
||||
ReportSectionMeta
|
||||
} from '../shared/group-report'
|
||||
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
|
||||
const TEMPLATE_NAME = 'mobile_daily_report.html'
|
||||
const TEMPLATE_FILES: Record<string, string> = {
|
||||
v1: 'mobile_daily_report_v1.html',
|
||||
v2: 'mobile_daily_report_v2.html'
|
||||
}
|
||||
const DEFAULT_TEMPLATE = TEMPLATE_FILES.v1
|
||||
|
||||
const templatePath = (templateId?: string): string => {
|
||||
const name = TEMPLATE_FILES[templateId || ''] || DEFAULT_TEMPLATE
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', name),
|
||||
path.join(app.getAppPath(), 'resources', name),
|
||||
path.join(process.cwd(), 'resources', name)
|
||||
]
|
||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? '')
|
||||
@@ -75,17 +93,6 @@ const embedAvatar = async (source: string | undefined, name: string): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
const templatePath = (): string => {
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, 'resources', TEMPLATE_NAME),
|
||||
path.join(app.getAppPath(), 'resources', TEMPLATE_NAME),
|
||||
path.join(process.cwd(), 'resources', TEMPLATE_NAME)
|
||||
]
|
||||
const found = candidates.find((candidate) => fs.existsSync(candidate))
|
||||
if (!found) throw new Error(`日报模板不存在: ${candidates.join(' | ')}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* 从群成员快照反推真头像,填进 metadata.avatars。
|
||||
* - 没传 talker → 跳过(向后兼容)
|
||||
@@ -138,13 +145,10 @@ const heatClass = (heat: ReportHeat): string => {
|
||||
const replacePlaceholder = (html: string, key: string, value: string): string =>
|
||||
html.replaceAll(`{{${key}}}`, value)
|
||||
|
||||
const modeLabel = (mode: GroupReportMetadata['reportMode']): string =>
|
||||
mode === 'full' ? '完整版' : '精简版'
|
||||
|
||||
const sectionMeta = (
|
||||
request: GroupReportExportRequest,
|
||||
key: keyof NonNullable<typeof request.report.sectionMeta>
|
||||
) => request.report.sectionMeta?.[key]
|
||||
): ReportSectionMeta | undefined => request.report.sectionMeta?.[key]
|
||||
|
||||
const sectionClass = (
|
||||
request: GroupReportExportRequest,
|
||||
@@ -205,8 +209,25 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
: ''
|
||||
}
|
||||
${
|
||||
topic.image?.imageUrl
|
||||
? `<div class="topic-inline-image"><img src="${topic.image.imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
topic.image
|
||||
? (() => {
|
||||
// 优先用已有 imageUrl;若有 imageHash(来自 visionGallery),按 hash 取原图
|
||||
let imageUrl = topic.image.imageUrl
|
||||
if (!imageUrl && topic.image.imageHash) {
|
||||
const insight = imageInsightService.getInsight(topic.image.imageHash)
|
||||
if (insight) {
|
||||
// insight 不含 imageUrl,需要按 md5/datName 重新拿;这里通过 ImageDecryptService 间接获取
|
||||
// 走 ImageDecryptService.findImageFile + decryptImageToBase64
|
||||
const decryptService = (globalThis as { __imageDecrypt?: { findImageFile: (md5?: string, dat?: string) => string | null; decryptImageToBase64: (p: string) => string | null } }).__imageDecrypt
|
||||
if (decryptService) {
|
||||
const filePath = decryptService.findImageFile(insight.md5, insight.datName)
|
||||
if (filePath) imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!imageUrl) return ''
|
||||
return `<div class="topic-inline-image"><img src="${imageUrl}" alt="热点图片"><div>${escapeHtml(topic.image.note)}</div></div>`
|
||||
})()
|
||||
: ''
|
||||
}
|
||||
<div class="participants">${topic.participants
|
||||
@@ -325,6 +346,24 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
// AI 图片理解结果板块(ImageInsight)
|
||||
// 内容由 ImageInsightService.analyze 生成,真实看图 + 看上下文
|
||||
const visionCards = (report.media?.visionGallery || [])
|
||||
.filter((item) => item.imageUrl) // 只显示加载成功的图
|
||||
.map(
|
||||
(item) => `<div class="vision-card">
|
||||
<img class="vision-image" src="${item.imageUrl}" alt="AI 识别的图片">
|
||||
<div class="vision-body">
|
||||
<div class="important-meta"><b>${escapeHtml(item.sender)}</b><span>${escapeHtml(item.time)}</span></div>
|
||||
<div class="vision-description">${escapeHtml(item.description)}</div>
|
||||
${item.ocrText ? `<div class="vision-ocr">📝 ${escapeHtml(item.ocrText)}</div>` : ''}
|
||||
${item.tags.length ? `<div class="vision-tags">${item.tags.map((t) => `<span class="vision-tag">${escapeHtml(t)}</span>`).join('')}</div>` : ''}
|
||||
<div class="vision-label">AI 图片识别</div>
|
||||
</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')
|
||||
|
||||
const voiceCards = (report.media?.voiceHighlights || [])
|
||||
.map(
|
||||
(item) => `<div class="qa-card">
|
||||
@@ -362,6 +401,20 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
)
|
||||
.join('')
|
||||
|
||||
// v1 模板使用的水平条形热度图,渲染 top speakers 排行
|
||||
const heatBarsHtml = report.analytics.topSpeakers
|
||||
.slice(0, 8)
|
||||
.map((speaker) => {
|
||||
const count = Math.max(0, speaker.count)
|
||||
const width = Math.min(100, count * 12)
|
||||
return `<div class="heat-row">
|
||||
<span class="heat-name">${escapeHtml(speaker.name)}</span>
|
||||
<span class="heat-bar"><i style="width:${width}%"></i></span>
|
||||
<span class="heat-val">${count}</span>
|
||||
</div>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const cloudTags = report.keywords
|
||||
.slice(0, 15)
|
||||
.map(
|
||||
@@ -390,14 +443,16 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
unresolvedCount: report.unresolved.length
|
||||
}
|
||||
|
||||
let html = await fs.readFile(templatePath(), 'utf8')
|
||||
let html = await fs.readFile(templatePath(request.templateId), 'utf8')
|
||||
const values: Record<string, string> = {
|
||||
REPORT_TITLE: escapeHtml(`${metadata.groupName}日报`),
|
||||
REPORT_MODE_CLASS: metadata.reportMode === 'full' ? 'full' : 'compact',
|
||||
GROUP_NAME: escapeHtml(metadata.groupName),
|
||||
DATE_RANGE: escapeHtml(metadata.dateRange),
|
||||
RECORD_NOTE: escapeHtml(metadata.recordNote),
|
||||
REPORT_MODE_LABEL: escapeHtml(modeLabel(metadata.reportMode)),
|
||||
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
|
||||
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
|
||||
// v2 模板使用的 hero-*
|
||||
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
|
||||
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
|
||||
HERO_TAKEAWAY: escapeHtml(report.hero?.keyTakeaway || ''),
|
||||
@@ -442,6 +497,14 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
CHAINS_EMPTY_CLASS: sectionClass(request, 'chains', report.participantChains?.length > 0),
|
||||
CHAIN_CARDS: chainCards,
|
||||
CHAINS_MORE_NOTE: overflowNote(request, 'chains'),
|
||||
// AI 图片识别板块
|
||||
VISION_EMPTY_CLASS: sectionClass(
|
||||
request,
|
||||
'vision',
|
||||
(report.media?.visionGallery?.length ?? 0) > 0
|
||||
),
|
||||
VISION_CARDS: visionCards,
|
||||
VISION_TITLE: '📸 AI 识别的图片精选',
|
||||
GALLERY_EMPTY_CLASS: sectionClass(request, 'gallery', report.media?.gallery?.length > 0),
|
||||
GALLERY_CARDS: galleryCards,
|
||||
GALLERY_MORE_NOTE: overflowNote(request, 'gallery'),
|
||||
@@ -463,9 +526,13 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
|
||||
KEYWORDS_MORE_NOTE: overflowNote(request, 'keywords'),
|
||||
ANALYTICS_EMPTY_CLASS: sectionClass(request, 'analytics', true),
|
||||
GENERATED_AT: escapeHtml(metadata.generatedAt),
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote)
|
||||
FOOTER_NOTE: escapeHtml(metadata.footerNote),
|
||||
// v1 模板独有:从 analytics.topSpeakers 渲染水平条形热度图
|
||||
HEAT_BARS: heatBarsHtml
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) html = replacePlaceholder(html, key, value)
|
||||
// 清空模板中残留的未使用占位符(模板独有但 values 没提供的键)
|
||||
html = html.replace(/\{\{[A-Z_]+\}\}/g, '')
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -520,7 +587,8 @@ export const exportGroupReport = async (
|
||||
|
||||
const outputDir = path.join(os.homedir(), 'Documents', '微信聊天记录')
|
||||
await fs.ensureDir(outputDir)
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${request.metadata.reportMode === 'full' ? '完整版' : '精简版'}`
|
||||
const templateLabel = request.templateId === 'v1' ? '经典版' : '模板2'
|
||||
const baseName = `${sanitizeFileName(request.metadata.groupName)}日报_${request.metadata.reportDate}_${templateLabel}`
|
||||
const htmlPath = path.join(outputDir, `${baseName}.html`)
|
||||
const pngPath = path.join(outputDir, `${baseName}.png`)
|
||||
const htmlStartedAt = new Date()
|
||||
|
||||
@@ -317,6 +317,39 @@ export class ImageDecryptService {
|
||||
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 首选 DAT 无法解密时,继续尝试同目录下属于同一图片的其他清晰度变体。
|
||||
* 微信可能只保留 base/_h/_hd/_t 中的一部分,不能把首个文件失败等同于整张图失败。
|
||||
*/
|
||||
decryptImageToBase64WithFallback(
|
||||
datPath: string,
|
||||
allowThumbnail = true
|
||||
): { data: string; filePath: string } | null {
|
||||
const candidates = [datPath]
|
||||
if (extname(datPath).toLowerCase().includes('dat')) {
|
||||
const dir = dirname(datPath)
|
||||
const base = this.normalizeDatBase(basename(datPath))
|
||||
const siblings = this.buildPreferredDatNames(base)
|
||||
.filter((name) => allowThumbnail || !this.isThumbnailName(name))
|
||||
.map((name) => join(dir, name))
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
.sort((left, right) => {
|
||||
const leftThumb = this.isThumbnailName(basename(left)) ? 1 : 0
|
||||
const rightThumb = this.isThumbnailName(basename(right)) ? 1 : 0
|
||||
if (leftThumb !== rightThumb) return leftThumb - rightThumb
|
||||
return statSync(right).size - statSync(left).size
|
||||
})
|
||||
candidates.push(...siblings)
|
||||
}
|
||||
|
||||
for (const candidate of this.uniq(candidates)) {
|
||||
const data = this.decryptImageToBase64(candidate)
|
||||
if (data) return { data, filePath: candidate }
|
||||
}
|
||||
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAT 文件版本
|
||||
*/
|
||||
@@ -485,7 +518,9 @@ export class ImageDecryptService {
|
||||
})
|
||||
.sort((left, right) => right.size - left.size)
|
||||
|
||||
const nonThumb = toSized(paths.filter((candidate) => !this.isThumbnailName(basename(candidate))))
|
||||
const nonThumb = toSized(
|
||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
if (nonThumb[0]) return nonThumb[0].candidate
|
||||
if (!allowThumbnail) return null
|
||||
|
||||
|
||||
+101
-5
@@ -37,6 +37,14 @@ import type {
|
||||
import { DatabaseKeyStore } from './database-key-store'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { AIProviderService } from './services/ai-provider-service'
|
||||
import { imageInsightService } from './services/image-insight-service'
|
||||
import type {
|
||||
ImageAnalysisRequest,
|
||||
ImageAnalysisResponse,
|
||||
ImageCandidate,
|
||||
ImageCandidateQuery,
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import { KeyServiceMac } from './key-service-mac'
|
||||
import { KeyService as KeyServiceWin } from './key-service-win'
|
||||
import * as chat from './services/chat-service'
|
||||
@@ -475,20 +483,108 @@ app.whenReady().then(async () => {
|
||||
return { success: false, error: force ? '未找到原图或缩略图文件' : '未找到图片文件' }
|
||||
}
|
||||
|
||||
const base64 = imageDecryptService.decryptImageToBase64(filePath)
|
||||
if (!base64) {
|
||||
const decrypted = imageDecryptService.decryptImageToBase64WithFallback(filePath, true)
|
||||
if (!decrypted) {
|
||||
return { success: false, error: '图片解密失败' }
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: base64,
|
||||
isThumb: imageDecryptService.isThumbnailFile(filePath),
|
||||
filePath
|
||||
data: decrypted.data,
|
||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||
filePath: decrypted.filePath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// AI 图片理解基础设施(ImageInsightService)
|
||||
// ============================================================
|
||||
// 注入依赖(用闭包捕获当前 db:getImage 已经初始化过的 imageDecryptService)
|
||||
// 同时把 imageDecryptService 暴露到 globalThis,供 group-report-service 渲染时按 imageHash 取图
|
||||
;(globalThis as { __imageDecrypt?: typeof imageDecryptService }).__imageDecrypt =
|
||||
imageDecryptService
|
||||
imageInsightService.bind({
|
||||
providerService: aiProviderService,
|
||||
decryptService: {
|
||||
findImageFile: (md5, datName, opts) =>
|
||||
imageDecryptService?.findImageFile(md5, datName, opts) ?? null,
|
||||
decryptImageToBase64: (filePath) =>
|
||||
imageDecryptService?.decryptImageToBase64(filePath) ?? null
|
||||
}
|
||||
})
|
||||
|
||||
/** 日报入口:取会话 Top N 热点图片 + 已缓存的 Insight */
|
||||
ipcMain.handle(
|
||||
'image:listCandidates',
|
||||
async (
|
||||
_,
|
||||
query: ImageCandidateQuery
|
||||
): Promise<{ success: boolean; candidates: ImageCandidate[]; error?: string }> => {
|
||||
console.log('[IPC] image:listCandidates query=%j', query)
|
||||
try {
|
||||
const inputs = (query as ImageCandidateQuery & { inputs?: unknown[] }).inputs || []
|
||||
console.log('[IPC] image:listCandidates received %d inputs', inputs.length)
|
||||
const candidates = await imageInsightService.listTopHotImages(query, inputs as never)
|
||||
console.log('[IPC] image:listCandidates returned %d candidates', candidates.length)
|
||||
return { success: true, candidates }
|
||||
} catch (error) {
|
||||
console.warn('[IPC] image:listCandidates failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
candidates: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/** 单图分析:缓存命中即返回,未命中调 AI;失败不抛 */
|
||||
ipcMain.handle(
|
||||
'image:analyze',
|
||||
async (_, request: ImageAnalysisRequest): Promise<ImageAnalysisResponse> => {
|
||||
console.log('[IPC] image:analyze hash=%s messageId=%s', request.imageHash, request.messageId)
|
||||
// 校验 provider 是否支持 vision
|
||||
const runtime = aiProviderService.getRuntimeConfig()
|
||||
if (!runtime.configured) {
|
||||
return { success: false, error: '尚未配置 AI Provider' }
|
||||
}
|
||||
const list = aiProviderService.list()
|
||||
const provider = list.providers.find((p) => p.id === runtime.providerId)
|
||||
const model = provider?.models.find((m) => m.id === runtime.model)
|
||||
if (!provider || !model) {
|
||||
return { success: false, error: '当前 AI 模型不存在' }
|
||||
}
|
||||
if (!model.capabilities.vision) {
|
||||
return { success: false, error: '当前模型不支持图片理解' }
|
||||
}
|
||||
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
|
||||
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
|
||||
return imageInsightService.analyze(request)
|
||||
}
|
||||
)
|
||||
|
||||
/** 单图查询缓存 */
|
||||
ipcMain.handle(
|
||||
'image:getInsight',
|
||||
async (_, imageHash: string): Promise<{ success: boolean; insight?: ImageInsight }> => {
|
||||
const insight = imageInsightService.getInsight(imageHash)
|
||||
return { success: true, insight: insight || undefined }
|
||||
}
|
||||
)
|
||||
|
||||
/** 列出某会话所有已分析的 insights */
|
||||
ipcMain.handle(
|
||||
'image:listInsights',
|
||||
async (
|
||||
_,
|
||||
sessionId: string,
|
||||
limit?: number
|
||||
): Promise<{ success: boolean; insights: ImageInsight[] }> => {
|
||||
return { success: true, insights: imageInsightService.listBySession(sessionId, limit) }
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getSticker', async (_, cdnUrl?: string, md5?: string) => {
|
||||
if (!stickerService) {
|
||||
stickerService = new StickerService(chat.getChatDb()?.getWcdb4Client())
|
||||
|
||||
@@ -69,7 +69,8 @@ export class AIProviderService {
|
||||
configured: Boolean(
|
||||
provider && provider.models.length && (provider.hasApiKey || !needsApiKey(provider))
|
||||
),
|
||||
status: provider?.status || 'untested'
|
||||
status: provider?.status || 'untested',
|
||||
timeoutMs: provider?.advanced.timeoutMs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +170,37 @@ export class AIProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多模态图片理解。
|
||||
* 输入:text + image parts 的 messages,返回 AI 文本响应。
|
||||
* 与 testVision 区别:不校验 prompt,不写入 capability marker(供 ImageInsightService 复用)。
|
||||
*/
|
||||
async analyzeImage(
|
||||
messages: Array<{
|
||||
role: string
|
||||
content: string | Array<{ type: 'text'; text: string } | { type: 'image'; dataUrl: string }>
|
||||
}>,
|
||||
options?: AIChatRequestOptions
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const imagePart = messages
|
||||
.flatMap((message) => (typeof message.content === 'string' ? [] : message.content))
|
||||
.find((part) => part.type === 'image')
|
||||
if (!imagePart || imagePart.type !== 'image') throw new Error('图片识别请求缺少图片数据')
|
||||
const imageError = validateVisionImage(imagePart.dataUrl)
|
||||
if (imageError) throw new Error(imageError)
|
||||
const result = await this.request(messages as AIMessage[], options)
|
||||
return { success: true, ...result }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
async testVision(request: AIVisionTestRequest): Promise<AIVisionTestResult> {
|
||||
const startedAt = Date.now()
|
||||
const imageError = validateVisionImage(request.imageDataUrl)
|
||||
@@ -215,7 +247,13 @@ export class AIProviderService {
|
||||
}> {
|
||||
if (options?.apiKey) return this.requestLegacy(messages, options)
|
||||
const resolved = this.resolveProvider(options)
|
||||
return requestProvider(resolved.provider, resolved.key, resolved.model, messages, testing)
|
||||
const provider = options?.timeoutMs
|
||||
? {
|
||||
...resolved.provider,
|
||||
advanced: { ...resolved.provider.advanced, timeoutMs: options.timeoutMs }
|
||||
}
|
||||
: resolved.provider
|
||||
return requestProvider(provider, resolved.key, resolved.model, messages, testing)
|
||||
}
|
||||
|
||||
private resolveProvider(options?: { providerId?: string; modelId?: string }): {
|
||||
@@ -263,12 +301,38 @@ export class AIProviderService {
|
||||
}
|
||||
|
||||
private markVisionCapability(providerId: string, modelId: string): void {
|
||||
this.markCapabilities(providerId, modelId, { vision: true, ocr: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记模型已验证的 capabilities(已存在则跳过)。
|
||||
* OCR 跟随 vision:几乎所有 vision 模型都能 OCR,标记 vision 时同步标记 ocr。
|
||||
*/
|
||||
private markCapabilities(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
caps: { vision?: boolean; ocr?: boolean }
|
||||
): void {
|
||||
const data = this.readMetadata()
|
||||
const provider = data.providers.find((item) => item.id === providerId)
|
||||
const model = provider?.models.find((item) => item.id === modelId)
|
||||
if (!provider || !model || model.capabilities.vision) return
|
||||
model.capabilities.vision = true
|
||||
this.writeMetadata(data)
|
||||
if (!provider || !model) return
|
||||
// 老配置可能没有 ocr 字段,补默认 false
|
||||
if (typeof model.capabilities.ocr !== 'boolean') model.capabilities.ocr = false
|
||||
let changed = false
|
||||
if (caps.vision === true && !model.capabilities.vision) {
|
||||
model.capabilities.vision = true
|
||||
// vision 开启默认带 ocr(派生能力)
|
||||
if (!model.capabilities.ocr) {
|
||||
model.capabilities.ocr = true
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (caps.ocr === true && !model.capabilities.ocr) {
|
||||
model.capabilities.ocr = true
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.writeMetadata(data)
|
||||
}
|
||||
|
||||
private ensureEnvironmentMigration(): void {
|
||||
@@ -300,6 +364,14 @@ export class AIProviderService {
|
||||
const data = fs.readJsonSync(filePath) as AIProviderMetadataFile
|
||||
if (data.version !== 1 || !Array.isArray(data.providers))
|
||||
throw new Error('invalid provider metadata')
|
||||
// 老配置兼容:补 capabilities.ocr 默认值(vision 派生 OCR)
|
||||
for (const provider of data.providers) {
|
||||
for (const model of provider.models) {
|
||||
if (typeof model.capabilities.ocr !== 'boolean') {
|
||||
model.capabilities.ocr = model.capabilities.vision === true
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -325,7 +397,7 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
|
||||
{
|
||||
name: modelId === 'deepseek-chat' ? 'DeepSeek Chat' : modelId,
|
||||
id: modelId,
|
||||
capabilities: { chat: true, vision: false, longContext: true }
|
||||
capabilities: { chat: true, vision: false, ocr: false, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: modelId,
|
||||
@@ -454,7 +526,7 @@ async function requestOpenAICompatible(
|
||||
},
|
||||
provider.advanced.timeoutMs
|
||||
)
|
||||
const payload = (await response.json()) as OpenAIResponsePayload
|
||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||
if (!response.ok) throw new Error(payload.error?.message || `AI 请求失败 (${response.status})`)
|
||||
return {
|
||||
data: String(payload.choices?.[0]?.message?.content || ''),
|
||||
@@ -508,7 +580,7 @@ async function requestAnthropic(
|
||||
},
|
||||
provider.advanced.timeoutMs
|
||||
)
|
||||
const payload = (await response.json()) as AnthropicResponsePayload
|
||||
const payload = await parseJsonResponse<AnthropicResponsePayload>(response)
|
||||
if (!response.ok)
|
||||
throw new Error(payload.error?.message || `Anthropic 请求失败 (${response.status})`)
|
||||
return {
|
||||
@@ -543,6 +615,20 @@ async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
const body = await response.text()
|
||||
try {
|
||||
return JSON.parse(body) as T
|
||||
} catch {
|
||||
const looksLikeHtml = /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)
|
||||
const status = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
|
||||
if (looksLikeHtml) {
|
||||
throw new Error(`模型服务返回了网页而不是 JSON(HTTP ${status}),请稍后重试或检查中转服务`)
|
||||
}
|
||||
throw new Error(`模型服务返回格式异常(HTTP ${status})`)
|
||||
}
|
||||
}
|
||||
|
||||
function safeAIError(error: unknown): string {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return 'AI 请求超时'
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -56,7 +56,14 @@ export interface FormattedMessage {
|
||||
export interface GroupSnapshot {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: { wxid: string; nickname: string; avatar: string }[]
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
const MSG_TYPE_DICT: Record<number, string> = {
|
||||
@@ -292,6 +299,9 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
.map((member) => ({
|
||||
wxid: member.m_nsUsrName,
|
||||
nickname: member.nickname || '',
|
||||
groupNickname: member.groupNickname || '',
|
||||
wechatNickname: member.wechatNickname || '',
|
||||
remark: member.remark || '',
|
||||
avatar: member.m_nsHeadImgUrl || ''
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// src/main/services/image-insight-prompt.ts
|
||||
// 图片理解 prompt 模板 — 输出严格的 JSON,便于程序化解析
|
||||
|
||||
export const IMAGE_ANALYSIS_SYSTEM_PROMPT = `你是微信群聊的图片分析助手。
|
||||
请根据用户提供的图片和图片前后的聊天上下文,生成对该图片的结构化理解。
|
||||
|
||||
输出要求(严格遵守):
|
||||
1. 必须输出 JSON,不要用 markdown 代码块包裹
|
||||
2. description:1-2 句中文,30-80 字,描述图片核心内容
|
||||
3. ocrText:如果图片含文字(截图、文档、票据等),提取出来;纯风景/表情包可填空字符串
|
||||
4. tags:3-6 个中文关键词标签
|
||||
5. category:screenshot / photo / meme / document / chart / other 之一
|
||||
6. importance:low / medium / high — 根据图片的信息密度和后续讨论热度判断
|
||||
|
||||
禁止:
|
||||
- 不要猜测图片中未明确可见的内容
|
||||
- 不要复述聊天上下文本身(那是 description 之外的事)
|
||||
- 不要输出 markdown 标记`
|
||||
|
||||
export interface ImageAnalysisContext {
|
||||
sender: string
|
||||
sentAt: number
|
||||
contextBefore: string[] // 图片前 1-3 条消息
|
||||
contextAfter: string[] // 图片后 1-3 条消息
|
||||
}
|
||||
|
||||
export function buildImageAnalysisUserText(ctx: ImageAnalysisContext): string {
|
||||
const before = ctx.contextBefore.length
|
||||
? ctx.contextBefore.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
|
||||
: ' (无前文)'
|
||||
const after = ctx.contextAfter.length
|
||||
? ctx.contextAfter.map((m, i) => ` ${i + 1}. ${m}`).join('\n')
|
||||
: ' (无后续讨论)'
|
||||
const time = new Date(ctx.sentAt * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
return `发送者:${ctx.sender}
|
||||
时间:${time}
|
||||
|
||||
图片前的聊天:
|
||||
${before}
|
||||
|
||||
图片后的聊天:
|
||||
${after}
|
||||
|
||||
请输出 JSON(严格遵守 system 要求):
|
||||
{"description":"...","ocrText":"...","tags":["..."],"category":"...","importance":"..."}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 AI 文本响应解析成结构化字段。
|
||||
* 容忍:无 markdown 包裹、有 markdown 包裹、尾部有杂质等。
|
||||
*/
|
||||
export function parseImageAnalysisResponse(raw: string): {
|
||||
description: string
|
||||
ocrText: string
|
||||
tags: string[]
|
||||
category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other'
|
||||
importance: 'low' | 'medium' | 'high'
|
||||
} {
|
||||
const text = raw.trim()
|
||||
// 提取 JSON 段
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) {
|
||||
throw new Error('AI 未返回合法 JSON')
|
||||
}
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(jsonMatch[0])
|
||||
} catch {
|
||||
throw new Error('AI 返回的 JSON 无法解析')
|
||||
}
|
||||
|
||||
const description = String(parsed.description || '').trim()
|
||||
if (!description) throw new Error('AI 未返回 description')
|
||||
|
||||
const ocrText = String(parsed.ocrText || '').trim()
|
||||
const tagsRaw = parsed.tags
|
||||
const tags = Array.isArray(tagsRaw)
|
||||
? tagsRaw.map((t) => String(t).trim()).filter(Boolean).slice(0, 8)
|
||||
: []
|
||||
|
||||
const categoryRaw = String(parsed.category || 'other').toLowerCase()
|
||||
const category: 'screenshot' | 'photo' | 'meme' | 'document' | 'chart' | 'other' =
|
||||
['screenshot', 'photo', 'meme', 'document', 'chart'].includes(categoryRaw)
|
||||
? (categoryRaw as 'screenshot' | 'photo' | 'meme' | 'document' | 'chart')
|
||||
: 'other'
|
||||
|
||||
const importanceRaw = String(parsed.importance || 'medium').toLowerCase()
|
||||
const importance: 'low' | 'medium' | 'high' = ['low', 'medium', 'high'].includes(importanceRaw)
|
||||
? (importanceRaw as 'low' | 'medium' | 'high')
|
||||
: 'medium'
|
||||
|
||||
return { description, ocrText, tags, category, importance }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -32,6 +32,9 @@ export interface Wcdb4MessageQueryOptions {
|
||||
export interface Wcdb4GroupMember {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
m_nsHeadImgUrl: string
|
||||
}
|
||||
|
||||
@@ -930,17 +933,24 @@ export class Wcdb4Client {
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const memberNickname = this.pickString(row, [
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'm_nsNickName'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
const memberNickname = this.pickString(row, [
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'roomNickname',
|
||||
'room_nickname',
|
||||
'remark',
|
||||
'm_nsNickName'
|
||||
'name'
|
||||
])
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
@@ -955,7 +965,11 @@ export class Wcdb4Client {
|
||||
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: groupNicknames.get(username) || memberNickname,
|
||||
nickname:
|
||||
groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
groupNickname: groupNicknames.get(username) || '',
|
||||
wechatNickname: wechatNickname || memberNickname,
|
||||
remark,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
@@ -975,6 +989,8 @@ export class Wcdb4Client {
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
wechatNickname:
|
||||
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user