feat: expand local speech models
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user