From c8050f4a9aaf32fe67986547f96e70b06189c3c0 Mon Sep 17 00:00:00 2001
From: lofyer
Date: Tue, 11 Aug 2026 01:34:28 +0800
Subject: [PATCH] feat: expand local speech models
---
src/main/speech/speech-model-catalog.ts | 246 +++++++++++++++
src/main/speech/speech-model-manager.test.ts | 43 ++-
.../speech-transcription-service.test.ts | 31 ++
.../speech/speech-transcription-service.ts | 14 +
src/renderer/src/SettingsPanel.tsx | 4 +-
.../src/SpeechModelSettingsSection.test.tsx | 85 ++++-
.../src/SpeechModelSettingsSection.tsx | 295 ++++++++++++------
src/renderer/src/styles.css | 246 ++++++++++++++-
src/shared/speech-model-contracts.test.ts | 3 +
src/shared/speech-model-contracts.ts | 5 +-
10 files changed, 855 insertions(+), 117 deletions(-)
diff --git a/src/main/speech/speech-model-catalog.ts b/src/main/speech/speech-model-catalog.ts
index 40e0ebe..9c00a5f 100644
--- a/src/main/speech/speech-model-catalog.ts
+++ b/src/main/speech/speech-model-catalog.ts
@@ -18,6 +18,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
languages: ['中文', '粤语', '英语', '日语', '韩语'],
family: 'sensevoice',
quantization: 'int8',
+ quality: 'high',
+ speed: 'fast',
+ recommended: true,
repositoryUrl:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue',
@@ -67,6 +70,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
languages: ['中文', '英语', '多语言'],
family: 'whisper',
quantization: 'int8',
+ quality: 'basic',
+ speed: 'fast',
+ recommended: false,
repositoryUrl:
'https://modelscope.cn/models/pengzhendong/' +
'sherpa-onnx-whisper-tiny',
@@ -121,6 +127,246 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
}
}
]
+ },
+ {
+ id: 'paraformer-bilingual-zh-en-int8',
+ displayName: 'Paraformer 中英双语 INT8',
+ description:
+ '面向普通话与英语的快速离线识别,适合以中文为主并夹杂英文的本地听写。',
+ languages: ['中文', '英语'],
+ family: 'paraformer',
+ quantization: 'int8',
+ quality: 'high',
+ speed: 'fast',
+ recommended: true,
+ repositoryUrl:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-bilingual-zh-en',
+ license: {
+ name: 'MIT License',
+ notice:
+ '转换仓库声明 MIT License;模型源自 FunASR Paraformer,使用前请同时阅读仓库说明。',
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-bilingual-zh-en/blob/' +
+ '4b891f7b5c73d874e607797a4b0578fd4c35dd4b/README.md'
+ },
+ manualOnly: false,
+ files: [
+ {
+ name: 'model.int8.onnx',
+ role: 'model',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' +
+ '4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' +
+ 'model.int8.onnx',
+ size: 223_385_835,
+ sha256:
+ '9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7'
+ }
+ },
+ {
+ name: 'tokens.txt',
+ role: 'tokens',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-bilingual-zh-en/resolve/' +
+ '4b891f7b5c73d874e607797a4b0578fd4c35dd4b/' +
+ 'tokens.txt',
+ size: 75_756,
+ sha256:
+ '59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6'
+ }
+ }
+ ]
+ },
+ {
+ id: 'paraformer-trilingual-zh-yue-en-int8',
+ displayName: 'Paraformer 中粤英三语 INT8',
+ description:
+ '支持普通话、粤语和英语的离线识别,适合多语混合及粤语输入。',
+ languages: ['中文', '粤语', '英语'],
+ family: 'paraformer',
+ quantization: 'int8',
+ quality: 'high',
+ speed: 'balanced',
+ recommended: false,
+ repositoryUrl:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
+ license: {
+ name: 'Apache License 2.0',
+ notice:
+ '转换模型来自 ModelScope SeACo-Paraformer 中粤英模型;上游仓库声明 Apache License 2.0。',
+ url:
+ 'https://modelscope.cn/models/dengcunqin/' +
+ 'speech_seaco_paraformer_large_asr_nat-zh-cantonese-en-' +
+ '16k-common-vocab11666-pytorch'
+ },
+ manualOnly: false,
+ files: [
+ {
+ name: 'model.int8.onnx',
+ role: 'model',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' +
+ 'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' +
+ 'model.int8.onnx',
+ size: 244_684_152,
+ sha256:
+ 'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144'
+ }
+ },
+ {
+ name: 'tokens.txt',
+ role: 'tokens',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-paraformer-trilingual-zh-cantonese-en/' +
+ 'resolve/8d90151338178bb433354c9fb677bd3acb8023cd/' +
+ 'tokens.txt',
+ size: 118_931,
+ sha256:
+ '8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492'
+ }
+ }
+ ]
+ },
+ {
+ id: 'whisper-small-multilingual-int8',
+ displayName: 'Whisper Small(多语言)INT8',
+ description:
+ '多语言均衡模型,识别质量明显高于 Tiny,适合常规多语言听写。',
+ languages: ['中文', '英语', '多语言'],
+ family: 'whisper',
+ quantization: 'int8',
+ quality: 'balanced',
+ speed: 'balanced',
+ recommended: false,
+ repositoryUrl:
+ 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small',
+ license: {
+ name: 'MIT License',
+ notice:
+ 'Whisper 模型由 OpenAI 以 MIT License 发布;转换后的文件应同时遵守上游仓库随附说明。',
+ url: 'https://github.com/openai/whisper/blob/main/LICENSE'
+ },
+ manualOnly: false,
+ files: [
+ {
+ name: 'small-encoder.int8.onnx',
+ role: 'encoder',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-whisper-small/resolve/' +
+ '8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
+ 'small-encoder.int8.onnx',
+ size: 112_442_483,
+ sha256:
+ '4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9'
+ }
+ },
+ {
+ name: 'small-decoder.int8.onnx',
+ role: 'decoder',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-whisper-small/resolve/' +
+ '8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
+ 'small-decoder.int8.onnx',
+ size: 262_226_114,
+ sha256:
+ 'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee'
+ }
+ },
+ {
+ name: 'small-tokens.txt',
+ role: 'tokens',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-whisper-small/resolve/' +
+ '8f3c18b358db4d1f2fc1eae49d75cd20989e4309/' +
+ 'small-tokens.txt',
+ size: 816_730,
+ sha256:
+ 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
+ }
+ }
+ ]
+ },
+ {
+ id: 'whisper-medium-multilingual-int8',
+ displayName: 'Whisper Medium(多语言)INT8',
+ description:
+ '高质量多语言模型,适合更重视准确率且能够接受较慢 CPU 推理的场景。',
+ languages: ['中文', '英语', '多语言'],
+ family: 'whisper',
+ quantization: 'int8',
+ quality: 'high',
+ speed: 'slow',
+ recommended: false,
+ repositoryUrl:
+ 'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium',
+ license: {
+ name: 'MIT License',
+ notice:
+ 'Whisper 模型由 OpenAI 以 MIT License 发布;转换后的文件应同时遵守上游仓库随附说明。',
+ url: 'https://github.com/openai/whisper/blob/main/LICENSE'
+ },
+ manualOnly: false,
+ files: [
+ {
+ name: 'medium-encoder.int8.onnx',
+ role: 'encoder',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-whisper-medium/resolve/' +
+ '8c31d28503847560985df21f90e14f0c736e075e/' +
+ 'medium-encoder.int8.onnx',
+ size: 374_196_283,
+ sha256:
+ '1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e'
+ }
+ },
+ {
+ name: 'medium-decoder.int8.onnx',
+ role: 'decoder',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-whisper-medium/resolve/' +
+ '8c31d28503847560985df21f90e14f0c736e075e/' +
+ 'medium-decoder.int8.onnx',
+ size: 571_059_257,
+ sha256:
+ '595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747'
+ }
+ },
+ {
+ name: 'medium-tokens.txt',
+ role: 'tokens',
+ download: {
+ url:
+ 'https://huggingface.co/csukuangfj/' +
+ 'sherpa-onnx-whisper-medium/resolve/' +
+ '8c31d28503847560985df21f90e14f0c736e075e/' +
+ 'medium-tokens.txt',
+ size: 816_730,
+ sha256:
+ 'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126'
+ }
+ }
+ ]
}
])
diff --git a/src/main/speech/speech-model-manager.test.ts b/src/main/speech/speech-model-manager.test.ts
index 81c22ff..cb6d9a0 100644
--- a/src/main/speech/speech-model-manager.test.ts
+++ b/src/main/speech/speech-model-manager.test.ts
@@ -55,6 +55,9 @@ function downloadableCatalog(
languages: ['中文'],
family: 'whisper',
quantization: 'int8',
+ quality: 'balanced',
+ speed: 'balanced',
+ recommended: false,
repositoryUrl:
'https://modelscope.cn/models/example/download-test-model',
license: {
@@ -92,13 +95,25 @@ function downloadableCatalog(
}
describe('speech model catalog', () => {
- it('lists metadata only and accurately labels SenseVoice custom licensing', () => {
+ it('lists verified multilingual models with accurate licensing', () => {
const senseVoice = SPEECH_MODEL_CATALOG.find(
(entry) => entry.id === 'sensevoice-small-int8'
)
const whisper = SPEECH_MODEL_CATALOG.find(
(entry) => entry.id === 'whisper-tiny-multilingual'
)
+ const paraformerBilingual = SPEECH_MODEL_CATALOG.find(
+ (entry) => entry.id === 'paraformer-bilingual-zh-en-int8'
+ )
+ const paraformerTrilingual = SPEECH_MODEL_CATALOG.find(
+ (entry) => entry.id === 'paraformer-trilingual-zh-yue-en-int8'
+ )
+ const whisperSmall = SPEECH_MODEL_CATALOG.find(
+ (entry) => entry.id === 'whisper-small-multilingual-int8'
+ )
+ const whisperMedium = SPEECH_MODEL_CATALOG.find(
+ (entry) => entry.id === 'whisper-medium-multilingual-int8'
+ )
expect(senseVoice).toMatchObject({
manualOnly: false,
@@ -125,13 +140,35 @@ describe('speech model catalog', () => {
'tiny-decoder.int8.onnx',
'tiny-tokens.txt'
])
+ expect(paraformerBilingual).toMatchObject({
+ family: 'paraformer',
+ languages: ['中文', '英语'],
+ license: { name: 'MIT License' },
+ recommended: true
+ })
+ expect(paraformerTrilingual).toMatchObject({
+ family: 'paraformer',
+ languages: ['中文', '粤语', '英语'],
+ license: { name: 'Apache License 2.0' }
+ })
+ expect(whisperSmall).toMatchObject({
+ family: 'whisper',
+ quality: 'balanced',
+ speed: 'balanced'
+ })
+ expect(whisperMedium).toMatchObject({
+ family: 'whisper',
+ quality: 'high',
+ speed: 'slow'
+ })
+ expect(SPEECH_MODEL_CATALOG).toHaveLength(6)
for (const entry of SPEECH_MODEL_CATALOG) {
expect(entry.repositoryUrl).toMatch(
- /^https:\/\/modelscope\.cn\/models\//u
+ /^https:\/\/(?:modelscope\.cn\/models\/|huggingface\.co\/)/u
)
for (const file of entry.files) {
expect(file.download?.url).toMatch(
- /^https:\/\/modelscope\.cn\/models\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
+ /^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
)
}
}
diff --git a/src/main/speech/speech-transcription-service.test.ts b/src/main/speech/speech-transcription-service.test.ts
index c15ece8..fffb257 100644
--- a/src/main/speech/speech-transcription-service.test.ts
+++ b/src/main/speech/speech-transcription-service.test.ts
@@ -39,6 +39,28 @@ function whisperModel(): SelectedSpeechRuntimeModel {
}
}
+function paraformerModel(): SelectedSpeechRuntimeModel {
+ return {
+ id: 'paraformer-bilingual-zh-en-int8',
+ family: 'paraformer',
+ directory: 'C:\\models\\paraformer',
+ files: [
+ {
+ name: 'model.int8.onnx',
+ role: 'model',
+ size: 1,
+ sha256: 'a'.repeat(64)
+ },
+ {
+ name: 'tokens.txt',
+ role: 'tokens',
+ size: 1,
+ sha256: 'b'.repeat(64)
+ }
+ ]
+ }
+}
+
function input(): SpeechTranscriptionInput {
return {
requestId,
@@ -72,6 +94,15 @@ describe('SpeechTranscriptionService', () => {
).toBe('')
})
+ it('wires an offline Paraformer model to local inference', () => {
+ expect(
+ createSherpaRecognizerConfig(paraformerModel()).modelConfig
+ .paraformer
+ ).toEqual({
+ model: 'C:\\models\\paraformer\\model.int8.onnx'
+ })
+ })
+
it('requires an installed selected model and rejects oversized audio', async () => {
const service = new SpeechTranscriptionService(
{
diff --git a/src/main/speech/speech-transcription-service.ts b/src/main/speech/speech-transcription-service.ts
index e8984d8..8cb535c 100644
--- a/src/main/speech/speech-transcription-service.ts
+++ b/src/main/speech/speech-transcription-service.ts
@@ -29,6 +29,9 @@ type SherpaRecognizerConfig = {
language: string
useInverseTextNormalization: number
}
+ paraformer?: {
+ model: string
+ }
whisper?: {
encoder: string
decoder: string
@@ -124,6 +127,17 @@ export function createSherpaRecognizerConfig(
}
}
}
+ if (model.family === 'paraformer') {
+ return {
+ ...base,
+ modelConfig: {
+ ...base.modelConfig,
+ paraformer: {
+ model: requiredFile(model, 'model')
+ }
+ }
+ }
+ }
return {
...base,
modelConfig: {
diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx
index ea2570f..0f36540 100644
--- a/src/renderer/src/SettingsPanel.tsx
+++ b/src/renderer/src/SettingsPanel.tsx
@@ -2039,7 +2039,9 @@ export function SettingsPanel({
}}
/>
)}
- {modelType === 'speech' && }
+ {modelType === 'speech' && (
+
+ )}
>
)}
diff --git a/src/renderer/src/SpeechModelSettingsSection.test.tsx b/src/renderer/src/SpeechModelSettingsSection.test.tsx
index 4bb9c4e..61fb3cf 100644
--- a/src/renderer/src/SpeechModelSettingsSection.test.tsx
+++ b/src/renderer/src/SpeechModelSettingsSection.test.tsx
@@ -17,6 +17,9 @@ const entry = {
languages: ['中文', '粤语'],
family: 'sensevoice' as const,
quantization: 'int8' as const,
+ quality: 'high' as const,
+ speed: 'fast' as const,
+ recommended: true,
repositoryUrl: 'https://huggingface.co/example/model',
license: {
name: '模型仓库自定义许可',
@@ -81,6 +84,7 @@ describe('SpeechModelSettingsSection', () => {
]
}
const install = vi.fn(async () => installedSnapshot)
+ const onNotify = vi.fn()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
@@ -97,16 +101,22 @@ describe('SpeechModelSettingsSection', () => {
} as unknown as DesktopApi
})
- render()
+ render()
expect(await screen.findByText('SenseVoiceSmall INT8'))
.toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
+ expect(screen.getByText('推荐')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', {
+ name: '下载 SenseVoiceSmall INT8'
+ }))
await waitFor(() =>
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
)
- expect(await screen.findByText('SenseVoiceSmall INT8 已安装'))
- .toBeInTheDocument()
+ expect(onNotify).toHaveBeenCalledWith({
+ tone: 'success',
+ message: 'SenseVoiceSmall INT8 已安装',
+ dedupeKey: 'speech-model-sensevoice-small-int8'
+ })
})
it('offers a download button for a verified Whisper model', async () => {
@@ -147,7 +157,9 @@ describe('SpeechModelSettingsSection', () => {
render()
expect(await screen.findByText('Whisper Tiny(多语言)'))
.toBeInTheDocument()
- fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
+ fireEvent.click(screen.getByRole('button', {
+ name: '下载 Whisper Tiny(多语言)'
+ }))
await waitFor(() =>
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
@@ -189,7 +201,9 @@ describe('SpeechModelSettingsSection', () => {
expect(await screen.findByRole('progressbar', {
name: 'SenseVoiceSmall INT8下载进度'
})).toHaveValue(50)
- fireEvent.click(screen.getByRole('button', { name: '取消' }))
+ fireEvent.click(screen.getByRole('button', {
+ name: '取消 SenseVoiceSmall INT8 操作'
+ }))
await waitFor(() =>
expect(cancel).toHaveBeenCalledWith('sensevoice-small-int8')
)
@@ -264,4 +278,63 @@ describe('SpeechModelSettingsSection', () => {
)
expect(getSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3)
})
+
+ it('shows installed and selected states and switches with a radio choice', async () => {
+ const installed = {
+ id: entry.id,
+ displayName: entry.displayName,
+ source: 'download' as const,
+ installedAt: '2026-08-06T00:00:00.000Z',
+ files: [
+ {
+ name: 'model.int8.onnx',
+ role: 'model' as const,
+ size: 1_000,
+ sha256: 'a'.repeat(64)
+ }
+ ]
+ }
+ const installedSnapshot: SpeechModelSnapshot = {
+ ...snapshot,
+ installed: [installed]
+ }
+ const selectedSnapshot: SpeechModelSnapshot = {
+ ...installedSnapshot,
+ selectedModelId: entry.id
+ }
+ let currentSnapshot = installedSnapshot
+ const select = vi.fn(async () => {
+ currentSnapshot = selectedSnapshot
+ return selectedSnapshot
+ })
+ Object.defineProperty(window, 'goodbuddy', {
+ configurable: true,
+ value: {
+ speechModels: {
+ getSnapshot: vi.fn(async () => currentSnapshot),
+ install: vi.fn(),
+ cancel: vi.fn(async () => true),
+ remove: vi.fn(),
+ select,
+ importLocalDirectory: vi.fn(),
+ openRepository: vi.fn(),
+ openModelsDirectory: vi.fn()
+ }
+ } as unknown as DesktopApi
+ })
+
+ render()
+ const choice = await screen.findByRole('radio', {
+ name: '使用 SenseVoiceSmall INT8'
+ })
+ expect(choice).not.toBeChecked()
+ expect(screen.getByText('已安装')).toBeInTheDocument()
+
+ fireEvent.click(choice)
+ await waitFor(() =>
+ expect(select).toHaveBeenCalledWith('sensevoice-small-int8')
+ )
+ expect(await screen.findByText('正在使用')).toBeInTheDocument()
+ expect(choice).toBeChecked()
+ })
})
diff --git a/src/renderer/src/SpeechModelSettingsSection.tsx b/src/renderer/src/SpeechModelSettingsSection.tsx
index 5301b86..d994752 100644
--- a/src/renderer/src/SpeechModelSettingsSection.tsx
+++ b/src/renderer/src/SpeechModelSettingsSection.tsx
@@ -1,4 +1,6 @@
import {
+ CheckCircle2,
+ ChevronDown,
Download,
ExternalLink,
FolderOpen,
@@ -12,6 +14,29 @@ import type {
SpeechModelOperation,
SpeechModelSnapshot
} from '../../shared/speech-model-contracts'
+import type { AppNotificationInput } from './notifications'
+
+type SpeechModelSettingsSectionProps = {
+ onNotify?: (notification: AppNotificationInput) => void
+}
+
+const qualityLabels: Record = {
+ basic: '基础质量',
+ balanced: '均衡质量',
+ high: '高质量'
+}
+
+const speedLabels: Record = {
+ fast: '快速',
+ balanced: '均衡速度',
+ slow: '较慢'
+}
+
+const familyLabels: Record = {
+ sensevoice: 'SenseVoice',
+ paraformer: 'Paraformer',
+ whisper: 'Whisper'
+}
function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
@@ -39,12 +64,23 @@ function progressPercent(operation: SpeechModelOperation): number | undefined {
: undefined
}
-export function SpeechModelSettingsSection(): React.JSX.Element {
+function operationLabel(operation: SpeechModelOperation): string {
+ if (operation.phase === 'installing') {
+ return '正在校验并安装'
+ }
+ if (operation.phase === 'preparing') {
+ return operation.kind === 'import' ? '正在准备导入' : '正在准备下载'
+ }
+ return operation.kind === 'import' ? '正在导入' : '正在下载'
+}
+
+export function SpeechModelSettingsSection({
+ onNotify
+}: SpeechModelSettingsSectionProps): React.JSX.Element {
const [snapshot, setSnapshot] = useState()
const [busyModelId, setBusyModelId] = useState()
const [confirmingRemove, setConfirmingRemove] = useState()
const [error, setError] = useState()
- const [notice, setNotice] = useState()
const mountedRef = useRef(false)
const refresh = useCallback(async (): Promise => {
@@ -106,12 +142,15 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
): Promise => {
setBusyModelId(modelId)
setError(undefined)
- setNotice(undefined)
try {
const next = await operation()
if (next && mountedRef.current) {
setSnapshot(next)
- setNotice(successMessage)
+ onNotify?.({
+ tone: 'success',
+ message: successMessage,
+ dedupeKey: `speech-model-${modelId}`
+ })
}
} catch (reason) {
if (mountedRef.current) {
@@ -192,9 +231,12 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
并校验文件大小和 SHA-256;也可以从模型仓库手动下载后导入。
{error && {error}
}
- {notice && {notice}
}
-
+
{snapshot.catalog.map((entry) => {
const installed = installedById.get(entry.id)
const operation = operationsById.get(entry.id)
@@ -203,59 +245,91 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
: undefined
const size = catalogSize(entry)
const selected = snapshot.selectedModelId === entry.id
+ const status = operation
+ ? operationLabel(operation)
+ : selected
+ ? '正在使用'
+ : installed
+ ? '已安装'
+ : entry.manualOnly
+ ? '手动导入'
+ : '可下载'
return (
-
-
-
+
+
+
+ void run(
+ entry.id,
+ () =>
+ window.goodbuddy.speechModels!.select(entry.id),
+ `已切换到 ${entry.displayName}`
+ )
+ }
+ type="radio"
+ />
+
+
+
+
{entry.displayName}
-
- {entry.languages.join('、')} · {entry.quantization.toUpperCase()}
- {size ? ` · ${formatBytes(size)}` : ''}
-
+ {entry.recommended && (
+
+ 推荐
+
+ )}
-
- {selected
- ? '正在使用'
- : installed
- ? '已安装'
- : entry.manualOnly
- ? '手动导入'
- : '可下载'}
+ {entry.description}
+
+
+ {familyLabels[entry.family]}
+
+
+ {entry.languages.join(' / ')}
+
+
+ {entry.quantization.toUpperCase()}
+
+
+
+
+
+ {qualityLabels[entry.quality]}
+ {speedLabels[entry.speed]}
+ {size ? formatBytes(size) : '大小未知'}
+
+
+
+
+ {selected && }
+ {status}
- {entry.description}
-
- 许可证:{entry.license.name}。
- {entry.license.notice}
-
- {operation && (
-
-
-
- {operation.currentFile
- ? `正在处理 ${operation.currentFile}`
- : operation.phase === 'installing'
- ? '正在校验并安装…'
- : '正在准备…'}
- {percent === undefined
- ? ''
- : ` · ${percent.toFixed(0)}%`}
-
-
- )}
-
- {entry.manualOnly && entry.manualReason && !installed && (
- {entry.manualReason}
- )}
-
-
+
{operation ? (
) : installed ? (
- <>
- {!selected && (
-
- )}
-
- >
+
) : (
<>
{!entry.manualOnly && (
)}
>
)}
-
+
+ {operation && (
+
+
+
+ {operation.currentFile
+ ? `正在处理 ${operation.currentFile}`
+ : `${operationLabel(operation)}…`}
+ {percent === undefined
+ ? ''
+ : ` · ${percent.toFixed(0)}%`}
+
+
+ )}
+
+
+
+
+ 模型详情
+
+
+ {entry.manualOnly &&
+ entry.manualReason &&
+ !installed && (
+
{entry.manualReason}
+ )}
+
+ 许可证:{entry.license.name}。
+ {entry.license.notice}
+
+
+
+
)
})}
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index 4aec9ce..59d5804 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -110,6 +110,7 @@
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
+ container: speech-model-list / inline-size;
grid-template-columns:
220px minmax(300px, 1fr) 9px
var(--magic-notes-ai-width, 300px);
@@ -4775,33 +4776,268 @@ details.settings-section > :not(summary) + :not(summary) {
.speech-model-settings__list {
display: grid;
- gap: var(--space-3);
+ overflow: hidden;
+ border: 1px solid var(--border-default);
+ border-radius: var(--radius-card);
+ background: var(--surface-raised);
}
.speech-model-settings .settings-section__title--actions > button,
-.speech-model-card__actions,
-.speech-model-card__actions button {
+.speech-model-row__actions,
+.speech-model-row__actions button,
+.speech-model-row__details button {
display: flex;
align-items: center;
}
.speech-model-settings .settings-section__title--actions > button,
-.speech-model-card__actions button {
+.speech-model-row__actions button,
+.speech-model-row__details button {
gap: var(--space-2);
}
-.speech-model-card__actions {
+.speech-model-row {
+ display: grid;
+ min-width: 0;
+ align-items: center;
+ padding: var(--space-3);
+ border-bottom: 1px solid var(--border-subtle);
+ background: var(--surface-raised);
+ grid-template-columns: 20px minmax(0, 1fr) minmax(144px, auto);
+ gap: var(--space-2) var(--space-3);
+ transition:
+ background var(--motion-fast) ease-out,
+ border-color var(--motion-fast) ease-out;
+}
+
+.speech-model-row:last-child {
+ border-bottom: 0;
+}
+
+.speech-model-row--selected {
+ box-shadow: inset 3px 0 0 var(--accent-solid);
+ background: var(--accent-subtle);
+}
+
+.speech-model-row__selection {
+ align-self: start;
+ padding-top: var(--space-1);
+}
+
+.speech-model-row__selection input {
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ accent-color: var(--accent-solid);
+}
+
+.speech-model-row__summary {
+ display: grid;
+ min-width: 0;
+ grid-column: 2;
+ gap: var(--space-1);
+}
+
+.speech-model-row__name,
+.speech-model-row__tags,
+.speech-model-row__profile,
+.speech-model-status,
+.speech-model-row__actions,
+.speech-model-row__details summary {
+ display: flex;
+ align-items: center;
+}
+
+.speech-model-row__name {
+ min-width: 0;
flex-wrap: wrap;
gap: var(--space-2);
}
+.speech-model-row__name strong {
+ color: var(--text-primary);
+ font-size: var(--font-body);
+}
+
+.speech-model-row__summary p,
+.speech-model-row__details p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: var(--font-caption);
+ line-height: 1.55;
+}
+
+.speech-model-row__tags {
+ flex-wrap: wrap;
+ gap: var(--space-1);
+}
+
+.speech-model-tag {
+ padding: 2px var(--space-2);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-control);
+ background: var(--surface-subtle);
+ color: var(--text-muted);
+ font-size: var(--font-caption);
+ line-height: 1.4;
+}
+
+.speech-model-tag--recommended {
+ border-color: var(--accent-selected);
+ background: var(--accent-selected);
+ color: var(--accent);
+ font-weight: 650;
+}
+
+.speech-model-row__profile {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ grid-column: 2;
+ color: var(--text-muted);
+ font-size: var(--font-caption);
+ gap: var(--space-1) var(--space-3);
+}
+
+.speech-model-row__state {
+ align-self: start;
+ padding-top: var(--space-1);
+ grid-column: 3;
+ grid-row: 1;
+}
+
+.speech-model-status {
+ color: var(--text-muted);
+ font-size: var(--font-caption);
+ font-weight: 650;
+ gap: var(--space-1);
+ white-space: nowrap;
+}
+
+.speech-model-status--installed {
+ color: var(--text-secondary);
+}
+
+.speech-model-status--selected {
+ color: var(--accent);
+}
+
+.speech-model-row__actions {
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ grid-column: 3;
+ grid-row: 2;
+ gap: var(--space-2);
+}
+
+.speech-model-row__actions button,
+.speech-model-row__details button {
+ min-height: 30px;
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+.speech-model-row__actions .danger-ghost {
+ padding: 0 var(--space-2);
+ border: 1px solid transparent;
+ border-radius: var(--radius-control);
+ background: transparent;
+ color: var(--danger);
+ font: inherit;
+ font-size: var(--font-caption);
+ gap: var(--space-1);
+}
+
+.speech-model-row__actions .danger-ghost:hover {
+ border-color: var(--danger-border);
+ background: var(--danger-subtle);
+}
+
.speech-model-operation {
display: grid;
+ grid-column: 2 / -1;
gap: var(--space-1);
}
.speech-model-operation progress {
width: 100%;
+ accent-color: var(--accent-solid);
+}
+
+.speech-model-operation small {
+ color: var(--text-muted);
+ font-size: var(--font-caption);
+}
+
+.speech-model-row__details {
+ min-width: 0;
+ grid-column: 2 / -1;
+}
+
+.speech-model-row__details summary {
+ width: fit-content;
+ cursor: pointer;
+ color: var(--text-muted);
+ font-size: var(--font-caption);
+ gap: var(--space-1);
+ list-style: none;
+}
+
+.speech-model-row__details summary::-webkit-details-marker {
+ display: none;
+}
+
+.speech-model-row__details summary svg {
+ transition: transform var(--motion-fast) ease-out;
+}
+
+.speech-model-row__details[open] summary svg {
+ transform: rotate(180deg);
+}
+
+.speech-model-row__details > div {
+ display: grid;
+ padding-top: var(--space-2);
+ gap: var(--space-2);
+}
+
+.speech-model-row__details button {
+ width: fit-content;
+}
+
+@container speech-model-list (max-width: 500px) {
+ .speech-model-row {
+ align-items: start;
+ grid-template-columns: 20px minmax(0, 1fr);
+ }
+
+ .speech-model-row__summary,
+ .speech-model-row__profile {
+ grid-column: 2;
+ }
+
+ .speech-model-row__state {
+ grid-column: 2;
+ grid-row: auto;
+ }
+
+ .speech-model-row__actions,
+ .speech-model-operation,
+ .speech-model-row__details {
+ justify-content: flex-start;
+ grid-column: 2;
+ grid-row: auto;
+ }
+}
+
+@media (max-width: 720px) {
+ .speech-model-settings .settings-section__title--actions {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .speech-model-settings .settings-section__title--actions > button {
+ margin-left: 26px;
+ }
}
.role-prompt-empty {
diff --git a/src/shared/speech-model-contracts.test.ts b/src/shared/speech-model-contracts.test.ts
index 29572ad..84b0be9 100644
--- a/src/shared/speech-model-contracts.test.ts
+++ b/src/shared/speech-model-contracts.test.ts
@@ -12,6 +12,9 @@ const downloadableEntry = {
languages: ['中文'],
family: 'whisper' as const,
quantization: 'int8' as const,
+ quality: 'balanced' as const,
+ speed: 'balanced' as const,
+ recommended: false,
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
license: {
name: 'MIT License',
diff --git a/src/shared/speech-model-contracts.ts b/src/shared/speech-model-contracts.ts
index e964a05..140596a 100644
--- a/src/shared/speech-model-contracts.ts
+++ b/src/shared/speech-model-contracts.ts
@@ -59,8 +59,11 @@ export const speechModelCatalogEntrySchema = z
displayName: z.string().trim().min(1).max(120),
description: z.string().trim().min(1).max(500),
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
- family: z.enum(['sensevoice', 'whisper']),
+ family: z.enum(['sensevoice', 'whisper', 'paraformer']),
quantization: z.enum(['int8', 'fp16', 'fp32']),
+ quality: z.enum(['basic', 'balanced', 'high']),
+ speed: z.enum(['fast', 'balanced', 'slow']),
+ recommended: z.boolean(),
repositoryUrl: z.url().max(2_048),
license: speechModelLicenseSchema,
manualOnly: z.boolean(),