Ariver
2026-06-24 7999e66c9c78ada3666eb7cea7c22a352bb4cbf0
Add cancellable model downloads
7 files modified
403 ■■■■■ changed files
privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte 71 ●●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte 81 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/en.json 9 ●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/lib/i18n/zh.json 9 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/downloader.go 68 ●●●● patch | view | raw | blame | history
privatevoice.src/internal/model/model_test.go 96 ●●●●● patch | view | raw | blame | history
privatevoice.src/services/engine_service.go 69 ●●●●● patch | view | raw | blame | history
privatevoice.src/frontend/src/components/onboarding/OnboardingView.svelte
@@ -10,12 +10,18 @@
  let progress = $state(0)
  let failed = $state(false)
  let downloadActive = $state(false)
  let cancelling = $state(false)
  let currentModelID = $state('')
  onMount(() => {
    Events.On('model:download-progress', (ev: any) => {
      const data = ev?.data
      if (data?.percent != null) {
        if (data?.modelID) {
          currentModelID = data.modelID
        }
        downloadActive = true
        cancelling = false
        failed = false
        statusText = t('onboarding.syncModel')
        progress = data.percent
@@ -29,6 +35,7 @@
      const data = ev?.data
      if (data?.status === 'ready') {
        downloadActive = false
        cancelling = false
        failed = false
        statusText = t('onboarding.complete')
        progress = 100
@@ -41,6 +48,7 @@
        }
      } else if (data?.status === 'error') {
        downloadActive = false
        cancelling = false
        statusText = t('onboarding.failed')
        detailText = data?.error || ''
        failed = true
@@ -52,7 +60,10 @@
  async function triggerDownload() {
    try {
      const current: any = await Call.ByName('voicesnap/services.EngineService.GetCurrentModelStatus')
      currentModelID = typeof current?.modelID === 'string' ? current.modelID : ''
      downloadActive = true
      cancelling = false
      failed = false
      statusText = t('onboarding.syncModel')
      detailText = ''
@@ -62,10 +73,28 @@
      detailText = t('onboarding.extracting')
    } catch (err: any) {
      downloadActive = false
      cancelling = false
      if (isCancelError(err)) {
        statusText = t('onboarding.cancelled')
        detailText = t('onboarding.cancelledDetail')
      } else {
      statusText = t('onboarding.failed')
      detailText = err?.message || String(err)
      }
      failed = true
    }
  }
  function isCancelError(err: any): boolean {
    const text = String(err?.message || err || '').toLowerCase()
    return text.includes('context canceled') || text.includes('cancelled') || text.includes('canceled')
  }
  async function cancelDownload() {
    if (!downloadActive || cancelling) return
    cancelling = true
    statusText = t('onboarding.cancelling')
    await Call.ByName('voicesnap/services.EngineService.CancelModelDownload', currentModelID)
  }
</script>
@@ -86,6 +115,18 @@
    <!-- Progress bar -->
    <div class="progress-track">
      <div class="progress-fill" style="width: {progress}%" class:failed></div>
    </div>
    <div class="actions">
      {#if downloadActive}
        <button class="action cancel" onclick={cancelDownload} disabled={cancelling}>
          {cancelling ? t('onboarding.cancelling') : t('onboarding.cancel')}
        </button>
      {:else if failed}
        <button class="action primary" onclick={triggerDownload}>
          {t('onboarding.retry')}
        </button>
      {/if}
    </div>
  </div>
</div>
@@ -162,4 +203,34 @@
  .progress-fill.failed {
    background: var(--color-red);
  }
  .actions {
    margin-top: var(--spacing-lg);
    min-height: 32px;
  }
  .action {
    min-width: 96px;
    height: 32px;
    padding: 0 14px;
    border: none;
    border-radius: var(--radius-sm);
    color: white;
    font-size: var(--font-size-sm);
    font-weight: 500;
    cursor: pointer;
  }
  .action.primary {
    background: var(--color-blue);
  }
  .action.cancel {
    background: var(--color-red);
  }
  .action:disabled {
    cursor: not-allowed;
    opacity: 0.72;
  }
</style>
privatevoice.src/frontend/src/components/settings/LanguagePage.svelte
@@ -30,6 +30,8 @@
  let languageOptions = $state<LanguageOption[]>([])
  let modelOptions = $state<ModelOption[]>([])
  let modelBusyID = $state('')
  let modelBusyKind = $state<'download' | 'select' | ''>('')
  let modelCancellingID = $state('')
  let modelProgress = $state(0)
  let modelError = $state('')
@@ -119,8 +121,8 @@
  function modelActionLabel(option: ModelOption): string {
    if (modelBusyID === option.modelID) {
      if (!option.installed && modelProgress > 0) {
        return `${Math.min(100, Math.max(0, modelProgress)).toFixed(0)}%`
      if (modelBusyKind === 'download') {
        return modelCancellingID === option.modelID ? t('settings.modelCancelling') : t('settings.modelCancel')
      }
      return t('settings.modelWorking')
    }
@@ -129,9 +131,62 @@
    return t('settings.modelDownload')
  }
  function modelMeta(option: ModelOption): string {
    let meta = option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')
    if (option.downloadSize) {
      meta += ` · ${option.downloadSize}`
    }
    if (modelBusyID === option.modelID && modelBusyKind === 'download' && modelProgress > 0) {
      const pct = Math.min(100, Math.max(0, modelProgress)).toFixed(0)
      meta += ` · ${pct}%`
    }
    return meta
  }
  function isCancelError(err: any): boolean {
    const text = String(err?.message || err || '').toLowerCase()
    return text.includes('context canceled') || text.includes('cancelled') || text.includes('canceled')
  }
  function canCancelModel(option: ModelOption): boolean {
    return modelBusyID === option.modelID && modelBusyKind === 'download'
  }
  async function onModelButton(option: ModelOption) {
    if (canCancelModel(option)) {
      await cancelModelDownload(option)
      return
    }
    await onModelAction(option)
  }
  async function cancelModelDownload(option: ModelOption) {
    if (!canCancelModel(option) || modelCancellingID) return
    modelCancellingID = option.modelID
    modelError = ''
    try {
      const cancelled: any = await Call.ByName('voicesnap/services.EngineService.CancelModelDownload', option.modelID)
      if (!cancelled) {
        modelBusyID = ''
        modelBusyKind = ''
        modelCancellingID = ''
        modelProgress = 0
        await loadModelOptions()
      }
    } catch (err: any) {
      modelError = err?.message || String(err || t('settings.modelActionFailed'))
      modelBusyID = ''
      modelBusyKind = ''
      modelCancellingID = ''
      modelProgress = 0
      await loadModelOptions()
    }
  }
  async function onModelAction(option: ModelOption) {
    if ((option.isCurrent && option.installed) || modelBusyID) return
    modelBusyID = option.modelID
    modelBusyKind = option.installed ? 'select' : 'download'
    modelProgress = 0
    modelError = ''
    try {
@@ -144,11 +199,17 @@
      }
      await loadModelOptions()
    } catch (err: any) {
      if (!isCancelError(err)) {
      modelError = err?.message || String(err || t('settings.modelActionFailed'))
      }
      await loadModelOptions()
    } finally {
      if (modelBusyID === option.modelID) {
      modelBusyID = ''
        modelBusyKind = ''
        modelCancellingID = ''
      modelProgress = 0
      }
    }
  }
@@ -236,20 +297,16 @@
              </span>
            </div>
            <span class="model-desc">{modelDescription(option)}</span>
            <span class="model-meta">
              {option.installed ? t('settings.modelInstalled') : t('settings.modelNotInstalled')}
              {#if option.downloadSize}
                · {option.downloadSize}
              {/if}
            </span>
            <span class="model-meta">{modelMeta(option)}</span>
          </div>
          {#if option.isCurrent && option.installed}
            <span class="model-current-status">{t('settings.modelCurrent')}</span>
          {:else}
            <button
              class="model-action primary"
              disabled={!!modelBusyID}
              onclick={() => onModelAction(option)}
              class:cancel={canCancelModel(option)}
              disabled={!!modelBusyID && modelBusyID !== option.modelID}
              onclick={() => onModelButton(option)}
            >
              {modelActionLabel(option)}
            </button>
@@ -455,6 +512,10 @@
    color: white;
  }
  .model-action.primary.cancel {
    background: var(--color-red);
  }
  .model-action:disabled {
    cursor: not-allowed;
    opacity: 0.72;
privatevoice.src/frontend/src/lib/i18n/en.json
@@ -66,6 +66,8 @@
    "modelCurrent": "Current",
    "modelUse": "Use",
    "modelDownload": "Download",
    "modelCancel": "Cancel",
    "modelCancelling": "Cancelling",
    "modelWorking": "Working",
    "modelInstalled": "Installed",
    "modelNotInstalled": "Not installed",
@@ -276,7 +278,12 @@
    "optimizing": "Optimizing...",
    "extracting": "Extracting model files...",
    "complete": "Complete",
    "failed": "Setup failed"
    "failed": "Setup failed",
    "cancel": "Cancel",
    "cancelling": "Cancelling",
    "cancelled": "Cancelled",
    "cancelledDetail": "Model download was cancelled. You can download it again later.",
    "retry": "Download again"
  },
  "update": {
    "title": "Update Available",
privatevoice.src/frontend/src/lib/i18n/zh.json
@@ -66,6 +66,8 @@
    "modelCurrent": "当前",
    "modelUse": "使用",
    "modelDownload": "下载",
    "modelCancel": "取消",
    "modelCancelling": "正在取消",
    "modelWorking": "处理中",
    "modelInstalled": "已安装",
    "modelNotInstalled": "未安装",
@@ -276,7 +278,12 @@
    "optimizing": "正在优化...",
    "extracting": "正在解压模型文件...",
    "complete": "完成",
    "failed": "设置失败"
    "failed": "设置失败",
    "cancel": "取消",
    "cancelling": "正在取消",
    "cancelled": "已取消",
    "cancelledDetail": "模型下载已取消,可以稍后重新下载。",
    "retry": "重新下载"
  },
  "update": {
    "title": "发现新版本",
privatevoice.src/internal/model/downloader.go
@@ -1,6 +1,7 @@
package model
import (
    "context"
    "errors"
    "fmt"
    "io"
@@ -27,6 +28,10 @@
}
func DownloadProfile(profile ModelProfile, urls []string, modelsDir string, progress ProgressCallback) error {
    return DownloadProfileWithContext(context.Background(), profile, urls, modelsDir, progress)
}
func DownloadProfileWithContext(ctx context.Context, profile ModelProfile, urls []string, modelsDir string, progress ProgressCallback) error {
    if err := os.MkdirAll(modelsDir, 0755); err != nil {
        return fmt.Errorf("failed to create models dir: %w", err)
    }
@@ -34,10 +39,16 @@
    var lastErr error
    for i, url := range urls {
        if err := ctx.Err(); err != nil {
            return err
        }
        if url == "" {
            continue
        }
        if err := downloadAndInstallFromURL(profile, url, modelsDir, i+1, progress); err != nil {
        if err := downloadAndInstallFromURL(ctx, profile, url, modelsDir, i+1, progress); err != nil {
            if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
                return err
            }
            lastErr = err
            logger.Info("Model install from URL %d failed: %v", i+1, err)
            continue
@@ -63,7 +74,7 @@
    }
}
func downloadAndInstallFromURL(profile ModelProfile, url, modelsDir string, attempt int, progress ProgressCallback) error {
func downloadAndInstallFromURL(ctx context.Context, profile ModelProfile, url, modelsDir string, attempt int, progress ProgressCallback) error {
    runID := fmt.Sprintf("%d-%d", time.Now().UnixNano(), attempt)
    downloadDir := filepath.Join(modelsDir, ".downloads", profile.ID, runID)
    extractDir := filepath.Join(downloadDir, "extract")
@@ -77,27 +88,42 @@
    defer os.RemoveAll(stagingDir)
    logger.Info("Downloading model from URL %d: %s", attempt, url)
    if err := downloadFile(url, archivePath, progress); err != nil {
    if err := downloadFileWithContext(ctx, url, archivePath, progress); err != nil {
        return err
    }
    if err := ctx.Err(); err != nil {
        return err
    }
    logger.Info("Extracting model archive...")
    if err := extractArchive(archivePath, extractDir); err != nil {
    if err := extractArchiveWithContext(ctx, archivePath, extractDir); err != nil {
        return fmt.Errorf("extraction failed: %w", err)
    }
    if err := ctx.Err(); err != nil {
        return err
    }
    sourceDir, err := findInstallSource(profile, extractDir)
    if err != nil {
        return err
    }
    if err := ctx.Err(); err != nil {
        return err
    }
    if err := moveOrCopyDir(sourceDir, stagingDir); err != nil {
        return fmt.Errorf("failed to stage model: %w", err)
    }
    if err := ctx.Err(); err != nil {
        return err
    }
    validation := ValidateModelDir(profile, stagingDir)
    if !validation.Valid {
        return fmt.Errorf("downloaded model is incomplete: missing=%v problems=%v", validation.Missing, validation.Problems)
    }
    if err := ctx.Err(); err != nil {
        return err
    }
    finalDir := filepath.Join(modelsDir, profile.InstallDirName)
@@ -105,15 +131,25 @@
}
func downloadFile(url, destPath string, progress ProgressCallback) error {
    return downloadFileWithContext(context.Background(), url, destPath, progress)
}
func downloadFileWithContext(ctx context.Context, url, destPath string, progress ProgressCallback) error {
    const maxAttempts = 6
    var lastErr error
    var lastPercent = -1.0
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        if err := ctx.Err(); err != nil {
            return err
        }
        downloaded := existingFileSize(destPath)
        err := downloadFileAttempt(url, destPath, downloaded, &lastPercent, progress)
        err := downloadFileAttempt(ctx, url, destPath, downloaded, &lastPercent, progress)
        if err == nil {
            return nil
        }
        if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
            return err
        }
        if isNonRetryableDownloadError(err) {
            return err
@@ -121,15 +157,19 @@
        lastErr = err
        logger.Info("Download attempt %d/%d failed: %v", attempt, maxAttempts, err)
        if attempt < maxAttempts {
            time.Sleep(time.Duration(attempt) * time.Second)
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(time.Duration(attempt) * time.Second):
            }
        }
    }
    return fmt.Errorf("download failed after %d attempts: %w", maxAttempts, lastErr)
}
func downloadFileAttempt(url, destPath string, resumeFrom int64, lastPercent *float64, progress ProgressCallback) error {
    req, err := http.NewRequest(http.MethodGet, url, nil)
func downloadFileAttempt(ctx context.Context, url, destPath string, resumeFrom int64, lastPercent *float64, progress ProgressCallback) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nonRetryableDownloadError{err: err}
    }
@@ -184,6 +224,9 @@
    }
    buf := make([]byte, 32*1024)
    for {
        if err := ctx.Err(); err != nil {
            return err
        }
        n, readErr := resp.Body.Read(buf)
        if n > 0 {
            if _, writeErr := out.Write(buf[:n]); writeErr != nil {
@@ -252,9 +295,16 @@
}
func extractArchive(archivePath, destDir string) error {
    cmd := exec.Command("tar", "-xf", archivePath, "-C", destDir)
    return extractArchiveWithContext(context.Background(), archivePath, destDir)
}
func extractArchiveWithContext(ctx context.Context, archivePath, destDir string) error {
    cmd := exec.CommandContext(ctx, "tar", "-xf", archivePath, "-C", destDir)
    output, err := cmd.CombinedOutput()
    if err != nil {
        if ctxErr := ctx.Err(); ctxErr != nil {
            return ctxErr
        }
        return fmt.Errorf("tar extraction failed: %v, output: %s", err, string(output))
    }
    return nil
privatevoice.src/internal/model/model_test.go
@@ -3,6 +3,8 @@
import (
    "archive/tar"
    "bytes"
    "context"
    "errors"
    "fmt"
    "net/http"
    "net/http/httptest"
@@ -10,7 +12,10 @@
    "path/filepath"
    "strconv"
    "strings"
    "sync"
    "sync/atomic"
    "testing"
    "time"
)
func TestRegistryReturnsDefaultSenseVoice(t *testing.T) {
@@ -761,6 +766,97 @@
    }
}
func TestDownloadProfileCancelStopsWithoutFallbackOrState(t *testing.T) {
    root := t.TempDir()
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    var once sync.Once
    var fallbackHits int32
    started := make(chan struct{})
    payload := bytes.Repeat([]byte("a"), 512*1024)
    fallbackArchive := tarArchive(t, map[string]string{
        "sensevoice/tokens.txt":      "tokens",
        "sensevoice/model.int8.onnx": "fallback-model",
    })
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        switch r.URL.Path {
        case "/slow":
            once.Do(func() { close(started) })
            w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
            w.WriteHeader(http.StatusOK)
            flusher, _ := w.(http.Flusher)
            for offset := 0; offset < len(payload); offset += 32 * 1024 {
                select {
                case <-r.Context().Done():
                    return
                default:
                }
                end := offset + 32*1024
                if end > len(payload) {
                    end = len(payload)
                }
                if _, err := w.Write(payload[offset:end]); err != nil {
                    return
                }
                if flusher != nil {
                    flusher.Flush()
                }
                time.Sleep(5 * time.Millisecond)
            }
        case "/fallback":
            atomic.AddInt32(&fallbackHits, 1)
            w.WriteHeader(http.StatusOK)
            w.Write(fallbackArchive)
        default:
            http.NotFound(w, r)
        }
    }))
    defer server.Close()
    errCh := make(chan error, 1)
    go func() {
        errCh <- DownloadProfileWithContext(ctx, DefaultModelProfile(), []string{server.URL + "/slow", server.URL + "/fallback"}, root, func(percent float64, _, _ int64) {
            if percent > 0 {
                cancel()
            }
        })
    }()
    select {
    case <-started:
    case <-time.After(2 * time.Second):
        t.Fatal("slow download did not start")
    }
    var err error
    select {
    case err = <-errCh:
    case <-time.After(2 * time.Second):
        t.Fatal("cancelled download did not return")
    }
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("download error = %v, want context.Canceled", err)
    }
    if got := atomic.LoadInt32(&fallbackHits); got != 0 {
        t.Fatalf("fallback hits = %d, want 0", got)
    }
    state, err := LoadInstallStateFromRoot(root)
    if err != nil {
        t.Fatal(err)
    }
    if _, ok := state.InstalledModels[DefaultModelID]; ok {
        t.Fatal("cancelled download should not write installed model state")
    }
    resolved, err := ResolveModelInRoot(DefaultModelID, root)
    if err != nil {
        t.Fatal(err)
    }
    if resolved.IsUsable() {
        t.Fatal("cancelled download should not leave a usable model")
    }
}
func TestDownloadFileResumesExistingPartialFile(t *testing.T) {
    payload := []byte("0123456789abcdefghijklmnopqrstuvwxyz")
privatevoice.src/services/engine_service.go
@@ -1,6 +1,7 @@
package services
import (
    "context"
    "fmt"
    "sync"
    "voicesnap/internal/config"
@@ -22,6 +23,9 @@
    status       string
    hardwareInfo string
    lastError    string
    downloadMu     sync.Mutex
    downloadID     string
    downloadCancel context.CancelFunc
}
func NewEngineService(cfg *config.Config) *EngineService {
@@ -106,7 +110,13 @@
        return fmt.Errorf("no download URL configured for model %s", profile.ID)
    }
    err = model.DownloadProfile(profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
    ctx, finish, err := s.beginModelDownload(profile.ID)
    if err != nil {
        return err
    }
    defer finish()
    err = model.DownloadProfileWithContext(ctx, profile, profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
@@ -126,6 +136,24 @@
    config.Save(s.cfg)
    s.ReloadCurrentModel()
    return nil
}
func (s *EngineService) CancelModelDownload(modelID string) bool {
    s.downloadMu.Lock()
    activeID := s.downloadID
    if s.downloadCancel == nil || (modelID != "" && activeID != modelID) {
        s.downloadMu.Unlock()
        return false
    }
    s.downloadCancel()
    s.downloadMu.Unlock()
    if s.app != nil {
        s.app.Event.Emit("model:download-cancelled", map[string]interface{}{
            "modelID": activeID,
        })
    }
    return true
}
func (s *EngineService) modelStatusMap(profile model.ModelProfile, current modelselection.CurrentModel) map[string]interface{} {
@@ -179,12 +207,21 @@
// DownloadModel downloads the ASR model with progress events.
func (s *EngineService) DownloadModel(primaryURL, fallbackURL string) error {
    err := model.Download(primaryURL, fallbackURL, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
    profile := model.DefaultModelProfile()
    ctx, finish, err := s.beginModelDownload(profile.ID)
    if err != nil {
        return err
    }
    defer finish()
    err = model.DownloadProfileWithContext(ctx, profile, []string{primaryURL, fallbackURL}, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
                "downloaded": downloaded,
                "total":      total,
                "modelID":    profile.ID,
                "modelName":  profile.DisplayName,
            })
        }
    })
@@ -204,7 +241,13 @@
        return fmt.Errorf("no download URL configured for model %s", current.ModelID)
    }
    err := model.DownloadProfile(current.Profile, current.Profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
    ctx, finish, err := s.beginModelDownload(current.ModelID)
    if err != nil {
        return err
    }
    defer finish()
    err = model.DownloadProfileWithContext(ctx, current.Profile, current.Profile.DownloadURLs, paths.ModelsRoot(), func(percent float64, downloaded, total int64) {
        if s.app != nil {
            s.app.Event.Emit("model:download-progress", map[string]interface{}{
                "percent":    percent,
@@ -252,6 +295,26 @@
    return modelselection.Resolve(s.cfg, language.NewSystemDetector())
}
func (s *EngineService) beginModelDownload(modelID string) (context.Context, func(), error) {
    s.downloadMu.Lock()
    defer s.downloadMu.Unlock()
    if s.downloadCancel != nil {
        return nil, nil, fmt.Errorf("model %s is already downloading", s.downloadID)
    }
    ctx, cancel := context.WithCancel(context.Background())
    s.downloadID = modelID
    s.downloadCancel = cancel
    finish := func() {
        s.downloadMu.Lock()
        if s.downloadID == modelID {
            s.downloadID = ""
            s.downloadCancel = nil
        }
        s.downloadMu.Unlock()
    }
    return ctx, finish, nil
}
func (s *EngineService) allowedModelProfile(modelID string) (model.ModelProfile, error) {
    profile, err := model.GetModelProfile(modelID)
    if err != nil {