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) => `
`)
.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(