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)
+ })
})