| | |
| | | import { t, setLocale } from '../../lib/i18n' |
| | | import { languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config' |
| | | import type { UILocale } from '../../lib/stores/config' |
| | | import { engineStatus, engineHardwareInfo } from '../../lib/stores/app' |
| | | import { engineStatus } from '../../lib/stores/app' |
| | | |
| | | interface ModelOption { |
| | | modelID: string |
| | |
| | | isDefault: boolean |
| | | downloadSize?: string |
| | | description?: string |
| | | supportedInBuild?: boolean |
| | | unsupportedReason?: string |
| | | } |
| | | |
| | | interface LanguageOption { |
| | |
| | | let modelCancellingID = $state('') |
| | | let modelProgress = $state(0) |
| | | let modelError = $state('') |
| | | let pendingLanguageID = $state('') |
| | | |
| | | const unsub1 = languageMode.subscribe(v => { languageModeVal = v }) |
| | | const unsub2 = languageID.subscribe(v => { languageIDVal = v }) |
| | |
| | | |
| | | Events.On('model:download-progress', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.modelID && data.modelID === modelBusyID && typeof data.percent === 'number') { |
| | | if (data?.modelID && typeof data.percent === 'number') { |
| | | modelBusyID = data.modelID |
| | | modelBusyKind = 'download' |
| | | if (modelCancellingID !== data.modelID) { |
| | | modelCancellingID = '' |
| | | } |
| | | modelProgress = data.percent |
| | | } |
| | | }) |
| | | |
| | | Events.On('model:download-cancelled', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.modelID) { |
| | | modelBusyID = data.modelID |
| | | modelBusyKind = 'download' |
| | | modelCancellingID = data.modelID |
| | | } |
| | | }) |
| | | |
| | | Events.On('model:download-finished', (ev: any) => { |
| | | const data = ev?.data |
| | | if (!data?.modelID || data.modelID === modelBusyID) { |
| | | clearModelBusy() |
| | | loadModelOptions() |
| | | } |
| | | }) |
| | | |
| | |
| | | applyLanguageSettings(lang) |
| | | } catch {} |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | settingsLoaded = true |
| | | } |
| | | loadSettings() |
| | |
| | | async function syncEngineForCurrentModel() { |
| | | try { |
| | | const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus') |
| | | if (current?.installed) { |
| | | engineStatus.set('loading') |
| | | } else { |
| | | engineHardwareInfo.set('') |
| | | } |
| | | if (!current?.installed) return |
| | | engineStatus.set('loading') |
| | | await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel') |
| | | } catch {} |
| | | } |
| | | |
| | | async function loadModelOptions() { |
| | | if (pendingLanguageID) { |
| | | await loadModelOptionsForLanguage(pendingLanguageID) |
| | | return |
| | | } |
| | | await loadCurrentModelOptions() |
| | | } |
| | | |
| | | async function loadCurrentModelOptions() { |
| | | try { |
| | | const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptions') |
| | | modelOptions = Array.isArray(options) ? options : [] |
| | | } catch { |
| | | modelOptions = [] |
| | | } |
| | | } |
| | | |
| | | async function loadModelOptionsForLanguage(selection: string): Promise<ModelOption[]> { |
| | | try { |
| | | const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptionsForLanguage', selection) |
| | | modelOptions = Array.isArray(options) ? options : [] |
| | | return modelOptions |
| | | } catch { |
| | | modelOptions = [] |
| | | return [] |
| | | } |
| | | } |
| | | |
| | | async function syncModelDownloadStatus(): Promise<boolean> { |
| | | try { |
| | | const status: any = await Call.ByName('voicesnap/services.EngineService.GetModelDownloadStatus') |
| | | return applyModelDownloadStatus(status) |
| | | } catch { |
| | | return false |
| | | } |
| | | } |
| | | |
| | | function applyModelDownloadStatus(status: any): boolean { |
| | | if (status?.active && status?.modelID) { |
| | | modelBusyID = status.modelID |
| | | modelBusyKind = 'download' |
| | | modelCancellingID = status.cancelling ? status.modelID : '' |
| | | modelProgress = typeof status.percent === 'number' ? status.percent : 0 |
| | | modelError = '' |
| | | return true |
| | | } |
| | | if (modelBusyKind === 'download') { |
| | | clearModelBusy() |
| | | } |
| | | return false |
| | | } |
| | | |
| | | function clearModelBusy() { |
| | | modelBusyID = '' |
| | | modelBusyKind = '' |
| | | modelCancellingID = '' |
| | | modelProgress = 0 |
| | | } |
| | | |
| | | function modelDescription(option: ModelOption): string { |
| | |
| | | case 'zipformer-ja-reazonspeech': return t('models.japaneseDesc') |
| | | case 'zipformer-ko': return t('models.koreanDesc') |
| | | case 'qwen3-asr-0.6b': return t('models.qwen3Desc') |
| | | case 'sensevoice-yue-2025-09-09': return t('models.sensevoiceYueDesc') |
| | | default: return option.description || '' |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | function modelTierLabel(option: ModelOption): string { |
| | | if (!modelSupported(option)) return t('settings.modelUnavailable') |
| | | if (option.isDefault) return t('settings.modelDefault') |
| | | if (option.tier === 'advanced') return t('settings.modelAdvanced') |
| | | return option.tier || '' |
| | | } |
| | | |
| | | function modelSupported(option: ModelOption): boolean { |
| | | return option.supportedInBuild !== false |
| | | } |
| | | |
| | | function modelUnsupportedText(option: ModelOption): string { |
| | | if (option.unsupportedReason === 'requires_macos_14') { |
| | | return t('models.qwen3RequiresMacOS14') |
| | | } |
| | | if (option.unsupportedReason === 'windows_preview_unsupported') { |
| | | return t('models.windowsPreviewUnsupported') |
| | | } |
| | | return t('settings.modelUnavailable') |
| | | } |
| | | |
| | | function modelActionLabel(option: ModelOption): string { |
| | | if (!modelSupported(option)) return t('settings.modelUnavailable') |
| | | if (modelBusyID === option.modelID) { |
| | | if (modelBusyKind === 'download') { |
| | | return modelCancellingID === option.modelID ? t('settings.modelCancelling') : t('settings.modelCancel') |
| | |
| | | |
| | | function modelMeta(option: ModelOption): string { |
| | | let meta = option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled') |
| | | if (!modelSupported(option)) { |
| | | return `${meta} · ${modelUnsupportedText(option)}` |
| | | } |
| | | if (option.downloadSize) { |
| | | meta += ` · ${option.downloadSize}` |
| | | } |
| | |
| | | } |
| | | |
| | | function isCancelError(err: any): boolean { |
| | | const text = String(err?.message || err || '').toLowerCase() |
| | | const text = errorMessage(err).toLowerCase() |
| | | return text.includes('context canceled') || text.includes('cancelled') || text.includes('canceled') |
| | | } |
| | | |
| | | function isAlreadyDownloadingError(err: any): boolean { |
| | | return errorMessage(err).toLowerCase().includes('already downloading') |
| | | } |
| | | |
| | | function errorMessage(err: any): string { |
| | | const raw = String(err?.message || err || '') |
| | | if (!raw.startsWith('{')) return raw |
| | | try { |
| | | const parsed = JSON.parse(raw) |
| | | return String(parsed?.message || raw) |
| | | } catch { |
| | | return raw |
| | | } |
| | | } |
| | | |
| | | function canCancelModel(option: ModelOption): boolean { |
| | |
| | | try { |
| | | const cancelled: any = await Call.ByName('voicesnap/services.EngineService.CancelModelDownload', option.modelID) |
| | | if (!cancelled) { |
| | | modelBusyID = '' |
| | | modelBusyKind = '' |
| | | modelCancellingID = '' |
| | | modelProgress = 0 |
| | | clearModelBusy() |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | } |
| | | } catch (err: any) { |
| | | modelError = err?.message || String(err || t('settings.modelActionFailed')) |
| | | modelBusyID = '' |
| | | modelBusyKind = '' |
| | | modelCancellingID = '' |
| | | modelProgress = 0 |
| | | modelError = errorMessage(err) || t('settings.modelActionFailed') |
| | | clearModelBusy() |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | } |
| | | } |
| | | |
| | | async function onModelAction(option: ModelOption) { |
| | | if ((option.isCurrent && option.installed) || modelBusyID) return |
| | | if (!modelSupported(option) || (option.isCurrent && option.installed) || modelBusyID) return |
| | | modelBusyID = option.modelID |
| | | modelBusyKind = option.installed ? 'select' : 'download' |
| | | modelProgress = 0 |
| | |
| | | try { |
| | | if (option.installed) { |
| | | engineStatus.set('loading') |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID) |
| | | if (pendingLanguageID) { |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModelForLanguage', pendingLanguageID, option.modelID) |
| | | pendingLanguageID = '' |
| | | const settings: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(settings) |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID) |
| | | } |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID) |
| | | if (pendingLanguageID) { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByIDForLanguage', pendingLanguageID, option.modelID) |
| | | pendingLanguageID = '' |
| | | const settings: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(settings) |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID) |
| | | } |
| | | engineStatus.set('loading') |
| | | } |
| | | await loadModelOptions() |
| | | } catch (err: any) { |
| | | if (!isCancelError(err)) { |
| | | modelError = err?.message || String(err || t('settings.modelActionFailed')) |
| | | if (isAlreadyDownloadingError(err)) { |
| | | const active = await syncModelDownloadStatus() |
| | | if (!active) { |
| | | modelError = errorMessage(err) |
| | | } |
| | | } else if (!isCancelError(err)) { |
| | | modelError = errorMessage(err) || t('settings.modelActionFailed') |
| | | } |
| | | await loadModelOptions() |
| | | } finally { |
| | | if (modelBusyID === option.modelID) { |
| | | modelBusyID = '' |
| | | modelBusyKind = '' |
| | | modelCancellingID = '' |
| | | modelProgress = 0 |
| | | const active = await syncModelDownloadStatus() |
| | | if (!active || modelBusyID !== option.modelID) { |
| | | clearModelBusy() |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | async function onLanguageChange(selection: string) { |
| | | const prevMode = languageModeVal |
| | | const prevID = languageIDVal |
| | | const prevEffectiveID = effectiveLanguageIDVal |
| | | const prevPendingID = pendingLanguageID |
| | | if (selection === 'auto') { |
| | | pendingLanguageID = '' |
| | | languageModeVal = 'auto' |
| | | languageMode.set('auto') |
| | | } else { |
| | | languageModeVal = 'manual' |
| | | languageIDVal = selection |
| | | languageMode.set('manual') |
| | | languageID.set(selection) |
| | | } |
| | | try { |
| | | if (selection !== 'auto') { |
| | | const options = await loadModelOptionsForLanguage(selection) |
| | | const defaultInstalled = options.some(option => option.isDefault && option.installed) |
| | | if (!defaultInstalled) { |
| | | pendingLanguageID = selection |
| | | effectiveLanguageIDVal = selection |
| | | await syncModelDownloadStatus() |
| | | return |
| | | } |
| | | } |
| | | pendingLanguageID = '' |
| | | const settings: any = selection === 'auto' |
| | | ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto') |
| | | : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection) |
| | | applyLanguageSettings(settings) |
| | | await syncEngineForCurrentModel() |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | | effectiveLanguageIDVal = prevEffectiveID |
| | | pendingLanguageID = prevPendingID |
| | | languageMode.set(prevMode) |
| | | languageID.set(prevID) |
| | | } |
| | |
| | | |
| | | <div class="model-list"> |
| | | {#each modelOptions as option} |
| | | <div class="model-card" class:current={option.isCurrent}> |
| | | <div class="model-card" class:current={option.isCurrent} class:unsupported={!modelSupported(option)}> |
| | | <div class="model-main"> |
| | | <div class="model-title-row"> |
| | | <span class="model-title">{option.displayName}</span> |
| | |
| | | <button |
| | | class="model-action primary" |
| | | class:cancel={canCancelModel(option)} |
| | | disabled={!!modelBusyID && modelBusyID !== option.modelID} |
| | | disabled={!modelSupported(option) || (!!modelBusyID && modelBusyID !== option.modelID)} |
| | | onclick={() => onModelButton(option)} |
| | | > |
| | | {modelActionLabel(option)} |
| | |
| | | background: rgba(0, 122, 255, 0.055); |
| | | } |
| | | |
| | | .model-card.unsupported { |
| | | opacity: 0.76; |
| | | } |
| | | |
| | | .model-card.current::before { |
| | | content: ''; |
| | | position: absolute; |