Skip to content
Merged
1 change: 1 addition & 0 deletions tools/server/server-context.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3880,6 +3880,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()) {
Expand Down
1 change: 1 addition & 0 deletions tools/server/server-models.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions tools/ui/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_PUBLIC_APP_NAME='llama-ui'
# VITE_DEBUG='true'
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import { activeMessages } from '$lib/stores/conversations.svelte';

interface Props {
currentModel?: string;
disabled?: boolean;
forceForegroundText?: boolean;
hasAudioModality?: boolean;
Expand All @@ -20,7 +19,6 @@
}

let {
currentModel,
disabled = false,
forceForegroundText = false,
hasAudioModality = $bindable(false),
Expand All @@ -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);
Expand Down Expand Up @@ -151,15 +163,15 @@
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
{currentModel}
currentModel={selectorModel}
{forceForegroundText}
{useGlobalSelection}
/>
{:else}
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
{currentModel}
currentModel={selectorModel}
{forceForegroundText}
{useGlobalSelection}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
32 changes: 26 additions & 6 deletions tools/ui/src/lib/hooks/use-models-selector.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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))
Expand All @@ -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',
Expand All @@ -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) {
Expand All @@ -183,6 +182,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
capabilities: []
};
}

return options.find((option) => option.model === currentModel);
}

Expand All @@ -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
};
}
59 changes: 47 additions & 12 deletions tools/ui/src/lib/services/mcp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand All @@ -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}`);
}

Expand Down Expand Up @@ -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...`);
}

Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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:`,
Expand Down
Loading
Loading