ui : add backend management settings UI

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 8d6bc71ebd
commit ca02ab35a1
8 changed files with 526 additions and 3 deletions
@@ -0,0 +1,89 @@
<script lang="ts">
import { Pencil, Server, Trash2 } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app/dialogs';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import type { Backend, BackendProtocol } from '$lib/types';
const PROTOCOL_LABELS: Record<BackendProtocol, string> = {
anthropic: 'Anthropic',
'llama.cpp': 'llama.cpp',
openai: 'OpenAI'
};
interface Props {
backend: Backend;
isLocal?: boolean;
onDelete?: () => void;
onEdit?: () => void;
onToggle?: (enabled: boolean) => void;
}
let { backend, isLocal = false, onDelete, onEdit, onToggle }: Props = $props();
let showDelete = $state(false);
let protocolLabel = $derived(PROTOCOL_LABELS[backend.protocol]);
let displayUrl = $derived(backend.baseUrl || 'This server');
</script>
<Card.Root class="!gap-3 bg-muted/30 p-4">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<Server class={ICON_CLASS_DEFAULT} />
<div class="min-w-0">
<p class="truncate text-sm font-medium">{backend.name}</p>
<p class="truncate text-xs text-muted-foreground">{displayUrl}</p>
</div>
</div>
<span class="shrink-0 rounded-md border px-2 py-0.5 text-[0.7rem] text-muted-foreground">
{protocolLabel}
</span>
</div>
<div class="flex items-center justify-between gap-2">
{#if isLocal}
<span class="text-xs text-muted-foreground">Built-in</span>
{:else}
<div class="flex items-center gap-2">
<Switch checked={backend.enabled} onCheckedChange={(value) => onToggle?.(value)} />
<span class="text-xs text-muted-foreground">
{backend.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div class="flex items-center gap-1">
<Button aria-label="Edit backend" onclick={() => onEdit?.()} size="sm" variant="ghost">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Delete backend"
onclick={() => (showDelete = true)}
size="sm"
variant="ghost"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
{/if}
</div>
</Card.Root>
<DialogConfirmation
bind:open={showDelete}
confirmText="Delete"
description="This removes the backend and its stored API key."
onCancel={() => (showDelete = false)}
onConfirm={() => {
showDelete = false;
onDelete?.();
}}
title="Delete backend?"
variant="destructive"
/>
@@ -0,0 +1,152 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { DEFAULT_BACKEND_CHAT_PATH, DEFAULT_BACKEND_MODELS_PATH } from '$lib/constants';
import type { Backend, BackendProtocol } from '$lib/types';
const PROTOCOL_OPTIONS: Array<{ label: string; value: BackendProtocol }> = [
{ label: 'OpenAI-compatible', value: 'openai' },
{ label: 'Anthropic-compatible', value: 'anthropic' },
{ label: 'llama.cpp (llama-server)', value: 'llama.cpp' }
];
interface Props {
backend: Backend;
id: string;
onChange: (patch: Partial<Backend>) => void;
urlError?: string | null;
}
let { backend, id, onChange, urlError = null }: Props = $props();
let showAdvanced = $state(false);
let protocolLabel = $derived(
PROTOCOL_OPTIONS.find((option) => option.value === backend.protocol)?.label ?? ''
);
let isAnthropic = $derived(backend.protocol === 'anthropic');
</script>
<div class="grid gap-2">
<div>
<label class="mb-2 block text-xs font-medium select-none" for="backend-url-{id}">
Base URL <span class="text-destructive">*</span>
</label>
<Input
class={urlError ? 'border-destructive' : ''}
id="backend-url-{id}"
oninput={(e) => onChange({ baseUrl: e.currentTarget.value })}
placeholder="https://api.example.com"
type="url"
value={backend.baseUrl}
/>
{#if urlError}
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
{/if}
</div>
<div>
<label class="mb-2 block text-xs font-medium select-none" for="backend-name-{id}">
Display name
</label>
<Input
id="backend-name-{id}"
oninput={(e) => onChange({ name: e.currentTarget.value })}
placeholder="Name shown in the model selector"
type="text"
value={backend.name}
/>
</div>
<div>
<span class="mb-2 block text-xs font-medium select-none">API format</span>
<Select.Root
onValueChange={(value) => onChange({ protocol: value as BackendProtocol })}
type="single"
value={backend.protocol}
>
<Select.Trigger class="w-full">{protocolLabel}</Select.Trigger>
<Select.Content>
{#each PROTOCOL_OPTIONS as option (option.value)}
<Select.Item label={option.label} value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div>
<label class="mb-2 block text-xs font-medium select-none" for="backend-key-{id}">
API key
</label>
<Input
autocomplete="off"
id="backend-key-{id}"
oninput={(e) => onChange({ apiKey: e.currentTarget.value || undefined })}
placeholder="Optional"
type="password"
value={backend.apiKey ?? ''}
/>
<p class="mt-1.5 text-xs text-muted-foreground">
{#if isAnthropic}
Sent as the x-api-key header.
{:else}
Sent as a Bearer token.
{/if}
</p>
</div>
<Collapsible.Root bind:open={showAdvanced}>
<Collapsible.Trigger
class="flex items-center gap-1 text-xs font-medium text-muted-foreground select-none hover:text-foreground"
>
{#if showAdvanced}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
Advanced
</Collapsible.Trigger>
<Collapsible.Content>
<div class="mt-3 grid gap-4">
<div>
<label class="mb-2 block text-xs font-medium select-none" for="backend-chat-path-{id}">
Chat completions path
</label>
<Input
id="backend-chat-path-{id}"
oninput={(e) => onChange({ chatPath: e.currentTarget.value || undefined })}
placeholder={DEFAULT_BACKEND_CHAT_PATH}
type="text"
value={backend.chatPath ?? ''}
/>
</div>
<div>
<label class="mb-2 block text-xs font-medium select-none" for="backend-models-path-{id}">
Models path
</label>
<Input
id="backend-models-path-{id}"
oninput={(e) => onChange({ modelsPath: e.currentTarget.value || undefined })}
placeholder={DEFAULT_BACKEND_MODELS_PATH}
type="text"
value={backend.modelsPath ?? ''}
/>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
@@ -0,0 +1,191 @@
<script lang="ts">
import BackendForm from './BackendForm.svelte';
import { CheckCircle2, Loader2, XCircle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { BACKEND_ID_PREFIX, BACKEND_PRESETS } from '$lib/constants';
import { BackendsService } from '$lib/services';
import type { BackendTestResult } from '$lib/services/backends.service';
import { backendsStore } from '$lib/stores';
import type { Backend, BackendPreset } from '$lib/types';
import { uuid } from '$lib/utils';
interface Props {
backend?: Backend | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { backend = null, onOpenChange, open = $bindable(false) }: Props = $props();
let draft = $state<Backend>(createBackend());
let selectedPresetId = $state<string | null>(null);
let testResult = $state<BackendTestResult | null>(null);
let testing = $state(false);
let isEdit = $derived(backend !== null);
let urlError = $derived.by(() => {
const url = draft.baseUrl.trim();
if (!url) return 'Base URL is required';
try {
new URL(url);
return null;
} catch {
return 'Invalid URL format';
}
});
let canSave = $derived(!urlError && draft.name.trim().length > 0);
// reset the draft each time the dialog opens
$effect(() => {
if (!open) return;
draft = backend ? { ...backend } : createBackend();
selectedPresetId = null;
testResult = null;
testing = false;
});
function createBackend(): Backend {
return {
baseUrl: '',
enabled: true,
id: uuid() || `${BACKEND_ID_PREFIX}-${Date.now()}`,
name: '',
protocol: 'openai'
};
}
function applyPreset(preset: BackendPreset) {
selectedPresetId = preset.id;
draft = {
...draft,
baseUrl: preset.baseUrl,
chatPath: preset.chatPath,
modelsPath: preset.modelsPath,
name: preset.id === 'custom' ? '' : preset.name,
protocol: preset.protocol
};
testResult = null;
}
function handleChange(patch: Partial<Backend>) {
draft = { ...draft, ...patch };
testResult = null;
}
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
async function handleTest() {
if (urlError) return;
testing = true;
testResult = null;
try {
testResult = await BackendsService.test(draft);
} finally {
testing = false;
}
}
function handleSave() {
if (!canSave) return;
const next: Backend = { ...draft, name: draft.name.trim() || hostOf(draft.baseUrl) };
if (isEdit && backend) {
backendsStore.updateBackend(backend.id, next);
} else {
backendsStore.addBackend(next);
}
handleOpenChange(false);
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
handleSave();
}
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content class="max-w-2xl!">
<Dialog.Header>
<Dialog.Title>{isEdit ? 'Edit backend' : 'Add backend'}</Dialog.Title>
<Dialog.Description>Connect an OpenAI- or Anthropic-compatible endpoint.</Dialog.Description>
</Dialog.Header>
{#if !isEdit}
<div class="grid grid-cols-2 gap-2 pt-2 sm:grid-cols-3">
{#each BACKEND_PRESETS as preset (preset.id)}
<Button
class="justify-start"
onclick={() => applyPreset(preset)}
size="sm"
variant={selectedPresetId === preset.id ? 'secondary' : 'outline'}
>
{preset.name}
</Button>
{/each}
</div>
{/if}
<form class="contents" onsubmit={handleSubmit}>
<div class="py-4">
<BackendForm backend={draft} id="backend" onChange={handleChange} {urlError} />
</div>
{#if testing || testResult}
<div class="flex items-center gap-2 pb-2 text-xs">
{#if testing}
<Loader2 class="h-3.5 w-3.5 shrink-0 animate-spin" />
<span class="text-muted-foreground">Testing connection...</span>
{:else if testResult?.ok}
<CheckCircle2 class="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<span class="text-muted-foreground">
Connected.
{testResult.modelCount ?? 0}
model{(testResult.modelCount ?? 0) === 1 ? '' : 's'} available.
</span>
{:else}
<XCircle class="h-3.5 w-3.5 shrink-0 text-destructive" />
<span class="text-destructive">{testResult?.error ?? 'Connection failed'}</span>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button onclick={() => handleOpenChange(false)} size="sm" variant="secondary">
Cancel
</Button>
<Button disabled={testing} onclick={handleTest} size="sm" type="button" variant="outline">
Test connection
</Button>
<Button disabled={!canSave} size="sm" type="submit">
{isEdit ? 'Save' : 'Add'}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,71 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { BackendCard, DialogBackendForm } from '$lib/components/app/backends';
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
import { backendsStore } from '$lib/stores';
import type { Backend } from '$lib/types';
import { fade } from 'svelte/transition';
interface Props {
class?: string;
}
let { class: className }: Props = $props();
let isAdding = $state(false);
let editing = $state<Backend | null>(null);
function handleAdd() {
editing = null;
isAdding = true;
}
function handleEdit(backend: Backend) {
editing = backend;
isAdding = true;
}
function handleOpenChange(open: boolean) {
isAdding = open;
if (!open) {
editing = null;
}
}
</script>
<div in:fade={{ duration: 150 }} class={['flex flex-col gap-4', className]}>
<DialogBackendForm bind:open={isAdding} backend={editing} onOpenChange={handleOpenChange} />
<BackendCard backend={backendsStore.local} isLocal />
{#each backendsStore.external as backend (backend.id)}
<BackendCard
{backend}
onDelete={() => backendsStore.removeBackend(backend.id)}
onEdit={() => handleEdit(backend)}
onToggle={(enabled) => backendsStore.updateBackend(backend.id, { enabled })}
/>
{/each}
<Empty.Root class="border">
<Empty.Header>
<Empty.Media variant="icon">
<Plus />
</Empty.Media>
<Empty.Title>Add another backend</Empty.Title>
<Empty.Description>Connect an OpenAI- or Anthropic-compatible endpoint.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button onclick={handleAdd} size="sm">
<Plus />
Add Backend
</Button>
</Empty.Content>
</Empty.Root>
</div>
@@ -0,0 +1,4 @@
export { default as BackendCard } from './BackendCard.svelte';
export { default as BackendForm } from './BackendForm.svelte';
export { default as DialogBackendForm } from './DialogBackendForm.svelte';
export { default as SettingsBackends } from './SettingsBackends.svelte';
+1
View File
@@ -1,4 +1,5 @@
export * from './actions';
export * from './backends';
export * from './badges';
export * from './chat';
export * from './content';
@@ -1,5 +1,6 @@
<script lang="ts">
import { RefreshCw } from '@lucide/svelte';
import { SettingsBackends } from '$lib/components/app/backends';
import {
SettingsChatDesktopSidebar,
SettingsChatFields,
@@ -151,6 +152,8 @@
<SettingsChatToolsTab />
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
<SettingsChatImportExportTab />
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.BACKENDS}
<SettingsBackends />
{:else if currentSection.fields}
<div class="space-y-6">
<SettingsChatFields
@@ -11,6 +11,7 @@ import {
Monitor,
Moon,
PencilRuler,
Server,
SlidersVertical,
Sun
} from '@lucide/svelte';
@@ -28,6 +29,7 @@ import type {
/** Settings sections — slug is the routing identity, title is the display label. */
export const SETTINGS_SECTIONS = {
AGENTIC: { slug: 'agentic', title: 'Agentic' },
BACKENDS: { slug: 'backends', title: 'Backends' },
DEVELOPER: { slug: 'developer', title: 'Developer' },
DISPLAY: { slug: 'display', title: 'Display' },
GENERAL: { slug: 'general', title: 'General' },
@@ -38,6 +40,7 @@ export const SETTINGS_SECTIONS = {
export const SETTINGS_SECTION_SLUGS = {
AGENTIC: SETTINGS_SECTIONS.AGENTIC.slug,
BACKENDS: SETTINGS_SECTIONS.BACKENDS.slug,
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.slug,
DISPLAY: SETTINGS_SECTIONS.DISPLAY.slug,
GENERAL: SETTINGS_SECTIONS.GENERAL.slug,
@@ -48,6 +51,7 @@ export const SETTINGS_SECTION_SLUGS = {
export const SETTINGS_SECTION_TITLES = {
AGENTIC: SETTINGS_SECTIONS.AGENTIC.title,
BACKENDS: SETTINGS_SECTIONS.BACKENDS.title,
DEVELOPER: SETTINGS_SECTIONS.DEVELOPER.title,
DISPLAY: SETTINGS_SECTIONS.DISPLAY.title,
GENERAL: SETTINGS_SECTIONS.GENERAL.title,
@@ -188,7 +192,15 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION,
label: 'Maximum image resolution (megapixels)',
type: SettingsFieldType.INPUT
}
],
slug: SETTINGS_SECTION_SLUGS.GENERAL,
title: SETTINGS_SECTION_TITLES.GENERAL
},
// Backends (non-UI config object)
{
icon: Server,
settings: [
{
defaultValue: '[]',
help: 'Configure external API backends as a JSON list. The local backend is always available.',
@@ -198,8 +210,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
type: SettingsFieldType.INPUT
}
],
slug: SETTINGS_SECTION_SLUGS.GENERAL,
title: SETTINGS_SECTION_TITLES.GENERAL
slug: SETTINGS_SECTION_SLUGS.BACKENDS,
title: SETTINGS_SECTION_TITLES.BACKENDS
},
// Display
{