feat: expand local speech models

This commit is contained in:
lofyer
2026-08-11 01:34:28 +08:00
parent f16ef993bc
commit c8050f4a9a
10 changed files with 855 additions and 117 deletions
+246
View File
@@ -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'
}
}
]
}
])
+40 -3
View File
@@ -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
)
}
}
@@ -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(
{
@@ -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: {
+3 -1
View File
@@ -2039,7 +2039,9 @@ export function SettingsPanel({
}}
/>
)}
{modelType === 'speech' && <SpeechModelSettingsSection />}
{modelType === 'speech' && (
<SpeechModelSettingsSection onNotify={onNotify} />
)}
</>
)}
@@ -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(<SpeechModelSettingsSection />)
render(<SpeechModelSettingsSection onNotify={onNotify} />)
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(<SpeechModelSettingsSection />)
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(<SpeechModelSettingsSection />)
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()
})
})
+194 -101
View File
@@ -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<SpeechModelCatalogEntry['quality'], string> = {
basic: '基础质量',
balanced: '均衡质量',
high: '高质量'
}
const speedLabels: Record<SpeechModelCatalogEntry['speed'], string> = {
fast: '快速',
balanced: '均衡速度',
slow: '较慢'
}
const familyLabels: Record<SpeechModelCatalogEntry['family'], string> = {
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<SpeechModelSnapshot>()
const [busyModelId, setBusyModelId] = useState<string>()
const [confirmingRemove, setConfirmingRemove] = useState<string>()
const [error, setError] = useState<string>()
const [notice, setNotice] = useState<string>()
const mountedRef = useRef(false)
const refresh = useCallback(async (): Promise<void> => {
@@ -106,12 +142,15 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
): Promise<void> => {
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
</p>
{error && <p className="settings-warning" role="alert">{error}</p>}
{notice && <p className="settings-success" role="status">{notice}</p>}
<div className="speech-model-settings__list">
<div
aria-label="可用语音模型"
className="speech-model-settings__list"
role="list"
>
{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 (
<article className="capability-card" key={entry.id}>
<div className="capability-card__header">
<div>
<article
className={`speech-model-row${selected ? ' speech-model-row--selected' : ''}`}
key={entry.id}
role="listitem"
>
<div className="speech-model-row__selection">
<input
aria-label={
installed
? `使用 ${entry.displayName}`
: `${entry.displayName} 尚未安装`
}
checked={selected}
disabled={!installed || operation !== undefined}
name="selected-speech-model"
onChange={() =>
void run(
entry.id,
() =>
window.goodbuddy.speechModels!.select(entry.id),
`已切换到 ${entry.displayName}`
)
}
type="radio"
/>
</div>
<div className="speech-model-row__summary">
<div className="speech-model-row__name">
<strong>{entry.displayName}</strong>
<small>
{entry.languages.join('、')} · {entry.quantization.toUpperCase()}
{size ? ` · ${formatBytes(size)}` : ''}
</small>
{entry.recommended && (
<span className="speech-model-tag speech-model-tag--recommended">
</span>
)}
</div>
<span>
{selected
? '正在使用'
: installed
? '已安装'
: entry.manualOnly
? '手动导入'
: '可下载'}
<p>{entry.description}</p>
<div className="speech-model-row__tags">
<span className="speech-model-tag">
{familyLabels[entry.family]}
</span>
<span className="speech-model-tag">
{entry.languages.join(' / ')}
</span>
<span className="speech-model-tag">
{entry.quantization.toUpperCase()}
</span>
</div>
</div>
<div className="speech-model-row__profile">
<span>{qualityLabels[entry.quality]}</span>
<span>{speedLabels[entry.speed]}</span>
<span>{size ? formatBytes(size) : '大小未知'}</span>
</div>
<div className="speech-model-row__state">
<span
className={`speech-model-status${
selected
? ' speech-model-status--selected'
: installed
? ' speech-model-status--installed'
: ''
}`}
>
{selected && <CheckCircle2 aria-hidden="true" size={13} />}
{status}
</span>
</div>
<p>{entry.description}</p>
<p>
<strong>{entry.license.name}</strong>
{entry.license.notice}
</p>
{operation && (
<div aria-live="polite" className="speech-model-operation">
<progress
aria-label={`${entry.displayName}下载进度`}
max={100}
{...(percent === undefined ? {} : { value: percent })}
/>
<small>
{operation.currentFile
? `正在处理 ${operation.currentFile}`
: operation.phase === 'installing'
? '正在校验并安装…'
: '正在准备…'}
{percent === undefined
? ''
: ` · ${percent.toFixed(0)}%`}
</small>
</div>
)}
{entry.manualOnly && entry.manualReason && !installed && (
<p className="settings-notice">{entry.manualReason}</p>
)}
<div className="speech-model-card__actions">
<div className="speech-model-row__actions">
{operation ? (
<button
aria-label={`取消 ${entry.displayName} 操作`}
className="secondary-button"
onClick={() =>
void window.goodbuddy.speechModels
@@ -268,46 +342,27 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
</button>
) : installed ? (
<>
{!selected && (
<button
className="primary-button"
disabled={busyModelId === entry.id}
onClick={() =>
void run(
entry.id,
() =>
window.goodbuddy.speechModels!.select(
entry.id
),
`已切换到 ${entry.displayName}`
)
}
type="button"
>
使
</button>
)}
<button
className={
confirmingRemove === entry.id
? 'danger-button'
: 'secondary-button'
}
disabled={busyModelId === entry.id}
onClick={() => void remove(entry.id)}
type="button"
>
<Trash2 aria-hidden="true" size={12} />
{confirmingRemove === entry.id
? '确认删除模型'
: '删除模型'}
</button>
</>
<button
aria-label={`删除 ${entry.displayName}`}
className={
confirmingRemove === entry.id
? 'danger-button'
: 'danger-ghost'
}
disabled={busyModelId === entry.id}
onClick={() => void remove(entry.id)}
type="button"
>
<Trash2 aria-hidden="true" size={12} />
{confirmingRemove === entry.id
? '确认删除'
: '删除'}
</button>
) : (
<>
{!entry.manualOnly && (
<button
aria-label={`下载 ${entry.displayName}`}
className="primary-button"
disabled={busyModelId === entry.id}
onClick={() =>
@@ -323,10 +378,11 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
type="button"
>
<Download aria-hidden="true" size={13} />
</button>
)}
<button
aria-label={`从本地目录导入 ${entry.displayName}`}
className="secondary-button"
disabled={busyModelId === entry.id}
onClick={() =>
@@ -341,23 +397,60 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
type="button"
>
<FolderOpen aria-hidden="true" size={13} />
</button>
</>
)}
<button
className="secondary-button"
onClick={() =>
void window.goodbuddy.speechModels?.openRepository(
entry.id
)
}
type="button"
>
<ExternalLink aria-hidden="true" size={13} />
</button>
</div>
{operation && (
<div aria-live="polite" className="speech-model-operation">
<progress
aria-label={`${entry.displayName}下载进度`}
max={100}
{...(percent === undefined ? {} : { value: percent })}
/>
<small>
{operation.currentFile
? `正在处理 ${operation.currentFile}`
: `${operationLabel(operation)}`}
{percent === undefined
? ''
: ` · ${percent.toFixed(0)}%`}
</small>
</div>
)}
<details className="speech-model-row__details">
<summary>
<ChevronDown aria-hidden="true" size={13} />
</summary>
<div>
{entry.manualOnly &&
entry.manualReason &&
!installed && (
<p>{entry.manualReason}</p>
)}
<p>
<strong>{entry.license.name}</strong>
{entry.license.notice}
</p>
<button
aria-label={`打开 ${entry.displayName} 模型仓库`}
className="secondary-button"
onClick={() =>
void window.goodbuddy.speechModels?.openRepository(
entry.id
)
}
type="button"
>
<ExternalLink aria-hidden="true" size={13} />
</button>
</div>
</details>
</article>
)
})}
+241 -5
View File
@@ -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 {
@@ -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',
+4 -1
View File
@@ -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(),