diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte index 148fb839d8..54624352d8 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte @@ -2,6 +2,7 @@ import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte'; import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte'; import { + downloadGroups, groupModelQuants, modelContextLength, type ModelQuantGroup, @@ -139,14 +140,22 @@ rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b)); // one entry per repo, so a model with several quants takes a single table row let entries = $derived(byRecency(groupModelQuants(matching))); + // tracked downloads get their own section, listed like any other model + let downloads = $derived( + downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models) + ); let groups = $derived.by(() => { // a loaded quant is a model of its own: its repo keeps the quants left behind const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model); + // a tracked download stands in its own section, so it is not listed twice + const isDownload = (option: ModelOption) => + modelsStore.status.isDownloadInProgress(option.model) || + modelsStore.status.isDownloadPaused(option.model); const loaded: ModelQuantGroup[] = []; const rest: ModelQuantGroup[] = []; for (const entry of entries) { - const remaining = entry.quants.filter((quant) => !isLoaded(quant)); + const remaining = entry.quants.filter((quant) => !isLoaded(quant) && !isDownload(quant)); for (const quant of entry.quants) { if (!isLoaded(quant)) continue; @@ -183,6 +192,7 @@ }; pushSection(ModelsTableGroupKind.LOADED, loaded); + pushSection(ModelsTableGroupKind.DOWNLOADING, downloads); pushSection(ModelsTableGroupKind.FAVORITES, favorites); pushSection(ModelsTableGroupKind.LOCAL, local); pushSection(ModelsTableGroupKind.HIDDEN, hidden); diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerDownloadControl.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerDownloadControl.svelte new file mode 100644 index 0000000000..146b2dd4e5 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerDownloadControl.svelte @@ -0,0 +1,49 @@ + + + +
+ + {percent !== null ? `${percent}%` : isPaused ? 'Paused' : ''} + + + +
diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte index e23362e2d3..104f631847 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte @@ -2,13 +2,14 @@ import ModelAvatar from '../ModelAvatar.svelte'; import ModelCapabilities from '../ModelCapabilities.svelte'; import ModelContext from '../ModelContext.svelte'; + import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte'; import ModelId from '../ModelId.svelte'; import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte'; import { modelRowActions } from './row-actions'; - import { configuredContext } from './utils'; + import { configuredContext, downloadProgressFor } from './utils'; import { MoreHorizontal } from '@lucide/svelte'; import { DropdownMenuActions } from '$lib/components/app'; - import { MODEL_ROW_GRID_CLASS } from '$lib/constants'; + import { MODEL_ROW_GRID_CLASS, ModelRowDownloadState } from '$lib/constants'; import { KeyboardKey } from '$lib/enums'; import { modelsStore, settingsStore } from '$lib/stores'; import type { ModelOption } from '$lib/types/models'; @@ -27,6 +28,16 @@ let favorite = $derived(isFavorite(option)); let isHidden = $derived(modelsStore.isHidden(option.id)); + // live while the download runs, frozen while it is paused + let downloadProgress = $derived(downloadProgressFor(option.model)); + // a tracked download takes over the status column while it runs + let download = $derived( + modelsStore.status.isDownloadPaused(option.model) + ? ModelRowDownloadState.PAUSED + : modelsStore.status.isDownloadInProgress(option.model) + ? ModelRowDownloadState.DOWNLOADING + : null + ); function handleKeydown(event: KeyboardEvent): void { if (event.key === KeyboardKey.SPACE) event.preventDefault(); @@ -38,7 +49,7 @@
- + + + {#if download} + + {/if}
group.items.length === 0)); let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0); - /** In-flight and paused downloads, tracked by the status feed. */ - let downloadEntries = $derived(modelsStore.status.getDownloadEntries()); - /** Noun the show-more row counts in, per unit of the grouped list. */ const SHOW_MORE_NOUNS: Record = { [GroupedListUnit.ENTRIES]: 'models', [GroupedListUnit.GROUPS]: 'families' }; - let pendingCancel = $state(''); - let cancelOpen = $state(false); let pendingDelete = $state(''); let deleteOpen = $state(false); /** Repos whose quants are folded away; the rest show them. */ const collapsedQuants = new SvelteSet(); /** Sections that list their models straight, without folding them into families. */ const FLAT_SECTIONS = new Set([ + ModelsTableGroupKind.DOWNLOADING, ModelsTableGroupKind.FAVORITES, ModelsTableGroupKind.LOADED ]); @@ -99,7 +94,9 @@ groups.map((group) => { // a flat section lists its models straight, families or not const flat = FLAT_SECTIONS.has(group.kind) || !settingsStore.config.groupModelsByFamily; - const items = sortEntries(group.items); + // downloads keep the feed's order: their progress, not their name, moves + const items = + group.kind === ModelsTableGroupKind.DOWNLOADING ? group.items : sortEntries(group.items); return { ...group, @@ -121,11 +118,6 @@ } // cancel is confirmed once for the whole list, so one dialog serves every row - function requestCancel(repoWithTag: string): void { - pendingCancel = repoWithTag; - cancelOpen = true; - } - function requestDelete(option: ModelOption): void { pendingDelete = option.model; deleteOpen = true; @@ -325,13 +317,6 @@
{/snippet} - (cancelOpen = false)} - open={cancelOpen} - repoWithTag={pendingCancel} -/> - (deleteOpen = false)} @@ -365,22 +350,12 @@
- {#if downloadEntries.length > 0} - - {#snippet icon()} - - {/snippet} - - {#each downloadEntries as entry (entry.repoWithTag)} - - {/each} - - {/if} - {#each sections as group (group.key)} {#if group.items.length > 0} {#snippet groupIcon()} - {#if group.kind === ModelsTableGroupKind.FAVORITES} + {#if group.kind === ModelsTableGroupKind.DOWNLOADING} + + {:else if group.kind === ModelsTableGroupKind.FAVORITES} {:else if group.kind === ModelsTableGroupKind.LOADED} diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte index 9f3d2873a4..92fcc061aa 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerStatusCell.svelte @@ -1,15 +1,19 @@ - +{#if download} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts index 48452d0bac..41b96afbd3 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts +++ b/tools/ui/src/lib/components/app/models/ModelsManager/row-actions.ts @@ -1,4 +1,5 @@ import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte'; +import { MODEL_DOWNLOAD_ICONS, ModelRowDownloadState } from '$lib/constants'; import { modelsStore } from '$lib/stores'; import type { ModelOption } from '$lib/types/models'; @@ -7,8 +8,35 @@ export function modelRowActions( option: ModelOption, favorite: boolean, isHidden: boolean, - onDelete: (option: ModelOption) => void + onDelete: (option: ModelOption) => void, + /** Download state, when the row stands for a tracked download. */ + download?: ModelRowDownloadState | null ) { + // 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 + if (download) { + const isPaused = download === ModelRowDownloadState.PAUSED; + + return [ + { + icon: isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause, + label: isPaused ? 'Resume downloading' : 'Pause downloading', + onclick: () => + void (isPaused + ? modelsStore.status.downloadModel(option.model) + : modelsStore.status.pauseDownload(option.model)) + }, + { + icon: Trash2, + label: 'Delete from disk', + onclick: () => onDelete(option), + separator: true, + variant: 'destructive' as const + } + ]; + } + return [ { icon: favorite ? HeartOff : Heart, diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts index fd84ef4470..1bb9eb696c 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts +++ b/tools/ui/src/lib/components/app/models/ModelsManager/utils.ts @@ -1,8 +1,8 @@ -import { ModelGroupKind, ModelsTableGroupKind } from '$lib/constants'; +import { LOCAL_BACKEND_ID, ModelGroupKind, ModelsTableGroupKind } from '$lib/constants'; import { ModelCapability, ServerModelStatus } from '$lib/enums'; import { HuggingFaceService, ModelsService } from '$lib/services'; import { modelsStore } from '$lib/stores'; -import type { ModelOption } from '$lib/types/models'; +import type { ModelDownloadProgress, ModelOption } from '$lib/types/models'; import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils'; /** One repo of the table, with the rows it ships as. */ @@ -13,6 +13,45 @@ export interface ModelQuantGroup { quants: ModelOption[]; } +/** Byte counts of a tracked download: live while it runs, frozen while paused. */ +export function downloadProgressFor(repoWithTag: string): ModelDownloadProgress | null { + return ( + modelsStore.status.getDownloadProgress(repoWithTag) ?? + modelsStore.status.getPausedDownloadProgress(repoWithTag) + ); +} + +/** One tracked download of the status feed. */ +export interface DownloadEntry { + isPaused: boolean; + progress: ModelDownloadProgress | null; + repoWithTag: string; +} + +/** + * One table row per tracked download, so the table lists them the way it lists + * any other model. A paused download is already in the router's listing, so its + * own option is reused; a fresh one stands for its tag alone. + */ +export function downloadGroups(entries: DownloadEntry[], models: ModelOption[]): ModelQuantGroup[] { + return entries.map((entry) => { + const option = models.find((candidate) => candidate.model === entry.repoWithTag) ?? { + backendId: LOCAL_BACKEND_ID, + capabilities: [], + id: entry.repoWithTag, + model: entry.repoWithTag, + name: entry.repoWithTag + }; + + return { + base: option, + key: `download::${entry.repoWithTag}`, + kind: ModelGroupKind.QUANTS, + quants: [option] + }; + }); +} + /** One collapsible block of the manager's table. */ export interface ModelsTableGroup { /** One entry per repo, its quants hanging off it. */ diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte index f5718b0352..760520e05e 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte @@ -1,10 +1,10 @@