diff --git a/tools/ui/src/lib/components/app/backends/BackendCard.svelte b/tools/ui/src/lib/components/app/backends/BackendCard.svelte new file mode 100644 index 0000000000..2067f3f46e --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/BackendCard.svelte @@ -0,0 +1,163 @@ + + + +
+
+ + {#snippet fallback()} + {#if isLocal} + + {:else} + + {/if} + {/snippet} + + +
+

{backend.name}

+ +

{displayUrl}

+
+
+ + onToggle?.(value)} + /> +
+ + {#if isOffline && modelsState.error} + + +

{modelsState.error}

+
+ + +

{modelsState.error}

+
+
+ {/if} + +
+
+ {#if isChecking} + + + Checking + {:else if isOnline} + + + {modelCount === 1 ? '1 model' : `${modelCount} models`} + {:else if isOffline} + + + Unreachable + + + {/if} + + {#if hasStatus} + · + {/if} + + {metaText} +
+ + {#if !isLocal} +
+ + + +
+ {/if} +
+
+ + (showDelete = false)} + onConfirm={() => { + showDelete = false; + onDelete?.(); + }} + title="Delete backend?" + variant="destructive" +/> diff --git a/tools/ui/src/lib/components/app/backends/BackendForm.svelte b/tools/ui/src/lib/components/app/backends/BackendForm.svelte new file mode 100644 index 0000000000..28f2114e84 --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/BackendForm.svelte @@ -0,0 +1,190 @@ + + +
+
+ + + onChange({ baseUrl: e.currentTarget.value })} + placeholder="https://api.example.com" + type="url" + value={backend.baseUrl} + /> + + {#if urlError} +

{urlError}

+ {:else if detectionLabel} +

{detectionLabel}

+ {/if} +
+ +
+
+ + + onChange({ name: e.currentTarget.value })} + placeholder="Name shown in the model selector" + type="text" + value={backend.name} + /> +
+ +
+ + + onChange({ apiKey: e.currentTarget.value || undefined })} + placeholder="Optional" + type="password" + value={backend.apiKey ?? ''} + /> + +

+ {#if apiKeyRequired} + Required by this endpoint. + {:else} + Sent as a Bearer token. + {/if} +

+
+
+ + + + {#if showAdvanced} + + {:else} + + {/if} + + Advanced + + + +
+
+ API format + + + onChange({ compat: undefined, protocol: value as BackendProtocol })} + type="single" + value={backend.protocol} + > + {protocolLabel} + + + {#each PROTOCOL_OPTIONS as option (option.value)} + {option.label} + {/each} + + + +

+ Detected from the endpoint. Override it when a server answers unusually. +

+
+ +
+
+ + + onChange({ chatPath: e.currentTarget.value || undefined })} + placeholder={DEFAULT_BACKEND_CHAT_PATH} + type="text" + value={backend.chatPath ?? ''} + /> +
+ +
+ + + onChange({ modelsPath: e.currentTarget.value || undefined })} + placeholder={DEFAULT_BACKEND_MODELS_PATH} + type="text" + value={backend.modelsPath ?? ''} + /> +
+
+
+
+
+
diff --git a/tools/ui/src/lib/components/app/backends/BackendIcon.svelte b/tools/ui/src/lib/components/app/backends/BackendIcon.svelte new file mode 100644 index 0000000000..d9ed53a518 --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/BackendIcon.svelte @@ -0,0 +1,37 @@ + + +{#if preset} + +{:else if faviconUrl && !failedFavicons.has(faviconUrl)} + failedFavicons.add(faviconUrl)} + src={faviconUrl} + /> +{:else} + {@render fallback?.()} +{/if} diff --git a/tools/ui/src/lib/components/app/backends/BackendPresetCard.svelte b/tools/ui/src/lib/components/app/backends/BackendPresetCard.svelte new file mode 100644 index 0000000000..bcdc400cb4 --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/BackendPresetCard.svelte @@ -0,0 +1,41 @@ + + + + {#if added} + + + Added + + {/if} + +
+ + +

{preset.name}

+
+ +

{preset.description}

+
diff --git a/tools/ui/src/lib/components/app/backends/BackendPresetIcon.svelte b/tools/ui/src/lib/components/app/backends/BackendPresetIcon.svelte new file mode 100644 index 0000000000..dccb9ff583 --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/BackendPresetIcon.svelte @@ -0,0 +1,34 @@ + + +{#if iconUrl} + +{:else} + +{/if} diff --git a/tools/ui/src/lib/components/app/backends/DialogBackendForm.svelte b/tools/ui/src/lib/components/app/backends/DialogBackendForm.svelte new file mode 100644 index 0000000000..9cf2b627f3 --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/DialogBackendForm.svelte @@ -0,0 +1,356 @@ + + + + + + + {isEdit ? 'Edit Provider' : 'Add New Provider'} + + + + Point at another llama-server, or connect an OpenAI-compatible endpoint. + + + + {#if !isEdit && presetsToShow.length > 0} +
+
+

Recommended providers

+ + +
+ +
+ {#each presetsToShow as preset (preset.id)} + applyPreset(preset)} + {preset} + selected={selectedPresetId === preset.id} + /> + {/each} +
+
+ {/if} + +
+
+ +
+ + +
+ {#if testing} + + +
+

Testing connection

+ +

+ Asking {hostOf(draft.baseUrl)} for the models it serves. +

+
+ {:else if testResult?.ok} + + +
+

Connected

+ +

+ {testResult.modelCount ?? 0} + model{(testResult.modelCount ?? 0) === 1 ? '' : 's'} available. +

+
+ {:else if testResult} + + +
+

Connection failed

+ +

+ {testResult.error ?? 'The endpoint did not answer.'}{testResult.status + ? ` (HTTP ${testResult.status})` + : ''} +

+
+ {:else} + + +
+

Connection not tested

+ +

+ Test it to list the models this provider serves. +

+
+ {/if} +
+ + + + + + + + +
+
+
diff --git a/tools/ui/src/lib/components/app/backends/index.ts b/tools/ui/src/lib/components/app/backends/index.ts new file mode 100644 index 0000000000..529bc7fc36 --- /dev/null +++ b/tools/ui/src/lib/components/app/backends/index.ts @@ -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'; diff --git a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte index 0b12310034..a466ad0533 100644 --- a/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte +++ b/tools/ui/src/lib/components/app/dialogs/DialogManageModels.svelte @@ -1,7 +1,8 @@ + +
+ void backendsModelsStore.loadAll()} + /> + + {#if showsLocal && !serverStore.localServerMissing} + backendsStore.setLocalEnabled(enabled)} + /> + {/if} + + {#each backends as backend (backend.id)} + { + backendsStore.removeBackend(backend.id); + void backendsModelsStore.loadAll(); + }} + onEdit={() => handleEdit(backend)} + onToggle={(enabled) => { + backendsStore.updateBackend(backend.id, { enabled }); + void backendsModelsStore.loadAll(); + }} + /> + {/each} + +
+ + +

{addDescription}

+
+
diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte index 8793900759..a128821142 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChat.svelte @@ -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 }; } diff --git a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte index e8795d7c52..f77b8089cc 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChat/SettingsChatFields.svelte @@ -25,7 +25,7 @@ let currentModelParams = $derived.by(() => { void modelsStore.props.cacheVersion; - if (serverStore.isRouterMode) { + if (serverStore.localIsRouter) { const currentModelName = modelsStore.selectedModelName; if (currentModelName) { diff --git a/tools/ui/src/lib/components/app/settings/index.ts b/tools/ui/src/lib/components/app/settings/index.ts index 7473e6af8e..899e34392d 100644 --- a/tools/ui/src/lib/components/app/settings/index.ts +++ b/tools/ui/src/lib/components/app/settings/index.ts @@ -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. + */ diff --git a/tools/ui/src/lib/components/ui/tabs/index.ts b/tools/ui/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000000..95bbb36763 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tabs/index.ts @@ -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 +}; diff --git a/tools/ui/src/lib/components/ui/tabs/tabs-content.svelte b/tools/ui/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000000..fae73d9199 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/tools/ui/src/lib/components/ui/tabs/tabs-list.svelte b/tools/ui/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000000..32c7f87a65 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,40 @@ + + + + + diff --git a/tools/ui/src/lib/components/ui/tabs/tabs-trigger.svelte b/tools/ui/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000000..be798b51ee --- /dev/null +++ b/tools/ui/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,23 @@ + + + diff --git a/tools/ui/src/lib/components/ui/tabs/tabs.svelte b/tools/ui/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000000..fcd40003f7 --- /dev/null +++ b/tools/ui/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/tools/ui/src/lib/constants/backend.constants.ts b/tools/ui/src/lib/constants/backend.constants.ts index 1f3ab2e394..100ee17dcd 100644 --- a/tools/ui/src/lib/constants/backend.constants.ts +++ b/tools/ui/src/lib/constants/backend.constants.ts @@ -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' + } +]; diff --git a/tools/ui/src/lib/constants/storage.constants.ts b/tools/ui/src/lib/constants/storage.constants.ts index f66a0a0112..8c6cb3462e 100644 --- a/tools/ui/src/lib/constants/storage.constants.ts +++ b/tools/ui/src/lib/constants/storage.constants.ts @@ -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.`; diff --git a/tools/ui/src/lib/utils/backend.ts b/tools/ui/src/lib/utils/backend.ts index 55b671bbfc..29c68558f7 100644 --- a/tools/ui/src/lib/utils/backend.ts +++ b/tools/ui/src/lib/utils/backend.ts @@ -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. diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 3c8a3caf8c..ca22ac3340 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -29,6 +29,7 @@ export { backendFaviconUrl, backendModelsUrl, createLocalBackend, + findBackendPreset, getBackendCapabilities, parseBackendsSettings } from './backend'; diff --git a/tools/ui/static/backend-presets/deepseek.svg b/tools/ui/static/backend-presets/deepseek.svg new file mode 100644 index 0000000000..185c108b45 --- /dev/null +++ b/tools/ui/static/backend-presets/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/tools/ui/static/backend-presets/google.svg b/tools/ui/static/backend-presets/google.svg new file mode 100644 index 0000000000..4545c8331f --- /dev/null +++ b/tools/ui/static/backend-presets/google.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tools/ui/static/backend-presets/huggingface.svg b/tools/ui/static/backend-presets/huggingface.svg new file mode 100644 index 0000000000..6b3b4f687e --- /dev/null +++ b/tools/ui/static/backend-presets/huggingface.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/tools/ui/static/backend-presets/kimi.png b/tools/ui/static/backend-presets/kimi.png new file mode 100644 index 0000000000..213b9b83d6 Binary files /dev/null and b/tools/ui/static/backend-presets/kimi.png differ diff --git a/tools/ui/static/backend-presets/openrouter-dark.svg b/tools/ui/static/backend-presets/openrouter-dark.svg new file mode 100644 index 0000000000..5df05ac315 --- /dev/null +++ b/tools/ui/static/backend-presets/openrouter-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/ui/static/backend-presets/openrouter-light.svg b/tools/ui/static/backend-presets/openrouter-light.svg new file mode 100644 index 0000000000..8fbbb87178 --- /dev/null +++ b/tools/ui/static/backend-presets/openrouter-light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/tools/ui/static/backend-presets/qwen.png b/tools/ui/static/backend-presets/qwen.png new file mode 100644 index 0000000000..87a98a9526 Binary files /dev/null and b/tools/ui/static/backend-presets/qwen.png differ diff --git a/tools/ui/static/backend-presets/zai.png b/tools/ui/static/backend-presets/zai.png new file mode 100644 index 0000000000..70c33481a4 Binary files /dev/null and b/tools/ui/static/backend-presets/zai.png differ