diff --git a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte b/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte deleted file mode 100644 index 6f4973c12e..0000000000 --- a/tools/ui/src/lib/components/app/models/discover/DownloadProgressBar.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - -{#if overlay} -
-
-
-{:else} -
-
-
-{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte new file mode 100644 index 0000000000..01b4e90e0a --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte @@ -0,0 +1,138 @@ + + + + +
+ {#if selectedId} + + {/if} +
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte new file mode 100644 index 0000000000..45eb209a2a --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverChatTemplateDialog.svelte @@ -0,0 +1,67 @@ + + + + + + + Chat template + + + + + + + + +
{chatTemplate}
+
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte new file mode 100644 index 0000000000..cecdda738c --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetails.svelte @@ -0,0 +1,96 @@ + + +{#if loading} + +{:else if error} +
+

{error}

+
+{:else if details} +
+ + + + + +
+{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte new file mode 100644 index 0000000000..75ecef320e --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptions.svelte @@ -0,0 +1,173 @@ + + +{#if bitDepthRows.length} +
+ +
+ {#each rows as row (row.bitDepth)} + + {/each} +
+ + +
+ +
+
+{/if} + + (confirmOpen = false)} + open={confirmOpen} + repoWithTag={pending.repoWithTag} +/> diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte new file mode 100644 index 0000000000..e380505f38 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte @@ -0,0 +1,278 @@ + + + + +
+ +
+ {SERVE_COMMAND.BIN} + + {SERVE_COMMAND.SUBCOMMAND} + + {SERVE_COMMAND.MODEL_FLAG} + + + {modelId}{mainQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''} + + + + {#if baseOption} + v && (basePick = v)} type="single" value={baseOption.path}> + + {baseOption.label} + + + + {#each mainOptions as option (option.path)} + + {option.label} + + {/each} + + + {/if} + + + {#if draftOptions.length && !withDraft} + + {/if} + + + {#if draftOption} + + {SERVE_COMMAND.DRAFT_FLAG} + + + {modelId}{draftQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''} + + + v && (draftPick = v)} + type="single" + value={draftOption.path} + > + + {draftOption.label} + + + + {#each typeDraftOptions as option (option.path)} + + {option.label} + + {/each} + + + + {#if draftType} + {SERVE_COMMAND.SPEC_TYPE_FLAG} + + + {#if specTypes.length > 1} + v && (draftTypePick = v as ModelSidecar)} + type="single" + value={draftType} + > + + {SPEC_TYPE[draftType]} + + + + {#each specTypes as type (type)} + + {SPEC_TYPE[type]} + + {/each} + + + {:else} + {SPEC_TYPE[draftType]} + {/if} + {/if} + + + + {/if} +
+ + +
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte new file mode 100644 index 0000000000..2908ea97a3 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte @@ -0,0 +1,244 @@ + + +{#snippet chipBody(dividerClass: string)} + + {#if meta?.sidecar} + + {meta.sidecar} + + {/if} + + {label} + + +{/snippet} + + +{#snippet tooltipTrigger(tip: string, button: Snippet<[Record]>)} + + + {#snippet child({ props })} + {@render button(props)} + {/snippet} + + + +

{tip}

+
+
+{/snippet} + +{#snippet deleteChip(props: Record)} + + +{/snippet} + +{#snippet pauseResumeChip(props: Record)} + +{/snippet} + +{#snippet cancelChip(props: Record)} + + +{/snippet} + +{#snippet downloadChip(props: Record)} + + +{/snippet} + +{#if entry.isDownloaded} + {@render tooltipTrigger(tooltipText, deleteChip)} +{:else if entry.isDownloading || entry.isPaused} + +
+ {@render tooltipTrigger(tooltipText, pauseResumeChip)} + + {@render tooltipTrigger('Cancel downloading', cancelChip)} + + {#if percent !== null} + + {/if} +
+{:else} + {@render tooltipTrigger(tooltipText, downloadChip)} +{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte new file mode 100644 index 0000000000..11bfe45d42 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/ModelsDiscoverDetailsDownloadOptionsRow.svelte @@ -0,0 +1,60 @@ + + +
+
+ {#if bitDepth === OTHER_BIT_DEPTH} + {OTHER_BIT_DEPTH_LABEL} + {:else} + {bitDepth}{BIT_DEPTH_LABEL_SUFFIX} + {/if} + + {#if mainMemGb} + + needs at least {mainMemGb}{GIGABYTE_LABEL}{draftMemGb + ? ` + ${draftMemGb}${GIGABYTE_LABEL}` + : ''} memory + + {/if} +
+ +
+ {#each files as file (file.path)} + + {/each} +
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts new file mode 100644 index 0000000000..4dd8accb42 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/download-options.utils.ts @@ -0,0 +1,28 @@ +import { DRAFT_FILE_LABEL, MODEL_ID, PATH_SEPARATOR } from '$lib/constants'; +import { ModelSelectableFileKind } from '$lib/enums'; +import { HuggingFaceService } from '$lib/services'; +import { isAuxSidecar, isDraftSidecar } from '$lib/utils'; + +/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */ +export function classify(path: string): ModelSelectableFileKind { + const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar; + + if (!sidecar) return ModelSelectableFileKind.MAIN; + + return isAuxSidecar(sidecar) ? ModelSelectableFileKind.AUX : ModelSelectableFileKind.DRAFT; +} + +/** Display label of a file: its quant, else the file name without the extension. */ +export function labelFor(path: string): string { + const meta = HuggingFaceService.extractQuantMeta(path); + + if (meta?.quant) return meta.quant; + + // Quantless sidecar files: the chip badge already carries the sidecar type, + // so the label only marks draft files; aux sidecars badge alone. + if (meta?.sidecar) return isDraftSidecar(meta.sidecar) ? DRAFT_FILE_LABEL : ''; + + const basename = path.split(PATH_SEPARATOR).pop() ?? path; + + return basename.replace(MODEL_ID.WEIGHT_EXTENSION_REGEX, ''); +} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts new file mode 100644 index 0000000000..b5c0ebef67 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsDownloadOptions/index.ts @@ -0,0 +1,40 @@ +/** + * + * MODELS DISCOVER - DETAILS - DOWNLOAD OPTIONS + * + * The download area of the detail pane: GGUF files grouped by bit depth, each an + * independent download action chip, plus the standalone terminal command preview. + * + */ + +/** + * **ModelsDiscoverDetailsDownloadOptions** - GGUF download options + * + * Groups GGUF files by bit depth and renders one independent download + * action chip per file, plus the standalone terminal command preview. + */ +export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte'; + +/** + * **ModelsDiscoverDetailsDownloadOptionsRow** - One bit-depth group of quants + * + * A single bit-depth row inside ModelsDiscoverDetailsDownloadOptions: the + * depth label with its memory hint and the quant chips of that depth. + */ +export { default as ModelsDiscoverDetailsDownloadOptionsRow } from './ModelsDiscoverDetailsDownloadOptionsRow.svelte'; + +/** + * **ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton** - One quant chip + * + * A single GGUF file as an independent action chip: download / retry when + * idle, pause / resume / cancel while in flight, delete when downloaded. + */ +export { default as ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton } from './ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte'; + +/** + * **ModelsDiscoverDetailsDownloadOptionsDownloadCommand** - Terminal command + * + * The `llama serve -hf ...` command box with inline quant selects and a copy + * button; owns its picks, nothing two-way binds them to the quant chips. + */ +export { default as ModelsDiscoverDetailsDownloadOptionsDownloadCommand } from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte'; diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte new file mode 100644 index 0000000000..a216283436 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHeader.svelte @@ -0,0 +1,94 @@ + + +
+
+
+ + +
+
+

{details.id ?? modelId}

+ + +
+ + {#if baseModels.length} +
+ {baseModels.join(', ')} + + + + +
+ {/if} +
+
+ + + + + View on Hugging Face + + + +
+ + + + +
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte new file mode 100644 index 0000000000..b848a9bfeb --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsHfHubStats.svelte @@ -0,0 +1,31 @@ + + +
+ {#if typeof details.downloads === 'number'} + + + {HuggingFaceService.formatDownloads(details.downloads)} + + {/if} + + {#if typeof details.likes === 'number'} + + + {HuggingFaceService.formatLikes(details.likes)} + + {/if} + + {#if details.lastModified} + Updated {HuggingFaceService.formatRelativeTime(details.lastModified)} + {/if} +
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte new file mode 100644 index 0000000000..9f0184ac9f --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadata.svelte @@ -0,0 +1,80 @@ + + +{#if description} +

{description}

+{/if} + + +
+ {#if gguf?.total} + + {/if} + + {#if gguf?.context_length} + + {/if} + + {#if gguf?.architecture} + + {/if} + + {#if gguf?.chat_template} + + {/if} + + {#if licenseTag} + + {/if} + + {#if details.gated === true} + + gated + + {/if} +
+ +{#if gguf?.chat_template} + +{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte new file mode 100644 index 0000000000..90c3c4c74f --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsMetadataItem.svelte @@ -0,0 +1,17 @@ + + + + + {label} + + {value} + diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte new file mode 100644 index 0000000000..fbf043b797 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsReadme.svelte @@ -0,0 +1,17 @@ + + +{#if readme} +
+ +
+{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte new file mode 100644 index 0000000000..030d90fa58 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/ModelsDiscoverDetailsSkeleton.svelte @@ -0,0 +1,93 @@ + + + +
+ +
+
+
+ + +
+ + + +
+
+ + +
+ + +
+ + + + + +
+ + +
+
+ + + + + + + +
+
+
+ + +
+
+ {#each [0, 1, 2] as _, index (index)} +
+
+ + + +
+ +
+ + + +
+
+ {/each} +
+ +
+ + +
+ + + + + +
+ + +
+
+ + +
+ + + + + + + +
+
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts new file mode 100644 index 0000000000..2420bd5ef2 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDetails/index.ts @@ -0,0 +1,72 @@ +/** + * + * MODELS DISCOVER - DETAILS + * + * The right-hand detail pane of the discover view: header (avatar, name, stats, metadata + * chips, capability badges), the download options area and the model-card README. + * + */ + +/** + * **ModelsDiscoverDetails** - Model detail view + * + * Detail pane for the selected model. Loads its own data (details + GGUF file + * list) from HuggingFaceService based on the `modelId` route param. + */ +export { default as ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte'; + +/** + * **ModelsDiscoverDetailsHeader** - Detail view header + * + * Shows the model avatar (base org + quant org corner badge), name, base model + * info, stats, metadata chips and capability badges. + */ +export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsHeader.svelte'; + +/** + * **ModelsDiscoverDetailsHfHubStats** - HuggingFace Hub stats row + * + * Downloads, likes and last-updated for the viewed model, formatted the way + * the Hub shows them. + */ +export { default as ModelsDiscoverDetailsHfHubStats } from './ModelsDiscoverDetailsHfHubStats.svelte'; + +/** + * **ModelsDiscoverDetailsSkeleton** - Detail view loading skeleton + * + * Static placeholder matching the detail layout: header with avatar and name, + * metadata chips, the download options box and readme text lines. + */ +export { default as ModelsDiscoverDetailsSkeleton } from './ModelsDiscoverDetailsSkeleton.svelte'; + +/** + * **ModelsDiscoverDetailsReadme** - Detail view README + * + * Renders the model card README as markdown. + */ +export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte'; + +/** + * **ModelsDiscoverDetailsMetadata** - Detail view metadata row + * + * The detail view's metadata chips (model size, context, architecture, license, + * chat template). + */ +export { default as ModelsDiscoverDetailsMetadata } from './ModelsDiscoverDetailsMetadata.svelte'; + +/** + * **ModelsDiscoverDetailsMetadataItem** - Single metadata chip + * + * One label | value chip of the detail view's metadata row (model size, + * context, architecture, license). + */ +export { default as ModelsDiscoverDetailsMetadataItem } from './ModelsDiscoverDetailsMetadataItem.svelte'; + +/** + * **ModelsDiscoverChatTemplateDialog** - Chat template viewer + * + * Shows the model's chat template in a scrollable dialog with a copy button. + */ +export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte'; + +export * from './ModelsDiscoverDetailsDownloadOptions'; diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte index c3a89c0ba4..6f4973c12e 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverDownloadProgressBar.svelte @@ -18,14 +18,14 @@ {#if overlay}
{:else}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte new file mode 100644 index 0000000000..5ebff7230d --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverList.svelte @@ -0,0 +1,41 @@ + + +
    + {#if loading} + {#each Array(loadingSkeletonRowsCount) as _, index (index)} + + {/each} + {:else} + {#each models as model (model.id)} + + {/each} + {/if} +
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte new file mode 100644 index 0000000000..3a496fd22f --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItem.svelte @@ -0,0 +1,129 @@ + + +
  • + +
  • diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte new file mode 100644 index 0000000000..851abc5238 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListItemSkeleton.svelte @@ -0,0 +1,39 @@ + + + +
  • +
    + + +
    + + +
    + {#each badgeWidths as width, i (i)} + + {/each} +
    +
    +
    +
  • diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte new file mode 100644 index 0000000000..dce949a0f6 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/ModelsDiscoverListSearch.svelte @@ -0,0 +1,26 @@ + + +
    + handleInput(v)} {placeholder} size="sm" /> +
    diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts new file mode 100644 index 0000000000..d888343a15 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverList/index.ts @@ -0,0 +1,38 @@ +/** + * + * MODELS DISCOVER - LIST + * + * The discover sidebar column: a debounced search field above a navigable list of + * HuggingFace GGUF models, with skeleton rows while loading. + * + */ + +/** + * **ModelsDiscoverList** - Sidebar model list + * + * Renders the discover model list as a navigable column. Each row links to the + * model's detail and highlights the active one. + */ +export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte'; + +/** + * **ModelsDiscoverListSearch** - Sidebar search input + * + * Debounced search field for the model list. + */ +export { default as ModelsDiscoverListSearch } from './ModelsDiscoverListSearch.svelte'; + +/** + * **ModelsDiscoverListItem** - Single sidebar row + * + * One model entry in the discover sidebar list, selectable via `onSelect`. + */ +export { default as ModelsDiscoverListItem } from './ModelsDiscoverListItem.svelte'; + +/** + * **ModelsDiscoverListItemSkeleton** - Skeleton sidebar row + * + * Pulsing placeholder matching a ModelsDiscoverListItem row, shown while the + * list is loading. + */ +export { default as ModelsDiscoverListItemSkeleton } from './ModelsDiscoverListItemSkeleton.svelte'; diff --git a/tools/ui/src/lib/components/app/models/discover/index.ts b/tools/ui/src/lib/components/app/models/discover/index.ts new file mode 100644 index 0000000000..5926b75153 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/index.ts @@ -0,0 +1,39 @@ +/** + * + * MODELS DISCOVER + * + * Components for the Models Discover view: a sidebar search + list of + * HuggingFace GGUF models and a detail view for the selected model, used as the + * body of the discovery dialog. The list and detail trees live in their own + * subfolders; this barrel re-exports them alongside the shared leaves. + * + */ + +/** + * **ModelsDiscover** - Models discover explorer + * + * The complete discovery layout: a sidebar search + model list on the left and a + * detail view for the selected model on the right. Used as the body of the + * discovery dialog. + */ +export { default as ModelsDiscover } from './ModelsDiscover.svelte'; + +/** + * **ModelsDiscoverAvatar** - Org avatar for a model row + * + * Shows the org's avatar image, falling back to a monogram on a stable hue + * derived from the org name when the image fails to load. Shared by the list, + * the detail header and the model selector rows. + */ +export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte'; + +/** + * **ModelsDiscoverDownloadProgressBar** - Thin download progress bar + * + * Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay. + * Shared by the quant chips and the model selector's download rows. + */ +export { default as ModelsDiscoverDownloadProgressBar } from './ModelsDiscoverDownloadProgressBar.svelte'; + +export * from './ModelsDiscoverList'; +export * from './ModelsDiscoverDetails'; diff --git a/tools/ui/src/lib/components/ui/select/select-content.svelte b/tools/ui/src/lib/components/ui/select/select-content.svelte index f8792f8a9d..a8263819a1 100644 --- a/tools/ui/src/lib/components/ui/select/select-content.svelte +++ b/tools/ui/src/lib/components/ui/select/select-content.svelte @@ -88,7 +88,7 @@ {@render children?.()} diff --git a/tools/ui/src/lib/components/ui/select/select-trigger.svelte b/tools/ui/src/lib/components/ui/select/select-trigger.svelte index 9f642819ec..de3abcdf21 100644 --- a/tools/ui/src/lib/components/ui/select/select-trigger.svelte +++ b/tools/ui/src/lib/components/ui/select/select-trigger.svelte @@ -11,20 +11,26 @@ variant = 'default', ...restProps }: WithoutChild & { - size?: 'sm' | 'default'; + size?: 'xs' | 'sm' | 'default'; variant?: 'default' | 'plain'; } = $props(); + // Super small trigger: fits its selected value, for dense inline use. + const xsClasses = + "group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-border/30 bg-muted/60 px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground"; + const baseClasses = $derived( variant === 'plain' ? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground" - : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground" + : size === 'xs' + ? xsClasses + : "flex w-fit items-center justify-between gap-2 rounded-md border border-border/30 bg-muted/60 px-3 py-2 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground" ); const chevronClasses = $derived( - variant === 'plain' + variant === 'plain' || size === 'xs' ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180' - : 'size-4 opacity-50' + : 'size-3.5 opacity-60 transition-transform group-data-[state=open]:-rotate-180' ); diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts index 04d5ab779d..6412511736 100644 --- a/tools/ui/src/lib/constants/index.ts +++ b/tools/ui/src/lib/constants/index.ts @@ -47,6 +47,7 @@ export * from './path-display.constants'; export * from './model-id.constants'; export * from './model-loading.constants'; export * from './models-discover.constants'; +export * from './models-discover-download.constants'; export * from './model-compatibility.constants'; export * from './huggingface.constants'; export * from './precision.constants'; diff --git a/tools/ui/src/lib/constants/models-discover-download.constants.ts b/tools/ui/src/lib/constants/models-discover-download.constants.ts new file mode 100644 index 0000000000..e38dd1efbe --- /dev/null +++ b/tools/ui/src/lib/constants/models-discover-download.constants.ts @@ -0,0 +1,54 @@ +/** + * Models Discover - download options constants. + * + * Shared values backing the download-options area of the details pane: the + * serve `--spec-type` mapping, the bit-depth buckets, and the standalone + * command builder. Kept here (not component-local) so they sit with the rest + * of the discover domain constants. + */ + +import type { ModelSidecar } from '$lib/constants'; +import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums'; + +/** + * `--spec-type` value for each draft sidecar; aux sidecars (mmproj, imatrix) + * carry none and stay empty. + */ +export const SPEC_TYPE: Record = { + [ModelAuxSidecar.IMATRIX]: '', + [ModelAuxSidecar.MMPROJ]: '', + [ModelDraftSidecar.DFLASH]: 'draft-dflash', + [ModelDraftSidecar.DSPARK]: 'draft-dspark', + [ModelDraftSidecar.EAGLE3]: 'draft-eagle3', + [ModelDraftSidecar.MTP]: 'draft-mtp' +}; + +/** Bit-depth bucket for files that carry no quant token; rendered as "Other". */ +export const OTHER_BIT_DEPTH = 99; + +/** Label for the OTHER_BIT_DEPTH bucket. */ +export const OTHER_BIT_DEPTH_LABEL = 'Other'; + +/** Suffix appended after a bit-depth number to label a row, e.g. `4-bit`. */ +export const BIT_DEPTH_LABEL_SUFFIX = '-bit'; + +/** + * Fixed tokens of the standalone `llama serve` command preview, so the command + * builder and its rendered spans share one source of the CLI spelling. + */ +export const SERVE_COMMAND = { + BIN: 'llama', + /** Draft model repo:tag argument (download sidecar / draft weights). */ + DRAFT_FLAG: '-hfd', + /** Main model repo:tag argument. */ + MODEL_FLAG: '-hf', + /** Speculative-decoding type argument for the draft. */ + SPEC_TYPE_FLAG: '--spec-type', + SUBCOMMAND: 'serve' +} as const; + +/** Bit depth preferred for the command's default base quant. */ +export const DEFAULT_BASE_BIT_DEPTH = 4; + +/** Label shown for a draft-sidecar chip whose quant is unknown. */ +export const DRAFT_FILE_LABEL = 'draft'; diff --git a/tools/ui/src/lib/constants/models-discover.constants.ts b/tools/ui/src/lib/constants/models-discover.constants.ts index f2a6a9463c..b0a0abc568 100644 --- a/tools/ui/src/lib/constants/models-discover.constants.ts +++ b/tools/ui/src/lib/constants/models-discover.constants.ts @@ -6,3 +6,9 @@ /** llama.app model catalog used as the default model list. Online-only source; the discover feature requires an internet connection anyway. */ export const MODELS_DISCOVER_CATALOG_URL = 'https://llama.app/v1/catalog.json'; + +/** + * Catalog repos fetched in parallel per batch; small on purpose so the HF API + * is not hit with the whole catalog at once. + */ +export const MODELS_DISCOVER_CATALOG_BATCH = 4; diff --git a/tools/ui/src/lib/enums/index.ts b/tools/ui/src/lib/enums/index.ts index 583187d964..6b529b66e4 100644 --- a/tools/ui/src/lib/enums/index.ts +++ b/tools/ui/src/lib/enums/index.ts @@ -69,7 +69,13 @@ export { JsonSchemaType } from './mcp.enums'; -export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums'; +export { + ModelAuxSidecar, + ModelCapability, + ModelDraftSidecar, + ModelModality, + ModelSelectableFileKind +} from './model.enums'; export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums'; diff --git a/tools/ui/src/lib/enums/model.enums.ts b/tools/ui/src/lib/enums/model.enums.ts index f2a0cc3552..0b54ec1631 100644 --- a/tools/ui/src/lib/enums/model.enums.ts +++ b/tools/ui/src/lib/enums/model.enums.ts @@ -36,6 +36,16 @@ export enum ModelAuxSidecar { MMPROJ = 'mmproj' } +/** + * Role of a selectable GGUF in the download options: the main weights, a + * speculative-decoding draft sidecar, or an auxiliary sidecar (mmproj). + */ +export enum ModelSelectableFileKind { + AUX = 'aux', + DRAFT = 'draft', + MAIN = 'main' +} + /** * Why an in-flight download is being stopped, so the terminal `download_failed` * feed event can be attributed: a user pause (resumable) or a user cancel diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts index b571699072..fcab8a8064 100644 --- a/tools/ui/src/lib/stores/index.ts +++ b/tools/ui/src/lib/stores/index.ts @@ -40,6 +40,9 @@ export { mcpStore } from './mcp/index.svelte'; // MODELS export { modelsStore } from './models/index.svelte'; +// MODELS DISCOVER (HuggingFace browse state for the discover dialog) +export { modelsDiscoverStore } from './models-discover/index.svelte'; + // SERVER export { serverStore } from './server.svelte'; diff --git a/tools/ui/src/lib/stores/models-discover/index.svelte.ts b/tools/ui/src/lib/stores/models-discover/index.svelte.ts new file mode 100644 index 0000000000..28cc66f00d --- /dev/null +++ b/tools/ui/src/lib/stores/models-discover/index.svelte.ts @@ -0,0 +1,282 @@ +/** + * modelsDiscoverStore - Models Discover browse state + * + * Owns the HuggingFace GGUF model list shown in the discover sidebar + * (the Discover tab of DialogManageModels). By default the list is the curated catalog set; + * search replaces it with matches across all of HuggingFace. Both paths fetch + * the same fields, so a row renders the same badges and sizes either way. + */ + +import { MODELS_DISCOVER_CATALOG_BATCH } from '$lib/constants'; +import { HuggingFaceService } from '$lib/services'; +import type { + HfCatalogBuild, + HfCatalogEntry, + HfModelInfo, + HfModelSibling +} from '$lib/types/huggingface'; +import { isAuxSidecar } from '$lib/utils'; +import { SvelteMap } from 'svelte/reactivity'; + +/** Min/max GGUF file size (bytes) across the quants of one repo. */ +export interface ModelsDiscoverSizeRange { + max: number; + min: number; +} + +class ModelsDiscoverStore { + error = $state(null); + models = $state([]); + /** First model in the list - discover auto-opens this one. */ + firstModel = $derived(this.models[0] ?? null); + + loading = $state(false); + /** True while a search is in flight; only the newest request owns this flag. */ + searching = $state(false); + + private catalog: HfCatalogEntry[] = []; + /** Repo id -> size range, for catalog rows and lazily measured search rows. */ + private catalogSizeRanges = new SvelteMap(); + private defaultModels: HfModelInfo[] = []; + private fetched = false; + private searchRequestId = 0; + /** In-flight `sizeRange()` lookups, keyed by repo id. */ + private sizeRangePending = new Map>(); + + /** + * Cached size range for a repo, without measuring: the synchronous part of + * `sizeRange()`, for rendering a row before its measurement resolves. + */ + cachedSizeRangeFor(modelId: string): ModelsDiscoverSizeRange | undefined { + return this.catalogSizeRanges.get(modelId); + } + + /** + * Catalog family description for a repo id, or undefined when the repo is + * not part of the catalog (e.g. a search result outside the curated list). + */ + descriptionFor(modelId: string): string | undefined { + return this.catalog.find((entry) => + entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId)) + )?.description; + } + + /** + * Fetch the default list from the llama.app catalog, one repo per catalog + * size in display order (newest family first). Every repo is fetched by ID + * with its file tree, so the rows carry chat-template capabilities, context + * length and a real size range - the same data a search result gets. + * No-op when already loaded or in flight. + */ + async fetch(): Promise { + if (this.loading || this.fetched) return; + + this.loading = true; + this.error = null; + + try { + const catalog = await HuggingFaceService.getCatalog(); + + this.catalog = catalog; + + const builds = this.catalogBuilds(); + const failedBuilds: HfCatalogBuild[] = []; + + this.defaultModels = []; + + // fetch in small batches: not every repo hits the HF API at once, + // and the rows land as they arrive instead of all at the end + for (let i = 0; i < builds.length; i += MODELS_DISCOVER_CATALOG_BATCH) { + const batch = await Promise.all( + builds.slice(i, i + MODELS_DISCOVER_CATALOG_BATCH).map(async (build) => { + const [info, tree] = await Promise.all([ + HuggingFaceService.getDetails(build.repo), + HuggingFaceService.getTree(build.repo) + ]); + + return { build, info, tree }; + }) + ); + + for (const { build, info, tree } of batch) { + if (!info) { + failedBuilds.push(build); + + continue; + } + + this.catalogSizeRanges.set(build.repo, this.sizeRangeFor(build, tree)); + + // the catalog repo id, not the HF response `id`, drives selection + this.defaultModels.push({ ...info, id: build.repo, modelId: build.repo } as HfModelInfo); + } + + // show what has landed, unless a search owns the list right now + if (!this.searching) { + this.models = [...this.defaultModels]; + } + } + + // stay incomplete when some repos failed, so the next mount retries + // them; succeeded repos are served from the service cache for free + this.fetched = failedBuilds.length === 0; + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to fetch models'; + } finally { + this.loading = false; + } + } + + /** Replace the list with search results; an empty query restores the default list. */ + async search(query: string): Promise { + const trimmed = query.trim(); + const requestId = ++this.searchRequestId; + + if (!trimmed) { + this.searching = false; + this.models = this.defaultModels; + this.error = null; + + return; + } + + this.searching = true; + + try { + const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50 }); + + if (requestId !== this.searchRequestId) return; + + this.models = results; + this.error = null; + } catch (err) { + if (requestId !== this.searchRequestId) return; + + this.error = err instanceof Error ? err.message : 'Search failed'; + } finally { + if (requestId === this.searchRequestId) this.searching = false; + } + } + + /** + * Size range for a repo not measured yet - a search result, or a catalog + * repo whose tree came back empty. Fetches the file tree once per repo and + * caches it, so remounting a row (scrolling, searching back) is free. + */ + sizeRange(modelId: string): Promise { + const cached = this.catalogSizeRanges.get(modelId); + + if (cached) return Promise.resolve(cached); + + const pending = this.sizeRangePending.get(modelId); + + if (pending) return pending; + + const request = (async () => { + const tree = await HuggingFaceService.getTree(modelId); + const range = this.sizeRangeOfMainQuants(tree); + + if (range) this.catalogSizeRanges.set(modelId, range); + + return range; + })() + .catch(() => undefined) + .finally(() => this.sizeRangePending.delete(modelId)); + + this.sizeRangePending.set(modelId, request); + + return request; + } + + /** + * Bytes of every quant the catalog lists under this repo, parsed from the + * `size` strings when a build carries no `sizeBytes`. Never empty: an + * unparsable entry contributes the build's own size. + */ + private buildSizeBytes(build: HfCatalogBuild): number[] { + const sizes = this.catalog + .flatMap((entry) => entry.sizes) + .flatMap((size) => size.builds.filter((b) => b.repo === build.repo)) + .map((b) => b.sizeBytes ?? HuggingFaceService.parseSizeBytes(b.size)) + .filter((bytes): bytes is number => Boolean(bytes) && bytes > 0); + + return sizes.length > 0 ? sizes : [build.sizeBytes ?? 0]; + } + + /** + * One build per catalog size, newest family first (by release date). + * Prefers the official ggml-org repo, falling back to the first build so + * families published only by other orgs (mistralai, unsloth) still show up. + * Returns an empty array when the catalog is empty. + */ + private catalogBuilds(): HfCatalogBuild[] { + return [...this.catalog] + .sort((a, b) => b.released.localeCompare(a.released)) + .flatMap((entry) => + entry.sizes.flatMap((size) => { + const build = size.builds.find((b) => b.repo.startsWith('ggml-org/')) ?? size.builds[0]; + + return build ? [build] : []; + }) + ); + } + + /** Byte sizes of every non-sidecar quant file in a tree, shards collapsed. */ + private quantSizesOf(tree: HfModelSibling[]): number[] { + return HuggingFaceService.collapseGgufShards( + HuggingFaceService.filterByExtension(tree, '.gguf') + ) + .filter((f) => { + const { quant, sidecar } = HuggingFaceService.extractQuantMeta(f.path) ?? {}; + + return Boolean(quant) && (sidecar === null || sidecar === undefined); + }) + .map((f) => f.size ?? 0) + .filter((size) => size > 0); + } + + /** + * Size range of one catalog build: every quant in the repo's file tree, plus + * the draft sidecars those files carry (mtp, dflash, ...) so the downloaded + * model fits within the range. Falls back to the catalog `size` / `sizeBytes` + * strings when the tree yielded nothing (partial fetch, sharded-only repo). + */ + private sizeRangeFor(build: HfCatalogBuild, tree: HfModelSibling[]): ModelsDiscoverSizeRange { + const quantSizes = this.quantSizesOf(tree); + + if (quantSizes.length === 0) { + const listed = this.buildSizeBytes(build); + + return { max: Math.max(...listed), min: Math.min(...listed) }; + } + + const draftSizes = tree + .filter((f) => { + const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar; + + return sidecar !== null && sidecar !== undefined && !isAuxSidecar(sidecar); + }) + .map((f) => f.size ?? 0) + .filter((size) => size > 0); + const extra = draftSizes.length > 0 ? Math.max(...draftSizes) : 0; + + return { + max: Math.max(...quantSizes) + extra, + min: Math.min(...quantSizes) + (draftSizes.length > 0 ? Math.min(...draftSizes) : 0) + }; + } + + /** + * Size range across the main-model quants of a file tree, draft sidecars + * excluded (they only widen the range when a row advertises them). + */ + private sizeRangeOfMainQuants(tree: HfModelSibling[]): ModelsDiscoverSizeRange | undefined { + const sizes = this.quantSizesOf(tree); + + if (sizes.length === 0) return undefined; + + return { max: Math.max(...sizes), min: Math.min(...sizes) }; + } +} + +export const modelsDiscoverStore = new ModelsDiscoverStore(); diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 453af9bb80..7f35a13633 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -111,6 +111,14 @@ export type { ModalityCapabilities } from './models'; +// Models discover types +export type { + ModelBitDepthRow, + ModelDownloadEntryState, + ModelQuantOption, + ModelSelectableFile +} from './models-discover'; + // Settings types export type { SettingsConfigValue, diff --git a/tools/ui/src/lib/types/models-discover.d.ts b/tools/ui/src/lib/types/models-discover.d.ts new file mode 100644 index 0000000000..f9812fea85 --- /dev/null +++ b/tools/ui/src/lib/types/models-discover.d.ts @@ -0,0 +1,30 @@ +import type { ModelSelectableFileKind } from '$lib/enums'; +import type { HfModelSibling } from '$lib/types/huggingface'; + +/** + * Option of a quant `