Ariver
2026-06-24 4a9a4cec1739007936d434a6a51180158e25078b
Fix missing model language switch flow
5 files modified
65 ■■■■ changed files
privatevoice.src/frontend/src/App.svelte 24 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte 11 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 12 ●●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/resolver.go 14 ●●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service.go 4 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/App.svelte
@@ -16,6 +16,7 @@
  let isIndicatorRoute = $state(window.location.hash === '#/indicator')
  let showUpdateDialog = $state(false)
  let languageReady = $state(false)
  let showOnboarding = $state(false)
  onMount(() => {
    Call.ByName('voicesnap/services.ConfigService.GetLanguageSettings').then((settings: any) => {
@@ -53,6 +54,9 @@
      const data = ev?.data
      if (data?.status) {
        engineStatus.set(data.status as any)
        if (data.status === 'ready') {
          showOnboarding = false
        }
      }
      if (data?.hardwareInfo) {
        engineHardwareInfo.set(data.hardwareInfo)
@@ -79,6 +83,9 @@
    Call.ByName('voicesnap/services.EngineService.GetStatus').then((data: any) => {
      if (data?.status) {
        engineStatus.set(data.status as any)
        if (data.status === 'ready') {
          showOnboarding = false
        }
      }
      if (data?.hardwareInfo) {
        engineHardwareInfo.set(data.hardwareInfo)
@@ -112,13 +119,24 @@
    try {
      const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
      if (!current?.installed) {
        engineStatus.set('need_model')
        const hasAnyInstalled: any = await Call.ByName('voicesnap/services.EngineService.HasAnyInstalledModel')
        showOnboarding = !hasAnyInstalled
        if (!hasAnyInstalled) {
          engineStatus.set('need_model')
        }
      } else {
        showOnboarding = false
      }
    } catch {
      try {
        const exists: any = await Call.ByName('voicesnap/services.EngineService.ModelExists')
        if (!exists) {
          engineStatus.set('need_model')
          const hasAnyInstalled: any = await Call.ByName('voicesnap/services.EngineService.HasAnyInstalledModel')
            .catch(() => false)
          showOnboarding = !hasAnyInstalled
          if (!hasAnyInstalled) {
            engineStatus.set('need_model')
          }
        }
      } catch {}
    }
@@ -130,7 +148,7 @@
{:else}
  {#if languageReady}
    {#key $localeRevision}
      {#if $engineStatus === 'need_model'}
      {#if showOnboarding && $engineStatus === 'need_model'}
        <OnboardingView />
      {:else}
        <SettingsWindow />
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -78,7 +78,6 @@
        engineStatus.set('loading')
      } else {
        engineHardwareInfo.set('')
        engineStatus.set('need_model')
      }
      await Call.ByName('voicesnap/services.EngineService.ReloadCurrentModel')
    } catch {}
@@ -125,13 +124,13 @@
      }
      return t('settings.modelWorking')
    }
    if (option.isCurrent) return t('settings.modelCurrent')
    if (option.isCurrent && option.installed) 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
    if ((option.isCurrent && option.installed) || modelBusyID) return
    modelBusyID = option.modelID
    modelProgress = 0
    modelError = ''
@@ -232,8 +231,8 @@
          <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 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>
@@ -244,7 +243,7 @@
              {/if}
            </span>
          </div>
          {#if option.isCurrent}
          {#if option.isCurrent && option.installed}
            <span class="model-current-status">{t('settings.modelCurrent')}</span>
          {:else}
            <button
privatevoice.src/internal/model/model_test.go
@@ -592,6 +592,18 @@
    }
}
func TestHasAnyUsableModelInRoot(t *testing.T) {
    root := t.TempDir()
    if HasAnyUsableModelInRoot(root) {
        t.Fatal("expected no usable model in empty root")
    }
    createValidSenseVoice(t, filepath.Join(root, "sensevoice-zh"))
    if !HasAnyUsableModelInRoot(root) {
        t.Fatal("expected installed sensevoice to count as a usable model")
    }
}
func TestInstallStateRoundTrip(t *testing.T) {
    root := t.TempDir()
    if err := UpdateInstalledModelState(root, DefaultModelProfile(), "sensevoice-zh", "new"); err != nil {
privatevoice.src/internal/model/resolver.go
@@ -19,6 +19,20 @@
    return ResolveModelInRoot(modelID, paths.ModelsRoot())
}
func HasAnyUsableModel() bool {
    return HasAnyUsableModelInRoot(paths.ModelsRoot())
}
func HasAnyUsableModelInRoot(modelsRoot string) bool {
    for _, profile := range ListModelProfiles() {
        resolved, err := ResolveModelInRoot(profile.ID, modelsRoot)
        if err == nil && resolved.IsUsable() {
            return true
        }
    }
    return false
}
func ResolveModelInRoot(modelID, modelsRoot string) (ResolvedModel, error) {
    profile, err := GetModelProfile(modelID)
    if err != nil {
privatevoice.src/services/engine_service.go
@@ -36,6 +36,10 @@
    return engine.ModelExists()
}
func (s *EngineService) HasAnyInstalledModel() bool {
    return model.HasAnyUsableModel()
}
func (s *EngineService) GetStatus() map[string]interface{} {
    s.mu.RLock()
    defer s.mu.RUnlock()