) => {
+ event.stopPropagation()
+ window.dispatchEvent(new Event('wxe:open-voice-recognition-settings'))
+ }, [])
+
const formatDuration = (seconds: number | undefined): string => {
if (!seconds || !isFinite(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
@@ -151,33 +200,54 @@ export function VoicePlayer({
return `${mins}:${secs.toString().padStart(2, '0')}`
}
- if (loading) {
- return (
-
- ▶
- 加载中...
-
- )
- }
-
- if (error && !audioUrl) {
- return (
-
- ▶
- 当前版本暂不支持播放
-
- )
- }
-
return (
-
-
{isPlaying ? '⏸' : '▶'}
-
-
-
-
+
+
+
{isPlaying ? '⏸' : '▶'}
+ {loading ? (
+
加载中...
+ ) : error && !audioUrl ? (
+
当前语音暂不支持播放
+ ) : (
+ <>
+
+
+
+
+
+
{formatDuration(audioDuration)}
+ >
+ )}
+ {transcribing ? (
+
+ ) : (
+
+ )}
-
{formatDuration(audioDuration)}
+ {modelStatus && modelStatus.state !== 'ready' && (
+
event.stopPropagation()}>
+
+ {modelStatus.state === 'downloading'
+ ? `离线模型正在下载 ${Math.round(modelStatus.progress * 100)}%`
+ : modelStatus.state === 'unsupported'
+ ? '当前系统暂不支持语音转文字'
+ : '请先在设置中准备离线语音模型'}
+
+
+
+ )}
+ {transcribing &&
正在识别...
}
+ {transcript &&
{transcript}
}
+ {transcriptError &&
{transcriptError}
}
)
}
diff --git a/src/renderer/src/components/export/ExportTaskCenter.tsx b/src/renderer/src/components/export/ExportTaskCenter.tsx
index f8e21c0..e1461f9 100644
--- a/src/renderer/src/components/export/ExportTaskCenter.tsx
+++ b/src/renderer/src/components/export/ExportTaskCenter.tsx
@@ -16,6 +16,22 @@ export function ExportTaskCenter({
onToggle,
onCancel
}: ExportTaskCenterProps): React.ReactElement {
+ const [copiedJobId, setCopiedJobId] = React.useState('')
+
+ const copyTaskLog = async (task: ExportTaskRecord): Promise
=> {
+ const log = [
+ 'WechatExplorer 导出任务日志',
+ `时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
+ `会话:${task.contactName}`,
+ `格式:${task.format.toUpperCase()}`,
+ `状态:${task.progress.phase}`,
+ `进度:${task.progress.percent ?? 0}%`,
+ `错误:${task.progress.error || '未记录具体错误'}`
+ ].join('\n')
+ await navigator.clipboard.writeText(log)
+ setCopiedJobId(task.jobId)
+ }
+
return (
<>
)}
+ {task.status === 'failed' && (
+
+ )}
))
)}
diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx
index 9486647..25b6179 100644
--- a/src/renderer/src/components/export/ExportWorkspace.tsx
+++ b/src/renderer/src/components/export/ExportWorkspace.tsx
@@ -16,6 +16,7 @@ import type {
GroupMemberName
} from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
+import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
export function ExportWorkspace({
contacts,
@@ -38,6 +39,8 @@ export function ExportWorkspace({
const [nameMode, setNameMode] = useState
('remark')
const [groupMembers, setGroupMembers] = useState([])
const [includeMedia, setIncludeMedia] = useState(true)
+ const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
+ const [voiceModelStatus, setVoiceModelStatus] = useState(null)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
@@ -149,6 +152,17 @@ export function ExportWorkspace({
return () => window.clearTimeout(timer)
}, [activeContact])
+ React.useEffect(() => {
+ let active = true
+ void window.api
+ .getVoiceModelStatus()
+ .then((next) => active && setVoiceModelStatus(next))
+ .catch(() => undefined)
+ return () => {
+ active = false
+ }
+ }, [])
+
const toggleKind = (value: string): void => {
setSelectedKinds((current) => {
const next = new Set(current)
@@ -208,6 +222,12 @@ export function ExportWorkspace({
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
+ includeVoiceTranscripts:
+ includeVoiceTranscripts &&
+ includeMedia &&
+ format === 'html' &&
+ selectedKinds.has('voice') &&
+ voiceModelStatus?.state === 'ready',
preferOriginal,
fallbackThumbnail,
keepMissing,
@@ -484,6 +504,20 @@ export function ExportWorkspace({
/>
媒体缺失时保留占位说明
+
资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。
@@ -492,6 +526,10 @@ export function ExportWorkspace({
图片解密:已就绪
视频资源:可用
语音资源:可用
+
+ 语音转文字:
+ {voiceModelStatus?.state === 'ready' ? '已就绪' : '请先在设置中准备模型'}
+
表情资源:按需解析
文件附件:按需复制
diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx
index 78cdc1b..965aab5 100644
--- a/src/renderer/src/features/settings/SettingsWorkspace.tsx
+++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx
@@ -11,6 +11,7 @@ import { AdvancedPage } from './pages/AdvancedPage'
import { CacheCleanupPage } from './pages/CacheCleanupPage'
import { AppearancePage } from './pages/AppearancePage'
import { AboutPage } from './pages/AboutPage'
+import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
import type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -88,6 +89,8 @@ export function SettingsWorkspace({
return
case 'ai-model':
return
+ case 'voice-recognition':
+ return
case 'recall-protection':
return
case 'advanced':
diff --git a/src/renderer/src/features/settings/model/settingsNavigation.ts b/src/renderer/src/features/settings/model/settingsNavigation.ts
index 13256cb..359da7e 100644
--- a/src/renderer/src/features/settings/model/settingsNavigation.ts
+++ b/src/renderer/src/features/settings/model/settingsNavigation.ts
@@ -17,7 +17,8 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
{
label: '智能能力',
items: [
- { id: 'ai-model', label: 'AI 模型' },
+ { id: 'voice-recognition', label: '语音转文字' },
+ { id: 'ai-model', label: 'AI 模型' }
]
},
{
diff --git a/src/renderer/src/features/settings/model/types.ts b/src/renderer/src/features/settings/model/types.ts
index 82aee06..0fbda56 100644
--- a/src/renderer/src/features/settings/model/types.ts
+++ b/src/renderer/src/features/settings/model/types.ts
@@ -2,6 +2,7 @@ export type SettingsCategoryId =
| 'account-database'
| 'database-key'
| 'image-key'
+ | 'voice-recognition'
| 'ai-model'
| 'recall-protection'
| 'local-api'
diff --git a/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx b/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx
new file mode 100644
index 0000000..3e48ba4
--- /dev/null
+++ b/src/renderer/src/features/settings/pages/VoiceRecognitionPage.tsx
@@ -0,0 +1,246 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import type { VoiceModelStatus } from '../../../../../shared/voice-recognition'
+
+const SENSEVOICE_URL = 'https://github.com/FunAudioLLM/SenseVoice'
+const SHERPA_URL = 'https://github.com/k2-fsa/sherpa-onnx'
+
+const STATUS_LABELS: Record = {
+ missing: '未下载',
+ downloading: '下载中',
+ ready: '已就绪',
+ invalid: '需要修复',
+ error: '下载失败',
+ unsupported: '暂不支持'
+}
+
+function formatBytes(value: number): string {
+ if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`
+ return `${(value / 1024 / 1024).toFixed(1)} MB`
+}
+
+function formatPlatform(status: VoiceModelStatus): string {
+ if (status.platform === 'win32')
+ return `Windows ${status.architecture === 'x64' ? '64 位' : status.architecture}`
+ if (status.platform === 'darwin') {
+ return status.architecture === 'arm64' ? 'macOS Apple 芯片' : 'macOS Intel'
+ }
+ return `${status.platform} ${status.architecture}`
+}
+
+export function VoiceRecognitionPage({
+ onNotice
+}: {
+ onNotice: (message: string) => void
+}): React.ReactElement {
+ const [status, setStatus] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const refresh = useCallback(async (): Promise => {
+ setStatus(await window.api.getVoiceModelStatus())
+ }, [])
+
+ useEffect(() => {
+ let active = true
+ void window.api.getVoiceModelStatus().then((next) => active && setStatus(next))
+ const unsubscribe = window.api.onVoiceModelProgress((next) => {
+ if (active) setStatus(next)
+ })
+ return () => {
+ active = false
+ unsubscribe()
+ }
+ }, [])
+
+ const badgeClass = useMemo(() => {
+ if (status?.state === 'ready') return 'ready'
+ if (status?.state === 'downloading') return 'checking'
+ if (status?.state === 'invalid' || status?.state === 'error') return 'error'
+ if (status?.state === 'unsupported') return 'unavailable'
+ return 'warning'
+ }, [status?.state])
+
+ const download = async (): Promise => {
+ setBusy(true)
+ setStatus((current) =>
+ current ? { ...current, state: 'downloading', downloadedBytes: 0, progress: 0 } : current
+ )
+ try {
+ const result = await window.api.downloadVoiceModel()
+ setStatus(result.status)
+ onNotice(result.success ? '离线语音模型已准备好' : result.error || '模型下载失败')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const cancelDownload = async (): Promise => {
+ await window.api.cancelVoiceModelDownload()
+ onNotice('正在取消模型下载')
+ }
+
+ const removeModel = async (): Promise => {
+ if (!window.confirm('删除离线语音模型?以后使用语音转文字时需要重新下载。')) return
+ setBusy(true)
+ try {
+ setStatus(await window.api.removeVoiceModel())
+ onNotice('离线语音模型已删除')
+ } catch (error) {
+ onNotice(error instanceof Error ? `模型删除失败:${error.message}` : '模型删除失败')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const openDirectory = async (): Promise => {
+ const result = await window.api.openVoiceModelDirectory()
+ if (!result.success) onNotice(result.error || '无法打开模型目录')
+ }
+
+ return (
+
+
+
+
+
+
+
+
语音内容仅在本机处理
+
识别过程不会上传语音、聊天内容或转写结果,也不需要配置在线 AI 服务。
+
+
+
+
运行环境
+
+
+
+
- 当前平台
+ - {status ? formatPlatform(status) : '检测中...'}
+
+
+
- 离线识别
+ -
+ {status?.supported ? '支持' : '暂不支持'}
+
+
+
+
- 识别引擎
+ - sherpa-onnx · SenseVoice
+
+
+
+
+
离线模型
+
+
+
SenseVoice Small INT8
+
+ {status?.state === 'downloading'
+ ? `正在下载 ${Math.round(status.progress * 100)}%`
+ : status
+ ? STATUS_LABELS[status.state]
+ : '正在检测'}
+
+
+ {status
+ ? `版本 ${status.version} · ${formatBytes(status.totalBytes)}`
+ : '读取模型状态...'}
+
+ {status?.error &&
{status.error}
}
+
+ 上游模型:
+
+ SenseVoice(MIT)
+
+ ·
+ 推理运行库:
+
+ sherpa-onnx(Apache-2.0)
+
+
+
+
+ {status?.state === 'downloading' ? (
+
+ ) : status?.state === 'ready' ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {status?.state === 'downloading' && (
+
+
+ {Math.round(status.progress * 100)}%
+
+ {formatBytes(status.downloadedBytes)} / {formatBytes(status.totalBytes)}
+
+
+
+
+ )}
+
+
+
平台支持
+
+
+ Windows
+ 支持 Windows 10/11 64 位
+
+
+ macOS
+ 支持 Intel 与 Apple 芯片
+
+
+
+ 模型由两个平台共用;应用会随安装包提供对应系统的本地识别运行库。
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/styles/export.scss b/src/renderer/src/styles/export.scss
index 42fc60e..9653472 100644
--- a/src/renderer/src/styles/export.scss
+++ b/src/renderer/src/styles/export.scss
@@ -764,6 +764,10 @@
color: var(--wxex-text-primary);
}
+ .export-task-error {
+ color: var(--wxex-danger, #b42318);
+ }
+
button {
border: 1px solid var(--wxex-border);
border-radius: 5px;
diff --git a/src/renderer/src/styles/rich-message.scss b/src/renderer/src/styles/rich-message.scss
index a45eb3e..c0f3930 100644
--- a/src/renderer/src/styles/rich-message.scss
+++ b/src/renderer/src/styles/rich-message.scss
@@ -1,9 +1,66 @@
/* Voice Player */
+.voice-player {
+ display: grid;
+ min-width: 190px;
+ gap: 7px;
+}
+
.voice-message {
cursor: pointer;
user-select: none;
}
+.voice-text-action,
+.voice-model-panel button {
+ border: 0;
+ background: transparent;
+ color: var(--wxex-brand);
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.voice-text-action {
+ margin-left: auto;
+ padding: 2px 0 2px 8px;
+}
+
+.voice-model-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 6px 10px;
+ padding-top: 7px;
+ border-top: 1px solid var(--wxex-border);
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+ line-height: 18px;
+ white-space: normal;
+}
+
+.voice-model-panel progress {
+ width: 100%;
+ height: 5px;
+}
+
+.voice-transcript,
+.voice-transcript-status,
+.voice-transcript-error {
+ padding-top: 7px;
+ border-top: 1px solid var(--wxex-border);
+ font-size: 13px;
+ line-height: 20px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.voice-transcript-status {
+ color: var(--wxex-text-muted);
+}
+
+.voice-transcript-error {
+ color: var(--wxex-danger, #c63c3c);
+}
+
.voice-loading {
opacity: 0.6;
}
diff --git a/src/renderer/src/styles/settings-preferences.scss b/src/renderer/src/styles/settings-preferences.scss
index 5d6575c..bbbe959 100644
--- a/src/renderer/src/styles/settings-preferences.scss
+++ b/src/renderer/src/styles/settings-preferences.scss
@@ -118,6 +118,185 @@
}
}
+.voice-runtime-card dl {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 20px 32px;
+ margin: 0;
+
+ div {
+ min-width: 0;
+ }
+
+ dt {
+ color: var(--wxex-text-muted);
+ font-size: 11px;
+ }
+
+ dd {
+ margin: 5px 0 0;
+ color: var(--wxex-text-primary);
+ font-size: 13px;
+ }
+}
+
+.voice-header-status {
+ display: grid;
+ min-width: 132px;
+ justify-items: end;
+ gap: 7px;
+
+ progress {
+ width: 112px;
+ height: 6px;
+ accent-color: var(--wxex-brand);
+ }
+}
+
+.voice-status-success {
+ color: var(--wxex-success, #2e8b68) !important;
+}
+
+.voice-status-error,
+.voice-model-error {
+ color: var(--wxex-danger, #c85a5a) !important;
+}
+
+.voice-model-license {
+ margin: 9px 0 0;
+ color: var(--wxex-text-muted);
+ font-size: 11px;
+ line-height: 17px;
+
+ a {
+ color: var(--wxex-brand);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.voice-model-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 18px 28px;
+ align-items: center;
+}
+
+.voice-model-summary {
+ min-width: 0;
+
+ strong,
+ small {
+ display: block;
+ }
+
+ strong {
+ color: var(--wxex-text-primary);
+ font-size: 18px;
+ }
+
+ small {
+ margin-top: 5px;
+ color: var(--wxex-text-secondary);
+ font-size: 11px;
+ }
+}
+
+.voice-model-error {
+ margin: 8px 0 0;
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.voice-model-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+
+ > button {
+ min-height: 34px;
+ padding: 7px 12px;
+ border: 1px solid var(--wxex-border);
+ border-radius: var(--wxex-radius-sm);
+ background: var(--wxex-bg-elevated);
+ color: var(--wxex-text-primary);
+ cursor: pointer;
+ font: 12px/18px var(--wxex-font);
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+ }
+ }
+
+ > .settings-primary-button {
+ border-color: var(--wxex-brand);
+ background: var(--wxex-brand);
+ color: #fff;
+ }
+}
+
+.voice-model-progress {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 8px;
+
+ > div {
+ display: flex;
+ justify-content: space-between;
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+ }
+
+ progress {
+ width: 100%;
+ height: 7px;
+ accent-color: var(--wxex-brand);
+ }
+}
+
+.voice-platform-list {
+ padding-top: 8px;
+ padding-bottom: 8px;
+
+ > div {
+ display: grid;
+ grid-template-columns: 120px minmax(0, 1fr);
+ gap: 16px;
+ padding: 14px 0;
+ border-bottom: 1px solid var(--wxex-border);
+
+ &:last-child {
+ border-bottom: 0;
+ }
+ }
+
+ strong {
+ color: var(--wxex-text-primary);
+ font-size: 13px;
+ }
+
+ span {
+ color: var(--wxex-text-secondary);
+ font-size: 12px;
+ }
+}
+
+@media (max-width: 760px) {
+ .voice-runtime-card dl,
+ .voice-model-card {
+ grid-template-columns: 1fr;
+ }
+
+ .voice-model-actions {
+ justify-content: flex-start;
+ }
+}
+
.settings-option-card {
padding: 12px;
}
@@ -308,7 +487,6 @@
--wxex-nav-width: 68px;
--wxex-shell-content-top: 8px;
}
-
}
.boot-splash.is-quiet {
diff --git a/src/shared/export.ts b/src/shared/export.ts
index 45a4947..fdd8d27 100644
--- a/src/shared/export.ts
+++ b/src/shared/export.ts
@@ -24,6 +24,7 @@ export interface ExportRequest {
endTime?: number
kinds: ExportMessageKind[]
includeMedia: boolean
+ includeVoiceTranscripts?: boolean
preferOriginal?: boolean
fallbackThumbnail?: boolean
keepMissing?: boolean
diff --git a/src/shared/types.ts b/src/shared/types.ts
index be57cd6..2d0e5e1 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -23,6 +23,8 @@ export interface Message {
contentData?: ParsedContent
voiceDataUrl?: string
voiceDuration?: number
+ voiceTranscript?: string
+ voiceTranscriptError?: string
localId?: number
serverId?: string
createTime?: number
diff --git a/src/shared/voice-recognition.ts b/src/shared/voice-recognition.ts
new file mode 100644
index 0000000..fa105a6
--- /dev/null
+++ b/src/shared/voice-recognition.ts
@@ -0,0 +1,58 @@
+export const DEFAULT_VOICE_MODEL_ID = 'sensevoice-small-int8'
+
+export interface VoiceMessageReference {
+ sessionId: string
+ localId: number
+ createTime: number
+ svrId?: string | number
+}
+
+export type VoiceModelState =
+ | 'missing'
+ | 'downloading'
+ | 'ready'
+ | 'invalid'
+ | 'error'
+ | 'unsupported'
+
+export interface VoiceModelStatus {
+ modelId: string
+ version: string
+ state: VoiceModelState
+ downloadedBytes: number
+ totalBytes: number
+ progress: number
+ platform: NodeJS.Platform
+ architecture: string
+ supported: boolean
+ error?: string
+}
+
+export interface VoiceModelDownloadResult {
+ success: boolean
+ status: VoiceModelStatus
+ error?: string
+}
+
+export type VoiceRecognitionErrorCode =
+ | 'NOT_CONNECTED'
+ | 'MODEL_NOT_READY'
+ | 'VOICE_NOT_FOUND'
+ | 'DECODE_FAILED'
+ | 'EMPTY_AUDIO'
+ | 'CANCELLED'
+ | 'TIMEOUT'
+ | 'WORKER_FAILED'
+ | 'RECOGNITION_FAILED'
+
+export interface VoiceRecognitionResult {
+ success: boolean
+ transcript?: string
+ language?: string
+ durationMs?: number
+ cached?: boolean
+ error?: string
+ code?: VoiceRecognitionErrorCode
+}
+
+export interface VoiceModelProgressEvent extends VoiceModelStatus {}
diff --git a/tests/component/export-task-center.test.tsx b/tests/component/export-task-center.test.tsx
new file mode 100644
index 0000000..7be92e0
--- /dev/null
+++ b/tests/component/export-task-center.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ExportTaskCenter } from '../../src/renderer/src/components/export/ExportTaskCenter'
+
+describe('export task center', () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+
+ beforeEach(() => {
+ writeText.mockClear()
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText }
+ })
+ })
+
+ it('shows the failure reason and copies a diagnostic log', async () => {
+ render(
+
+ )
+
+ expect(screen.getByText('EPERM: operation not permitted, copyfile')).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
+
+ expect(writeText).toHaveBeenCalledOnce()
+ expect(writeText.mock.calls[0][0]).toContain('会话:脱敏会话')
+ expect(writeText.mock.calls[0][0]).toContain('EPERM: operation not permitted, copyfile')
+ expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
+ })
+})
diff --git a/tests/component/export-voice-transcript.test.tsx b/tests/component/export-voice-transcript.test.tsx
new file mode 100644
index 0000000..bc33e49
--- /dev/null
+++ b/tests/component/export-voice-transcript.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ExportWorkspace } from '../../src/renderer/src/components/export/ExportWorkspace'
+import type { VoiceModelStatus } from '../../src/shared/voice-recognition'
+
+const readyStatus: VoiceModelStatus = {
+ modelId: 'sensevoice-small-int8',
+ version: '2024-07-17',
+ state: 'ready',
+ downloadedBytes: 239_549_735,
+ totalBytes: 239_549_735,
+ progress: 1,
+ platform: 'win32',
+ architecture: 'x64',
+ supported: true
+}
+
+describe('export voice transcripts', () => {
+ beforeEach(() => {
+ window.api = {
+ getVoiceModelStatus: vi.fn().mockResolvedValue(readyStatus),
+ onExportProgress: vi.fn(() => vi.fn())
+ } as typeof window.api
+ })
+
+ it('enables voice transcription by default for a ready HTML voice export', async () => {
+ const onStartExport = vi.fn().mockResolvedValue({
+ success: true,
+ messageCount: 1,
+ outputPath: 'C:\\fixture\\index.html'
+ })
+ render(
+
+ )
+
+ await userEvent.click(screen.getAllByRole('button', { name: /HTML/ })[0])
+ await userEvent.click(screen.getByRole('checkbox', { name: '语音' }))
+
+ const transcriptOption = await screen.findByRole('checkbox', {
+ name: '语音转文字,显示在语音条下方'
+ })
+ expect(transcriptOption).toBeEnabled()
+ expect(transcriptOption).toBeChecked()
+
+ await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
+ await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
+ expect(onStartExport.mock.calls[0][0]).toMatchObject({
+ format: 'html',
+ includeVoiceTranscripts: true,
+ kinds: expect.arrayContaining(['voice'])
+ })
+ })
+})
diff --git a/tests/component/voice-player.test.tsx b/tests/component/voice-player.test.tsx
index abc4863..3db4270 100644
--- a/tests/component/voice-player.test.tsx
+++ b/tests/component/voice-player.test.tsx
@@ -29,7 +29,28 @@ describe('VoicePlayer', () => {
getVoiceData: vi.fn().mockResolvedValue({
success: true,
data: 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
- })
+ }),
+ getVoiceModelStatus: vi.fn().mockResolvedValue({
+ modelId: 'sensevoice-small-int8',
+ version: 'fixture',
+ state: 'ready',
+ downloadedBytes: 10,
+ totalBytes: 10,
+ progress: 1,
+ platform: 'win32',
+ architecture: 'x64',
+ supported: true
+ }),
+ recognizeVoice: vi.fn().mockResolvedValue({
+ success: true,
+ transcript: '这是固定的测试转写',
+ language: 'zh',
+ cached: false
+ }),
+ downloadVoiceModel: vi.fn(),
+ cancelVoiceModelDownload: vi.fn(),
+ cancelVoiceRecognition: vi.fn(),
+ onVoiceModelProgress: vi.fn(() => vi.fn())
} as typeof window.api
})
@@ -44,4 +65,42 @@ describe('VoicePlayer', () => {
expect(container.querySelector('.voice-icon')).toHaveClass('playing')
expect(screen.queryByText('当前版本暂不支持播放')).not.toBeInTheDocument()
})
+
+ it('recognizes one voice message and renders the transcript', async () => {
+ render()
+ await userEvent.click(screen.getByRole('button', { name: '转文字' }))
+
+ await waitFor(() =>
+ expect(window.api.recognizeVoice).toHaveBeenCalledWith({
+ sessionId: 'filehelper',
+ localId: 11,
+ createTime: 1785553200,
+ svrId: undefined
+ })
+ )
+ expect(await screen.findByText('这是固定的测试转写')).toBeInTheDocument()
+ })
+
+ it('opens centralized settings when recognition assets are missing', async () => {
+ vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue({
+ modelId: 'sensevoice-small-int8',
+ version: 'fixture',
+ state: 'missing',
+ downloadedBytes: 0,
+ totalBytes: 239_549_735,
+ progress: 0,
+ platform: 'win32',
+ architecture: 'x64',
+ supported: true
+ })
+ render()
+ const openSettings = vi.fn()
+ window.addEventListener('wxe:open-voice-recognition-settings', openSettings, { once: true })
+ await userEvent.click(screen.getByRole('button', { name: '转文字' }))
+
+ expect(await screen.findByText(/请先在设置中准备离线语音模型/)).toBeInTheDocument()
+ await userEvent.click(screen.getByRole('button', { name: '前往设置' }))
+ expect(openSettings).toHaveBeenCalledOnce()
+ expect(window.api.recognizeVoice).not.toHaveBeenCalled()
+ })
})
diff --git a/tests/component/voice-recognition-settings.test.tsx b/tests/component/voice-recognition-settings.test.tsx
new file mode 100644
index 0000000..7b00783
--- /dev/null
+++ b/tests/component/voice-recognition-settings.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { VoiceRecognitionPage } from '../../src/renderer/src/features/settings/pages/VoiceRecognitionPage'
+import type { VoiceModelStatus } from '../../src/shared/voice-recognition'
+
+const readyStatus: VoiceModelStatus = {
+ modelId: 'sensevoice-small-int8',
+ version: '2024-07-17',
+ state: 'ready',
+ downloadedBytes: 239_549_735,
+ totalBytes: 239_549_735,
+ progress: 1,
+ platform: 'win32',
+ architecture: 'x64',
+ supported: true
+}
+
+describe('voice recognition settings', () => {
+ beforeEach(() => {
+ window.api = {
+ getVoiceModelStatus: vi.fn().mockResolvedValue(readyStatus),
+ downloadVoiceModel: vi.fn(),
+ cancelVoiceModelDownload: vi.fn(),
+ removeVoiceModel: vi.fn().mockResolvedValue({ ...readyStatus, state: 'missing' }),
+ openVoiceModelDirectory: vi.fn().mockResolvedValue({ success: true }),
+ onVoiceModelProgress: vi.fn(() => vi.fn())
+ } as typeof window.api
+ })
+
+ it('shows Windows runtime and installed model actions', async () => {
+ render()
+ expect(await screen.findByText('Windows 64 位')).toBeInTheDocument()
+ expect(screen.queryByText('额外环境')).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'SenseVoice(MIT)' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'sherpa-onnx(Apache-2.0)' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '打开模型目录' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: '删除模型' })).toBeInTheDocument()
+ })
+
+ it('downloads the model from the centralized settings page', async () => {
+ const missing = { ...readyStatus, state: 'missing' as const, progress: 0, downloadedBytes: 0 }
+ vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue(missing)
+ vi.mocked(window.api.downloadVoiceModel).mockResolvedValue({
+ success: true,
+ status: readyStatus
+ })
+ const notice = vi.fn()
+ render()
+ await userEvent.click(await screen.findByRole('button', { name: '下载模型' }))
+
+ await waitFor(() => expect(window.api.downloadVoiceModel).toHaveBeenCalledOnce())
+ expect(notice).toHaveBeenCalledWith('离线语音模型已准备好')
+ })
+
+ it('shows download percentage in the header and model card', async () => {
+ const missing = {
+ ...readyStatus,
+ state: 'missing' as const,
+ progress: 0,
+ downloadedBytes: 0
+ }
+ let progressListener: ((status: VoiceModelStatus) => void) | undefined
+ let finishDownload:
+ | ((value: { success: boolean; status: VoiceModelStatus }) => void)
+ | undefined
+ vi.mocked(window.api.getVoiceModelStatus).mockResolvedValue(missing)
+ vi.mocked(window.api.onVoiceModelProgress).mockImplementation((listener) => {
+ progressListener = listener
+ return vi.fn()
+ })
+ vi.mocked(window.api.downloadVoiceModel).mockReturnValue(
+ new Promise((resolve) => {
+ finishDownload = resolve
+ })
+ )
+ render()
+ await userEvent.click(await screen.findByRole('button', { name: '下载模型' }))
+ progressListener?.({
+ ...missing,
+ state: 'downloading',
+ downloadedBytes: Math.round(missing.totalBytes * 0.42),
+ progress: 0.42
+ })
+
+ expect(await screen.findByText('下载中 42%')).toBeInTheDocument()
+ expect(screen.getByText('正在下载 42%')).toBeInTheDocument()
+ finishDownload?.({ success: true, status: readyStatus })
+ })
+})
diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs
index 5ab03dc..f483a1b 100644
--- a/tests/e2e/support/electron-main.cjs
+++ b/tests/e2e/support/electron-main.cjs
@@ -223,6 +223,24 @@ handle('db:getImage', (md5, datName, sessionId, options) =>
}
)
handle('db:getVoiceData', () => ({ success: true, data: voiceData }))
+const voiceModelStatus = (state = 'missing') => ({
+ modelId: 'sensevoice-small-int8',
+ version: '2024-07-17',
+ state,
+ downloadedBytes: state === 'ready' ? 239549735 : 0,
+ totalBytes: 239549735,
+ progress: state === 'ready' ? 1 : 0,
+ platform: process.platform,
+ architecture: process.arch,
+ supported: process.platform === 'win32' || process.platform === 'darwin'
+})
+handle('voice:getModelStatus', () => voiceModelStatus())
+handle('voice:downloadModel', () => ({ success: true, status: voiceModelStatus('ready') }))
+handle('voice:cancelModelDownload', () => ({ success: true }))
+handle('voice:removeModel', () => voiceModelStatus())
+handle('voice:openModelDirectory', () => ({ success: true }))
+handle('voice:recognize', () => ({ success: true, transcript: '固定脱敏转写文本', language: 'zh' }))
+handle('voice:cancelRecognition', () => ({ success: true }))
handle('db:getSticker', (url) =>
String(url || '').includes('403')
? {
diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts
index b6a5297..6c79ec1 100644
--- a/tests/integration/export-media-flow.test.ts
+++ b/tests/integration/export-media-flow.test.ts
@@ -1,6 +1,7 @@
import { dirname, join } from 'path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
+import fsExtra from 'fs-extra'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
@@ -207,11 +208,20 @@ describe('media export flow', () => {
outputName: 'fixture',
kinds: ['voice', 'image', 'video', 'file'],
includeMedia: true,
+ includeVoiceTranscripts: true,
preferOriginal: true,
fallbackThumbnail: true,
keepMissing: true
},
- win as never
+ win as never,
+ {
+ recognize: vi.fn().mockResolvedValue({
+ success: true,
+ transcript: '这是导出的固定语音转写',
+ language: 'zh',
+ cached: true
+ })
+ } as never
)
expect(result.success).toBe(true)
@@ -231,6 +241,7 @@ describe('media export flow', () => {
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
expect(html).toContain('')
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
+ expect(voice.voiceTranscript).toBe('这是导出的固定语音转写')
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/)
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
@@ -302,6 +313,52 @@ describe('media export flow', () => {
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
})
+ it('reuses existing video and file assets when Windows rejects an overwrite', async () => {
+ const { runExport } = await import('../../src/main/export-service')
+ const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
+ const request = {
+ userMd5: 'fixture-user',
+ name: '媒体复用会话',
+ format: 'html' as const,
+ outputName: 'reused-media-fixture',
+ kinds: ['video', 'file'] as const,
+ includeMedia: true,
+ keepMissing: true
+ }
+
+ const first = await runExport(
+ { ...request, jobId: 'media-reuse-first', kinds: [...request.kinds] },
+ win as never
+ )
+ expect(first.success).toBe(true)
+
+ const originalCopyFile = fsExtra.copyFile.bind(fsExtra)
+ const copyFile = vi
+ .spyOn(fsExtra, 'copyFile')
+ .mockRejectedValueOnce(
+ Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
+ )
+ .mockRejectedValueOnce(
+ Object.assign(new Error('EPERM: operation not permitted, copyfile'), { code: 'EPERM' })
+ )
+ .mockImplementation(originalCopyFile)
+
+ const second = await runExport(
+ { ...request, jobId: 'media-reuse-second', kinds: [...request.kinds] },
+ win as never
+ )
+ copyFile.mockRestore()
+
+ expect(second.success).toBe(true)
+ const archive = readArchive(second.outputPath!)
+ expect(archive.messages.find((item) => item.id === 'video')?.exportMediaUrl).toMatch(
+ /^media\/video_/
+ )
+ expect(archive.messages.find((item) => item.id === 'file')?.exportMediaUrl).toMatch(
+ /^media\/file_/
+ )
+ })
+
it('refuses to merge a different conversation into an existing named archive', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
diff --git a/tests/integration/preload-contract.test.ts b/tests/integration/preload-contract.test.ts
index 62d7b83..f8011de 100644
--- a/tests/integration/preload-contract.test.ts
+++ b/tests/integration/preload-contract.test.ts
@@ -48,6 +48,23 @@ describe('preload IPC contract', () => {
'fixture-session',
{ force: true, priority: 0 }
)
+
+ const voiceReference = {
+ sessionId: 'filehelper',
+ localId: 11,
+ createTime: 1785553200,
+ svrId: 'server-11'
+ }
+ await api.recognizeVoice(voiceReference)
+ expect(invoke).toHaveBeenLastCalledWith('voice:recognize', voiceReference)
+ await api.cancelVoiceRecognition(voiceReference)
+ expect(invoke).toHaveBeenLastCalledWith('voice:cancelRecognition', voiceReference)
+ await api.downloadVoiceModel()
+ expect(invoke).toHaveBeenLastCalledWith('voice:downloadModel')
+ await api.removeVoiceModel()
+ expect(invoke).toHaveBeenLastCalledWith('voice:removeModel')
+ await api.openVoiceModelDirectory()
+ expect(invoke).toHaveBeenLastCalledWith('voice:openModelDirectory')
})
it('preserves key API return values without exposing ipcRenderer', async () => {
diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts
index 5bda0ab..0285a0e 100644
--- a/tests/unit/export-media.test.ts
+++ b/tests/unit/export-media.test.ts
@@ -102,11 +102,55 @@ describe('export media', () => {
expect(html).toContain('class="file-attachment" href="')
expect(html).toContain('class="quote-reference"')
expect(html).toContain('message.exportMediaError')
- expect(html).toContain('.audio-wrap { width: 260px; max-width: 100%; min-width: 0; }')
+ expect(html).toContain('.audio-wrap { width: 380px; max-width: 100%; min-width: 0; }')
expect(html).toContain('.audio { display: block; width: 100%; max-width: 100%; height: 38px; }')
+ expect(html).toContain('class="voice-transcript"')
+ expect(html).toContain('message.voiceTranscript')
+ expect(html).toContain('class="message-stack"')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
})
+ it('renders a voice transcript below audio inside the same exported bubble', () => {
+ const html = renderExportPage('语音转写档案')
+ const dom = new JSDOM(html, { runScripts: 'outside-only' })
+ Object.assign(dom.window, {
+ __WECHAT_EXPORT__: {
+ version: 1,
+ sourceId: 'fixture',
+ name: '语音转写档案',
+ exportedAt: '2026-08-04T00:00:00.000Z',
+ messages: [
+ {
+ id: 'voice-transcript',
+ from: 'user',
+ type: '语音',
+ datetime: '2026-08-04 14:26',
+ content: '[语音消息]',
+ isSender: true,
+ voiceDataUrl: 'voices/fixture.wav',
+ voiceTranscript: '试一下',
+ createTime: 1_785_549_600
+ }
+ ]
+ }
+ })
+ dom.window.eval(inlineScriptOf(html))
+
+ const stack = dom.window.document.querySelector('.message-stack')!
+ const bubble = stack.querySelector('.bubble')!
+ const transcript = stack.querySelector('.voice-transcript')!
+ expect(bubble.querySelector('audio')?.getAttribute('src')).toBe('voices/fixture.wav')
+ expect(transcript.textContent).toBe('试一下')
+ expect(bubble.contains(transcript)).toBe(true)
+ expect(stack.children).toHaveLength(1)
+ expect(
+ bubble.querySelector('audio')!.compareDocumentPosition(transcript) &
+ dom.window.Node.DOCUMENT_POSITION_FOLLOWING
+ ).toBeTruthy()
+ expect(bubble.textContent).not.toContain('[语音消息]')
+ dom.window.close()
+ })
+
it('renders explicit and keyboard-accessible lightbox closing controls', () => {
const html = renderExportPage('图片预览')
diff --git a/tests/unit/runtime-packaging.test.ts b/tests/unit/runtime-packaging.test.ts
index 37ee4ba..30e64a9 100644
--- a/tests/unit/runtime-packaging.test.ts
+++ b/tests/unit/runtime-packaging.test.ts
@@ -5,10 +5,11 @@ import { dirname, join, resolve } from 'path'
import { afterAll, describe, expect, it } from 'vitest'
const nodeRequire = createRequire(import.meta.url)
-const { validateFfmpegRuntime, validateSilkWasmRuntime } = nodeRequire(
+const { validateFfmpegRuntime, validateSherpaRuntime, validateSilkWasmRuntime } = nodeRequire(
'../../scripts/after-pack.cjs'
) as {
validateFfmpegRuntime: (runtimeResources: string, platform?: NodeJS.Platform) => void
+ validateSherpaRuntime: (runtimeResources: string, platform: NodeJS.Platform, arch: string) => void
validateSilkWasmRuntime: (runtimeResources: string) => void
}
const root = mkdtempSync(join(tmpdir(), 'wxe-runtime-package-'))
@@ -49,4 +50,31 @@ describe('production runtime packaging', () => {
const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
expect(config).toContain('node_modules/ffmpeg-static/**')
})
+
+ it('requires the matching Windows and macOS sherpa native runtime', () => {
+ const resources = join(root, 'sherpa-resources')
+ const unpacked = join(resources, 'app.asar.unpacked', 'node_modules')
+ const base = join(unpacked, 'sherpa-onnx-node')
+ mkdirSync(base, { recursive: true })
+ writeFileSync(join(base, 'package.json'), '{}')
+ writeFileSync(join(base, 'sherpa-onnx.js'), 'module.exports = {}')
+
+ expect(() => validateSherpaRuntime(resources, 'win32', 'x64')).toThrow(/win-x64/)
+ const windows = join(unpacked, 'sherpa-onnx-win-x64')
+ mkdirSync(windows, { recursive: true })
+ writeFileSync(join(windows, 'package.json'), '{}')
+ writeFileSync(join(windows, 'sherpa-onnx.node'), 'fixture')
+ expect(() => validateSherpaRuntime(resources, 'win32', 'x64')).not.toThrow()
+
+ expect(() => validateSherpaRuntime(resources, 'darwin', 'arm64')).toThrow(/darwin-arm64/)
+ const mac = join(unpacked, 'sherpa-onnx-darwin-arm64')
+ mkdirSync(mac, { recursive: true })
+ writeFileSync(join(mac, 'package.json'), '{}')
+ writeFileSync(join(mac, 'sherpa-onnx.node'), 'fixture')
+ expect(() => validateSherpaRuntime(resources, 'darwin', 'arm64')).not.toThrow()
+
+ const config = readFileSync(resolve(__dirname, '../../electron-builder.yml'), 'utf8')
+ expect(config).toContain('node_modules/sherpa-onnx-node/**')
+ expect(config).toContain('node_modules/sherpa-onnx-*/**')
+ })
})
diff --git a/tests/unit/voice-pipeline.test.ts b/tests/unit/voice-pipeline.test.ts
new file mode 100644
index 0000000..bb1be31
--- /dev/null
+++ b/tests/unit/voice-pipeline.test.ts
@@ -0,0 +1,141 @@
+import { mkdtempSync, rmSync } from 'fs'
+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 { 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'
+import { SENSEVOICE_MODEL_FILES } from '../../src/main/voice-pipeline/model-manager'
+
+const root = mkdtempSync(join(tmpdir(), 'wxe-voice-pipeline-'))
+
+describe('SenseVoice model manifest', () => {
+ it('uses the Git LFS content digest rather than the Hugging Face xet hash', () => {
+ expect(SENSEVOICE_MODEL_FILES[0]).toMatchObject({
+ name: 'model.int8.onnx',
+ size: 239_233_841,
+ sha256: 'c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51'
+ })
+ expect(SENSEVOICE_MODEL_FILES[0].sha256).not.toBe(
+ 'c45ba1d6a13329c4aca1dc118cabdc643ca09cb8192abb979648dd68f9917323'
+ )
+ })
+})
+
+function pcm16(samples: number[]): Buffer {
+ const buffer = Buffer.alloc(samples.length * 2)
+ samples.forEach((sample, index) => buffer.writeInt16LE(sample, index * 2))
+ return buffer
+}
+
+describe('PCM audio processing', () => {
+ it('really resamples 24 kHz PCM to 16 kHz and trims outer silence', () => {
+ const silence = Array.from({ length: 2400 }, () => 0)
+ const tone = Array.from({ length: 24000 }, (_, index) =>
+ Math.round(Math.sin((index / 24000) * Math.PI * 440 * 2) * 20000)
+ )
+ const processor = new PcmAudioProcessor({ silencePaddingMs: 0 })
+ const output = processor.process({
+ pcm: pcm16([...silence, ...tone, ...silence]),
+ sampleRate: 24000,
+ channels: 1,
+ sourceHash: 'fixture-audio'
+ })
+
+ expect(output.sampleRate).toBe(16000)
+ expect(output.samples.length).toBeGreaterThan(15900)
+ expect(output.samples.length).toBeLessThanOrEqual(16000)
+ expect(output.durationMs).toBeGreaterThanOrEqual(990)
+ expect(Math.max(...output.samples)).toBeLessThanOrEqual(0.92)
+ })
+
+ it('returns an empty signal when the source only contains silence', () => {
+ const output = new PcmAudioProcessor().process({
+ pcm: pcm16(Array.from({ length: 2400 }, () => 0)),
+ sampleRate: 24000,
+ channels: 1,
+ sourceHash: 'silence'
+ })
+ expect(output.samples).toHaveLength(0)
+ })
+})
+
+describe('voice task scheduling', () => {
+ it('runs recognition tasks serially', async () => {
+ const scheduler = new VoiceTaskScheduler()
+ const order: string[] = []
+ let releaseFirst: (() => void) | undefined
+ const first = scheduler.schedule('first', async () => {
+ order.push('first:start')
+ await new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ order.push('first:end')
+ return 1
+ })
+ const second = scheduler.schedule('second', async () => {
+ order.push('second')
+ return 2
+ })
+
+ await vi.waitFor(() => expect(order).toEqual(['first:start']))
+ releaseFirst?.()
+ await expect(Promise.all([first, second])).resolves.toEqual([1, 2])
+ expect(order).toEqual(['first:start', 'first:end', 'second'])
+ })
+
+ it('cancels a queued task without running it', async () => {
+ const scheduler = new VoiceTaskScheduler()
+ let releaseFirst: (() => void) | undefined
+ const first = scheduler.schedule(
+ 'first',
+ () =>
+ new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ )
+ const queued = scheduler.schedule('queued', async () => 'should-not-run')
+ expect(scheduler.cancel('queued')).toBe(true)
+ await expect(queued).rejects.toMatchObject({ name: 'AbortError' })
+ releaseFirst?.()
+ await first
+ })
+})
+
+describe('transcript repository', () => {
+ afterAll(() => rmSync(root, { recursive: true, force: true }))
+
+ it('keeps records isolated by account and model fingerprint', () => {
+ const repository = new SqliteTranscriptRepository(join(root, 'transcripts.sqlite'))
+ const record: TranscriptRecord = {
+ accountId: 'account-a',
+ messageIdentity: 'message-1',
+ audioHash: 'audio-1',
+ processorVersion: 'processor-v1',
+ recognizerId: 'sensevoice',
+ modelVersion: 'model-v1',
+ modelFingerprint: 'fingerprint-a',
+ transcript: '固定测试文本',
+ language: 'zh',
+ durationMs: 1200,
+ createdAt: 1,
+ updatedAt: 1
+ }
+ repository.save(record)
+
+ const key = {
+ accountId: record.accountId,
+ messageIdentity: record.messageIdentity,
+ audioHash: record.audioHash,
+ processorVersion: record.processorVersion,
+ recognizerId: record.recognizerId,
+ modelVersion: record.modelVersion,
+ modelFingerprint: record.modelFingerprint
+ }
+ expect(repository.find(key)).toMatchObject({ transcript: '固定测试文本' })
+ expect(repository.find({ ...key, accountId: 'account-b' })).toBeNull()
+ expect(repository.find({ ...key, modelFingerprint: 'fingerprint-b' })).toBeNull()
+ repository.close()
+ })
+})