From 4a9a4cec1739007936d434a6a51180158e25078b Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Wed, 24 Jun 2026 02:17:08 +0800
Subject: [PATCH] Fix missing model language switch flow

---
 privatevoice.src/frontend/src/App.svelte                              |   24 ++++++++++-
 privatevoice.src/services/engine_service.go                           |    4 ++
 privatevoice.src/frontend/src/components/settings/LanguagePage.svelte |   11 ++---
 privatevoice.src/internal/model/resolver.go                           |   14 +++++++
 privatevoice.src/internal/model/model_test.go                         |   12 ++++++
 5 files changed, 56 insertions(+), 9 deletions(-)

diff --git a/privatevoice.src/frontend/src/App.svelte b/privatevoice.src/frontend/src/App.svelte
index 407dd7e..8406d60 100755
--- a/privatevoice.src/frontend/src/App.svelte
+++ b/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 />
diff --git a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte b/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
index 3ff0db7..2bb1b72 100644
--- a/privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
+++ b/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
diff --git a/privatevoice.src/internal/model/model_test.go b/privatevoice.src/internal/model/model_test.go
index b163161..795a0d0 100644
--- a/privatevoice.src/internal/model/model_test.go
+++ b/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 {
diff --git a/privatevoice.src/internal/model/resolver.go b/privatevoice.src/internal/model/resolver.go
index b5455fd..d9942ce 100644
--- a/privatevoice.src/internal/model/resolver.go
+++ b/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 {
diff --git a/privatevoice.src/services/engine_service.go b/privatevoice.src/services/engine_service.go
index a5463a5..b6c00b6 100755
--- a/privatevoice.src/services/engine_service.go
+++ b/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()

--
Gitblit v1.9.3