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 &&
-

{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(),