diff --git a/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx b/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx index aa87522920..c1b8b131d0 100644 --- a/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx +++ b/frontend/libs/components/model-selector/src/lib/classy-model-selector.tsx @@ -44,6 +44,11 @@ interface ClassyModelSelectorProps { showProviderSelection?: boolean; } +// Model instances are rebuilt when the backend listing hydrates, so compare by +// tauriId rather than object identity. +const isSameModel = (a: Model | undefined, b: Model | undefined): boolean => + a !== undefined && b !== undefined && a.tauriId === b.tauriId; + const DEFAULT_PROVIDER_OPTIONS: GenerationProvider[] = [GenerationProvider.Artcraft]; function ProviderTooltipContent({ @@ -143,6 +148,20 @@ export function ClassyModelSelector({ } }, []); + // The backend listing hydrates asynchronously and rebuilds the model + // instances with API capabilities. Swap a stale selected instance for the + // fresh one so capability-driven UI (keyframes, references, pickers) + // reflects the API data without needing a manual re-select. + useEffect(() => { + const selected = selectedModels[page]; + if (!selected) return; + const fresh = itemModels.find((m) => m.tauriId === selected.tauriId); + if (fresh !== undefined && fresh !== selected) { + setSelectedModel(page, fresh); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, selectedModels, page]); + // Initialize a default provider for each model so we can render icons even when not selected useEffect(() => { for (const item of items) { @@ -175,7 +194,7 @@ export function ClassyModelSelector({ return { ...item, - selected: item.model === selectedModel, + selected: isSameModel(item.model as Model | undefined, selectedModel), hoverTooltip: hasMultipleProviders ? (close: () => void) => ( { const prov = modelId ? selectedProvidersByModel[modelId] @@ -206,7 +225,7 @@ export function ClassyModelSelector({ })() : undefined, selectedRight: - item.model === selectedModel && + isSameModel(item.model as Model | undefined, selectedModel) && selectedProvider && hasMultipleProviders ? (
@@ -265,7 +284,7 @@ export function ClassyModelSelector({ {...popoverProps} buttonClassName="rounded-xl bg-ui-controls/90 hover:bg-ui-controls text-left shadow-sm px-3 py-1 gap-3 border border-ui-controls-border" renderTrigger={(selectedItem) => { - const modelTitle = selectedItem?.label ?? ""; + const modelTitle = selectedItem?.label ?? selectedModel?.selectorName ?? ""; const providerIcon = selectedProvider ? getProviderIcon(selectedProvider) : null; diff --git a/frontend/libs/components/model-selector/src/lib/defaultModelForPage.ts b/frontend/libs/components/model-selector/src/lib/defaultModelForPage.ts index 069306d46b..60aca7a6bd 100644 --- a/frontend/libs/components/model-selector/src/lib/defaultModelForPage.ts +++ b/frontend/libs/components/model-selector/src/lib/defaultModelForPage.ts @@ -6,35 +6,37 @@ import { } from "@storyteller/model-list"; import { ModelPage } from "./model-pages"; +const DEFAULT_MODEL_ID_FOR_PAGE: Partial> = { + [ModelPage.TextToImage]: "nano_banana_pro", + [ModelPage.ImageToVideo]: "seedance_2p0", + [ModelPage.Canvas2D]: "gpt_image_1p5", + [ModelPage.Stage3D]: "gpt_image_1p5", + [ModelPage.ImageEditor]: "nano_banana_pro", + [ModelPage.ImageTo3DWorld]: "marble_0p1_mini", + [ModelPage.Angles]: "flux_2_lora_angles", +}; + export const defaultModelForPage = ( models: Model[], page: ModelPage, ): Model => { - let imageModel: Model | undefined; + const defaultId = DEFAULT_MODEL_ID_FOR_PAGE[page]; - switch (page) { - case ModelPage.TextToImage: - imageModel = IMAGE_MODELS_BY_ID.get("nano_banana_pro"); - break; - case ModelPage.ImageToVideo: - imageModel = VIDEO_MODELS_BY_ID.get("seedance_2p0"); - break; - case ModelPage.Canvas2D: - imageModel = IMAGE_MODELS_BY_ID.get("gpt_image_1p5"); - break; - case ModelPage.Stage3D: - imageModel = IMAGE_MODELS_BY_ID.get("gpt_image_1p5"); - break; - case ModelPage.ImageEditor: - imageModel = IMAGE_MODELS_BY_ID.get("nano_banana_pro"); - break; - case ModelPage.ImageTo3DWorld: - imageModel = SPLAT_MODELS_BY_ID.get("marble_0p1_mini"); - break; - case ModelPage.Angles: - imageModel = IMAGE_MODELS_BY_ID.get("flux_2_lora_angles"); - break; + if (defaultId) { + // Prefer the instance from the caller's (backend-hydrated) list — the + // static map instances are presentation-only fallbacks without the API's + // capability data. + const fromList = models.find( + (m) => m.id === defaultId || m.tauriId === defaultId, + ); + if (fromList) return fromList; + + const fromStaticMaps = + IMAGE_MODELS_BY_ID.get(defaultId) ?? + VIDEO_MODELS_BY_ID.get(defaultId) ?? + SPLAT_MODELS_BY_ID.get(defaultId); + if (fromStaticMaps) return fromStaticMaps; } - return imageModel || models[0]; + return models[0]; }; diff --git a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts index 7ee14c6c94..7bf63ed4a6 100644 --- a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts +++ b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts @@ -76,43 +76,84 @@ export interface ListingVideoModel extends ListingModelBase { export const buildImageModelsFromListing = ( overlay: ImageModel[], listing: ListingImageModel[], + offeredModelIds: string[] = [], ): ImageModel[] => - build(overlay, listing, BACKEND_TO_TAURI_IMAGE_ID, mergedImageModel); + build(overlay, listing, offeredModelIds, BACKEND_TO_TAURI_IMAGE_ID, mergedImageModel); export const buildVideoModelsFromListing = ( overlay: VideoModel[], listing: ListingVideoModel[], + offeredModelIds: string[] = [], ): VideoModel[] => - build(overlay, listing, BACKEND_TO_TAURI_VIDEO_ID, mergedVideoModel); + build(overlay, listing, offeredModelIds, BACKEND_TO_TAURI_VIDEO_ID, mergedVideoModel); // ── Core assembly ────────────────────────────────────────────────────────── +// Membership: a model appears in the picker when it is +// 1. OFFERED by a provider in the response's `providers[]` (the backend's +// publish switch — `models[]` details also contain disabled entries and +// internal variants that must NOT surface), or +// 2. an ENABLED `models[]` detail that the frontend knows (overlay entry) — +// covers desktop-native models like grok video that no backend provider +// offers yet, or +// 3. a frontend-only overlay model the backend has never heard of +// (switch_x, inpaint models, …). +// `models[]` details are the capability source either way (the enabled entry +// wins when a model is detailed twice). const build = ( overlay: T[], listing: L[], + offeredModelIds: string[], alias: Record, merge: (m: L, tauriId: string, overlayEntry: T | undefined) => T, ): T[] => { + const toTauriId = (backendId: string) => alias[backendId] ?? backendId; const overlayByTauriId = new Map(overlay.map((m) => [m.tauriId, m])); - const knownTauriIds = new Set(listing.map((m) => alias[m.model] ?? m.model)); + const knownTauriIds = new Set(listing.map((m) => toTauriId(m.model))); + const offeredTauriIds = new Set(offeredModelIds.map(toTauriId)); + + // Capability lookup — prefer the enabled detail entry when duplicated. + const detailsByTauriId = new Map(); + for (const m of listing) { + const tauriId = toTauriId(m.model); + const existing = detailsByTauriId.get(tauriId); + if (existing === undefined || existing.is_disabled === true) { + detailsByTauriId.set(tauriId, m); + } + } const result: T[] = []; const seenTauriIds = new Set(); - // Backend drives membership + order; every entry is rebuilt with API-first - // capabilities. + // Backend details order drives ordering. for (const m of listing) { - if (m.is_disabled === true) continue; - const tauriId = alias[m.model] ?? m.model; - // Guard: several backend ids aliasing to one frontend model would render - // duplicates — only surface the first. + const tauriId = toTauriId(m.model); + if (seenTauriIds.has(tauriId)) continue; + + const overlayEntry = overlayByTauriId.get(tauriId); + const detail = detailsByTauriId.get(tauriId) ?? m; + const enabled = detail.is_disabled !== true; + const show = + offeredTauriIds.has(tauriId) || (enabled && overlayEntry !== undefined); + if (!show) continue; + + seenTauriIds.add(tauriId); + result.push(merge(detail, tauriId, overlayEntry)); + } + // Offered models with no detail entry at all (unusual, but the publish + // switch wins): surface minimally. + for (const backendId of offeredModelIds) { + const tauriId = toTauriId(backendId); if (seenTauriIds.has(tauriId)) continue; seenTauriIds.add(tauriId); - result.push(merge(m, tauriId, overlayByTauriId.get(tauriId))); + result.push( + merge({ model: backendId } as L, tauriId, overlayByTauriId.get(tauriId)), + ); } - // Append frontend-only models the backend has never heard of (switch_x, - // inpaint models, …). + // Append frontend-only models the backend has never heard of. for (const m of overlay) { - if (!knownTauriIds.has(m.tauriId)) result.push(m); + if (!knownTauriIds.has(m.tauriId) && !seenTauriIds.has(m.tauriId)) { + result.push(m); + } } return result; }; diff --git a/frontend/libs/tauri-api/src/lib/models/modelsStore.ts b/frontend/libs/tauri-api/src/lib/models/modelsStore.ts index 0c6540e84c..e07b44a44d 100644 --- a/frontend/libs/tauri-api/src/lib/models/modelsStore.ts +++ b/frontend/libs/tauri-api/src/lib/models/modelsStore.ts @@ -47,8 +47,9 @@ export const useModelsStore = create((set, get) => ({ const next: Partial = { isLoading: false, loaded: true }; if (imageResult.status === "fulfilled") { - const { models } = imageResult.value.payload; - const built = buildImageModelsFromListing(IMAGE_MODELS, models); + const { models, providers } = imageResult.value.payload; + const offered = providers.flatMap((p) => p.models.map((pm) => pm.model)); + const built = buildImageModelsFromListing(IMAGE_MODELS, models, offered); next.imageModels = built; console.log( `[models] image: backend returned ${models.length} → ${built.length} models shown`, @@ -59,8 +60,9 @@ export const useModelsStore = create((set, get) => ({ } if (videoResult.status === "fulfilled") { - const { models } = videoResult.value.payload; - const built = buildVideoModelsFromListing(VIDEO_MODELS, models); + const { models, providers } = videoResult.value.payload; + const offered = providers.flatMap((p) => p.models.map((pm) => pm.model)); + const built = buildVideoModelsFromListing(VIDEO_MODELS, models, offered); next.videoModels = built; console.log( `[models] video: backend returned ${models.length} → ${built.length} models shown`,