ui : add backend store and API base resolution

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 1d2cc0daae
commit 31031a0cd2
8 changed files with 232 additions and 0 deletions
@@ -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',
@@ -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,
@@ -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<string>(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();
+3
View File
@@ -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';
+2
View File
@@ -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<void> {
await MigrationService.runAllMigrations();
settingsStore.initialize();
backendsStore.initialize();
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
+68
View File
@@ -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();
}
+95
View File
@@ -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<string, unknown>;
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<string, string> | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const entries = Object.entries(raw as Record<string, unknown>)
.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;
}
+2
View File
@@ -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';