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
+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 {