mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-02 19:07:25 -05:00
ui : lead the discover list with downloads
The discover sidebar lists running downloads above the suggested models, and steps aside while a query is active. A paused download stays in the manager table, where it is resumed or dropped. Assisted-by: pi
This commit is contained in:
@@ -1,16 +1,82 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverListItem from './ModelsDiscoverList/ModelsDiscoverListItem.svelte';
|
||||
import { Download } from '@lucide/svelte';
|
||||
import { ModelsSection } from '$lib/components/app';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import {
|
||||
ModelsDiscoverDetails,
|
||||
ModelsDiscoverList,
|
||||
ModelsDiscoverListSearch
|
||||
} from '$lib/components/app/models/discover';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { MODEL_ID } from '$lib/constants';
|
||||
import { ModelDownloadConfirmAction } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsDiscoverStore, uiStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types';
|
||||
import { modelsDiscoverStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelInfo, HfModelSibling } from '$lib/types';
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
// running and paused downloads both lead the list; a search owns the pane, so the
|
||||
// section steps aside while a query is active
|
||||
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
|
||||
let isSearching = $derived(searchQuery.trim().length > 0);
|
||||
|
||||
/** Repo id of a download tag, e.g. `org/repo:Q8_0-mtp` reports `org/repo`. */
|
||||
function repoOf(repoWithTag: string): string {
|
||||
return repoWithTag.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? repoWithTag;
|
||||
}
|
||||
|
||||
/** Hub record for a repo the list does not carry, so its row still renders. */
|
||||
function hubRecord(id: string): HfModelInfo {
|
||||
return {
|
||||
_id: id,
|
||||
downloads: 0,
|
||||
id,
|
||||
library_name: null,
|
||||
likes: 0,
|
||||
modelId: id,
|
||||
pipeline_tag: null,
|
||||
private: false,
|
||||
tags: []
|
||||
};
|
||||
}
|
||||
|
||||
// Hub records of repos the list does not carry, e.g. a download started from a
|
||||
// search hit: fetched once so the row shows the same badges and icons as a result.
|
||||
let fetchedRecords = $state<Record<string, HfModelInfo>>({});
|
||||
|
||||
/** Record of a repo: what the list fetched, else its own details, else a bare one. */
|
||||
function recordOf(repo: string): HfModelInfo {
|
||||
return (
|
||||
modelsDiscoverStore.models.find((model) => model.id === repo) ??
|
||||
fetchedRecords[repo] ??
|
||||
hubRecord(repo)
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
for (const entry of downloadEntries) {
|
||||
const repo = repoOf(entry.repoWithTag);
|
||||
|
||||
if (fetchedRecords[repo] || modelsDiscoverStore.models.some((m) => m.id === repo)) continue;
|
||||
|
||||
void HuggingFaceService.getDetails(repo).then((details) => {
|
||||
if (details) fetchedRecords = { ...fetchedRecords, [repo]: details };
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// cancel is confirmed once, so a single dialog serves every download row
|
||||
// The target is kept while the dialog closes so its copy stays rendered.
|
||||
let pendingCancel = $state('');
|
||||
let cancelOpen = $state(false);
|
||||
|
||||
function requestCancel(repoWithTag: string) {
|
||||
pendingCancel = repoWithTag;
|
||||
cancelOpen = true;
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Detail pane state, reloaded when the selection changes.
|
||||
@@ -104,11 +170,54 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet catalog()}
|
||||
<ModelsDiscoverList
|
||||
activeId={selectedId}
|
||||
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
|
||||
models={modelsDiscoverStore.models}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<aside
|
||||
class="w-md shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:pr-4 h-full space-y-4"
|
||||
>
|
||||
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
|
||||
|
||||
{#if downloadEntries.length > 0 && !isSearching}
|
||||
<ModelsSection
|
||||
count={downloadEntries.length}
|
||||
label="Downloading"
|
||||
persistKey="discover-downloads"
|
||||
sticky
|
||||
stickyClass="sticky z-10 bg-background"
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Download class="h-3.5 w-3.5 shrink-0" />
|
||||
{/snippet}
|
||||
|
||||
{#each downloadEntries as entry (entry.repoWithTag)}
|
||||
<!-- the row is the list's own, so a download opens the same details pane a
|
||||
result does; only the state line and the trailing control differ -->
|
||||
{@const repo = repoOf(entry.repoWithTag)}
|
||||
|
||||
<ModelsDiscoverListItem
|
||||
active={repo === selectedId}
|
||||
download={{
|
||||
isPaused: entry.isPaused,
|
||||
onRequestCancel: requestCancel,
|
||||
progress: entry.progress,
|
||||
repoWithTag: entry.repoWithTag
|
||||
}}
|
||||
model={recordOf(repo)}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/each}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
|
||||
<!-- One list instance, so the rows keep their state across search round trips;
|
||||
skeleton rows replace them while the initial catalog or a query loads. -->
|
||||
<div>
|
||||
@@ -123,13 +232,19 @@
|
||||
{:else if !modelsDiscoverStore.loading && !modelsDiscoverStore.searching && modelsDiscoverStore.models.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No models found</p>
|
||||
{:else}
|
||||
<ModelsDiscoverList
|
||||
activeId={selectedId}
|
||||
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
|
||||
models={modelsDiscoverStore.models}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{#if isSearching}
|
||||
{@render catalog()}
|
||||
{:else}
|
||||
<ModelsSection
|
||||
count={modelsDiscoverStore.models.length}
|
||||
label="Suggested models"
|
||||
persistKey="discover-suggested"
|
||||
sticky
|
||||
stickyClass="sticky z-10 bg-background"
|
||||
>
|
||||
{@render catalog()}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -146,3 +261,10 @@
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={ModelDownloadConfirmAction.CANCEL}
|
||||
onClose={() => (cancelOpen = false)}
|
||||
open={cancelOpen}
|
||||
repoWithTag={pendingCancel}
|
||||
/>
|
||||
|
||||
+73
-7
@@ -1,25 +1,43 @@
|
||||
<script lang="ts">
|
||||
import ModelDownloadProgressBar from '../../ModelDownloadProgressBar.svelte';
|
||||
import ModelId from '../../ModelId.svelte';
|
||||
import ModelOrgAvatar from '../../ModelOrgAvatar.svelte';
|
||||
import { Pause, Play, X } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { HF_MMPROJ_FILENAME_TOKEN, HF_MODALITY_PIPELINE_TAGS } from '$lib/constants';
|
||||
import { ModelDraftSidecar } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsDiscoverStore } from '$lib/stores';
|
||||
import { modelsDiscoverStore, modelsStore } from '$lib/stores';
|
||||
import type { ModelsDiscoverSizeRange } from '$lib/stores/models-discover/index.svelte';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import type { ModelDownloadProgress, ModelModalities } from '$lib/types/models';
|
||||
import { detectThinkingSupport, detectToolUseSupport, isDraftSidecar, orgOf } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
model: HfModelInfo;
|
||||
active?: boolean;
|
||||
/** State of a tracked download this row stands for, else the row is a plain result. */
|
||||
download?: {
|
||||
isPaused: boolean;
|
||||
/** Live while the download runs, frozen while it is paused. */
|
||||
progress: ModelDownloadProgress | null;
|
||||
repoWithTag: string;
|
||||
/** Ask the list to confirm cancelling; the list owns the single dialog. */
|
||||
onRequestCancel?: (repoWithTag: string) => void;
|
||||
};
|
||||
/** Show the original (base) model's org avatar instead of the repo's org. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
onSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
|
||||
let { active = false, download, model, onSelect, showBaseModelAvatar = false }: Props = $props();
|
||||
|
||||
let percent = $derived(
|
||||
download?.progress && download.progress.totalBytes > 0
|
||||
? Math.round((download.progress.downloadedBytes / download.progress.totalBytes) * 100)
|
||||
: null
|
||||
);
|
||||
|
||||
let org = $derived(orgOf(model.id));
|
||||
|
||||
@@ -92,12 +110,14 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<li
|
||||
class="group relative flex items-center gap-0.5 overflow-hidden rounded-lg transition-colors {active
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-muted/60'}"
|
||||
>
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-muted/60'}"
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 p-2.5 text-left"
|
||||
onclick={() => onSelect?.(model.id)}
|
||||
type="button"
|
||||
>
|
||||
@@ -119,4 +139,50 @@
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if download}
|
||||
{@const state = download}
|
||||
|
||||
<!-- the percent sits on the row's centre line, and the download's controls take
|
||||
its place on hover, the way the table's status column does -->
|
||||
<span class="flex shrink-0 items-center gap-0.5 pr-2.5">
|
||||
<span
|
||||
class="text-xs text-muted-foreground tabular-nums group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
>
|
||||
{percent !== null ? `${percent}%` : state.isPaused ? 'Paused' : 'Downloading'}
|
||||
</span>
|
||||
|
||||
<span class="hidden items-center gap-0.5 group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
icon={state.isPaused ? Play : Pause}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() =>
|
||||
void (state.isPaused
|
||||
? modelsStore.status.downloadModel(state.repoWithTag)
|
||||
: modelsStore.status.pauseDownload(state.repoWithTag))}
|
||||
stopPropagationOnClick
|
||||
tooltip={state.isPaused ? 'Resume downloading' : 'Pause downloading'}
|
||||
tooltipAsTitle
|
||||
/>
|
||||
|
||||
<ActionIcon
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
icon={X}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => state.onRequestCancel?.(state.repoWithTag)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Cancel downloading"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if state.progress && state.progress.totalBytes > 0}
|
||||
<ModelDownloadProgressBar
|
||||
downloadedBytes={state.progress.downloadedBytes}
|
||||
overlay
|
||||
totalBytes={state.progress.totalBytes}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user