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:
parent
5dbff5d8ea
commit
bc2f5145da
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -6277,6 +6277,7 @@
|
|||
"c2c64b24d0": "Reset"
|
||||
},
|
||||
"VoicePane": {
|
||||
"6fa734ed95": "Delete {{value0}}",
|
||||
"68de13f72c": "Failed to delete model.",
|
||||
"1ba81c0ff0": "recommended",
|
||||
"cfde55c7b0": "Failed to download model.",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -6290,7 +6290,8 @@
|
|||
"ad5d036ecc": "マイクの許可を要求できませんでした。音声ディクテーションが有効になっていませんでした。",
|
||||
"f9a9cf6928": "音声ディクテーションを有効にする前に、マイクの許可が必要です。",
|
||||
"1eac933202": "macOS のプライバシーとセキュリティを開きました。アクセスを許可した後、ディクテーションを再度有効にします。",
|
||||
"cd9fe37556": "マイクの許可が与えられました"
|
||||
"cd9fe37556": "マイクの許可が与えられました",
|
||||
"6fa734ed95": "Delete {{value0}}"
|
||||
},
|
||||
"WorktreeSymlinksSection": {
|
||||
"1c1e35b219": "{{value0}} を削除",
|
||||
|
|
|
|||
|
|
@ -6253,7 +6253,8 @@
|
|||
"ad5d036ecc": "마이크 권한을 요청할 수 없습니다. 음성 받아쓰기가 활성화되지 않았습니다.",
|
||||
"f9a9cf6928": "음성 받아쓰기를 활성화하려면 마이크 권한이 필요합니다.",
|
||||
"1eac933202": "macOS 개인 정보 보호 및 보안을 열었습니다. 액세스 권한을 부여한 후 받아쓰기를 다시 활성화하세요.",
|
||||
"cd9fe37556": "마이크 권한이 부여되었습니다."
|
||||
"cd9fe37556": "마이크 권한이 부여되었습니다.",
|
||||
"6fa734ed95": "Delete {{value0}}"
|
||||
},
|
||||
"WorktreeSymlinksSection": {
|
||||
"1c1e35b219": "{{value0}} 제거",
|
||||
|
|
|
|||
|
|
@ -6253,7 +6253,8 @@
|
|||
"ad5d036ecc": "无法请求麦克风权限。未启用语音听写。",
|
||||
"f9a9cf6928": "启用语音听写之前需要麦克风许可。",
|
||||
"1eac933202": "打开 macOS 隐私和安全。授予访问权限后再次启用听写。",
|
||||
"cd9fe37556": "已授予麦克风权限"
|
||||
"cd9fe37556": "已授予麦克风权限",
|
||||
"6fa734ed95": "Delete {{value0}}"
|
||||
},
|
||||
"WorktreeSymlinksSection": {
|
||||
"1c1e35b219": "删除 {{value0}}",
|
||||
|
|
|
|||
Loading…
Reference in New Issue