<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
|
supportedInBuild?: boolean
|
unsupportedReason?: 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 modelBusyKind = $state<'download' | 'select' | ''>('')
|
let modelCancellingID = $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 && 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()
|
}
|
})
|
|
async function loadSettings() {
|
try {
|
const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
|
applyLanguageSettings(lang)
|
} catch {}
|
await loadModelOptions()
|
await syncModelDownloadStatus()
|
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('')
|
}
|
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 = []
|
}
|
}
|
|
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 {
|
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 (!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')
|
}
|
return t('settings.modelWorking')
|
}
|
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 (!modelSupported(option) || (option.isCurrent && option.installed) || modelBusyID) return
|
modelBusyID = option.modelID
|
modelBusyKind = option.installed ? 'select' : 'download'
|
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) {
|
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) {
|
const active = await syncModelDownloadStatus()
|
if (!active || modelBusyID !== option.modelID) {
|
clearModelBusy()
|
}
|
}
|
}
|
}
|
|
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()
|
await syncModelDownloadStatus()
|
} 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="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 === 'manual' && languageIDVal === option.id}
|
onclick={() => onLanguageChange(option.id)}
|
>
|
{option.nativeName || option.displayName}
|
</button>
|
{/each}
|
</div>
|
</div>
|
{/if}
|
|
{#if settingsLoaded && modelOptions.length > 0}
|
<div class="section">
|
<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} 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.installed}>
|
{option.isCurrent && option.installed ? t('settings.modelCurrent') : modelTierLabel(option)}
|
</span>
|
</div>
|
<span class="model-desc">{modelDescription(option)}</span>
|
<span class="model-meta">{modelMeta(option)}</span>
|
</div>
|
{#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>
|
|
{#if modelError}
|
<p class="model-error">{modelError}</p>
|
{/if}
|
</div>
|
{/if}
|
</div>
|
|
<style>
|
.page {
|
width: 100%;
|
max-width: 920px;
|
}
|
|
.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: 18px 20px;
|
margin-bottom: var(--spacing-md);
|
}
|
|
.section-head {
|
display: flex;
|
flex-direction: column;
|
gap: 4px;
|
margin-bottom: 14px;
|
}
|
|
.setting-label {
|
font-size: var(--font-size-base);
|
font-weight: 500;
|
}
|
|
.setting-desc {
|
font-size: var(--font-size-xs);
|
line-height: 1.35;
|
color: var(--color-secondary-label);
|
max-width: 560px;
|
}
|
|
.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);
|
}
|
|
.language-grid button {
|
height: 34px;
|
min-width: 0;
|
padding: 0 10px;
|
border: none;
|
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);
|
}
|
|
.language-grid button.active {
|
background: var(--color-blue);
|
color: white;
|
}
|
|
.model-list {
|
display: flex;
|
flex-direction: column;
|
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: 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 {
|
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 {
|
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;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.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);
|
line-height: 1.35;
|
color: var(--color-secondary-label);
|
}
|
|
.model-meta {
|
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.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 {
|
margin-top: 8px;
|
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>
|