<script lang="ts">
|
import { Call, Events } from '@wailsio/runtime'
|
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'
|
|
interface ModelOption {
|
modelID: string
|
displayName: string
|
tier: string
|
installed: boolean
|
isCurrent: boolean
|
isDefault: boolean
|
downloadSize?: string
|
description?: string
|
}
|
|
interface LanguageOption {
|
id: string
|
displayName: string
|
nativeName: string
|
uiLocale: string
|
}
|
|
let settingsLoaded = $state(false)
|
let languageModeVal: 'auto' | 'manual' = $state('auto')
|
let languageIDVal = $state('zh-CN')
|
let effectiveLanguageIDVal = $state('zh-CN')
|
let languageOptions = $state<LanguageOption[]>([])
|
let modelOptions = $state<ModelOption[]>([])
|
let modelBusyID = $state('')
|
let modelProgress = $state(0)
|
let modelError = $state('')
|
|
const unsub1 = languageMode.subscribe(v => { languageModeVal = v })
|
const unsub2 = languageID.subscribe(v => { languageIDVal = v })
|
const unsub3 = 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
|
}
|
})
|
|
async function loadSettings() {
|
try {
|
const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
|
applyLanguageSettings(lang)
|
} catch {}
|
await loadModelOptions()
|
settingsLoaded = true
|
}
|
loadSettings()
|
|
function applyLanguageSettings(settings: any) {
|
const mode = settings?.languageMode === 'manual' ? 'manual' : 'auto'
|
const configured = typeof settings?.languageID === 'string' && settings.languageID ? settings.languageID : 'zh-CN'
|
const effective = typeof settings?.effectiveLanguageID === 'string' && settings.effectiveLanguageID ? settings.effectiveLanguageID : configured
|
const locale = typeof settings?.uiLocale === 'string' && settings.uiLocale ? settings.uiLocale : 'en'
|
const options = Array.isArray(settings?.options) ? settings.options : []
|
languageMode.set(mode)
|
languageID.set(configured)
|
effectiveLanguageID.set(effective)
|
uiLocale.set(locale as UILocale)
|
languageModeVal = mode
|
languageIDVal = configured
|
effectiveLanguageIDVal = effective
|
languageOptions = options
|
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(option: ModelOption): string {
|
switch (option.modelID) {
|
case 'sensevoice-zh': return t('models.sensevoiceDesc')
|
case 'moonshine-en': return t('models.moonshineDesc')
|
case 'parakeet-en': return t('models.parakeetDesc')
|
case 'parakeet-tdt-0.6b-v3': return t('models.parakeetV3Desc')
|
case 'zipformer-ja-reazonspeech': return t('models.japaneseDesc')
|
case 'zipformer-ko': return t('models.koreanDesc')
|
case 'qwen3-asr-0.6b': return t('models.qwen3Desc')
|
default: return option.description || ''
|
}
|
}
|
|
function selectedLanguageName(): string {
|
const id = effectiveLanguageIDVal || languageIDVal
|
const option = languageOptions.find(item => item.id === id)
|
return option?.nativeName || option?.displayName || id
|
}
|
|
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 onLanguageChange(selection: string) {
|
const prevMode = languageModeVal
|
const prevID = languageIDVal
|
if (selection === 'auto') {
|
languageModeVal = 'auto'
|
languageMode.set('auto')
|
} else {
|
languageModeVal = 'manual'
|
languageIDVal = selection
|
languageMode.set('manual')
|
languageID.set(selection)
|
}
|
try {
|
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()
|
} catch {
|
languageModeVal = prevMode
|
languageIDVal = prevID
|
languageMode.set(prevMode)
|
languageID.set(prevID)
|
}
|
}
|
</script>
|
|
<div class="page">
|
<div class="header">
|
<h1 class="tagline">{t('settings.inputConfigTitle')}</h1>
|
<p class="app-desc">{t('settings.inputConfigDesc')}</p>
|
</div>
|
|
{#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')}>
|
<button
|
class:active={languageModeVal === 'auto'}
|
onclick={() => onLanguageChange('auto')}
|
>
|
{t('settings.languageAuto')}
|
</button>
|
{#each languageOptions as option}
|
<button
|
class:active={languageModeVal === 'manual' && languageIDVal === option.id}
|
onclick={() => onLanguageChange(option.id)}
|
>
|
{option.nativeName || option.displayName}
|
</button>
|
{/each}
|
</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">
|
{t('settings.recognitionModelDesc', { language: selectedLanguageName() })}
|
</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)}</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}
|
</div>
|
|
<style>
|
.header {
|
margin-bottom: var(--spacing-xl);
|
}
|
|
.tagline {
|
font-size: 22px;
|
font-weight: 700;
|
}
|
|
.app-desc {
|
font-size: var(--font-size-sm);
|
color: var(--color-secondary-label);
|
margin-top: 6px;
|
}
|
|
.section {
|
background: var(--color-bg-grouped-secondary);
|
border-radius: var(--radius-md);
|
padding: var(--spacing-lg);
|
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 {
|
display: flex;
|
flex-direction: column;
|
gap: 2px;
|
min-width: 0;
|
}
|
|
.setting-label {
|
font-size: var(--font-size-base);
|
font-weight: 500;
|
}
|
|
.setting-desc {
|
font-size: var(--font-size-xs);
|
color: var(--color-tertiary-label);
|
}
|
|
.segmented {
|
display: inline-grid;
|
grid-template-columns: 1fr 1fr;
|
min-width: 148px;
|
padding: 2px;
|
border-radius: var(--radius-sm);
|
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;
|
padding: 0 10px;
|
}
|
|
.segmented button {
|
min-width: 66px;
|
height: 30px;
|
padding: 0 12px;
|
border: none;
|
border-radius: calc(var(--radius-sm) - 2px);
|
background: transparent;
|
color: var(--color-secondary-label);
|
font-size: var(--font-size-sm);
|
font-weight: 500;
|
cursor: pointer;
|
transition: background var(--transition-fast), color var(--transition-fast);
|
}
|
|
.segmented 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;
|
}
|
|
.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);
|
}
|
</style>
|