fix: 修复导出日期、语音和联系人名字问题

- 导出的 HTML:点月份先看日期,选中日期后才跳到对应消息。
- 语音转文字:不同账号、不同聊天里的语音不会再串到一起。
- 联系人名字:正常昵称显示不变;昵称异常时使用可见的备注或账号,导出文件名也能正常使用。
This commit is contained in:
Wxw-Gu
2026-09-13 11:15:30 +08:00
parent ab3b3731b7
commit add16885fe
19 changed files with 702 additions and 150 deletions
+139 -15
View File
@@ -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) + ' 年</button>' +
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
yearGroups.get(year).map((entry) =>
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
).join('') + '</div></section>'
yearGroups.get(year).map((entry) => {
const monthExpanded = entry.key === expandedTimelineMonth
return '<div class="timeline-month-entry">' +
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) +
'" aria-expanded="' + String(monthExpanded) + '">' +
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>' +
'<div class="timeline-days" data-days-for-month="' + esc(entry.key) + '"' +
(monthExpanded ? '' : ' hidden') + '>' + entry.days.map((day) =>
'<button class="timeline-day" type="button" data-date="' + esc(day.date) + '">' +
'<span>' + esc(day.date.slice(5)) + '</span><small>' + day.total + '</small></button>'
).join('') + '</div></div>'
}).join('') + '</div></section>'
}
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
}
@@ -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)
+130 -74
View File
@@ -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<string>()
const activeArchives = new Map<string, Archiver>()
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<void> => {
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 === '已取消'
+2 -2
View File
@@ -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()
+13 -11
View File
@@ -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<string, string>()
private pcmCache = new Map<string, ResolvedPcmAudio>()
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<ResolvePcmResult> {
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[] {
@@ -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
@@ -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<ExportFormat, { label: string; hint?: string }
export const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
export function displayName(contact: Contact | null): string {
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
return displayContactName(contact, '未选择会话')
}
export function formatPreviewTime(message: Message): string {
@@ -598,6 +598,8 @@ export function ScheduledReportsWorkspace({
const [editingTask, setEditingTask] = React.useState<ScheduledReportTask | null>(null)
const [saving, setSaving] = React.useState(false)
const [deletingTask, setDeletingTask] = React.useState<ScheduledReportTask | null>(null)
const [retryingExecution, setRetryingExecution] =
React.useState<ScheduledReportExecution | null>(null)
const [busyTaskId, setBusyTaskId] = React.useState<string | null>(null)
const load = React.useCallback(async (): Promise<void> => {
@@ -784,6 +786,13 @@ export function ScheduledReportsWorkspace({
}
}
const confirmRetrySend = async (): Promise<void> => {
if (!retryingExecution || busyTaskId) return
const execution = retryingExecution
setRetryingExecution(null)
await retrySend(execution)
}
const testErrorNotification = async (task: ScheduledReportTask): Promise<void> => {
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 ? '发送中…' : '重新发送'}
</Button>
@@ -1177,6 +1186,44 @@ export function ScheduledReportsWorkspace({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(retryingExecution)}
onOpenChange={(open) => !open && setRetryingExecution(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>将重新发送这份日报?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="grid gap-1">
<span>
目标:
{retryingExecution?.sendTarget ||
tasks.find((item) => item.id === retryingExecution?.taskId)?.target ||
tasks.find((item) => item.id === retryingExecution?.taskId)?.group ||
'未指定'}
</span>
<span>
日报:
{tasks.find((item) => item.id === retryingExecution?.taskId)?.name || '定时日报'}
</span>
<span>日期:{formatDateTime(retryingExecution?.startedAt)}</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(busyTaskId)}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
event.preventDefault()
void confirmRetrySend()
}}
disabled={Boolean(busyTaskId)}
>
{busyTaskId ? '发送中…' : '确认重新发送'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
+2 -1
View File
@@ -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, '未命名群聊')
@@ -1000,37 +1000,43 @@ export function AISearchWorkspace({
<div className="ai-search-insufficient-icon">!</div>
<span className="ai-search-kicker">检索反馈</span>
<h2>{analysisError || '当前范围没有足够证据'}</h2>
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
<Button
size="sm"
className="mt-5"
onClick={() => {
const expandToAll = range === '30d' || range === 'all'
setRange(expandToAll ? 'all' : '30d')
setTimeRangeOverride(
expandToAll
? {
label: '全部历史',
reason: '用户主动扩大到全部历史',
source: 'user_retry'
}
: undefined
)
skipNextCache()
void runAnalysis(undefined, {
range: expandToAll ? 'all' : '30d',
timeRangeOverride: expandToAll
? {
label: '全部历史',
reason: '用户主动扩大到全部历史',
source: 'user_retry'
}
: undefined
})
}}
>
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
</Button>
{queryAgentEnabled ? (
<p>可以切换聊天范围,或换一个更具体的问题。</p>
) : (
<>
<p>可以扩大时间范围、切换群聊,或换一个更具体的问题。</p>
<Button
size="sm"
className="mt-5"
onClick={() => {
const expandToAll = range === '30d' || range === 'all'
setRange(expandToAll ? 'all' : '30d')
setTimeRangeOverride(
expandToAll
? {
label: '全部历史',
reason: '用户主动扩大到全部历史',
source: 'user_retry'
}
: undefined
)
skipNextCache()
void runAnalysis(undefined, {
range: expandToAll ? 'all' : '30d',
timeRangeOverride: expandToAll
? {
label: '全部历史',
reason: '用户主动扩大到全部历史',
source: 'user_retry'
}
: undefined
})
}}
>
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
</Button>
</>
)}
</div>
)
@@ -1400,7 +1406,7 @@ export function AISearchWorkspace({
<AISearchComposer
query={query}
sourceLabel={sourceLabel}
rangeLabel={RANGE_LABELS[range]}
rangeLabel={queryAgentEnabled ? '时间由问题决定' : RANGE_LABELS[range]}
history={history}
historyOpen={historyOpen}
loading={stage === 'loading'}
@@ -1,6 +1,7 @@
import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge'
import { isKnowledgeFresh } from '../../../../shared/knowledge'
import type { Contact } from '../../../../shared/types'
import { displayContactName } from '../../../../shared/contact-name'
import type { SearchTrace } from './searchTypes'
export const formatBytes = (bytes: number): string => {
@@ -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
@@ -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)}
</small>
{currentBatchConversation && (
<small>当前会话:{currentBatchConversation.m_nsNickName}</small>
<small>当前会话:{contactName(currentBatchConversation)}</small>
)}
</div>
<progress value={batchProgress.processed} max={Math.max(1, batchProgress.total)} />
+64
View File
@@ -0,0 +1,64 @@
import type { Contact } from './types'
type ContactNameSource = Partial<Pick<
Contact,
| 'm_nsNickName'
| 'm_nsUsrName'
| 'remark'
| 'alias'
| 'wechatNickname'
| 'wechatId'
| 'wxid'
>>
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('') || '未命名联系人'
}
+3 -1
View File
@@ -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 | undefined>): 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') {
@@ -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 () => {
@@ -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(
<ScheduledReportsWorkspace
contacts={[group]}
onNotice={onNotice}
onOpenWechatSettings={onOpenWechatSettings}
onOpenAgentHub={onOpenAgentHub}
/>
)
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)
})
})
+45 -1
View File
@@ -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')
+67 -2
View File
@@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({
avatarMap: {} as Record<string, string>,
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportErrors: {} as Record<string, string>,
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'
+36
View File
@@ -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)
})
})
+62
View File
@@ -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<typeof vi.fn> }
}).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<typeof vi.fn> }
}).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<string, string> }).voiceCache
const secondCache = (second as unknown as { voiceCache: Map<string, string> }).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)
})
})