Files
WechatExplorer/src/renderer/src/utils/group-report-facts.ts
T
Wxw-Gu cd2fb09e02 feat: 新增实验性微信卡片分享与自动部署能力
补充 Cloudflare Worker、R2 存储、微信 JS-SDK 签名与上传鉴权
增加自动部署 Skill 和配置引导文档
优化报告工具栏、微信卡片弹窗及窄屏响应式布局
补充 Worker 鉴权、卡片生成、过期清理与安全转义测试
2026-08-13 10:48:07 +08:00

843 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Contact, Message } from '../../../shared/types'
import {
GroupDailyReport,
GroupReportMetadata,
ReportFunBadge,
ReportMode,
ReportSpeakerRank,
ReportVisionGalleryItem,
ReportVoiceHighlight,
ReportVoiceLeaderboardItem
} from '../../../shared/group-report'
import type {
ImageAnalysisRequest,
ImageAnalysisResponse,
ImageCandidate,
ImageCandidateQuery
} from '../../../shared/image-insight'
import { calculateImageHeatScore, isHotImageCandidate } from '../../../shared/image-insight'
import type { ReportModelChoice } from '../../../shared/ai-provider'
interface ReportImageReadResult {
success: boolean
data?: string
error?: string
}
declare const window: {
api: {
imageListCandidates: (query: ImageCandidateQuery) => Promise<{
success: boolean
candidates: ImageCandidate[]
error?: string
}>
imageAnalyze: (request: ImageAnalysisRequest) => Promise<ImageAnalysisResponse>
getImage: (
imageMd5?: string,
imageDatNameOrThumb?: string | boolean,
sessionId?: string,
options?: { includeData?: boolean }
) => Promise<ReportImageReadResult>
}
}
export interface GroupReportTranscriptRow {
id: string
datetime: string
timestamp: number
sender: string
content: string
avatar?: string
}
export interface GroupReportFactsSnapshot {
metadata: GroupReportMetadata
transcriptRows: GroupReportTranscriptRow[]
topSpeakers: ReportSpeakerRank[]
activeTimeline: string
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
factsPrompt: string
imageInsightSummary: ReportImageInsightSummary
}
export interface ReportImageInsightItem {
messageId: string
sender: string
time: string
description: string
ocrText?: string
tags: string[]
}
export interface ReportImageInsightFailure {
messageId?: string
sender: string
time?: string
error: string
}
export interface ReportImageInsightSummary {
total: number
succeeded: number
failed: number
items: ReportImageInsightItem[]
failures: ReportImageInsightFailure[]
}
export interface ReportPreparationProgress {
stage: 'selectingImages' | 'recognizingImages' | 'summarizingInput'
label: string
completed?: number
total?: number
}
export interface BuildGroupReportFactsOptions {
onProgress?: (progress: ReportPreparationProgress) => void
visionModel?: ReportModelChoice
}
function friendlyImageNotice(warnings: string[]): string {
const detail = warnings.join(' ')
if (/模型.*不支持|vision|multimodal|image.*support/i.test(detail)) {
return '当前 AI 模型暂未通过图片理解验证,已跳过图片精选;文字日报不受影响。'
}
if (/解密|密钥|未找到|读取失败/.test(detail)) {
return '部分图片在本机暂不可用,已跳过图片精选;文字日报不受影响。'
}
if (/格式.*不支持|图片格式/.test(detail)) {
return '部分图片暂不适合 AI 分析,已跳过图片精选;文字日报不受影响。'
}
return '图片精选暂未生成,文字消息、统计和关键词仍已正常处理。'
}
export const isInternalIdentifier = (value: string): boolean =>
/@chatroom$/i.test(value) || /^wxid_/i.test(value) || /^[a-z0-9_-]{18,}$/i.test(value)
const isSystemMessage = (message: Message): boolean =>
message.from === 'system' || message.type === '系统消息' || message.contentData?.type === 'system'
export const summarySender = (
message: Message,
contact: Contact | null,
isGroup: boolean
): string => {
if (isSystemMessage(message)) return '微信系统消息'
if (message.from === 'assistant') {
const ownGroupNickname = message.name?.trim()
if (isGroup && ownGroupNickname && !isInternalIdentifier(ownGroupNickname)) {
return ownGroupNickname
}
return '我'
}
const candidate = isGroup ? message.name : contact?.m_nsNickName
if (!candidate || isInternalIdentifier(candidate)) return isGroup ? '未命名群成员' : '对方'
return candidate
}
export const summaryContent = (message: Message): string => {
const data = message.contentData
if (message.type === '语音' || data?.type === 'voice') {
return message.voiceTranscript?.trim()
? `[语音${data?.type === 'voice' && data.duration ? ` ${data.duration}秒` : ''}] ${message.voiceTranscript.trim()}`
: `[语音${data?.type === 'voice' && data.duration ? ` ${data.duration}秒` : ''}]`
}
if (!data) return message.content?.trim() || `[${message.type || '消息'}]`
switch (data.type) {
case 'image':
return '[图片]'
case 'sticker':
return '[表情]'
case 'share':
return data.articles?.length
? `[分享] ${data.articles
.map(
(article) =>
`${article.title}${article.description ? `:${article.description}` : ''}`
)
.join(';')}`
: `[分享] ${data.title}${data.des ? `:${data.des}` : ''}`
case 'quote': {
const reply = data.title || data.content || message.content || '[回复]'
const quotedSender =
data.quotedSender && !isInternalIdentifier(data.quotedSender) ? data.quotedSender : '群成员'
return `${reply}(引用 ${quotedSender}:${data.quotedContent || `[引用${data.quotedType || '消息'}]`})`
}
case 'location':
return `[位置] ${data.poiname || data.label || '位置消息'}`
case 'card':
return `[名片] ${data.nickname || '微信名片'}`
case 'voip':
return `[通话] ${data.status}${data.duration ? `,${data.duration}秒` : ''}`
case 'system':
case 'text':
return data.content
case 'unknown':
return `[${message.type || '未知消息'}]`
}
return `[${message.type || '消息'}]`
}
export const parseTimestamp = (message: Message): number => {
const value = new Date(message.datetime).getTime()
return Number.isFinite(value) ? value : 0
}
export const localDate = (timestamp: number): string => {
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export const localTime = (timestamp: number): string =>
new Date(timestamp).toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit'
})
export const resolveVoiceDuration = (message: Message): number => {
const fromData = message.contentData?.type === 'voice' ? message.contentData.duration : undefined
return Math.max(0, Number(fromData ?? message.voiceDuration ?? 0) || 0)
}
const truncate = (value: string, max = 48): string =>
value.length > max ? `${value.slice(0, max - 1)}…` : value
const buildImageContext = (
messages: Message[],
index: number,
contact: Contact | null,
isGroup: boolean
): {
note: string
stats: string
responseCount: number
participantCount: number
interactionCount: number
snippets: string[]
} => {
const baseTime = parseTimestamp(messages[index])
const participants = new Set<string>()
const snippets: string[] = []
let responseCount = 0
let interactionCount = 0
for (let offset = index + 1; offset < messages.length && offset <= index + 8; offset++) {
const candidate = messages[offset]
const candidateTime = parseTimestamp(candidate)
if (baseTime && candidateTime && candidateTime - baseTime > 20 * 60 * 1000) break
if (candidate.type === '系统消息' || candidate.from === 'system') continue
const sender = summarySender(candidate, contact, isGroup)
const sameSender = sender === summarySender(messages[index], contact, isGroup)
const content = summaryContent(candidate)
if (!sameSender) {
responseCount += 1
participants.add(sender)
}
if (candidate.contentData?.type === 'sticker' || candidate.contentData?.type === 'voice') {
interactionCount += 1
}
if (
snippets.length < 3 &&
!content.startsWith('[图片]') &&
!content.startsWith('[表情]') &&
!content.startsWith('[语音')
) {
snippets.push(truncate(content, 28))
}
}
const note = snippets.length
? `图片发出后,群里接着聊到:${snippets.join(' / ')}`
: responseCount > 0
? '图片发出后引发了一波接续讨论。'
: '这张图片更多像是一次轻量分享,没有形成长链路讨论。'
const statsParts: string[] = []
if (responseCount > 0) statsParts.push(`${responseCount} 条后续消息`)
if (participants.size > 0) statsParts.push(`${participants.size} 人接话`)
if (!statsParts.length) statsParts.push('讨论热度较低')
return {
note,
stats: statsParts.join(' · '),
responseCount,
participantCount: participants.size,
interactionCount,
snippets
}
}
const buildMediaSection = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
topSpeakersMap: Map<string, number>,
options: BuildGroupReportFactsOptions = {}
): Promise<{
media: GroupDailyReport['media']
voiceLeaderboard: ReportVoiceLeaderboardItem[]
warnings: string[]
imageInsightSummary: ReportImageInsightSummary
}> => {
const warnings: string[] = []
const imageFailures: ReportImageInsightFailure[] = []
let imageCandidateTotal = 0
const rendererApi = typeof window === 'undefined' ? null : window.api
const rawImageCandidates = messages
.map((message, index) => {
if (message.contentData?.type !== 'image') return null
const sender = summarySender(message, contact, isGroup)
const context = buildImageContext(messages, index, contact, isGroup)
return {
sourceMessageIds: [message.id],
md5: message.contentData.md5,
datName: message.contentData.datName,
sessionId: message.sessionId,
sender,
time: localTime(parseTimestamp(message)),
note: context.note,
stats: context.stats,
replyCount: context.responseCount,
participantCount: context.participantCount,
interactionCount: context.interactionCount,
score: calculateImageHeatScore({
responseCount: context.responseCount,
interactionCount: context.interactionCount
}),
isHot: isHotImageCandidate({
responseCount: context.responseCount,
interactionCount: context.participantCount
})
}
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.filter((item) => item.isHot)
.sort((left, right) => right.score - left.score)
.slice(0, 6)
// ============================================================
// AI 图片理解(ImageInsightService 接入)
// 通过 main 进程拿最多 3 张真正的热点图 + 已缓存的 Insight;
// 未缓存的并发调 AI。失败不阻塞文字日报。
// ============================================================
let visionGallery: ReportVisionGalleryItem[] = []
try {
if (!rendererApi) throw new Error('后台模式不读取 Renderer 图片')
options.onProgress?.({ stage: 'selectingImages', label: '筛选热点图片' })
const sessionId = messages.find((m) => m.sessionId)?.sessionId || (contact?.md5 ?? '')
const startTime = messages.length ? parseTimestamp(messages[0]) : 0
const endTime = messages.length ? parseTimestamp(messages[messages.length - 1]) : 0
// 从 renderer 已加载的消息中提取图片候选(复用 buildImageContext 已算的 replyCount)
const imageInputs = rawImageCandidates.map((c) => {
const srcMsg = messages.find((m) => m.id === c.sourceMessageIds[0]) || messages[0]
return {
messageId: c.sourceMessageIds[0] || '',
md5: c.md5,
datName: c.datName,
sessionId: c.sessionId || sessionId,
sender: c.sender,
sentAt: parseTimestamp(srcMsg),
responseCount: c.replyCount || 0,
interactionCount: c.interactionCount || 0
}
})
const candidatesResp = await rendererApi.imageListCandidates({
sessionId,
startTime,
endTime,
limit: 3,
inputs: imageInputs
})
const candidates = candidatesResp.success ? candidatesResp.candidates : []
imageCandidateTotal = candidates.length
console.log('[buildMediaSection] imageListCandidates returned', candidates.length, 'candidates')
if (!candidatesResp.success && rawImageCandidates.length) {
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
imageFailures.push({
messageId: candidate.sourceMessageIds[0],
sender: candidate.sender,
time: candidate.time,
error: candidatesResp.error || '热点图片筛选失败'
})
}
}
options.onProgress?.({
stage: 'recognizingImages',
label: candidates.length ? '识别图片中' : '未找到可识别的热点图片',
completed: 0,
total: candidates.length
})
// 对每个候选:缓存命中直接用,未命中并发调 imageAnalyze
let analyzedCount = 0
const analyzed = await Promise.all(
candidates.map(async (candidate) => {
const finish = (): void => {
analyzedCount += 1
options.onProgress?.({
stage: 'recognizingImages',
label: '识别图片中',
completed: analyzedCount,
total: candidates.length
})
}
if (candidate.insight) {
finish()
return candidate.insight
}
// 未命中:解密图片拿 base64 → 调 AI
try {
const img = await rendererApi.getImage(
candidate.md5,
candidate.datName,
candidate.sessionId,
{ includeData: true }
)
if (!img.success || !img.data) {
warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片读取失败:${img.error || '未知错误'}`
)
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: img.error || '图片读取失败'
})
return null
}
const analyzeResp = await rendererApi.imageAnalyze({
imageHash: candidate.imageHash,
imageDataUrl: img.data,
messageId: candidate.messageId,
sender: candidate.sender,
sentAt: candidate.sentAt,
sessionId: candidate.sessionId,
providerId: options.visionModel?.providerId,
modelId: options.visionModel?.model,
force: false
})
if (!analyzeResp.success || !analyzeResp.insight) {
warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别失败:${analyzeResp.error || '模型未返回识别结果'}`
)
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: analyzeResp.error || '模型未返回识别结果'
})
return null
}
return analyzeResp.insight
} catch (error) {
console.warn('[buildMediaSection] image analyze failed:', error)
warnings.push(
`${candidate.sender} ${localTime(candidate.sentAt)} 的图片识别异常:${error instanceof Error ? error.message : String(error)}`
)
imageFailures.push({
messageId: candidate.messageId,
sender: candidate.sender,
time: localTime(candidate.sentAt),
error: error instanceof Error ? error.message : String(error)
})
return null
} finally {
finish()
}
})
)
visionGallery = analyzed
.filter((it): it is NonNullable<typeof it> => Boolean(it))
.map((it) => ({
messageId: it.messageId,
imageHash: it.imageHash,
sender: it.sender,
time: localTime(it.sentAt),
description: it.description,
ocrText: it.ocrText,
tags: it.tags,
category: it.category,
importance: it.importance,
sourceMessageIds: [it.messageId]
}))
// 为 visionGallery 加载原图 dataUrl(给 main 渲染用,不暴露给 LLM)
if (visionGallery.length) {
visionGallery = await Promise.all(
visionGallery.map(async (item) => {
const orig = rawImageCandidates.find((c) => c.sourceMessageIds[0] === item.messageId)
if (!orig) return item
try {
const img = await rendererApi.getImage(orig.md5, orig.datName, orig.sessionId, {
includeData: true
})
if (img.success && img.data?.startsWith('data:image/')) {
return { ...item, imageUrl: img.data }
}
} catch (error) {
console.warn('[buildMediaSection] preload image failed for', item.messageId, error)
}
return item
})
)
}
} catch (error) {
console.warn('[buildMediaSection] vision flow failed, fallback to empty:', error)
warnings.push(`图片识别流程失败:${error instanceof Error ? error.message : String(error)}`)
if (!imageFailures.length && rawImageCandidates.length) {
imageCandidateTotal = Math.min(3, rawImageCandidates.length)
for (const candidate of rawImageCandidates.slice(0, imageCandidateTotal)) {
imageFailures.push({
messageId: candidate.sourceMessageIds[0],
sender: candidate.sender,
time: candidate.time,
error: error instanceof Error ? error.message : String(error)
})
}
}
visionGallery = []
}
options.onProgress?.({
stage: 'summarizingInput',
label: '汇总图片识别结果',
completed: visionGallery.length,
total: imageCandidateTotal
})
const voiceMessages = messages
.filter((message) => message.contentData?.type === 'voice')
.map((message) => ({
sender: summarySender(message, contact, isGroup),
duration: resolveVoiceDuration(message),
time: localTime(parseTimestamp(message))
}))
const voiceTotals = new Map<string, { count: number; duration: number }>()
for (const item of voiceMessages) {
const current = voiceTotals.get(item.sender) || { count: 0, duration: 0 }
current.count += 1
current.duration += item.duration
voiceTotals.set(item.sender, current)
}
const voiceLeaderboard: ReportVoiceLeaderboardItem[] = Array.from(voiceTotals.entries())
.map(([sender, value]) => ({
sender,
count: value.count,
durationSec: value.duration
}))
.sort((left, right) => right.durationSec - left.durationSec || right.count - left.count)
.slice(0, 5)
let bestStreak: { sender: string; count: number; duration: number; time: string } | null = null
let currentStreak: { sender: string; count: number; duration: number; time: string } | null = null
for (const message of messages) {
if (message.contentData?.type !== 'voice') {
currentStreak = null
continue
}
const sender = summarySender(message, contact, isGroup)
const duration = resolveVoiceDuration(message)
const time = localTime(parseTimestamp(message))
if (currentStreak && currentStreak.sender === sender) {
currentStreak.count += 1
currentStreak.duration += duration
} else {
currentStreak = { sender, count: 1, duration, time }
}
if (!bestStreak || currentStreak.count > bestStreak.count) {
bestStreak = { ...currentStreak }
}
}
const voiceHighlights: ReportVoiceHighlight[] = []
if (voiceLeaderboard[0]) {
voiceHighlights.push({
title: '语音输出王',
sender: voiceLeaderboard[0].sender,
note: `共发送 ${voiceLeaderboard[0].count} 条语音,累计 ${voiceLeaderboard[0].durationSec} 秒。`
})
}
if (bestStreak && bestStreak.count >= 2) {
voiceHighlights.push({
title: '连续发言时刻',
sender: bestStreak.sender,
note: `${bestStreak.time} 连发 ${bestStreak.count} 条语音,共 ${bestStreak.duration} 秒。`
})
}
const funBadges: ReportFunBadge[] = []
const topSpeaker = Array.from(topSpeakersMap.entries()).sort(
(left, right) => right[1] - left[1]
)[0]
if (topSpeaker) {
funBadges.push({
title: '高能输出王',
owner: topSpeaker[0],
note: `今天一共发了 ${topSpeaker[1]} 条消息。`
})
}
if (visionGallery[0]) {
funBadges.push({
title: '图片话题王',
owner: visionGallery[0].sender,
note: `${visionGallery[0].time} 的热点图片进入了 AI 图片精选。`
})
}
if (voiceLeaderboard[0]) {
funBadges.push({
title: '语音麦霸',
owner: voiceLeaderboard[0].sender,
note: `语音总时长暂居第一,适合放进“今日声音档案”。`
})
}
return {
media: {
// 保留旧字段以兼容历史报告,但新日报不再生成或读取“群聊相册”。
gallery: [],
visionGallery,
voiceHighlights: voiceHighlights.slice(0, 2),
funBadges: funBadges.slice(0, 3)
},
voiceLeaderboard,
warnings,
imageInsightSummary: {
total: imageCandidateTotal,
succeeded: visionGallery.length,
failed: imageFailures.length,
items: visionGallery.map((item) => ({
messageId: item.messageId,
sender: item.sender,
time: item.time,
description: item.description,
ocrText: item.ocrText,
tags: item.tags
})),
failures: imageFailures
}
}
}
const collectQuestionCandidates = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): string[] =>
messages
.map((message) => ({
id: message.id,
sender: summarySender(message, contact, isGroup),
content: summaryContent(message)
}))
.filter(
(item) =>
/[??]$/.test(item.content) || item.content.includes('吗') || item.content.includes('怎么')
)
.slice(-6)
.map((item) => `${item.sender}(${item.id}):${truncate(item.content, 32)}`)
const collectReplyFacts = (
messages: Message[],
contact: Contact | null,
isGroup: boolean
): string[] =>
messages
.filter((message) => message.contentData?.type === 'quote' && message.contentData.quotedSender)
.slice(0, 10)
.map((message) => {
const sender = summarySender(message, contact, isGroup)
const quotedSender =
message.contentData?.type === 'quote' && message.contentData.quotedSender
? message.contentData.quotedSender
: '群成员'
return `${sender} 回复了 ${quotedSender}`
})
export const buildGroupReportFacts = async (
messages: Message[],
contact: Contact | null,
isGroup: boolean,
reportMode: ReportMode,
options: BuildGroupReportFactsOptions = {}
): Promise<GroupReportFactsSnapshot> => {
const transcriptRows = messages.map((message) => ({
id: message.id,
datetime: message.datetime,
timestamp: parseTimestamp(message),
sender: summarySender(message, contact, isGroup),
content: summaryContent(message),
avatar: message.img
}))
let firstTimestamp = Number.POSITIVE_INFINITY
let lastTimestamp = Number.NEGATIVE_INFINITY
for (const row of transcriptRows) {
if (!Number.isFinite(row.timestamp)) continue
firstTimestamp = Math.min(firstTimestamp, row.timestamp)
lastTimestamp = Math.max(lastTimestamp, row.timestamp)
}
if (!Number.isFinite(firstTimestamp)) firstTimestamp = Date.now()
if (!Number.isFinite(lastTimestamp)) lastTimestamp = firstTimestamp
const speakerCounts = new Map<string, number>()
const hourCounts = new Map<number, number>()
const avatars: Record<string, string | undefined> = {}
let imageCount = 0
let stickerCount = 0
let voiceCount = 0
let voiceDurationSec = 0
for (const message of messages) {
const sender = summarySender(message, contact, isGroup)
const timestamp = parseTimestamp(message)
if (!isSystemMessage(message)) {
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
if (message.img && !avatars[sender]) avatars[sender] = message.img
}
if (Number.isFinite(timestamp)) {
const hour = new Date(timestamp).getHours()
hourCounts.set(hour, (hourCounts.get(hour) || 0) + 1)
}
if (message.contentData?.type === 'image') imageCount += 1
if (message.contentData?.type === 'sticker') stickerCount += 1
if (message.contentData?.type === 'voice') {
voiceCount += 1
voiceDurationSec += resolveVoiceDuration(message)
}
}
const topSpeakers = Array.from(speakerCounts, ([name, count]) => ({ name, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 5)
const activeTimeline = Array.from(hourCounts, ([hour, count]) => ({ hour, count }))
.sort((left, right) => right.count - left.count)
.slice(0, 4)
.sort((left, right) => left.hour - right.hour)
.map(
({ hour, count }) =>
`${String(hour).padStart(2, '0')}:00-${String(hour).padStart(2, '0')}:59(${count}条)`
)
.join('、')
const startDate = localDate(firstTimestamp)
const endDate = localDate(lastTimestamp)
const sameDay = startDate === endDate
const dateRange = sameDay
? `${startDate} ${localTime(firstTimestamp)}-${localTime(lastTimestamp)}`
: `${startDate} ${localTime(firstTimestamp)} 至 ${endDate} ${localTime(lastTimestamp)}`
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
const durationHours = durationMs / 3600000
const timeSpan = (() => {
if (sameDay) {
if (durationHours < 1) {
const minutes = Math.max(1, Math.round(durationMs / 60000))
return `${minutes} min`
}
const hours = Math.max(1, Math.ceil(durationHours))
return `${hours} h`
}
const days = Math.max(1, Math.ceil(durationMs / 86400000))
return `${days} d`
})()
const contactName = contact?.m_nsNickName || ''
const groupName = contactName && !isInternalIdentifier(contactName) ? contactName : '未命名会话'
const metadata: GroupReportMetadata = {
groupName,
reportDate: sameDay ? startDate : `${startDate}_to_${endDate}`,
dateRange,
messageCount: transcriptRows.length,
activeUsers: speakerCounts.size,
imageCount,
voiceCount,
stickerCount,
mediaMessageCount: imageCount + voiceCount + stickerCount,
timeSpan,
generatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
recordNote: `基于 TraceMemo 已加载的 ${transcriptRows.length} 条记录`,
footerNote: '基于已读取聊天记录生成;图片、表情等未解析内容默认只按类型与上下文参与日报。',
heroParticipants: topSpeakers.slice(0, 4).map((speaker) => speaker.name),
avatars,
reportMode
}
const { media, voiceLeaderboard, warnings, imageInsightSummary } = await buildMediaSection(
messages,
contact,
isGroup,
speakerCounts,
options
)
if (warnings.length) metadata.warnings = [...(metadata.warnings || []), ...warnings]
if (imageCount > 0 && !media.visionGallery?.length) {
metadata.footerNote = friendlyImageNotice(warnings)
} else if (media.visionGallery?.length) {
metadata.footerNote = `基于已读取聊天记录生成;其中 ${media.visionGallery.length} 张图片已由当前视觉模型识别。`
}
if (
transcriptRows.length > 0 &&
transcriptRows.every((row) => row.content === '[图片]') &&
imageInsightSummary.total > 0 &&
!media.visionGallery?.length
) {
throw new Error(
'当前范围只有图片,但这些图片暂时无法分析。请改选文字消息,或在设置中验证图片理解能力。'
)
}
const factsPrompt = [
`报告模式:${reportMode === 'compact' ? '精简版(30秒可读完)' : '完整版(保留更多上下文)'}`,
`消息统计:共 ${transcriptRows.length} 条,活跃成员 ${speakerCounts.size} 人,图片 ${imageCount} 张,表情 ${stickerCount} 条,语音 ${voiceCount} 条(累计 ${voiceDurationSec} 秒)。`,
activeTimeline ? `活跃时段:${activeTimeline}` : '',
// AI 图片理解结果(由 ImageInsightService 提供,缓存命中或已调用 Vision)
(media.visionGallery?.length ?? 0) > 0
? `AI 图片识别摘要:${(media.visionGallery || [])
.map(
(it) =>
`[${it.time} ${it.sender}] ${it.description}${it.ocrText ? `(OCR: ${it.ocrText})` : ''}${it.tags.length ? ` [${it.tags.join('/')}]` : ''}`
)
.join(';')}`
: '',
voiceLeaderboard.length
? `语音榜:${voiceLeaderboard
.slice(0, 3)
.map((item) => `${item.sender} ${item.count} 条 / ${item.durationSec} 秒`)
.join(';')}`
: '',
collectQuestionCandidates(messages, contact, isGroup).length
? `疑似待跟进问题:${collectQuestionCandidates(messages, contact, isGroup).join(';')}`
: '',
collectReplyFacts(messages, contact, isGroup).length
? `回复关系样本:${collectReplyFacts(messages, contact, isGroup).join(';')}`
: ''
]
.filter(Boolean)
.join('\n')
return {
metadata,
transcriptRows,
topSpeakers,
activeTimeline,
media,
voiceLeaderboard,
factsPrompt,
imageInsightSummary
}
}