From add16885fe5e091610c3ebba620c68317ce6b2f7 Mon Sep 17 00:00:00 2001 From: Wxw-Gu Date: Sat, 12 Sep 2026 23:30:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E3=80=81=E8=AF=AD=E9=9F=B3=E5=92=8C=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E5=90=8D=E5=AD=97=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。 - 语音转文字:不同账号、不同聊天里的语音不会再串到一起。 - 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。 --- src/main/export-html-template.ts | 154 +++++++++++-- src/main/export-service.ts | 204 +++++++++++------- src/main/index.ts | 4 +- src/main/voice-service.ts | 24 ++- .../conversation/ConversationItem.tsx | 4 +- .../src/components/export/exportUtils.ts | 3 +- .../reports/ScheduledReportsWorkspace.tsx | 49 ++++- src/renderer/src/components/reports/types.ts | 3 +- .../components/search/AISearchWorkspace.tsx | 70 +++--- .../src/components/search/searchFormatters.ts | 7 +- .../settings/pages/VoiceRecognitionPage.tsx | 5 +- src/shared/contact-name.ts | 64 ++++++ src/shared/member-names.ts | 4 +- .../component/ai-search-query-agent.test.tsx | 2 + .../scheduled-reports-workspace.test.tsx | 42 ++++ tests/e2e/export-archive.spec.ts | 46 +++- tests/integration/export-media-flow.test.ts | 69 +++++- tests/unit/contact-name.test.ts | 36 ++++ tests/unit/voice-service.test.ts | 62 ++++++ 19 files changed, 702 insertions(+), 150 deletions(-) create mode 100644 src/shared/contact-name.ts create mode 100644 tests/unit/contact-name.test.ts diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index d489142..04af63a 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -329,6 +329,32 @@ body { color: var(--accent); } .timeline-month small { color: inherit; } +.timeline-month-entry { display: grid; gap: 2px; } +.timeline-days { + display: grid; + gap: 1px; + margin: 0 0 4px 11px; + padding-left: 8px; + border-left: 1px solid var(--border); +} +.timeline-days[hidden] { display: none; } +.timeline-day { + width: 100%; + display: flex; + justify-content: space-between; + gap: 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + padding: 5px 7px; + cursor: pointer; + font: inherit; + font-size: 11px; + text-align: left; +} +.timeline-day:hover, .timeline-day.active { background: var(--accent-soft); color: var(--accent); } +.timeline-day small { color: inherit; } .scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; } .lazy-hint { width: min(100%, 820px); @@ -787,6 +813,23 @@ body { } .timeline-months { display: flex; gap: 6px; } .timeline-months[hidden] { display: none; } + .timeline-month-entry { display: grid; gap: 2px; } + .timeline-days { + display: flex; + gap: 4px; + margin: 0; + padding: 0; + border-left: 0; + } + .timeline-days[hidden] { display: none; } + .timeline-day { + width: auto; + flex: 0 0 auto; + border-bottom: 2px solid transparent; + padding: 5px 6px; + white-space: nowrap; + } + .timeline-day:hover, .timeline-day.active { border-bottom-color: var(--accent); } .timeline-month { flex: 0 0 auto; width: auto; @@ -879,6 +922,9 @@ const renderExportScript = (name: string): string => ` let scrollLoadSuppressed = false let activeMonthUpdatePending = false let expandedTimelineYear = '' + let expandedTimelineMonth = '' + let selectedTimelineMonth = '' + let selectedTimelineDate = '' const tabPositions = new Map() let lastScrollTop = 0 let zoom = 1 @@ -941,6 +987,13 @@ const renderExportScript = (name: string): string => ` const date = new Date(timestamp * 1000) return date.getFullYear() + '-' + pad(date.getMonth() + 1) } + const dateKey = (message) => { + const timestamp = Number(message.createTime || 0) + if (!timestamp) return 'unknown' + const date = new Date(timestamp * 1000) + if (Number.isNaN(date.getTime())) return 'unknown' + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + } const kindOf = (message) => { const data = message.contentData || {} const shareType = String(data.typeVal || '') @@ -1317,6 +1370,15 @@ const renderExportScript = (name: string): string => ` if (months) months.hidden = !expanded }) } + const setExpandedTimelineMonth = (month) => { + expandedTimelineMonth = month || '' + timeline.querySelectorAll('.timeline-month').forEach((button) => { + const expanded = button.dataset.month === expandedTimelineMonth + button.setAttribute('aria-expanded', String(expanded)) + const days = button.nextElementSibling + if (days) days.hidden = !expanded + }) + } const renderTimeline = () => { if (filtered.length === 0) { expandedTimelineYear = '' @@ -1327,18 +1389,36 @@ const renderExportScript = (name: string): string => ` for (const message of filtered) { const key = monthKey(message) if (key === 'unknown') continue - groups.set(key, (groups.get(key) || 0) + 1) + const date = dateKey(message) + const group = groups.get(key) || { total: 0, days: new Map() } + group.total += 1 + if (date !== 'unknown') group.days.set(date, (group.days.get(date) || 0) + 1) + groups.set(key, group) } const yearGroups = new Map() - for (const [key, total] of groups) { + for (const [key, group] of groups) { const parts = key.split('-') if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], []) - yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total }) + yearGroups.get(parts[0]).push({ + key, + month: Number(parts[1]), + total: group.total, + days: Array.from(group.days.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([date, total]) => ({ date, day: Number(date.slice(-2)), total })) + }) } - const years = Array.from(yearGroups.keys()) + const years = Array.from(yearGroups.keys()).sort((left, right) => left.localeCompare(right)) if (!years.includes(expandedTimelineYear)) { expandedTimelineYear = years[years.length - 1] || '' } + for (const entries of yearGroups.values()) { + entries.sort((left, right) => left.key.localeCompare(right.key)) + } + const allMonthKeys = Array.from(groups.keys()).sort((left, right) => left.localeCompare(right)) + if (!allMonthKeys.includes(expandedTimelineMonth)) { + expandedTimelineMonth = allMonthKeys[allMonthKeys.length - 1] || '' + } let html = '' for (const year of years) { const expanded = year === expandedTimelineYear @@ -1348,10 +1428,18 @@ const renderExportScript = (name: string): string => ` '" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' + esc(year) + ' 年' + '
' + - yearGroups.get(year).map((entry) => - '' - ).join('') + '
' + yearGroups.get(year).map((entry) => { + const monthExpanded = entry.key === expandedTimelineMonth + return '
' + + '' + + '
' + entry.days.map((day) => + '' + ).join('') + '
' + }).join('') + '' } timeline.innerHTML = html || '
时间信息不可用
' } @@ -1377,16 +1465,27 @@ const renderExportScript = (name: string): string => ` const activeMessage = atBottom ? visible[visible.length - 1] || messages[messages.length - 1] : anchoredMessage || visible[0] || messages[0] - const key = activeMessage && activeMessage.dataset.month + const key = selectedTimelineMonth || (selectedTimelineDate + ? selectedTimelineDate.slice(0, 7) + : '') || + (activeMessage && activeMessage.dataset.month) + const activeIndex = activeMessage ? Number(activeMessage.dataset.index) : -1 + const activeDate = + selectedTimelineDate || + (activeIndex >= 0 && filtered[activeIndex] ? dateKey(filtered[activeIndex]) : '') let activeButton timeline.querySelectorAll('.timeline-month').forEach((button) => { const active = button.dataset.month === key button.classList.toggle('active', active) if (active) activeButton = button }) + timeline.querySelectorAll('.timeline-day').forEach((button) => { + button.classList.toggle('active', button.dataset.date === activeDate) + }) if (!activeButton) return const activeYear = key.split('-')[0] if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear) + if (key !== expandedTimelineMonth) setExpandedTimelineMonth(key) const timelineBounds = timeline.getBoundingClientRect() const buttonBounds = activeButton.getBoundingClientRect() if (buttonBounds.top < timelineBounds.top) { @@ -1535,6 +1634,8 @@ const renderExportScript = (name: string): string => ` ) } const applyFilters = (restorePosition = false) => { + selectedTimelineMonth = '' + selectedTimelineDate = '' filtered = matchingMessages() renderTimeline() if (!restorePosition || !restoreTabPosition()) resetWindow(true) @@ -1572,18 +1673,32 @@ const renderExportScript = (name: string): string => ` target.classList.add('located') window.setTimeout(() => target.classList.remove('located'), 1600) } - const jumpToMonth = (key) => { - const index = filtered.findIndex((message) => monthKey(message) === key) + const selectTimelineMonth = (key) => { + if (!filtered.some((message) => monthKey(message) === key)) return + selectedTimelineMonth = key + selectedTimelineDate = '' + setExpandedTimelineYear(key.split('-')[0]) + setExpandedTimelineMonth(key) + timeline.querySelectorAll('.timeline-month').forEach((button) => { + button.classList.toggle('active', button.dataset.month === key) + }) + } + const jumpToDate = (key) => { + const index = filtered.findIndex((message) => dateKey(message) === key) if (index < 0) return + selectedTimelineMonth = '' + selectedTimelineDate = key windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4)) windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE) windowStart = Math.max(0, windowEnd - PAGE_SIZE) renderWindow() const target = list.querySelector('.message[data-index="' + index + '"]') setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0) - setExpandedTimelineYear(key.split('-')[0]) - timeline.querySelectorAll('.timeline-month').forEach((button) => { - button.classList.toggle('active', button.dataset.month === key) + const month = key.slice(0, 7) + setExpandedTimelineYear(month.slice(0, 4)) + setExpandedTimelineMonth(month) + timeline.querySelectorAll('.timeline-day').forEach((button) => { + button.classList.toggle('active', button.dataset.date === key) }) } const slideWindow = (direction) => { @@ -1620,6 +1735,10 @@ const renderExportScript = (name: string): string => ` const nearTop = currentTop < 180 const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240 lastScrollTop = currentTop + if (!scrollLoadSuppressed) { + selectedTimelineMonth = '' + selectedTimelineDate = '' + } scheduleActiveMonthUpdate() if (scrollLoadSuppressed) return if (movingUp && nearTop) scheduleWindowSlide(-1) @@ -1676,7 +1795,12 @@ const renderExportScript = (name: string): string => ` return } const button = event.target.closest('[data-month]') - if (button) jumpToMonth(button.dataset.month) + if (button) { + selectTimelineMonth(button.dataset.month) + return + } + const dayButton = event.target.closest('[data-date]') + if (dayButton) jumpToDate(dayButton.dataset.date) }) const updateZoom = () => preview.style.setProperty('--zoom', zoom) diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 7db4939..f339d29 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -25,11 +25,12 @@ import { mergeCachedSelfInfo, type CachedSelfInfo } from './services/bootstrap-c import type { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case' import { imageFileQuality } from '../shared/image-quality' import { resolveMemberName } from '../shared/member-names' +import { filesystemSafeName } from '../shared/contact-name' const jobs = new Set() const activeArchives = new Map() -const safeFilePart = (value: string): string => - value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案' +const safeFilePart = (value: string, fallback = '聊天档案'): string => + filesystemSafeName(value, fallback) const copyWritableExportFile = async (source: string, destination: string): Promise => { try { await fs.chmod(destination, 0o644) @@ -643,6 +644,7 @@ const kindOf = (message: Message): ExportMessageKind => { return 'text' } const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"` +const csvBom = '\uFEFF' function render(format: ExportRequest['format'], messages: Message[], name: string): string { if (format === 'html') return renderExportPage(name) @@ -650,7 +652,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2) if (format === 'markdown') return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError ? `\n\n媒体:${m.exportMediaUrl || m.voiceDataUrl || m.exportMediaError}` : ''}\n`).join('\n')}` - return [ + return csvBom + [ '时间,发送者,类型,内容,媒体路径,媒体状态', ...messages.map((m) => [ @@ -664,7 +666,7 @@ function render(format: ExportRequest['format'], messages: Message[], name: stri .map(csv) .join(',') ) - ].join('\n') + ].join('\r\n') } interface SingleExportOptions { @@ -681,6 +683,8 @@ interface AllExportManifestEntry { type: ExportTarget['type'] folder: string messageCount: number + status: 'completed' | 'failed' + error?: string } const writeAllExportManifest = async ( @@ -1086,7 +1090,10 @@ async function runSingleExport( } const voiceService = request.includeMedia && chat.getChatDb() - ? new VoiceService(chat.getChatDb()!.getWcdb4Client()) + ? new VoiceService( + chat.getChatDb()!.getWcdb4Client(), + chat.getChatDb()!.getWcdb4Client().getAccountRoot() + ) : null const voiceMessages = messages.filter((message) => kindOf(message) === 'voice') const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media' @@ -1635,6 +1642,8 @@ async function runAllExport( let outputDir = '' const manifest: AllExportManifestEntry[] = [] let totalMessages = 0 + let failedCount = 0 + const failureMessages: string[] = [] try { const targets = [...(request.targets || [])].sort((left, right) => left.type === right.type ? 0 : left.type === 'group' ? -1 : 1 @@ -1667,80 +1676,121 @@ async function runAllExport( const categoryName = target.type === 'group' ? '群聊' : '联系人' const categoryDir = join(outputDir, categoryName) const folderName = folderNames.get(target.userMd5) || safeFilePart(target.name) - await fs.mkdir(categoryDir, { recursive: true }) - const conversationOutputRoot = - request.format === 'html' ? categoryDir : join(categoryDir, folderName) - const basePercent = Math.floor((targetIndex / targets.length) * 100) - send({ - jobId: request.jobId, - phase: 'reading', - processed: 0, - percent: basePercent, - currentTargetIndex: targetIndex + 1, - currentTargetCount: targets.length, - currentTargetName: target.name, - currentTargetType: target.type - }) + const folder = `${categoryName}/${folderName}` + let result: ExportResult + try { + await fs.mkdir(categoryDir, { recursive: true }) + const conversationOutputRoot = + request.format === 'html' ? categoryDir : join(categoryDir, folderName) + const basePercent = Math.floor((targetIndex / targets.length) * 100) + send({ + jobId: request.jobId, + phase: 'reading', + processed: 0, + percent: basePercent, + currentTargetIndex: targetIndex + 1, + currentTargetCount: targets.length, + currentTargetName: target.name, + currentTargetType: target.type + }) - const result = await runSingleExport( - { - ...request, - targets: [target], - outputName: target.name, - zip: false - }, - win, - voiceRecognition, - { - outputRoot: conversationOutputRoot, - outputFolderName: request.format === 'html' ? folderName : undefined, - manageJob: false, - selfInfo, - sendProgress: (childProgress) => { - const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0)) - const percent = Math.min( - 99, - Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100) - ) - const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase - const now = Date.now() - const progressKey = `${targetIndex}:${phase}:${percent}` - const terminal = phase === 'failed' || phase === 'cancelled' - if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return - lastProgressKey = progressKey - lastProgressAt = now - send({ - ...childProgress, - jobId: request.jobId, - phase, - percent, - outputPath: undefined, - currentTargetIndex: targetIndex + 1, - currentTargetCount: targets.length, - currentTargetName: target.name, - currentTargetType: target.type - }) + result = await runSingleExport( + { + ...request, + targets: [target], + outputName: target.name, + zip: false + }, + win, + voiceRecognition, + { + outputRoot: conversationOutputRoot, + outputFolderName: request.format === 'html' ? folderName : undefined, + manageJob: false, + selfInfo, + sendProgress: (childProgress) => { + const childPercent = Math.max(0, Math.min(100, childProgress.percent || 0)) + const percent = Math.min( + 99, + Math.floor(((targetIndex + childPercent / 100) / targets.length) * 100) + ) + const phase = childProgress.phase === 'completed' ? 'writing' : childProgress.phase + const now = Date.now() + const progressKey = `${targetIndex}:${phase}:${percent}` + const terminal = phase === 'failed' || phase === 'cancelled' + if (!terminal && progressKey === lastProgressKey && now - lastProgressAt < 500) return + lastProgressKey = progressKey + lastProgressAt = now + send({ + ...childProgress, + jobId: request.jobId, + phase, + percent, + outputPath: undefined, + currentTargetIndex: targetIndex + 1, + currentTargetCount: targets.length, + currentTargetName: target.name, + currentTargetType: target.type + }) + } } - } - ) - if (!result.success) { - if (result.error === '已取消') throw new Error('已取消') - throw new Error(`${target.name}:${result.error || '导出失败'}`) + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消') + result = { success: false, error: message } } - const messageCount = result.messageCount || 0 - totalMessages += messageCount - manifest.push({ - id: target.userMd5, - name: target.name, - type: target.type, - folder: `${categoryName}/${folderName}`, - messageCount - }) + if (!result.success) { + if (result.error === '已取消' || !jobs.has(request.jobId)) throw new Error('已取消') + const error = `${target.name}:${result.error || '导出失败'}` + failedCount += 1 + failureMessages.push(error) + manifest.push({ + id: target.userMd5, + name: target.name, + type: target.type, + folder, + messageCount: 0, + status: 'failed', + error: result.error || '导出失败' + }) + const failedPercent = Math.min( + 99, + Math.floor(((targetIndex + 1) / targets.length) * 100) + ) + send({ + jobId: request.jobId, + phase: 'failed', + processed: totalMessages, + total: totalMessages, + percent: failedPercent, + error, + currentTargetIndex: targetIndex + 1, + currentTargetCount: targets.length, + currentTargetName: target.name, + currentTargetType: target.type + }) + } else { + const messageCount = result.messageCount || 0 + totalMessages += messageCount + manifest.push({ + id: target.userMd5, + name: target.name, + type: target.type, + folder, + messageCount, + status: 'completed' + }) + } await writeAllExportManifest(outputDir, manifest, totalMessages, 'running') } - await writeAllExportManifest(outputDir, manifest, totalMessages, 'completed') + const finalStatus = failedCount ? 'failed' : 'completed' + const finalError = failedCount + ? `全部导出完成:成功 ${targets.length - failedCount} 个,失败 ${failedCount} 个。${failureMessages.join(';')}` + : undefined + await writeAllExportManifest(outputDir, manifest, totalMessages, finalStatus, finalError) let completedPath = outputDir if (request.zip) { @@ -1759,17 +1809,23 @@ async function runAllExport( send({ jobId: request.jobId, - phase: 'completed', + phase: failedCount ? 'failed' : 'completed', processed: totalMessages, total: totalMessages, percent: 100, + error: finalError, outputPath: completedPath, currentTargetIndex: targets.length, currentTargetCount: targets.length, currentTargetName: targets.at(-1)?.name, currentTargetType: targets.at(-1)?.type }) - return { success: true, outputPath: completedPath, messageCount: totalMessages } + return { + success: failedCount === 0, + outputPath: completedPath, + messageCount: totalMessages, + ...(finalError ? { error: finalError } : {}) + } } catch (error) { const message = error instanceof Error ? error.message : String(error) const cancelled = !jobs.has(request.jobId) || message === '已取消' diff --git a/src/main/index.ts b/src/main/index.ts index 0bdbe04..ae458e0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -818,7 +818,7 @@ app.whenReady().then(async () => { const wcdb4Client = nextWechatDb.getWcdb4Client() const sessions = await wcdb4Client.getSessionsAsync({ hydrateDisplayNames: false }) configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled) - voiceService = new VoiceService(wcdb4Client) + voiceService = new VoiceService(wcdb4Client, resolvedRoot) voiceRecognition?.connect(voiceService, resolvedRoot) stickerService = new StickerService(wcdb4Client) videoAssetService = new VideoAssetService(wcdb4Client) @@ -1958,7 +1958,7 @@ app.whenReady().then(async () => { if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' } const client = chat.getChatDb()?.getWcdb4Client() if (client) { - voiceService = new VoiceService(client) + voiceService = new VoiceService(client, client.getAccountRoot()) voiceRecognition?.connect(voiceService, client.getAccountRoot()) const monitoring = await client.startMonitor((type, json) => { client.invalidateSessionCache() diff --git a/src/main/voice-service.ts b/src/main/voice-service.ts index 36210a3..32ba639 100644 --- a/src/main/voice-service.ts +++ b/src/main/voice-service.ts @@ -4,6 +4,7 @@ import { createDefaultAudioDecoderRegistry, type EncodedVoiceSource } from './voice-pipeline/audio-decoder' +import { voiceAccountIdentity, voiceMessageIdentity } from './voice-pipeline/voice-message-identity' export { findSilkWasmRuntimeLocation, @@ -36,12 +37,16 @@ export interface VoiceReference { export class VoiceService { private wcdb4Client: Wcdb4Client + private readonly accountIdentity: string private voiceCache = new Map() private pcmCache = new Map() private readonly decoderRegistry = createDefaultAudioDecoderRegistry() - constructor(wcdb4Client: Wcdb4Client) { + constructor(wcdb4Client: Wcdb4Client, accountRoot?: string) { this.wcdb4Client = wcdb4Client + const clientWithRoot = wcdb4Client as unknown as { getAccountRoot?: () => string } + const resolvedRoot = accountRoot || clientWithRoot.getAccountRoot?.() || 'unbound-account' + this.accountIdentity = voiceAccountIdentity(resolvedRoot) } async resolveVoice( @@ -50,7 +55,8 @@ export class VoiceService { createTime: number, svrId?: string | number ): Promise<{ success: boolean; data?: string; error?: string }> { - const cacheKey = this.buildCacheKey(sessionId, localId, createTime) + const reference = { sessionId, localId, createTime, svrId } + const cacheKey = this.buildCacheKey(reference) const cached = this.voiceCache.get(cacheKey) if (cached) { @@ -89,7 +95,7 @@ export class VoiceService { const missing: Array<{ index: number; reference: VoiceReference }> = [] references.forEach((reference, index) => { const cached = this.voiceCache.get( - this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime) + this.buildCacheKey(reference) ) if (cached) results[index] = { success: true, data: cached } else missing.push({ index, reference }) @@ -131,11 +137,7 @@ export class VoiceService { decoded.channels ) const data = wavData.toString('base64') - const cacheKey = this.buildCacheKey( - reference.sessionId, - reference.localId, - reference.createTime - ) + const cacheKey = this.buildCacheKey(reference) this.voiceCache.set(cacheKey, data) this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' }) results[index] = { success: true, data } @@ -168,7 +170,7 @@ export class VoiceService { createTime: number, svrId?: string | number ): Promise { - const cacheKey = this.buildCacheKey(sessionId, localId, createTime) + const cacheKey = this.buildCacheKey({ sessionId, localId, createTime, svrId }) const cached = this.pcmCache.get(cacheKey) if (cached) return { success: true, audio: cached } @@ -217,8 +219,8 @@ export class VoiceService { } } - private buildCacheKey(sessionId: string, localId: number, createTime: number): string { - return `${sessionId}-${localId}-${createTime}` + private buildCacheKey(reference: VoiceReference): string { + return `voice-cache-v2:${this.accountIdentity}:${voiceMessageIdentity(reference)}` } private buildCandidates(sessionId: string): string[] { diff --git a/src/renderer/src/components/conversation/ConversationItem.tsx b/src/renderer/src/components/conversation/ConversationItem.tsx index 68c37bf..adefbdf 100644 --- a/src/renderer/src/components/conversation/ConversationItem.tsx +++ b/src/renderer/src/components/conversation/ConversationItem.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react' import { Contact } from '../../../../shared/types' +import { displayContactName } from '../../../../shared/contact-name' interface ConversationItemProps { contact: Contact @@ -12,10 +13,9 @@ export function ConversationItem({ active, onSelect }: ConversationItemProps): React.ReactElement { - const nickname = contact.m_nsNickName?.trim() const avatarUsername = contact.m_nsUsrName const internalWxid = contact.wxid || avatarUsername - const displayName = nickname || internalWxid || '未命名会话' + const displayName = displayContactName(contact, '未命名会话') const initial = (displayName || internalWxid || '?').charAt(0) const [repairedAvatar, setRepairedAvatar] = useState<{ username: string diff --git a/src/renderer/src/components/export/exportUtils.ts b/src/renderer/src/components/export/exportUtils.ts index 381d012..93f551e 100644 --- a/src/renderer/src/components/export/exportUtils.ts +++ b/src/renderer/src/components/export/exportUtils.ts @@ -1,6 +1,7 @@ import type { Contact, Message } from '../../../../shared/types' import type { ExportFormat, GroupMemberName } from './exportTypes' import { resolveMemberName } from '../../../../shared/member-names' +import { displayContactName } from '../../../../shared/contact-name' export const messageKinds = [ ['text', '文字'], @@ -24,7 +25,7 @@ export const formatLabels: Record(null) const [saving, setSaving] = React.useState(false) const [deletingTask, setDeletingTask] = React.useState(null) + const [retryingExecution, setRetryingExecution] = + React.useState(null) const [busyTaskId, setBusyTaskId] = React.useState(null) const load = React.useCallback(async (): Promise => { @@ -784,6 +786,13 @@ export function ScheduledReportsWorkspace({ } } + const confirmRetrySend = async (): Promise => { + if (!retryingExecution || busyTaskId) return + const execution = retryingExecution + setRetryingExecution(null) + await retrySend(execution) + } + const testErrorNotification = async (task: ScheduledReportTask): Promise => { setBusyTaskId(task.id) try { @@ -1090,7 +1099,7 @@ export function ScheduledReportsWorkspace({ size="sm" variant="outline" disabled={busy} - onClick={() => void retrySend(execution)} + onClick={() => setRetryingExecution(execution)} > {busy ? '发送中…' : '重新发送'} @@ -1177,6 +1186,44 @@ export function ScheduledReportsWorkspace({ + !open && setRetryingExecution(null)} + > + + + 将重新发送这份日报? + +
+ + 目标: + {retryingExecution?.sendTarget || + tasks.find((item) => item.id === retryingExecution?.taskId)?.target || + tasks.find((item) => item.id === retryingExecution?.taskId)?.group || + '未指定'} + + + 日报: + {tasks.find((item) => item.id === retryingExecution?.taskId)?.name || '定时日报'} + + 日期:{formatDateTime(retryingExecution?.startedAt)} +
+
+
+ + 取消 + { + event.preventDefault() + void confirmRetrySend() + }} + disabled={Boolean(busyTaskId)} + > + {busyTaskId ? '发送中…' : '确认重新发送'} + + +
+
) } diff --git a/src/renderer/src/components/reports/types.ts b/src/renderer/src/components/reports/types.ts index 895285b..5b4d0d8 100644 --- a/src/renderer/src/components/reports/types.ts +++ b/src/renderer/src/components/reports/types.ts @@ -1,4 +1,5 @@ import { Contact } from '../../../../shared/types' +import { displayContactName } from '../../../../shared/contact-name' export type { GeneratedReportRecord, ReportAssetStatus, @@ -12,4 +13,4 @@ export type { export type ReportWorkspaceView = 'configure' | 'result' export const contactDisplayName = (contact: Contact | null): string => - contact?.m_nsNickName?.trim() || contact?.m_nsUsrName || '未命名群聊' + displayContactName(contact, '未命名群聊') diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index b656029..5da93fe 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -1000,37 +1000,43 @@ export function AISearchWorkspace({
!
检索反馈

{analysisError || '当前范围没有足够证据'}

-

可以扩大时间范围、切换群聊,或换一个更具体的问题。

- + {queryAgentEnabled ? ( +

可以切换聊天范围,或换一个更具体的问题。

+ ) : ( + <> +

可以扩大时间范围、切换群聊,或换一个更具体的问题。

+ + + )} ) @@ -1400,7 +1406,7 @@ export function AISearchWorkspace({ { @@ -83,11 +84,7 @@ export const formatKnowledgeProcessed = (status: KnowledgeRuntimeStatus): string } export const contactLabel = (contact: Contact | null | undefined): string => - contact?.m_nsNickName || - contact?.remark || - contact?.wechatNickname || - contact?.m_nsUsrName || - '未选择会话' + displayContactName(contact, '未选择会话') export const formatSearchTraceOverview = ( trace: SearchTrace diff --git a/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx b/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx index 3f926e3..0d7fb79 100644 --- a/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx +++ b/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { Contact } from '../../../../../shared/types' +import { displayContactName } from '../../../../../shared/contact-name' import type { VoiceBatchConversationSummary, VoiceBatchProgress, @@ -68,7 +69,7 @@ const hasBatchApi = (): boolean => const BATCH_PAGE_SIZE = 12 function contactName(contact: Contact): string { - return contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName + return displayContactName(contact) } export function VoiceRecognitionPage({ @@ -640,7 +641,7 @@ export function VoiceRecognitionPage({ {' · '}剩余 {formatTaskDuration(batchProgress.estimatedRemainingMs)} {currentBatchConversation && ( - 当前会话:{currentBatchConversation.m_nsNickName} + 当前会话:{contactName(currentBatchConversation)} )} diff --git a/src/shared/contact-name.ts b/src/shared/contact-name.ts new file mode 100644 index 0000000..e164d90 --- /dev/null +++ b/src/shared/contact-name.ts @@ -0,0 +1,64 @@ +import type { Contact } from './types' + +type ContactNameSource = Partial> + +const nonRenderingCharacters = /[\p{Cc}\p{Cf}\p{Co}\p{Cs}]/gu +const trailingPathCharacters = /[. ]+$/u +const reservedDeviceName = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/iu + +/** Remove characters that can make a name invisible while preserving normal emoji and CJK text. */ +export function visibleNamePart(value: unknown): string { + return String(value ?? '') + .replace(nonRenderingCharacters, '') + .replace(/[\r\n\t]+/gu, ' ') + .replace(/ {2,}/gu, ' ') + .trim() +} + +export function isVisibleName(value: unknown): boolean { + return visibleNamePart(value).length > 0 +} + +/** Resolve a contact label without ever exposing an all-invisible raw nickname. */ +export function displayContactName( + contact: ContactNameSource | null | undefined, + fallback = '未命名联系人' +): string { + const candidates = [ + contact?.m_nsNickName, + contact?.remark, + contact?.alias, + contact?.wechatNickname, + contact?.wechatId, + contact?.wxid, + contact?.m_nsUsrName + ] + for (const candidate of candidates) { + const visible = visibleNamePart(candidate) + if (visible) return visible + } + return fallback +} + +/** Make a human-readable label safe for a file or directory name. */ +export function filesystemSafeName(value: unknown, fallback = '未命名联系人'): string { + const visible = visibleNamePart(value) + const replaced = visible + .replace(/[\\/:*?"<>|]/gu, '_') + .replace(/[\r\n\t]+/gu, ' ') + .replace(/ {2,}/gu, ' ') + .replace(trailingPathCharacters, '') + .trim() + const candidate = replaced || visibleNamePart(fallback) || '未命名联系人' + if (reservedDeviceName.test(candidate)) return `${candidate}_` + return Array.from(candidate).slice(0, 80).join('') || '未命名联系人' +} diff --git a/src/shared/member-names.ts b/src/shared/member-names.ts index e7ede4d..36cd95a 100644 --- a/src/shared/member-names.ts +++ b/src/shared/member-names.ts @@ -1,3 +1,5 @@ +import { visibleNamePart } from './contact-name' + export type MemberNameMode = 'groupNickname' | 'wechatNickname' | 'remark' export interface MemberNameFields { @@ -9,7 +11,7 @@ export interface MemberNameFields { } const firstName = (...values: Array): string => - values.map((value) => String(value || '').trim()).find(Boolean) || '' + values.map((value) => visibleNamePart(value)).find(Boolean) || '' export function resolveMemberName(member: MemberNameFields, mode: MemberNameMode): string { if (mode === 'groupNickname') { diff --git a/tests/component/ai-search-query-agent.test.tsx b/tests/component/ai-search-query-agent.test.tsx index 40ab4e9..2d7dc46 100644 --- a/tests/component/ai-search-query-agent.test.tsx +++ b/tests/component/ai-search-query-agent.test.tsx @@ -222,6 +222,8 @@ describe('AISearchWorkspace — 搜索范围 / 时间 UI / 真实统计与证据 // 时间范围控件在 Query Agent 主路径下移除 expect(screen.queryByText('时间范围')).toBeNull() expect(screen.queryByText('不限时间')).toBeNull() + expect(screen.queryByText('近 30 天')).toBeNull() + expect(screen.getByText('时间由问题决定')).toBeTruthy() }) it('Query Agent 关闭时恢复 Legacy 的时间范围控件', async () => { diff --git a/tests/component/scheduled-reports-workspace.test.tsx b/tests/component/scheduled-reports-workspace.test.tsx index c808860..8a35db9 100644 --- a/tests/component/scheduled-reports-workspace.test.tsx +++ b/tests/component/scheduled-reports-workspace.test.tsx @@ -58,6 +58,10 @@ describe('ScheduledReportsWorkspace', () => { deleteScheduledReport: vi.fn(async () => ({ success: true, data: { deletedId: task.id } })), setScheduledReportEnabled: vi.fn(async () => ({ success: true, data: task })), runScheduledReportNow: vi.fn(async () => ({ success: true, data: { status: 'success' } })), + retryScheduledReportSend: vi.fn(async () => ({ + success: true, + data: { status: 'success' } + })), testScheduledReportErrorNotification: vi.fn(async () => ({ success: true, data: { status: 'failed', notificationStatus: 'sent' } @@ -214,4 +218,42 @@ describe('ScheduledReportsWorkspace', () => { 'success' ) }) + + it('confirms a manual resend and does not retry when the dialog is cancelled', async () => { + const execution = { + id: 'execution-1', + taskId: task.id, + startedAt: '2026-08-27T01:00:00.000Z', + finishedAt: '2026-08-27T01:01:00.000Z', + status: 'waiting_to_send' as const, + sendTarget: group.m_nsUsrName, + userMessage: '日报已生成,但微信暂不可用' + } + vi.mocked(window.api.listScheduledReportExecutions).mockResolvedValue([execution]) + render( + + ) + + await waitFor(() => expect(screen.getByText('日报已生成,但微信暂不可用')).toBeVisible()) + fireEvent.click(screen.getByRole('button', { name: '重新发送' })) + const dialog = await screen.findByRole('alertdialog') + expect(within(dialog).getByText('目标:' + group.m_nsUsrName)).toBeVisible() + expect(within(dialog).getByText('日报:技术交流 · 每日日报')).toBeVisible() + expect(within(dialog).getByText(/日期:/)).toBeVisible() + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })) + expect(window.api.retryScheduledReportSend).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: '重新发送' })) + const secondDialog = await screen.findByRole('alertdialog') + fireEvent.click(within(secondDialog).getByRole('button', { name: '确认重新发送' })) + await waitFor(() => + expect(window.api.retryScheduledReportSend).toHaveBeenCalledWith(execution.id) + ) + expect(window.api.retryScheduledReportSend).toHaveBeenCalledTimes(1) + }) }) diff --git a/tests/e2e/export-archive.spec.ts b/tests/e2e/export-archive.spec.ts index 9ded770..5986035 100644 --- a/tests/e2e/export-archive.spec.ts +++ b/tests/e2e/export-archive.spec.ts @@ -567,7 +567,51 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing } }) -test('EXPORT-ARCHIVE-05 each message tab restores its previous scroll anchor', async ({ page }) => { +test('EXPORT-ARCHIVE-05 date navigation jumps across days and years', async ({ page }) => { + const outputDir = mkdtempSync(join(tmpdir(), 'wxe-date-timeline-e2e-')) + try { + const dataPath = join(outputDir, 'data', 'messages.js') + mkdirSync(dirname(dataPath), { recursive: true }) + writeFileSync(join(outputDir, 'index.html'), renderExportPage('日期时间轴档案'), 'utf8') + const timestamp = (value: string): number => Math.floor(new Date(value).getTime() / 1000) + const messages = [ + archiveMessage('date-3', 'timeline', '日期时间轴档案', '十二月三十一日', timestamp('2025-12-31T12:00:00+08:00')), + archiveMessage('date-1', 'timeline', '日期时间轴档案', '九月一日', timestamp('2026-09-01T12:00:00+08:00')), + archiveMessage('date-2', 'timeline', '日期时间轴档案', '九月三日', timestamp('2026-09-03T12:00:00+08:00')) + ] + writeFileSync( + dataPath, + 'window.__WECHAT_EXPORT__ = ' + JSON.stringify({ + version: 1, + sourceId: 'timeline', + name: '日期时间轴档案', + exportedAt: '2026-09-04T00:00:00.000Z', + messages + }) + ';\n', + 'utf8' + ) + + await page.goto(pathToFileURL(join(outputDir, 'index.html')).href) + const september = page.locator('.timeline-month[data-month="2026-09"]') + await expect(september).toBeVisible() + await september.click() + await expect(page.locator('.timeline-day[data-date="2026-09-01"]')).toBeVisible() + await expect(page.locator('.timeline-day[data-date="2026-09-03"]')).toBeVisible() + await page.locator('.timeline-day[data-date="2026-09-01"]').click() + await expect(page.locator('.timeline-day.active')).toHaveAttribute('data-date', '2026-09-01') + await expect(page.locator('.message').filter({ hasText: '九月一日' })).toBeVisible() + + await page.locator('.timeline-year[data-year="2025"]').click() + await page.locator('.timeline-month[data-month="2025-12"]').click() + await page.locator('.timeline-day[data-date="2025-12-31"]').click() + await expect(page.locator('.timeline-day.active')).toHaveAttribute('data-date', '2025-12-31') + await expect(page.locator('.message').filter({ hasText: '十二月三十一日' })).toBeVisible() + } finally { + rmSync(outputDir, { recursive: true, force: true }) + } +}) + +test('EXPORT-ARCHIVE-06 each message tab restores its previous scroll anchor', async ({ page }) => { const outputDir = mkdtempSync(join(tmpdir(), 'wxe-tab-position-e2e-')) try { const dataPath = join(outputDir, 'data', 'messages.js') diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index f3d5972..78accee 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({ avatarMap: {} as Record, messages: [] as Message[], messagesByUser: {} as Record, + exportErrors: {} as Record, exportReads: [] as string[], selfInfoReads: 0, groupSnapshotReads: [] as string[], @@ -97,6 +98,7 @@ vi.mock('../../src/main/services/chat-service', () => ({ structuredClone(state.messagesByUser[userMd5] || state.messages), listMessagesForExport: async (userMd5: string) => { state.exportReads.push(userMd5) + if (state.exportErrors[userMd5]) throw new Error(state.exportErrors[userMd5]) return structuredClone(state.messagesByUser[userMd5] || state.messages) }, getChatDb: () => ({ @@ -279,6 +281,7 @@ describe('media export flow', () => { 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=' state.videoLookups = [] state.messagesByUser = {} + state.exportErrors = {} state.exportReads = [] state.selfInfoReads = 0 state.groupSnapshotReads = [] @@ -1233,7 +1236,9 @@ describe('media export flow', () => { target('csv-user', '测试联系人') ] state.messagesByUser = { - 'csv-group': [message({ id: 'group-text', content: '群聊消息' })], + 'csv-group': [ + message({ id: 'group-text', content: '群聊消息, 引号 \" 和换行\\n😀' }) + ], 'csv-user': [message({ id: 'user-text', content: '联系人消息' })] } @@ -1261,11 +1266,71 @@ describe('media export flow', () => { expect(userFiles).toHaveLength(1) expect(groupFiles[0]).toMatch(/^测试群聊_\d{8}_\d{6}\.csv$/) expect(userFiles[0]).toMatch(/^测试联系人_\d{8}_\d{6}\.csv$/) - expect(readFileSync(join(groupDir, groupFiles[0]), 'utf8')).toContain('群聊消息') + const groupCsv = readFileSync(join(groupDir, groupFiles[0])) + expect(groupCsv.subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf])) + expect(groupCsv.toString('utf8')).toContain('\"群聊消息, 引号 \"\" 和换行\\n😀\"') + expect(groupCsv.toString('utf8')).toContain('\r\n') expect(readFileSync(join(userDir, userFiles[0]), 'utf8')).toContain('联系人消息') expect(existsSync(join(outputDir, 'index.html'))).toBe(false) }) + it('continues all-export after one conversation fails and records a truthful manifest', async () => { + const { runExport } = await import('../../src/main/export-service') + const targets: ExportTarget[] = [ + target('all-fail-1', '成功会话一'), + target('all-fail-2', '失败会话'), + target('all-fail-3', '成功会话三') + ] + state.messagesByUser = Object.fromEntries( + targets.map((item) => [item.userMd5, [message({ id: item.userMd5, content: item.name })]]) + ) + state.exportErrors = { 'all-fail-2': '模拟单会话读取失败' } + const progress: Array<{ phase: string; percent?: number; error?: string }> = [] + const win = { + isDestroyed: () => false, + webContents: { + send: (_channel: string, item: (typeof progress)[number]) => progress.push(item) + } + } + + const result = await runExport( + { + jobId: 'all-export-continue-after-failure', + scope: 'all', + targets, + format: 'html', + outputName: 'all-export-continue-after-failure', + kinds: ['text'], + includeMedia: false + }, + win as never + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('失败会话') + expect(result.outputPath).toBeTruthy() + expect(state.exportReads).toEqual(['all-fail-1', 'all-fail-2', 'all-fail-3']) + expect(existsSync(join(result.outputPath!, '联系人', '成功会话一', 'index.html'))).toBe(true) + expect(existsSync(join(result.outputPath!, '联系人', '成功会话三', 'index.html'))).toBe(true) + const manifest = JSON.parse( + readFileSync(join(result.outputPath!, '导出清单.json'), 'utf8') + ) as { + status: string + conversations: Array<{ id: string; status: string; error?: string }> + } + expect(manifest.status).toBe('failed') + expect(manifest.conversations).toEqual([ + expect.objectContaining({ id: 'all-fail-1', status: 'completed' }), + expect.objectContaining({ + id: 'all-fail-2', + status: 'failed', + error: '模拟单会话读取失败' + }), + expect.objectContaining({ id: 'all-fail-3', status: 'completed' }) + ]) + expect(progress.at(-1)).toMatchObject({ phase: 'failed', percent: 100 }) + }) + it('cancels an all-export task between conversations without starting the next database read', async () => { const { cancelExport, runExport } = await import('../../src/main/export-service') const jobId = 'all-export-cancel' diff --git a/tests/unit/contact-name.test.ts b/tests/unit/contact-name.test.ts new file mode 100644 index 0000000..2beae63 --- /dev/null +++ b/tests/unit/contact-name.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + displayContactName, + filesystemSafeName, + isVisibleName, + visibleNamePart +} from '../../src/shared/contact-name' + +describe('contact name safety', () => { + it('recognizes private-use, zero-width, and control-only labels as invisible', () => { + expect(isVisibleName('\uE000\uE000')).toBe(false) + expect(isVisibleName('\u200B\u200D')).toBe(false) + expect(displayContactName({ m_nsNickName: '\uE000\uE000', remark: '备注名', wxid: 'wxid_a' })).toBe( + '备注名' + ) + }) + + it('preserves visible emoji and removes only non-rendering code points', () => { + expect(visibleNamePart(' 👩‍💻\u200B ')).toBe('👩💻') + expect( + displayContactName({ + m_nsNickName: String.fromCodePoint(0xE0020), + wechatId: 'wechat-alias' + }) + ).toBe( + 'wechat-alias' + ) + }) + + it('sanitizes reserved path characters, trailing dots, device names, and length', () => { + expect(filesystemSafeName('a/b:c*?"<>|. ')).toMatch(/^a_b_c_+$/) + expect(filesystemSafeName('CON')).toBe('CON_') + expect(filesystemSafeName('\uE000\uE000', '未命名联系人')).toBe('未命名联系人') + expect(Array.from(filesystemSafeName('😀'.repeat(100))).length).toBe(80) + }) +}) diff --git a/tests/unit/voice-service.test.ts b/tests/unit/voice-service.test.ts index 7565f16..407a8ce 100644 --- a/tests/unit/voice-service.test.ts +++ b/tests/unit/voice-service.test.ts @@ -51,4 +51,66 @@ describe('VoiceService batch lookup', () => { expect(resolveVoice).toHaveBeenNthCalledWith(1, 'session', 10, 100, '1000') expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001') }) + + it('keeps same local ids isolated by session and server identity, then reuses the exact cache entries', async () => { + const getVoiceDataBatch = vi + .fn() + .mockResolvedValue([ + { success: true, hex: '0102' }, + { success: true, hex: '0304' }, + { success: true, hex: '0506' } + ]) + const service = new VoiceService({ getVoiceDataBatch } as never, '/accounts/fixture-a') + const decoder = (service as unknown as { + decoderRegistry: { decode: ReturnType } + }).decoderRegistry + vi.spyOn(decoder, 'decode').mockImplementation(async (source: { data: Buffer }) => ({ + pcm: Buffer.from([source.data[0]]), + sampleRate: 16_000, + channels: 1 + })) + + const references = [ + { sessionId: 'session-a', localId: 7, createTime: 100, svrId: 'same-second-a' }, + { sessionId: 'session-b', localId: 7, createTime: 100, svrId: 'same-second-a' }, + { sessionId: 'session-a', localId: 7, createTime: 100, svrId: 'same-second-b' } + ] + const first = await service.resolveVoices(references) + expect(first.every((item) => item.success)).toBe(true) + expect(new Set(first.map((item) => item.data)).size).toBe(3) + expect(getVoiceDataBatch).toHaveBeenCalledOnce() + + const second = await service.resolveVoices(references) + expect(second).toEqual(first) + expect(getVoiceDataBatch).toHaveBeenCalledOnce() + }) + + it('versions the in-memory namespace per account so a restarted or switched account cannot reuse old audio', async () => { + const client = { + getVoiceDataBatch: vi.fn().mockResolvedValue([{ success: true, hex: '0102' }]), + getAccountRoot: () => '/accounts/fixture-a' + } + const first = new VoiceService(client as never) + const second = new VoiceService( + { ...client, getAccountRoot: () => '/accounts/fixture-b' } as never + ) + const decode = (service: VoiceService, value: number): void => { + const decoder = (service as unknown as { + decoderRegistry: { decode: ReturnType } + }).decoderRegistry + vi.spyOn(decoder, 'decode').mockResolvedValue({ + pcm: Buffer.from([value]), + sampleRate: 16_000, + channels: 1 + }) + } + decode(first, 1) + decode(second, 2) + const firstCache = (first as unknown as { voiceCache: Map }).voiceCache + const secondCache = (second as unknown as { voiceCache: Map }).voiceCache + await expect(first.resolveVoices([{ sessionId: 'session', localId: 1, createTime: 2 }])).resolves.toHaveLength(1) + await expect(second.resolveVoices([{ sessionId: 'session', localId: 1, createTime: 2 }])).resolves.toHaveLength(1) + expect([...firstCache.keys()][0]).not.toBe([...secondCache.keys()][0]) + expect(client.getVoiceDataBatch).toHaveBeenCalledTimes(2) + }) })