Ariver
2026-06-24 a5adc6d1d88cadeead81b79fd270501c243524c7
Sync model download state in settings
2 files modified
1 files added
230 ■■■■ changed files
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte 105 ●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service.go 70 ●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service_test.go 55 ●●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -41,8 +41,30 @@
  Events.On('model:download-progress', (ev: any) => {
    const data = ev?.data
    if (data?.modelID && data.modelID === modelBusyID && typeof data.percent === 'number') {
    if (data?.modelID && typeof data.percent === 'number') {
      modelBusyID = data.modelID
      modelBusyKind = 'download'
      if (modelCancellingID !== data.modelID) {
        modelCancellingID = ''
      }
      modelProgress = data.percent
    }
  })
  Events.On('model:download-cancelled', (ev: any) => {
    const data = ev?.data
    if (data?.modelID) {
      modelBusyID = data.modelID
      modelBusyKind = 'download'
      modelCancellingID = data.modelID
    }
  })
  Events.On('model:download-finished', (ev: any) => {
    const data = ev?.data
    if (!data?.modelID || data.modelID === modelBusyID) {
      clearModelBusy()
      loadModelOptions()
    }
  })
@@ -52,6 +74,7 @@
      applyLanguageSettings(lang)
    } catch {}
    await loadModelOptions()
    await syncModelDownloadStatus()
    settingsLoaded = true
  }
  loadSettings()
@@ -92,6 +115,37 @@
    } catch {
      modelOptions = []
    }
  }
  async function syncModelDownloadStatus(): Promise<boolean> {
    try {
      const status: any = await Call.ByName('voicesnap/services.EngineService.GetModelDownloadStatus')
      return applyModelDownloadStatus(status)
    } catch {
      return false
    }
  }
  function applyModelDownloadStatus(status: any): boolean {
    if (status?.active && status?.modelID) {
      modelBusyID = status.modelID
      modelBusyKind = 'download'
      modelCancellingID = status.cancelling ? status.modelID : ''
      modelProgress = typeof status.percent === 'number' ? status.percent : 0
      modelError = ''
      return true
    }
    if (modelBusyKind === 'download') {
      clearModelBusy()
    }
    return false
  }
  function clearModelBusy() {
    modelBusyID = ''
    modelBusyKind = ''
    modelCancellingID = ''
    modelProgress = 0
  }
  function modelDescription(option: ModelOption): string {
@@ -144,8 +198,23 @@
  }
  function isCancelError(err: any): boolean {
    const text = String(err?.message || err || '').toLowerCase()
    const text = errorMessage(err).toLowerCase()
    return text.includes('context canceled') || text.includes('cancelled') || text.includes('canceled')
  }
  function isAlreadyDownloadingError(err: any): boolean {
    return errorMessage(err).toLowerCase().includes('already downloading')
  }
  function errorMessage(err: any): string {
    const raw = String(err?.message || err || '')
    if (!raw.startsWith('{')) return raw
    try {
      const parsed = JSON.parse(raw)
      return String(parsed?.message || raw)
    } catch {
      return raw
    }
  }
  function canCancelModel(option: ModelOption): boolean {
@@ -167,19 +236,15 @@
    try {
      const cancelled: any = await Call.ByName('voicesnap/services.EngineService.CancelModelDownload', option.modelID)
      if (!cancelled) {
        modelBusyID = ''
        modelBusyKind = ''
        modelCancellingID = ''
        modelProgress = 0
        clearModelBusy()
        await loadModelOptions()
        await syncModelDownloadStatus()
      }
    } catch (err: any) {
      modelError = err?.message || String(err || t('settings.modelActionFailed'))
      modelBusyID = ''
      modelBusyKind = ''
      modelCancellingID = ''
      modelProgress = 0
      modelError = errorMessage(err) || t('settings.modelActionFailed')
      clearModelBusy()
      await loadModelOptions()
      await syncModelDownloadStatus()
    }
  }
@@ -199,16 +264,21 @@
      }
      await loadModelOptions()
    } catch (err: any) {
      if (!isCancelError(err)) {
        modelError = err?.message || String(err || t('settings.modelActionFailed'))
      if (isAlreadyDownloadingError(err)) {
        const active = await syncModelDownloadStatus()
        if (!active) {
          modelError = errorMessage(err)
        }
      } else if (!isCancelError(err)) {
        modelError = errorMessage(err) || t('settings.modelActionFailed')
      }
      await loadModelOptions()
    } finally {
      if (modelBusyID === option.modelID) {
        modelBusyID = ''
        modelBusyKind = ''
        modelCancellingID = ''
        modelProgress = 0
        const active = await syncModelDownloadStatus()
        if (!active || modelBusyID !== option.modelID) {
          clearModelBusy()
        }
      }
    }
  }
@@ -232,6 +302,7 @@
      applyLanguageSettings(settings)
      await syncEngineForCurrentModel()
      await loadModelOptions()
      await syncModelDownloadStatus()
    } catch {
      languageModeVal = prevMode
      languageIDVal = prevID
privatevoice.src/services/engine_service.go
@@ -16,16 +16,20 @@
// EngineService provides engine status and model management to the frontend.
type EngineService struct {
    cfg            *config.Config
    app            *application.App
    initCallback   func()
    mu             sync.RWMutex
    status         string
    hardwareInfo   string
    lastError      string
    downloadMu     sync.Mutex
    downloadID     string
    downloadCancel context.CancelFunc
    cfg                *config.Config
    app                *application.App
    initCallback       func()
    mu                 sync.RWMutex
    status             string
    hardwareInfo       string
    lastError          string
    downloadMu         sync.Mutex
    downloadID         string
    downloadCancel     context.CancelFunc
    downloadCancelling bool
    downloadProgress   float64
    downloadDownloaded int64
    downloadTotal      int64
}
func NewEngineService(cfg *config.Config) *EngineService {
@@ -117,6 +121,7 @@
    defer finish()
    err = model.DownloadProfileWithContext(ctx, profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        s.updateModelDownloadProgress(profile.ID, percent, downloaded, total)
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
@@ -145,6 +150,7 @@
        s.downloadMu.Unlock()
        return false
    }
    s.downloadCancelling = true
    s.downloadCancel()
    s.downloadMu.Unlock()
@@ -154,6 +160,12 @@
        })
    }
    return true
}
func (s *EngineService) GetModelDownloadStatus() map[string]interface{} {
    s.downloadMu.Lock()
    defer s.downloadMu.Unlock()
    return s.modelDownloadStatusLocked()
}
func (s *EngineService) modelStatusMap(profile model.ModelProfile, current modelselection.CurrentModel) map[string]interface{} {
@@ -215,6 +227,7 @@
    defer finish()
    err = model.DownloadProfileWithContext(ctx, profile, []string{primaryURL, fallbackURL}, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        s.updateModelDownloadProgress(profile.ID, percent, downloaded, total)
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
@@ -248,6 +261,7 @@
    defer finish()
    err = model.DownloadProfileWithContext(ctx, current.Profile, current.Profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        s.updateModelDownloadProgress(current.ModelID, percent, downloaded, total)
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
@@ -304,17 +318,53 @@
    ctx, cancel := context.WithCancel(context.Background())
    s.downloadID = modelID
    s.downloadCancel = cancel
    s.downloadCancelling = false
    s.downloadProgress = 0
    s.downloadDownloaded = 0
    s.downloadTotal = 0
    finish := func() {
        emitFinished := false
        s.downloadMu.Lock()
        if s.downloadID == modelID {
            s.downloadID = ""
            s.downloadCancel = nil
            s.downloadCancelling = false
            s.downloadProgress = 0
            s.downloadDownloaded = 0
            s.downloadTotal = 0
            emitFinished = true
        }
        s.downloadMu.Unlock()
        if emitFinished && s.app != nil {
            s.app.Event.Emit("model:download-finished", map[string]interface{}{
                "modelID": modelID,
            })
        }
    }
    return ctx, finish, nil
}
func (s *EngineService) updateModelDownloadProgress(modelID string, percent float64, downloaded, total int64) {
    s.downloadMu.Lock()
    if s.downloadID == modelID && s.downloadCancel != nil {
        s.downloadProgress = percent
        s.downloadDownloaded = downloaded
        s.downloadTotal = total
    }
    s.downloadMu.Unlock()
}
func (s *EngineService) modelDownloadStatusLocked() map[string]interface{} {
    return map[string]interface{}{
        "active":     s.downloadCancel != nil,
        "modelID":    s.downloadID,
        "cancelling": s.downloadCancelling,
        "percent":    s.downloadProgress,
        "downloaded": s.downloadDownloaded,
        "total":      s.downloadTotal,
    }
}
func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) {
    profile, err := model.GetModelProfile(modelID)
    if err != nil {
privatevoice.src/services/engine_service_test.go
New file
@@ -0,0 +1,55 @@
package services
import "testing"
func TestEngineServiceDownloadStatusTracksCancelAndFinish(t *testing.T) {
    service := NewEngineService(nil)
    status := service.GetModelDownloadStatus()
    if status["active"].(bool) {
        t.Fatal("new service should not report an active model download")
    }
    _, finish, err := service.beginModelDownload("zipformer-ko")
    if err != nil {
        t.Fatal(err)
    }
    status = service.GetModelDownloadStatus()
    if !status["active"].(bool) {
        t.Fatal("active download was not reported")
    }
    if got := status["modelID"]; got != "zipformer-ko" {
        t.Fatalf("modelID = %v, want zipformer-ko", got)
    }
    if status["cancelling"].(bool) {
        t.Fatal("new download should not start in cancelling state")
    }
    service.updateModelDownloadProgress("zipformer-ko", 42.5, 425, 1000)
    status = service.GetModelDownloadStatus()
    if got := status["percent"]; got != 42.5 {
        t.Fatalf("percent = %v, want 42.5", got)
    }
    if got := status["downloaded"]; got != int64(425) {
        t.Fatalf("downloaded = %v, want 425", got)
    }
    if got := status["total"]; got != int64(1000) {
        t.Fatalf("total = %v, want 1000", got)
    }
    if !service.CancelModelDownload("zipformer-ko") {
        t.Fatal("expected active download to be cancelled")
    }
    status = service.GetModelDownloadStatus()
    if !status["cancelling"].(bool) {
        t.Fatal("cancelled download should report cancelling state until finish")
    }
    finish()
    status = service.GetModelDownloadStatus()
    if status["active"].(bool) {
        t.Fatal("finished download should clear active state")
    }
    if got := status["modelID"]; got != "" {
        t.Fatalf("modelID after finish = %v, want empty", got)
    }
}