ui : switch backends from the models selector

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 19:22:47 +02:00
parent a68b59e853
commit 9dd4acb71d
7 changed files with 158 additions and 8 deletions
@@ -14,9 +14,10 @@
backend?: Backend | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSaved?: (backend: Backend) => void;
}
let { backend = null, onOpenChange, open = $bindable(false) }: Props = $props();
let { backend = null, onOpenChange, onSaved, open = $bindable(false) }: Props = $props();
let draft = $state<Backend>(createBackend());
let selectedPresetId = $state<string | null>(null);
@@ -106,6 +107,7 @@
backendsStore.addBackend(next);
}
onSaved?.(next);
handleOpenChange(false);
}
@@ -0,0 +1,43 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import type { Backend } from '$lib/types';
interface Props {
activeId: string;
backends: Backend[];
onAdd: () => void;
onSelect: (backendId: string) => void;
}
let { activeId, backends, onAdd, onSelect }: Props = $props();
</script>
<div class="flex items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-2">
{#each backends as backend (backend.id)}
<button
class={[
'shrink-0 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'
]}
onclick={() => onSelect(backend.id)}
type="button"
>
{backend.name}
</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>
@@ -5,10 +5,12 @@
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
ModelsSelectorBackendSwitcher,
ModelsSelectorList,
ModelsSelectorOption,
ModelsSelectorReasoningPanel
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import type { ModelItem } from '$lib/components/app/navigation/utils';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -43,6 +45,7 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -123,6 +126,13 @@
highlightedId = null;
}
function handleAddBackend() {
isOpen = false;
// let the menu finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
@@ -165,13 +175,13 @@
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
{#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.isRouter}
{:else if ms.options.length === 0 && ms.isMultiModel}
{#if currentModel}
<span
class={[
@@ -197,7 +207,7 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
{#if ms.isMultiModel}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger>
@@ -263,6 +273,13 @@
class="w-full md:min-w-80 md:max-w-[26rem] max-w-[calc(100vw-2rem)] p-0!"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<ModelsSelectorBackendSwitcher
activeId={ms.activeBackendId}
backends={ms.backends}
onAdd={handleAddBackend}
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
/>
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
@@ -407,3 +424,8 @@
open={ms.showModelDialog}
/>
{/if}
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.handleBackendChange(backend.id)}
/>
@@ -4,10 +4,12 @@
import {
DialogModelInformation,
ModelId,
ModelsSelectorBackendSwitcher,
ModelsSelectorList,
ModelsSelectorReasoningPanel,
SearchInput
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import * as Sheet from '$lib/components/ui/sheet';
import { ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
@@ -36,6 +38,7 @@
}: Props = $props();
let sheetOpen = $state(false);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -57,15 +60,22 @@
ms.handleOpenChange(false);
}
}
function handleAddBackend() {
sheetOpen = false;
// let the sheet finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
{#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.isRouter}
{:else if ms.options.length === 0 && ms.isMultiModel}
<p class="text-xs text-muted-foreground">No models available.</p>
{:else}
{@const selectedOption = ms.getDisplayOption()}
@@ -81,7 +91,7 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
{#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`,
@@ -139,6 +149,13 @@
</Sheet.Header>
<div class="flex flex-col gap-1 pb-4">
<ModelsSelectorBackendSwitcher
activeId={ms.activeBackendId}
backends={ms.backends}
onAdd={handleAddBackend}
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
/>
<div class="mb-3 px-4">
<SearchInput
onInput={(v) => ms.setSearchTerm(v)}
@@ -219,3 +236,8 @@
open={ms.showModelDialog}
/>
{/if}
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.handleBackendChange(backend.id)}
/>
@@ -46,6 +46,8 @@
*/
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
export { default as ModelsSelectorBackendSwitcher } from './ModelsSelectorBackendSwitcher.svelte';
/**
* **ModelsSelectorList** - Grouped model options list
*
@@ -1,6 +1,7 @@
import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { modelsStore, serverStore } from '$lib/stores';
import { backendsStore, modelsStore, serverStore } from '$lib/stores';
import type { Backend } from '$lib/types';
import type { ModelOption } from '$lib/types/models';
import { onMount } from 'svelte';
@@ -18,6 +19,9 @@ export interface UseModelsSelectorReturn {
readonly loading: boolean;
readonly updating: boolean;
readonly activeId: string | null;
readonly activeBackendId: string;
readonly backends: Backend[];
readonly isMultiModel: boolean;
readonly isRouter: boolean;
readonly serverModel: string | null;
readonly isHighlightedCurrentModelActive: boolean;
@@ -31,6 +35,7 @@ export interface UseModelsSelectorReturn {
setSearchTerm(value: string): 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;
@@ -55,6 +60,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const loading = $derived(modelsStore.loading);
const updating = $derived(modelsStore.updating);
const activeId = $derived(modelsStore.selectedModelId);
const activeBackendId = $derived(backendsStore.active.id);
const backends = $derived(backendsStore.enabled);
// Router mode and external backends both expose a selectable model list;
// a single-model llama.cpp server does not.
const isMultiModel = $derived(serverStore.isRouterMode || !serverStore.capabilities.props);
const isRouter = $derived(serverStore.isRouterMode);
const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
@@ -113,6 +123,15 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
}
async function handleBackendChange(backendId: string) {
if (backendId === backendsStore.active.id) return;
backendsStore.setActive(backendId);
searchTerm = '';
await modelsStore.switchBackend();
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
@@ -152,6 +171,16 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
// External backend: the selection is backend-scoped, so it wins over
// the conversation's model, which may belong to another backend.
if (!serverStore.capabilities.props) {
const selected = activeId ? options.find((option) => option.id === activeId) : undefined;
if (selected) return selected;
return currentModel ? options.find((option) => option.model === currentModel) : undefined;
}
const displayModel = serverModel || currentModel;
if (displayModel) {
@@ -187,10 +216,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
return {
get activeBackendId() {
return activeBackendId;
},
get activeId() {
return activeId;
},
get backends() {
return backends;
},
get filteredOptions() {
return filteredOptions;
},
@@ -201,6 +238,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return groupedFilteredOptions;
},
handleBackendChange,
handleInfoClick,
handleOpenChange,
@@ -227,6 +266,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return isLoadingModel;
},
get isMultiModel() {
return isMultiModel;
},
get isRouter() {
return isRouter;
},
@@ -331,6 +331,22 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
/**
* Drop per-backend state when the active backend changes. The model list,
* router rows and selection all belong to the previous backend, so they
* must not leak into the next one. Refetches for the new backend.
*/
async switchBackend(): Promise<void> {
this.status.unsubscribe();
this.clearSelection();
this.models = [];
this.routerModels = [];
this.error = null;
serverStore.clear();
await this.fetch(true);
}
toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();