mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
ui : rework the model selector around local and remote views
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
-70
@@ -1,70 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Plus, Star } from '@lucide/svelte';
|
||||
import { backendsModelsStore } from '$lib/stores';
|
||||
import type { Backend } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
activeId: string;
|
||||
backends: Backend[];
|
||||
favoritesActive?: boolean;
|
||||
onAdd: () => void;
|
||||
onSelect: (backendId: string) => void;
|
||||
onSelectFavorites?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
activeId,
|
||||
backends,
|
||||
favoritesActive = false,
|
||||
onAdd,
|
||||
onSelect,
|
||||
onSelectFavorites
|
||||
}: Props = $props();
|
||||
|
||||
const tabClass = (active: boolean) => [
|
||||
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
|
||||
active
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-2">
|
||||
{#if onSelectFavorites}
|
||||
<button class={tabClass(favoritesActive)} onclick={onSelectFavorites} type="button">
|
||||
<Star class="h-3 w-3" />
|
||||
|
||||
Favorites
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#each backends as backend (backend.id)}
|
||||
{@const state = backendsModelsStore.get(backend.id)}
|
||||
<button
|
||||
class={tabClass(activeId === backend.id)}
|
||||
onclick={() => onSelect(backend.id)}
|
||||
type="button"
|
||||
>
|
||||
{backend.name}
|
||||
|
||||
{#if state.loading}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{:else if state.error}
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-destructive" title={state.error}></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if backends.length === 0}
|
||||
<span class="px-1 text-xs text-muted-foreground whitespace-nowrap">No backends configured</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
aria-label="Add backend"
|
||||
class="ml-auto shrink-0 rounded-full p-1 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
onclick={onAdd}
|
||||
type="button"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
+23
-22
@@ -5,10 +5,10 @@
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorBackendSwitcher,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorReasoningPanel
|
||||
ModelsSelectorReasoningPanel,
|
||||
ModelsSelectorTabs
|
||||
} from '$lib/components/app';
|
||||
import { DialogBackendForm } from '$lib/components/app/backends';
|
||||
import type { ModelItem } from '$lib/components/app/navigation/utils';
|
||||
@@ -94,7 +94,9 @@
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
for (const item of ms.groupedFilteredOptions.external) order.push(item.option.id);
|
||||
for (const provider of ms.groupedFilteredOptions.providers) {
|
||||
for (const item of provider.items) order.push(item.option.id);
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
@@ -178,13 +180,13 @@
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel}
|
||||
{#if currentModel}
|
||||
<span
|
||||
class={[
|
||||
@@ -210,7 +212,7 @@
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0}
|
||||
|
||||
{#if ms.isMultiModel || ms.switchingBackends}
|
||||
{#if ms.isMultiModel}
|
||||
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
@@ -251,7 +253,7 @@
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel || ms.switchingBackends}
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
@@ -273,11 +275,11 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-80 md:max-w-[26rem] max-w-[calc(100vw-2rem)] p-0!"
|
||||
class="w-full md:min-w-80 md:w-96 max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage={ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
|
||||
emptyMessage={ms.emptyMessage}
|
||||
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
@@ -285,15 +287,14 @@
|
||||
searchClass="bg-transparent"
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<!-- Provider tabs (favorites first), above the option list. -->
|
||||
<ModelsSelectorBackendSwitcher
|
||||
activeId={ms.viewId}
|
||||
backends={ms.backends}
|
||||
favoritesActive={ms.isFavoritesView}
|
||||
onAdd={handleAddBackend}
|
||||
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
|
||||
onSelectFavorites={ms.showFavorites}
|
||||
/>
|
||||
<!-- View tabs (favorites first), sticky under the search input. -->
|
||||
{#snippet subheader()}
|
||||
<ModelsSelectorTabs
|
||||
activeId={ms.viewId}
|
||||
onAdd={handleAddBackend}
|
||||
onSelect={ms.setView}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<!-- Option list; the search header sticks to the top and the actions
|
||||
footer to the bottom of the content scrollport. -->
|
||||
@@ -315,9 +316,7 @@
|
||||
{/if}
|
||||
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">
|
||||
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
|
||||
</p>
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean)}
|
||||
@@ -351,6 +350,8 @@
|
||||
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
|
||||
onProviderOpen={ms.openProvider}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
@@ -436,5 +437,5 @@
|
||||
|
||||
<DialogBackendForm
|
||||
bind:open={showAddBackend}
|
||||
onSaved={(backend) => void ms.handleBackendChange(backend.id)}
|
||||
onSaved={(backend) => void ms.showBackendModels(backend.id)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
|
||||
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
|
||||
import { ModelsSelectorOption } from '$lib/components/app';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
|
||||
@@ -16,6 +17,10 @@
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
/** Favorite models of every backend; shown on the favorites tab. */
|
||||
favorites?: ModelItem[];
|
||||
/** Open one provider's full list, offered when a section is cut short. */
|
||||
onProviderOpen?: (backendId: string) => void;
|
||||
/** Leave the drilled-in provider; enables the back affordance. */
|
||||
onProviderBack?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,11 +29,18 @@
|
||||
favorites = [],
|
||||
groups,
|
||||
onInfoClick,
|
||||
onProviderBack,
|
||||
onProviderOpen,
|
||||
onSelect,
|
||||
renderOption,
|
||||
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
// section headers stick right below the search/tabs block of the dropdown
|
||||
// scrollport; `--dropdown-sticky-height` is set by DropdownMenuSearchable
|
||||
// and falls back to 0 in surfaces without one (the mobile sheet)
|
||||
let headerClass = $derived(`${sectionHeaderClass} sticky z-10 bg-popover`);
|
||||
const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
|
||||
|
||||
/** In-flight / paused downloads, tracked by the status feed. */
|
||||
let getDownloadEntries = $derived(modelsStore.status.getDownloadEntries());
|
||||
@@ -70,7 +82,7 @@
|
||||
{/each}
|
||||
|
||||
{#if getDownloadEntries.length > 0}
|
||||
<p class={sectionHeaderClass}>Download in progress</p>
|
||||
<p class={headerClass} style={headerStyle}>Download in progress</p>
|
||||
|
||||
{#each getDownloadEntries as entry (entry.repoWithTag)}
|
||||
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
|
||||
@@ -78,7 +90,7 @@
|
||||
{/if}
|
||||
|
||||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
<p class={headerClass} style={headerStyle}>Loaded models</p>
|
||||
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
@@ -86,7 +98,7 @@
|
||||
{/if}
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<h2 class={sectionHeaderClass}>Downloaded models</h2>
|
||||
<h2 class={headerClass} style={headerStyle}>Downloaded models</h2>
|
||||
|
||||
{#each groups.available as group (group.orgName)}
|
||||
{#each group.items as item (item.option.id)}
|
||||
@@ -95,11 +107,50 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.external.length > 0}
|
||||
{#each groups.external as item (`ext-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- Remote view: one section per backend. -->
|
||||
{#each groups.providers as provider (provider.backendId)}
|
||||
<p class="{headerClass} flex items-center gap-1.5" style={headerStyle}>
|
||||
{#if onProviderBack}
|
||||
<button
|
||||
aria-label="Back to all providers"
|
||||
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
onclick={onProviderBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{provider.name}
|
||||
|
||||
{#if provider.loading}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{:else if provider.error}
|
||||
<CircleAlert class="h-3 w-3 text-destructive" />
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if provider.items.length > 0}
|
||||
{#each provider.items as item (`${provider.backendId}-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
|
||||
{#if onProviderOpen && provider.matched > provider.items.length}
|
||||
<!-- same box as a model row, it opens the provider's full list -->
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-foreground focus:outline-none"
|
||||
onclick={() => onProviderOpen(provider.backendId)}
|
||||
type="button"
|
||||
>
|
||||
+ {provider.matched - provider.items.length} more
|
||||
</button>
|
||||
{/if}
|
||||
{:else if provider.catalog === 0}
|
||||
<p class="px-4 pb-2 text-xs text-muted-foreground">
|
||||
{provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
|
||||
</p>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={ModelDownloadConfirmAction.CANCEL}
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
showBaseModelAvatar = false
|
||||
}: Props = $props();
|
||||
|
||||
// row actions follow the backend that serves the row, not the selected one
|
||||
let rowBackend = $derived(getBackend(option.backendId));
|
||||
let canLoad = $derived(rowBackend ? getBackendCapabilities(rowBackend).loadUnload : false);
|
||||
let currentRouterModels = $derived(modelsStore.routerModels);
|
||||
let serverStatus = $derived.by(() => {
|
||||
const model = currentRouterModels.find((m) => m.id === option.model);
|
||||
@@ -196,7 +199,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
{#if !canLoad}
|
||||
<!-- remote rows have no load state, the column stays out of the way -->
|
||||
{:else if isLoading}
|
||||
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
|
||||
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
ModelsSelectorBackendSwitcher,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorReasoningPanel,
|
||||
ModelsSelectorTabs,
|
||||
SearchInput
|
||||
} from '$lib/components/app';
|
||||
import { DialogBackendForm } from '$lib/components/app/backends';
|
||||
@@ -70,12 +70,12 @@
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
@@ -91,7 +91,7 @@
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0}
|
||||
|
||||
{#if ms.isMultiModel || ms.switchingBackends}
|
||||
{#if ms.isMultiModel}
|
||||
<button
|
||||
class={[
|
||||
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
@@ -127,7 +127,7 @@
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel || ms.switchingBackends}
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||
@@ -157,14 +157,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Provider tabs (favorites first), above the option list. -->
|
||||
<ModelsSelectorBackendSwitcher
|
||||
<!-- View tabs (favorites first), above the option list. -->
|
||||
<ModelsSelectorTabs
|
||||
activeId={ms.viewId}
|
||||
backends={ms.backends}
|
||||
favoritesActive={ms.isFavoritesView}
|
||||
onAdd={handleAddBackend}
|
||||
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
|
||||
onSelectFavorites={ms.showFavorites}
|
||||
onSelect={ms.setView}
|
||||
/>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
@@ -185,9 +182,7 @@
|
||||
{/if}
|
||||
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">
|
||||
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
|
||||
</p>
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">{ms.emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
@@ -196,6 +191,8 @@
|
||||
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
|
||||
onProviderOpen={ms.openProvider}
|
||||
onSelect={ms.handleSelect}
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
/>
|
||||
@@ -245,5 +242,5 @@
|
||||
|
||||
<DialogBackendForm
|
||||
bind:open={showAddBackend}
|
||||
onSaved={(backend) => void ms.handleBackendChange(backend.id)}
|
||||
onSaved={(backend) => void ms.showBackendModels(backend.id)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import {
|
||||
LOCAL_BACKEND_ID,
|
||||
MODELS_VIEW_FAVORITES,
|
||||
MODELS_VIEW_LOCAL,
|
||||
MODELS_VIEW_REMOTE
|
||||
} from '$lib/constants';
|
||||
import { backendsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
activeId: string;
|
||||
onAdd: () => void;
|
||||
onSelect: (viewId: string) => void;
|
||||
}
|
||||
|
||||
let { activeId, onAdd, onSelect }: Props = $props();
|
||||
|
||||
const tabClass = (active: boolean) => [
|
||||
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
|
||||
active
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
];
|
||||
|
||||
// the remote view is only offered while a remote backend exists
|
||||
const hasRemoteBackends = $derived(
|
||||
backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 overflow-x-auto px-2 py-2">
|
||||
<button
|
||||
class={tabClass(activeId === MODELS_VIEW_FAVORITES)}
|
||||
onclick={() => onSelect(MODELS_VIEW_FAVORITES)}
|
||||
type="button"
|
||||
>
|
||||
Favorites
|
||||
</button>
|
||||
|
||||
<button
|
||||
class={tabClass(activeId === MODELS_VIEW_LOCAL)}
|
||||
onclick={() => onSelect(MODELS_VIEW_LOCAL)}
|
||||
type="button"
|
||||
>
|
||||
Local
|
||||
</button>
|
||||
|
||||
{#if hasRemoteBackends}
|
||||
<button
|
||||
class={tabClass(activeId === MODELS_VIEW_REMOTE)}
|
||||
onclick={() => onSelect(MODELS_VIEW_REMOTE)}
|
||||
type="button"
|
||||
>
|
||||
Remote
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
aria-label="Add backend"
|
||||
class="ml-auto shrink-0 rounded-full p-1 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
onclick={onAdd}
|
||||
type="button"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -46,7 +46,14 @@
|
||||
*/
|
||||
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
|
||||
|
||||
export { default as ModelsSelectorBackendSwitcher } from './ModelsSelectorBackendSwitcher.svelte';
|
||||
/**
|
||||
* **ModelsSelectorTabs** - View tabs for the selector
|
||||
*
|
||||
* Switches the rendered view: the favorites of every backend, the local server's
|
||||
* models, or the remote backends'. Purely a display concern - the backend that
|
||||
* serves requests follows the selected model.
|
||||
*/
|
||||
export { default as ModelsSelectorTabs } from './ModelsSelectorTabs.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorList** - Grouped model options list
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
* overflow-y-auto and a max-height) and must not be `overflow-hidden`.
|
||||
*/
|
||||
footer?: Snippet;
|
||||
/** Extra sticky content under the search input, e.g. the view tabs. */
|
||||
subheader?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,11 +35,16 @@
|
||||
onSearchKeyDown,
|
||||
placeholder = 'Search...',
|
||||
searchClass = '',
|
||||
searchValue = $bindable('')
|
||||
searchValue = $bindable(''),
|
||||
subheader
|
||||
}: Props = $props();
|
||||
|
||||
// the search and the subheader stick as one block; its height is published so
|
||||
// list section headers can stick right below it
|
||||
let stickyHeaderHeight = $state(0);
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-20 p-1.5">
|
||||
<div bind:clientHeight={stickyHeaderHeight} class="sticky top-0 z-20 bg-popover p-1.5">
|
||||
<SearchInput
|
||||
bind:value={searchValue}
|
||||
class={searchClass}
|
||||
@@ -45,14 +52,21 @@
|
||||
onKeyDown={onSearchKeyDown}
|
||||
{placeholder}
|
||||
/>
|
||||
|
||||
{#if subheader}
|
||||
{@render subheader()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class={contentClass}>
|
||||
{@render children()}
|
||||
<!-- wrapper carries the sticky offset: bits-ui owns the style of the scrollport -->
|
||||
<div style="--dropdown-sticky-height: {stickyHeaderHeight}px">
|
||||
{@render children()}
|
||||
|
||||
{#if isEmpty}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{#if isEmpty}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
|
||||
@@ -12,11 +12,25 @@ export interface OrgGroup {
|
||||
items: ModelItem[];
|
||||
}
|
||||
|
||||
/** One remote backend's section on the remote view. */
|
||||
export interface ProviderGroup {
|
||||
backendId: string;
|
||||
/** Models the provider lists without any search filtering. */
|
||||
catalog: number;
|
||||
error: string | null;
|
||||
/** Rows to render, capped by the display limit. */
|
||||
items: ModelItem[];
|
||||
loading: boolean;
|
||||
/** Rows left after the search filter, before the cap. */
|
||||
matched: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
loaded: ModelItem[];
|
||||
available: OrgGroup[];
|
||||
/** Models served by other backends; rendered flat, without categories. */
|
||||
external: ModelItem[];
|
||||
loaded: ModelItem[];
|
||||
/** Remote backends, one section each. */
|
||||
providers: ProviderGroup[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
@@ -72,8 +86,7 @@ export function groupFavoriteOptions(
|
||||
|
||||
export function groupModelOptions(
|
||||
filteredOptions: ModelOption[],
|
||||
isModelLoaded: (model: string) => boolean,
|
||||
isLocalModel: (option: ModelOption) => boolean = () => true
|
||||
isModelLoaded: (model: string) => boolean
|
||||
): GroupedModelOptions {
|
||||
// Loaded models
|
||||
const loaded: ModelItem[] = [];
|
||||
@@ -89,8 +102,6 @@ export function groupModelOptions(
|
||||
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
|
||||
// Available models grouped by org (excluding loaded)
|
||||
const available: OrgGroup[] = [];
|
||||
// models from other backends have no load state, so they stay flat
|
||||
const external: ModelItem[] = [];
|
||||
const orgGroups = new SvelteMap<string, ModelItem[]>();
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
@@ -98,12 +109,6 @@ export function groupModelOptions(
|
||||
|
||||
if (loadedModelIds.has(option.model)) continue;
|
||||
|
||||
if (!isLocalModel(option)) {
|
||||
external.push({ flatIndex: i, option });
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = option.parsedId?.orgName ?? '';
|
||||
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
@@ -115,5 +120,39 @@ export function groupModelOptions(
|
||||
available.push({ items, orgName: orgName || null });
|
||||
}
|
||||
|
||||
return { available, external, loaded };
|
||||
return { available, loaded, providers: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote backends as sections, one per backend, in the given order. Each section
|
||||
* keeps at most `limit` rows; `matched` carries the full count so the caller can
|
||||
* offer the rest.
|
||||
*/
|
||||
export function groupProviderOptions(
|
||||
options: ModelOption[],
|
||||
providers: {
|
||||
backendId: string;
|
||||
catalog: number;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
name: string;
|
||||
}[],
|
||||
limit = Infinity
|
||||
): ProviderGroup[] {
|
||||
const byBackend = new SvelteMap<string, ModelItem[]>();
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
const backendId = option.backendId ?? '';
|
||||
|
||||
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
|
||||
|
||||
byBackend.get(backendId)!.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
return providers.map((provider) => {
|
||||
const items = byBackend.get(provider.backendId) ?? [];
|
||||
|
||||
return { ...provider, items: items.slice(0, limit), matched: items.length };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,8 +50,13 @@ export const DARK_INVERT_AVATAR_ORGS = ['openai'];
|
||||
/** Icon used for the model selector and the `/model` slash command. */
|
||||
export const MODEL_SELECTOR_ICON = Package;
|
||||
|
||||
/** Pseudo backend tab listing the favorites of every backend. */
|
||||
export const FAVORITES_TAB_ID = 'favorites';
|
||||
/** Models listed per remote provider before the "+ X more" line; search covers the rest. */
|
||||
export const REMOTE_PROVIDER_MODEL_LIMIT = 12;
|
||||
|
||||
/** Model selector views: the favorites of every backend, the local server, the remote backends. */
|
||||
export const MODELS_VIEW_FAVORITES = 'favorites';
|
||||
export const MODELS_VIEW_LOCAL = 'local';
|
||||
export const MODELS_VIEW_REMOTE = 'remote';
|
||||
|
||||
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
||||
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||
|
||||
@@ -2,17 +2,24 @@ import type { ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import {
|
||||
filterModelOptions,
|
||||
groupFavoriteOptions,
|
||||
groupModelOptions
|
||||
groupModelOptions,
|
||||
groupProviderOptions
|
||||
} from '$lib/components/app/navigation/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR, FAVORITES_TAB_ID, LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import {
|
||||
CHAT_INPUT_FOCUS_SELECTOR,
|
||||
LOCAL_BACKEND_ID,
|
||||
MODELS_VIEW_FAVORITES,
|
||||
MODELS_VIEW_LOCAL,
|
||||
MODELS_VIEW_REMOTE,
|
||||
REMOTE_PROVIDER_MODEL_LIMIT
|
||||
} from '$lib/constants';
|
||||
import { backendsModelsStore, backendsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import type { Backend } from '$lib/types';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { rawModelId } from '$lib/utils/model-option-id';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
/** Groups of the favorites tab, which lists favorites only. */
|
||||
const EMPTY_GROUPS = { available: [], external: [], loaded: [] };
|
||||
const EMPTY_GROUPS = { available: [], loaded: [], providers: [] };
|
||||
|
||||
export interface UseModelsSelectorOptions {
|
||||
currentModel: () => string | null;
|
||||
@@ -28,8 +35,7 @@ export interface UseModelsSelectorReturn {
|
||||
readonly loading: boolean;
|
||||
readonly updating: boolean;
|
||||
readonly activeId: string | null;
|
||||
readonly activeBackendId: string;
|
||||
readonly backends: Backend[];
|
||||
readonly emptyMessage: string;
|
||||
readonly isMultiModel: boolean;
|
||||
readonly isRouter: boolean;
|
||||
readonly serverModel: string | null;
|
||||
@@ -39,18 +45,20 @@ export interface UseModelsSelectorReturn {
|
||||
readonly filteredOptions: ModelOption[];
|
||||
readonly isFavoritesView: boolean;
|
||||
readonly isEmpty: boolean;
|
||||
readonly isProviderView: boolean;
|
||||
readonly groupedFilteredOptions: ReturnType<typeof groupModelOptions>;
|
||||
readonly isLoadingModel: boolean;
|
||||
readonly switchingBackends: boolean;
|
||||
readonly searchTerm: string;
|
||||
readonly showModelDialog: boolean;
|
||||
readonly infoModelId: string | null;
|
||||
readonly viewId: string;
|
||||
showFavorites(): void;
|
||||
closeProvider(): void;
|
||||
openProvider(backendId: string): void;
|
||||
setSearchTerm(value: string): void;
|
||||
setView(viewId: string): void;
|
||||
showBackendModels(backendId: string): Promise<void>;
|
||||
setShowModelDialog(value: boolean): void;
|
||||
handleInfoClick(modelName: string): void;
|
||||
handleBackendChange(backendId: string): Promise<void>;
|
||||
handleSelect(modelId: string): Promise<void>;
|
||||
handleOpenChange(open: boolean): void;
|
||||
isFavorite(model: string): boolean;
|
||||
@@ -66,16 +74,15 @@ export interface UseModelsSelectorReturn {
|
||||
*/
|
||||
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
|
||||
/**
|
||||
* Tab the selector shows: a backend id, or the favorites pseudo tab. Favorites
|
||||
* are the default view while there is at least one.
|
||||
* Current view: the favorites of every backend, the local server's models, or
|
||||
* the remote backends'. Favorites are the default while there is at least one.
|
||||
*/
|
||||
let viewId = $state<string>(
|
||||
modelsStore.favoriteModelIds.size > 0 ? FAVORITES_TAB_ID : backendsStore.active.id
|
||||
modelsStore.favoriteModelIds.size > 0 ? MODELS_VIEW_FAVORITES : MODELS_VIEW_LOCAL
|
||||
);
|
||||
|
||||
const activeBackendId = $derived(backendsStore.active.id);
|
||||
// every enabled backend's models stay selectable and resolvable, so a
|
||||
// model never turns unavailable just because another tab is open
|
||||
// every enabled backend's models stay selectable and resolvable, so a model
|
||||
// never turns unavailable just because another view is open
|
||||
const allOptions = $derived(
|
||||
modelsStore.models.filter((option) => {
|
||||
const modelProps = modelsStore.props.getModelProps(option.model);
|
||||
@@ -83,16 +90,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return modelProps?.ui !== false;
|
||||
})
|
||||
);
|
||||
// the switcher tabs scope the rendered list to one backend's models; the
|
||||
// favorites tab is not a backend and lists every backend's favorites
|
||||
const isFavoritesView = $derived(viewId === FAVORITES_TAB_ID);
|
||||
const options = $derived(
|
||||
isFavoritesView ? allOptions : allOptions.filter((option) => option.backendId === viewId)
|
||||
);
|
||||
const isFavoritesView = $derived(viewId === MODELS_VIEW_FAVORITES);
|
||||
const isRemoteView = $derived(viewId === MODELS_VIEW_REMOTE);
|
||||
|
||||
/** Remote backend drilled into from the remote list; null while browsing sections. */
|
||||
let providerViewId = $state<string | null>(null);
|
||||
|
||||
const isProviderView = $derived(providerViewId !== null);
|
||||
const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID;
|
||||
const options = $derived.by(() => {
|
||||
if (isFavoritesView) return allOptions;
|
||||
|
||||
if (providerViewId) {
|
||||
return allOptions.filter((option) => option.backendId === providerViewId);
|
||||
}
|
||||
|
||||
return allOptions.filter((option) =>
|
||||
isRemoteView ? !isLocalOption(option) : isLocalOption(option)
|
||||
);
|
||||
});
|
||||
const loading = $derived(modelsStore.loading);
|
||||
const updating = $derived(modelsStore.updating);
|
||||
const activeId = $derived(modelsStore.selectedModelId);
|
||||
const backends = $derived(backendsStore.enabled);
|
||||
// Router mode and external backends both expose a selectable model list, and
|
||||
// configured backends always need the tabs; only a lone llama.cpp server
|
||||
// without a router has nothing to list.
|
||||
@@ -117,7 +136,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
});
|
||||
|
||||
let isLoadingModel = $state(false);
|
||||
let switchingBackends = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let showModelDialog = $state(false);
|
||||
let infoModelId = $state<string | null>(null);
|
||||
@@ -127,18 +145,54 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
const favoriteItems = $derived(
|
||||
groupFavoriteOptions(filterModelOptions(allOptions, searchTerm), modelsStore.favoriteModelIds)
|
||||
);
|
||||
const groupedFilteredOptions = $derived(
|
||||
isFavoritesView
|
||||
? EMPTY_GROUPS
|
||||
: groupModelOptions(
|
||||
filteredOptions,
|
||||
(m) => modelsStore.isModelLoaded(m),
|
||||
(option) => option.backendId === LOCAL_BACKEND_ID
|
||||
)
|
||||
const remoteProviders = $derived(
|
||||
backendsStore.enabled
|
||||
.filter((backend) => backend.id !== LOCAL_BACKEND_ID)
|
||||
.map((backend) => {
|
||||
const state = backendsModelsStore.get(backend.id);
|
||||
|
||||
return {
|
||||
backendId: backend.id,
|
||||
catalog: state.models.length,
|
||||
error: state.error,
|
||||
loading: state.loading,
|
||||
name: backend.name
|
||||
};
|
||||
})
|
||||
);
|
||||
const providerSections = $derived(
|
||||
groupProviderOptions(
|
||||
filteredOptions,
|
||||
remoteProviders,
|
||||
// a drill-in or a search reaches every model, the sections stay short
|
||||
providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT
|
||||
)
|
||||
);
|
||||
const groupedFilteredOptions = $derived.by(() => {
|
||||
if (isFavoritesView) return EMPTY_GROUPS;
|
||||
|
||||
if (isRemoteView || isProviderView) {
|
||||
const sections = providerViewId
|
||||
? providerSections.filter((section) => section.backendId === providerViewId)
|
||||
: providerSections;
|
||||
|
||||
return { ...EMPTY_GROUPS, providers: sections };
|
||||
}
|
||||
|
||||
return groupModelOptions(filteredOptions, (m) => modelsStore.isModelLoaded(m));
|
||||
});
|
||||
const isEmpty = $derived(
|
||||
isFavoritesView ? favoriteItems.length === 0 : filteredOptions.length === 0
|
||||
);
|
||||
const emptyMessage = $derived(
|
||||
searchTerm
|
||||
? 'No models found.'
|
||||
: isFavoritesView
|
||||
? 'No favorite models yet.'
|
||||
: isRemoteView
|
||||
? 'No remote models.'
|
||||
: 'No local models.'
|
||||
);
|
||||
|
||||
function handleInfoClick(modelName: string) {
|
||||
infoModelId = modelName;
|
||||
@@ -163,6 +217,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
}
|
||||
|
||||
searchTerm = '';
|
||||
providerViewId = null;
|
||||
|
||||
if (open && isRouter) {
|
||||
modelsStore.props.fetchModalitiesForLoadedModels();
|
||||
@@ -171,27 +226,32 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
opts.onOpenChange?.(open);
|
||||
}
|
||||
|
||||
async function handleBackendChange(backendId: string) {
|
||||
viewId = backendId;
|
||||
/**
|
||||
* Switch the rendered view. Views are display only: the backend that serves
|
||||
* requests follows the selected model, not the view.
|
||||
*/
|
||||
function setView(nextViewId: string) {
|
||||
viewId = nextViewId;
|
||||
providerViewId = null;
|
||||
searchTerm = '';
|
||||
}
|
||||
|
||||
if (backendId === backendsStore.active.id) return;
|
||||
/** Drill into one remote backend's full model list. */
|
||||
function openProvider(backendId: string) {
|
||||
providerViewId = backendId;
|
||||
searchTerm = '';
|
||||
}
|
||||
|
||||
backendsStore.setActive(backendId);
|
||||
function closeProvider() {
|
||||
providerViewId = null;
|
||||
searchTerm = '';
|
||||
}
|
||||
|
||||
// keep the multi-model selector mounted across the swap; a role flip
|
||||
// (external MODEL mode -> local ROUTER mode) would otherwise unmount and
|
||||
// remount the open dropdown
|
||||
switchingBackends = true;
|
||||
/** Show a backend's models, e.g. right after it was added. */
|
||||
async function showBackendModels(backendId: string): Promise<void> {
|
||||
setView(backendId === LOCAL_BACKEND_ID ? MODELS_VIEW_LOCAL : MODELS_VIEW_REMOTE);
|
||||
|
||||
try {
|
||||
await backendsModelsStore.ensureLoaded(backendId);
|
||||
await modelsStore.switchBackend();
|
||||
} catch (error) {
|
||||
console.error('Failed to switch backend:', error);
|
||||
} finally {
|
||||
switchingBackends = false;
|
||||
}
|
||||
await backendsModelsStore.ensureLoaded(backendId);
|
||||
}
|
||||
|
||||
async function handleSelect(modelId: string) {
|
||||
@@ -282,16 +342,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
}
|
||||
|
||||
return {
|
||||
get activeBackendId() {
|
||||
return activeBackendId;
|
||||
},
|
||||
|
||||
get activeId() {
|
||||
return activeId;
|
||||
},
|
||||
|
||||
get backends() {
|
||||
return backends;
|
||||
closeProvider,
|
||||
|
||||
get emptyMessage() {
|
||||
return emptyMessage;
|
||||
},
|
||||
|
||||
get favoriteItems() {
|
||||
@@ -308,8 +366,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return groupedFilteredOptions;
|
||||
},
|
||||
|
||||
handleBackendChange,
|
||||
|
||||
handleInfoClick,
|
||||
|
||||
handleOpenChange,
|
||||
@@ -348,6 +404,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return isMultiModel;
|
||||
},
|
||||
|
||||
get isProviderView() {
|
||||
return isProviderView;
|
||||
},
|
||||
|
||||
get isRouter() {
|
||||
return isRouter;
|
||||
},
|
||||
@@ -356,6 +416,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return loading;
|
||||
},
|
||||
|
||||
openProvider,
|
||||
|
||||
get options() {
|
||||
return options;
|
||||
},
|
||||
@@ -376,19 +438,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
showModelDialog = value;
|
||||
},
|
||||
|
||||
showFavorites() {
|
||||
viewId = FAVORITES_TAB_ID;
|
||||
searchTerm = '';
|
||||
},
|
||||
setView,
|
||||
|
||||
showBackendModels,
|
||||
|
||||
get showModelDialog() {
|
||||
return showModelDialog;
|
||||
},
|
||||
|
||||
get switchingBackends() {
|
||||
return switchingBackends;
|
||||
},
|
||||
|
||||
get updating() {
|
||||
return updating;
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
CLI_FLAGS,
|
||||
HF_UD_QUANT_PREFIX_REGEX,
|
||||
LOCAL_BACKEND_ID,
|
||||
MODEL_ID,
|
||||
PATH_SEPARATOR,
|
||||
PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY
|
||||
@@ -17,8 +18,10 @@ import {
|
||||
import { ModelDownloadStopRequest, ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services/huggingface.service';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
|
||||
import { backendsStore } from '$lib/stores/backends.svelte';
|
||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||
import { backendsModelsStore } from '$lib/stores/backendsModels.svelte';
|
||||
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
|
||||
import type { ApiModelsSseDownloadProgressData, ModelDownloadProgress } from '$lib/types';
|
||||
@@ -37,6 +40,7 @@ export interface ModelStatusHost {
|
||||
routerModels: ApiModelDataEntry[];
|
||||
fetchRouterModels(): Promise<void>;
|
||||
isModelLoaded(modelId: string): boolean;
|
||||
switchBackend(): Promise<void>;
|
||||
toDisplayName(id: string): string;
|
||||
}
|
||||
|
||||
@@ -109,6 +113,8 @@ export class ModelStatusManager {
|
||||
* feed's model_remove event.
|
||||
*/
|
||||
async cancelDownload(repoWithTag: string): Promise<boolean> {
|
||||
await this.ensureLocalTarget();
|
||||
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
@@ -154,6 +160,8 @@ export class ModelStatusManager {
|
||||
* waiter is registered here.
|
||||
*/
|
||||
async cancelLoad(modelId: string): Promise<void> {
|
||||
await this.ensureLocalTarget();
|
||||
|
||||
if (!serverStore.isRouterMode) return;
|
||||
|
||||
this.subscribe();
|
||||
@@ -190,6 +198,8 @@ export class ModelStatusManager {
|
||||
* (same tag) continues from the partial files the pause kept on disk.
|
||||
*/
|
||||
async downloadModel(repoWithTag: string): Promise<void> {
|
||||
await this.ensureLocalTarget();
|
||||
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
@@ -320,6 +330,8 @@ export class ModelStatusManager {
|
||||
}
|
||||
|
||||
async load(modelId: string): Promise<void> {
|
||||
await this.ensureLocalTarget();
|
||||
|
||||
if (this.host.isModelLoaded(modelId)) return;
|
||||
|
||||
if (this.loadingStates.get(modelId)) return;
|
||||
@@ -356,6 +368,8 @@ export class ModelStatusManager {
|
||||
* reports the stop as download_failed; a 'pause' stop request marks it as such.
|
||||
*/
|
||||
async pauseDownload(repoWithTag: string): Promise<void> {
|
||||
await this.ensureLocalTarget();
|
||||
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
@@ -374,10 +388,6 @@ export class ModelStatusManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the /models/sse feed and keep it live with auto reconnect.
|
||||
* Idempotent and router mode only.
|
||||
*/
|
||||
subscribe(): void {
|
||||
if (this.statusReaderActive) return;
|
||||
|
||||
@@ -389,6 +399,8 @@ export class ModelStatusManager {
|
||||
}
|
||||
|
||||
async unload(modelId: string): Promise<void> {
|
||||
await this.ensureLocalTarget();
|
||||
|
||||
if (!this.host.isModelLoaded(modelId)) return;
|
||||
|
||||
if (this.loadingStates.get(modelId)) return;
|
||||
@@ -586,6 +598,23 @@ export class ModelStatusManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the /models/sse feed and keep it live with auto reconnect.
|
||||
* Idempotent and router mode only.
|
||||
*/
|
||||
/**
|
||||
* Load, unload and download only exist on the local server. Make it the
|
||||
* target first when the action comes from a row while a remote backend is
|
||||
* the selected one.
|
||||
*/
|
||||
private async ensureLocalTarget(): Promise<void> {
|
||||
if (backendsStore.active.id === LOCAL_BACKEND_ID) return;
|
||||
|
||||
backendsStore.setActive(LOCAL_BACKEND_ID);
|
||||
await backendsModelsStore.ensureLoaded(LOCAL_BACKEND_ID);
|
||||
await this.host.switchBackend();
|
||||
}
|
||||
|
||||
private persistPausedDownloads(): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -103,8 +103,8 @@
|
||||
orgName: 'intel'
|
||||
}
|
||||
],
|
||||
external: [],
|
||||
loaded: loadedModels
|
||||
loaded: loadedModels,
|
||||
providers: []
|
||||
};
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
@@ -139,8 +139,8 @@
|
||||
currentModel={null}
|
||||
groups={{
|
||||
available: [],
|
||||
external: [],
|
||||
loaded: [loadedModels[0]]
|
||||
loaded: [loadedModels[0]],
|
||||
providers: []
|
||||
}}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
onSelect={handleSelect}
|
||||
@@ -156,8 +156,8 @@
|
||||
favorites={favoriteModels}
|
||||
groups={{
|
||||
available: [],
|
||||
external: [],
|
||||
loaded: []
|
||||
loaded: [],
|
||||
providers: []
|
||||
}}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
onSelect={handleSelect}
|
||||
|
||||
Reference in New Issue
Block a user