Ariver
2026-06-03 62f9a3b1e84455c32971272c03163ca543e2f68f
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,64 @@
      }
      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 = ''
    engineStatus.set('loading')
    try {
      if (option.installed) {
        await Call.ByName('voicesnap/services.EngineService.SelectModel', option.modelID)
      } else {
        await Call.ByName('voicesnap/services.EngineService.DownloadModelByID', option.modelID)
      }
      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 +249,7 @@
        : await Call.ByName('voicesnap/services.ConfigService.SetLanguageManual', selection)
      applyLanguageSettings(settings)
      await syncEngineForCurrentModel()
      await loadModelOptions()
    } catch {
      languageModeVal = prevMode
      languageIDVal = prevID
@@ -374,6 +455,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 +791,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;