diff --git a/resources/mobile_daily_report_v1.html b/resources/mobile_daily_report_v1.html index e80e5cb..62d3da8 100644 --- a/resources/mobile_daily_report_v1.html +++ b/resources/mobile_daily_report_v1.html @@ -76,6 +76,17 @@ gap: 3px; flex: 0 0 auto; } + .avatar-grid.avatar-count-1 { + width: 28px; + height: 28px; + grid-template-columns: 1fr; + } + .avatar-grid.avatar-count-2 { + height: 28px; + } + .avatar-grid.empty-section { + display: none; + } .avatar-grid img, .avatar { width: 100%; @@ -523,7 +534,7 @@
{{OVERVIEW}}
-
{{HERO_AVATARS}}
+
{{HERO_AVATARS}}
{{MESSAGE_COUNT}}消息数
diff --git a/resources/mobile_daily_report_v2.html b/resources/mobile_daily_report_v2.html index 38d173d..b84d2d1 100644 --- a/resources/mobile_daily_report_v2.html +++ b/resources/mobile_daily_report_v2.html @@ -69,6 +69,17 @@ gap: 3px; flex: 0 0 auto; } + .avatar-grid.avatar-count-1 { + width: 28px; + height: 28px; + grid-template-columns: 1fr; + } + .avatar-grid.avatar-count-2 { + height: 28px; + } + .avatar-grid.empty-section { + display: none; + } .avatar-grid img, .avatar { width: 100%; @@ -695,7 +706,7 @@

{{GROUP_NAME}}日报

{{DATE_RANGE}}
{{RECORD_NOTE}}
-
{{HERO_AVATARS}}
+
{{HERO_AVATARS}}
{{HERO_HEADLINE}} diff --git a/src/main/group-report-service.ts b/src/main/group-report-service.ts index 8f79818..50c61f8 100644 --- a/src/main/group-report-service.ts +++ b/src/main/group-report-service.ts @@ -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 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) => `${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 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 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 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, diff --git a/src/main/index.ts b/src/main/index.ts index ec5ee08..c9ae757 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index aa6e4c1..f4b489a 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -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')) || diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index b7f517d..5a94b19 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -301,7 +301,7 @@ function listSourceMessages( / Promise openVoiceModelDirectory: () => Promise<{ success: boolean; error?: string }> recognizeVoice: (reference: VoiceMessageReference) => Promise + getVoiceTranscriptSnapshot: ( + reference: VoiceMessageReference + ) => Promise cancelVoiceRecognition: (reference: VoiceMessageReference) => Promise<{ success: boolean }> getVoiceBatchPreflight: (request: VoiceBatchRequest) => Promise getVoiceBatchConversationSummaries: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index b080f64..5be1820 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('voice:recognize', reference), + getVoiceTranscriptSnapshot: ( + reference: VoiceMessageReference + ): Promise => + ipcRenderer.invoke('voice:getTranscriptSnapshot', reference), cancelVoiceRecognition: (reference: VoiceMessageReference): Promise<{ success: boolean }> => ipcRenderer.invoke('voice:cancelRecognition', reference), getVoiceBatchPreflight: (request: VoiceBatchRequest): Promise => diff --git a/src/renderer/src/hooks/useGroupReportGeneration.ts b/src/renderer/src/hooks/useGroupReportGeneration.ts index be40070..dfef6d3 100644 --- a/src/renderer/src/hooks/useGroupReportGeneration.ts +++ b/src/renderer/src/hooks/useGroupReportGeneration.ts @@ -387,6 +387,8 @@ export function useGroupReportGeneration({ window.api.getVoiceModelStatus(), '检查语音模型' ) as Promise, + getCachedTranscript: (reference) => + withTimeout(window.api.getVoiceTranscriptSnapshot(reference), '读取语音缓存'), recognize: (reference) => withTimeout(window.api.recognizeVoice(reference), '语音转写'), onProgress: setVoiceTranscriptionProgress }) diff --git a/src/renderer/src/utils/group-report-facts.ts b/src/renderer/src/utils/group-report-facts.ts index 84f87ae..1ded0f0 100644 --- a/src/renderer/src/utils/group-report-facts.ts +++ b/src/renderer/src/utils/group-report-facts.ts @@ -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') { diff --git a/src/renderer/src/utils/group-report.ts b/src/renderer/src/utils/group-report.ts index f95b528..2835c3f 100644 --- a/src/renderer/src/utils/group-report.ts +++ b/src/renderer/src/utils/group-report.ts @@ -35,6 +35,7 @@ export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请 8. 所有候选条目尽量返回 sourceMessageIds,便于程序去重和追溯。 9. 精简版面向 30 秒阅读,摘要必须短;完整版可以保留更多候选项。 10. 只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 代码块或其他文字。 +11. 发送者标记为“微信系统消息”的记录是平台通知,不是群成员;不得将其计入参与者、负责人、活跃成员或人物对话。 JSON 结构必须为: { diff --git a/src/renderer/src/utils/voice-message-reference.ts b/src/renderer/src/utils/voice-message-reference.ts index b2eea86..1293208 100644 --- a/src/renderer/src/utils/voice-message-reference.ts +++ b/src/renderer/src/utils/voice-message-reference.ts @@ -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 + getCachedTranscript?: (reference: VoiceMessageReference) => Promise recognize: (reference: VoiceMessageReference) => Promise 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 } diff --git a/src/shared/group-report.ts b/src/shared/group-report.ts index cd4aca2..e80f33d 100644 --- a/src/shared/group-report.ts +++ b/src/shared/group-report.ts @@ -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' diff --git a/tests/unit/group-report.test.ts b/tests/unit/group-report.test.ts index a5fb0a5..67d83e7 100644 --- a/tests/unit/group-report.test.ts +++ b/tests/unit/group-report.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { parseGroupDailyReport } from '../../src/renderer/src/utils/group-report' +import { + buildGroupReportInput, + parseGroupDailyReport +} from '../../src/renderer/src/utils/group-report' import { summaryContent } from '../../src/renderer/src/utils/group-report-facts' +import { summarySender } from '../../src/renderer/src/utils/group-report-facts' +import { selectHeroParticipantNames } from '../../src/shared/group-report' import type { GroupReportMetadata } from '../../src/shared/group-report' import type { Message } from '../../src/shared/types' @@ -25,6 +30,12 @@ const media = { } describe('group report parsing', () => { + it('keeps only distinct real participants in the hero avatar list', () => { + expect( + selectHeroParticipantNames(['濑岛田井卫', '测试群昵称', '濑岛田井卫', '', ' ']) + ).toEqual(['濑岛田井卫', '测试群昵称']) + }) + it('includes a cached voice transcript in the report input content', () => { const message: Message = { id: 'voice-1', @@ -40,6 +51,68 @@ describe('group report parsing', () => { expect(summaryContent(message)).toContain('今晚八点确认发布。') }) + it('includes a voice transcript when legacy cached messages have no contentData', () => { + const message: Message = { + id: 'voice-legacy', + from: 'member', + type: '语音', + datetime: '2026-08-11 10:00:00', + content: '[语音消息]', + isSender: false, + voiceTranscript: '能不能听见这个语音?' + } + + expect(summaryContent(message)).toBe('[语音] 能不能听见这个语音?') + }) + + it('passes legacy voice transcripts into the daily-report model prompt', async () => { + const message: Message = { + id: 'voice-prompt', + from: 'member', + type: '语音', + datetime: '2026-08-11 10:00:00', + content: '[语音消息]', + isSender: false, + name: '测试成员', + voiceTranscript: '试一下好不好使?' + } + + const input = await buildGroupReportInput([message], null, true, 'full') + + expect(input.prompt).toContain('[语音] 试一下好不好使?') + }) + + it('labels system notices separately and excludes them from active members', async () => { + const systemMessage: Message = { + id: 'system-1', + from: 'system', + type: '系统消息', + datetime: '2026-08-11 09:59:00', + content: '由于账号安全原因,无法加入当前群聊。', + isSender: false, + contentData: { + type: 'system', + content: '由于账号安全原因,无法加入当前群聊。' + } + } + const memberMessage: Message = { + id: 'member-1', + from: 'member', + type: '普通文本', + datetime: '2026-08-11 10:00:00', + content: '收到', + name: '测试成员', + isSender: false + } + + expect(summarySender(systemMessage, null, true)).toBe('微信系统消息') + const input = await buildGroupReportInput([systemMessage, memberMessage], null, true, 'full') + + expect(input.metadata.activeUsers).toBe(1) + expect(input.topSpeakers).toEqual([{ name: '测试成员', count: 1 }]) + expect(input.prompt).toContain('微信系统消息:由于账号安全原因,无法加入当前群聊。') + }) + it('falls back to topic keywords when the model omits top-level keywords', () => { const report = parseGroupDailyReport( JSON.stringify({ diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index dc27d68..7bf57ea 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -8,6 +8,7 @@ describe('message parser', () => { md5: '0123456789abcdef0123456789abcdef' }) expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' }) + expect(parseMessageContent('', 34)).toEqual({ type: 'voice' }) expect( parseMessageContent( '', diff --git a/tests/unit/voice-message-reference.test.ts b/tests/unit/voice-message-reference.test.ts index ce9c47d..cf3382e 100644 --- a/tests/unit/voice-message-reference.test.ts +++ b/tests/unit/voice-message-reference.test.ts @@ -74,6 +74,20 @@ describe('daily report voice transcription', () => { }) }) + it('normalizes legacy voice messages without contentData for report facts', async () => { + const legacy = { ...voice('legacy', 4), contentData: undefined } + const result = await transcribeVoiceMessages([legacy], { + getModelStatus: vi.fn(async () => status('ready')), + recognize: vi.fn(async () => ({ success: true, transcript: '日报语音内容' })), + onProgress: vi.fn() + }) + + expect(result[0]).toMatchObject({ + voiceTranscript: '日报语音内容', + contentData: { type: 'voice' } + }) + }) + it('does not require the model when every transcript is cached', async () => { const getModelStatus = vi.fn(async () => status('missing')) const recognize = vi.fn() @@ -88,6 +102,26 @@ describe('daily report voice transcription', () => { expect(recognize).not.toHaveBeenCalled() }) + it('hydrates persisted transcript cache before invoking recognition', async () => { + const getModelStatus = vi.fn(async () => status('missing')) + const getCachedTranscript = vi.fn(async () => ({ + state: 'transcribed' as const, + transcript: '持久化缓存内容' + })) + const recognize = vi.fn() + const result = await transcribeVoiceMessages([voice('persisted', 8)], { + getModelStatus, + getCachedTranscript, + recognize, + onProgress: vi.fn() + }) + + expect(result[0].voiceTranscript).toBe('持久化缓存内容') + expect(getCachedTranscript).toHaveBeenCalledOnce() + expect(getModelStatus).not.toHaveBeenCalled() + expect(recognize).not.toHaveBeenCalled() + }) + it('stops before recognition when the local model is not ready', async () => { const recognize = vi.fn() await expect(