mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
ui : switch backends from the models selector
Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+43
@@ -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>
|
||||
+25
-3
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user