feat: expand local speech models
This commit is contained in:
@@ -18,6 +18,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
|||||||
languages: ['中文', '粤语', '英语', '日语', '韩语'],
|
languages: ['中文', '粤语', '英语', '日语', '韩语'],
|
||||||
family: 'sensevoice',
|
family: 'sensevoice',
|
||||||
quantization: 'int8',
|
quantization: 'int8',
|
||||||
|
quality: 'high',
|
||||||
|
speed: 'fast',
|
||||||
|
recommended: true,
|
||||||
repositoryUrl:
|
repositoryUrl:
|
||||||
'https://modelscope.cn/models/pengzhendong/' +
|
'https://modelscope.cn/models/pengzhendong/' +
|
||||||
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue',
|
'sherpa-onnx-sense-voice-zh-en-ja-ko-yue',
|
||||||
@@ -67,6 +70,9 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
|||||||
languages: ['中文', '英语', '多语言'],
|
languages: ['中文', '英语', '多语言'],
|
||||||
family: 'whisper',
|
family: 'whisper',
|
||||||
quantization: 'int8',
|
quantization: 'int8',
|
||||||
|
quality: 'basic',
|
||||||
|
speed: 'fast',
|
||||||
|
recommended: false,
|
||||||
repositoryUrl:
|
repositoryUrl:
|
||||||
'https://modelscope.cn/models/pengzhendong/' +
|
'https://modelscope.cn/models/pengzhendong/' +
|
||||||
'sherpa-onnx-whisper-tiny',
|
'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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ function downloadableCatalog(
|
|||||||
languages: ['中文'],
|
languages: ['中文'],
|
||||||
family: 'whisper',
|
family: 'whisper',
|
||||||
quantization: 'int8',
|
quantization: 'int8',
|
||||||
|
quality: 'balanced',
|
||||||
|
speed: 'balanced',
|
||||||
|
recommended: false,
|
||||||
repositoryUrl:
|
repositoryUrl:
|
||||||
'https://modelscope.cn/models/example/download-test-model',
|
'https://modelscope.cn/models/example/download-test-model',
|
||||||
license: {
|
license: {
|
||||||
@@ -92,13 +95,25 @@ function downloadableCatalog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('speech model catalog', () => {
|
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(
|
const senseVoice = SPEECH_MODEL_CATALOG.find(
|
||||||
(entry) => entry.id === 'sensevoice-small-int8'
|
(entry) => entry.id === 'sensevoice-small-int8'
|
||||||
)
|
)
|
||||||
const whisper = SPEECH_MODEL_CATALOG.find(
|
const whisper = SPEECH_MODEL_CATALOG.find(
|
||||||
(entry) => entry.id === 'whisper-tiny-multilingual'
|
(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({
|
expect(senseVoice).toMatchObject({
|
||||||
manualOnly: false,
|
manualOnly: false,
|
||||||
@@ -125,13 +140,35 @@ describe('speech model catalog', () => {
|
|||||||
'tiny-decoder.int8.onnx',
|
'tiny-decoder.int8.onnx',
|
||||||
'tiny-tokens.txt'
|
'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) {
|
for (const entry of SPEECH_MODEL_CATALOG) {
|
||||||
expect(entry.repositoryUrl).toMatch(
|
expect(entry.repositoryUrl).toMatch(
|
||||||
/^https:\/\/modelscope\.cn\/models\//u
|
/^https:\/\/(?:modelscope\.cn\/models\/|huggingface\.co\/)/u
|
||||||
)
|
)
|
||||||
for (const file of entry.files) {
|
for (const file of entry.files) {
|
||||||
expect(file.download?.url).toMatch(
|
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 {
|
function input(): SpeechTranscriptionInput {
|
||||||
return {
|
return {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -72,6 +94,15 @@ describe('SpeechTranscriptionService', () => {
|
|||||||
).toBe('')
|
).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 () => {
|
it('requires an installed selected model and rejects oversized audio', async () => {
|
||||||
const service = new SpeechTranscriptionService(
|
const service = new SpeechTranscriptionService(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type SherpaRecognizerConfig = {
|
|||||||
language: string
|
language: string
|
||||||
useInverseTextNormalization: number
|
useInverseTextNormalization: number
|
||||||
}
|
}
|
||||||
|
paraformer?: {
|
||||||
|
model: string
|
||||||
|
}
|
||||||
whisper?: {
|
whisper?: {
|
||||||
encoder: string
|
encoder: string
|
||||||
decoder: 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 {
|
return {
|
||||||
...base,
|
...base,
|
||||||
modelConfig: {
|
modelConfig: {
|
||||||
|
|||||||
@@ -2039,7 +2039,9 @@ export function SettingsPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modelType === 'speech' && <SpeechModelSettingsSection />}
|
{modelType === 'speech' && (
|
||||||
|
<SpeechModelSettingsSection onNotify={onNotify} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const entry = {
|
|||||||
languages: ['中文', '粤语'],
|
languages: ['中文', '粤语'],
|
||||||
family: 'sensevoice' as const,
|
family: 'sensevoice' as const,
|
||||||
quantization: 'int8' as const,
|
quantization: 'int8' as const,
|
||||||
|
quality: 'high' as const,
|
||||||
|
speed: 'fast' as const,
|
||||||
|
recommended: true,
|
||||||
repositoryUrl: 'https://huggingface.co/example/model',
|
repositoryUrl: 'https://huggingface.co/example/model',
|
||||||
license: {
|
license: {
|
||||||
name: '模型仓库自定义许可',
|
name: '模型仓库自定义许可',
|
||||||
@@ -81,6 +84,7 @@ describe('SpeechModelSettingsSection', () => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
const install = vi.fn(async () => installedSnapshot)
|
const install = vi.fn(async () => installedSnapshot)
|
||||||
|
const onNotify = vi.fn()
|
||||||
Object.defineProperty(window, 'goodbuddy', {
|
Object.defineProperty(window, 'goodbuddy', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
@@ -97,16 +101,22 @@ describe('SpeechModelSettingsSection', () => {
|
|||||||
} as unknown as DesktopApi
|
} as unknown as DesktopApi
|
||||||
})
|
})
|
||||||
|
|
||||||
render(<SpeechModelSettingsSection />)
|
render(<SpeechModelSettingsSection onNotify={onNotify} />)
|
||||||
expect(await screen.findByText('SenseVoiceSmall INT8'))
|
expect(await screen.findByText('SenseVoiceSmall INT8'))
|
||||||
.toBeInTheDocument()
|
.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
|
expect(screen.getByText('推荐')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('button', {
|
||||||
|
name: '下载 SenseVoiceSmall INT8'
|
||||||
|
}))
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
|
expect(install).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||||
)
|
)
|
||||||
expect(await screen.findByText('SenseVoiceSmall INT8 已安装'))
|
expect(onNotify).toHaveBeenCalledWith({
|
||||||
.toBeInTheDocument()
|
tone: 'success',
|
||||||
|
message: 'SenseVoiceSmall INT8 已安装',
|
||||||
|
dedupeKey: 'speech-model-sensevoice-small-int8'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('offers a download button for a verified Whisper model', async () => {
|
it('offers a download button for a verified Whisper model', async () => {
|
||||||
@@ -147,7 +157,9 @@ describe('SpeechModelSettingsSection', () => {
|
|||||||
render(<SpeechModelSettingsSection />)
|
render(<SpeechModelSettingsSection />)
|
||||||
expect(await screen.findByText('Whisper Tiny(多语言)'))
|
expect(await screen.findByText('Whisper Tiny(多语言)'))
|
||||||
.toBeInTheDocument()
|
.toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
|
fireEvent.click(screen.getByRole('button', {
|
||||||
|
name: '下载 Whisper Tiny(多语言)'
|
||||||
|
}))
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
|
expect(install).toHaveBeenCalledWith('whisper-tiny-multilingual')
|
||||||
@@ -189,7 +201,9 @@ describe('SpeechModelSettingsSection', () => {
|
|||||||
expect(await screen.findByRole('progressbar', {
|
expect(await screen.findByRole('progressbar', {
|
||||||
name: 'SenseVoiceSmall INT8下载进度'
|
name: 'SenseVoiceSmall INT8下载进度'
|
||||||
})).toHaveValue(50)
|
})).toHaveValue(50)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
fireEvent.click(screen.getByRole('button', {
|
||||||
|
name: '取消 SenseVoiceSmall INT8 操作'
|
||||||
|
}))
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(cancel).toHaveBeenCalledWith('sensevoice-small-int8')
|
expect(cancel).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||||
)
|
)
|
||||||
@@ -264,4 +278,63 @@ describe('SpeechModelSettingsSection', () => {
|
|||||||
)
|
)
|
||||||
expect(getSnapshot.mock.calls.length).toBeGreaterThanOrEqual(3)
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
@@ -12,6 +14,29 @@ import type {
|
|||||||
SpeechModelOperation,
|
SpeechModelOperation,
|
||||||
SpeechModelSnapshot
|
SpeechModelSnapshot
|
||||||
} from '../../shared/speech-model-contracts'
|
} 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 {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes >= 1024 * 1024 * 1024) {
|
if (bytes >= 1024 * 1024 * 1024) {
|
||||||
@@ -39,12 +64,23 @@ function progressPercent(operation: SpeechModelOperation): number | undefined {
|
|||||||
: 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 [snapshot, setSnapshot] = useState<SpeechModelSnapshot>()
|
||||||
const [busyModelId, setBusyModelId] = useState<string>()
|
const [busyModelId, setBusyModelId] = useState<string>()
|
||||||
const [confirmingRemove, setConfirmingRemove] = useState<string>()
|
const [confirmingRemove, setConfirmingRemove] = useState<string>()
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
const [notice, setNotice] = useState<string>()
|
|
||||||
const mountedRef = useRef(false)
|
const mountedRef = useRef(false)
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
@@ -106,12 +142,15 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
setBusyModelId(modelId)
|
setBusyModelId(modelId)
|
||||||
setError(undefined)
|
setError(undefined)
|
||||||
setNotice(undefined)
|
|
||||||
try {
|
try {
|
||||||
const next = await operation()
|
const next = await operation()
|
||||||
if (next && mountedRef.current) {
|
if (next && mountedRef.current) {
|
||||||
setSnapshot(next)
|
setSnapshot(next)
|
||||||
setNotice(successMessage)
|
onNotify?.({
|
||||||
|
tone: 'success',
|
||||||
|
message: successMessage,
|
||||||
|
dedupeKey: `speech-model-${modelId}`
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
@@ -192,9 +231,12 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
并校验文件大小和 SHA-256;也可以从模型仓库手动下载后导入。
|
并校验文件大小和 SHA-256;也可以从模型仓库手动下载后导入。
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="settings-warning" role="alert">{error}</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) => {
|
{snapshot.catalog.map((entry) => {
|
||||||
const installed = installedById.get(entry.id)
|
const installed = installedById.get(entry.id)
|
||||||
const operation = operationsById.get(entry.id)
|
const operation = operationsById.get(entry.id)
|
||||||
@@ -203,59 +245,91 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
: undefined
|
: undefined
|
||||||
const size = catalogSize(entry)
|
const size = catalogSize(entry)
|
||||||
const selected = snapshot.selectedModelId === entry.id
|
const selected = snapshot.selectedModelId === entry.id
|
||||||
return (
|
const status = operation
|
||||||
<article className="capability-card" key={entry.id}>
|
? operationLabel(operation)
|
||||||
<div className="capability-card__header">
|
: selected
|
||||||
<div>
|
|
||||||
<strong>{entry.displayName}</strong>
|
|
||||||
<small>
|
|
||||||
{entry.languages.join('、')} · {entry.quantization.toUpperCase()}
|
|
||||||
{size ? ` · ${formatBytes(size)}` : ''}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{selected
|
|
||||||
? '正在使用'
|
? '正在使用'
|
||||||
: installed
|
: installed
|
||||||
? '已安装'
|
? '已安装'
|
||||||
: entry.manualOnly
|
: entry.manualOnly
|
||||||
? '手动导入'
|
? '手动导入'
|
||||||
: '可下载'}
|
: '可下载'
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{entry.recommended && (
|
||||||
|
<span className="speech-model-tag speech-model-tag--recommended">
|
||||||
|
推荐
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p>{entry.description}</p>
|
<p>{entry.description}</p>
|
||||||
<p>
|
<div className="speech-model-row__tags">
|
||||||
许可证:<strong>{entry.license.name}</strong>。
|
<span className="speech-model-tag">
|
||||||
{entry.license.notice}
|
{familyLabels[entry.family]}
|
||||||
</p>
|
</span>
|
||||||
|
<span className="speech-model-tag">
|
||||||
{operation && (
|
{entry.languages.join(' / ')}
|
||||||
<div aria-live="polite" className="speech-model-operation">
|
</span>
|
||||||
<progress
|
<span className="speech-model-tag">
|
||||||
aria-label={`${entry.displayName}下载进度`}
|
{entry.quantization.toUpperCase()}
|
||||||
max={100}
|
</span>
|
||||||
{...(percent === undefined ? {} : { value: percent })}
|
</div>
|
||||||
/>
|
|
||||||
<small>
|
|
||||||
{operation.currentFile
|
|
||||||
? `正在处理 ${operation.currentFile}`
|
|
||||||
: operation.phase === 'installing'
|
|
||||||
? '正在校验并安装…'
|
|
||||||
: '正在准备…'}
|
|
||||||
{percent === undefined
|
|
||||||
? ''
|
|
||||||
: ` · ${percent.toFixed(0)}%`}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{entry.manualOnly && entry.manualReason && !installed && (
|
<div className="speech-model-row__profile">
|
||||||
<p className="settings-notice">{entry.manualReason}</p>
|
<span>{qualityLabels[entry.quality]}</span>
|
||||||
)}
|
<span>{speedLabels[entry.speed]}</span>
|
||||||
|
<span>{size ? formatBytes(size) : '大小未知'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="speech-model-card__actions">
|
<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>
|
||||||
|
|
||||||
|
<div className="speech-model-row__actions">
|
||||||
{operation ? (
|
{operation ? (
|
||||||
<button
|
<button
|
||||||
|
aria-label={`取消 ${entry.displayName} 操作`}
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void window.goodbuddy.speechModels
|
void window.goodbuddy.speechModels
|
||||||
@@ -268,31 +342,12 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
) : installed ? (
|
) : 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
|
<button
|
||||||
|
aria-label={`删除 ${entry.displayName}`}
|
||||||
className={
|
className={
|
||||||
confirmingRemove === entry.id
|
confirmingRemove === entry.id
|
||||||
? 'danger-button'
|
? 'danger-button'
|
||||||
: 'secondary-button'
|
: 'danger-ghost'
|
||||||
}
|
}
|
||||||
disabled={busyModelId === entry.id}
|
disabled={busyModelId === entry.id}
|
||||||
onClick={() => void remove(entry.id)}
|
onClick={() => void remove(entry.id)}
|
||||||
@@ -300,14 +355,14 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Trash2 aria-hidden="true" size={12} />
|
<Trash2 aria-hidden="true" size={12} />
|
||||||
{confirmingRemove === entry.id
|
{confirmingRemove === entry.id
|
||||||
? '确认删除模型'
|
? '确认删除'
|
||||||
: '删除模型'}
|
: '删除'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!entry.manualOnly && (
|
{!entry.manualOnly && (
|
||||||
<button
|
<button
|
||||||
|
aria-label={`下载 ${entry.displayName}`}
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={busyModelId === entry.id}
|
disabled={busyModelId === entry.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -323,10 +378,11 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Download aria-hidden="true" size={13} />
|
<Download aria-hidden="true" size={13} />
|
||||||
下载模型
|
下载
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
aria-label={`从本地目录导入 ${entry.displayName}`}
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
disabled={busyModelId === entry.id}
|
disabled={busyModelId === entry.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -341,11 +397,47 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<FolderOpen aria-hidden="true" size={13} />
|
<FolderOpen aria-hidden="true" size={13} />
|
||||||
从本地目录导入
|
导入
|
||||||
</button>
|
</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
|
<button
|
||||||
|
aria-label={`打开 ${entry.displayName} 模型仓库`}
|
||||||
className="secondary-button"
|
className="secondary-button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void window.goodbuddy.speechModels?.openRepository(
|
void window.goodbuddy.speechModels?.openRepository(
|
||||||
@@ -358,6 +450,7 @@ export function SpeechModelSettingsSection(): React.JSX.Element {
|
|||||||
打开模型仓库
|
打开模型仓库
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+241
-5
@@ -110,6 +110,7 @@
|
|||||||
border: 1px solid var(--border-default);
|
border: 1px solid var(--border-default);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
|
container: speech-model-list / inline-size;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
220px minmax(300px, 1fr) 9px
|
220px minmax(300px, 1fr) 9px
|
||||||
var(--magic-notes-ai-width, 300px);
|
var(--magic-notes-ai-width, 300px);
|
||||||
@@ -4775,33 +4776,268 @@ details.settings-section > :not(summary) + :not(summary) {
|
|||||||
|
|
||||||
.speech-model-settings__list {
|
.speech-model-settings__list {
|
||||||
display: grid;
|
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-settings .settings-section__title--actions > button,
|
||||||
.speech-model-card__actions,
|
.speech-model-row__actions,
|
||||||
.speech-model-card__actions button {
|
.speech-model-row__actions button,
|
||||||
|
.speech-model-row__details button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.speech-model-settings .settings-section__title--actions > button,
|
.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);
|
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;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
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 {
|
.speech-model-operation {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-column: 2 / -1;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.speech-model-operation progress {
|
.speech-model-operation progress {
|
||||||
width: 100%;
|
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 {
|
.role-prompt-empty {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const downloadableEntry = {
|
|||||||
languages: ['中文'],
|
languages: ['中文'],
|
||||||
family: 'whisper' as const,
|
family: 'whisper' as const,
|
||||||
quantization: 'int8' as const,
|
quantization: 'int8' as const,
|
||||||
|
quality: 'balanced' as const,
|
||||||
|
speed: 'balanced' as const,
|
||||||
|
recommended: false,
|
||||||
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
|
repositoryUrl: 'https://huggingface.co/example/test-speech-model',
|
||||||
license: {
|
license: {
|
||||||
name: 'MIT License',
|
name: 'MIT License',
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ export const speechModelCatalogEntrySchema = z
|
|||||||
displayName: z.string().trim().min(1).max(120),
|
displayName: z.string().trim().min(1).max(120),
|
||||||
description: z.string().trim().min(1).max(500),
|
description: z.string().trim().min(1).max(500),
|
||||||
languages: z.array(z.string().trim().min(1).max(32)).min(1).max(32),
|
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']),
|
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),
|
repositoryUrl: z.url().max(2_048),
|
||||||
license: speechModelLicenseSchema,
|
license: speechModelLicenseSchema,
|
||||||
manualOnly: z.boolean(),
|
manualOnly: z.boolean(),
|
||||||
|
|||||||
Reference in New Issue
Block a user