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..2875de7 --- /dev/null +++ b/src/app/lib/store/slices/model-sync-actions.ts @@ -0,0 +1,88 @@ +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 + : null, + }, + bootstrapStatus: resolveModelBootstrapStatus( + { + ...state.settings, + downloadedModels, + modelVariant: + state.settings.modelVariant && downloadedModels.includes(state.settings.modelVariant) + ? state.settings.modelVariant + : null, + }, + state.deviceInfo, + modelStatuses, + backendProvision, + ), + })); + }, + }; +} diff --git a/src/app/lib/store/slices/model.ts b/src/app/lib/store/slices/model.ts index bda602e..c52f647 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,15 @@ 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); - } - } + const { patch, sideEffects } = computeModelStatusPatch(status, state); + for (const effect of sideEffects) { + Promise.resolve(effect).catch((error) => { + console.warn("Model status side effect failed:", error); + }); } - - 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, - ), - }; + return patch; }); }, @@ -356,68 +194,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), }; }