| | |
| | | <script lang="ts"> |
| | | import { Call } from '@wailsio/runtime' |
| | | import { Call, Events } from '@wailsio/runtime' |
| | | import ToggleSwitch from '../shared/ToggleSwitch.svelte' |
| | | import { t, setLocale } from '../../lib/i18n' |
| | | import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode, languageMode, languageID, effectiveLanguageID, uiLocale } from '../../lib/stores/config' |
| | |
| | | interface InputDevice { |
| | | name: string |
| | | isDefault: boolean |
| | | } |
| | | |
| | | interface ModelOption { |
| | | modelID: string |
| | | displayName: string |
| | | tier: string |
| | | installed: boolean |
| | | isCurrent: boolean |
| | | isDefault: boolean |
| | | downloadSize?: string |
| | | } |
| | | |
| | | let autoHideVal = $state(true) |
| | |
| | | let hintText = $state('') |
| | | let devices = $state<InputDevice[]>([]) |
| | | let showDeviceDropdown = $state(false) |
| | | let modelOptions = $state<ModelOption[]>([]) |
| | | let modelBusyID = $state('') |
| | | let modelProgress = $state(0) |
| | | let modelError = $state('') |
| | | |
| | | const unsub1 = autoHide.subscribe(v => { autoHideVal = v }) |
| | | const unsub2 = startAtLogin.subscribe(v => { startAtLoginVal = v }) |
| | |
| | | const unsub10 = languageMode.subscribe(v => { languageModeVal = v }) |
| | | const unsub11 = languageID.subscribe(v => { languageIDVal = v }) |
| | | const unsub12 = effectiveLanguageID.subscribe(v => { effectiveLanguageIDVal = v }) |
| | | |
| | | Events.On('model:download-progress', (ev: any) => { |
| | | const data = ev?.data |
| | | if (data?.modelID && data.modelID === modelBusyID && typeof data.percent === 'number') { |
| | | modelProgress = data.percent |
| | | } |
| | | }) |
| | | |
| | | // Load actual device name from backend on mount |
| | | async function loadDeviceName() { |
| | |
| | | const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings') |
| | | applyLanguageSettings(lang) |
| | | } catch {} |
| | | await loadModelOptions() |
| | | settingsLoaded = true |
| | | } |
| | | loadSettings() |
| | |
| | | languageIDVal = configured |
| | | effectiveLanguageIDVal = effective |
| | | setLocale(locale) |
| | | } |
| | | |
| | | async function syncEngineForCurrentModel() { |
| | | try { |
| | | const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus') |
| | | if (current?.installed) { |
| | | engineStatus.set('loading') |
| | | } else { |
| | | engineHardwareInfo.set('') |
| | | engineStatus.set('need_model') |
| | | } |
| | | await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel') |
| | | } catch {} |
| | | } |
| | | |
| | | async function loadModelOptions() { |
| | | try { |
| | | const options: any = await Call.ByName('voicesnap/services.EngineService.ListModelOptions') |
| | | modelOptions = Array.isArray(options) ? options : [] |
| | | } catch { |
| | | modelOptions = [] |
| | | } |
| | | } |
| | | |
| | | function modelDescription(modelID: string): string { |
| | | switch (modelID) { |
| | | case 'sensevoice-zh': return t('models.sensevoiceDesc') |
| | | case 'moonshine-en': return t('models.moonshineDesc') |
| | | case 'parakeet-en': return t('models.parakeetDesc') |
| | | default: return '' |
| | | } |
| | | } |
| | | |
| | | function modelTierLabel(option: ModelOption): string { |
| | | if (option.isDefault) return t('settings.modelDefault') |
| | | if (option.tier === 'advanced') return t('settings.modelAdvanced') |
| | | return option.tier || '' |
| | | } |
| | | |
| | | function modelActionLabel(option: ModelOption): string { |
| | | if (modelBusyID === option.modelID) { |
| | | if (!option.installed && modelProgress > 0) { |
| | | return `${Math.min(100, Math.max(0, modelProgress)).toFixed(0)}%` |
| | | } |
| | | return t('settings.modelWorking') |
| | | } |
| | | if (option.isCurrent) return t('settings.modelCurrent') |
| | | if (option.installed) return t('settings.modelUse') |
| | | return t('settings.modelDownload') |
| | | } |
| | | |
| | | async function onModelAction(option: ModelOption) { |
| | | if (option.isCurrent || modelBusyID) return |
| | | modelBusyID = option.modelID |
| | | modelProgress = 0 |
| | | modelError = '' |
| | | try { |
| | | if (option.installed) { |
| | | engineStatus.set('loading') |
| | | await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID) |
| | | } else { |
| | | await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID) |
| | | engineStatus.set('loading') |
| | | } |
| | | await loadModelOptions() |
| | | } catch (err: any) { |
| | | modelError = err?.message || String(err || t('settings.modelActionFailed')) |
| | | await loadModelOptions() |
| | | } finally { |
| | | modelBusyID = '' |
| | | modelProgress = 0 |
| | | } |
| | | } |
| | | |
| | | async function onAutoHideChange(checked: boolean) { |
| | |
| | | ? await Call.ByName('voicesnap/services.ConfigService.SetLanguageAuto') |
| | | : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection) |
| | | applyLanguageSettings(settings) |
| | | await syncEngineForCurrentModel() |
| | | await loadModelOptions() |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | |
| | | </button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | {/if} |
| | | |
| | | {#if settingsLoaded && modelOptions.length > 0} |
| | | <div class="section"> |
| | | <div class="setting-row models-header"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.recognitionModel')}</span> |
| | | <span class="setting-desc"> |
| | | {effectiveLanguageIDVal === 'en' ? t('settings.recognitionModelEnglishDesc') : t('settings.recognitionModelChineseDesc')} |
| | | </span> |
| | | </div> |
| | | </div> |
| | | |
| | | <div class="model-list"> |
| | | {#each modelOptions as option} |
| | | <div class="model-card" class:current={option.isCurrent}> |
| | | <div class="model-main"> |
| | | <div class="model-title-row"> |
| | | <span class="model-title">{option.displayName}</span> |
| | | <span class="model-pill" class:current={option.isCurrent}> |
| | | {option.isCurrent ? t('settings.modelCurrent') : modelTierLabel(option)} |
| | | </span> |
| | | </div> |
| | | <span class="model-desc">{modelDescription(option.modelID)}</span> |
| | | <span class="model-meta"> |
| | | {option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')} |
| | | {#if option.downloadSize} |
| | | ยท {option.downloadSize} |
| | | {/if} |
| | | </span> |
| | | </div> |
| | | <button |
| | | class="model-action" |
| | | class:primary={!option.isCurrent} |
| | | disabled={option.isCurrent || !!modelBusyID} |
| | | onclick={() => onModelAction(option)} |
| | | > |
| | | {modelActionLabel(option)} |
| | | </button> |
| | | </div> |
| | | {/each} |
| | | </div> |
| | | |
| | | {#if modelError} |
| | | <p class="model-error">{modelError}</p> |
| | | {/if} |
| | | </div> |
| | | {/if} |
| | | |
| | |
| | | margin: var(--spacing-sm) 0; |
| | | } |
| | | |
| | | .models-header { |
| | | align-items: flex-start; |
| | | } |
| | | |
| | | .model-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | } |
| | | |
| | | .model-card { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: var(--spacing-md); |
| | | min-height: 78px; |
| | | padding: 10px 12px; |
| | | border: 1px solid var(--color-separator); |
| | | border-radius: var(--radius-sm); |
| | | background: var(--color-bg-secondary); |
| | | } |
| | | |
| | | .model-card.current { |
| | | border-color: rgba(0, 122, 255, 0.38); |
| | | } |
| | | |
| | | .model-main { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 3px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .model-title-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | min-width: 0; |
| | | } |
| | | |
| | | .model-title { |
| | | font-size: var(--font-size-base); |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .model-pill { |
| | | display: inline-flex; |
| | | align-items: center; |
| | | height: 20px; |
| | | padding: 0 7px; |
| | | border-radius: 999px; |
| | | background: var(--color-bg-tertiary); |
| | | color: var(--color-secondary-label); |
| | | font-size: 11px; |
| | | font-weight: 500; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .model-pill.current { |
| | | background: rgba(52, 199, 89, 0.14); |
| | | color: var(--color-green); |
| | | } |
| | | |
| | | .model-desc, |
| | | .model-meta { |
| | | font-size: var(--font-size-xs); |
| | | color: var(--color-tertiary-label); |
| | | } |
| | | |
| | | .model-action { |
| | | min-width: 76px; |
| | | height: 30px; |
| | | padding: 0 12px; |
| | | border: none; |
| | | border-radius: var(--radius-sm); |
| | | background: var(--color-bg-tertiary); |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-sm); |
| | | font-weight: 500; |
| | | cursor: pointer; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .model-action.primary { |
| | | background: var(--color-blue); |
| | | color: white; |
| | | } |
| | | |
| | | .model-action:disabled { |
| | | cursor: not-allowed; |
| | | opacity: 0.72; |
| | | } |
| | | |
| | | .model-error { |
| | | margin-top: 8px; |
| | | font-size: var(--font-size-xs); |
| | | color: var(--color-red); |
| | | } |
| | | |
| | | /* Device selector */ |
| | | .device-selector { |
| | | position: relative; |