mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
ui : add a favorites tab to the model selector
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
+27
-8
@@ -1,28 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Plus } from '@lucide/svelte';
|
||||
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, onAdd, onSelect }: Props = $props();
|
||||
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={[
|
||||
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
|
||||
activeId === backend.id
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||||
]}
|
||||
class={tabClass(activeId === backend.id)}
|
||||
onclick={() => onSelect(backend.id)}
|
||||
type="button"
|
||||
>
|
||||
|
||||
+13
-8
@@ -89,12 +89,12 @@
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const item of ms.favoriteItems) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.external) order.push(item.option.id);
|
||||
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);
|
||||
|
||||
return order;
|
||||
});
|
||||
@@ -277,20 +277,22 @@
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
emptyMessage={ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
|
||||
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchClass="bg-transparent"
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<!-- Provider tabs sit under the search input, above the option list. -->
|
||||
<!-- Provider tabs (favorites first), above the option list. -->
|
||||
<ModelsSelectorBackendSwitcher
|
||||
activeId={ms.activeBackendId}
|
||||
activeId={ms.viewId}
|
||||
backends={ms.backends}
|
||||
favoritesActive={ms.isFavoritesView}
|
||||
onAdd={handleAddBackend}
|
||||
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
|
||||
onSelectFavorites={ms.showFavorites}
|
||||
/>
|
||||
|
||||
<!-- Option list; the search header sticks to the top and the actions
|
||||
@@ -312,8 +314,10 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">
|
||||
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean)}
|
||||
@@ -344,6 +348,7 @@
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
|
||||
@@ -14,11 +14,14 @@
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
/** Favorite models of every backend; shown on the favorites tab. */
|
||||
favorites?: ModelItem[];
|
||||
}
|
||||
|
||||
let {
|
||||
activeId,
|
||||
currentModel,
|
||||
favorites = [],
|
||||
groups,
|
||||
onInfoClick,
|
||||
onSelect,
|
||||
@@ -61,13 +64,10 @@
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.favorites.length > 0}
|
||||
<p class={sectionHeaderClass}>Favorite models</p>
|
||||
|
||||
{#each groups.favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- Favorites tab: rows only, the tab already names the view. -->
|
||||
{#each favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
|
||||
{#if getDownloadEntries.length > 0}
|
||||
<p class={sectionHeaderClass}>Download in progress</p>
|
||||
|
||||
@@ -157,12 +157,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Provider tabs sit under the search input, above the option list. -->
|
||||
<!-- Provider tabs (favorites first), above the option list. -->
|
||||
<ModelsSelectorBackendSwitcher
|
||||
activeId={ms.activeBackendId}
|
||||
activeId={ms.viewId}
|
||||
backends={ms.backends}
|
||||
favoritesActive={ms.isFavoritesView}
|
||||
onAdd={handleAddBackend}
|
||||
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
|
||||
onSelectFavorites={ms.showFavorites}
|
||||
/>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
@@ -182,13 +184,16 @@
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface OrgGroup {
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
loaded: ModelItem[];
|
||||
favorites: ModelItem[];
|
||||
available: OrgGroup[];
|
||||
/** Models served by other backends; rendered flat, without categories. */
|
||||
external: ModelItem[];
|
||||
@@ -52,9 +51,27 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorite models across every backend, in list order. The favorites tab spans
|
||||
* all backends, so they are collected from the full option list.
|
||||
*/
|
||||
export function groupFavoriteOptions(
|
||||
options: ModelOption[],
|
||||
favoriteIds: Set<string>
|
||||
): ModelItem[] {
|
||||
const favorites: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (favoriteIds.has(options[i].model)) {
|
||||
favorites.push({ flatIndex: i, option: options[i] });
|
||||
}
|
||||
}
|
||||
|
||||
return favorites;
|
||||
}
|
||||
|
||||
export function groupModelOptions(
|
||||
filteredOptions: ModelOption[],
|
||||
favoriteIds: Set<string>,
|
||||
isModelLoaded: (model: string) => boolean,
|
||||
isLocalModel: (option: ModelOption) => boolean = () => true
|
||||
): GroupedModelOptions {
|
||||
@@ -62,25 +79,15 @@ export function groupModelOptions(
|
||||
const loaded: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (isModelLoaded(filteredOptions[i].model)) {
|
||||
loaded.push({ flatIndex: i, option: filteredOptions[i] });
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (isModelLoaded(option.model)) {
|
||||
loaded.push({ flatIndex: i, option });
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites (excluding loaded)
|
||||
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
|
||||
const favorites: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (
|
||||
favoriteIds.has(filteredOptions[i].model) &&
|
||||
!loadedModelIds.has(filteredOptions[i].model)
|
||||
) {
|
||||
favorites.push({ flatIndex: i, option: filteredOptions[i] });
|
||||
}
|
||||
}
|
||||
|
||||
// Available models grouped by org (excluding loaded and favorites)
|
||||
// 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[] = [];
|
||||
@@ -89,7 +96,7 @@ export function groupModelOptions(
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
|
||||
if (loadedModelIds.has(option.model)) continue;
|
||||
|
||||
if (!isLocalModel(option)) {
|
||||
external.push({ flatIndex: i, option });
|
||||
@@ -108,5 +115,5 @@ export function groupModelOptions(
|
||||
available.push({ items, orgName: orgName || null });
|
||||
}
|
||||
|
||||
return { available, external, favorites, loaded };
|
||||
return { available, external, loaded };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ 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';
|
||||
|
||||
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
||||
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR, LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import type { ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import {
|
||||
filterModelOptions,
|
||||
groupFavoriteOptions,
|
||||
groupModelOptions
|
||||
} from '$lib/components/app/navigation/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR, FAVORITES_TAB_ID, LOCAL_BACKEND_ID } 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: [] };
|
||||
|
||||
export interface UseModelsSelectorOptions {
|
||||
currentModel: () => string | null;
|
||||
useGlobalSelection?: () => boolean;
|
||||
@@ -27,13 +35,18 @@ export interface UseModelsSelectorReturn {
|
||||
readonly serverModel: string | null;
|
||||
readonly isHighlightedCurrentModelActive: boolean;
|
||||
readonly isCurrentModelInCache: boolean;
|
||||
readonly favoriteItems: ModelItem[];
|
||||
readonly filteredOptions: ModelOption[];
|
||||
readonly isFavoritesView: boolean;
|
||||
readonly isEmpty: 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;
|
||||
setSearchTerm(value: string): void;
|
||||
setShowModelDialog(value: boolean): void;
|
||||
handleInfoClick(modelName: string): void;
|
||||
@@ -52,6 +65,9 @@ export interface UseModelsSelectorReturn {
|
||||
* duplicating store derivations, selection handling, and model loading.
|
||||
*/
|
||||
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
|
||||
/** Tab the selector shows: a backend id, or the favorites pseudo tab. */
|
||||
let viewId = $state<string>(backendsStore.active.id);
|
||||
|
||||
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
|
||||
@@ -62,8 +78,12 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return modelProps?.ui !== false;
|
||||
})
|
||||
);
|
||||
// the switcher tabs scope the rendered list to one backend's models
|
||||
const options = $derived(allOptions.filter((option) => option.backendId === activeBackendId));
|
||||
// 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 loading = $derived(modelsStore.loading);
|
||||
const updating = $derived(modelsStore.updating);
|
||||
const activeId = $derived(modelsStore.selectedModelId);
|
||||
@@ -98,13 +118,21 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
let infoModelId = $state<string | null>(null);
|
||||
|
||||
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
|
||||
// favorites span every backend, so they come from the full option list
|
||||
const favoriteItems = $derived(
|
||||
groupFavoriteOptions(filterModelOptions(allOptions, searchTerm), modelsStore.favoriteModelIds)
|
||||
);
|
||||
const groupedFilteredOptions = $derived(
|
||||
groupModelOptions(
|
||||
filteredOptions,
|
||||
modelsStore.favoriteModelIds,
|
||||
(m) => modelsStore.isModelLoaded(m),
|
||||
(option) => option.backendId === LOCAL_BACKEND_ID
|
||||
)
|
||||
isFavoritesView
|
||||
? EMPTY_GROUPS
|
||||
: groupModelOptions(
|
||||
filteredOptions,
|
||||
(m) => modelsStore.isModelLoaded(m),
|
||||
(option) => option.backendId === LOCAL_BACKEND_ID
|
||||
)
|
||||
);
|
||||
const isEmpty = $derived(
|
||||
isFavoritesView ? favoriteItems.length === 0 : filteredOptions.length === 0
|
||||
);
|
||||
|
||||
function handleInfoClick(modelName: string) {
|
||||
@@ -139,14 +167,16 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
}
|
||||
|
||||
async function handleBackendChange(backendId: string) {
|
||||
viewId = backendId;
|
||||
searchTerm = '';
|
||||
|
||||
if (backendId === backendsStore.active.id) return;
|
||||
|
||||
backendsStore.setActive(backendId);
|
||||
searchTerm = '';
|
||||
|
||||
// keep the multi-model selector mounted while the new backend's props and
|
||||
// models load; role flips (external MODEL mode -> local ROUTER mode) would
|
||||
// otherwise unmount and remount the open dropdown mid switch
|
||||
// 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;
|
||||
|
||||
try {
|
||||
@@ -160,7 +190,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
}
|
||||
|
||||
async function handleSelect(modelId: string) {
|
||||
const option = options.find((opt) => opt.id === modelId);
|
||||
// favorites live above the tabs and may belong to another backend, so the
|
||||
// lookup spans every enabled backend
|
||||
const option = allOptions.find((opt) => opt.id === modelId);
|
||||
|
||||
if (!option) return;
|
||||
|
||||
@@ -257,6 +289,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return backends;
|
||||
},
|
||||
|
||||
get favoriteItems() {
|
||||
return favoriteItems;
|
||||
},
|
||||
|
||||
get filteredOptions() {
|
||||
return filteredOptions;
|
||||
},
|
||||
@@ -283,10 +319,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return isCurrentModelInCache;
|
||||
},
|
||||
|
||||
get isEmpty() {
|
||||
return isEmpty;
|
||||
},
|
||||
|
||||
isFavorite(model: string) {
|
||||
return modelsStore.favoriteModelIds.has(model);
|
||||
},
|
||||
|
||||
get isFavoritesView() {
|
||||
return isFavoritesView;
|
||||
},
|
||||
|
||||
get isHighlightedCurrentModelActive() {
|
||||
return isHighlightedCurrentModelActive;
|
||||
},
|
||||
@@ -327,6 +371,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
showModelDialog = value;
|
||||
},
|
||||
|
||||
showFavorites() {
|
||||
viewId = FAVORITES_TAB_ID;
|
||||
searchTerm = '';
|
||||
},
|
||||
|
||||
get showModelDialog() {
|
||||
return showModelDialog;
|
||||
},
|
||||
@@ -337,6 +386,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
|
||||
get updating() {
|
||||
return updating;
|
||||
},
|
||||
|
||||
get viewId() {
|
||||
return viewId;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@
|
||||
}
|
||||
],
|
||||
external: [],
|
||||
favorites: favoriteModels,
|
||||
loaded: loadedModels
|
||||
};
|
||||
|
||||
@@ -125,6 +124,7 @@
|
||||
<ModelsSelectorList
|
||||
{activeId}
|
||||
currentModel={selectedModel}
|
||||
favorites={favoriteModels}
|
||||
groups={groupedOptions}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
onSelect={handleSelect}
|
||||
@@ -140,7 +140,6 @@
|
||||
groups={{
|
||||
available: [],
|
||||
external: [],
|
||||
favorites: [],
|
||||
loaded: [loadedModels[0]]
|
||||
}}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
@@ -154,10 +153,10 @@
|
||||
<ModelsSelectorList
|
||||
activeId={null}
|
||||
currentModel={null}
|
||||
favorites={favoriteModels}
|
||||
groups={{
|
||||
available: [],
|
||||
external: [],
|
||||
favorites: favoriteModels,
|
||||
loaded: []
|
||||
}}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
|
||||
Reference in New Issue
Block a user