mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
Merge branch 'nanin/develop' into develop
This commit is contained in:
+3
-3
@@ -2,7 +2,7 @@
|
||||
"name": "tracememo",
|
||||
"version": "2.2.0",
|
||||
"packageManager": "pnpm@7.33.7",
|
||||
"description": "macOS / Windows 本地优先、可追溯的 AI 微信知识与分析工作台",
|
||||
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"wechat chat",
|
||||
@@ -20,7 +20,7 @@
|
||||
"author": "Qingmao",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Wxw-Gu/WechatExplorer.git"
|
||||
"url": "https://github.com/Wxw-Gu/TraceMemo.git"
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
@@ -135,4 +135,4 @@
|
||||
"ffmpeg-static"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
type UserDataSelection
|
||||
} from './app-data-paths'
|
||||
import type { LegacySecretBundle } from './legacy-safe-storage-helper'
|
||||
import { SqliteTranscriptRepository } from './voice-pipeline/transcript-repository'
|
||||
|
||||
const MIGRATION_STATE_FILE = 'tracememo-migration-v1.json'
|
||||
const VOICE_TRANSCRIPT_CACHE_ITEM = 'cache/voice-transcripts.sqlite'
|
||||
const TOKEN_MIGRATION_BLOCK_MESSAGE =
|
||||
'检测到旧版 API Token 尚未完成迁移。请重试数据迁移,或在 API Center 主动重新生成 Token。'
|
||||
|
||||
@@ -289,6 +291,24 @@ async function copyDirectoryWithoutOverwrite(
|
||||
return 'migrated'
|
||||
}
|
||||
|
||||
export async function migrateLegacyVoiceTranscripts(
|
||||
sourceRoot: string,
|
||||
targetRoot: string
|
||||
): Promise<MigrationItemStatus> {
|
||||
const relativePath = path.join('cache', 'voice-transcripts.sqlite')
|
||||
const sourcePath = path.join(sourceRoot, relativePath)
|
||||
const targetPath = path.join(targetRoot, relativePath)
|
||||
if (!(await fs.pathExists(sourcePath))) return 'missing'
|
||||
if (path.resolve(sourcePath) === path.resolve(targetPath)) return 'skipped'
|
||||
|
||||
const repository = new SqliteTranscriptRepository(targetPath)
|
||||
try {
|
||||
return repository.mergeFrom(sourcePath) > 0 ? 'migrated' : 'skipped'
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEncryptedWithoutOverwrite(
|
||||
targetRoot: string,
|
||||
relativePath: string,
|
||||
@@ -472,6 +492,9 @@ export async function executeMigration(
|
||||
copyDirectoryWithoutOverwrite(sourceRoot, targetRoot, relativePath, stagingRoot)
|
||||
)
|
||||
}
|
||||
await runItem(VOICE_TRANSCRIPT_CACHE_ITEM, () =>
|
||||
migrateLegacyVoiceTranscripts(sourceRoot, targetRoot)
|
||||
)
|
||||
|
||||
const agentRoots = dependencies.agentRoots()
|
||||
await runItem('agent-credentials', () => copyMissingTree(agentRoots.legacy, agentRoots.current))
|
||||
@@ -528,7 +551,9 @@ export async function executeMigration(
|
||||
await fs.remove(stagingRoot).catch(() => undefined)
|
||||
}
|
||||
|
||||
const failed = Object.values(items).some((status) => status === 'failed')
|
||||
const failed = Object.entries(items).some(
|
||||
([name, status]) => name !== VOICE_TRANSCRIPT_CACHE_ITEM && status === 'failed'
|
||||
)
|
||||
state.status = failed || secretFailures.length > 0 ? 'partial' : 'completed'
|
||||
state.updatedAt = timestamp()
|
||||
state.secretFailures = Array.from(new Set(secretFailures))
|
||||
@@ -592,6 +617,38 @@ function closeMigrationProgress(window: BrowserWindow | null): void {
|
||||
|
||||
export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<MigrationFlowResult> {
|
||||
const assessment = assessMigration(roots)
|
||||
const voiceMigrationStatus = assessment.state?.items[VOICE_TRANSCRIPT_CACHE_ITEM]
|
||||
const needsVoiceBackfill = !voiceMigrationStatus
|
||||
if (
|
||||
assessment.reason === 'migration-completed' &&
|
||||
assessment.sourceRoot &&
|
||||
assessment.state &&
|
||||
needsVoiceBackfill
|
||||
) {
|
||||
const state: MigrationState = {
|
||||
...assessment.state,
|
||||
items: { ...assessment.state.items },
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
try {
|
||||
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = await migrateLegacyVoiceTranscripts(
|
||||
assessment.sourceRoot,
|
||||
roots.current
|
||||
)
|
||||
} catch {
|
||||
state.items[VOICE_TRANSCRIPT_CACHE_ITEM] = 'failed'
|
||||
}
|
||||
await writeMigrationState(roots.current, state)
|
||||
return {
|
||||
assessment: { ...assessment, state },
|
||||
action: state.items[VOICE_TRANSCRIPT_CACHE_ITEM] === 'migrated' ? 'migrated' : 'none',
|
||||
execution: {
|
||||
state,
|
||||
tokenGenerationBlocked: false
|
||||
},
|
||||
tokenGenerationBlocked: false
|
||||
}
|
||||
}
|
||||
if (!assessment.shouldPrompt || !assessment.sourceRoot) {
|
||||
return { assessment, action: 'none', tokenGenerationBlocked: false }
|
||||
}
|
||||
@@ -649,9 +706,7 @@ export async function runFirstLaunchMigration(roots: UserDataRoots): Promise<Mig
|
||||
type: execution.state.status === 'completed' ? ('info' as const) : ('warning' as const),
|
||||
title: 'TraceMemo 数据迁移',
|
||||
message:
|
||||
execution.state.status === 'completed'
|
||||
? 'WechatExplorer 数据迁移完成'
|
||||
: '部分数据未能迁移',
|
||||
execution.state.status === 'completed' ? 'WechatExplorer 数据迁移完成' : '部分数据未能迁移',
|
||||
detail:
|
||||
execution.state.status === 'completed'
|
||||
? '核心用户资产已复制到 TraceMemo。旧目录仍完整保留。'
|
||||
|
||||
@@ -131,7 +131,8 @@ export function hasValidUserAssets(root: string): boolean {
|
||||
'wechat-db-key.bin',
|
||||
'wechat-image-keys.bin',
|
||||
'image-insights.json',
|
||||
'wechat-share-service.bin'
|
||||
'wechat-share-service.bin',
|
||||
path.join('cache', 'voice-transcripts.sqlite')
|
||||
]
|
||||
if (markers.some((marker) => isNonEmptyFile(path.join(root, marker)))) return true
|
||||
if (hasKnowledgeDatabase(root)) return true
|
||||
|
||||
@@ -995,6 +995,19 @@ const renderExportScript = (name: string): string => `
|
||||
message.contentData && forwardedSearchText(message.contentData.items),
|
||||
message.exportMediaName
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
const exportedImageOrVideoFileStem = (message) => {
|
||||
const mediaType = message.exportMediaType || (message.contentData && message.contentData.type)
|
||||
if (mediaType !== 'image' && mediaType !== 'video') return ''
|
||||
const mediaUrl = String(message.exportMediaUrl || '').split(/[?#]/, 1)[0]
|
||||
const fileName = mediaUrl.slice(mediaUrl.lastIndexOf('/') + 1)
|
||||
if (!fileName) return ''
|
||||
let decodedFileName = fileName
|
||||
try {
|
||||
decodedFileName = decodeURIComponent(fileName)
|
||||
} catch {}
|
||||
const extensionIndex = decodedFileName.lastIndexOf('.')
|
||||
return (extensionIndex > 0 ? decodedFileName.slice(0, extensionIndex) : decodedFileName).toLowerCase()
|
||||
}
|
||||
|
||||
const shareLabel = (typeVal) => {
|
||||
if (String(typeVal) === '5') return '公众号链接'
|
||||
@@ -1516,7 +1529,9 @@ const renderExportScript = (name: string): string => `
|
||||
return allMessages.filter((message) =>
|
||||
(activeConversation === 'all' || message.exportConversationId === activeConversation) &&
|
||||
(activeKind === 'all' || kindOf(message) === activeKind) &&
|
||||
(!term || searchText(message).includes(term))
|
||||
(!term ||
|
||||
searchText(message).includes(term) ||
|
||||
exportedImageOrVideoFileStem(message) === term)
|
||||
)
|
||||
}
|
||||
const applyFilters = (restorePosition = false) => {
|
||||
@@ -1779,7 +1794,7 @@ export function renderExportPage(name: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="query" type="search" placeholder="搜索发送者或消息内容…" aria-label="搜索消息">
|
||||
<input id="query" type="search" placeholder="搜索发送者、消息内容或媒体文件名(不含后缀)…" aria-label="搜索消息">
|
||||
</div>
|
||||
<div class="filters" id="filters">
|
||||
<button class="filter-button active" type="button" data-kind="all">全部</button>
|
||||
|
||||
+155
-66
@@ -275,6 +275,8 @@ const mergeArchiveMessage = (previous: Message, current: Message): Message => {
|
||||
if (!current.exportMediaUrl && !current.voiceDataUrl && previous.exportMediaError) {
|
||||
merged.exportMediaError = previous.exportMediaError
|
||||
}
|
||||
if (merged.voiceDataUrl) delete merged.exportMediaError
|
||||
if (merged.voiceTranscript) delete merged.voiceTranscriptError
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -754,7 +756,8 @@ const preserveLegacyCombinedArchive = async (outputDir: string): Promise<void> =
|
||||
async function runSingleExport(
|
||||
request: ExportRequest,
|
||||
win: BrowserWindow,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
|
||||
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>,
|
||||
options: SingleExportOptions = {}
|
||||
): Promise<ExportResult> {
|
||||
const manageJob = options.manageJob !== false
|
||||
@@ -1083,89 +1086,173 @@ async function runSingleExport(
|
||||
total: voiceMessages.length,
|
||||
percent: 20
|
||||
})
|
||||
for (const [voiceIndex, message] of voiceMessages.entries()) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const previous = reusablePreviousMessages.get(message)
|
||||
let canTranscribe = true
|
||||
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
|
||||
message.voiceDataUrl = previous.voiceDataUrl
|
||||
message.voiceDuration = previous.voiceDuration
|
||||
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
|
||||
message.voiceTranscript = previous.voiceTranscript
|
||||
const voiceIndexUpdates = new Map<
|
||||
string,
|
||||
{
|
||||
reference: {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
}
|
||||
} else if (!message.sessionId || message.localId == null || !message.createTime) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
canTranscribe = false
|
||||
} else {
|
||||
try {
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) {
|
||||
const detail = voice.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
canTranscribe = false
|
||||
} else {
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||||
const voiceUrl = `voices/${voiceName}`
|
||||
if (!(await resourceExists(voiceUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
markResourceExists(voiceUrl)
|
||||
transcript: string
|
||||
cached: boolean
|
||||
}
|
||||
>()
|
||||
const batchSize = 16
|
||||
for (let batchStart = 0; batchStart < voiceMessages.length; batchStart += batchSize) {
|
||||
const batch = voiceMessages.slice(batchStart, batchStart + batchSize)
|
||||
const mediaItems: Array<{
|
||||
message: Message
|
||||
reference: {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
}
|
||||
}> = []
|
||||
for (const message of batch) {
|
||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||
const previous = reusablePreviousMessages.get(message)
|
||||
const hasVoiceIdentity = Boolean(
|
||||
message.sessionId && message.localId != null && message.createTime
|
||||
)
|
||||
const reference = hasVoiceIdentity
|
||||
? {
|
||||
sessionId: message.sessionId!,
|
||||
localId: message.localId!,
|
||||
createTime: message.createTime!,
|
||||
svrId: message.serverId
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
: null
|
||||
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
|
||||
message.voiceDataUrl = previous.voiceDataUrl
|
||||
message.voiceDuration = previous.voiceDuration
|
||||
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
|
||||
message.voiceTranscript = previous.voiceTranscript
|
||||
}
|
||||
}
|
||||
if (request.includeVoiceTranscripts && !message.voiceTranscript) {
|
||||
if (!reference) {
|
||||
message.voiceTranscriptError = '语音标识不完整,无法转文字'
|
||||
} else if (!voiceRecognition) {
|
||||
message.voiceTranscriptError = '语音转文字服务不可用'
|
||||
} else {
|
||||
try {
|
||||
const recognition = await voiceRecognition.recognize(
|
||||
reference,
|
||||
voiceRecognition.publishTranscript
|
||||
? { publishTranscriptUpdate: false }
|
||||
: undefined
|
||||
)
|
||||
if (recognition.success) {
|
||||
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
|
||||
if (voiceRecognition.publishTranscript && recognition.transcript?.trim()) {
|
||||
voiceIndexUpdates.set(reference.sessionId, {
|
||||
reference,
|
||||
transcript: recognition.transcript.trim(),
|
||||
cached: Boolean(recognition.cached)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
message.voiceTranscriptError = recognition.error || '语音识别失败'
|
||||
}
|
||||
} catch (error) {
|
||||
message.voiceTranscriptError =
|
||||
error instanceof Error ? error.message : '语音识别失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
request.includeVoiceTranscripts &&
|
||||
reference &&
|
||||
message.voiceTranscript &&
|
||||
voiceRecognition?.publishTranscript &&
|
||||
!voiceIndexUpdates.has(reference.sessionId)
|
||||
) {
|
||||
voiceIndexUpdates.set(reference.sessionId, {
|
||||
reference,
|
||||
transcript: message.voiceTranscript,
|
||||
cached: true
|
||||
})
|
||||
}
|
||||
if (!message.voiceDataUrl) {
|
||||
if (!reference) {
|
||||
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
|
||||
} else {
|
||||
mediaItems.push({ message, reference })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const voices =
|
||||
typeof voiceService.resolveVoices === 'function'
|
||||
? await voiceService.resolveVoices(mediaItems.map((item) => item.reference))
|
||||
: await Promise.all(
|
||||
mediaItems.map(({ reference }) =>
|
||||
voiceService.resolveVoice(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime,
|
||||
reference.svrId
|
||||
)
|
||||
)
|
||||
)
|
||||
for (const [{ message }, voice] of mediaItems.map(
|
||||
(item, index) => [item, voices[index]] as const
|
||||
)) {
|
||||
if (!voice?.success || !voice.data) {
|
||||
const detail = voice?.error || '未知原因'
|
||||
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
|
||||
? `语音文件缺失:${detail}`
|
||||
: /Silk|解码|数据为空/.test(detail)
|
||||
? `语音解析失败:${detail}`
|
||||
: `语音格式不支持或读取失败:${detail}`
|
||||
keepMediaError(request, message, reason)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
|
||||
const voiceUrl = `voices/${voiceName}`
|
||||
if (!(await resourceExists(voiceUrl))) {
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
markResourceExists(voiceUrl)
|
||||
}
|
||||
message.voiceDataUrl = voiceUrl
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
} catch (error) {
|
||||
keepMediaError(
|
||||
request,
|
||||
message,
|
||||
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
canTranscribe = false
|
||||
}
|
||||
}
|
||||
if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) {
|
||||
if (!voiceRecognition) {
|
||||
message.voiceTranscriptError = '语音转文字服务不可用'
|
||||
} else {
|
||||
try {
|
||||
const recognition = await voiceRecognition.recognize({
|
||||
sessionId: message.sessionId!,
|
||||
localId: message.localId!,
|
||||
createTime: message.createTime!,
|
||||
svrId: message.serverId
|
||||
})
|
||||
if (recognition.success) {
|
||||
message.voiceTranscript = recognition.transcript?.trim() || '未识别出文字'
|
||||
} else {
|
||||
message.voiceTranscriptError = recognition.error || '语音识别失败'
|
||||
}
|
||||
} catch (error) {
|
||||
message.voiceTranscriptError =
|
||||
error instanceof Error ? error.message : '语音识别失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
const processedVoices = Math.min(batchStart + batch.length, voiceMessages.length)
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: voicePhase,
|
||||
processed: voiceIndex + 1,
|
||||
processed: processedVoices,
|
||||
total: voiceMessages.length,
|
||||
percent:
|
||||
20 +
|
||||
Math.round(
|
||||
((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
|
||||
(processedVoices / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
|
||||
)
|
||||
})
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
}
|
||||
for (const update of voiceIndexUpdates.values()) {
|
||||
try {
|
||||
await voiceRecognition?.publishTranscript?.(
|
||||
update.reference,
|
||||
update.transcript,
|
||||
update.cached
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[Export] voice transcript index refresh failed:', error)
|
||||
}
|
||||
}
|
||||
} else if (request.includeMedia) {
|
||||
for (const message of messages) {
|
||||
@@ -1500,7 +1587,8 @@ async function runSingleExport(
|
||||
async function runAllExport(
|
||||
request: ExportRequest,
|
||||
win: BrowserWindow,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
|
||||
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
|
||||
): Promise<ExportResult> {
|
||||
jobs.add(request.jobId)
|
||||
const send = (progress: ExportJobProgress): void => {
|
||||
@@ -1674,7 +1762,8 @@ async function runAllExport(
|
||||
export async function runExport(
|
||||
request: ExportRequest,
|
||||
win: BrowserWindow,
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'>
|
||||
voiceRecognition?: Pick<VoiceRecognitionUseCase, 'recognize'> &
|
||||
Partial<Pick<VoiceRecognitionUseCase, 'publishTranscript'>>
|
||||
): Promise<ExportResult> {
|
||||
return request.scope === 'all'
|
||||
? runAllExport(request, win, voiceRecognition)
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PcmProcessorOptions {
|
||||
}
|
||||
|
||||
export class PcmAudioProcessor implements AudioProcessor {
|
||||
readonly version = VOICE_PROCESSOR_VERSION
|
||||
private readonly targetSampleRate: number
|
||||
private readonly silenceThreshold: number
|
||||
private readonly silencePaddingMs: number
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
import { dirname } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type {
|
||||
TranscriptMessageStatus,
|
||||
TranscriptRecord,
|
||||
TranscriptRepository
|
||||
} from './types'
|
||||
import type { TranscriptMessageStatus, TranscriptRecord, TranscriptRepository } from './types'
|
||||
|
||||
type TranscriptKey = Omit<
|
||||
TranscriptRecord,
|
||||
'transcript' | 'language' | 'durationMs' | 'createdAt' | 'updatedAt'
|
||||
>
|
||||
|
||||
type CompatibleTranscriptKey = Pick<
|
||||
TranscriptRecord,
|
||||
| 'accountId'
|
||||
| 'messageIdentity'
|
||||
| 'processorVersion'
|
||||
| 'recognizerId'
|
||||
| 'modelVersion'
|
||||
| 'modelFingerprint'
|
||||
>
|
||||
|
||||
function transcriptRecord(row: Record<string, unknown>): TranscriptRecord {
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
messageIdentity: String(row.message_identity),
|
||||
audioHash: String(row.audio_hash),
|
||||
processorVersion: String(row.processor_version),
|
||||
recognizerId: String(row.recognizer_id),
|
||||
modelVersion: String(row.model_version),
|
||||
modelFingerprint: String(row.model_fingerprint),
|
||||
transcript: String(row.transcript),
|
||||
language: row.language ? String(row.language) : undefined,
|
||||
durationMs: Number(row.duration_ms),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
export class SqliteTranscriptRepository implements TranscriptRepository {
|
||||
private readonly database: DatabaseSync
|
||||
|
||||
@@ -70,20 +93,7 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
|
||||
key.modelFingerprint
|
||||
) as Record<string, unknown> | undefined
|
||||
if (!row) return null
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
messageIdentity: String(row.message_identity),
|
||||
audioHash: String(row.audio_hash),
|
||||
processorVersion: String(row.processor_version),
|
||||
recognizerId: String(row.recognizer_id),
|
||||
modelVersion: String(row.model_version),
|
||||
modelFingerprint: String(row.model_fingerprint),
|
||||
transcript: String(row.transcript),
|
||||
language: row.language ? String(row.language) : undefined,
|
||||
durationMs: Number(row.duration_ms),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
}
|
||||
return transcriptRecord(row)
|
||||
}
|
||||
|
||||
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null {
|
||||
@@ -99,19 +109,86 @@ export class SqliteTranscriptRepository implements TranscriptRepository {
|
||||
)
|
||||
.get(accountId, messageIdentity) as Record<string, unknown> | undefined
|
||||
if (!row) return null
|
||||
return {
|
||||
accountId: String(row.account_id),
|
||||
messageIdentity: String(row.message_identity),
|
||||
audioHash: String(row.audio_hash),
|
||||
processorVersion: String(row.processor_version),
|
||||
recognizerId: String(row.recognizer_id),
|
||||
modelVersion: String(row.model_version),
|
||||
modelFingerprint: String(row.model_fingerprint),
|
||||
transcript: String(row.transcript),
|
||||
language: row.language ? String(row.language) : undefined,
|
||||
durationMs: Number(row.duration_ms),
|
||||
createdAt: Number(row.created_at),
|
||||
updatedAt: Number(row.updated_at)
|
||||
return transcriptRecord(row)
|
||||
}
|
||||
|
||||
findCompatible(key: CompatibleTranscriptKey): TranscriptRecord | null {
|
||||
const row = this.database
|
||||
.prepare(
|
||||
`SELECT account_id, message_identity, audio_hash, processor_version,
|
||||
recognizer_id, model_version, model_fingerprint, transcript,
|
||||
language, duration_ms, created_at, updated_at
|
||||
FROM voice_transcripts
|
||||
WHERE account_id = ? AND message_identity = ? AND processor_version = ?
|
||||
AND recognizer_id = ? AND model_version = ? AND model_fingerprint = ?
|
||||
AND trim(transcript) <> ''
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(
|
||||
key.accountId,
|
||||
key.messageIdentity,
|
||||
key.processorVersion,
|
||||
key.recognizerId,
|
||||
key.modelVersion,
|
||||
key.modelFingerprint
|
||||
) as Record<string, unknown> | undefined
|
||||
return row ? transcriptRecord(row) : null
|
||||
}
|
||||
|
||||
mergeFrom(databasePath: string): number {
|
||||
this.database.prepare('ATTACH DATABASE ? AS legacy_voice').run(databasePath)
|
||||
try {
|
||||
const hasTranscripts = this.database
|
||||
.prepare(
|
||||
`SELECT 1 FROM legacy_voice.sqlite_master
|
||||
WHERE type = 'table' AND name = 'voice_transcripts'`
|
||||
)
|
||||
.get()
|
||||
if (!hasTranscripts)
|
||||
throw new Error('Legacy voice transcript database has no transcript table')
|
||||
|
||||
this.database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
this.database.exec(`
|
||||
INSERT OR IGNORE INTO main.voice_transcripts (
|
||||
account_id, message_identity, audio_hash, processor_version,
|
||||
recognizer_id, model_version, model_fingerprint, transcript,
|
||||
language, duration_ms, created_at, updated_at
|
||||
)
|
||||
SELECT account_id, message_identity, audio_hash, processor_version,
|
||||
recognizer_id, model_version, model_fingerprint, transcript,
|
||||
language, duration_ms, created_at, updated_at
|
||||
FROM legacy_voice.voice_transcripts
|
||||
WHERE trim(transcript) <> ''
|
||||
`)
|
||||
const inserted = Number(
|
||||
(
|
||||
this.database.prepare('SELECT changes() AS count').get() as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.count || 0
|
||||
)
|
||||
this.database.exec(`
|
||||
INSERT INTO main.voice_transcript_message_states (
|
||||
account_id, message_identity, state, error, updated_at
|
||||
)
|
||||
SELECT account_id, message_identity, 'transcribed', NULL, MAX(updated_at)
|
||||
FROM legacy_voice.voice_transcripts
|
||||
WHERE trim(transcript) <> ''
|
||||
GROUP BY account_id, message_identity
|
||||
ON CONFLICT (account_id, message_identity) DO UPDATE SET
|
||||
state = 'transcribed', error = NULL, updated_at = excluded.updated_at
|
||||
WHERE excluded.updated_at > voice_transcript_message_states.updated_at
|
||||
`)
|
||||
this.database.exec('COMMIT')
|
||||
return inserted
|
||||
} catch (error) {
|
||||
this.database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
this.database.exec('DETACH DATABASE legacy_voice')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export class SpeechRecognizerRegistry {
|
||||
}
|
||||
|
||||
export interface AudioProcessor {
|
||||
readonly version: string
|
||||
process(input: {
|
||||
pcm: Buffer
|
||||
sampleRate: number
|
||||
@@ -87,6 +88,17 @@ export interface TranscriptRepository {
|
||||
>
|
||||
): TranscriptRecord | null
|
||||
findLatest(accountId: string, messageIdentity: string): TranscriptRecord | null
|
||||
findCompatible(
|
||||
key: Pick<
|
||||
TranscriptRecord,
|
||||
| 'accountId'
|
||||
| 'messageIdentity'
|
||||
| 'processorVersion'
|
||||
| 'recognizerId'
|
||||
| 'modelVersion'
|
||||
| 'modelFingerprint'
|
||||
>
|
||||
): TranscriptRecord | null
|
||||
getMessageStatus(accountId: string, messageIdentity: string): TranscriptMessageStatus
|
||||
save(record: TranscriptRecord): void
|
||||
markFailure(accountId: string, messageIdentity: string, error: string): void
|
||||
|
||||
@@ -39,13 +39,28 @@ export class VoicePipeline {
|
||||
reference: VoiceMessageReference,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ transcript: string; language?: string; durationMs: number; cached: boolean }> {
|
||||
const messageIdentity = voiceMessageIdentity(reference)
|
||||
const compatible = this.transcripts.findCompatible({
|
||||
accountId,
|
||||
messageIdentity,
|
||||
processorVersion: this.audioProcessor.version,
|
||||
...this.recognizer.metadata
|
||||
})
|
||||
if (compatible?.transcript.trim()) {
|
||||
return {
|
||||
transcript: compatible.transcript.trim(),
|
||||
language: compatible.language,
|
||||
durationMs: compatible.durationMs,
|
||||
cached: true
|
||||
}
|
||||
}
|
||||
|
||||
const source = await this.sourceResolver.resolve(reference)
|
||||
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
const decoded = await this.decoderRegistry.decode(source)
|
||||
if (signal?.aborted) throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
const audio = this.audioProcessor.process(decoded)
|
||||
if (audio.samples.length === 0) throw new Error('Voice audio is empty after processing')
|
||||
const messageIdentity = voiceMessageIdentity(reference)
|
||||
const key = {
|
||||
accountId,
|
||||
messageIdentity,
|
||||
|
||||
@@ -97,36 +97,44 @@ export class VoiceRecognitionUseCase {
|
||||
const generation = this.accountGeneration
|
||||
const accountIdentity = this.accountId
|
||||
return this.scheduler
|
||||
.schedule(key, async (signal) => {
|
||||
const status = await this.modelManager.getStatus()
|
||||
if (status.state !== 'ready') {
|
||||
return { success: false, code: 'MODEL_NOT_READY', error: '请先下载语音识别模型' } as const
|
||||
}
|
||||
const result = await pipeline.run(accountId, reference, signal)
|
||||
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
}
|
||||
const transcript = result.transcript.trim()
|
||||
if (
|
||||
transcript &&
|
||||
options?.publishTranscriptUpdate !== false &&
|
||||
this.isCurrentAccount(accountId, generation)
|
||||
) {
|
||||
try {
|
||||
await this.publishTranscriptUpdate({
|
||||
accountIdentity,
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: 'transcribed',
|
||||
transcript,
|
||||
cached: result.cached
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[Voice] transcript indexed asynchronously failed:', error)
|
||||
.schedule(
|
||||
key,
|
||||
async (signal) => {
|
||||
const status = await this.modelManager.getStatus()
|
||||
if (status.state !== 'ready') {
|
||||
return {
|
||||
success: false,
|
||||
code: 'MODEL_NOT_READY',
|
||||
error: '请先下载语音识别模型'
|
||||
} as const
|
||||
}
|
||||
}
|
||||
return { success: true, ...result, transcript } as const
|
||||
}, { priority: options?.priority })
|
||||
const result = await pipeline.run(accountId, reference, signal)
|
||||
if (signal.aborted || !this.isCurrentAccount(accountId, generation)) {
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
}
|
||||
const transcript = result.transcript.trim()
|
||||
if (
|
||||
transcript &&
|
||||
options?.publishTranscriptUpdate !== false &&
|
||||
this.isCurrentAccount(accountId, generation)
|
||||
) {
|
||||
try {
|
||||
await this.publishTranscriptUpdate({
|
||||
accountIdentity,
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: 'transcribed',
|
||||
transcript,
|
||||
cached: result.cached
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[Voice] transcript indexed asynchronously failed:', error)
|
||||
}
|
||||
}
|
||||
return { success: true, ...result, transcript } as const
|
||||
},
|
||||
{ priority: options?.priority }
|
||||
)
|
||||
.catch((error): VoiceRecognitionResult => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
return { success: false, code: 'CANCELLED', error: '语音识别已取消' }
|
||||
@@ -173,18 +181,25 @@ export class VoiceRecognitionUseCase {
|
||||
}
|
||||
|
||||
async publishTranscriptSnapshot(reference: VoiceMessageReference): Promise<void> {
|
||||
const accountIdentity = this.accountId
|
||||
if (!accountIdentity) return
|
||||
const snapshot = this.getTranscriptSnapshot(reference)
|
||||
if (snapshot.state === 'pending') return
|
||||
await this.publishTranscript(reference, snapshot.transcript, snapshot.state === 'transcribed')
|
||||
}
|
||||
|
||||
async publishTranscript(
|
||||
reference: VoiceMessageReference,
|
||||
transcript?: string,
|
||||
cached = true
|
||||
): Promise<void> {
|
||||
const accountIdentity = this.accountId
|
||||
if (!accountIdentity || !transcript?.trim()) return
|
||||
await this.publishTranscriptUpdate({
|
||||
accountIdentity,
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: snapshot.state,
|
||||
transcript: snapshot.transcript,
|
||||
error: snapshot.error,
|
||||
cached: snapshot.state === 'transcribed'
|
||||
state: 'transcribed',
|
||||
transcript: transcript.trim(),
|
||||
cached
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,13 @@ export type ResolveSourceResult =
|
||||
| { success: true; source: EncodedVoiceSource }
|
||||
| { success: false; error: string }
|
||||
|
||||
export interface VoiceReference {
|
||||
sessionId: string
|
||||
localId: number
|
||||
createTime: number
|
||||
svrId?: string | number
|
||||
}
|
||||
|
||||
export class VoiceService {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private voiceCache = new Map<string, string>()
|
||||
@@ -70,6 +77,83 @@ export class VoiceService {
|
||||
return { success: true, data: base64Data }
|
||||
}
|
||||
|
||||
async resolveVoices(
|
||||
references: VoiceReference[]
|
||||
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
|
||||
const results: Array<{ success: boolean; data?: string; error?: string } | undefined> =
|
||||
new Array(references.length)
|
||||
const missing: Array<{ index: number; reference: VoiceReference }> = []
|
||||
references.forEach((reference, index) => {
|
||||
const cached = this.voiceCache.get(
|
||||
this.buildCacheKey(reference.sessionId, reference.localId, reference.createTime)
|
||||
)
|
||||
if (cached) results[index] = { success: true, data: cached }
|
||||
else missing.push({ index, reference })
|
||||
})
|
||||
if (!missing.length)
|
||||
return results as Array<{ success: boolean; data?: string; error?: string }>
|
||||
|
||||
const sources = await this.wcdb4Client.getVoiceDataBatch(
|
||||
missing.map(({ reference }) => ({
|
||||
sessionId: reference.sessionId,
|
||||
createTime: reference.createTime,
|
||||
localId: reference.localId,
|
||||
svrId: reference.svrId,
|
||||
candidates: this.buildCandidates(reference.sessionId)
|
||||
}))
|
||||
)
|
||||
const failed: Array<{ index: number; reference: VoiceReference }> = []
|
||||
for (const [{ index, reference }, source] of missing.map(
|
||||
(item, sourceIndex) => [item, sources[sourceIndex]] as const
|
||||
)) {
|
||||
if (!source?.success || !source.hex) {
|
||||
failed.push({ index, reference })
|
||||
continue
|
||||
}
|
||||
const silkData = this.decodeVoiceBlob(source.hex)
|
||||
if (!silkData?.length) {
|
||||
results[index] = { success: false, error: '语音数据为空' }
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const decoded = await this.decoderRegistry.decode({
|
||||
data: silkData,
|
||||
codec: 'silk',
|
||||
sourceHash: createHash('sha256').update(silkData).digest('hex')
|
||||
})
|
||||
const wavData = this.createWavBuffer(decoded.pcm, 24000)
|
||||
const data = wavData.toString('base64')
|
||||
const cacheKey = this.buildCacheKey(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime
|
||||
)
|
||||
this.voiceCache.set(cacheKey, data)
|
||||
this.pcmCache.set(cacheKey, { ...decoded, codec: 'silk' })
|
||||
results[index] = { success: true, data }
|
||||
} catch (error) {
|
||||
results[index] = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Silk 解码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
const retried = await Promise.all(
|
||||
failed.map(({ reference }) =>
|
||||
this.resolveVoice(
|
||||
reference.sessionId,
|
||||
reference.localId,
|
||||
reference.createTime,
|
||||
reference.svrId
|
||||
)
|
||||
)
|
||||
)
|
||||
failed.forEach(({ index }, retryIndex) => {
|
||||
results[index] = retried[retryIndex]
|
||||
})
|
||||
return results as Array<{ success: boolean; data?: string; error?: string }>
|
||||
}
|
||||
|
||||
async resolvePcm(
|
||||
sessionId: string,
|
||||
localId: number,
|
||||
|
||||
+112
-17
@@ -250,6 +250,20 @@ type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||
type WcdbVoidOut = [unknown]
|
||||
type WcdbHandleOut = [number]
|
||||
|
||||
export interface Wcdb4VoiceDataRequest {
|
||||
sessionId: string
|
||||
createTime: number
|
||||
candidates: string[]
|
||||
localId?: number
|
||||
svrId?: string | number
|
||||
}
|
||||
|
||||
export interface Wcdb4VoiceDataResult {
|
||||
success: boolean
|
||||
hex?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
function isAsciiPath(value: string): boolean {
|
||||
@@ -409,6 +423,7 @@ export class Wcdb4Client {
|
||||
outHex: WcdbVoidOut
|
||||
) => number)
|
||||
| null = null
|
||||
private wcdbGetVoiceDataBatch: KoffiAsyncFunction | null = null
|
||||
private wcdbResolveImageHardlink:
|
||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -1953,32 +1968,104 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
const handle = this.ensureHandle()
|
||||
const outHex: WcdbVoidOut = [null]
|
||||
|
||||
try {
|
||||
const result = this.wcdbGetVoiceData(
|
||||
const fn = this.wcdbGetVoiceData as unknown as KoffiAsyncFunction
|
||||
return this.createTrackedNativeCall<Wcdb4VoiceDataResult>((resolve, reject) => {
|
||||
const outHex: WcdbVoidOut = [null]
|
||||
fn.async(
|
||||
handle,
|
||||
sessionId,
|
||||
createTime,
|
||||
localId,
|
||||
BigInt(svrId || 0),
|
||||
JSON.stringify(candidates),
|
||||
outHex
|
||||
outHex,
|
||||
(error: unknown, code: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = Number(code)
|
||||
if (result !== 0 || !outHex[0]) {
|
||||
resolve({ success: false, error: `获取语音数据失败: ${result}` })
|
||||
return
|
||||
}
|
||||
const hex = this.decodeHexPtr(outHex[0])
|
||||
resolve(
|
||||
hex === null
|
||||
? { success: false, error: '解析语音数据失败' }
|
||||
: { success: true, hex, error: '' }
|
||||
)
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outHex[0])
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (result !== 0 || !outHex[0]) {
|
||||
return { success: false, error: `获取语音数据失败: ${result}` }
|
||||
}
|
||||
|
||||
const hex = this.decodeHexPtr(outHex[0])
|
||||
if (hex === null) {
|
||||
return { success: false, error: '解析语音数据失败' }
|
||||
}
|
||||
|
||||
return { success: true, hex, error: '' }
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outHex[0])
|
||||
async getVoiceDataBatch(requests: Wcdb4VoiceDataRequest[]): Promise<Wcdb4VoiceDataResult[]> {
|
||||
if (!requests.length) return []
|
||||
if (!this.wcdbGetVoiceDataBatch) {
|
||||
return this.getVoiceDataIndividually(requests)
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetVoiceDataBatch,
|
||||
JSON.stringify(
|
||||
requests.map((request, index) => ({
|
||||
session_id: request.sessionId,
|
||||
create_time: request.createTime,
|
||||
local_id: request.localId || 0,
|
||||
svr_id: String(request.svrId || 0),
|
||||
candidates: request.candidates,
|
||||
index
|
||||
}))
|
||||
)
|
||||
)
|
||||
const byIndex = new Map(
|
||||
(Array.isArray(rows) ? rows : []).map((row) => [Number(row.index), row])
|
||||
)
|
||||
return requests.map((_, index) => {
|
||||
const row = byIndex.get(index)
|
||||
const rawHex = row?.hex ?? row?.data
|
||||
const rawSuccess = row?.success ?? row?.Success
|
||||
const hex = typeof rawHex === 'string' ? rawHex : undefined
|
||||
const success = (rawSuccess === true || Number(rawSuccess) === 1) && Boolean(hex)
|
||||
return {
|
||||
success,
|
||||
hex: success ? hex : undefined,
|
||||
error:
|
||||
typeof row?.error === 'string' && row.error
|
||||
? row.error
|
||||
: success
|
||||
? ''
|
||||
: '获取语音数据失败'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] batch voice lookup failed, using single-item fallback:', error)
|
||||
return this.getVoiceDataIndividually(requests)
|
||||
}
|
||||
}
|
||||
|
||||
private async getVoiceDataIndividually(
|
||||
requests: Wcdb4VoiceDataRequest[]
|
||||
): Promise<Wcdb4VoiceDataResult[]> {
|
||||
const results: Wcdb4VoiceDataResult[] = []
|
||||
for (const request of requests) {
|
||||
results.push(
|
||||
await this.getVoiceData(
|
||||
request.sessionId,
|
||||
request.createTime,
|
||||
request.candidates,
|
||||
request.localId,
|
||||
request.svrId
|
||||
)
|
||||
)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private decodeHexPtr(ptr: unknown): string | null {
|
||||
@@ -2279,6 +2366,14 @@ export class Wcdb4Client {
|
||||
this.wcdbGetVoiceData = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetVoiceDataBatch = lib.func(
|
||||
'int32 wcdb_get_voice_data_batch(int64 handle, const char* requestsJson, _Out_ void** outJson)'
|
||||
)
|
||||
} catch {
|
||||
this.wcdbGetVoiceDataBatch = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbResolveImageHardlink = lib.func(
|
||||
'int32 wcdb_resolve_image_hardlink(int64 handle, const char* md5, const char* accountDir, _Out_ void** outJson)'
|
||||
|
||||
@@ -43,6 +43,7 @@ const state = vi.hoisted(() => ({
|
||||
}
|
||||
>,
|
||||
voiceLookups: [] as number[],
|
||||
voiceBatches: [] as number[][],
|
||||
videoLookups: [] as {
|
||||
createTime?: number
|
||||
byteLength?: number
|
||||
@@ -137,6 +138,13 @@ vi.mock('../../src/main/voice-service', () => ({
|
||||
}
|
||||
: { success: false, error: '本地未找到语音数据' }
|
||||
}
|
||||
|
||||
async resolveVoices(
|
||||
references: Array<{ localId: number }>
|
||||
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
|
||||
state.voiceBatches.push(references.map((reference) => reference.localId))
|
||||
return Promise.all(references.map((reference) => this.resolveVoice('', reference.localId)))
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/image-decrypt-service', () => ({
|
||||
@@ -264,6 +272,7 @@ describe('media export flow', () => {
|
||||
state.groupSnapshotReads = []
|
||||
state.groupSnapshots = {}
|
||||
state.voiceLookups = []
|
||||
state.voiceBatches = []
|
||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||
mkdirSync(fileMonth, { recursive: true })
|
||||
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
||||
@@ -391,7 +400,10 @@ describe('media export flow', () => {
|
||||
send: (_channel: string, payload: (typeof progress)[number]) => progress.push(payload)
|
||||
}
|
||||
}
|
||||
const recognize = vi.fn(async () => ({ success: true as const, transcript: '固定转写文本' }))
|
||||
const recognize = vi.fn(async () => {
|
||||
expect(state.voiceLookups).toEqual([])
|
||||
return { success: true as const, transcript: '固定转写文本' }
|
||||
})
|
||||
|
||||
const result = await runExport(
|
||||
{
|
||||
@@ -423,6 +435,88 @@ describe('media export flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses compatible transcript results and waits for one coalesced knowledge update per chat', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-cache-a',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 1,
|
||||
createTime: 1_785_549_600,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
}),
|
||||
message({
|
||||
id: 'voice-cache-b',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 2,
|
||||
createTime: 1_785_549_601,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
})
|
||||
]
|
||||
const recognize = vi.fn(async (reference: { localId: number }) => ({
|
||||
success: true as const,
|
||||
transcript: `缓存文字-${reference.localId}`,
|
||||
cached: true
|
||||
}))
|
||||
const publishTranscript = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await runExport(
|
||||
{
|
||||
jobId: 'voice-cache-coalesced-index',
|
||||
targets: [target()],
|
||||
format: 'html',
|
||||
outputName: 'voice-cache-coalesced-index',
|
||||
kinds: ['voice'],
|
||||
includeMedia: true,
|
||||
includeVoiceTranscripts: true
|
||||
},
|
||||
{ isDestroyed: () => true, webContents: { send: vi.fn() } } as never,
|
||||
{ recognize, publishTranscript }
|
||||
)
|
||||
|
||||
expect(result.success, result.error).toBe(true)
|
||||
expect(recognize).toHaveBeenCalledTimes(2)
|
||||
expect(recognize).toHaveBeenNthCalledWith(1, expect.objectContaining({ localId: 1 }), {
|
||||
publishTranscriptUpdate: false
|
||||
})
|
||||
expect(publishTranscript).toHaveBeenCalledOnce()
|
||||
expect(publishTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'fixture-session', localId: 2 }),
|
||||
'缓存文字-2',
|
||||
true
|
||||
)
|
||||
expect(state.voiceBatches).toEqual([[1, 2]])
|
||||
expect(readArchive(result.outputPath!).messages.map((item) => item.voiceTranscript)).toEqual([
|
||||
'缓存文字-1',
|
||||
'缓存文字-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('clears stale missing errors when an incremental merge restores playable voice data', async () => {
|
||||
const { mergeHtmlArchiveMessages } = await import('../../src/main/export-service')
|
||||
const previous = message({
|
||||
id: 'voice-incremental',
|
||||
type: '语音',
|
||||
voiceDataUrl: 'voices/existing.wav',
|
||||
voiceTranscript: '已有转写',
|
||||
voiceTranscriptError: '旧转写错误'
|
||||
})
|
||||
const current = message({
|
||||
id: 'voice-incremental',
|
||||
type: '语音',
|
||||
exportMediaError: '语音文件缺失:获取语音数据失败'
|
||||
})
|
||||
|
||||
const [merged] = mergeHtmlArchiveMessages([previous], [current])
|
||||
|
||||
expect(merged.voiceDataUrl).toBe('voices/existing.wav')
|
||||
expect(merged.voiceTranscript).toBe('已有转写')
|
||||
expect(merged.exportMediaError).toBeUndefined()
|
||||
expect(merged.voiceTranscriptError).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the customized file name as the HTML archive title', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||
|
||||
@@ -30,8 +30,15 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { assessMigration, executeMigration } from '../../src/main/app-data-migration'
|
||||
import { getUserDataRoots } from '../../src/main/app-data-paths'
|
||||
import {
|
||||
assessMigration,
|
||||
executeMigration,
|
||||
migrateLegacyVoiceTranscripts,
|
||||
runFirstLaunchMigration
|
||||
} from '../../src/main/app-data-migration'
|
||||
import { getUserDataRoots, type UserDataRoots } from '../../src/main/app-data-paths'
|
||||
import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository'
|
||||
import type { TranscriptRecord } from '../../src/main/voice-pipeline/types'
|
||||
|
||||
let root = ''
|
||||
|
||||
@@ -43,7 +50,7 @@ afterEach(() => {
|
||||
fs.removeSync(root)
|
||||
})
|
||||
|
||||
function roots() {
|
||||
function roots(): UserDataRoots {
|
||||
return getUserDataRoots(path.join(root, 'Application Support'))
|
||||
}
|
||||
|
||||
@@ -72,6 +79,16 @@ describe('TraceMemo app data migration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes a legacy voice transcript cache as user-owned data', () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'))
|
||||
expect(assessMigration(fixture)).toMatchObject({
|
||||
shouldPrompt: true,
|
||||
reason: 'legacy-assets-detected',
|
||||
sourceRoot: fixture.legacy
|
||||
})
|
||||
})
|
||||
|
||||
it('detects legacy settings but never proposes overwriting valid TraceMemo data', () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
|
||||
@@ -180,4 +197,113 @@ describe('TraceMemo app data migration', () => {
|
||||
)
|
||||
expect(fs.readFileSync(path.join(fixture.legacy, 'settings.json'), 'utf8')).toContain('legacy')
|
||||
})
|
||||
|
||||
it('supplements legacy voice transcripts into an existing TraceMemo cache', async () => {
|
||||
const fixture = roots()
|
||||
const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')
|
||||
const currentPath = path.join(fixture.current, 'cache', 'voice-transcripts.sqlite')
|
||||
const record: TranscriptRecord = {
|
||||
accountId: 'account-a',
|
||||
messageIdentity: 'message-a',
|
||||
audioHash: 'audio-a',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '已经转写过的文字',
|
||||
durationMs: 800,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const legacy = new SqliteTranscriptRepository(legacyPath)
|
||||
legacy.save(record)
|
||||
legacy.close()
|
||||
|
||||
expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('migrated')
|
||||
expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('skipped')
|
||||
const current = new SqliteTranscriptRepository(currentPath)
|
||||
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('已经转写过的文字')
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('backfills voice transcripts after the original migration was already completed', async () => {
|
||||
const fixture = roots()
|
||||
const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')
|
||||
const legacy = new SqliteTranscriptRepository(legacyPath)
|
||||
legacy.save({
|
||||
accountId: 'account-a',
|
||||
messageIdentity: 'message-a',
|
||||
audioHash: 'audio-a',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '补迁文字',
|
||||
durationMs: 800,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
legacy.close()
|
||||
fs.ensureDirSync(fixture.current)
|
||||
fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), {
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
sourceRoot: fixture.legacy,
|
||||
updatedAt: '2026-08-11T00:00:00.000Z',
|
||||
items: { 'settings.json': 'migrated' },
|
||||
secretFailures: []
|
||||
})
|
||||
|
||||
const result = await runFirstLaunchMigration(fixture)
|
||||
|
||||
expect(result.action).toBe('migrated')
|
||||
expect(result.execution?.state.items['cache/voice-transcripts.sqlite']).toBe('migrated')
|
||||
expect(result.execution?.state.status).toBe('completed')
|
||||
const current = new SqliteTranscriptRepository(
|
||||
path.join(fixture.current, 'cache', 'voice-transcripts.sqlite')
|
||||
)
|
||||
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('补迁文字')
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('does not retry a voice transcript backfill after it was marked failed', async () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'settings.json'), '{}')
|
||||
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite')
|
||||
fs.ensureDirSync(fixture.current)
|
||||
fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), {
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
sourceRoot: fixture.legacy,
|
||||
updatedAt: '2026-08-11T00:00:00.000Z',
|
||||
items: { 'cache/voice-transcripts.sqlite': 'failed' },
|
||||
secretFailures: []
|
||||
})
|
||||
|
||||
const result = await runFirstLaunchMigration(fixture)
|
||||
|
||||
expect(result.action).toBe('none')
|
||||
expect(result.execution).toBeUndefined()
|
||||
expect(fs.existsSync(path.join(fixture.current, 'cache', 'voice-transcripts.sqlite'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a failed voice transcript migration as a non-blocking downgrade', async () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'settings.json'), '{}')
|
||||
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite')
|
||||
|
||||
const result = await executeMigration(fixture.legacy, fixture.current, {
|
||||
decryptLegacySecrets: async () => ({ databaseKeys: {}, failures: [] }),
|
||||
agentRoots: () => ({
|
||||
legacy: path.join(root, 'agent-legacy'),
|
||||
current: path.join(root, 'agent-current')
|
||||
}),
|
||||
now: () => new Date('2026-08-11T00:00:00.000Z')
|
||||
})
|
||||
|
||||
expect(result.state.status).toBe('completed')
|
||||
expect(result.state.items['cache/voice-transcripts.sqlite']).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('export media', () => {
|
||||
expect(html).toContain('aria-expanded="')
|
||||
expect(html).toContain('setExpandedTimelineYear')
|
||||
expect(html).toContain('data-kind="media"')
|
||||
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
|
||||
expect(html).toContain('placeholder="搜索发送者、消息内容或媒体文件名(不含后缀)…"')
|
||||
expect(html).toContain('font-size: 16px;')
|
||||
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
|
||||
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
|
||||
@@ -156,6 +156,61 @@ describe('export media', () => {
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('matches exported image and video filenames exactly without their extension', () => {
|
||||
const html = renderExportPage('媒体文件名搜索')
|
||||
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||
const imageFileName = 'image_0123456789abcdef.jpg'
|
||||
const videoFileName = 'video_fedcba9876543210.mp4'
|
||||
const messages: Message[] = [
|
||||
{
|
||||
...messageForArchive('image-name', 'fixture', '媒体文件名搜索', '', 1),
|
||||
type: '图片',
|
||||
exportMediaType: 'image',
|
||||
exportMediaUrl: `media/${imageFileName}`,
|
||||
contentData: { type: 'image' }
|
||||
},
|
||||
{
|
||||
...messageForArchive('video-name', 'fixture', '媒体文件名搜索', '', 2),
|
||||
type: '视频',
|
||||
exportMediaType: 'video',
|
||||
exportMediaUrl: `media/${videoFileName}`,
|
||||
contentData: { type: 'video' }
|
||||
}
|
||||
]
|
||||
Object.assign(dom.window, {
|
||||
__WECHAT_EXPORT__: {
|
||||
version: 1,
|
||||
sourceId: 'fixture',
|
||||
name: '媒体文件名搜索',
|
||||
messages
|
||||
}
|
||||
})
|
||||
|
||||
dom.window.eval(inlineScriptOf(html))
|
||||
const search = dom.window.document.querySelector('#query') as HTMLInputElement
|
||||
|
||||
search.value = 'IMAGE_0123456789ABCDEF'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelectorAll('img.media-image')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelectorAll('video.media-image')).toHaveLength(0)
|
||||
|
||||
search.value = '0123456789abcdef'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(0)
|
||||
|
||||
search.value = 'video_fedcba9876543210'
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
|
||||
expect(dom.window.document.querySelectorAll('img.media-image')).toHaveLength(0)
|
||||
expect(dom.window.document.querySelectorAll('video.media-image')).toHaveLength(1)
|
||||
|
||||
search.value = videoFileName
|
||||
search.dispatchEvent(new dom.window.Event('input'))
|
||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(0)
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('filters a v2 merged archive by conversation before search and month counts', () => {
|
||||
const html = renderExportPage('合并档案')
|
||||
const dom = new JSDOM(html, { runScripts: 'outside-only' })
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { PcmAudioProcessor } from '../../src/main/voice-pipeline/audio-processor'
|
||||
import { VoicePipeline } from '../../src/main/voice-pipeline/voice-pipeline'
|
||||
import { voiceMessageIdentity } from '../../src/main/voice-pipeline/voice-message-identity'
|
||||
import { VoiceTaskScheduler } from '../../src/main/voice-pipeline/task-scheduler'
|
||||
import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository'
|
||||
import type { TranscriptRecord } from '../../src/main/voice-pipeline/types'
|
||||
@@ -114,9 +116,13 @@ describe('voice task scheduling', () => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
)
|
||||
const background = scheduler.schedule('background', async () => {
|
||||
order.push('background')
|
||||
}, { priority: 'background' })
|
||||
const background = scheduler.schedule(
|
||||
'background',
|
||||
async () => {
|
||||
order.push('background')
|
||||
},
|
||||
{ priority: 'background' }
|
||||
)
|
||||
const interactive = scheduler.schedule('interactive', async () => {
|
||||
order.push('interactive')
|
||||
})
|
||||
@@ -134,7 +140,9 @@ describe('voice task scheduling', () => {
|
||||
'background',
|
||||
async (signal) => {
|
||||
order.push('background:start')
|
||||
await new Promise<void>((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
||||
await new Promise<void>((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true })
|
||||
)
|
||||
order.push('background:aborted')
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
},
|
||||
@@ -201,4 +209,155 @@ describe('transcript repository', () => {
|
||||
})
|
||||
repository.close()
|
||||
})
|
||||
|
||||
it('merges legacy transcripts idempotently without overwriting current records', () => {
|
||||
const legacyPath = join(root, 'legacy-transcripts.sqlite')
|
||||
const currentPath = join(root, 'current-transcripts.sqlite')
|
||||
const legacy = new SqliteTranscriptRepository(legacyPath)
|
||||
const current = new SqliteTranscriptRepository(currentPath)
|
||||
const base: TranscriptRecord = {
|
||||
accountId: 'account-a',
|
||||
messageIdentity: 'message-1',
|
||||
audioHash: 'audio-1',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '旧缓存文字',
|
||||
durationMs: 1200,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
legacy.save(base)
|
||||
legacy.save({ ...base, messageIdentity: 'message-2', audioHash: 'audio-2' })
|
||||
current.save({ ...base, transcript: '当前缓存文字', updatedAt: 2 })
|
||||
legacy.close()
|
||||
|
||||
expect(current.mergeFrom(legacyPath)).toBe(1)
|
||||
expect(current.mergeFrom(legacyPath)).toBe(0)
|
||||
expect(
|
||||
current.find({
|
||||
accountId: base.accountId,
|
||||
messageIdentity: base.messageIdentity,
|
||||
audioHash: base.audioHash,
|
||||
processorVersion: base.processorVersion,
|
||||
recognizerId: base.recognizerId,
|
||||
modelVersion: base.modelVersion,
|
||||
modelFingerprint: base.modelFingerprint
|
||||
})?.transcript
|
||||
).toBe('当前缓存文字')
|
||||
expect(current.findLatest('account-a', 'message-2')?.transcript).toBe('旧缓存文字')
|
||||
current.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('voice pipeline cache lookup', () => {
|
||||
it('returns a compatible message cache before reading or decoding audio', async () => {
|
||||
const repository = new SqliteTranscriptRepository(join(root, 'fast-cache.sqlite'))
|
||||
const reference = { sessionId: 'session', localId: 1, createTime: 2 }
|
||||
repository.save({
|
||||
accountId: 'account-a',
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
audioHash: 'audio-1',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '快速命中',
|
||||
durationMs: 900,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
const resolve = vi.fn()
|
||||
const decode = vi.fn()
|
||||
const process = vi.fn()
|
||||
const recognize = vi.fn()
|
||||
const pipeline = new VoicePipeline(
|
||||
{ resolve },
|
||||
{ decode } as never,
|
||||
{ version: 'processor-v1', process },
|
||||
{
|
||||
metadata: {
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a'
|
||||
},
|
||||
recognize,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
repository
|
||||
)
|
||||
|
||||
await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({
|
||||
transcript: '快速命中',
|
||||
cached: true
|
||||
})
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
expect(decode).not.toHaveBeenCalled()
|
||||
expect(process).not.toHaveBeenCalled()
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
repository.close()
|
||||
})
|
||||
|
||||
it('falls through when the cached processor version is incompatible', async () => {
|
||||
const repository = new SqliteTranscriptRepository(join(root, 'version-cache.sqlite'))
|
||||
const reference = { sessionId: 'session', localId: 1, createTime: 2 }
|
||||
repository.save({
|
||||
accountId: 'account-a',
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
audioHash: 'old-audio',
|
||||
processorVersion: 'processor-v0',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '不兼容缓存',
|
||||
durationMs: 900,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
const resolve = vi.fn().mockResolvedValue({
|
||||
data: Buffer.from('encoded'),
|
||||
codec: 'silk',
|
||||
sourceHash: 'new-audio'
|
||||
})
|
||||
const pipeline = new VoicePipeline(
|
||||
{ resolve },
|
||||
{
|
||||
decode: vi.fn().mockResolvedValue({
|
||||
pcm: Buffer.from([1, 0]),
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
sourceHash: 'new-audio'
|
||||
})
|
||||
} as never,
|
||||
{
|
||||
version: 'processor-v1',
|
||||
process: vi.fn().mockReturnValue({
|
||||
samples: new Float32Array([0.1]),
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
sourceHash: 'new-audio',
|
||||
processorVersion: 'processor-v1',
|
||||
durationMs: 1
|
||||
})
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a'
|
||||
},
|
||||
recognize: vi.fn().mockResolvedValue({ text: '新转写' }),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
repository
|
||||
)
|
||||
|
||||
await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({
|
||||
transcript: '新转写',
|
||||
cached: false
|
||||
})
|
||||
expect(resolve).toHaveBeenCalledOnce()
|
||||
repository.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,9 @@ describe('VoiceRecognitionUseCase transcript updates', () => {
|
||||
|
||||
it('does not publish a transcript after the account generation changes mid-recognition', async () => {
|
||||
const useCase = createUseCase()
|
||||
let finish: ((value: { transcript: string; durationMs: number; cached: boolean }) => void) | undefined
|
||||
let finish:
|
||||
| ((value: { transcript: string; durationMs: number; cached: boolean }) => void)
|
||||
| undefined
|
||||
const state = useCase as unknown as {
|
||||
accountGeneration: number
|
||||
pipeline: { run: ReturnType<typeof vi.fn> }
|
||||
@@ -98,4 +100,24 @@ describe('VoiceRecognitionUseCase transcript updates', () => {
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
await useCase.dispose()
|
||||
})
|
||||
|
||||
it('publishes an explicit cached transcript for a coalesced export index refresh', async () => {
|
||||
const useCase = createUseCase()
|
||||
const listener = vi.fn().mockResolvedValue(undefined)
|
||||
useCase.onTranscriptUpdate(listener)
|
||||
const reference = { sessionId: 'fixture-contact', localId: 11, createTime: 1_785_895_202 }
|
||||
|
||||
await useCase.publishTranscript(reference, '缓存导出文字', true)
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountIdentity: 'account-a',
|
||||
reference,
|
||||
state: 'transcribed',
|
||||
transcript: '缓存导出文字',
|
||||
cached: true
|
||||
})
|
||||
)
|
||||
await useCase.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { VoiceService } from '../../src/main/voice-service'
|
||||
|
||||
describe('VoiceService batch lookup', () => {
|
||||
it('retries failed batch entries with the compatible single-item lookup', async () => {
|
||||
const getVoiceDataBatch = vi.fn().mockResolvedValue([
|
||||
{ success: false, error: '获取语音数据失败' },
|
||||
{ success: false, error: '获取语音数据失败' }
|
||||
])
|
||||
const service = new VoiceService({ getVoiceDataBatch } as never)
|
||||
const resolveVoice = vi
|
||||
.spyOn(service, 'resolveVoice')
|
||||
.mockImplementation(async (_sessionId, localId) => ({
|
||||
success: true,
|
||||
data: `voice-${localId}`
|
||||
}))
|
||||
|
||||
const result = await service.resolveVoices([
|
||||
{ sessionId: 'session', localId: 10, createTime: 100, svrId: '1000' },
|
||||
{ sessionId: 'session', localId: 11, createTime: 101, svrId: '1001' }
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{ success: true, data: 'voice-10' },
|
||||
{ success: true, data: 'voice-11' }
|
||||
])
|
||||
expect(resolveVoice).toHaveBeenCalledTimes(2)
|
||||
expect(resolveVoice).toHaveBeenNthCalledWith(1, 'session', 10, 100, '1000')
|
||||
expect(resolveVoice).toHaveBeenNthCalledWith(2, 'session', 11, 101, '1001')
|
||||
})
|
||||
})
|
||||
@@ -37,4 +37,59 @@ describe('Wcdb4Client shutdown', () => {
|
||||
expect(shutdown).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores async batch voice results to request order', async () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
setPrivate(client, 'wcdbGetVoiceDataBatch', vi.fn())
|
||||
setPrivate(
|
||||
client,
|
||||
'callJsonAsync',
|
||||
vi.fn().mockResolvedValue([
|
||||
{ index: 1, success: false, error: 'missing' },
|
||||
{ index: 0, Success: true, hex: 'aabb' }
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.getVoiceDataBatch([
|
||||
{ sessionId: 'a', createTime: 1, localId: 10, candidates: ['a'] },
|
||||
{ sessionId: 'b', createTime: 2, localId: 20, candidates: ['b'] }
|
||||
])
|
||||
).resolves.toEqual([
|
||||
{ success: true, hex: 'aabb', error: '' },
|
||||
{ success: false, hex: undefined, error: 'missing' }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the async Koffi path for a single voice lookup and releases the result', async () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const nativePointer = { value: 'aabb' }
|
||||
const freeString = vi.fn()
|
||||
const nativeFunction = {
|
||||
async: vi.fn((...args: unknown[]) => {
|
||||
const outHex = args.at(-2) as [unknown]
|
||||
const callback = args.at(-1) as (error: unknown, code: number) => void
|
||||
outHex[0] = nativePointer
|
||||
queueMicrotask(() => callback(null, 0))
|
||||
})
|
||||
}
|
||||
setPrivate(client, 'wcdbGetVoiceData', nativeFunction)
|
||||
setPrivate(client, 'wcdbFreeString', freeString)
|
||||
setPrivate(client, 'handle', 1)
|
||||
setPrivate(client, 'closing', false)
|
||||
setPrivate(client, 'nativeCallsInFlight', new Set())
|
||||
setPrivate(
|
||||
client,
|
||||
'decodeHexPtr',
|
||||
vi.fn(() => 'aabb')
|
||||
)
|
||||
|
||||
await expect(client.getVoiceData('session', 100, ['session'], 10, 20)).resolves.toEqual({
|
||||
success: true,
|
||||
hex: 'aabb',
|
||||
error: ''
|
||||
})
|
||||
expect(nativeFunction.async).toHaveBeenCalledOnce()
|
||||
expect(freeString).toHaveBeenCalledWith(nativePointer)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user