ui: Refactor models store, MCP service, and gate logs behind VITE_DEBUG (#23236)

* refactor: Scope console logs to `DEV` + `VITE_DEBUG` env vars

* refactor: skip MCP proxy probe when no server requires it

* refactor: suppress expected disconnect errors during MCP client shutdown

* refactor: Deduplicate requests

* refactor: deduplicate model fetching across ROUTER and MODEL modes

* refactor: Clean up models logic

* chore: Add `.env.example` file

* refactor: replace client-side CORS proxy probe with server status flag

* refactor: Post-review fixes

* test: add vitest client setup with API fetch mocks
This commit is contained in:
Aleksander Grygier 2026-05-18 16:09:40 +02:00 committed by GitHub
parent a135ec0baa
commit 1ff0fc1384
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 410 additions and 349 deletions

View file

@ -3885,6 +3885,7 @@ void server_routes::init_routes() {
{ "eos_token", meta->eos_token_str },
{ "build_info", meta->build_info },
{ "is_sleeping", queue_tasks.is_sleeping() },
{ "cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy },
};
if (params.use_jinja) {
if (!tmpl_tools.empty()) {

View file

@ -1165,6 +1165,7 @@ void server_models_routes::init_routes() {
// Deprecated: use ui_settings instead (kept for backward compat)
{"webui_settings", webui_settings},
{"build_info", std::string(llama_build_info())},
{"cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy},
});
return res;
}

2
tools/ui/.env.example Normal file
View file

@ -0,0 +1,2 @@
VITE_PUBLIC_APP_NAME='llama-ui'
# VITE_DEBUG='true'

View file

@ -7,7 +7,6 @@
import { activeMessages } from '$lib/stores/conversations.svelte';
interface Props {
currentModel?: string;
disabled?: boolean;
forceForegroundText?: boolean;
hasAudioModality?: boolean;
@ -20,7 +19,6 @@
}
let {
currentModel,
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
@ -41,14 +39,28 @@
let lastSyncedConversationModel: string | null = null;
let selectorModel = $derived(conversationModel ?? modelsStore.selectedModelName ?? null);
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
lastSyncedConversationModel = conversationModel;
if (modelOptions().some((m) => m.model === conversationModel)) {
modelsStore.selectedModelName = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else {
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
modelsStore.selectModelByName(conversationModel);
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
lastSyncedConversationModel = conversationModel;
} else if (
isRouter &&
!modelsStore.selectedModelId &&
modelsStore.loadedModelIds.length > 0 &&
activeMessages().length > 0 &&
!conversationModel
) {
lastSyncedConversationModel = null;
// auto-select the first loaded model only when nothing is selected yet
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
@ -151,7 +163,7 @@
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
{currentModel}
currentModel={selectorModel}
{forceForegroundText}
{useGlobalSelection}
/>
@ -159,7 +171,7 @@
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
{currentModel}
currentModel={selectorModel}
{forceForegroundText}
{useGlobalSelection}
/>

View file

@ -162,7 +162,7 @@
return;
}
if (import.meta.env.DEV) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
@ -181,7 +181,7 @@
value
);
if (import.meta.env.DEV) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
argName,
value,

View file

@ -66,7 +66,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const serverModel = $derived(singleModelName());
const currentModel = $derived(opts.currentModel());
const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false);
const onModelChange = $derived(opts.onModelChange?.());
const isHighlightedCurrentModelActive = $derived.by(() => {
@ -128,6 +127,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
if (result === false) {
shouldCloseMenu = false;
}
@ -142,12 +142,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
textarea?.focus();
});
}
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
@ -158,6 +160,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
const displayModel = serverModel || currentModel;
if (displayModel) {
return {
id: serverModel ? 'current' : 'offline-current',
@ -166,12 +169,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
capabilities: []
};
}
return undefined;
}
if (useGlobalSelection && activeId) {
const selected = options.find((option) => option.id === activeId);
if (selected) return selected;
return undefined;
}
if (currentModel) {
@ -183,6 +182,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
capabilities: []
};
}
return options.find((option) => option.model === currentModel);
}
@ -197,57 +197,77 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
get options() {
return options;
},
get loading() {
return loading;
},
get updating() {
return updating;
},
get activeId() {
return activeId;
},
get isRouter() {
return isRouter;
},
get serverModel() {
return serverModel;
},
get isHighlightedCurrentModelActive() {
return isHighlightedCurrentModelActive;
},
get isCurrentModelInCache() {
return isCurrentModelInCache;
},
get filteredOptions() {
return filteredOptions;
},
get groupedFilteredOptions() {
return groupedFilteredOptions;
},
get isLoadingModel() {
return isLoadingModel;
},
get searchTerm() {
return searchTerm;
},
get showModelDialog() {
return showModelDialog;
},
get infoModelId() {
return infoModelId;
},
setSearchTerm(value: string) {
searchTerm = value;
},
setShowModelDialog(value: boolean) {
showModelDialog = value;
},
handleInfoClick,
handleSelect,
handleOpenChange,
isFavorite(model: string) {
return modelsStore.favoriteModelIds.has(model);
},
getDisplayOption
};
}

View file

@ -392,7 +392,7 @@ export class MCPService {
const url = new URL(config.url);
if (import.meta.env.DEV) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
}
@ -413,12 +413,12 @@ export class MCPService {
onLog
);
if (useProxy && import.meta.env.DEV) {
if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
}
try {
if (import.meta.env.DEV) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
}
@ -520,7 +520,7 @@ export class MCPService {
)
);
if (import.meta.env.DEV) {
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService][${serverName}] Creating transport...`);
}
@ -560,6 +560,22 @@ export class MCPService {
);
const runtimeErrorHandler = (error: Error) => {
// Ignore errors that are expected when the SDK's transport is closed,
// or when connecting to servers that don't support SSE (stateless-only
// endpoints returning 405). The SDK wraps the original AbortError in
// a new Error with the message "SSE stream disconnected: AbortError",
// and also produces "Cannot cancel a stream locked by a reader".
// DOMException is thrown by the browser when aborting fetch requests.
const msg = error.message || String(error);
if (
error.name === 'AbortError' ||
error instanceof DOMException ||
msg.includes('SSE stream disconnected') ||
msg.includes('stream locked by a reader') ||
msg.includes('The operation was aborted')
) {
return;
}
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
};
@ -658,7 +674,10 @@ export class MCPService {
this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
);
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
}
const tools = await this.listTools({
client,
transport,
@ -680,10 +699,11 @@ export class MCPService {
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
)
);
console.log(
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
);
}
return {
client,
@ -709,9 +729,22 @@ export class MCPService {
* @param connection - The active MCP connection to close
*/
static async disconnect(connection: MCPConnection): Promise<void> {
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
}
try {
// Prevent reconnection on voluntary disconnect
// Terminate the session first for streamable-http transports to cleanly
// close streams, matching the inspector's disconnect flow.
if (connection.transport instanceof StreamableHTTPClientTransport) {
await connection.transport.terminateSession();
}
// Clear error handlers before closing to prevent noise from expected
// abort errors during shutdown. The inspector avoids this entirely
// by not setting onerror, but since we use it for protocol logging,
// we must clear it before disconnect.
connection.client.onerror = undefined;
if (connection.transport.onclose) {
connection.transport.onclose = undefined;
}
@ -1078,7 +1111,9 @@ export class MCPService {
try {
await connection.client.unsubscribeResource({ uri });
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
}
} catch (error) {
console.error(
`[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,

View file

@ -119,7 +119,8 @@ const localStorageMigration: Migration = {
// Only migrate if new key doesn't already exist
const newValue = localStorage.getItem(newKey);
if (newValue !== null) {
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
continue;
}
@ -127,9 +128,11 @@ const localStorageMigration: Migration = {
if (oldValue !== null) {
localStorage.setItem(newKey, oldValue);
// Keep old key for downgrade compatibility - DO NOT DELETE
console.log(
`[Migration] localStorage: copied ${deprecatedKey}${newKey} (preserved old)`
);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
console.log(
`[Migration] localStorage: copied ${deprecatedKey}${newKey} (preserved old)`
);
}
}
}
}
@ -146,7 +149,8 @@ const idxdbMigration: Migration = {
async run(): Promise<void> {
const oldDbNames = await Dexie.getDatabaseNames();
if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
console.log('[Migration] IndexedDB: no old database found, skipping');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: no old database found, skipping');
return;
}
@ -155,11 +159,13 @@ const idxdbMigration: Migration = {
newDb.version(1).stores(IDXDB_STORES);
const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
if (existingConvs > 0) {
console.log('[Migration] IndexedDB: new database already has data, skipping');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: new database already has data, skipping');
return;
}
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
oldDb.version(1).stores(IDXDB_STORES);
@ -169,15 +175,18 @@ const idxdbMigration: Migration = {
if (conversations.length > 0) {
await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
}
if (messages.length > 0) {
await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
}
// Non-destructive: DO NOT delete old database - keep for downgrade compatibility
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
}
};
@ -419,7 +428,8 @@ const legacyMessageMigration: Migration = {
}
}
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
}
};
@ -434,7 +444,8 @@ const themeMigration: Migration = {
async run(): Promise<void> {
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme === null) {
console.log('[Migration] Theme: no legacy theme key found, skipping');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Theme: no legacy theme key found, skipping');
return;
}
@ -443,7 +454,8 @@ const themeMigration: Migration = {
const config = configRaw ? JSON.parse(configRaw) : {};
if (SETTINGS_KEYS.THEME in config) {
console.log('[Migration] Theme: config already has theme, skipping');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Theme: config already has theme, skipping');
return;
}
@ -451,7 +463,8 @@ const themeMigration: Migration = {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
// Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
}
};
@ -491,7 +504,8 @@ export const MigrationService = {
*/
resetState(): void {
localStorage.removeItem(MIGRATION_STATE_KEY);
console.log('[Migration] State reset - all migrations will run again');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] State reset - all migrations will run again');
},
/**
@ -500,25 +514,30 @@ export const MigrationService = {
*/
async runAllMigrations(): Promise<void> {
const state = getMigrationState();
console.log('[Migration] Starting migration run, state:', state);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Starting migration run, state:', state);
for (const migration of migrations) {
if (isMigrationCompleted(migration.id)) {
console.log(`[Migration] ${migration.id}: already completed, skipping`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: already completed, skipping`);
continue;
}
try {
console.log(`[Migration] ${migration.id}: running...`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: running...`);
await migration.run();
markMigrationCompleted(migration.id);
console.log(`[Migration] ${migration.id}: completed successfully`);
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(`[Migration] ${migration.id}: completed successfully`);
} catch (error) {
console.error(`[Migration] ${migration.id}: failed`, error);
markMigrationFailed(migration.id);
}
}
console.log('[Migration] All migrations complete');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] All migrations complete');
}
};

View file

@ -20,11 +20,11 @@
*/
import { browser } from '$app/environment';
import { base } from '$app/paths';
import { SETTINGS_KEYS } from '$lib/constants';
import { MCPService } from '$lib/services/mcp.service';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { mode } from 'mode-watcher';
import {
parseMcpServerSettings,
@ -43,7 +43,6 @@ import {
ToolCallType
} from '$lib/enums';
import {
CORS_PROXY_ENDPOINT,
DEFAULT_CACHE_TTL_MS,
DEFAULT_MCP_CONFIG,
EXPECTED_THEMED_ICON_PAIR_COUNT,
@ -86,7 +85,6 @@ class MCPStore {
private _toolCount = $state(0);
private _connectedServers = $state<string[]>([]);
private _healthChecks = $state<Record<string, HealthCheckState>>({});
private _proxyAvailable = $state(false);
private connections = new Map<string, MCPConnection>();
private toolsIndex = new Map<string, string>();
@ -96,27 +94,8 @@ class MCPStore {
private initPromise: Promise<boolean> | null = null;
private activeFlowCount = 0;
constructor() {
if (browser) {
this.probeProxy();
}
}
/**
* Probes the CORS proxy endpoint to determine availability.
* The endpoint is only registered when llama-server runs with --ui-mcp-proxy.
*/
async probeProxy(): Promise<void> {
try {
const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' });
this._proxyAvailable = response.status !== 404;
} catch {
this._proxyAvailable = false;
}
}
get isProxyAvailable(): boolean {
return this._proxyAvailable;
return serverStore.props?.cors_proxy_enabled ?? false;
}
/**

View file

@ -3,7 +3,7 @@ import { toast } from 'svelte-sonner';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { PropsService } from '$lib/services/props.service';
import { serverStore } from '$lib/stores/server.svelte';
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
import { TTLCache } from '$lib/utils';
import {
MODEL_PROPS_CACHE_TTL_MS,
@ -14,14 +14,7 @@ import {
import { conversationsStore } from '$lib/stores/conversations.svelte';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
*
* This store manages:
* - Available models list
* - Selected model for new conversations
* - Loaded models tracking (ROUTER mode)
* - Model usage tracking per conversation
* - Automatic unloading of unused models
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes.
*
* **Architecture & Relationships:**
* - **ModelsService**: Stateless service for model API communication
@ -31,14 +24,8 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
*
* **API Inconsistency Workaround:**
* In MODEL mode, `/props` returns modalities for the single model.
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
* In ROUTER mode, `/props` has no modalities must use `/props?model=<id>` per model.
* This store normalizes this behavior so consumers don't need to know the server mode.
*
* **Key Features:**
* - **MODEL mode**: Single model, always loaded
* - **ROUTER mode**: Multi-model with load/unload capability
* - **Auto-unload**: Automatically unloads models not used by any conversation
* - **Lazy loading**: ensureModelLoaded() loads models on demand
*/
class ModelsStore {
/**
@ -57,8 +44,8 @@ class ModelsStore {
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
// dedup concurrent fetch() callers, all awaiters share the same inflight promise
// without this, ?model=<name> URL handler raced an in-progress fetch and saw an empty list
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
private inflightFetch: Promise<void> | null = null;
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
@ -67,9 +54,9 @@ class ModelsStore {
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
/**
* Model-specific props cache with TTL
* Key: modelId, Value: props data including modalities
* TTL: 10 minutes - props don't change frequently
* Model-specific props cache with TTL.
* Key: modelId, Value: props data including modalities.
* TTL: 10 minutes props don't change frequently.
*/
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
@ -78,7 +65,7 @@ class ModelsStore {
private modelPropsFetching = $state<Set<string>>(new Set());
/**
* Version counter for props cache - used to trigger reactivity when props are updated
* Version counter for props cache used to trigger reactivity when props are updated.
*/
propsCacheVersion = $state(0);
@ -92,7 +79,7 @@ class ModelsStore {
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
}
get loadedModelIds(): string[] {
@ -117,7 +104,7 @@ class ModelsStore {
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
if (serverStore.isRouterMode) return null;
if (isRouterMode()) return null;
const props = serverStore.props;
if (props?.model_alias) return props.model_alias;
@ -126,6 +113,11 @@ class ModelsStore {
return props.model_path.split(/(\\|\/)/).pop() || null;
}
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
return this.getModelContextSize(this.selectedModelName);
}
/**
*
*
@ -134,10 +126,6 @@ class ModelsStore {
*
*/
/**
* Get modalities for a specific model
* Returns cached modalities from model props
*/
getModelModalities(modelId: string): ModelModalities | null {
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
@ -146,46 +134,29 @@ class ModelsStore {
const props = this.modelPropsCache.get(modelId);
if (props?.modalities) {
return {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false,
video: props.modalities.video ?? false
};
return this.buildModalities(props.modalities);
}
return null;
}
/**
* Check if a model supports vision modality
*/
modelSupportsVision(modelId: string): boolean {
return this.getModelModalities(modelId)?.vision ?? false;
}
/**
* Check if a model supports audio modality
*/
modelSupportsAudio(modelId: string): boolean {
return this.getModelModalities(modelId)?.audio ?? false;
}
/**
* Check if a model supports video modality
*/
modelSupportsVideo(modelId: string): boolean {
return this.getModelModalities(modelId)?.video ?? false;
}
/**
* Get model modalities as an array of ModelModality enum values
*/
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
if (!modalities) return [];
const result: ModelModality[] = [];
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
if (modalities.video) result.push(ModelModality.VIDEO);
@ -193,16 +164,10 @@ class ModelsStore {
return result;
}
/**
* Get props for a specific model (from cache)
*/
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this.modelPropsCache.get(modelId);
}
/**
* Get context size (n_ctx) for a specific model from cached props
*/
getModelContextSize(modelId: string): number | null {
const props = this.getModelProps(modelId);
const nCtx = props?.default_generation_settings?.n_ctx;
@ -210,17 +175,6 @@ class ModelsStore {
return typeof nCtx === 'number' ? nCtx : null;
}
/**
* Get context size for the currently selected model or null if no model is selected
*/
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
return this.getModelContextSize(this.selectedModelName);
}
/**
* Check if props are being fetched for a model
*/
isModelPropsFetching(modelId: string): boolean {
return this.modelPropsFetching.has(modelId);
}
@ -235,10 +189,10 @@ class ModelsStore {
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return (
model?.status.value === ServerModelStatus.LOADED ||
model?.status.value === ServerModelStatus.SLEEPING ||
false
model?.status.value === ServerModelStatus.SLEEPING
);
}
@ -248,6 +202,7 @@ class ModelsStore {
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value ?? null;
}
@ -257,6 +212,7 @@ class ModelsStore {
isModelInUse(modelId: string): boolean {
const usage = this.modelUsage.get(modelId);
return usage !== undefined && usage.size > 0;
}
@ -269,8 +225,8 @@ class ModelsStore {
*/
/**
* Fetch list of models from server and detect server role
* Also fetches modalities for MODEL mode (single model)
* Fetch list of models from server and detect server role.
* Also fetches modalities for MODEL mode (single model).
*/
async fetch(force = false): Promise<void> {
if (this.inflightFetch) return this.inflightFetch;
@ -293,69 +249,87 @@ class ModelsStore {
await serverStore.fetch();
}
const response = await ModelsService.list();
const router = isRouterMode();
const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
const details = response.models?.[index];
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const displayName = this.toDisplayName(displayNameSource);
const modelId = details?.model || item.id;
if (router) {
const response = await ModelsService.listRouter();
return {
id: item.id,
name: displayName,
model: modelId,
description: details?.description,
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null,
parsedId: ModelsService.parseModelId(modelId),
aliases: item.aliases ?? [],
tags: item.tags ?? []
} satisfies ModelOption;
});
this.routerModels = response.data;
this.models = this.buildModelOptions(response);
this.models = models;
await this.fetchModalitiesForLoadedModels();
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
// but /v1/models doesn't include modalities. We bridge this gap here.
const serverProps = serverStore.props;
if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
const modalities: ModelModalities = {
vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false,
video: serverProps.modalities.video ?? false
};
this.modelPropsCache.set(this.models[0].model, serverProps);
this.models = this.models.map((model, index) =>
index === 0 ? { ...model, modalities } : model
);
const visible = this.getVisibleModels();
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
} else {
this.models = await this.fetchModelModeInternal();
}
} catch (error) {
this.models = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
} finally {
this.loading = false;
}
}
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
private async fetchModelModeInternal(): Promise<ModelOption[]> {
const response = await ModelsService.list();
return this.buildModelOptions(response);
}
/**
* Fetch router models with full metadata (ROUTER mode only)
* This fetches the /models endpoint which returns status info for each model
* Build ModelOption[] from an API response.
* Both MODEL and ROUTER modes share the same mapping logic;
* they differ only in which endpoint is called.
*/
private buildModelOptions(
response: ApiModelListResponse | ApiRouterModelsListResponse
): ModelOption[] {
return response.data.map((item: ApiModelDataEntry, index: number) => {
const details = response.models?.[index];
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const modelId = details?.model || item.id;
return {
id: item.id,
name: this.toDisplayName(displayNameSource),
model: modelId,
description: details?.description,
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null,
parsedId: ModelsService.parseModelId(modelId),
aliases: item.aliases ?? [],
tags: item.tags ?? []
};
});
}
/**
* Fetch router models with full metadata (ROUTER mode only).
* No-op in router mode fetch() already calls listRouter() internally.
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
*/
async fetchRouterModels(): Promise<void> {
if (!isRouterMode()) return;
try {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
await this.fetchModalitiesForLoadedModels();
const o = this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
if (o.length === 1 && this.isModelLoaded(o[0].model)) {
this.selectModelById(o[0].id);
const visible = this.getVisibleModels();
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
this.selectModelById(visible[0].id);
}
} catch (error) {
console.warn('Failed to fetch router models:', error);
@ -364,10 +338,10 @@ class ModelsStore {
}
/**
* Fetch props for a specific model from /props endpoint
* Uses caching to avoid redundant requests
* Fetch props for a specific model from /props endpoint.
* Uses caching to avoid redundant requests.
*
* In ROUTER mode, this will only fetch props if the model is loaded,
* In ROUTER mode, this only fetches props if the model is loaded,
* since unloaded models return 400 from /props endpoint.
*
* @param modelId - Model identifier to fetch props for
@ -397,10 +371,7 @@ class ModelsStore {
}
}
/**
* Fetch modalities for all loaded models from /props endpoint
* This updates the modalities field in models array
*/
/** Fetch modalities for all loaded models from /props endpoint. */
async fetchModalitiesForLoadedModels(): Promise<void> {
const loadedModelIds = this.loadedModelIds;
if (loadedModelIds.length === 0) return;
@ -410,7 +381,6 @@ class ModelsStore {
try {
const results = await Promise.all(propsPromises);
// Update models with modalities
this.models = this.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
@ -418,13 +388,7 @@ class ModelsStore {
const props = results[modelIndex];
if (!props?.modalities) return model;
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false,
video: props.modalities.video ?? false
};
return { ...model, modalities };
return { ...model, modalities: this.buildModalities(props.modalities) };
});
this.propsCacheVersion++;
@ -433,17 +397,38 @@ class ModelsStore {
}
}
/**
* Update modalities for a specific model.
* Called when a model is loaded or when we need fresh modality data.
*/
async updateModelModalities(modelId: string): Promise<void> {
const props = await this.fetchModelProps(modelId);
if (!props?.modalities) return;
this.models = this.models.map((model) =>
model.model === modelId
? { ...model, modalities: this.buildModalities(props.modalities!) }
: model
);
this.propsCacheVersion++;
}
/**
* Filter to models visible in the UI (ui !== false).
*/
private getVisibleModels(): ModelOption[] {
return this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
}
/**
* Gets the model name from the last assistant message in the active conversation.
* Iterates backward through messages to find the most recent message with a model.
* Used by both the chat page and settings page to maintain model consistency.
* @returns The model name or null if not found
*/
getModelFromLastAssistantResponse(): string | null {
const messages = conversationsStore.activeMessages;
if (!messages || messages.length === 0) return null;
// Iterate backward to find the last message with a model
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].model) {
return messages[i].model;
@ -456,22 +441,13 @@ class ModelsStore {
/**
* Auto-selects the model from the last assistant response if available and loaded.
* Returns true if a model was selected, false otherwise.
* This is used by the chat page to maintain model consistency across page navigation.
*/
async selectModelFromLastAssistantResponse(): Promise<boolean> {
const lastModel = this.getModelFromLastAssistantResponse();
if (!lastModel) return false;
// Skip if already selected
if (this.selectedModelName === lastModel) return false;
if (!lastModel || this.selectedModelName === lastModel) return false;
const matchingModel = this.models.find((option) => option.model === lastModel);
if (!matchingModel) return false;
if (!this.isModelLoaded(lastModel)) {
console.log('[modelsStore] last assistant model not loaded:', lastModel);
return false;
}
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
try {
await this.selectModelById(matchingModel.id);
@ -484,22 +460,17 @@ class ModelsStore {
}
/**
* Auto-selects the first available model if none is selected, and fetches its props.
* Auto-selects the first available model if none is selected.
* Prioritizes:
* 1. Model from active conversation's last assistant response (if loaded)
* 2. Model from active conversation's last assistant response (if not loaded)
* 3. First loaded model (not from active conversation)
* 4. First available model
* This is used to ensure default values are populated in settings pages.
*/
async ensureFirstModelSelected(): Promise<void> {
if (this.selectedModelName) return;
// Filter models that are visible in the UI
const availableModels = this.models.filter(
(option) => this.getModelProps(option.model)?.ui !== false
);
const availableModels = this.getVisibleModels();
if (availableModels.length === 0) return;
// Try to select model from last assistant response first
@ -515,7 +486,7 @@ class ModelsStore {
}
}
// Try to find a loaded model first
// Try a loaded model first
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
if (loadedModel) {
await this.selectModelById(loadedModel.id);
@ -524,34 +495,7 @@ class ModelsStore {
}
// Fall back to the first available model
const firstModel = availableModels[0];
await this.selectModelById(firstModel.id);
// Don't fetch props for unloaded models (will fail in ROUTER mode)
}
/**
* Update modalities for a specific model
* Called when a model is loaded or when we need fresh modality data
*/
async updateModelModalities(modelId: string): Promise<void> {
try {
const props = await this.fetchModelProps(modelId);
if (!props?.modalities) return;
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false,
video: props.modalities.video ?? false
};
this.models = this.models.map((model) =>
model.model === modelId ? { ...model, modalities } : model
);
this.propsCacheVersion++;
} catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error);
}
await this.selectModelById(availableModels[0].id);
}
/**
@ -562,9 +506,6 @@ class ModelsStore {
*
*/
/**
* Select a model for new conversations
*/
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) return;
@ -584,8 +525,7 @@ class ModelsStore {
}
/**
* Select a model by its model name (used for syncing with conversation model)
* @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF")
* Select a model by its model name (used for syncing with conversation model).
*/
selectModelByName(modelName: string): void {
const option = this.models.find((model) => model.model === modelName);
@ -615,7 +555,7 @@ class ModelsStore {
/**
*
*
* Loading/Unloading Models
* Loading / Unloading Models
*
*
*/
@ -623,27 +563,18 @@ class ModelsStore {
/**
* WORKAROUND: Polling for model status after load/unload operations.
*
* Currently, the `/models/load` and `/models/unload` endpoints return success
* before the operation actually completes on the server. This means an immediate
* request to `/models` returns stale status (e.g., "loading" after load request,
* "loaded" after unload request).
* Currently, `/models/load` and `/models/unload` return success before
* the operation actually completes on the server.
*
* TODO: Remove this polling once llama-server properly waits for the operation
* to complete before returning success from `/load` and `/unload` endpoints.
* At that point, a single `fetchRouterModels()` call after the operation will
* be sufficient to get the correct status.
* TODO: Remove polling once llama-server properly waits for the operation
* to complete before returning success.
*/
/** Polling interval in ms for checking model status */
private static readonly STATUS_POLL_INTERVAL = 500;
/**
* Poll for expected model status after load/unload operation.
* Keeps polling indefinitely until the model reaches the expected status or fails.
*
* @param modelId - Model identifier to check
* @param expectedStatus - Expected status to wait for
* @throws Error if model reaches FAILED status
* Keeps polling until the model reaches the expected status or fails.
*/
private async pollForModelStatus(
modelId: string,
@ -654,9 +585,7 @@ class ModelsStore {
await this.fetchRouterModels();
const currentStatus = this.getModelStatus(modelId);
if (currentStatus === expectedStatus) {
return;
}
if (currentStatus === expectedStatus) return;
if (currentStatus === ServerModelStatus.FAILED) {
throw new Error(
@ -677,15 +606,8 @@ class ModelsStore {
}
}
/**
* Load a model (ROUTER mode)
* @param modelId - Model identifier to load
*/
async loadModel(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) {
return;
}
if (this.isModelLoaded(modelId)) return;
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
@ -694,7 +616,6 @@ class ModelsStore {
try {
await ModelsService.load(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
@ -706,15 +627,8 @@ class ModelsStore {
}
}
/**
* Unload a model (ROUTER mode)
* @param modelId - Model identifier to unload
*/
async unloadModel(modelId: string): Promise<void> {
if (!this.isModelLoaded(modelId)) {
return;
}
if (!this.isModelLoaded(modelId)) return;
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
@ -722,7 +636,6 @@ class ModelsStore {
try {
await ModelsService.unload(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
@ -734,15 +647,8 @@ class ModelsStore {
}
}
/**
* Ensure a model is loaded before use
* @param modelId - Model identifier to ensure is loaded
*/
async ensureModelLoaded(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) {
return;
}
if (this.isModelLoaded(modelId)) return;
await this.loadModel(modelId);
}
@ -779,11 +685,9 @@ class ModelsStore {
private loadFavoritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favorite models from local storage');
return new Set();
}
}
@ -799,10 +703,19 @@ class ModelsStore {
private toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
return candidate && candidate.trim().length > 0 ? candidate : id;
}
private buildModalities(
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
): ModelModalities {
return {
vision: modalities.vision ?? false,
audio: modalities.audio ?? false,
video: modalities.video ?? false
};
}
clear(): void {
this.models = [];
this.routerModels = [];

View file

@ -203,6 +203,7 @@ export interface ApiLlamaCppServerProps {
/** @deprecated Use {@link ui_settings} instead */
webui_settings?: Record<string, string | number | boolean>;
ui_settings?: Record<string, string | number | boolean>;
cors_proxy_enabled?: boolean;
}
export interface ApiChatCompletionRequest {

View file

@ -12,17 +12,21 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
return;
}
const apiKey = config().apiKey;
// No API key configured — server doesn't require auth, skip the request entirely.
// The /props endpoint is only protected when the server has API keys configured,
// and in that case the client always has one set (from settings).
if (!apiKey) {
return;
}
try {
const apiKey = config().apiKey;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(`${base}/props`, { headers });
if (!response.ok) {

View file

@ -333,7 +333,8 @@ async function migrateConversation(convId: string): Promise<number> {
export async function runLegacyMigration(): Promise<void> {
if (!isMigrationNeeded()) return;
console.log('[Migration] Starting legacy message format migration...');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log('[Migration] Starting legacy message format migration...');
try {
const conversations = await DatabaseService.getAllConversations();
@ -344,12 +345,14 @@ export async function runLegacyMigration(): Promise<void> {
totalMigrated += count;
}
if (totalMigrated > 0) {
console.log(
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
);
} else {
console.log('[Migration] No legacy messages found, marking as done');
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
if (totalMigrated > 0) {
console.log(
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
);
} else {
console.log('[Migration] No legacy messages found, marking as done');
}
}
markMigrationDone();

View file

@ -3,7 +3,6 @@
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
@ -72,23 +71,13 @@
conversationsStore.clearActiveConversation();
chatStore.clearUIState();
if (
isRouterMode() &&
modelsStore.selectedModelName &&
!modelsStore.isModelLoaded(modelsStore.selectedModelName)
) {
modelsStore.clearSelection();
await modelsStore.fetch();
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) {
await modelsStore.selectModelById(first.id);
}
}
// Handle URL params only if we have ?q= or ?model= or ?new_chat=true
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
await handleUrlParams();
}
await modelsStore.ensureFirstModelSelected();
});
</script>

View file

@ -84,29 +84,34 @@
function checkApiKey() {
const apiKey = config().apiKey;
if (
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
page.status !== 401 &&
page.status !== 403
) {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (apiKey && apiKey.trim() !== '') {
headers.Authorization = `Bearer ${apiKey.trim()}`;
}
fetch(`${base}/props`, { headers })
.then((response) => {
if (response.status === 401 || response.status === 403) {
window.location.reload();
}
})
.catch((e) => {
console.error('Error checking API key:', e);
});
// No API key configured — server doesn't require auth, no need to validate.
// This mirrors the early return in validateApiKey() to avoid redundant /props requests.
if (!apiKey || apiKey.trim() === '') {
return;
}
untrack(() => {
if (
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
page.status !== 401 &&
page.status !== 403
) {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey.trim()}`
};
fetch(`${base}/props`, { headers })
.then((response) => {
if (response.status === 401 || response.status === 403) {
window.location.reload();
}
})
.catch((e) => {
console.error('Error checking API key:', e);
});
}
});
}
function handleTitleUpdateCancel() {

View file

@ -4,8 +4,9 @@ import TestWrapper from './components/TestWrapper.svelte';
describe('/+page.svelte', () => {
it('should render page without throwing', async () => {
// Basic smoke test - page should render without throwing errors
// API calls will fail in test environment but component should still mount
expect(() => render(TestWrapper)).not.toThrow();
// Basic smoke test - page should render without throwing errors.
// API calls are mocked in vitest-setup-client.ts.
await render(TestWrapper);
expect(true).toBe(true);
});
});

View file

@ -1,2 +1,78 @@
/// <reference types="@vitest/browser/matchers" />
/// <reference types="@vitest/browser/providers/playwright" />
import { beforeEach, vi } from 'vitest';
// Mock fetch for API calls during client tests.
// In test environment there is no backend server, so we intercept
// the specific endpoints the app uses and return valid mock data.
beforeEach(() => {
const originalFetch = globalThis.fetch;
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
// Mock server props endpoint
if (url.includes('/server')) {
return new Response(
JSON.stringify({
mode: 'router',
version: 'test',
git_commit: 'test',
git_branch: 'test'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// Mock models list endpoint
if (/\/v1\/models|\/models\b/.test(url)) {
return new Response(
JSON.stringify({
object: 'list',
data: [
{
id: 'test-model.gguf',
object: 'model',
owned_by: 'llamacpp',
created: 0,
in_cache: false,
path: 'models/test-model.gguf',
status: { value: 'unloaded' },
meta: {}
}
],
models: [
{
model: 'test-model.gguf',
name: 'Test Model',
details: {}
}
]
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// Mock /props endpoint (used for modalities)
if (url.includes('/props')) {
return new Response(
JSON.stringify({
default_generation_settings: { n_ctx: 2048 }
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
// Mock /tools endpoint (used for built-in tools list)
if (url.includes('/tools')) {
return new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
// Default: use real fetch
return originalFetch(input, init);
});
});