mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-11 07:20:33 +02: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 # Conflicts: # tools/ui/src/lib/components/ui/button/button.svelte
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);
|
||||
@@ -21,11 +74,49 @@
|
||||
class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Dialog.Header class="flex flex-row items-center 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.Description class="sr-only">
|
||||
@@ -33,8 +124,45 @@
|
||||
</Dialog.Description>
|
||||
</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>
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
import { DropdownMenuActions } from '$lib/components/app';
|
||||
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
|
||||
import { ModelRowDownloadState } from '$lib/enums';
|
||||
import { deviceStore, modelsStore } from '$lib/stores';
|
||||
import { deviceStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { repoOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
isFavorite: (option: ModelOption) => boolean;
|
||||
@@ -38,6 +39,18 @@
|
||||
? ModelRowDownloadState.DOWNLOADING
|
||||
: null
|
||||
);
|
||||
|
||||
/** Repo of the row's model, which is what the Discover details are keyed by. */
|
||||
function openInDiscover(): void {
|
||||
uiStore.openModelsDiscover(repoOf(option.model));
|
||||
}
|
||||
|
||||
// a tracked download has nothing to configure yet, so its row opens the Discover
|
||||
// details, where the sizes, variants and download options live
|
||||
function activate(): void {
|
||||
if (download) openInDiscover();
|
||||
else onSelect(option);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -51,9 +64,9 @@
|
||||
<!-- the row holds a load control and an actions menu, so only the model itself is
|
||||
the button: a button nested in a role="button" row is invalid -->
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
aria-pressed={download ? undefined : selected}
|
||||
class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:gap-2 max-md:pr-9"
|
||||
onclick={() => onSelect(option)}
|
||||
onclick={activate}
|
||||
style="padding-left: {indent}px"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
|
||||
import { Eye, EyeOff, Heart, HeartOff, Search, Trash2 } from '@lucide/svelte';
|
||||
import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
|
||||
import { ModelRowDownloadState } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { repoOf } from '$lib/utils';
|
||||
|
||||
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
|
||||
export function modelRowActions(
|
||||
@@ -13,6 +14,14 @@ export function modelRowActions(
|
||||
/** Download state, when the row stands for a tracked download. */
|
||||
download?: ModelRowDownloadState | null
|
||||
) {
|
||||
const viewInDiscover = {
|
||||
icon: Search,
|
||||
label: 'View in Discover',
|
||||
// the details pane is keyed by repo, not by `<repo>:<quant>`
|
||||
onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
|
||||
separator: true
|
||||
};
|
||||
|
||||
// a tracked download is paused, resumed or dropped: it is not loaded, hidden or
|
||||
// drafted. Deleting it stops the download and removes what is on disk, which is
|
||||
// what the same wording offers on a cached model
|
||||
@@ -34,7 +43,8 @@ export function modelRowActions(
|
||||
onclick: () => onDelete(option),
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
}
|
||||
},
|
||||
viewInDiscover
|
||||
];
|
||||
}
|
||||
|
||||
@@ -56,6 +66,7 @@ export function modelRowActions(
|
||||
label: isHidden ? 'Unhide model' : 'Hide model',
|
||||
onclick: () => modelsStore.toggleHidden(option.id),
|
||||
separator: true
|
||||
}
|
||||
},
|
||||
viewInDiscover
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
} from '$lib/components/app/models/discover';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsDiscoverStore } from '$lib/stores';
|
||||
import { modelsDiscoverStore, uiStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types';
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
@@ -35,6 +35,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// a caller can ask for one repo to be opened, the manager's rows do
|
||||
$effect(() => {
|
||||
const focus = uiStore.discoverModelFocus;
|
||||
|
||||
if (!focus) return;
|
||||
|
||||
selectedId = focus;
|
||||
uiStore.discoverModelFocus = null;
|
||||
});
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchQuery = value;
|
||||
|
||||
|
||||
+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 { ModelDraftSidecar } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
@@ -101,11 +101,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';
|
||||
|
||||
@@ -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
|
||||
@@ -581,21 +587,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[];
|
||||
|
||||
@@ -603,11 +619,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[]> {
|
||||
|
||||
@@ -12,6 +12,10 @@ class UiStore {
|
||||
/** Set when a flow outside the chat wants the composer focused once it can take it. */
|
||||
composerFocusRequested = $state(false);
|
||||
|
||||
/** Repo the Discover view selects when it opens, set by a model row. */
|
||||
discoverModelFocus = $state<string | null>(null);
|
||||
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. */
|
||||
@@ -21,11 +25,22 @@ class UiStore {
|
||||
/** Model the information dialog shows; a phone opens it instead of the manager. */
|
||||
modelInformation = $state<ModelOption | null>(null);
|
||||
|
||||
openDiscoverModels(): void {
|
||||
this.discoverModelFocus = null;
|
||||
this.discoverModelsOpen = true;
|
||||
}
|
||||
|
||||
/** Open the model information dialog, a phone's stand-in for the manager pane. */
|
||||
openModelInformation(option: ModelOption): void {
|
||||
this.modelInformation = option;
|
||||
}
|
||||
|
||||
/** Open the models dialog on Discover, focused on one repo. */
|
||||
openModelsDiscover(focus?: string): void {
|
||||
this.discoverModelFocus = focus ?? null;
|
||||
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