mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-03 03:17:32 -05:00
ui : put the discover view in the models manager
The manager dialog gains its second view: the Discover Models call to action fades the table out, slides the title with its compass mark, and fades the discover surface in; the arrow button returns to the table. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Compass } from '@lucide/svelte';
|
||||
import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
|
||||
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import { uiStore } from '$lib/stores';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
@@ -10,6 +15,54 @@
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
type View = 'discover' | 'manage';
|
||||
|
||||
/** How long a view takes to fade out before the next one fades in. */
|
||||
const VIEW_FADE_MS = 150;
|
||||
|
||||
// what the user asked for, and what is actually rendered: a view change fades the
|
||||
// current one out, swaps, then fades the next one in
|
||||
let view = $state<View>('manage');
|
||||
let shownView = $state<View>('manage');
|
||||
let isSwapping = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const next = view;
|
||||
|
||||
if (next === shownView) return;
|
||||
|
||||
// nothing to fade while the dialog is closed, so the view is set before it opens
|
||||
if (!open) {
|
||||
untrack(() => (shownView = next));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => (isSwapping = true));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
shownView = next;
|
||||
isSwapping = false;
|
||||
});
|
||||
}, VIEW_FADE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
let title = $derived(view === 'discover' ? 'Discover' : 'Models');
|
||||
|
||||
// the sidebar's Discover entry opens this dialog on its Discover view
|
||||
$effect(() => {
|
||||
if (!uiStore.discoverModelsOpen) return;
|
||||
|
||||
untrack(() => {
|
||||
uiStore.discoverModelsOpen = false;
|
||||
view = 'discover';
|
||||
handleOpenChange(true);
|
||||
});
|
||||
});
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
@@ -22,16 +75,91 @@
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="flex flex-row items-center p-2 pr-8">
|
||||
<Dialog.Title class="flex min-h-7 items-center gap-2">
|
||||
<MODEL_ICON class="h-5 w-5 shrink-0" />
|
||||
<Dialog.Header class="relative flex flex-row items-center justify-between p-2 pr-8">
|
||||
<!--
|
||||
The arrow is out of the flow and the title slides, so entering a sub-view
|
||||
moves the title with a transform instead of reflowing the header. min-h
|
||||
keeps the header's height fixed either way.
|
||||
-->
|
||||
<Button
|
||||
aria-label="Back to models"
|
||||
class="absolute top-1/2 left-1 h-7 w-7 -translate-y-1/2 transition-[opacity,visibility] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
|
||||
'manage'
|
||||
? 'invisible opacity-0'
|
||||
: 'visible opacity-100'}"
|
||||
onclick={() => (view = 'manage')}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span>Models</span>
|
||||
<Dialog.Title
|
||||
class="flex min-h-7 items-center gap-2 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
|
||||
'manage'
|
||||
? 'translate-x-0'
|
||||
: 'translate-x-8'}"
|
||||
>
|
||||
<!-- the marks cross-fade in one grid cell, so the title never shifts -->
|
||||
<span class="grid h-5 w-5 shrink-0 place-items-center">
|
||||
<MODEL_ICON
|
||||
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
|
||||
'manage'
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
/>
|
||||
|
||||
<Compass
|
||||
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
|
||||
'discover'
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span>{title}</span>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<ModelsManager class="h-full" />
|
||||
<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
|
||||
{#if shownView === 'manage'}
|
||||
<ModelsManager class="h-full">
|
||||
{#snippet toolbarEnd()}
|
||||
<Button
|
||||
class="gap-1.5"
|
||||
onclick={() => (view = 'discover')}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
<Compass class="h-3.5 w-3.5" />
|
||||
|
||||
Discover Models
|
||||
</Button>
|
||||
{/snippet}
|
||||
</ModelsManager>
|
||||
{:else}
|
||||
<div class="grid h-full overflow-hidden" style="grid-template-columns: auto 1fr;">
|
||||
<ModelsDiscover />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
/* a view change reads as one surface swapping, not as content teleporting */
|
||||
.dialog-view {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.dialog-view[data-visible='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dialog-view {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverDownloadProgressBar from '../../ModelsDiscoverDownloadProgressBar.svelte';
|
||||
import ModelDownloadProgressBar from '../../../ModelDownloadProgressBar.svelte';
|
||||
import { labelFor } from './download-options.utils';
|
||||
import { Check, Download, Loader2, Pause, Play, RotateCw, X } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -232,7 +232,7 @@
|
||||
{@render tooltipTrigger('Cancel downloading', cancelChip)}
|
||||
|
||||
{#if percent !== null}
|
||||
<ModelsDiscoverDownloadProgressBar
|
||||
<ModelDownloadProgressBar
|
||||
downloadedBytes={entry.progress?.downloadedBytes ?? 0}
|
||||
overlay
|
||||
totalBytes={entry.progress?.totalBytes ?? 0}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ModelCapabilityIcons from '../../ModelCapabilityIcons.svelte';
|
||||
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
|
||||
import ModelOrgAvatar from '../../ModelOrgAvatar.svelte';
|
||||
import ModelsDiscoverDetailsHfHubStats from './ModelsDiscoverDetailsHfHubStats.svelte';
|
||||
import ModelsDiscoverDetailsMetadata from './ModelsDiscoverDetailsMetadata.svelte';
|
||||
import { ExternalLink } from '@lucide/svelte';
|
||||
@@ -35,7 +35,7 @@
|
||||
<header class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ModelsDiscoverAvatar
|
||||
<ModelOrgAvatar
|
||||
org={avatarOrg}
|
||||
{quantOrg}
|
||||
quantPositionClass="-bottom-1.5 -right-1.5"
|
||||
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ModelId from '../../ModelId.svelte';
|
||||
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
|
||||
import ModelOrgAvatar from '../../ModelOrgAvatar.svelte';
|
||||
import { HF_MMPROJ_FILENAME_TOKEN, HF_MODALITY_PIPELINE_TAGS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsDiscoverStore } from '$lib/stores';
|
||||
@@ -104,11 +104,7 @@
|
||||
onclick={() => onSelect?.(model.id)}
|
||||
type="button"
|
||||
>
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-1"
|
||||
org={avatarOrg}
|
||||
quantOrg={showBaseModelAvatar ? org : undefined}
|
||||
/>
|
||||
<ModelOrgAvatar class="mt-1" org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<ModelId
|
||||
|
||||
@@ -18,22 +18,5 @@
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
outline:
|
||||
'shadow-sm hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
|
||||
secondary:
|
||||
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25'
|
||||
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25',
|
||||
tertiary:
|
||||
'bg-muted/60 dark:bg-muted/75 shadow-sm border border-border/30 text-foreground hover:bg-muted/80 dark:border-border/20 dark:hover:bg-muted'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,6 +144,12 @@ export class HuggingFaceService {
|
||||
Q8_0: 8
|
||||
};
|
||||
|
||||
// File trees already fetched, keyed by repo id: the discover view asks for the same
|
||||
// repo from several places.
|
||||
private static treeCache = new Map<string, HfModelSibling[]>();
|
||||
|
||||
private static treePending = new Map<string, Promise<HfModelSibling[]>>();
|
||||
|
||||
/** Details already fetched, for a caller that cannot await (a table sorting its rows). */
|
||||
static cachedDetails(modelId: string): HfModelDetailInfo | null | undefined {
|
||||
// llama.cpp model ids carry the quant tag after a colon
|
||||
@@ -577,21 +583,31 @@ export class HuggingFaceService {
|
||||
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
|
||||
* are included; follows cursor pagination for repos over one page.
|
||||
*/
|
||||
static async getTree(modelId: string): Promise<HfModelSibling[]> {
|
||||
if (!HuggingFaceService.isEnabled()) return [];
|
||||
|
||||
const files: HfModelSibling[] = [];
|
||||
const firstUrl =
|
||||
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
|
||||
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
|
||||
static getTree(modelId: string): Promise<HfModelSibling[]> {
|
||||
if (!HuggingFaceService.isEnabled()) return Promise.resolve([]);
|
||||
|
||||
let url: string | null = firstUrl;
|
||||
const cached = HuggingFaceService.treeCache.get(modelId);
|
||||
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = HuggingFaceService.treePending.get(modelId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const files: HfModelSibling[] = [];
|
||||
const firstUrl =
|
||||
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
|
||||
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
|
||||
|
||||
let url: string | null = firstUrl;
|
||||
|
||||
try {
|
||||
for (let page = 0; url && page < HF_TREE_MAX_PAGES; page++) {
|
||||
const response: Response = await fetch(url);
|
||||
|
||||
if (!response.ok) return files;
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch tree for ${modelId}: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as HfModelSibling[];
|
||||
|
||||
@@ -599,11 +615,24 @@ export class HuggingFaceService {
|
||||
|
||||
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
|
||||
}
|
||||
} catch {
|
||||
// Return whatever was fetched before the failure.
|
||||
}
|
||||
|
||||
return files;
|
||||
HuggingFaceService.treeCache.set(modelId, files);
|
||||
|
||||
return files;
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
// not cached: a rate limited or failed fetch should retry on the
|
||||
// next mount; an empty tree makes the store fall back to the
|
||||
// catalog's advertised sizes
|
||||
console.error(`Error fetching tree for ${modelId}:`, error);
|
||||
|
||||
return [] as HfModelSibling[];
|
||||
})
|
||||
.finally(() => HuggingFaceService.treePending.delete(modelId));
|
||||
|
||||
HuggingFaceService.treePending.set(modelId, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
static async getTrending(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
|
||||
|
||||
@@ -10,6 +10,8 @@ class UiStore {
|
||||
/** Set when a flow outside the chat wants the composer focused once it can take it. */
|
||||
composerFocusRequested = $state(false);
|
||||
|
||||
discoverModelsOpen = $state(false);
|
||||
|
||||
/** Whether the desktop sidebar is expanded (open). */
|
||||
isSidebarExpanded = $state(false);
|
||||
/** Model the manager reveals when it opens, a qualified id or a raw model name. */
|
||||
@@ -25,6 +27,10 @@ class UiStore {
|
||||
return requested;
|
||||
}
|
||||
|
||||
openDiscoverModels(): void {
|
||||
this.discoverModelsOpen = true;
|
||||
}
|
||||
|
||||
/** Open the models manager, optionally focused on one model. */
|
||||
openModelsManager(focus?: string): void {
|
||||
this.manageModelFocus = focus ?? null;
|
||||
|
||||
Reference in New Issue
Block a user