mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 完善群聊日报语音转写缓存
支持空内容的微信语音消息并兼容历史缓存转写结果 将系统通知标记为微信系统消息,排除活跃成员与发言排行
This commit is contained in:
@@ -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 @@
|
||||
<div class="overview">{{OVERVIEW}}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><b>{{MESSAGE_COUNT}}</b><span>消息数</span></div>
|
||||
|
||||
@@ -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 @@
|
||||
<h1>{{GROUP_NAME}}日报</h1>
|
||||
<div class="sub">{{DATE_RANGE}}<br />{{RECORD_NOTE}}</div>
|
||||
</div>
|
||||
<div class="avatar-grid">{{HERO_AVATARS}}</div>
|
||||
<div class="avatar-grid {{HERO_AVATAR_CLASS}}">{{HERO_AVATARS}}</div>
|
||||
</div>
|
||||
<div class="hero-headline">
|
||||
<b>{{HERO_HEADLINE}}</b>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')) ||
|
||||
|
||||
@@ -301,7 +301,7 @@ function listSourceMessages(
|
||||
/<appmsg\b|<refermsg\b|<appmsg\b|<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 =
|
||||
|
||||
Vendored
+5
-1
@@ -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: (
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
'<emoji md5="abcdefabcdefabcdefabcdefabcdefab" cdnurl="https://fixture.invalid/a" />',
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user