From 94992b563fdfddce683fc07ef5bb70e18ff910fe Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 03 Jun 2026 16:25:00 +0800
Subject: [PATCH] Record Round 4 candidate install verification
---
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte | 230 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 229 insertions(+), 1 deletions(-)
diff --git a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
index 50d099b..132378a 100755
--- a/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
+++ b/privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,5 +1,5 @@
<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'
@@ -9,6 +9,16 @@
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)
@@ -29,6 +39,10 @@
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 })
@@ -42,6 +56,13 @@
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() {
@@ -90,6 +111,7 @@
const lang: any = await Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings')
applyLanguageSettings(lang)
} catch {}
+ await loadModelOptions()
settingsLoaded = true
}
loadSettings()
@@ -120,6 +142,65 @@
}
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) {
@@ -169,6 +250,7 @@
: await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
applyLanguageSettings(settings)
await syncEngineForCurrentModel()
+ await loadModelOptions()
} catch {
languageModeVal = prevMode
languageIDVal = prevID
@@ -374,6 +456,53 @@
</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}
@@ -663,6 +792,105 @@
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;
--
Gitblit v1.9.3