Ariver
2026-07-01 3803f0987d7fd38cbd754f3dc37caa29247c852c
privatevoice.src/frontend/src/components/settings/GeneralPage.svelte
@@ -1,24 +1,14 @@
<script lang="ts">
  import { Call, Events } from '@wailsio/runtime'
  import { Call } 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'
  import { t } from '../../lib/i18n'
  import { autoHide, soundFeedback, copyToClipboard, startAtLogin, hotkeyVK, hotkeyMode } from '../../lib/stores/config'
  import { deviceName, engineStatus, engineHardwareInfo } from '../../lib/stores/app'
  import { hotkeyName } from '../../lib/stores/indicator'
  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)
@@ -32,17 +22,10 @@
  let hwInfo = $state('')
  let currentKeyName = $state('Ctrl')
  let hotkeyModeVal: 'tap' | 'hold' = $state('hold')
  let languageModeVal: 'auto' | 'manual' = $state('auto')
  let languageIDVal: 'zh-CN' | 'en' = $state('zh-CN')
  let effectiveLanguageIDVal: 'zh-CN' | 'en' = $state('zh-CN')
  let isRecording = $state(false)
  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 })
@@ -53,16 +36,6 @@
  const unsub7 = soundFeedback.subscribe(v => { soundFeedbackVal = v })
  const unsub8 = copyToClipboard.subscribe(v => { copyToClipboardVal = v })
  const unsub9 = hotkeyMode.subscribe(v => { hotkeyModeVal = v })
  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() {
@@ -107,101 +80,9 @@
      const hideDock: any = await Call.ByName('voicesnap/services.ConfigService.GetHideDockIcon')
      hideDockIconVal = !!hideDock
    } catch {}
    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 = settings?.languageID === 'en' ? 'en' : 'zh-CN'
    const effective = settings?.effectiveLanguageID === 'en' ? 'en' : 'zh-CN'
    const locale = settings?.uiLocale === 'en' ? 'en' : 'zh'
    languageMode.set(mode)
    languageID.set(configured)
    effectiveLanguageID.set(effective)
    uiLocale.set(locale)
    languageModeVal = mode
    languageIDVal = configured
    effectiveLanguageIDVal = effective
    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(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) {
    autoHide.set(checked)
@@ -230,33 +111,6 @@
    try {
      await Call.ByName('voicesnap/services.ConfigService.SetHotkeyMode', mode)
    } catch {}
  }
  async function onLanguageChange(selection: 'auto' | 'zh-CN' | 'en') {
    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)
    }
  }
  async function onStartAtLoginChange(checked: boolean) {
@@ -421,90 +275,6 @@
    </div>
  </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')
            : effectiveLanguageIDVal === 'en'
              ? t('settings.languageEnglishDesc')
              : t('settings.languageChineseDesc')}
        </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>
        <button
          class:active={languageModeVal === 'manual' && languageIDVal === 'zh-CN'}
          onclick={() => onLanguageChange('zh-CN')}
        >
          {t('settings.languageChinese')}
        </button>
        <button
          class:active={languageModeVal === 'manual' && languageIDVal === 'en'}
          onclick={() => onLanguageChange('en')}
        >
          {t('settings.languageEnglish')}
        </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}
  <!-- Engine + Device -->
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -757,16 +527,6 @@
    flex-shrink: 0;
  }
  .segmented.language {
    grid-template-columns: repeat(3, 1fr);
    min-width: 246px;
  }
  .segmented.language button {
    min-width: 76px;
    padding: 0 10px;
  }
  .segmented button {
    min-width: 66px;
    height: 30px;
@@ -790,105 +550,6 @@
    height: 1px;
    background: var(--color-separator);
    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 */