feat: 完善群聊日报语音转写缓存

支持空内容的微信语音消息并兼容历史缓存转写结果
将系统通知标记为微信系统消息,排除活跃成员与发言排行
This commit is contained in:
Wxw-Gu
2026-08-11 11:08:55 +08:00
parent 68f0c0b5a3
commit 6192e7cd35
16 changed files with 240 additions and 42 deletions
+33 -9
View File
@@ -7,7 +7,8 @@ import {
GroupReportExportResult,
GroupReportMetadata,
ReportHeat,
ReportSectionMeta
ReportSectionMeta,
selectHeroParticipantNames
} from '../shared/group-report'
import { resolveMd5, getGroupSnapshot } from './services/chat-service'
import { imageInsightService } from './services/image-insight-service'
@@ -186,11 +187,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
)
const avatar = (name: string): string => avatars.get(name) || fallbackAvatar(name)
const heroNames = metadata.heroParticipants.slice(0, 4)
while (heroNames.length < 4) heroNames.push(metadata.groupName)
const heroNames = selectHeroParticipantNames(metadata.heroParticipants)
const heroAvatars = heroNames
.map((name) => `<img src="${avatar(name)}" alt="${escapeHtml(name)}">`)
.join('')
const heroAvatarClass = heroNames.length ? `avatar-count-${heroNames.length}` : 'empty-section'
const topicCards = report.topics
.map(
@@ -218,10 +219,18 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
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
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 (filePath)
imageUrl = decryptService.decryptImageToBase64(filePath) || undefined
}
}
}
@@ -451,7 +460,9 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
DATE_RANGE: escapeHtml(metadata.dateRange),
RECORD_NOTE: escapeHtml(metadata.recordNote),
// v1 模板使用的 OVERVIEW(经典版以概览段落呈现)
OVERVIEW: escapeHtml(report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'),
OVERVIEW: escapeHtml(
report.overview || report.hero?.summary || '基于已读取聊天记录生成的群聊日报'
),
// v2 模板使用的 hero-*
HERO_HEADLINE: escapeHtml(report.hero?.headline || '今日群聊速览'),
HERO_SUMMARY: escapeHtml(report.hero?.summary || report.overview),
@@ -462,6 +473,7 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
HERO_TAKEAWAY_EMPTY_CLASS: report.hero?.keyTakeaway ? '' : 'empty-section',
HERO_PENDING_EMPTY_CLASS: report.hero?.pendingNote ? '' : 'empty-section',
HERO_AVATARS: heroAvatars,
HERO_AVATAR_CLASS: heroAvatarClass,
MESSAGE_COUNT: String(summaryStats.messageCount),
ACTIVE_USERS: String(summaryStats.activeUsers),
TIME_SPAN: escapeHtml(metadata.timeSpan || ''),
@@ -473,13 +485,21 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
RESOURCES_EMPTY_CLASS: sectionClass(request, 'resources', report.resources.length > 0),
RESOURCE_ITEMS: resourceItems,
RESOURCES_MORE_NOTE: overflowNote(request, 'resources'),
MESSAGES_EMPTY_CLASS: sectionClass(request, 'importantMessages', report.importantMessages.length > 0),
MESSAGES_EMPTY_CLASS: sectionClass(
request,
'importantMessages',
report.importantMessages.length > 0
),
IMPORTANT_MESSAGES: importantMessages,
MESSAGES_MORE_NOTE: overflowNote(request, 'importantMessages'),
QUOTES_EMPTY_CLASS: sectionClass(request, 'moments', report.quotes.length > 0),
QUOTE_BLOCKS: quoteBlocks,
QUOTES_MORE_NOTE: overflowNote(request, 'moments'),
ACTIONS_EMPTY_CLASS: sectionClass(request, 'actions', report.todos.length + report.unresolved.length > 0),
ACTIONS_EMPTY_CLASS: sectionClass(
request,
'actions',
report.todos.length + report.unresolved.length > 0
),
TODO_EMPTY_CLASS: report.todos.length ? '' : 'empty-section',
TODO_CARDS: todoCards,
UNRESOLVED_EMPTY_CLASS: report.unresolved?.length ? '' : 'empty-section',
@@ -511,7 +531,11 @@ const renderReportHtml = async (request: GroupReportExportRequest): Promise<stri
VOICE_EMPTY_CLASS: sectionClass(request, 'voices', report.media?.voiceHighlights?.length > 0),
VOICE_CARDS: voiceCards,
VOICE_MORE_NOTE: overflowNote(request, 'voices'),
VOICE_RANK_EMPTY_CLASS: sectionClass(request, 'voices', report.analytics.voiceLeaderboard?.length > 0),
VOICE_RANK_EMPTY_CLASS: sectionClass(
request,
'voices',
report.analytics.voiceLeaderboard?.length > 0
),
VOICE_RANK_CARDS: voiceRankCards,
BADGES_EMPTY_CLASS: sectionClass(request, 'badges', report.media?.funBadges?.length > 0),
BADGE_CARDS: badgeCards,
+4
View File
@@ -1114,6 +1114,10 @@ app.whenReady().then(async () => {
return voiceRecognition.recognize(reference)
})
ipcMain.handle('voice:getTranscriptSnapshot', (_, reference: VoiceMessageReference) => {
return voiceRecognition?.getTranscriptSnapshot(reference) || { state: 'pending' as const }
})
ipcMain.handle('voice:getBatchPreflight', (_, request: VoiceBatchRequest) => {
if (!voiceBatchService) throw new Error('Voice recognition is not initialized')
return voiceBatchService.preflight(request)
+4 -4
View File
@@ -123,6 +123,9 @@ export type ParsedContent =
| UnknownContent
export function parseMessageContent(content: string, messageType: number): ParsedContent {
// Voice rows may keep their binary payload outside msgContent, so an empty
// content string is still a valid voice message.
if (messageType === 34) return { type: 'voice' }
if (!content || typeof content !== 'string') {
return { type: 'unknown', raw: content || '' }
}
@@ -132,8 +135,6 @@ export function parseMessageContent(content: string, messageType: number): Parse
switch (messageType) {
case 1:
return { type: 'text', content: normalized }
case 34:
return { type: 'voice' }
case 3:
return parseImageMessage(normalized)
case 42:
@@ -490,8 +491,7 @@ function parseShareMessage(content: string): ParsedContent {
}
const articles = parseShareArticles(content)
const title =
articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const title = articles[0]?.title || decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const des =
articles[0]?.description ||
decodeXmlEntities(extractXmlValue(content, 'des') || extractXmlValue(content, 'desc')) ||
+1 -1
View File
@@ -301,7 +301,7 @@ function listSourceMessages(
/<appmsg\b|<refermsg\b|&lt;appmsg\b|&lt;refermsg\b/i.test(content)
? 49
: msgType
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
if (!isPatMessage && [3, 34, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
try {
const isQuotePayload = /<refermsg\b/i.test(content)
const hasStickerPayload =
+5 -1
View File
@@ -58,7 +58,8 @@ import type {
VoiceModelDownloadResult,
VoiceModelProgressEvent,
VoiceModelStatus,
VoiceRecognitionResult
VoiceRecognitionResult,
VoiceTranscriptSnapshot
} from '../shared/voice-recognition'
import type {
AiSearchCancelResult,
@@ -247,6 +248,9 @@ declare global {
removeVoiceModel: () => Promise<VoiceModelStatus>
openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }>
recognizeVoice: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
getVoiceTranscriptSnapshot: (
reference: VoiceMessageReference
) => Promise<VoiceTranscriptSnapshot>
cancelVoiceRecognition: (reference: VoiceMessageReference) => Promise<{ success: boolean }>
getVoiceBatchPreflight: (request: VoiceBatchRequest) => Promise<VoiceBatchPreflight>
getVoiceBatchConversationSummaries: (
+6 -1
View File
@@ -34,7 +34,8 @@ import type {
VoiceModelDownloadResult,
VoiceModelProgressEvent,
VoiceModelStatus,
VoiceRecognitionResult
VoiceRecognitionResult,
VoiceTranscriptSnapshot
} from '../shared/voice-recognition'
import type {
AiSearchCancelResult,
@@ -139,6 +140,10 @@ const api = {
ipcRenderer.invoke('voice:openModelDirectory'),
recognizeVoice: (reference: VoiceMessageReference): Promise<VoiceRecognitionResult> =>
ipcRenderer.invoke('voice:recognize', reference),
getVoiceTranscriptSnapshot: (
reference: VoiceMessageReference
): Promise<VoiceTranscriptSnapshot> =>
ipcRenderer.invoke('voice:getTranscriptSnapshot', reference),
cancelVoiceRecognition: (reference: VoiceMessageReference): Promise<{ success: boolean }> =>
ipcRenderer.invoke('voice:cancelRecognition', reference),
getVoiceBatchPreflight: (request: VoiceBatchRequest): Promise<VoiceBatchPreflight> =>
@@ -387,6 +387,8 @@ export function useGroupReportGeneration({
window.api.getVoiceModelStatus(),
'检查语音模型'
) as Promise<VoiceModelStatus>,
getCachedTranscript: (reference) =>
withTimeout(window.api.getVoiceTranscriptSnapshot(reference), '读取语音缓存'),
recognize: (reference) => withTimeout(window.api.recognizeVoice(reference), '语音转写'),
onProgress: setVoiceTranscriptionProgress
})
+13 -6
View File
@@ -76,11 +76,15 @@ function friendlyImageNotice(warnings: string[]): string {
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)) {
@@ -95,6 +99,11 @@ export const summarySender = (
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) {
@@ -102,10 +111,6 @@ export const summaryContent = (message: Message): string => {
return '[图片]'
case 'sticker':
return '[表情]'
case 'voice':
return message.voiceTranscript?.trim()
? `[语音${data.duration ? ` ${data.duration}` : ''}] ${message.voiceTranscript.trim()}`
: `[语音${data.duration ? ` ${data.duration}` : ''}]`
case 'share':
return data.articles?.length
? `[分享] ${data.articles
@@ -588,12 +593,14 @@ export const buildGroupReportFacts = async (
for (const message of messages) {
const sender = summarySender(message, contact, isGroup)
const timestamp = parseTimestamp(message)
speakerCounts.set(sender, (speakerCounts.get(sender) || 0) + 1)
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.img && !avatars[sender]) avatars[sender] = message.img
if (message.contentData?.type === 'image') imageCount += 1
if (message.contentData?.type === 'sticker') stickerCount += 1
if (message.contentData?.type === 'voice') {
+1
View File
@@ -35,6 +35,7 @@ export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请
8. sourceMessageIds便
9. 30
10. JSON.parse JSON Markdown
11.
JSON
{
@@ -2,7 +2,8 @@ import type { Message } from '../../../shared/types'
import type {
VoiceMessageReference,
VoiceModelStatus,
VoiceRecognitionResult
VoiceRecognitionResult,
VoiceTranscriptSnapshot
} from '../../../shared/voice-recognition'
export interface VoiceTranscriptionProgress {
@@ -14,6 +15,7 @@ export interface VoiceTranscriptionProgress {
interface VoiceTranscriptionDependencies {
getModelStatus: () => Promise<VoiceModelStatus>
getCachedTranscript?: (reference: VoiceMessageReference) => Promise<VoiceTranscriptSnapshot>
recognize: (reference: VoiceMessageReference) => Promise<VoiceRecognitionResult>
onProgress: (progress: VoiceTranscriptionProgress) => void
}
@@ -52,18 +54,12 @@ export async function transcribeVoiceMessages(
}
dependencies.onProgress({ ...progress })
const hasPendingVoice = voiceItems.some(
(item) => item.reference && !item.message.voiceTranscript?.trim()
)
if (hasPendingVoice) {
const modelStatus = await dependencies.getModelStatus()
if (modelStatus.state !== 'ready') {
throw new Error('请先在设置中准备离线语音识别模型,再生成包含语音转写的日报')
}
}
const result = messages.map((message) => ({ ...message }))
const pendingItems: typeof voiceItems = []
for (const item of voiceItems) {
if (!result[item.index].contentData) {
result[item.index].contentData = { type: 'voice' }
}
const cachedTranscript = item.message.voiceTranscript?.trim()
if (cachedTranscript) {
result[item.index].voiceTranscript = cachedTranscript
@@ -72,19 +68,40 @@ export async function transcribeVoiceMessages(
result[item.index].voiceTranscriptError = '语音标识不完整,无法定位本地语音'
progress.failed += 1
} else {
const recognition = await dependencies.recognize(item.reference)
const transcript = recognition.transcript?.trim()
if (recognition.success && transcript) {
result[item.index].voiceTranscript = transcript
const snapshot = await dependencies.getCachedTranscript?.(item.reference)
if (snapshot?.state === 'transcribed' && snapshot.transcript?.trim()) {
result[item.index].voiceTranscript = snapshot.transcript.trim()
result[item.index].voiceTranscriptError = undefined
progress.succeeded += 1
} else {
result[item.index].voiceTranscriptError = recognition.error || '语音转写失败'
progress.failed += 1
pendingItems.push(item)
continue
}
}
progress.processed += 1
dependencies.onProgress({ ...progress })
}
if (pendingItems.length) {
const modelStatus = await dependencies.getModelStatus()
if (modelStatus.state !== 'ready') {
throw new Error('请先在设置中准备离线语音识别模型,再生成包含语音转写的日报')
}
}
for (const item of pendingItems) {
const recognition = await dependencies.recognize(item.reference!)
const transcript = recognition.transcript?.trim()
if (recognition.success && transcript) {
result[item.index].voiceTranscript = transcript
result[item.index].voiceTranscriptError = undefined
progress.succeeded += 1
} else {
result[item.index].voiceTranscriptError = recognition.error || '语音转写失败'
progress.failed += 1
}
dependencies.onProgress({ ...progress, processed: progress.processed + 1 })
progress.processed += 1
}
return result
}
+4
View File
@@ -1,5 +1,9 @@
export type ReportHeat = '高' | '中' | '低'
export type ReportMode = 'compact' | 'full'
export const selectHeroParticipantNames = (names: string[]): string[] =>
Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))).slice(0, 4)
export type ReportSectionKey =
| 'hero'
| 'topics'