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 ? (
-
) : !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}}",