diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts index 8ba25485b0..4e547f3744 100644 --- a/tools/ui/src/lib/constants/settings-keys.constants.ts +++ b/tools/ui/src/lib/constants/settings-keys.constants.ts @@ -11,6 +11,7 @@ export const SETTINGS_KEYS = { API_KEY: 'apiKey', AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty', BACKEND_SAMPLING: 'backend_sampling', + BACKENDS: 'backends', CONVERSATION_TABS: 'conversationTabs', COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText', CUSTOM_CSS: 'customCss', diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts index d39230e9ad..29f83abd50 100644 --- a/tools/ui/src/lib/constants/settings.constants.ts +++ b/tools/ui/src/lib/constants/settings.constants.ts @@ -188,6 +188,14 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [ key: SETTINGS_KEYS.MAX_IMAGE_RESOLUTION, label: 'Maximum image resolution (megapixels)', type: SettingsFieldType.INPUT + }, + { + defaultValue: '[]', + help: 'Configure external API backends as a JSON list. The local backend is always available.', + key: SETTINGS_KEYS.BACKENDS, + label: 'Backends', + standaloneField: false, + type: SettingsFieldType.INPUT } ], slug: SETTINGS_SECTION_SLUGS.GENERAL, diff --git a/tools/ui/src/lib/stores/backends.svelte.ts b/tools/ui/src/lib/stores/backends.svelte.ts new file mode 100644 index 0000000000..989bc8e609 --- /dev/null +++ b/tools/ui/src/lib/stores/backends.svelte.ts @@ -0,0 +1,53 @@ +/** + * backendsStore - API endpoints the UI can talk to. + * + * The built-in local backend is the llama-server serving this UI. External + * backends are user-configured endpoints read from settings. The store + * registers the resolved list with the api-base registry, which services use + * to build request URLs. + */ + +import { browser } from '$app/environment'; +import { LOCAL_BACKEND_ID, SETTINGS_KEYS } from '$lib/constants'; +import { settingsStore } from '$lib/stores/settings/index.svelte'; +import type { Backend } from '$lib/types'; +import { setBackendsResolver } from '$lib/utils/api-base'; +import { createLocalBackend, parseBackendsSettings } from '$lib/utils/backend'; + +class BackendsStore { + activeId = $state(LOCAL_BACKEND_ID); + + get active(): Backend { + return this.list.find((backend) => backend.id === this.activeId) ?? this.local; + } + + get enabled(): Backend[] { + return this.list.filter((backend) => backend.enabled); + } + + get external(): Backend[] { + return parseBackendsSettings(settingsStore.config[SETTINGS_KEYS.BACKENDS]); + } + + get list(): Backend[] { + return [this.local, ...this.external]; + } + + get local(): Backend { + return createLocalBackend(); + } + + initialize(): void { + if (!browser) return; + + setBackendsResolver(() => ({ activeId: this.activeId, backends: this.list })); + } + + setActive(backendId: string): void { + this.activeId = this.list.some((backend) => backend.id === backendId) + ? backendId + : LOCAL_BACKEND_ID; + } +} + +export const backendsStore = new BackendsStore(); diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts index fcab8a8064..8dc70da24d 100644 --- a/tools/ui/src/lib/stores/index.ts +++ b/tools/ui/src/lib/stores/index.ts @@ -37,6 +37,9 @@ export { conversationsStore } from './conversations/index.svelte'; // MCP export { mcpStore } from './mcp/index.svelte'; +// BACKENDS +export { backendsStore } from './backends.svelte'; + // MODELS export { modelsStore } from './models/index.svelte'; diff --git a/tools/ui/src/lib/stores/init.ts b/tools/ui/src/lib/stores/init.ts index 37ea87b17c..af2c70dfc9 100644 --- a/tools/ui/src/lib/stores/init.ts +++ b/tools/ui/src/lib/stores/init.ts @@ -1,4 +1,5 @@ // direct imports, not via the barrel, to avoid circular deps +import { backendsStore } from './backends.svelte'; import { conversationsStore } from './conversations/index.svelte'; import { permissionsStore } from './permissions.svelte'; import { settingsStore } from './settings/index.svelte'; @@ -17,6 +18,7 @@ export function initStores(): Promise { await MigrationService.runAllMigrations(); settingsStore.initialize(); + backendsStore.initialize(); permissionsStore.initialize(); toolsStore.initialize(); void versionStore.initialize(); diff --git a/tools/ui/src/lib/utils/api-base.ts b/tools/ui/src/lib/utils/api-base.ts new file mode 100644 index 0000000000..07e8517200 --- /dev/null +++ b/tools/ui/src/lib/utils/api-base.ts @@ -0,0 +1,68 @@ +/** + * API base resolution for backends. + * + * The UI can talk to more than one backend endpoint. Services build request + * URLs through {@link apiUrl} so a request always targets the right backend. + * The backends store registers a resolver here; this module never imports the + * store, which keeps URL resolution free of store dependencies. + */ + +import { base } from '$app/paths'; +import { API_ABSOLUTE_URL_PROTOCOLS } from '$lib/constants'; +import type { Backend } from '$lib/types'; + +/** Backend list and active selection as exposed to URL resolution. */ +export interface BackendsSnapshot { + activeId: string; + backends: Backend[]; +} + +type BackendsResolver = () => BackendsSnapshot; + +let resolveBackends: BackendsResolver | null = null; + +/** Registered once by the backends store. */ +export function setBackendsResolver(resolver: BackendsResolver | null): void { + resolveBackends = resolver; +} + +/** + * Look up a backend by id, defaulting to the active one. + */ +export function getBackend(backendId?: string): Backend | undefined { + const snapshot = resolveBackends?.(); + + if (!snapshot) return undefined; + + const id = backendId ?? snapshot.activeId; + + return snapshot.backends.find((backend) => backend.id === id); +} + +/** API root for a backend, or an empty string for the local backend. */ +export function getBackendBaseUrl(backendId?: string): string { + return getBackend(backendId)?.baseUrl.trim() ?? ''; +} + +/** + * Absolute URL for an API path on a backend. + * + * Absolute URLs pass through untouched. Paths on the local backend keep the + * existing base-path-relative form, so serving under a subpath still works. + * Paths on external backends resolve against the backend's API root. + */ +export function apiUrl(path: string, backendId?: string): string { + if (API_ABSOLUTE_URL_PROTOCOLS.some((protocol) => path.startsWith(protocol))) { + return path; + } + + const baseUrl = getBackendBaseUrl(backendId); + + if (!baseUrl) { + return `${base}${path}`; + } + + const root = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + + return new URL(path.replace(/^\.?\//, ''), root).toString(); +} diff --git a/tools/ui/src/lib/utils/backend.ts b/tools/ui/src/lib/utils/backend.ts new file mode 100644 index 0000000000..4771e59875 --- /dev/null +++ b/tools/ui/src/lib/utils/backend.ts @@ -0,0 +1,95 @@ +/** + * Backend list parsing and defaults. + * + * External backends are persisted in settings as a JSON list. Malformed + * entries are dropped instead of throwing so a corrupted settings value can + * never break URL resolution. + */ + +import { BACKEND_ID_PREFIX, BACKEND_PROTOCOLS, LOCAL_BACKEND_ID } from '$lib/constants'; +import type { Backend, BackendProtocol } from '$lib/types'; + +/** The built-in backend pointing at the server that serves this UI. */ +export function createLocalBackend(): Backend { + return { + baseUrl: '', + enabled: true, + id: LOCAL_BACKEND_ID, + name: 'Local', + protocol: 'llama.cpp' + }; +} + +/** + * Parse the persisted backends JSON into backend entries. + */ +export function parseBackendsSettings(rawBackends: unknown): Backend[] { + if (!rawBackends) return []; + + let parsed: unknown; + + if (typeof rawBackends === 'string') { + const trimmed = rawBackends.trim(); + + if (!trimmed) return []; + + try { + parsed = JSON.parse(trimmed); + } catch (error) { + console.warn('[backends] Failed to parse backends JSON, ignoring value:', error); + + return []; + } + } else { + parsed = rawBackends; + } + + if (!Array.isArray(parsed)) return []; + + return parsed.flatMap((entry, index) => { + const backend = parseBackendEntry(entry, index); + + return backend ? [backend] : []; + }); +} + +function parseBackendEntry(entry: unknown, index: number): Backend | null { + if (!entry || typeof entry !== 'object') return null; + + const raw = entry as Record; + const baseUrl = typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : ''; + + // the local backend is built in and never persisted + if (!baseUrl || raw.id === LOCAL_BACKEND_ID) return null; + + const protocol = BACKEND_PROTOCOLS.includes(raw.protocol as BackendProtocol) + ? (raw.protocol as BackendProtocol) + : 'openai'; + const id = + typeof raw.id === 'string' && raw.id.trim() + ? raw.id.trim() + : `${BACKEND_ID_PREFIX}-${index + 1}`; + const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : baseUrl; + const apiKey = + typeof raw.apiKey === 'string' && raw.apiKey.trim() ? raw.apiKey.trim() : undefined; + + return { + apiKey, + baseUrl, + enabled: raw.enabled !== false, + headers: parseBackendHeaders(raw.headers), + id, + name, + protocol + }; +} + +function parseBackendHeaders(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + + const entries = Object.entries(raw as Record) + .filter(([, value]) => typeof value === 'string' && value.trim() !== '') + .map(([key, value]) => [key.trim(), (value as string).trim()] as const); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index f518cc9890..210a3460a0 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -8,9 +8,11 @@ */ // API utilities +export { apiUrl, getBackend, getBackendBaseUrl, type BackendsSnapshot } from './api-base'; export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers'; export { ApiError, apiDelete, apiFetch, apiFetchWithParams, apiPost } from './api-fetch'; export { validateApiKey } from './api-key-validation'; +export { createLocalBackend, parseBackendsSettings } from './backend'; // Attachment utilities export { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from './attachment-display';