Skip to content

Commit 1ff0fc1

Browse files
authored
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
1 parent a135ec0 commit 1ff0fc1

17 files changed

Lines changed: 413 additions & 352 deletions

File tree

tools/server/server-context.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3885,6 +3885,7 @@ void server_routes::init_routes() {
38853885
{ "eos_token", meta->eos_token_str },
38863886
{ "build_info", meta->build_info },
38873887
{ "is_sleeping", queue_tasks.is_sleeping() },
3888+
{ "cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy },
38883889
};
38893890
if (params.use_jinja) {
38903891
if (!tmpl_tools.empty()) {

tools/server/server-models.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,7 @@ void server_models_routes::init_routes() {
11651165
// Deprecated: use ui_settings instead (kept for backward compat)
11661166
{"webui_settings", webui_settings},
11671167
{"build_info", std::string(llama_build_info())},
1168+
{"cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy},
11681169
});
11691170
return res;
11701171
}

tools/ui/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
VITE_PUBLIC_APP_NAME='llama-ui'
2+
# VITE_DEBUG='true'

tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import { activeMessages } from '$lib/stores/conversations.svelte';
88
99
interface Props {
10-
currentModel?: string;
1110
disabled?: boolean;
1211
forceForegroundText?: boolean;
1312
hasAudioModality?: boolean;
@@ -20,7 +19,6 @@
2019
}
2120
2221
let {
23-
currentModel,
2422
disabled = false,
2523
forceForegroundText = false,
2624
hasAudioModality = $bindable(false),
@@ -41,14 +39,28 @@
4139
4240
let lastSyncedConversationModel: string | null = null;
4341
42+
let selectorModel = $derived(conversationModel ?? modelsStore.selectedModelName ?? null);
43+
4444
$effect(() => {
4545
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
46-
lastSyncedConversationModel = conversationModel;
46+
if (modelOptions().some((m) => m.model === conversationModel)) {
47+
modelsStore.selectedModelName = conversationModel;
48+
modelsStore.selectModelByName(conversationModel);
49+
} else {
50+
modelsStore.selectedModelName = null;
51+
modelsStore.clearSelection();
52+
}
4753
48-
modelsStore.selectModelByName(conversationModel);
49-
} else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
54+
lastSyncedConversationModel = conversationModel;
55+
} else if (
56+
isRouter &&
57+
!modelsStore.selectedModelId &&
58+
modelsStore.loadedModelIds.length > 0 &&
59+
activeMessages().length > 0 &&
60+
!conversationModel
61+
) {
5062
lastSyncedConversationModel = null;
51-
// auto-select the first loaded model only when nothing is selected yet
63+
5264
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
5365
5466
if (first) modelsStore.selectModelById(first.id);
@@ -151,15 +163,15 @@
151163
<ModelsSelectorSheet
152164
disabled={disabled || isOffline}
153165
bind:this={selectorModelRef}
154-
{currentModel}
166+
currentModel={selectorModel}
155167
{forceForegroundText}
156168
{useGlobalSelection}
157169
/>
158170
{:else}
159171
<ModelsSelectorDropdown
160172
disabled={disabled || isOffline}
161173
bind:this={selectorModelRef}
162-
{currentModel}
174+
currentModel={selectorModel}
163175
{forceForegroundText}
164176
{useGlobalSelection}
165177
/>

tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@
162162
return;
163163
}
164164
165-
if (import.meta.env.DEV) {
165+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
166166
console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
167167
serverName: selectedPrompt.serverName,
168168
promptName: selectedPrompt.name,
@@ -181,7 +181,7 @@
181181
value
182182
);
183183
184-
if (import.meta.env.DEV) {
184+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
185185
console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
186186
argName,
187187
value,

tools/ui/src/lib/hooks/use-models-selector.svelte.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
6666
const serverModel = $derived(singleModelName());
6767

6868
const currentModel = $derived(opts.currentModel());
69-
const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false);
7069
const onModelChange = $derived(opts.onModelChange?.());
7170

7271
const isHighlightedCurrentModelActive = $derived.by(() => {
@@ -128,6 +127,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
128127

129128
if (onModelChange) {
130129
const result = await onModelChange(option.id, option.model);
130+
131131
if (result === false) {
132132
shouldCloseMenu = false;
133133
}
@@ -142,12 +142,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
142142
const textarea = document.querySelector<HTMLTextAreaElement>(
143143
'[data-slot="chat-form"] textarea'
144144
);
145+
145146
textarea?.focus();
146147
});
147148
}
148149

149150
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
150151
isLoadingModel = true;
152+
151153
modelsStore
152154
.loadModel(option.model)
153155
.catch((error) => console.error('Failed to load model:', error))
@@ -158,6 +160,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
158160
function getDisplayOption(): ModelOption | undefined {
159161
if (!isRouter) {
160162
const displayModel = serverModel || currentModel;
163+
161164
if (displayModel) {
162165
return {
163166
id: serverModel ? 'current' : 'offline-current',
@@ -166,12 +169,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
166169
capabilities: []
167170
};
168171
}
169-
return undefined;
170-
}
171172

172-
if (useGlobalSelection && activeId) {
173-
const selected = options.find((option) => option.id === activeId);
174-
if (selected) return selected;
173+
return undefined;
175174
}
176175

177176
if (currentModel) {
@@ -183,6 +182,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
183182
capabilities: []
184183
};
185184
}
185+
186186
return options.find((option) => option.model === currentModel);
187187
}
188188

@@ -197,57 +197,77 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
197197
get options() {
198198
return options;
199199
},
200+
200201
get loading() {
201202
return loading;
202203
},
204+
203205
get updating() {
204206
return updating;
205207
},
208+
206209
get activeId() {
207210
return activeId;
208211
},
212+
209213
get isRouter() {
210214
return isRouter;
211215
},
216+
212217
get serverModel() {
213218
return serverModel;
214219
},
220+
215221
get isHighlightedCurrentModelActive() {
216222
return isHighlightedCurrentModelActive;
217223
},
224+
218225
get isCurrentModelInCache() {
219226
return isCurrentModelInCache;
220227
},
228+
221229
get filteredOptions() {
222230
return filteredOptions;
223231
},
232+
224233
get groupedFilteredOptions() {
225234
return groupedFilteredOptions;
226235
},
236+
227237
get isLoadingModel() {
228238
return isLoadingModel;
229239
},
240+
230241
get searchTerm() {
231242
return searchTerm;
232243
},
244+
233245
get showModelDialog() {
234246
return showModelDialog;
235247
},
248+
236249
get infoModelId() {
237250
return infoModelId;
238251
},
252+
239253
setSearchTerm(value: string) {
240254
searchTerm = value;
241255
},
256+
242257
setShowModelDialog(value: boolean) {
243258
showModelDialog = value;
244259
},
260+
245261
handleInfoClick,
262+
246263
handleSelect,
264+
247265
handleOpenChange,
266+
248267
isFavorite(model: string) {
249268
return modelsStore.favoriteModelIds.has(model);
250269
},
270+
251271
getDisplayOption
252272
};
253273
}

tools/ui/src/lib/services/mcp.service.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ export class MCPService {
392392

393393
const url = new URL(config.url);
394394

395-
if (import.meta.env.DEV) {
395+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
396396
console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
397397
}
398398

@@ -413,12 +413,12 @@ export class MCPService {
413413
onLog
414414
);
415415

416-
if (useProxy && import.meta.env.DEV) {
416+
if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
417417
console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
418418
}
419419

420420
try {
421-
if (import.meta.env.DEV) {
421+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
422422
console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
423423
}
424424

@@ -520,7 +520,7 @@ export class MCPService {
520520
)
521521
);
522522

523-
if (import.meta.env.DEV) {
523+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
524524
console.log(`[MCPService][${serverName}] Creating transport...`);
525525
}
526526

@@ -560,6 +560,22 @@ export class MCPService {
560560
);
561561

562562
const runtimeErrorHandler = (error: Error) => {
563+
// Ignore errors that are expected when the SDK's transport is closed,
564+
// or when connecting to servers that don't support SSE (stateless-only
565+
// endpoints returning 405). The SDK wraps the original AbortError in
566+
// a new Error with the message "SSE stream disconnected: AbortError",
567+
// and also produces "Cannot cancel a stream locked by a reader".
568+
// DOMException is thrown by the browser when aborting fetch requests.
569+
const msg = error.message || String(error);
570+
if (
571+
error.name === 'AbortError' ||
572+
error instanceof DOMException ||
573+
msg.includes('SSE stream disconnected') ||
574+
msg.includes('stream locked by a reader') ||
575+
msg.includes('The operation was aborted')
576+
) {
577+
return;
578+
}
563579
console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
564580
};
565581

@@ -658,7 +674,10 @@ export class MCPService {
658674
this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
659675
);
660676

661-
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
677+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
678+
console.log(`[MCPService][${serverName}] Connected, listing tools...`);
679+
}
680+
662681
const tools = await this.listTools({
663682
client,
664683
transport,
@@ -680,10 +699,11 @@ export class MCPService {
680699
`Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
681700
)
682701
);
683-
684-
console.log(
685-
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
686-
);
702+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
703+
console.log(
704+
`[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
705+
);
706+
}
687707

688708
return {
689709
client,
@@ -709,9 +729,22 @@ export class MCPService {
709729
* @param connection - The active MCP connection to close
710730
*/
711731
static async disconnect(connection: MCPConnection): Promise<void> {
712-
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
732+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
733+
console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
734+
}
735+
713736
try {
714-
// Prevent reconnection on voluntary disconnect
737+
// Terminate the session first for streamable-http transports to cleanly
738+
// close streams, matching the inspector's disconnect flow.
739+
if (connection.transport instanceof StreamableHTTPClientTransport) {
740+
await connection.transport.terminateSession();
741+
}
742+
743+
// Clear error handlers before closing to prevent noise from expected
744+
// abort errors during shutdown. The inspector avoids this entirely
745+
// by not setting onerror, but since we use it for protocol logging,
746+
// we must clear it before disconnect.
747+
connection.client.onerror = undefined;
715748
if (connection.transport.onclose) {
716749
connection.transport.onclose = undefined;
717750
}
@@ -1078,7 +1111,9 @@ export class MCPService {
10781111
try {
10791112
await connection.client.unsubscribeResource({ uri });
10801113

1081-
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
1114+
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
1115+
console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
1116+
}
10821117
} catch (error) {
10831118
console.error(
10841119
`[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,

0 commit comments

Comments
 (0)