From 7ab519092a15313e5cb7b433ce01771e05e31f72 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:07:40 -0700 Subject: [PATCH 1/3] refactor: split generation.ts and model.ts store slices below 250 lines (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract cohesive units from the two largest store slices to bring each under the 250-line target from #53, without splitting logic that would hurt maintainability. generation.ts: 352 → 204 lines - Extract applyGenerationEvent → generation-events.ts (event-to-state mapper) - Extract active task actions → generation-tasks.ts (refresh/resume/discard) model.ts: 423 → 196 lines - Extract MODEL_CATALOG constant → model-catalog.ts - Extract applyModelStatus computation → model-status-apply.ts - Extract backend provision actions → backend-provision-actions.ts - Extract model sync actions → model-sync-actions.ts (deleteAll/refresh) SettingsOverlay.tsx (209 lines) was already split into section components. model_manager/mod.rs (442 lines) was already split into submodules by #121. All existing tests pass with no regressions. Closes #53 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../store/slices/backend-provision-actions.ts | 78 ++++++ src/app/lib/store/slices/generation-events.ts | 124 +++++++++ src/app/lib/store/slices/generation-tasks.ts | 63 +++++ src/app/lib/store/slices/generation.ts | 162 +----------- src/app/lib/store/slices/model-catalog.ts | 23 ++ .../lib/store/slices/model-status-apply.ts | 106 ++++++++ .../lib/store/slices/model-sync-actions.ts | 85 ++++++ src/app/lib/store/slices/model.ts | 249 +----------------- 8 files changed, 497 insertions(+), 393 deletions(-) create mode 100644 src/app/lib/store/slices/backend-provision-actions.ts create mode 100644 src/app/lib/store/slices/generation-events.ts create mode 100644 src/app/lib/store/slices/generation-tasks.ts create mode 100644 src/app/lib/store/slices/model-catalog.ts create mode 100644 src/app/lib/store/slices/model-status-apply.ts create mode 100644 src/app/lib/store/slices/model-sync-actions.ts diff --git a/src/app/lib/store/slices/backend-provision-actions.ts b/src/app/lib/store/slices/backend-provision-actions.ts new file mode 100644 index 0000000..9e21ec5 --- /dev/null +++ b/src/app/lib/store/slices/backend-provision-actions.ts @@ -0,0 +1,78 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { StoreApi } from "zustand"; +import * as api from "@/app/lib/api"; + +/** + * Backend provision actions — installing, updating, and checking the ACE-Step + * backend. Extracted from the model slice to keep the slice focused on model + * download/status management. + */ +export function createBackendProvisionActions( + set: StoreApi["setState"], + get: StoreApi["getState"], +) { + return { + refreshBackendProvisionStatus: async () => { + if (!api.isTauriRuntime()) return; + try { + const status = await api.getBackendProvisionStatus(); + set({ backendProvisionStatus: status }); + } catch (error) { + console.warn("Failed to refresh backend provision status:", error); + } + }, + + provisionBackend: async () => { + if (!api.isTauriRuntime()) return; + try { + const status = await api.provisionBackend(); + set({ backendProvisionStatus: status }); + await get().refreshBootstrapStatus(); + } catch (error) { + set({ + backendProvisionStatus: { + state: "failed", + installedCommit: null, + installedTag: null, + latestCommit: null, + latestTag: null, + updateAvailable: false, + downloadedBytes: 0, + error: + error instanceof Error + ? { + code: "BACKEND_PROVISION_FAILED", + message: error.message, + recoverable: true, + } + : undefined, + }, + }); + } + }, + + updateBackend: async () => { + if (!api.isTauriRuntime()) return; + try { + const status = await api.updateBackend(); + set({ backendProvisionStatus: status }); + await get().refreshBootstrapStatus(); + } catch (error) { + set({ + backendProvisionStatus: { + ...get().backendProvisionStatus, + state: "failed", + error: + error instanceof Error + ? { + code: "BACKEND_PROVISION_FAILED", + message: error.message, + recoverable: true, + } + : undefined, + }, + }); + } + }, + }; +} diff --git a/src/app/lib/store/slices/generation-events.ts b/src/app/lib/store/slices/generation-events.ts new file mode 100644 index 0000000..9344e28 --- /dev/null +++ b/src/app/lib/store/slices/generation-events.ts @@ -0,0 +1,124 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { StoreApi } from "zustand"; +import type { GenerationEvent } from "@/app/lib/types"; +import { createFailedGenerationState, variationLabel } from "@/app/lib/store-helpers"; +import { localizeAppError } from "@/app/lib/errors"; +import { shouldMarkBootstrapFailed } from "@/app/lib/model-bootstrap"; +import { tr } from "@/app/lib/i18n"; + +/** + * Apply a generation lifecycle event to the store. Extracted from the + * generation slice so the slice file stays focused on action orchestration + * (run, cancel, enhance) rather than event-to-state mapping. + */ +export function applyGenerationEvent( + event: GenerationEvent, + set: StoreApi["setState"], +) { + switch (event.type) { + case "backend_starting": + set({ + bootstrapStatus: { + state: "downloading", + message: tr("status.preparingBackend"), + }, + generationState: { + status: "running", + phase: "backend_starting", + statusMessage: `${tr("status.startingBackend")}${variationLabel(event)}`, + error: null, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + }, + }); + break; + case "submitted": + set({ + generationState: { + status: "running", + phase: "submitted", + statusMessage: `${tr("status.submittedTask", { taskId: event.taskId })}${variationLabel(event)}`, + error: null, + taskId: event.taskId, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + }, + }); + break; + case "queued": + set({ + generationState: { + status: "running", + phase: "queued", + statusMessage: `${tr("status.queued")}${variationLabel(event)}`, + error: null, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + }, + }); + break; + case "running": + set({ + generationState: { + status: "running", + phase: "running", + statusMessage: `${tr("status.running")}${variationLabel(event)}`, + error: null, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + progressPercent: event.progressPercent, + }, + }); + break; + case "downloading": + set({ + generationState: { + status: "running", + phase: "downloading", + statusMessage: `${tr("status.downloadingAudio")}${variationLabel(event)}`, + error: null, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + }, + }); + break; + case "completed": + set({ + bootstrapStatus: { + state: "ready", + message: tr("status.localStackReady"), + }, + generationState: { + status: "completed", + phase: "completed", + statusMessage: tr("status.completed"), + error: null, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + }, + }); + break; + case "cancelled": + set({ + generationState: { + status: "cancelled", + phase: "cancelled", + statusMessage: tr("status.cancelled"), + error: null, + variationCurrent: event.variationCurrent, + variationTotal: event.variationTotal, + }, + }); + break; + case "failed": { + const error = localizeAppError(event.error); + set({ + bootstrapStatus: shouldMarkBootstrapFailed(error.code) + ? { state: "failed", message: error.message, error } + : { state: "ready", message: tr("status.localStackReady") }, + generationState: createFailedGenerationState(tr("status.failed"), error), + }); + break; + } + } +} diff --git a/src/app/lib/store/slices/generation-tasks.ts b/src/app/lib/store/slices/generation-tasks.ts new file mode 100644 index 0000000..1bda35e --- /dev/null +++ b/src/app/lib/store/slices/generation-tasks.ts @@ -0,0 +1,63 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { StoreApi } from "zustand"; +import * as api from "@/app/lib/api"; +import { createFailedGenerationState } from "@/app/lib/store-helpers"; +import { localizeAppError } from "@/app/lib/errors"; +import { tr } from "@/app/lib/i18n"; + +/** + * Active generation task management — recovering, discarding, and refreshing + * tasks that survived a restart. Extracted from the generation slice to keep + * the slice focused on the primary generation lifecycle. + */ +export function createGenerationTaskActions( + set: StoreApi["setState"], + _get: StoreApi["getState"], +) { + return { + refreshActiveTasks: async () => { + if (!api.isTauriRuntime()) return; + const activeTasks = await api.listActiveGenerationTasks(); + set({ activeTasks }); + }, + + resumeActiveTask: async (id: string) => { + set({ + generationState: { + status: "running", + phase: "recovering", + statusMessage: tr("status.recovering"), + error: null, + }, + }); + try { + const record = await api.resumeGenerationTask(id); + set((state) => ({ + activeTasks: state.activeTasks.filter((task) => task.id !== id), + currentGeneration: record, + history: [record, ...state.history.filter((item) => item.id !== record.id)], + generationState: { + status: "completed", + phase: "completed", + statusMessage: tr("status.completed"), + error: null, + }, + })); + } catch (error) { + const appError = localizeAppError(error); + set({ + generationState: createFailedGenerationState(tr("status.recoveryFailed"), appError), + }); + } + }, + + discardActiveTask: async (id: string) => { + if (api.isTauriRuntime()) { + await api.discardActiveGenerationTask(id); + } + set((state) => ({ + activeTasks: state.activeTasks.filter((task) => task.id !== id), + })); + }, + }; +} diff --git a/src/app/lib/store/slices/generation.ts b/src/app/lib/store/slices/generation.ts index 45f100b..7b18f19 100644 --- a/src/app/lib/store/slices/generation.ts +++ b/src/app/lib/store/slices/generation.ts @@ -1,6 +1,6 @@ import type { GenerationStore } from "@/app/lib/store/types"; import type { StoreApi } from "zustand"; -import type { GenerationEvent, GenerationRecord } from "@/app/lib/types"; +import type { GenerationRecord } from "@/app/lib/types"; import * as api from "@/app/lib/api"; import { PREVIEW_DELAY_MS, @@ -8,7 +8,6 @@ import { createIdleGenerationState, prependRecentPrompt, sleep, - variationLabel, } from "@/app/lib/store-helpers"; import { createModelRequiredError, @@ -23,6 +22,8 @@ import { mergeGenerationRecords } from "@/app/lib/history-workflow"; import { validateGenerationForm } from "@/app/lib/validation"; import { tr } from "@/app/lib/i18n"; import { isModelDownloaded } from "@/app/lib/model-packs"; +import { applyGenerationEvent } from "./generation-events"; +import { createGenerationTaskActions } from "./generation-tasks"; export function createGenerationSlice( set: StoreApi["setState"], @@ -34,114 +35,8 @@ export function createGenerationSlice( playbackToggleRequest: 0, activeTasks: [], - applyGenerationEvent: (event: GenerationEvent) => { - switch (event.type) { - case "backend_starting": - set({ - bootstrapStatus: { - state: "downloading", - message: tr("status.preparingBackend"), - }, - generationState: { - status: "running", - phase: "backend_starting", - statusMessage: `${tr("status.startingBackend")}${variationLabel(event)}`, - error: null, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - }, - }); - break; - case "submitted": - set({ - generationState: { - status: "running", - phase: "submitted", - statusMessage: `${tr("status.submittedTask", { taskId: event.taskId })}${variationLabel(event)}`, - error: null, - taskId: event.taskId, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - }, - }); - break; - case "queued": - set({ - generationState: { - status: "running", - phase: "queued", - statusMessage: `${tr("status.queued")}${variationLabel(event)}`, - error: null, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - }, - }); - break; - case "running": - set({ - generationState: { - status: "running", - phase: "running", - statusMessage: `${tr("status.running")}${variationLabel(event)}`, - error: null, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - progressPercent: event.progressPercent, - }, - }); - break; - case "downloading": - set({ - generationState: { - status: "running", - phase: "downloading", - statusMessage: `${tr("status.downloadingAudio")}${variationLabel(event)}`, - error: null, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - }, - }); - break; - case "completed": - set({ - bootstrapStatus: { - state: "ready", - message: tr("status.localStackReady"), - }, - generationState: { - status: "completed", - phase: "completed", - statusMessage: tr("status.completed"), - error: null, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - }, - }); - break; - case "cancelled": - set({ - generationState: { - status: "cancelled", - phase: "cancelled", - statusMessage: tr("status.cancelled"), - error: null, - variationCurrent: event.variationCurrent, - variationTotal: event.variationTotal, - }, - }); - break; - case "failed": { - const error = localizeAppError(event.error); - set({ - bootstrapStatus: shouldMarkBootstrapFailed(error.code) - ? { state: "failed", message: error.message, error } - : { state: "ready", message: tr("status.localStackReady") }, - generationState: createFailedGenerationState(tr("status.failed"), error), - }); - break; - } - } - }, + applyGenerationEvent: (event: Parameters[0]) => + applyGenerationEvent(event, set), runGeneration: async () => { const validation = validateGenerationForm(get().form); @@ -298,55 +193,12 @@ export function createGenerationSlice( }); }, - refreshActiveTasks: async () => { - if (!api.isTauriRuntime()) return; - const activeTasks = await api.listActiveGenerationTasks(); - set({ activeTasks }); - }, - - resumeActiveTask: async (id: string) => { - set({ - generationState: { - status: "running", - phase: "recovering", - statusMessage: tr("status.recovering"), - error: null, - }, - }); - try { - const record = await api.resumeGenerationTask(id); - set((state) => ({ - activeTasks: state.activeTasks.filter((task) => task.id !== id), - currentGeneration: record, - history: [record, ...state.history.filter((item) => item.id !== record.id)], - generationState: { - status: "completed", - phase: "completed", - statusMessage: tr("status.completed"), - error: null, - }, - })); - } catch (error) { - const appError = localizeAppError(error); - set({ - generationState: createFailedGenerationState(tr("status.recoveryFailed"), appError), - }); - } - }, - - discardActiveTask: async (id: string) => { - if (api.isTauriRuntime()) { - await api.discardActiveGenerationTask(id); - } - set((state) => ({ - activeTasks: state.activeTasks.filter((task) => task.id !== id), - })); - }, - requestPlaybackToggle: () => { set((state) => ({ playbackToggleRequest: state.playbackToggleRequest + 1, })); }, + + ...createGenerationTaskActions(set, get), }; } diff --git a/src/app/lib/store/slices/model-catalog.ts b/src/app/lib/store/slices/model-catalog.ts new file mode 100644 index 0000000..7ad6dfa --- /dev/null +++ b/src/app/lib/store/slices/model-catalog.ts @@ -0,0 +1,23 @@ +import type { ModelCatalogItem, ModelVariant } from "@/app/lib/types"; + +/** + * Static model catalog — the list of available model variants and their + * metadata. Extracted from the model slice so the slice file stays focused + * on state management logic. + */ +export const MODEL_CATALOG: ModelCatalogItem[] = (["lite", "turbo", "pro"] as ModelVariant[]).map( + (id) => { + const label = id === "pro" ? "XL Turbo" : id === "lite" ? "Lite" : "Turbo"; + const modelName = id === "pro" ? "acestep-v15-xl-turbo" : "acestep-v15-turbo"; + return { + variant: id, + label, + modelName, + lmModel: id === "pro" ? "acestep-5Hz-lm-1.7B" : "acestep-5Hz-lm-0.6B", + lmBackend: "mlx" as const, + estimatedSizeBytes: id === "pro" ? 22 * 1024 * 1024 * 1024 : 8 * 1024 * 1024 * 1024, + description: "", + recommendedMemoryGb: id === "pro" ? 20 : id === "lite" ? 8 : 16, + }; + }, +); diff --git a/src/app/lib/store/slices/model-status-apply.ts b/src/app/lib/store/slices/model-status-apply.ts new file mode 100644 index 0000000..10248fb --- /dev/null +++ b/src/app/lib/store/slices/model-status-apply.ts @@ -0,0 +1,106 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { ModelStatusSnapshot } from "@/app/lib/types"; +import * as api from "@/app/lib/api"; +import { + MODEL_PACKS, + aggregatePackStatus, + expandDownloadedVariantsFromStatuses, + packIdForVariant, +} from "@/app/lib/model-packs"; +import { resolveModelBootstrapStatus } from "@/app/lib/model-bootstrap"; +import { tr } from "@/app/lib/i18n"; + +type StoreState = Pick< + GenerationStore, + "modelStatuses" | "settings" | "deviceInfo" | "backendProvisionStatus" +>; + +/** + * Compute the state patch + side effects for a model status update. + * Extracted from the model slice so the slice stays under the line limit + * without splitting a cohesive state-update across multiple files. + * + * Returns a partial state patch to `set()`, plus any `setSetting` side + * effects that should be fired (non-blocking). + */ +export function computeModelStatusPatch( + status: ModelStatusSnapshot, + state: StoreState, +): { + patch: Partial; + sideEffects: Promise[]; +} { + const modelStatuses = [ + ...state.modelStatuses.filter((current) => current.variant !== status.variant), + status, + ]; + const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); + const selectedPack = state.settings.modelVariant + ? packIdForVariant(state.settings.modelVariant) + : null; + const eventPack = packIdForVariant(status.variant); + const packAggregate = aggregatePackStatus(modelStatuses, eventPack); + const nextSettings = { ...state.settings, downloadedModels }; + const sideEffects: Promise[] = []; + + if (status.state !== "downloading") { + const currentSelected = state.settings.modelVariant; + const nextSelected = + currentSelected && + MODEL_PACKS[eventPack].variants.includes(currentSelected) && + !downloadedModels.includes(currentSelected) + ? null + : currentSelected; + if (nextSettings.modelVariant !== nextSelected) { + nextSettings.modelVariant = nextSelected; + } + if (api.isTauriRuntime()) { + sideEffects.push(api.setSetting("downloadedModels", downloadedModels)); + if (nextSelected === null && currentSelected !== null) { + sideEffects.push(api.setSetting("modelVariant", nextSelected)); + } + } + } + + const bootstrapStatus = + selectedPack === eventPack + ? packAggregate.state === "downloading" + ? { + state: "downloading" as const, + message: tr("status.downloadingModel", { + model: MODEL_PACKS[eventPack].label, + }), + downloadedBytes: packAggregate.downloadedBytes, + totalBytes: packAggregate.totalBytes, + } + : packAggregate.state === "failed" + ? { + state: "failed" as const, + message: packAggregate.error?.message ?? tr("status.stackReportedError"), + error: packAggregate.error ?? null, + } + : packAggregate.state === "ready" + ? { + state: "ready" as const, + message: tr("status.modelReady", { + model: MODEL_PACKS[eventPack].label, + }), + } + : { + state: "pending" as const, + message: tr("status.downloadModelToStart", { + model: MODEL_PACKS[eventPack].label, + }), + } + : resolveModelBootstrapStatus( + nextSettings, + state.deviceInfo, + modelStatuses, + state.backendProvisionStatus, + ); + + return { + patch: { modelStatuses, settings: nextSettings, bootstrapStatus }, + sideEffects, + }; +} diff --git a/src/app/lib/store/slices/model-sync-actions.ts b/src/app/lib/store/slices/model-sync-actions.ts new file mode 100644 index 0000000..55e685a --- /dev/null +++ b/src/app/lib/store/slices/model-sync-actions.ts @@ -0,0 +1,85 @@ +import type { GenerationStore } from "@/app/lib/store/types"; +import type { StoreApi } from "zustand"; +import type { AppSettings, BackendProvisionStatus } from "@/app/lib/types"; +import * as api from "@/app/lib/api"; +import { expandDownloadedVariantsFromStatuses } from "@/app/lib/model-packs"; +import { localizeModelStatuses } from "@/app/lib/errors"; +import { resolveModelBootstrapStatus } from "@/app/lib/model-bootstrap"; + +/** + * Model sync actions — refreshing model statuses from the backend and + * deleting all models. Extracted from the model slice to keep it focused. + */ +export function createModelSyncActions( + set: StoreApi["setState"], + get: StoreApi["getState"], +) { + return { + deleteAllModels: async () => { + if (!api.isTauriRuntime()) return; + const state = get(); + const rawModelStatuses = await api.deleteAllModels(); + const modelStatuses = localizeModelStatuses(rawModelStatuses); + const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); + const nextModelVariant = ( + downloadedModels.length === 0 ? "" : state.settings.modelVariant + ) as AppSettings["modelVariant"]; + set((prev) => ({ + modelStatuses, + settings: { + ...prev.settings, + downloadedModels, + modelVariant: nextModelVariant, + }, + bootstrapStatus: resolveModelBootstrapStatus( + { + ...prev.settings, + downloadedModels, + modelVariant: nextModelVariant, + }, + null, + modelStatuses, + prev.backendProvisionStatus, + ), + })); + void api.setSetting("downloadedModels", downloadedModels); + void api.setSetting("modelVariant", nextModelVariant); + }, + + refreshModelStatuses: async () => { + if (!api.isTauriRuntime()) return; + const [modelCatalog, rawModelStatuses, backendProvision] = await Promise.all([ + api.listModelCatalog(), + api.getModelStatus(), + api + .getBackendProvisionStatus() + .catch(() => ({ state: "not_installed" }) as BackendProvisionStatus), + ]); + const modelStatuses = localizeModelStatuses(rawModelStatuses); + const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); + set((state) => ({ + modelCatalog, + modelStatuses, + backendProvisionStatus: backendProvision, + settings: { + ...state.settings, + downloadedModels, + modelVariant: + state.settings.modelVariant && downloadedModels.includes(state.settings.modelVariant) + ? state.settings.modelVariant + : state.settings.modelVariant, + }, + bootstrapStatus: resolveModelBootstrapStatus( + { + ...state.settings, + downloadedModels, + modelVariant: state.settings.modelVariant, + }, + state.deviceInfo, + modelStatuses, + backendProvision, + ), + })); + }, + }; +} diff --git a/src/app/lib/store/slices/model.ts b/src/app/lib/store/slices/model.ts index bda602e..7be70b9 100644 --- a/src/app/lib/store/slices/model.ts +++ b/src/app/lib/store/slices/model.ts @@ -1,21 +1,14 @@ import type { GenerationStore } from "@/app/lib/store/types"; import type { StoreApi } from "zustand"; -import type { - AppSettings, - ModelVariant, - ModelStatusSnapshot, - BackendProvisionStatus, -} from "@/app/lib/types"; +import type { ModelVariant, ModelStatusSnapshot, BackendProvisionStatus } from "@/app/lib/types"; import * as api from "@/app/lib/api"; import { MODEL_PACKS, aggregatePackStatus, - expandDownloadedVariantsFromStatuses, packIdForVariant, primaryVariantForPack, profileForVariant, } from "@/app/lib/model-packs"; -import { localizeModelStatuses } from "@/app/lib/errors"; import { PROFILE_FORM_PRESETS, applyModelVariantToForm, @@ -24,6 +17,10 @@ import { import { computeValidationState } from "@/app/lib/validation-helpers"; import { resolveModelBootstrapStatus } from "@/app/lib/model-bootstrap"; import { tr } from "@/app/lib/i18n"; +import { MODEL_CATALOG } from "./model-catalog"; +import { createBackendProvisionActions } from "./backend-provision-actions"; +import { computeModelStatusPatch } from "./model-status-apply"; +import { createModelSyncActions } from "./model-sync-actions"; export function createModelSlice( set: StoreApi["setState"], @@ -34,35 +31,7 @@ export function createModelSlice( state: "pending", message: tr("status.chooseAndDownload"), } as const, - modelCatalog: Object.values({ - lite: { - id: "lite", - label: "Lite", - modelName: "acestep-v15-turbo", - description: "", - }, - turbo: { - id: "turbo", - label: "Turbo", - modelName: "acestep-v15-turbo", - description: "", - }, - pro: { - id: "pro", - label: "XL Turbo", - modelName: "acestep-v15-xl-turbo", - description: "", - }, - }).map((variant) => ({ - variant: variant.id as ModelVariant, - label: variant.label, - modelName: variant.modelName, - lmModel: variant.id === "pro" ? "acestep-5Hz-lm-1.7B" : "acestep-5Hz-lm-0.6B", - lmBackend: "mlx" as const, - estimatedSizeBytes: variant.id === "pro" ? 22 * 1024 * 1024 * 1024 : 8 * 1024 * 1024 * 1024, - description: variant.description, - recommendedMemoryGb: variant.id === "pro" ? 20 : variant.id === "lite" ? 8 : 16, - })), + modelCatalog: MODEL_CATALOG, modelStatuses: [], backendProvisionStatus: { state: "not_installed", @@ -176,146 +145,11 @@ export function createModelSlice( } }, - deleteAllModels: async () => { - if (!api.isTauriRuntime()) return; - const state = get(); - const rawModelStatuses = await api.deleteAllModels(); - const modelStatuses = localizeModelStatuses(rawModelStatuses); - const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); - const nextModelVariant = ( - downloadedModels.length === 0 ? "" : state.settings.modelVariant - ) as AppSettings["modelVariant"]; - set((prev) => ({ - modelStatuses, - settings: { - ...prev.settings, - downloadedModels, - modelVariant: nextModelVariant, - }, - bootstrapStatus: resolveModelBootstrapStatus( - { - ...prev.settings, - downloadedModels, - modelVariant: nextModelVariant, - }, - null, - modelStatuses, - prev.backendProvisionStatus, - ), - })); - void api.setSetting("downloadedModels", downloadedModels); - void api.setSetting("modelVariant", nextModelVariant); - }, - - refreshModelStatuses: async () => { - if (!api.isTauriRuntime()) return; - const [modelCatalog, rawModelStatuses, backendProvision] = await Promise.all([ - api.listModelCatalog(), - api.getModelStatus(), - api - .getBackendProvisionStatus() - .catch(() => ({ state: "not_installed" }) as BackendProvisionStatus), - ]); - const modelStatuses = localizeModelStatuses(rawModelStatuses); - const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); - set((state) => ({ - modelCatalog, - modelStatuses, - backendProvisionStatus: backendProvision, - settings: { - ...state.settings, - downloadedModels, - modelVariant: - state.settings.modelVariant && downloadedModels.includes(state.settings.modelVariant) - ? state.settings.modelVariant - : state.settings.modelVariant, - }, - bootstrapStatus: resolveModelBootstrapStatus( - { - ...state.settings, - downloadedModels, - modelVariant: state.settings.modelVariant, - }, - state.deviceInfo, - modelStatuses, - backendProvision, - ), - })); - }, - applyModelStatus: (status: ModelStatusSnapshot) => { set((state) => { - const modelStatuses = [ - ...state.modelStatuses.filter((current) => current.variant !== status.variant), - status, - ]; - const downloadedModels = expandDownloadedVariantsFromStatuses(modelStatuses); - const selectedPack = state.settings.modelVariant - ? packIdForVariant(state.settings.modelVariant) - : null; - const eventPack = packIdForVariant(status.variant); - const packAggregate = aggregatePackStatus(modelStatuses, eventPack); - const nextSettings = { ...state.settings, downloadedModels }; - - if (status.state !== "downloading") { - const currentSelected = state.settings.modelVariant; - const nextSelected = - currentSelected && - MODEL_PACKS[eventPack].variants.includes(currentSelected) && - !downloadedModels.includes(currentSelected) - ? null - : currentSelected; - if (nextSettings.modelVariant !== nextSelected) { - nextSettings.modelVariant = nextSelected; - } - if (api.isTauriRuntime()) { - void api.setSetting("downloadedModels", downloadedModels); - if (nextSelected === null && currentSelected !== null) { - void api.setSetting("modelVariant", nextSelected); - } - } - } - - return { - modelStatuses, - settings: nextSettings, - bootstrapStatus: - selectedPack === eventPack - ? packAggregate.state === "downloading" - ? { - state: "downloading", - message: tr("status.downloadingModel", { - model: MODEL_PACKS[eventPack].label, - }), - downloadedBytes: packAggregate.downloadedBytes, - totalBytes: packAggregate.totalBytes, - } - : packAggregate.state === "failed" - ? { - state: "failed", - message: packAggregate.error?.message ?? tr("status.stackReportedError"), - error: packAggregate.error ?? null, - } - : packAggregate.state === "ready" - ? { - state: "ready", - message: tr("status.modelReady", { - model: MODEL_PACKS[eventPack].label, - }), - } - : { - state: "pending", - message: tr("status.downloadModelToStart", { - model: MODEL_PACKS[eventPack].label, - }), - } - : resolveModelBootstrapStatus( - nextSettings, - state.deviceInfo, - modelStatuses, - state.backendProvisionStatus, - ), - }; + const { patch, sideEffects } = computeModelStatusPatch(status, state); + for (const effect of sideEffects) void effect; + return patch; }); }, @@ -356,68 +190,7 @@ export function createModelSlice( set({ bootstrapStatus }); }, - refreshBackendProvisionStatus: async () => { - if (!api.isTauriRuntime()) return; - try { - const status = await api.getBackendProvisionStatus(); - set({ backendProvisionStatus: status }); - } catch (error) { - console.warn("Failed to refresh backend provision status:", error); - } - }, - - provisionBackend: async () => { - if (!api.isTauriRuntime()) return; - try { - const status = await api.provisionBackend(); - set({ backendProvisionStatus: status }); - // Refresh bootstrap status after provisioning - await get().refreshBootstrapStatus(); - } catch (error) { - set({ - backendProvisionStatus: { - state: "failed", - installedCommit: null, - installedTag: null, - latestCommit: null, - latestTag: null, - updateAvailable: false, - downloadedBytes: 0, - error: - error instanceof Error - ? { - code: "BACKEND_PROVISION_FAILED", - message: error.message, - recoverable: true, - } - : undefined, - }, - }); - } - }, - - updateBackend: async () => { - if (!api.isTauriRuntime()) return; - try { - const status = await api.updateBackend(); - set({ backendProvisionStatus: status }); - await get().refreshBootstrapStatus(); - } catch (error) { - set({ - backendProvisionStatus: { - ...get().backendProvisionStatus, - state: "failed", - error: - error instanceof Error - ? { - code: "BACKEND_PROVISION_FAILED", - message: error.message, - recoverable: true, - } - : undefined, - }, - }); - } - }, + ...createBackendProvisionActions(set, get), + ...createModelSyncActions(set, get), }; } From 2cd35552ff5f99cc5c98d5a80768a7fd3dd3cc39 Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:17:09 -0700 Subject: [PATCH 2/3] fix: clear stale modelVariant in refreshModelStatuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ternary in refreshModelStatuses was tautological — both branches returned state.settings.modelVariant, so a selected model that was no longer downloaded would remain selected after a status refresh. Now correctly sets modelVariant to null when the selected variant is not in downloadedModels. Also fixes the bootstrapStatus call to use the corrected variant instead of the stale one. Addresses Greptile review on #138. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/app/lib/store/slices/model-sync-actions.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/lib/store/slices/model-sync-actions.ts b/src/app/lib/store/slices/model-sync-actions.ts index 55e685a..2875de7 100644 --- a/src/app/lib/store/slices/model-sync-actions.ts +++ b/src/app/lib/store/slices/model-sync-actions.ts @@ -67,13 +67,16 @@ export function createModelSyncActions( modelVariant: state.settings.modelVariant && downloadedModels.includes(state.settings.modelVariant) ? state.settings.modelVariant - : state.settings.modelVariant, + : null, }, bootstrapStatus: resolveModelBootstrapStatus( { ...state.settings, downloadedModels, - modelVariant: state.settings.modelVariant, + modelVariant: + state.settings.modelVariant && downloadedModels.includes(state.settings.modelVariant) + ? state.settings.modelVariant + : null, }, state.deviceInfo, modelStatuses, From d100ef507bafffa864484cc02e36d0b5c1f50c2d Mon Sep 17 00:00:00 2001 From: Davy <95214375+thedavidweng@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:02:55 -0700 Subject: [PATCH 3/3] fix: surface errors from applyModelStatus side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The for-loop over sideEffects used `void effect`, which discarded each setSetting promise without awaiting or handling errors — a rejected persistence call would silently vanish as an unhandled rejection. Attach a .catch logger (normalised via Promise.resolve so non-promise return values from mocks do not throw) to surface failures. Addresses Greptile P2 review comment on #138. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/app/lib/store/slices/model.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/lib/store/slices/model.ts b/src/app/lib/store/slices/model.ts index 7be70b9..c52f647 100644 --- a/src/app/lib/store/slices/model.ts +++ b/src/app/lib/store/slices/model.ts @@ -148,7 +148,11 @@ export function createModelSlice( applyModelStatus: (status: ModelStatusSnapshot) => { set((state) => { const { patch, sideEffects } = computeModelStatusPatch(status, state); - for (const effect of sideEffects) void effect; + for (const effect of sideEffects) { + Promise.resolve(effect).catch((error) => { + console.warn("Model status side effect failed:", error); + }); + } return patch; }); },