| | |
| | | isDefault: boolean |
| | | downloadSize?: string |
| | | description?: string |
| | | supportedInBuild?: boolean |
| | | unsupportedReason?: string |
| | | } |
| | | |
| | | interface LanguageOption { |
| | |
| | | let languageOptions = $state<LanguageOption[]>([]) |
| | | let modelOptions = $state<ModelOption[]>([]) |
| | | let modelBusyID = $state('') |
| | | let modelBusyKind = $state<'download' | 'select' | ''>('') |
| | | let modelCancellingID = $state('') |
| | | let modelProgress = $state(0) |
| | | let modelError = $state('') |
| | | |
| | |
| | | |
| | | 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() |
| | |
| | | engineStatus.set('loading') |
| | | } else { |
| | | engineHardwareInfo.set('') |
| | | engineStatus.set('need_model') |
| | | } |
| | | await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel') |
| | | } catch {} |
| | |
| | | } catch { |
| | | modelOptions = [] |
| | | } |
| | | } |
| | | |
| | | 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 { |
| | |
| | | } |
| | | |
| | | 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') |
| | | } |
| | | return t('settings.modelUnavailable') |
| | | } |
| | | |
| | | function modelActionLabel(option: ModelOption): string { |
| | | if (!modelSupported(option)) return t('settings.modelUnavailable') |
| | | if (modelBusyID === option.modelID) { |
| | | if (!option.installed && modelProgress > 0) { |
| | | return `${Math.min(100, Math.max(0, modelProgress)).toFixed(0)}%` |
| | | if (modelBusyKind === 'download') { |
| | | return modelCancellingID === option.modelID ? t('settings.modelCancelling') : t('settings.modelCancel') |
| | | } |
| | | return t('settings.modelWorking') |
| | | } |
| | | if (option.isCurrent) return t('settings.modelCurrent') |
| | | if (option.isCurrent && option.installed) return t('settings.modelCurrent') |
| | | if (option.installed) return t('settings.modelUse') |
| | | return t('settings.modelDownload') |
| | | } |
| | | |
| | | 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}` |
| | | } |
| | | if (modelBusyID === option.modelID && modelBusyKind === 'download' && modelProgress > 0) { |
| | | const pct = Math.min(100, Math.max(0, modelProgress)).toFixed(0) |
| | | meta += ` · ${pct}%` |
| | | } |
| | | return meta |
| | | } |
| | | |
| | | function isCancelError(err: any): boolean { |
| | | 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 { |
| | | return modelBusyID === option.modelID && modelBusyKind === 'download' |
| | | } |
| | | |
| | | async function onModelButton(option: ModelOption) { |
| | | if (canCancelModel(option)) { |
| | | await cancelModelDownload(option) |
| | | return |
| | | } |
| | | await onModelAction(option) |
| | | } |
| | | |
| | | async function cancelModelDownload(option: ModelOption) { |
| | | if (!canCancelModel(option) || modelCancellingID) return |
| | | modelCancellingID = option.modelID |
| | | modelError = '' |
| | | try { |
| | | const cancelled: any = await Call.ByName('voicesnap/services.EngineService.CancelModelDownload', option.modelID) |
| | | if (!cancelled) { |
| | | clearModelBusy() |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | } |
| | | } catch (err: any) { |
| | | modelError = errorMessage(err) || t('settings.modelActionFailed') |
| | | clearModelBusy() |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | } |
| | | } |
| | | |
| | | async function onModelAction(option: ModelOption) { |
| | | if (option.isCurrent || modelBusyID) return |
| | | if (!modelSupported(option) || (option.isCurrent && option.installed) || modelBusyID) return |
| | | modelBusyID = option.modelID |
| | | modelBusyKind = option.installed ? 'select' : 'download' |
| | | modelProgress = 0 |
| | | modelError = '' |
| | | try { |
| | |
| | | } |
| | | await loadModelOptions() |
| | | } catch (err: any) { |
| | | 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 { |
| | | modelBusyID = '' |
| | | modelProgress = 0 |
| | | if (modelBusyID === option.modelID) { |
| | | const active = await syncModelDownloadStatus() |
| | | if (!active || modelBusyID !== option.modelID) { |
| | | clearModelBusy() |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | applyLanguageSettings(settings) |
| | | await syncEngineForCurrentModel() |
| | | await loadModelOptions() |
| | | await syncModelDownloadStatus() |
| | | } catch { |
| | | languageModeVal = prevMode |
| | | languageIDVal = prevID |
| | |
| | | |
| | | {#if settingsLoaded} |
| | | <div class="section"> |
| | | <div class="setting-row"> |
| | | <div class="setting-info"> |
| | | <span class="setting-label">{t('settings.language')}</span> |
| | | <span class="setting-desc"> |
| | | {languageModeVal === 'auto' |
| | | ? t('settings.languageAutoDesc') |
| | | : t('settings.languageManualDesc', { language: selectedLanguageName() })} |
| | | </span> |
| | | </div> |
| | | <div class="segmented language" role="group" aria-label={t('settings.language')}> |
| | | <div class="section-head"> |
| | | <span class="setting-label">{t('settings.language')}</span> |
| | | <span class="setting-desc"> |
| | | {languageModeVal === 'auto' |
| | | ? t('settings.languageAutoDesc') |
| | | : t('settings.languageManualDesc', { language: selectedLanguageName() })} |
| | | </span> |
| | | </div> |
| | | |
| | | <div class="language-grid" role="group" aria-label={t('settings.language')}> |
| | | <button |
| | | class:active={languageModeVal === 'auto'} |
| | | onclick={() => onLanguageChange('auto')} |
| | | > |
| | | {t('settings.languageAuto')} |
| | | </button> |
| | | {#each languageOptions as option} |
| | | <button |
| | | class:active={languageModeVal === 'auto'} |
| | | onclick={() => onLanguageChange('auto')} |
| | | class:active={languageModeVal === 'manual' && languageIDVal === option.id} |
| | | onclick={() => onLanguageChange(option.id)} |
| | | > |
| | | {t('settings.languageAuto')} |
| | | {option.nativeName || option.displayName} |
| | | </button> |
| | | {#each languageOptions as option} |
| | | <button |
| | | class:active={languageModeVal === 'manual' && languageIDVal === option.id} |
| | | onclick={() => onLanguageChange(option.id)} |
| | | > |
| | | {option.nativeName || option.displayName} |
| | | </button> |
| | | {/each} |
| | | </div> |
| | | {/each} |
| | | </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"> |
| | | {t('settings.recognitionModelDesc', { language: selectedLanguageName() })} |
| | | </span> |
| | | </div> |
| | | <div class="section-head"> |
| | | <span class="setting-label">{t('settings.recognitionModel')}</span> |
| | | <span class="setting-desc"> |
| | | {t('settings.recognitionModelDesc', { language: selectedLanguageName() })} |
| | | </span> |
| | | </div> |
| | | |
| | | <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> |
| | | <span class="model-pill" class:current={option.isCurrent}> |
| | | {option.isCurrent ? t('settings.modelCurrent') : modelTierLabel(option)} |
| | | <span class="model-pill" class:current={option.isCurrent && option.installed}> |
| | | {option.isCurrent && option.installed ? t('settings.modelCurrent') : modelTierLabel(option)} |
| | | </span> |
| | | </div> |
| | | <span class="model-desc">{modelDescription(option)}</span> |
| | | <span class="model-meta"> |
| | | {option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')} |
| | | {#if option.downloadSize} |
| | | · {option.downloadSize} |
| | | {/if} |
| | | </span> |
| | | <span class="model-meta">{modelMeta(option)}</span> |
| | | </div> |
| | | <button |
| | | class="model-action" |
| | | class:primary={!option.isCurrent} |
| | | disabled={option.isCurrent || !!modelBusyID} |
| | | onclick={() => onModelAction(option)} |
| | | > |
| | | {modelActionLabel(option)} |
| | | </button> |
| | | {#if option.isCurrent && option.installed} |
| | | <span class="model-current-status">{t('settings.modelCurrent')}</span> |
| | | {:else} |
| | | <button |
| | | class="model-action primary" |
| | | class:cancel={canCancelModel(option)} |
| | | disabled={!modelSupported(option) || (!!modelBusyID && modelBusyID !== option.modelID)} |
| | | onclick={() => onModelButton(option)} |
| | | > |
| | | {modelActionLabel(option)} |
| | | </button> |
| | | {/if} |
| | | </div> |
| | | {/each} |
| | | </div> |
| | |
| | | </div> |
| | | |
| | | <style> |
| | | .page { |
| | | width: 100%; |
| | | max-width: 920px; |
| | | } |
| | | |
| | | .header { |
| | | margin-bottom: var(--spacing-xl); |
| | | } |
| | |
| | | .section { |
| | | background: var(--color-bg-grouped-secondary); |
| | | border-radius: var(--radius-md); |
| | | padding: var(--spacing-lg); |
| | | padding: 18px 20px; |
| | | margin-bottom: var(--spacing-md); |
| | | } |
| | | |
| | | .setting-row { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: var(--spacing-md); |
| | | padding: var(--spacing-xs) 0; |
| | | } |
| | | |
| | | .setting-info { |
| | | .section-head { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | min-width: 0; |
| | | gap: 4px; |
| | | margin-bottom: 14px; |
| | | } |
| | | |
| | | .setting-label { |
| | |
| | | |
| | | .setting-desc { |
| | | font-size: var(--font-size-xs); |
| | | color: var(--color-tertiary-label); |
| | | line-height: 1.35; |
| | | color: var(--color-secondary-label); |
| | | max-width: 560px; |
| | | } |
| | | |
| | | .segmented { |
| | | display: inline-grid; |
| | | grid-template-columns: 1fr 1fr; |
| | | min-width: 148px; |
| | | padding: 2px; |
| | | border-radius: var(--radius-sm); |
| | | .language-grid { |
| | | display: grid; |
| | | grid-template-columns: repeat(4, minmax(0, 1fr)); |
| | | gap: 6px; |
| | | padding: 6px; |
| | | border-radius: var(--radius-md); |
| | | background: var(--color-bg-secondary); |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .segmented.language { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | justify-content: flex-end; |
| | | max-width: 390px; |
| | | min-width: 246px; |
| | | } |
| | | |
| | | .segmented.language button { |
| | | min-width: 76px; |
| | | .language-grid button { |
| | | height: 34px; |
| | | min-width: 0; |
| | | padding: 0 10px; |
| | | } |
| | | |
| | | .segmented button { |
| | | min-width: 66px; |
| | | height: 30px; |
| | | padding: 0 12px; |
| | | border: none; |
| | | border-radius: calc(var(--radius-sm) - 2px); |
| | | border-radius: var(--radius-sm); |
| | | background: transparent; |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-sm); |
| | | font-weight: 500; |
| | | cursor: pointer; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | transition: background var(--transition-fast), color var(--transition-fast); |
| | | } |
| | | |
| | | .segmented button.active { |
| | | .language-grid button.active { |
| | | background: var(--color-blue); |
| | | color: white; |
| | | } |
| | | |
| | | .models-header { |
| | | align-items: flex-start; |
| | | margin-bottom: var(--spacing-sm); |
| | | } |
| | | |
| | | .model-list { |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 8px; |
| | | gap: 0; |
| | | overflow: hidden; |
| | | border: 1px solid var(--color-separator); |
| | | border-radius: var(--radius-md); |
| | | background: var(--color-bg-tertiary); |
| | | } |
| | | |
| | | .model-card { |
| | | position: relative; |
| | | 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); |
| | | min-height: 72px; |
| | | padding: 12px 14px; |
| | | border: 0; |
| | | border-radius: 0; |
| | | background: var(--color-bg-tertiary); |
| | | } |
| | | |
| | | .model-card + .model-card { |
| | | border-top: 1px solid var(--color-separator); |
| | | } |
| | | |
| | | .model-card.current { |
| | | border-color: rgba(0, 122, 255, 0.38); |
| | | background: rgba(0, 122, 255, 0.055); |
| | | } |
| | | |
| | | .model-card.unsupported { |
| | | opacity: 0.76; |
| | | } |
| | | |
| | | .model-card.current::before { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 0; |
| | | top: 12px; |
| | | bottom: 12px; |
| | | width: 3px; |
| | | border-radius: 2px; |
| | | background: var(--color-blue); |
| | | } |
| | | |
| | | .model-main { |
| | |
| | | .model-title { |
| | | font-size: var(--font-size-base); |
| | | font-weight: 600; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .model-pill { |
| | |
| | | .model-desc, |
| | | .model-meta { |
| | | font-size: var(--font-size-xs); |
| | | line-height: 1.35; |
| | | color: var(--color-secondary-label); |
| | | } |
| | | |
| | | .model-meta { |
| | | color: var(--color-tertiary-label); |
| | | } |
| | | |
| | |
| | | color: white; |
| | | } |
| | | |
| | | .model-action.primary.cancel { |
| | | background: var(--color-red); |
| | | } |
| | | |
| | | .model-action:disabled { |
| | | cursor: not-allowed; |
| | | opacity: 0.72; |
| | | } |
| | | |
| | | .model-current-status { |
| | | min-width: 64px; |
| | | text-align: center; |
| | | color: var(--color-secondary-label); |
| | | font-size: var(--font-size-sm); |
| | | font-weight: 500; |
| | | white-space: nowrap; |
| | | } |
| | | |
| | | .model-error { |
| | |
| | | font-size: var(--font-size-xs); |
| | | color: var(--color-red); |
| | | } |
| | | |
| | | @media (max-width: 860px) { |
| | | .language-grid { |
| | | grid-template-columns: repeat(3, minmax(0, 1fr)); |
| | | } |
| | | } |
| | | |
| | | @media (max-width: 640px) { |
| | | .language-grid { |
| | | grid-template-columns: repeat(2, minmax(0, 1fr)); |
| | | } |
| | | |
| | | .model-card { |
| | | align-items: flex-start; |
| | | } |
| | | } |
| | | </style> |