diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index cfee1854f..a6c725c0e 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -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(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [busyModelId, setBusyModelId] = useState(null) + const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) const pollRef = useRef | 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 { void handleUseModel(m)} onDownload={(m) => void handleDownload(m)} + onDelete={(m) => void handleDelete(m)} /> ) : null} diff --git a/mobile/src/components/VoiceModelList.tsx b/mobile/src/components/VoiceModelList.tsx index 4f74d18b5..4151f2591 100644 --- a/mobile/src/components/VoiceModelList.tsx +++ b/mobile/src/components/VoiceModelList.tsx @@ -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 ( {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 ( {idx > 0 && } - {model.label} + + {model.label} + {model.recommended ? Recommended : null} {modelMeta(model)} @@ -68,30 +76,51 @@ export function VoiceModelList({ {model.status === 'ready' ? 'API key set' : 'Set up on desktop'} ) : model.status === 'ready' ? ( - isSelected ? ( - - - In use - - ) : ( + + {isSelected ? ( + + + In use + + ) : ( + [ + styles.actionButton, + pressed && styles.actionPressed + ]} + disabled={anyBusy} + onPress={() => onUseModel(model)} + > + {selectBusy ? ( + + ) : ( + Use + )} + + )} [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} > - Use + {deleteBusy ? ( + + ) : ( + + )} - ) + ) : inFlight ? ( ) : ( [styles.iconButton, pressed && styles.actionPressed]} - disabled={rowBusy} + disabled={anyBusy} onPress={() => onDownload(model)} accessibilityLabel={'Download ' + model.label} > - {rowBusy ? ( + {downloadBusy ? ( ) : ( @@ -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: { diff --git a/mobile/src/dictation/mobile-dictation-setup.test.ts b/mobile/src/dictation/mobile-dictation-setup.test.ts index 375f989f1..d72854afc 100644 --- a/mobile/src/dictation/mobile-dictation-setup.test.ts +++ b/mobile/src/dictation/mobile-dictation-setup.test.ts @@ -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)]) diff --git a/mobile/src/dictation/mobile-dictation-setup.ts b/mobile/src/dictation/mobile-dictation-setup.ts index 489465710..ad4d4b968 100644 --- a/mobile/src/dictation/mobile-dictation-setup.ts +++ b/mobile/src/dictation/mobile-dictation-setup.ts @@ -51,6 +51,17 @@ export async function downloadDictationModel( } } +export async function deleteDictationModel( + client: Pick, + modelId: string +): Promise { + 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, params: { enabled?: boolean; modelId?: string; dictationMode?: 'toggle' | 'hold' } diff --git a/src/main/ipc/speech.test.ts b/src/main/ipc/speech.test.ts index 05d6dbb5e..2e84124b8 100644 --- a/src/main/ipc/speech.test.ts +++ b/src/main/ipc/speech.test.ts @@ -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 @@ -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' + }) + }) }) diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index 053a0038d..808ec375e 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -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 => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1c68dff4b..66b728103 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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(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 - } - ): void { + private persistHeadlessTerminalPaneLayout(args: { + tabId: string + root: TerminalPaneLayoutNode | null + expandedLeafId: string | null + titlesByLeafId?: Record + }): 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 { + 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 } }) } diff --git a/src/main/runtime/rpc/methods/speech.test.ts b/src/main/runtime/rpc/methods/speech.test.ts index 7218836c2..dbc06ab8a 100644 --- a/src/main/runtime/rpc/methods/speech.test.ts +++ b/src/main/runtime/rpc/methods/speech.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index fcbcd5bb0..d686475ab 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -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, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8628989b3..182f0b493 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -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', diff --git a/src/main/speech/model-manager.test.ts b/src/main/speech/model-manager.test.ts index dc5815845..aeacac09c 100644 --- a/src/main/speech/model-manager.test.ts +++ b/src/main/speech/model-manager.test.ts @@ -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 { diff --git a/src/main/speech/speech-model-deletion.test.ts b/src/main/speech/speech-model-deletion.test.ts new file mode 100644 index 000000000..5ce3788d7 --- /dev/null +++ b/src/main/speech/speech-model-deletion.test.ts @@ -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) => { + 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() + }) +}) diff --git a/src/main/speech/speech-model-deletion.ts b/src/main/speech/speech-model-deletion.ts new file mode 100644 index 000000000..32a0c3363 --- /dev/null +++ b/src/main/speech/speech-model-deletion.ts @@ -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 + sttService: Pick + 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 { + 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 } + ) + } +} diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index ce0bbc77d..5138831d8 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -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({ diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index f31a3b90e..32e3a16ec 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -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() 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 { + 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 { + private async teardownIdleWorker( + options: { ignoreTerminateErrors?: boolean } = { ignoreTerminateErrors: true } + ): Promise { 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 diff --git a/src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx b/src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx new file mode 100644 index 000000000..b6e061994 --- /dev/null +++ b/src/renderer/src/components/settings/VoiceSpeechModelSection.test.tsx @@ -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) => + values ? fallback.replace('{{value0}}', values.value0) : fallback +})) + +vi.mock('../ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + disabled, + onSelect, + className + }: { + children: ReactNode + disabled?: boolean + onSelect?: () => void + className?: string + }) => ( +
{ + if (!disabled) { + onSelect?.() + } + }} + > + {children} +
+ ) +})) + +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 + 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( + + ) + }) + + 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((resolve) => { + resolveDelete = resolve + }) + }) + const deleteButton = container.querySelector( + '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 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((resolve) => { + deleteResolvers.set(modelId, resolve) + }) + }) + const firstDeleteButton = container.querySelector( + 'button[aria-label="Delete Local Model"]' + ) + const secondDeleteButton = container.querySelector( + '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( + '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() + }) +}) diff --git a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx index 870b498b5..0380bbc0c 100644 --- a/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx +++ b/src/renderer/src/components/settings/VoiceSpeechModelSection.tsx @@ -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>(() => 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}

- {!isCloud && isReady && !isActive ? ( - + {deletePending ? ( + + ) : ( + + )} + ) : !isCloud && !isReady && !isDownloading ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 25c6577d3..be7d84706 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6277,6 +6277,7 @@ "c2c64b24d0": "Reset" }, "VoicePane": { + "6fa734ed95": "Delete {{value0}}", "68de13f72c": "Failed to delete model.", "1ba81c0ff0": "recommended", "cfde55c7b0": "Failed to download model.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0866938eb..a0a53a4a9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 50f47f047..e7ce8ab1d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -6290,7 +6290,8 @@ "ad5d036ecc": "マイクの許可を要求できませんでした。音声ディクテーションが有効になっていませんでした。", "f9a9cf6928": "音声ディクテーションを有効にする前に、マイクの許可が必要です。", "1eac933202": "macOS のプライバシーとセキュリティを開きました。アクセスを許可した後、ディクテーションを再度有効にします。", - "cd9fe37556": "マイクの許可が与えられました" + "cd9fe37556": "マイクの許可が与えられました", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} を削除", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 919609547..aafb68a9a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -6253,7 +6253,8 @@ "ad5d036ecc": "마이크 권한을 요청할 수 없습니다. 음성 받아쓰기가 활성화되지 않았습니다.", "f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.", "1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.", - "cd9fe37556": "마이크 권한이 부여되었습니다." + "cd9fe37556": "마이크 권한이 부여되었습니다.", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "{{value0}} 제거", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1b1569758..1cea91547 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6253,7 +6253,8 @@ "ad5d036ecc": "无法请求麦克风权限。未启用语音听写。", "f9a9cf6928": "启用语音听写之前需要麦克风许可。", "1eac933202": "打开 macOS 隐私和安全。授予访问权限后再次启用听写。", - "cd9fe37556": "已授予麦克风权限" + "cd9fe37556": "已授予麦克风权限", + "6fa734ed95": "Delete {{value0}}" }, "WorktreeSymlinksSection": { "1c1e35b219": "删除 {{value0}}",