diff --git a/studio/frontend/src/features/hub/catalog/model-card.tsx b/studio/frontend/src/features/hub/catalog/model-card.tsx index b042eb1ad0..c3905d99d3 100644 --- a/studio/frontend/src/features/hub/catalog/model-card.tsx +++ b/studio/frontend/src/features/hub/catalog/model-card.tsx @@ -249,7 +249,7 @@ export const ModelCard = memo(function ModelCard({ }), [isDataset, row.id, row.result, deviceType], ); - const unsupported = support?.status === "unsupported"; + const unsupported = support?.status === "unsupported" && !support?.supportedIn; const partial = row.isAvailableOnDevice && row.isPartialOnDevice; const onDevice = row.isAvailableOnDevice && !row.isPartialOnDevice; const topCapability = row.capabilities[0] ?? null; diff --git a/studio/frontend/src/features/hub/catalog/model-inspector.tsx b/studio/frontend/src/features/hub/catalog/model-inspector.tsx index c304738ab1..0d1b336c2c 100644 --- a/studio/frontend/src/features/hub/catalog/model-inspector.tsx +++ b/studio/frontend/src/features/hub/catalog/model-inspector.tsx @@ -280,7 +280,12 @@ function ModelStatusChips({ unslothSupport: UnslothSupport; vramInfo: VramInfo; }) { - const showUnsupported = !isDataset && unslothSupport.status === "unsupported"; + // The Images/Video pages run these, so they are not "unsupported" to a user even + // though chat cannot load them. + const showUnsupported = + !isDataset && + unslothSupport.status === "unsupported" && + !unslothSupport.supportedIn; // The format-unsupported chip already explains itself; this one covers the // supported-format model a chat-only host still can't run. const showChatOnly = !isDataset && !isGguf && chatOnly && !showUnsupported; @@ -694,7 +699,7 @@ export const ModelInspector = memo(function ModelInspector({ gpuGb={gpuGb} systemRamGb={systemRamGb} unsupportedReason={ - unslothSupport.status === "unsupported" + unslothSupport.status === "unsupported" && !unslothSupport.supportedIn ? (unslothSupport.reason ?? "Unsupported format") : null } diff --git a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx index 5236c0be74..e0cf3b33ca 100644 --- a/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx +++ b/studio/frontend/src/features/hub/catalog/models-catalog-rows.tsx @@ -449,7 +449,7 @@ export const DiscoverModelRow = memo(function DiscoverModelRow({ }), [isDataset, row.id, row.result, deviceType], ); - const unsupported = support?.status === "unsupported"; + const unsupported = support?.status === "unsupported" && !support?.supportedIn; const handleClick = useCallback(() => onSelect(row.id), [onSelect, row.id]); const partialRepoId = row.isAvailableOnDevice && row.isPartialOnDevice @@ -593,16 +593,16 @@ export const InventoryRow = memo(function InventoryRow({ const rowTagsSignature = row.tags?.join("\u0001") ?? ""; const unsupported = useMemo(() => { if (isDataset) return false; - return ( - classifyUnslothSupport({ - modelId: rowModelId, - pipelineTag: row.pipelineTag, - tags: rowTagsSignature ? rowTagsSignature.split("\u0001") : undefined, - libraryName: row.libraryName, - quantMethod: row.quantMethod, - deviceType, - }).status === "unsupported" - ); + const classified = classifyUnslothSupport({ + modelId: rowModelId, + pipelineTag: row.pipelineTag, + tags: rowTagsSignature ? rowTagsSignature.split("\u0001") : undefined, + libraryName: row.libraryName, + quantMethod: row.quantMethod, + deviceType, + }); + // Images/Video run these, so they are not unsupported to a user. + return classified.status === "unsupported" && !classified.supportedIn; }, [ isDataset, rowModelId, diff --git a/studio/frontend/src/features/hub/catalog/models-table.tsx b/studio/frontend/src/features/hub/catalog/models-table.tsx index e3cd77f781..41a0a3b531 100644 --- a/studio/frontend/src/features/hub/catalog/models-table.tsx +++ b/studio/frontend/src/features/hub/catalog/models-table.tsx @@ -576,7 +576,7 @@ function useResultRowModel( const taskLabel = isDataset ? null : formatPipelineTag(row.result.pipelineTag); - const unsupported = support?.status === "unsupported"; + const unsupported = support?.status === "unsupported" && !support?.supportedIn; return { support, unsupported, diff --git a/studio/frontend/src/features/hub/download-manager/use-staged-download.ts b/studio/frontend/src/features/hub/download-manager/use-staged-download.ts index a5fec6f822..f4fc1d1ce4 100644 --- a/studio/frontend/src/features/hub/download-manager/use-staged-download.ts +++ b/studio/frontend/src/features/hub/download-manager/use-staged-download.ts @@ -15,10 +15,9 @@ export interface StagedDownloadEntry { repoId: string; files: string[]; bytes: number; - /** Set when this entry is a single-file GGUF checkpoint rather than a scoped subset. */ + /** Set when this entry is a single-file GGUF checkpoint. Informational: it is fetched + * as a scoped job like every other entry. */ ggufFilename?: string | null; - /** Quant label for a GGUF entry, so the job keys like any other variant download. */ - variant?: string | null; } /** @@ -42,10 +41,11 @@ export function useStagedDownload({ const [queue, setQueue] = useState(null); const current = queue?.[0] ?? null; - // A GGUF entry is a normal variant download; a scoped entry keys itself under "@scope". - const activeVariant = current - ? (current.variant ?? (current.ggufFilename ? null : scopedVariant(scopeId))) - : null; + // Every entry is scoped, including a GGUF checkpoint. A plain snapshot job would be the + // wrong tool for it: the Hub's snapshot ignore list drops *.gguf, so the job would finish + // at once having fetched everything EXCEPT the weights, and the repo would land on device + // unloadable. + const activeVariant = current ? scopedVariant(scopeId) : null; const advance = useCallback(() => { setQueue((rest) => { @@ -78,8 +78,8 @@ export function useStagedDownload({ repoId: current.repoId, variant: activeVariant, expectedBytes: current.bytes, - scopeId: current.ggufFilename ? null : scopeId, - files: current.ggufFilename ? undefined : current.files, + scopeId, + files: current.files, }); if (!active) return; if (outcome === "started") { diff --git a/studio/frontend/src/features/hub/lib/unsloth-support.ts b/studio/frontend/src/features/hub/lib/unsloth-support.ts index a0fcc2ad2f..9c6a3c2fed 100644 --- a/studio/frontend/src/features/hub/lib/unsloth-support.ts +++ b/studio/frontend/src/features/hub/lib/unsloth-support.ts @@ -126,6 +126,35 @@ export type UnslothSupportStatus = "supported" | "unsupported"; export interface UnslothSupport { status: UnslothSupportStatus; reason: string | null; + /** + * Set when Studio runs this model on a dedicated page rather than in chat. The status + * stays "unsupported" because the chat pickers gate on it, but the UI must not call the + * model unsupported: the Images and Video pages load it. + */ + supportedIn?: "images" | "video"; +} + +// Generation tasks the Images / Video pages handle. Mirrors IMAGE_GEN_TASKS and the video +// picker's tasks; image-to-video is included for LTX-2.3, whose HF pipeline tag is that. +const IMAGE_PAGE_TASKS: ReadonlySet = new Set([ + "text-to-image", + "image-to-image", + "image-text-to-image", +]); +const VIDEO_PAGE_TASKS: ReadonlySet = new Set([ + "text-to-video", + "image-to-video", +]); + +/** Which Studio page runs this pipeline task, if any. */ +export function studioPageForTask( + pipelineTag?: string | null, +): "images" | "video" | undefined { + const tag = pipelineTag?.toLowerCase().trim(); + if (!tag) return undefined; + if (IMAGE_PAGE_TASKS.has(tag)) return "images"; + if (VIDEO_PAGE_TASKS.has(tag)) return "video"; + return undefined; } export function excludedFormatTagsForDevice( @@ -214,6 +243,9 @@ export function classifyUnslothSupport({ return { status: "unsupported", reason: `Pipeline task: ${pipeline}.`, + // Not chat-loadable, but the Images/Video pages run it, so the UI must not + // present it as unsupported. + supportedIn: studioPageForTask(pipeline), }; } for (const tag of lowerTags) { diff --git a/studio/frontend/src/features/images/images-page.tsx b/studio/frontend/src/features/images/images-page.tsx index f198500b1e..7320b1984e 100644 --- a/studio/frontend/src/features/images/images-page.tsx +++ b/studio/frontend/src/features/images/images-page.tsx @@ -2390,6 +2390,7 @@ export function ImagesPage({ active = true }: { active?: boolean }) { className="!h-[34px]" task={IMAGE_GEN_TASKS} catalog={IMAGE_CATALOG} + placeholder="Select image model" open={active && selectorOpen} onOpenChange={(o) => setSelectorOpen(active && o)} /> diff --git a/studio/frontend/src/features/model-picker/components/model-selector.tsx b/studio/frontend/src/features/model-picker/components/model-selector.tsx index 751189ec42..bdbfc11867 100644 --- a/studio/frontend/src/features/model-picker/components/model-selector.tsx +++ b/studio/frontend/src/features/model-picker/components/model-selector.tsx @@ -155,6 +155,9 @@ interface ModelSelectorProps { * artifact repos into one row with a format second level and device-aware * routing. Undefined (chat) changes nothing. */ catalog?: CatalogGroup[]; + /** Trigger text when nothing is loaded. Defaults to "Select model"; task pages name + * what they pick so it reads as separate from the chat model. */ + placeholder?: string; } function ModelSelectorTrigger({ @@ -166,6 +169,9 @@ function ModelSelectorTrigger({ className, dataTour, onEject, + // Task pages name what they pick ("Select image model"), so it is clear the choice is + // separate from the chat model. + placeholder = "Select model", }: { currentModel?: ModelOption; isLoaded: boolean; @@ -175,6 +181,7 @@ function ModelSelectorTrigger({ className?: string; dataTour?: string; onEject?: () => void; + placeholder?: string; }) { return ( @@ -241,7 +248,7 @@ function ModelSelectorTrigger({ ) : null} - {currentModel?.name ?? "Select model"} + {currentModel?.name ?? placeholder} {showCloudIndicator ? ( setSelectorOpen(active && o)} /> diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 0ae144e61b..b100a1d829 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -622,3 +622,18 @@ def test_diffusion_pages_stage_downloads_through_the_manager(): assert "isDownloaded !== false" in body, f"{rel}: cached picks would re-stage" # A missing plan must still load rather than dead-end. assert "catch" in body, f"{rel}: no fallback when the plan is unavailable" + + +def test_staged_downloads_always_scope_their_files(): + """Every staged entry must go out as a scoped job carrying its file list, GGUF + checkpoints included. A plain snapshot job drops *.gguf via the Hub's ignore list, so + it would finish instantly having fetched everything except the weights and leave the + repo on device unloadable.""" + src = _read("features/hub/download-manager/use-staged-download.ts") + start = re.search(r"downloadManager\.requestStart\(\{.*?\}\);", src, re.S) + assert start, "requestStart call not found" + body = start.group(0) + # Unconditional: no branch may send a null scope or omit the files. + assert "scopeId," in body and "files: current.files," in body + assert "? null" not in body and "? undefined" not in body + assert "const activeVariant = current ? scopedVariant(scopeId) : null;" in src