ui : add the manage providers view

A provider is added from a preset card or by hand: the dialog probes the
endpoint, reads a refusal as a llama.cpp server that wants a key, and
shows the connection test as a status block. Presets carry their official
artwork and a saved backend keeps its branding through the favicon
fallback. The manager dialog gains its third view, and the settings save
stops clobbering keys that other surfaces write.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-10-02 11:59:25 +02:00
parent 742fe79906
commit cc229b4b6d
30 changed files with 1228 additions and 9 deletions
@@ -0,0 +1,163 @@
<script lang="ts">
import BackendIcon from './BackendIcon.svelte';
import { Pencil, RotateCw, Server, Trash2 } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app/dialogs';
import Logo from '$lib/components/app/misc/Logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { backendsModelsStore } from '$lib/stores/backendsModels.svelte';
import type { Backend, BackendProtocol } from '$lib/types';
const PROTOCOL_LABELS: Record<BackendProtocol, string> = {
'llama.cpp': 'Llama-compatible',
openai: 'OpenAI-compatible'
};
const CARD_ICON_CLASS = 'h-5 w-5';
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');
// the model listing doubles as the reachability probe
let modelsState = $derived(backendsModelsStore.get(backend.id));
let isChecking = $derived(backend.enabled && !modelsState.loaded && !modelsState.error);
let isOnline = $derived(backend.enabled && modelsState.loaded && !modelsState.error);
let isOffline = $derived(backend.enabled && !!modelsState.error);
let hasStatus = $derived(isChecking || isOnline || isOffline);
let modelCount = $derived(modelsState.models.length);
let metaText = $derived(isLocal ? `Built-in · ${protocolLabel}` : protocolLabel);
// kick off the probe for backends the prefetch has not covered yet
$effect(() => {
if (isChecking && !modelsState.loading) {
void backendsModelsStore.ensureLoaded(backend.id);
}
});
function handleRetry() {
backendsModelsStore.clear(backend.id);
void backendsModelsStore.ensureLoaded(backend.id);
}
</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">
<BackendIcon {backend} class={CARD_ICON_CLASS}>
{#snippet fallback()}
{#if isLocal}
<Logo class={CARD_ICON_CLASS} style="--size: 1.25rem" />
{:else}
<Server class={CARD_ICON_CLASS} />
{/if}
{/snippet}
</BackendIcon>
<div class="min-w-0">
<p class="truncate leading-5 font-medium">{backend.name}</p>
<p class="truncate text-xs text-muted-foreground">{displayUrl}</p>
</div>
</div>
<Switch
aria-label={backend.enabled ? 'Disable backend' : 'Enable backend'}
checked={backend.enabled}
onCheckedChange={(value) => onToggle?.(value)}
/>
</div>
{#if isOffline && modelsState.error}
<Tooltip.Root>
<Tooltip.Trigger>
<p class="max-w-full truncate text-left text-xs text-destructive">{modelsState.error}</p>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="max-w-80 break-words">{modelsState.error}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<div class="mt-auto flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
{#if isChecking}
<span class="size-1.5 shrink-0 animate-pulse rounded-full bg-amber-500"></span>
<span class="shrink-0">Checking</span>
{:else if isOnline}
<span class="size-1.5 shrink-0 rounded-full bg-emerald-500"></span>
<span class="shrink-0">{modelCount === 1 ? '1 model' : `${modelCount} models`}</span>
{:else if isOffline}
<span class="size-1.5 shrink-0 rounded-full bg-destructive"></span>
<span class="shrink-0">Unreachable</span>
<button
aria-label="Retry connection"
class="shrink-0 rounded-sm p-0.5 transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground"
onclick={handleRetry}
>
<RotateCw class="h-3 w-3" />
</button>
{/if}
{#if hasStatus}
<span class="shrink-0 opacity-50">·</span>
{/if}
<span class="truncate">{metaText}</span>
</div>
{#if !isLocal}
<div class="flex shrink-0 items-center gap-1">
<Button
aria-label="Edit backend"
class="relative h-7 w-7 after:absolute after:-inset-0.5 after:content-['']"
onclick={() => onEdit?.()}
size="icon"
variant="ghost"
>
<Pencil />
</Button>
<Button
aria-label="Delete backend"
class="hover:text-destructive-foreground relative h-7 w-7 text-destructive after:absolute after:-inset-0.5 after:content-[''] hover:bg-destructive/10"
onclick={() => (showDelete = true)}
size="icon"
variant="ghost"
>
<Trash2 />
</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,190 @@
<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: 'Llama-compatible (llama-server)', value: 'llama.cpp' }
];
interface Props {
backend: Backend;
/** The endpoint refused the probe for want of a key. */
apiKeyRequired?: boolean;
/** Protocol the endpoint reported, once it has been probed. */
detected?: BackendProtocol | null;
detecting?: boolean;
id: string;
onChange: (patch: Partial<Backend>) => void;
urlError?: string | null;
}
let {
apiKeyRequired = false,
backend,
detected = null,
detecting = false,
id,
onChange,
urlError = null
}: Props = $props();
let showAdvanced = $state(false);
let protocolLabel = $derived(
PROTOCOL_OPTIONS.find((option) => option.value === backend.protocol)?.label ?? ''
);
let detectionLabel = $derived.by(() => {
if (detecting) return 'Checking what the endpoint speaks...';
if (detected === 'llama.cpp') {
return apiKeyRequired
? 'llama.cpp server detected. It asks for an API key.'
: 'llama.cpp server detected.';
}
if (detected === 'openai') return 'OpenAI-compatible endpoint detected.';
return null;
});
</script>
<div class="grid gap-5">
<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>
{:else if detectionLabel}
<p class="mt-1.5 text-xs text-muted-foreground">{detectionLabel}</p>
{/if}
</div>
<div class="grid gap-5 sm:grid-cols-2">
<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>
<label class="mb-2 block text-xs font-medium select-none" for="backend-key-{id}">
API key{#if apiKeyRequired}<span class="text-destructive"> *</span>{/if}
</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 apiKeyRequired}
Required by this endpoint.
{:else}
Sent as a Bearer token.
{/if}
</p>
</div>
</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-4 grid gap-5">
<div>
<span class="mb-2 block text-xs font-medium select-none">API format</span>
<Select.Root
onValueChange={(value) =>
onChange({ compat: undefined, 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>
<p class="mt-1.5 text-xs text-muted-foreground">
Detected from the endpoint. Override it when a server answers unusually.
</p>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<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>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import BackendPresetIcon from './BackendPresetIcon.svelte';
import type { Backend } from '$lib/types';
import { backendFaviconUrl, findBackendPreset } from '$lib/utils';
import type { Snippet } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
/** Favicons that failed to load, so a row remount does not ask again. */
const failedFavicons = new SvelteSet<string>();
interface Props {
backend?: Backend;
class?: string;
/** Rendered when the backend has neither a bundled mark nor a favicon. */
fallback?: Snippet;
}
let { backend, class: className = 'size-4', fallback }: Props = $props();
let preset = $derived(backend ? findBackendPreset(backend.baseUrl) : undefined);
let faviconUrl = $derived(preset || !backend ? null : backendFaviconUrl(backend.baseUrl));
</script>
{#if preset}
<BackendPresetIcon class={className} {preset} />
{:else if faviconUrl && !failedFavicons.has(faviconUrl)}
<img
alt=""
class={['shrink-0 rounded-sm object-contain', className]}
decoding="async"
loading="lazy"
onerror={() => failedFavicons.add(faviconUrl)}
src={faviconUrl}
/>
{:else}
{@render fallback?.()}
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import BackendPresetIcon from './BackendPresetIcon.svelte';
import { Check } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import * as Card from '$lib/components/ui/card';
import type { BackendPreset } from '$lib/types';
interface Props {
preset: BackendPreset;
onClick?: () => void;
selected?: boolean;
dimmed?: boolean;
/** A backend already points at this provider. */
added?: boolean;
}
let { added = false, dimmed = false, onClick, preset, selected = false }: Props = $props();
// an added provider is a label, not an action: no hover, no selection
let interactive = $derived(!added && Boolean(onClick));
</script>
<Card.Root
class={`relative gap-3! select-none bg-muted/30 p-4 transition-[background-color,opacity,transform] duration-150 ${interactive ? 'cursor-pointer hover:bg-muted/50 hover:opacity-100 active:scale-[0.98]' : ''} ${selected && !added ? 'bg-muted/30 ring-1 ring-primary/40' : ''} ${dimmed || added ? 'opacity-50' : ''}`}
onclick={interactive ? onClick : undefined}
>
{#if added}
<Badge class="absolute top-2 right-2 gap-1 px-1.5 text-[10px]" variant="secondary">
<Check class="h-3 w-3" />
Added
</Badge>
{/if}
<div class="flex min-w-0 items-center gap-2">
<BackendPresetIcon class="h-5 w-5" {preset} />
<h4 class="min-w-0 flex-1 truncate font-medium">{preset.name}</h4>
</div>
<p class="text-xs text-muted-foreground">{preset.description}</p>
</Card.Root>
@@ -0,0 +1,34 @@
<script lang="ts">
import { Plug } from '@lucide/svelte';
import type { BackendPreset } from '$lib/types';
import { mode } from 'mode-watcher';
interface Props {
class?: string;
preset?: BackendPreset;
}
let { class: className = 'h-4 w-4', preset }: Props = $props();
let iconUrl = $derived.by(() => {
if (!preset) return null;
return (
(mode.current === 'dark'
? (preset.iconUrlDark ?? preset.iconUrl)
: (preset.iconUrlLight ?? preset.iconUrl)) ?? null
);
});
</script>
{#if iconUrl}
<img
alt=""
class={['shrink-0 rounded-sm object-contain', className]}
decoding="async"
loading="lazy"
src={iconUrl}
/>
{:else}
<Plug class={className} />
{/if}
@@ -0,0 +1,356 @@
<script lang="ts">
import BackendForm from './BackendForm.svelte';
import BackendPresetCard from './BackendPresetCard.svelte';
import { CheckCircle2, Loader2, PlugZap, XCircle } from '@lucide/svelte';
import { browser } from '$app/environment';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import {
BACKEND_ID_PREFIX,
BACKEND_PRESETS,
DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY
} from '$lib/constants';
import { BooleanString } from '$lib/enums';
import { BackendsService } from '$lib/services';
import type { BackendTestResult } from '$lib/services/backends.service';
import { backendsStore } from '$lib/stores';
import type { Backend, BackendPreset, BackendProtocol } from '$lib/types';
import { findBackendPreset, uuid } from '$lib/utils';
import { untrack } from 'svelte';
interface Props {
backend?: Backend | null;
defaultProtocol?: BackendProtocol;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSaved?: (backend: Backend) => void;
}
let {
backend = null,
defaultProtocol = 'openai',
onOpenChange,
onSaved,
open = $bindable(false)
}: Props = $props();
let draft = $state<Backend>(createBackend());
let testResult = $state<BackendTestResult | null>(null);
let testing = $state(false);
let detected = $state<BackendProtocol | null>(null);
let detecting = $state(false);
let apiKeyRequired = $state(false);
let detectRun = 0;
// the card follows the URL, so editing any field deselects it
let selectedPresetId = $derived(findBackendPreset(draft.baseUrl)?.id ?? null);
// presets a configured backend already points at
let addedPresetIds = $derived(
backendsStore.external
.map((backend) => findBackendPreset(backend.baseUrl)?.id)
.filter((id) => id !== undefined)
);
let unconfiguredPresets = $derived(
BACKEND_PRESETS.filter((preset) => !addedPresetIds.includes(preset.id))
);
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 && (!apiKeyRequired || Boolean(draft.apiKey?.trim()))
);
// Backward-compatible read: older versions stored a JSON array of dismissed ids.
function readRecommendationsDismissed(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY);
if (!raw) return false;
if (raw === BooleanString.TRUE) return true;
if (raw === BooleanString.FALSE) return false;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && parsed.length > 0;
} catch {
return false;
}
}
function writeRecommendationsDismissed(dismissed: boolean) {
recommendationsDismissed = dismissed;
if (browser) {
localStorage.setItem(
DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY,
dismissed ? BooleanString.TRUE : BooleanString.FALSE
);
}
}
let recommendationsDismissed = $state<boolean>(readRecommendationsDismissed());
let presetsToShow = $derived(recommendationsDismissed ? [] : unconfiguredPresets);
// reset the draft each time the dialog opens
$effect(() => {
if (!open) return;
draft = backend ? { ...backend } : createBackend();
testResult = null;
testing = false;
detected = null;
apiKeyRequired = false;
});
// Once the URL settles, ask the endpoint what it speaks, the way the MCP dialog
// previews a server. A preset already knows, and the answer is applied only when
// it differs, so this cannot feed itself.
$effect(() => {
const url = draft.baseUrl.trim();
// the key is a dependency: a refused probe is retried once one is typed
const apiKey = draft.apiKey?.trim();
if (!open || isEdit || urlError || !url) {
detected = null;
detecting = false;
apiKeyRequired = false;
return;
}
const run = ++detectRun;
const timer = setTimeout(async () => {
detecting = true;
const probe = await BackendsService.detectProtocol({
...draft,
apiKey,
baseUrl: url
});
if (run !== detectRun) return;
detecting = false;
detected = probe.protocol;
apiKeyRequired = probe.authRequired;
untrack(() => {
if (probe.protocol !== draft.protocol) handleChange({ protocol: probe.protocol });
});
}, 600);
return () => clearTimeout(timer);
});
function createBackend(): Backend {
return {
baseUrl: '',
enabled: true,
id: uuid() || `${BACKEND_ID_PREFIX}-${Date.now()}`,
name: '',
protocol: defaultProtocol
};
}
function applyPreset(preset: BackendPreset) {
draft = {
...draft,
baseUrl: preset.baseUrl,
chatPath: preset.chatPath,
compat: preset.compat,
modelsPath: preset.modelsPath,
name: preset.name,
protocol: preset.protocol
};
testResult = null;
detected = null;
apiKeyRequired = false;
}
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);
}
onSaved?.(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 class="select-none">
{isEdit ? 'Edit Provider' : 'Add New Provider'}
</Dialog.Title>
<Dialog.Description>
Point at another llama-server, or connect an OpenAI-compatible endpoint.
</Dialog.Description>
</Dialog.Header>
{#if !isEdit && presetsToShow.length > 0}
<div class="space-y-3 pt-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">Recommended providers</h3>
<Button
class="text-muted-foreground"
onclick={() => writeRecommendationsDismissed(true)}
size="sm"
variant="ghost">Dismiss</Button
>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each presetsToShow as preset (preset.id)}
<BackendPresetCard
dimmed={Boolean(selectedPresetId) && selectedPresetId !== preset.id}
onClick={() => applyPreset(preset)}
{preset}
selected={selectedPresetId === preset.id}
/>
{/each}
</div>
</div>
{/if}
<form class="contents" onsubmit={handleSubmit}>
<div class="space-y-4 py-4">
<BackendForm
{apiKeyRequired}
backend={draft}
{detected}
{detecting}
id="backend"
onChange={handleChange}
{urlError}
/>
</div>
<!-- the last thing before the actions: what the endpoint said when asked -->
<div
aria-live="polite"
class="mb-3 flex items-start gap-2.5 rounded-lg border border-border/60 bg-muted/30 p-3"
>
{#if testing}
<Loader2 class="mt-0.5 h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<div class="min-w-0">
<p class="text-sm font-medium">Testing connection</p>
<p class="text-xs text-muted-foreground">
Asking {hostOf(draft.baseUrl)} for the models it serves.
</p>
</div>
{:else if testResult?.ok}
<CheckCircle2 class="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
<div class="min-w-0">
<p class="text-sm font-medium">Connected</p>
<p class="text-xs text-muted-foreground">
{testResult.modelCount ?? 0}
model{(testResult.modelCount ?? 0) === 1 ? '' : 's'} available.
</p>
</div>
{:else if testResult}
<XCircle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="min-w-0">
<p class="text-sm font-medium text-destructive">Connection failed</p>
<p class="text-xs break-words text-muted-foreground">
{testResult.error ?? 'The endpoint did not answer.'}{testResult.status
? ` (HTTP ${testResult.status})`
: ''}
</p>
</div>
{:else}
<PlugZap class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="min-w-0">
<p class="text-sm font-medium">Connection not tested</p>
<p class="text-xs text-muted-foreground">
Test it to list the models this provider serves.
</p>
</div>
{/if}
</div>
<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,6 @@
export { default as BackendCard } from './BackendCard.svelte';
export { default as BackendForm } from './BackendForm.svelte';
export { default as BackendIcon } from './BackendIcon.svelte';
export { default as BackendPresetCard } from './BackendPresetCard.svelte';
export { default as BackendPresetIcon } from './BackendPresetIcon.svelte';
export { default as DialogBackendForm } from './DialogBackendForm.svelte';
@@ -1,7 +1,8 @@
<script lang="ts">
import { ArrowLeft, Compass } from '@lucide/svelte';
import { ArrowLeft, Compass, Server } from '@lucide/svelte';
import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
import ModelsManagerModelProviders from '$lib/components/app/models/ModelsManager/ModelsManagerModelProviders.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { MODEL_ICON } from '$lib/constants';
@@ -15,7 +16,7 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
type View = 'discover' | 'manage';
type View = 'discover' | 'manage' | 'providers';
/** How long a view takes to fade out before the next one fades in. */
const VIEW_FADE_MS = 150;
@@ -50,7 +51,9 @@
return () => clearTimeout(timer);
});
let title = $derived(view === 'discover' ? 'Discover' : 'Models');
let title = $derived(
view === 'discover' ? 'Discover' : view === 'providers' ? 'Providers' : 'Models'
);
// the sidebar's Discover entry opens this dialog on its Discover view
$effect(() => {
@@ -115,6 +118,13 @@
? 'opacity-100'
: 'opacity-0'}"
/>
<Server
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'providers'
? 'opacity-100'
: 'opacity-0'}"
/>
</span>
<span>{title}</span>
@@ -135,12 +145,27 @@
Discover Models
</Button>
<Button
class="gap-1.5"
onclick={() => (view = 'providers')}
size="sm"
variant="tertiary"
>
<Server class="h-3.5 w-3.5" />
Manage Providers
</Button>
{/snippet}
</ModelsManager>
{:else}
{:else if shownView === 'discover'}
<div class="grid h-full overflow-hidden" style="grid-template-columns: auto 1fr;">
<ModelsDiscover />
</div>
{:else}
<div class="h-full overflow-y-auto">
<ModelsManagerModelProviders />
</div>
{/if}
</div>
</Dialog.Content>
+1
View File
@@ -1,4 +1,5 @@
export * from './actions';
export * from './backends';
export * from './badges';
export * from './chat';
export * from './content';
@@ -0,0 +1,108 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { BackendCard, DialogBackendForm } from '$lib/components/app/backends';
import { Button } from '$lib/components/ui/button';
import { backendsModelsStore, backendsStore, serverStore } from '$lib/stores';
import type { Backend, BackendProtocol } from '$lib/types';
import { fade } from 'svelte/transition';
interface Props {
class?: string;
/** Narrows the list to one protocol; omit it to list every provider at once. */
protocol?: BackendProtocol;
}
let { class: className, protocol }: Props = $props();
let backends = $derived(
protocol
? backendsStore.external.filter((b) => b.protocol === protocol)
: backendsStore.external
);
// the bundled server belongs to the list whenever llama.cpp providers are in it
let showsLocal = $derived(protocol === undefined || protocol === 'llama.cpp');
let addTitle = $derived(
protocol === 'openai'
? 'Add a backend'
: protocol === 'llama.cpp'
? 'Add a Llama-compatible backend'
: 'Add new provider'
);
let addDescription = $derived(
protocol === 'openai'
? 'Connect an OpenAI-compatible endpoint.'
: protocol === 'llama.cpp'
? 'Point at another llama-server.'
: 'External llama-server or connect an OpenAI-compatible API.'
);
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={['grid gap-4', className]}
style="grid-template-columns: repeat(auto-fill, minmax(min(25rem, calc(100dvw - 4rem)), 1fr));"
>
<DialogBackendForm
bind:open={isAdding}
backend={editing}
defaultProtocol={protocol ?? 'llama.cpp'}
onOpenChange={handleOpenChange}
onSaved={() => void backendsModelsStore.loadAll()}
/>
{#if showsLocal && !serverStore.localServerMissing}
<BackendCard
backend={backendsStore.local}
isLocal
onToggle={(enabled) => backendsStore.setLocalEnabled(enabled)}
/>
{/if}
{#each backends as backend (backend.id)}
<BackendCard
{backend}
onDelete={() => {
backendsStore.removeBackend(backend.id);
void backendsModelsStore.loadAll();
}}
onEdit={() => handleEdit(backend)}
onToggle={(enabled) => {
backendsStore.updateBackend(backend.id, { enabled });
void backendsModelsStore.loadAll();
}}
/>
{/each}
<div
class="flex w-full flex-col items-center justify-center gap-4 rounded-xl border border-dashed p-6"
>
<Button onclick={handleAdd} size="sm">
<Plus />
{addTitle}
</Button>
<p class="text-xs text-muted-foreground">{addDescription}</p>
</div>
</div>
@@ -47,12 +47,17 @@
let localConfig: SettingsConfigType = $state({ ...settingsStore.config });
// Snapshot used to persist only the fields edited in this dialog. Config keys
// that other surfaces write directly (backends, MCP servers) must not be
// clobbered by the dialog's save.
let baseline: SettingsConfigType = $state({ ...settingsStore.config });
let mobileHeader: { updateCarousel: () => void } | undefined;
let fetchInitiated = false;
$effect(() => {
if (serverStore.isRouterMode && currentSection.fields?.length && !fetchInitiated) {
if (serverStore.localIsRouter && currentSection.fields?.length && !fetchInitiated) {
fetchInitiated = true;
void modelsStore
@@ -74,6 +79,7 @@
function handleReset() {
localConfig = { ...settingsStore.config };
baseline = { ...settingsStore.config };
setMode(localConfig.theme as ColorMode);
mobileHeader?.updateCarousel();
}
@@ -94,7 +100,13 @@
}
}
const processedConfig = { ...localConfig };
const processedConfig: SettingsConfigType = {};
for (const [key, value] of Object.entries(localConfig)) {
if (value !== baseline[key]) {
processedConfig[key] = value;
}
}
for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
@@ -126,6 +138,7 @@
export function reset() {
localConfig = { ...settingsStore.config };
baseline = { ...settingsStore.config };
}
</script>
@@ -25,7 +25,7 @@
let currentModelParams = $derived.by(() => {
void modelsStore.props.cacheVersion;
if (serverStore.isRouterMode) {
if (serverStore.localIsRouter) {
const currentModelName = modelsStore.selectedModelName;
if (currentModelName) {
@@ -74,3 +74,8 @@ export { default as SettingsChatFields } from './SettingsChat/SettingsChatFields
* server favicons and permission management controls.
*/
export { default as SettingsChatToolsTab } from './SettingsChat/SettingsChatToolsTab.svelte';
/**
* Providers panel for chat settings. Switches between the llama.cpp and
* OpenAI-compatible protocols and lists the endpoints configured for each.
*/
@@ -0,0 +1,18 @@
import Root from './tabs.svelte';
import Content from './tabs-content.svelte';
import List, { type TabsListVariant, tabsListVariants } from './tabs-list.svelte';
import Trigger from './tabs-trigger.svelte';
export {
Root,
Content,
List,
Trigger,
tabsListVariants,
type TabsListVariant,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { Tabs as TabsPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
class={cn('text-sm flex-1 outline-none', className)}
data-slot="tabs-content"
{...restProps}
/>
@@ -0,0 +1,40 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const tabsListVariants = tv({
base: 'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
defaultVariants: {
variant: 'default'
},
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent'
}
}
});
export type TabsListVariant = VariantProps<typeof tabsListVariants>['variant'];
</script>
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { Tabs as TabsPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
variant = 'default',
...restProps
}: TabsPrimitive.ListProps & {
variant?: TabsListVariant;
} = $props();
</script>
<TabsPrimitive.List
bind:ref
class={cn(tabsListVariants({ variant }), className)}
data-slot="tabs-list"
data-variant={variant}
{...restProps}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { Tabs as TabsPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
class={cn(
"gap-1.5 rounded-md border border-transparent px-1.75 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg:not([class*='size-'])]:size-4 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%_-_1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
'group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent',
'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100',
className
)}
data-slot="tabs-trigger"
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { Tabs as TabsPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
value = $bindable(''),
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
class={cn('gap-2 group/tabs flex data-[orientation=horizontal]:flex-col', className)}
data-slot="tabs"
{...restProps}
/>
@@ -1,4 +1,9 @@
import type { BackendCapabilities, BackendCompat, BackendProtocol } from '$lib/types';
import type {
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
@@ -64,3 +69,31 @@ export const MODEL_CONTEXT_LENGTH_FIELDS = [
/** Favicon extract keyed by domain, for backends with no bundled mark. */
export const FAVICON_SERVICE_URL = 'https://www.google.com/s2/favicons?domain=';
/**
* Ready-made endpoints offered when adding a backend.
*
* TODO: pair a llama.cpp server by scanning its QR code (WebRTC transport, so a
* hosted PWA can reach a server on the user's network), see
* ggml-org/llama.cpp#24577.
*/
export const BACKEND_PRESETS: readonly BackendPreset[] = [
{
baseUrl: 'https://router.huggingface.co',
description: 'Open models from the Hub, served by inference providers.',
iconUrl: '/backend-presets/huggingface.svg',
id: 'huggingface',
name: 'Hugging Face',
protocol: 'openai'
},
{
baseUrl: 'https://openrouter.ai/api',
description: 'Models from many providers behind a single API.',
// v2 brand: purple on light, lime on dark
iconUrlDark: '/backend-presets/openrouter-dark.svg',
iconUrlLight: '/backend-presets/openrouter-light.svg',
id: 'openrouter',
name: 'OpenRouter',
protocol: 'openai'
}
];
@@ -52,6 +52,7 @@ export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.re
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
export const DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedBackends`;
/** Key prefix for per-conversation resumable stream state, conversationId is appended */
export const STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX = `${STORAGE_APP_NAME}.streamResume.`;
+31 -1
View File
@@ -10,6 +10,7 @@ import {
BACKEND_CAPABILITIES,
BACKEND_COMPAT,
BACKEND_ID_PREFIX,
BACKEND_PRESETS,
BACKEND_PROTOCOLS,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH,
@@ -17,7 +18,13 @@ import {
LOCAL_BACKEND_ID,
MODEL_CONTEXT_LENGTH_FIELDS
} from '$lib/constants';
import type { Backend, BackendCapabilities, BackendCompat, BackendProtocol } from '$lib/types';
import type {
Backend,
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
/** Absolute chat completions URL for a backend. */
export function backendChatUrl(backend: Backend): string {
@@ -49,6 +56,29 @@ export function backendFaviconUrl(baseUrl: string): string | null {
}
}
/**
* Preset a backend was created from, matched on origin and path so a saved
* backend keeps its branding. Returns undefined for edited or custom URLs.
*/
export function findBackendPreset(baseUrl: string): BackendPreset | undefined {
const target = normalizeBaseUrl(baseUrl);
if (!target) return undefined;
return BACKEND_PRESETS.find((preset) => normalizeBaseUrl(preset.baseUrl) === target);
}
/** Origin and path of a URL, without a trailing slash; null when unparsable. */
function normalizeBaseUrl(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`.toLowerCase();
} catch {
return null;
}
}
/**
* Features a backend supports, derived from its protocol. A missing backend
* (unknown model, early startup) gets the plain compatible defaults.
+1
View File
@@ -29,6 +29,7 @@ export {
backendFaviconUrl,
backendModelsUrl,
createLocalBackend,
findBackendPreset,
getBackendCapabilities,
parseBackendsSettings
} from './backend';
@@ -0,0 +1 @@
<svg fill="#5786FE" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.651c-.254-.124-.364.113-.512.233-.051.04-.094.09-.137.137-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.155-.708-.311-.955-.65-.172-.24-.219-.509-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.094.172.187.129.323-.082.28-.18.553-.266.833-.055.179-.137.218-.328.14a5.5 5.5 0 0 1-1.737-1.179c-.857-.828-1.631-1.743-2.597-2.46a12 12 0 0 0-.689-.47c-.985-.957.13-1.743.387-1.836.27-.098.094-.433-.778-.428-.872.003-1.67.295-2.687.685a3 3 0 0 1-.465.136 9.6 9.6 0 0 0-2.883-.101c-1.885.21-3.39 1.1-4.497 2.622C.082 8.776-.231 10.854.152 13.02c.403 2.284 1.568 4.175 3.36 5.653 1.857 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.132-.284 4.994-1.86.47.234.962.328 1.78.398.629.058 1.235-.031 1.705-.129.735-.155.684-.836.418-.961-2.155-1.004-1.682-.595-2.112-.926 1.095-1.295 2.768-3.598 3.284-6.733.05-.346.115-.834.108-1.114-.004-.171.035-.238.23-.257a4.2 4.2 0 0 0 1.545-.475c1.397-.763 1.96-2.016 2.093-3.517.02-.23-.004-.467-.247-.588M11.58 18.168c-2.088-1.642-3.101-2.183-3.52-2.16-.39.024-.32.472-.234.763.09.288.207.487.371.74.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.168-1.361-.801-2.5-1.86-3.301-3.306-.775-1.393-1.225-2.888-1.299-4.482-.02-.385.094-.522.477-.592a4.7 4.7 0 0 1 1.53-.038c2.131.311 3.946 1.264 5.467 2.774.868.86 1.525 1.887 2.202 2.89.72 1.066 1.494 2.082 2.48 2.915.348.291.626.513.892.677-.802.09-2.14.109-3.055-.615zm1.001-6.44a.306.306 0 0 1 .415-.287.3.3 0 0 1 .113.074.3.3 0 0 1 .086.214c0 .17-.136.307-.308.307a.303.303 0 0 1-.306-.307m3.11 1.596c-.2.081-.4.151-.591.16a1.25 1.25 0 0 1-.798-.254c-.274-.23-.47-.358-.551-.758a1.7 1.7 0 0 1 .015-.588c.07-.327-.007-.537-.238-.727-.188-.156-.426-.199-.689-.199a.6.6 0 0 1-.254-.078.253.253 0 0 1-.114-.358 1 1 0 0 1 .192-.21c.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.392.451.462.576.685.915.176.264.336.536.446.848.066.194-.02.353-.25.45"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)"/>
<defs>
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
<stop offset="0.0671246" stop-color="#9168C0"/>
<stop offset="0.342551" stop-color="#5684D1"/>
<stop offset="0.672076" stop-color="#1BA1E3"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 95 88" width="95" height="88" fill="none">
<path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z" />
<path
fill="#FF9D0B"
d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"
/>
<path
fill="#3A3B45"
d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"
/>
<path
fill="#FF323D"
d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"
/>
<path
fill="#3A3B45"
fill-rule="evenodd"
d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z"
clip-rule="evenodd"
/>
<path
fill="#FF9D0B"
d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"
/>
<path
fill="#FFD21E"
d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"
/>
<path
fill="#FF9D0B"
d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"
/>
<path
fill="#FFD21E"
d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 401.4 293.7">
<defs><style>.st-glyph { fill: #C8FF00; }</style></defs>
<path class="st-glyph" d="M303.9475,17.19926c42.79734,0,77.48933,34.69327,77.48933,77.48933s-34.69199,77.48933-77.48933,77.48933l76.86166,76.86244c9.76367,9.76313,2.84903,26.45667-10.95697,26.45667h-220.88335c-71.32686,0-129.14889-57.82202-129.14889-129.14889S77.64197,17.19926,148.96884,17.19926h154.97866ZM148.96884,68.85881c-42.79607,0-77.48933,34.69327-77.48933,77.48933s34.69327,77.48933,77.48933,77.48933,77.48933-34.69327,77.48933-77.48933-34.69327-77.48933-77.48933-77.48933Z"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 401.4 293.7">
<defs><style>.st-glyph { fill: #7624F4; }</style></defs>
<path class="st-glyph" d="M303.9475,17.19926c42.79734,0,77.48933,34.69327,77.48933,77.48933s-34.69199,77.48933-77.48933,77.48933l76.86166,76.86244c9.76367,9.76313,2.84903,26.45667-10.95697,26.45667h-220.88335c-71.32686,0-129.14889-57.82202-129.14889-129.14889S77.64197,17.19926,148.96884,17.19926h154.97866ZM148.96884,68.85881c-42.79607,0-77.48933,34.69327-77.48933,77.48933s34.69327,77.48933,77.48933,77.48933,77.48933-34.69327,77.48933-77.48933-34.69327-77.48933-77.48933-77.48933Z"/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB