Allow deleting downloaded speech models on desktop and mobile (#5794)

* Allow deleting downloaded speech models on desktop and mobile

Implement safe deletion of local speech models across desktop and
mobile platforms.

- Add a shared helper to coordinate STT service teardown, file deletion,
  and voice settings cleanup.
- Prevent deleting speech models that are currently in use or starting.
- Update desktop and mobile settings UIs to expose the delete action.

* Track multiple pending model deletions and update action disabling

- Replace the single pending delete ID string state with a Set of IDs on desktop settings to track multiple concurrently deleting models.
- Switch the desktop delete action to use the standard themed Button component.
- Disable all row interactions on the mobile voice model list when any model action is busy.
- Add unit test coverage verifying that individual row delete button disabled states behave correctly.
This commit is contained in:
Jinjing 2026-06-19 12:31:11 -07:00 committed by GitHub
parent 5dbff5d8ea
commit bc2f5145da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 836 additions and 77 deletions

View File

@ -19,6 +19,7 @@ import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
import {
deleteDictationModel,
downloadDictationModel,
fetchDictationSetup,
isModelInFlight,
@ -34,6 +35,8 @@ const DICTATION_MODES = [
{ value: 'hold', label: 'Hold' }
] as const
type ModelBusyAction = { modelId: string; type: 'download' | 'select' | 'delete' }
export default function VoiceSettingsScreen(): React.JSX.Element {
const router = useRouter()
const insets = useSafeAreaInsets()
@ -53,7 +56,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busyModelId, setBusyModelId] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@ -134,7 +137,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
if (!client) {
return
}
setBusyModelId(model.id)
setBusyAction({ modelId: model.id, type: 'select' })
setError(null)
try {
setSetup(await setDictationConfig(client, { enabled: true, modelId: model.id }))
@ -142,7 +145,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not select model')
} finally {
setBusyModelId(null)
setBusyAction(null)
}
},
[client]
@ -153,7 +156,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
if (!client) {
return
}
setBusyModelId(model.id)
setBusyAction({ modelId: model.id, type: 'download' })
setError(null)
try {
await downloadDictationModel(client, model.id)
@ -161,12 +164,34 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
} catch (err) {
setError(err instanceof Error ? err.message : 'Download failed')
} finally {
setBusyModelId(null)
setBusyAction(null)
}
},
[client, refresh]
)
const handleDelete = useCallback(
async (model: MobileSpeechModel) => {
if (!client) {
return
}
const deletedSelectedModel = setup?.selectedModelId === model.id
setBusyAction({ modelId: model.id, type: 'delete' })
setError(null)
try {
setSetup(await deleteDictationModel(client, model.id))
if (deletedSelectedModel) {
setModelDrawerOpen(false)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed')
} finally {
setBusyAction(null)
}
},
[client, setup?.selectedModelId]
)
const enabled = setup?.enabled ?? false
const selectedModel = setup?.models.find((m) => m.id === setup.selectedModelId)
const selectedModelLabel = selectedModel?.label ?? 'None selected'
@ -276,9 +301,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
<VoiceModelList
setup={setup}
disabled={false}
busyModelId={busyModelId}
busyAction={busyAction}
onUseModel={(m) => void handleUseModel(m)}
onDownload={(m) => void handleDownload(m)}
onDelete={(m) => void handleDelete(m)}
/>
) : null}
</BottomDrawer>

View File

@ -1,5 +1,5 @@
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
import { Check, Download } from 'lucide-react-native'
import { Check, Download, Trash2 } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
isModelInFlight,
@ -11,9 +11,10 @@ type Props = {
setup: MobileSpeechSetup
// Disabled mirrors desktop: the model list greys out when dictation is off.
disabled: boolean
busyModelId: string | null
busyAction: { modelId: string; type: 'download' | 'select' | 'delete' } | null
onUseModel: (model: MobileSpeechModel) => void
onDownload: (model: MobileSpeechModel) => void
onDelete: (model: MobileSpeechModel) => void
}
function formatSize(bytes: number | null): string {
@ -38,27 +39,34 @@ function modelMeta(model: MobileSpeechModel): string {
}
// Renders the speech-model rows shared between the setup sheet and the Voice
// settings page: size/progress, recommended badge, selected check, download.
// settings page: size/progress, recommended badge, selected check, download, delete.
export function VoiceModelList({
setup,
disabled,
busyModelId,
busyAction,
onUseModel,
onDownload
onDownload,
onDelete
}: Props): React.JSX.Element {
return (
<View style={disabled ? styles.disabled : undefined} pointerEvents={disabled ? 'none' : 'auto'}>
{setup.models.map((model, idx) => {
const anyBusy = busyAction !== null
const isSelected = model.id === setup.selectedModelId
const inFlight = isModelInFlight(model)
const rowBusy = busyModelId === model.id
const rowBusy = busyAction?.modelId === model.id
const selectBusy = rowBusy && busyAction?.type === 'select'
const downloadBusy = rowBusy && busyAction?.type === 'download'
const deleteBusy = rowBusy && busyAction?.type === 'delete'
return (
<View key={model.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.modelRow}>
<View style={styles.modelInfo}>
<View style={styles.modelTitleRow}>
<Text style={styles.modelLabel}>{model.label}</Text>
<Text style={styles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
{model.recommended ? <Text style={styles.recommended}>Recommended</Text> : null}
</View>
<Text style={styles.modelMeta}>{modelMeta(model)}</Text>
@ -68,30 +76,51 @@ export function VoiceModelList({
{model.status === 'ready' ? 'API key set' : 'Set up on desktop'}
</Text>
) : model.status === 'ready' ? (
isSelected ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
</View>
) : (
<View style={styles.readyActions}>
{isSelected ? (
<View style={styles.selectedTag}>
<Check size={14} color={colors.statusGreen} strokeWidth={2.4} />
<Text style={styles.selectedText}>In use</Text>
</View>
) : (
<Pressable
style={({ pressed }) => [
styles.actionButton,
pressed && styles.actionPressed
]}
disabled={anyBusy}
onPress={() => onUseModel(model)}
>
{selectBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text style={styles.actionText}>Use</Text>
)}
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.actionButton, pressed && styles.actionPressed]}
disabled={rowBusy}
onPress={() => onUseModel(model)}
style={({ pressed }) => [styles.iconButton, pressed && styles.actionPressed]}
disabled={anyBusy}
onPress={() => onDelete(model)}
accessibilityLabel={'Delete ' + model.label}
>
<Text style={styles.actionText}>Use</Text>
{deleteBusy ? (
<ActivityIndicator size="small" color={colors.statusRed} />
) : (
<Trash2 size={18} color={colors.statusRed} strokeWidth={2.2} />
)}
</Pressable>
)
</View>
) : inFlight ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.actionPressed]}
disabled={rowBusy}
disabled={anyBusy}
onPress={() => onDownload(model)}
accessibilityLabel={'Download ' + model.label}
>
{rowBusy ? (
{downloadBusy ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Download size={18} color={colors.textSecondary} strokeWidth={2.2} />
@ -118,7 +147,12 @@ const styles = StyleSheet.create({
},
modelInfo: { flex: 1, minWidth: 0 },
modelTitleRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
modelLabel: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '500' },
modelLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '500',
flexShrink: 1
},
recommended: { color: colors.statusGreen, fontSize: 10, fontWeight: '700' },
modelMeta: { color: colors.textMuted, fontSize: typography.metaSize, marginTop: 2 },
modelStateText: { color: colors.textMuted, fontSize: typography.metaSize },
@ -141,6 +175,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
readyActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
selectedTag: { flexDirection: 'row', alignItems: 'center', gap: 4 },
selectedText: { color: colors.statusGreen, fontSize: typography.metaSize, fontWeight: '600' },
separator: {

View File

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import {
deleteDictationModel,
downloadDictationModel,
fetchDictationSetup,
isDictationReady,
@ -72,6 +73,13 @@ describe('rpc wrappers', () => {
expect(client.calls[0]).toEqual({ method: 'speech.models.download', params: { modelId: 'm1' } })
})
it('deletes a model and returns refreshed setup', async () => {
const setup: MobileSpeechSetup = { enabled: true, selectedModelId: '', models: [] }
const client = clientWith([ok(setup)])
await expect(deleteDictationModel(client, 'm1')).resolves.toEqual(setup)
expect(client.calls[0]).toEqual({ method: 'speech.models.delete', params: { modelId: 'm1' } })
})
it('sets config', async () => {
const setup: MobileSpeechSetup = { enabled: true, selectedModelId: 'm1', models: [] }
const client = clientWith([ok(setup)])

View File

@ -51,6 +51,17 @@ export async function downloadDictationModel(
}
}
export async function deleteDictationModel(
client: Pick<RpcClient, 'sendRequest'>,
modelId: string
): Promise<MobileSpeechSetup> {
const response = await client.sendRequest('speech.models.delete', { modelId })
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to delete model')
}
return (response as RpcSuccess).result as MobileSpeechSetup
}
export async function setDictationConfig(
client: Pick<RpcClient, 'sendRequest'>,
params: { enabled?: boolean; modelId?: string; dictationMode?: 'toggle' | 'hold' }

View File

@ -1,12 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, fromWebContentsMock, getSpeechModelManagerMock, getSpeechSttServiceMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
fromWebContentsMock: vi.fn(),
getSpeechModelManagerMock: vi.fn(),
getSpeechSttServiceMock: vi.fn()
}))
const {
handleMock,
fromWebContentsMock,
getSpeechModelManagerMock,
getSpeechSttServiceMock,
deleteLocalSpeechModelMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
fromWebContentsMock: vi.fn(),
getSpeechModelManagerMock: vi.fn(),
getSpeechSttServiceMock: vi.fn(),
deleteLocalSpeechModelMock: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: vi.fn(() => '/tmp/orca-speech-test') },
@ -33,6 +39,10 @@ vi.mock('../speech/speech-runtime-service', () => ({
getSpeechSttService: getSpeechSttServiceMock
}))
vi.mock('../speech/speech-model-deletion', () => ({
deleteLocalSpeechModel: deleteLocalSpeechModelMock
}))
import { registerSpeechHandlers } from './speech'
type SpeechDownloadHandler = (event: { sender: { id: number } }, modelId: string) => Promise<void>
@ -51,6 +61,7 @@ describe('registerSpeechHandlers', () => {
fromWebContentsMock.mockReset()
getSpeechModelManagerMock.mockReset()
getSpeechSttServiceMock.mockReset()
deleteLocalSpeechModelMock.mockReset()
})
it('clears the model download progress callback after completion', async () => {
@ -126,4 +137,23 @@ describe('registerSpeechHandlers', () => {
expect(clearProgressCallback).toHaveBeenCalledTimes(1)
expect(window.off).toHaveBeenCalledWith('closed', expect.any(Function))
})
it('routes desktop model deletion through the shared deletion helper', async () => {
const store = {} as never
const manager = { deleteModel: vi.fn() }
const sttService = { prepareModelForDeletion: vi.fn() }
getSpeechModelManagerMock.mockReturnValue(manager)
getSpeechSttServiceMock.mockReturnValue(sttService)
deleteLocalSpeechModelMock.mockResolvedValue(undefined)
registerSpeechHandlers(store)
await getHandler('speech:deleteModel')({ sender: { id: 7 } }, 'model-1')
expect(deleteLocalSpeechModelMock).toHaveBeenCalledWith({
store,
modelManager: manager,
sttService,
modelId: 'model-1'
})
})
})

View File

@ -2,7 +2,8 @@ import { ipcMain, BrowserWindow, systemPreferences, app } from 'electron'
import { join } from 'path'
import { writeFile, unlink } from 'fs/promises'
import { createHash } from 'crypto'
import { SPEECH_MODEL_CATALOG, getCatalogModel } from '../speech/model-catalog'
import { SPEECH_MODEL_CATALOG } from '../speech/model-catalog'
import { deleteLocalSpeechModel } from '../speech/speech-model-deletion'
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
import {
clearOpenAiSpeechApiKey,
@ -69,10 +70,12 @@ export function registerSpeechHandlers(store: Store): void {
})
ipcMain.handle('speech:deleteModel', async (_event, modelId: string) => {
if (!getCatalogModel(modelId)) {
throw new Error(`Unknown model: ${modelId}`)
}
await getSpeechModelManager(store).deleteModel(modelId)
await deleteLocalSpeechModel({
store,
modelManager: getSpeechModelManager(store),
sttService: getSpeechSttService(store),
modelId
})
})
const getHotwordsFilePath = (content: string): string => {

View File

@ -625,6 +625,10 @@ import type { RateLimitState } from '../../shared/rate-limit-types'
import type { VoiceSettings } from '../../shared/speech-types'
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
import { getCatalogModel, isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog'
import {
deleteLocalSpeechModel,
getSpeechModelDeletionErrorCode
} from '../speech/speech-model-deletion'
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
import { scanNestedRepos } from '../project-groups/nested-repo-discovery'
import {
@ -2980,7 +2984,9 @@ export class OrcaRuntimeService {
.map((group) => {
const tabOrder = retainedOrder.get(group.id) ?? []
const keptActive =
group.activeTabId && tabOrder.includes(group.activeTabId) && liveTabIds.has(group.activeTabId)
group.activeTabId &&
tabOrder.includes(group.activeTabId) &&
liveTabIds.has(group.activeTabId)
? group.activeTabId
: null
return {
@ -3370,8 +3376,7 @@ export class OrcaRuntimeService {
groupIdByTabId.set(newTabAssignment!.tabId, newTabAssignment!.groupId)
}
const activeGroupId =
(activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ??
existingGroups[0]!.id
(activeTopLevelId ? groupIdByTabId.get(activeTopLevelId) : undefined) ?? existingGroups[0]!.id
const orderByGroup = new Map<string, string[]>(existingGroups.map((group) => [group.id, []]))
for (const tabId of tabOrder) {
const groupId = groupIdByTabId.get(tabId) ?? activeGroupId
@ -4061,14 +4066,12 @@ export class OrcaRuntimeService {
// Merge the client's pane structure into the persisted tab layout. PTY
// bindings and active leaf stay host-owned; only ratios/expand/titles change.
// terminalLayoutsByTabId is keyed by tab id (worktree-independent).
private persistHeadlessTerminalPaneLayout(
args: {
tabId: string
root: TerminalPaneLayoutNode | null
expandedLeafId: string | null
titlesByLeafId?: Record<string, string>
}
): void {
private persistHeadlessTerminalPaneLayout(args: {
tabId: string
root: TerminalPaneLayoutNode | null
expandedLeafId: string | null
titlesByLeafId?: Record<string, string>
}): void {
const session = this.store?.getWorkspaceSession?.()
if (!session || !this.store?.setWorkspaceSession) {
return
@ -4167,11 +4170,7 @@ export class OrcaRuntimeService {
})
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
const reorderedTargetActiveTabId =
active?.type === 'terminal'
? active.parentTabId
: active
? active.id
: (tabOrder[0] ?? null)
active?.type === 'terminal' ? active.parentTabId : active ? active.id : (tabOrder[0] ?? null)
// Why: reorder only changes ONE group's order. Preserve every other group so
// a multi-group split isn't deleted by re-sorting tabs in one of its groups.
const existingGroups = snapshot.tabGroups ?? []
@ -4323,9 +4322,7 @@ export class OrcaRuntimeService {
...session,
tabsByWorktree: {
...session.tabsByWorktree,
[worktreeId]: tabs.map((tab) =>
tab.id === tabId ? { ...tab, customTitle: title } : tab
)
[worktreeId]: tabs.map((tab) => (tab.id === tabId ? { ...tab, customTitle: title } : tab))
}
})
}
@ -5549,6 +5546,28 @@ export class OrcaRuntimeService {
return { started: true }
}
async deleteMobileSpeechModel(modelId: string): Promise<RuntimeSpeechSetupState> {
if (!this.store?.getSettings || !this.store.updateSettings) {
throw new Error('voice_dictation_unavailable')
}
const store = this.store
try {
// The runtime store is adapted to the minimal speech settings contract used by deletion.
await deleteLocalSpeechModel({
store: {
getSettings: () => store.getSettings(),
updateSettings: (updates, options) => store.updateSettings?.(updates, options)
},
modelManager: getSpeechModelManager(store),
sttService: getSpeechSttService(store),
modelId
})
} catch (error) {
throw new Error(getSpeechModelDeletionErrorCode(error) ?? 'voice_model_delete_failed')
}
return this.listMobileSpeechModels()
}
// Enables/disables dictation and/or selects the model, merging into the
// existing voice settings so other voice fields are preserved.
async configureMobileDictation(params: {
@ -15118,8 +15137,7 @@ export class OrcaRuntimeService {
} catch {
warnings.push({
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
message:
'Worktree created, but Orca could not validate the environment parent context.',
message: 'Worktree created, but Orca could not validate the environment parent context.',
details: { envParentWorkspace: input.envParentWorkspace }
})
}

View File

@ -102,6 +102,35 @@ describe('speech RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { started: true } })
})
it('deletes a speech model and returns refreshed setup', async () => {
const setup = { enabled: true, selectedModelId: '', dictationMode: 'toggle', models: [] }
const runtime = {
getRuntimeId: () => 'test-runtime',
deleteMobileSpeechModel: vi.fn().mockResolvedValue(setup)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS })
const response = await dispatcher.dispatch(
makeRequest('speech.models.delete', { modelId: 'parakeet-tdt-0.6b-v3-int8' })
)
expect(runtime.deleteMobileSpeechModel).toHaveBeenCalledWith('parakeet-tdt-0.6b-v3-int8')
expect(response).toMatchObject({ ok: true, result: setup })
})
it('rejects invalid speech model delete params', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
deleteMobileSpeechModel: vi.fn()
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SPEECH_METHODS })
const response = await dispatcher.dispatch(makeRequest('speech.models.delete', {}))
expect(response).toMatchObject({ ok: false })
expect(runtime.deleteMobileSpeechModel).not.toHaveBeenCalled()
})
it('configures dictation enable + model selection', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -38,7 +38,7 @@ const DictationHandle = z.object({
dictationId: requiredString('Missing dictation ID')
})
const SpeechModelDownload = z.object({
const SpeechModelAction = z.object({
modelId: requiredString('Missing model ID')
})
@ -56,9 +56,14 @@ export const SPEECH_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'speech.models.download',
params: SpeechModelDownload,
params: SpeechModelAction,
handler: async (params, { runtime }) => runtime.downloadMobileSpeechModel(params.modelId)
}),
defineMethod({
name: 'speech.models.delete',
params: SpeechModelAction,
handler: async (params, { runtime }) => runtime.deleteMobileSpeechModel(params.modelId)
}),
defineMethod({
name: 'speech.dictation.setup',
params: DictationSetup,

View File

@ -305,6 +305,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'speech.dictation.finish',
'speech.dictation.setup',
'speech.dictation.start',
'speech.models.delete',
'speech.models.download',
'speech.models.list',
'stats.summary',

View File

@ -1,7 +1,7 @@
import { createHash } from 'crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { dirname, join } from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { ModelManager } from './model-manager'
@ -122,6 +122,35 @@ describe('ModelManager', () => {
}
})
it('deletes a ready local model and reports it as not downloaded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {
const manifest = SPEECH_MODEL_CATALOG.find((model) => model.provider === 'local')
expect(manifest?.files).toBeDefined()
const manager = new ModelManager(dir)
const modelDir = manager.getModelDir(manifest!.id)
for (const file of manifest!.files ?? []) {
const path = join(modelDir, file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, 'model file')
}
await expect(manager.getModelState(manifest!.id)).resolves.toEqual({
id: manifest!.id,
status: 'ready'
})
await manager.deleteModel(manifest!.id)
expect(existsSync(modelDir)).toBe(false)
await expect(manager.getModelState(manifest!.id)).resolves.toEqual({
id: manifest!.id,
status: 'not-downloaded'
})
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('aborts an in-flight model download request when cancelled', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
try {

View File

@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultVoiceSettings } from '../../shared/constants'
import type { VoiceSettings } from '../../shared/speech-types'
import type { GlobalSettings } from '../../shared/types'
import { SPEECH_MODEL_CATALOG } from './model-catalog'
import { deleteLocalSpeechModel } from './speech-model-deletion'
const localModel = SPEECH_MODEL_CATALOG.find((model) => model.provider === 'local')
const cloudModel = SPEECH_MODEL_CATALOG.find((model) => model.provider === 'openai')
function makeStore(initialVoice: VoiceSettings) {
let voice = initialVoice
const updateSettings = vi.fn((updates: Partial<GlobalSettings>) => {
if (updates.voice) {
voice = updates.voice
}
return { voice } as GlobalSettings
})
return {
getSettings: vi.fn(() => ({ voice }) as GlobalSettings),
updateSettings
}
}
describe('deleteLocalSpeechModel', () => {
it('clears the selected local model after deletion succeeds', async () => {
expect(localModel).toBeDefined()
const calls: string[] = []
const voice = { ...getDefaultVoiceSettings(), enabled: true, sttModel: localModel!.id }
const store = makeStore(voice)
const modelManager = {
deleteModel: vi.fn(async () => {
calls.push('delete')
})
}
const sttService = {
prepareModelForDeletion: vi.fn(async () => {
calls.push('prepare')
})
}
await deleteLocalSpeechModel({
store,
modelManager,
sttService,
modelId: localModel!.id
})
expect(calls).toEqual(['prepare', 'delete'])
expect(store.updateSettings).toHaveBeenCalledWith(
{
voice: {
...voice,
sttModel: ''
}
},
{ notifyListeners: true }
)
})
it('does not clear selection when another client selected a newer model', async () => {
expect(localModel).toBeDefined()
const voice = { ...getDefaultVoiceSettings(), enabled: true, sttModel: localModel!.id }
const store = makeStore(voice)
const modelManager = {
deleteModel: vi.fn(async () => {
store.updateSettings({ voice: { ...voice, sttModel: 'newer-model' } })
})
}
const sttService = { prepareModelForDeletion: vi.fn(async () => {}) }
await deleteLocalSpeechModel({
store,
modelManager,
sttService,
modelId: localModel!.id
})
expect(store.updateSettings).toHaveBeenCalledTimes(1)
expect(store.updateSettings).toHaveBeenCalledWith({
voice: { ...voice, sttModel: 'newer-model' }
})
})
it('leaves settings untouched when deletion fails', async () => {
expect(localModel).toBeDefined()
const store = makeStore({ ...getDefaultVoiceSettings(), sttModel: localModel!.id })
const modelManager = {
deleteModel: vi.fn(async () => {
throw new Error('permission denied')
})
}
const sttService = { prepareModelForDeletion: vi.fn(async () => {}) }
await expect(
deleteLocalSpeechModel({
store,
modelManager,
sttService,
modelId: localModel!.id
})
).rejects.toThrow('permission denied')
expect(store.updateSettings).not.toHaveBeenCalled()
})
it('rejects unknown and cloud models before preparing storage deletion', async () => {
expect(cloudModel).toBeDefined()
const store = makeStore(getDefaultVoiceSettings())
const modelManager = { deleteModel: vi.fn(async () => {}) }
const sttService = { prepareModelForDeletion: vi.fn(async () => {}) }
await expect(
deleteLocalSpeechModel({
store,
modelManager,
sttService,
modelId: 'missing-model'
})
).rejects.toThrow('voice_model_unknown')
await expect(
deleteLocalSpeechModel({
store,
modelManager,
sttService,
modelId: cloudModel!.id
})
).rejects.toThrow('voice_model_not_deletable')
expect(sttService.prepareModelForDeletion).not.toHaveBeenCalled()
expect(modelManager.deleteModel).not.toHaveBeenCalled()
expect(store.updateSettings).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,79 @@
import { getDefaultVoiceSettings } from '../../shared/constants'
import type { VoiceSettings } from '../../shared/speech-types'
import { getCatalogModel, isLocalSpeechModel } from './model-catalog'
import type { ModelManager } from './model-manager'
import type { SttService } from './stt-service'
export type SpeechModelDeletionErrorCode =
| 'voice_model_unknown'
| 'voice_model_not_deletable'
| 'voice_model_in_use'
export class SpeechModelDeletionError extends Error {
constructor(readonly code: SpeechModelDeletionErrorCode) {
super(code)
this.name = 'SpeechModelDeletionError'
}
}
type SpeechModelDeletionStore = {
getSettings: () => {
voice?: VoiceSettings
}
updateSettings: (
updates: {
voice: VoiceSettings
},
options?: { notifyListeners?: boolean; originWebContentsId?: number }
) => unknown
}
type DeleteLocalSpeechModelArgs = {
store: SpeechModelDeletionStore
modelManager: Pick<ModelManager, 'deleteModel'>
sttService: Pick<SttService, 'prepareModelForDeletion'>
modelId: string
}
export function getSpeechModelDeletionErrorCode(
error: unknown
): SpeechModelDeletionErrorCode | null {
if (error instanceof SpeechModelDeletionError) {
return error.code
}
if (error instanceof Error && error.message === 'voice_model_in_use') {
return 'voice_model_in_use'
}
return null
}
export async function deleteLocalSpeechModel({
store,
modelManager,
sttService,
modelId
}: DeleteLocalSpeechModelArgs): Promise<void> {
const manifest = getCatalogModel(modelId)
if (!manifest) {
throw new SpeechModelDeletionError('voice_model_unknown')
}
if (!isLocalSpeechModel(manifest)) {
throw new SpeechModelDeletionError('voice_model_not_deletable')
}
await sttService.prepareModelForDeletion(modelId)
await modelManager.deleteModel(modelId)
const currentVoice = store.getSettings().voice ?? getDefaultVoiceSettings()
if (currentVoice.sttModel === modelId) {
store.updateSettings(
{
voice: {
...currentVoice,
sttModel: ''
}
},
{ notifyListeners: true }
)
}
}

View File

@ -239,6 +239,70 @@ describe('SttService', () => {
expect(worker!.messages.filter((message) => message.type === 'feed')).toHaveLength(0)
})
it('rejects deletion prep while the target model is starting', async () => {
let resolveModelState: (state: { id: string; status: string }) => void = () => {}
const modelStatePromise = new Promise<{ id: string; status: string }>((resolve) => {
resolveModelState = resolve
})
const service = new SttService({
getModelState: vi.fn(() => modelStatePromise),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
const startPromise = service.startDictation('model-a', vi.fn(), undefined, 'desktop')
await Promise.resolve()
await expect(service.prepareModelForDeletion('model-a')).rejects.toThrow('voice_model_in_use')
resolveModelState({ id: 'model-a', status: 'ready' })
await startPromise
await service.stopDictation('desktop')
})
it('tears down an idle warm worker before deleting the target model', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
const worker = getLastWorker()
expect(worker).toBeDefined()
await service.stopDictation('desktop')
await service.prepareModelForDeletion('model-a')
expect(worker!.messages.some((message) => message.type === 'teardown')).toBe(true)
expect(worker!.terminated).toBe(true)
expect(service.isActive()).toBe(false)
})
it('rejects deletion prep when a target warm worker cannot be torn down during another start', async () => {
let resolveModelState: (state: { id: string; status: string }) => void = () => {}
const secondModelState = new Promise<{ id: string; status: string }>((resolve) => {
resolveModelState = resolve
})
const getModelState = vi
.fn()
.mockResolvedValueOnce({ id: 'model-a', status: 'ready' })
.mockReturnValue(secondModelState)
const service = new SttService({
getModelState,
getModelDir: vi.fn().mockReturnValue('/tmp/model-a')
} as never)
await service.startDictation('model-a', vi.fn(), undefined, 'desktop')
await service.stopDictation('desktop')
const startOtherModel = service.startDictation('model-b', vi.fn(), undefined, 'desktop')
await Promise.resolve()
await expect(service.prepareModelForDeletion('model-a')).rejects.toThrow('voice_model_in_use')
resolveModelState({ id: 'model-b', status: 'ready' })
await startOtherModel
await service.stopDictation('desktop')
})
it('uses the OpenAI transcription session without creating a worker', async () => {
const sink = vi.fn()
const service = new SttService({

View File

@ -30,6 +30,7 @@ export class SttService {
private activeHotwordsFilePath: string | undefined
private activeOwner: string | null = null
private startingOwner: string | null = null
private startingModelId: string | null = null
private starting = false
private canceledOwners = new Set<string>()
private eventSink: SttEventSink | null = null
@ -59,6 +60,7 @@ export class SttService {
}
this.starting = true
this.startingOwner = owner
this.startingModelId = modelId
this.clearIdleTeardownTimer()
try {
@ -71,6 +73,7 @@ export class SttService {
} finally {
this.starting = false
this.startingOwner = null
this.startingModelId = null
this.canceledOwners.delete(owner)
}
}
@ -382,6 +385,18 @@ export class SttService {
return this.activeModelId
}
async prepareModelForDeletion(modelId: string): Promise<void> {
if (this.startingModelId === modelId || (this.activeOwner && this.activeModelId === modelId)) {
throw new Error('voice_model_in_use')
}
if (this.worker && this.activeModelId === modelId) {
await this.teardownIdleWorker({ ignoreTerminateErrors: false })
if (this.worker && this.activeModelId === modelId) {
throw new Error('voice_model_in_use')
}
}
}
private getWorkerPath(): string {
if (app.isPackaged) {
return join(process.resourcesPath, 'app.asar', 'out', 'main', 'stt-worker.js')
@ -407,16 +422,24 @@ export class SttService {
this.idleTeardownTimer.unref?.()
}
private async teardownIdleWorker(): Promise<void> {
private async teardownIdleWorker(
options: { ignoreTerminateErrors?: boolean } = { ignoreTerminateErrors: true }
): Promise<void> {
this.clearIdleTeardownTimer()
if (!this.worker || this.activeOwner || this.startingOwner) {
return
}
const worker = this.worker
worker.postMessage({ type: 'teardown' })
try {
await worker.terminate()
} catch (error) {
if (!options.ignoreTerminateErrors) {
throw error
}
}
this.cleanupActiveWorkerLifecycleListeners()
worker.removeAllListeners()
await worker.terminate().catch(() => undefined)
if (this.worker === worker) {
this.worker = null
this.activeModelId = null

View File

@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { act } from 'react'
import type { ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types'
import { getDefaultVoiceSettings } from '../../../../shared/constants'
const toastErrorMock = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({
toast: {
error: toastErrorMock
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, values?: Record<string, string>) =>
values ? fallback.replace('{{value0}}', values.value0) : fallback
}))
vi.mock('../ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({
children,
disabled,
onSelect,
className
}: {
children: ReactNode
disabled?: boolean
onSelect?: () => void
className?: string
}) => (
<div
className={className}
aria-disabled={disabled}
role="option"
onClick={() => {
if (!disabled) {
onSelect?.()
}
}}
>
{children}
</div>
)
}))
import { VoiceSpeechModelSection } from './VoiceSpeechModelSection'
const localModel: SpeechModelManifest = {
id: 'model-a',
label: 'Local Model',
description: 'Runs offline',
provider: 'local',
language: 'en',
type: 'transducer',
streaming: true,
sampleRate: 16000,
sizeBytes: 123_000_000,
files: ['encoder.onnx']
}
const secondLocalModel: SpeechModelManifest = {
...localModel,
id: 'model-b',
label: 'Second Local Model'
}
function renderSection(args: {
deleteModel: (modelId: string) => Promise<void>
catalog?: SpeechModelManifest[]
modelStates?: SpeechModelState[]
refreshModelStates?: () => void
}): { container: HTMLDivElement; root: Root } {
Object.assign(window, {
api: {
speech: {
deleteModel: vi.fn(args.deleteModel),
downloadModel: vi.fn()
}
}
})
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const voiceSettings = { ...getDefaultVoiceSettings(), enabled: true, sttModel: localModel.id }
const catalog = args.catalog ?? [localModel]
const modelStates = args.modelStates ?? [{ id: localModel.id, status: 'ready' }]
act(() => {
root.render(
<VoiceSpeechModelSection
voiceSettings={voiceSettings}
catalog={catalog}
modelStates={modelStates}
onUpdateVoiceSettings={vi.fn()}
onOpenOpenAiDialog={vi.fn()}
onRefreshModelStates={args.refreshModelStates ?? vi.fn()}
/>
)
})
return { container, root }
}
describe('VoiceSpeechModelSection', () => {
beforeEach(() => {
toastErrorMock.mockReset()
})
afterEach(() => {
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
it('shows delete for the selected ready local row and refreshes after success', async () => {
let resolveDelete: () => void = () => {}
const refreshModelStates = vi.fn()
const { container, root } = renderSection({
refreshModelStates,
deleteModel: () =>
new Promise<void>((resolve) => {
resolveDelete = resolve
})
})
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Local Model"]'
)
expect(deleteButton).not.toBeNull()
await act(async () => {
deleteButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(window.api.speech.deleteModel).toHaveBeenCalledWith(localModel.id)
expect(deleteButton!.disabled).toBe(true)
await act(async () => {
deleteButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
resolveDelete()
await Promise.resolve()
})
expect(window.api.speech.deleteModel).toHaveBeenCalledTimes(1)
expect(refreshModelStates).toHaveBeenCalledTimes(1)
root.unmount()
})
it('keeps another row delete disabled until its own request finishes', async () => {
const deleteResolvers = new Map<string, () => void>()
const refreshModelStates = vi.fn()
const { container, root } = renderSection({
refreshModelStates,
catalog: [localModel, secondLocalModel],
modelStates: [
{ id: localModel.id, status: 'ready' },
{ id: secondLocalModel.id, status: 'ready' }
],
deleteModel: (modelId) =>
new Promise<void>((resolve) => {
deleteResolvers.set(modelId, resolve)
})
})
const firstDeleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Local Model"]'
)
const secondDeleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Second Local Model"]'
)
await act(async () => {
firstDeleteButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await act(async () => {
secondDeleteButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(firstDeleteButton!.disabled).toBe(true)
expect(secondDeleteButton!.disabled).toBe(true)
await act(async () => {
deleteResolvers.get(localModel.id)!()
await Promise.resolve()
})
expect(firstDeleteButton!.disabled).toBe(false)
expect(secondDeleteButton!.disabled).toBe(true)
await act(async () => {
deleteResolvers.get(secondLocalModel.id)!()
await Promise.resolve()
})
expect(refreshModelStates).toHaveBeenCalledTimes(2)
root.unmount()
})
it('shows the existing error toast when selected-row deletion fails', async () => {
const refreshModelStates = vi.fn()
const { container, root } = renderSection({
refreshModelStates,
deleteModel: () => Promise.reject(new Error('in use'))
})
const deleteButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Delete Local Model"]'
)
await act(async () => {
deleteButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
})
expect(toastErrorMock).toHaveBeenCalledWith('Failed to delete model.')
expect(refreshModelStates).not.toHaveBeenCalled()
root.unmount()
})
})

View File

@ -1,3 +1,4 @@
import { useState } from 'react'
import { toast } from 'sonner'
import type { VoiceSettings } from '../../../../shared/speech-types'
import type { SpeechModelManifest, SpeechModelState } from '../../../../shared/speech-types'
@ -29,6 +30,7 @@ export function VoiceSpeechModelSection({
onOpenOpenAiDialog,
onRefreshModelStates
}: VoiceSpeechModelSectionProps): React.JSX.Element {
const [pendingDeleteModelIds, setPendingDeleteModelIds] = useState<Set<string>>(() => new Set())
const getModelState = (id: string): SpeechModelState | undefined =>
modelStates.find((s) => s.id === id)
@ -73,6 +75,7 @@ export function VoiceSpeechModelSection({
mState?.status === 'downloading' || mState?.status === 'extracting'
const isActive = voiceSettings.sttModel === manifest.id
const isCloud = manifest.provider === 'openai'
const deletePending = pendingDeleteModelIds.has(manifest.id)
const sizeMb = manifest.sizeBytes ? Math.round(manifest.sizeBytes / 1_000_000) : null
return (
@ -146,10 +149,34 @@ export function VoiceSpeechModelSection({
{manifest.description}
</p>
</div>
{!isCloud && isReady && !isActive ? (
<button
onClick={(e) => {
e.stopPropagation()
{!isCloud && isReady ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.settings.VoicePane.6fa734ed95',
'Delete {{value0}}',
{
value0: manifest.label
}
)}
disabled={deletePending}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (deletePending) {
return
}
setPendingDeleteModelIds((prev) => {
const next = new Set(prev)
next.add(manifest.id)
return next
})
void window.api.speech
.deleteModel(manifest.id)
.then(onRefreshModelStates)
@ -161,11 +188,22 @@ export function VoiceSpeechModelSection({
)
)
)
.finally(() =>
setPendingDeleteModelIds((prev) => {
const next = new Set(prev)
next.delete(manifest.id)
return next
})
)
}}
className="shrink-0 p-1 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 hover:text-destructive transition-all rounded"
className="shrink-0 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 hover:text-destructive disabled:opacity-60 disabled:hover:text-muted-foreground"
>
<Trash2 className="size-3" />
</button>
{deletePending ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
</Button>
) : !isCloud && !isReady && !isDownloading ? (
<span className="shrink-0 p-1 text-muted-foreground can-hover:opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="size-3" />

View File

@ -6277,6 +6277,7 @@
"c2c64b24d0": "Reset"
},
"VoicePane": {
"6fa734ed95": "Delete {{value0}}",
"68de13f72c": "Failed to delete model.",
"1ba81c0ff0": "recommended",
"cfde55c7b0": "Failed to download model.",

View File

@ -6268,7 +6268,8 @@
"ad5d036ecc": "No se pudo solicitar permiso del micrófono. El dictado de voz no estaba habilitado.",
"f9a9cf6928": "Se requiere permiso del micrófono antes de habilitar el dictado de voz.",
"1eac933202": "Se abrió Privacidad y seguridad de macOS. Habilite el dictado nuevamente después de otorgar acceso.",
"cd9fe37556": "Permiso de micrófono concedido"
"cd9fe37556": "Permiso de micrófono concedido",
"6fa734ed95": "Delete {{value0}}"
},
"WorktreeSymlinksSection": {
"1c1e35b219": "Quitar {{value0}}",

View File

@ -6290,7 +6290,8 @@
"ad5d036ecc": "マイクの許可を要求できませんでした。音声ディクテーションが有効になっていませんでした。",
"f9a9cf6928": "音声ディクテーションを有効にする前に、マイクの許可が必要です。",
"1eac933202": "macOS のプライバシーとセキュリティを開きました。アクセスを許可した後、ディクテーションを再度有効にします。",
"cd9fe37556": "マイクの許可が与えられました"
"cd9fe37556": "マイクの許可が与えられました",
"6fa734ed95": "Delete {{value0}}"
},
"WorktreeSymlinksSection": {
"1c1e35b219": "{{value0}} を削除",

View File

@ -6253,7 +6253,8 @@
"ad5d036ecc": "마이크 권한을 요청할 수 없습니다. 음성 받아쓰기가 활성화되지 않았습니다.",
"f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.",
"1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.",
"cd9fe37556": "마이크 권한이 부여되었습니다."
"cd9fe37556": "마이크 권한이 부여되었습니다.",
"6fa734ed95": "Delete {{value0}}"
},
"WorktreeSymlinksSection": {
"1c1e35b219": "{{value0}} 제거",

View File

@ -6253,7 +6253,8 @@
"ad5d036ecc": "无法请求麦克风权限。未启用语音听写。",
"f9a9cf6928": "启用语音听写之前需要麦克风许可。",
"1eac933202": "打开 macOS 隐私和安全。授予访问权限后再次启用听写。",
"cd9fe37556": "已授予麦克风权限"
"cd9fe37556": "已授予麦克风权限",
"6fa734ed95": "Delete {{value0}}"
},
"WorktreeSymlinksSection": {
"1c1e35b219": "删除 {{value0}}",