char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
12.5 KiB464 linesraw
1import { extractRootDomain } from './url';
2import {
3	AlertTriangle,
4	Code,
5	Database,
6	File,
7	FileText,
8	Image,
9	Info,
10	XCircle
11} from '@lucide/svelte';
12import {
13	CODE_FILE_EXTENSION_REGEX,
14	DEFAULT_RESOURCE_FILENAME,
15	DISPLAY_NAME_SEPARATOR_REGEX,
16	EXPECTED_THEMED_ICON_PAIR_COUNT,
17	FILE_EXTENSION_REGEX,
18	IMAGE_FILE_EXTENSION_REGEX,
19	MCP_ALLOWED_ICON_MIME_TYPES,
20	MCP_SERVER_ID_PREFIX,
21	MCP_SSE,
22	MIME_TYPE_PREFIXES,
23	MIME_TYPE_SUBSTRINGS,
24	PATH_SEPARATOR,
25	PROTOCOL_PREFIX_REGEX,
26	RESOURCE_TEXT_CONTENT_SEPARATOR,
27	TEXT_FILE_EXTENSION_REGEX,
28	URI_PATTERNS
29} from '$lib/constants';
30import {
31	ColorMode,
32	HealthCheckStatus,
33	MCPLogLevel,
34	MCPTransportType,
35	MimeTypeText,
36	UrlProtocol
37} from '$lib/enums';
38import type {
39	HealthCheckState,
40	MCPResourceContent,
41	MCPResourceIcon,
42	MCPResourceInfo,
43	MCPServerDisplayInfo,
44	MCPServerSettingsEntry
45} from '$lib/types';
46import type { MimeTypeUnion } from '$lib/types/common';
47import type { Component } from 'svelte';
48
49/**
50 * Detects the MCP transport type from a URL.
51 * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
52 */
53export function detectMcpTransportFromUrl(url: string): MCPTransportType {
54	const normalized = url.trim().toLowerCase();
55
56	if (
57		normalized.startsWith(UrlProtocol.WEBSOCKET) ||
58		normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE)
59	) {
60		return MCPTransportType.WEBSOCKET;
61	}
62
63	if (
64		normalized.endsWith(MCP_SSE.ENDPOINT) ||
65		normalized.endsWith(MCP_SSE.ENDPOINT_SLASH) ||
66		normalized.includes(MCP_SSE.ENDPOINT_QUERY)
67	) {
68		return MCPTransportType.SSE;
69	}
70
71	return MCPTransportType.STREAMABLE_HTTP;
72}
73
74/**
75 * Parses MCP server settings from a JSON string or array.
76 * @param rawServers - The raw servers to parse
77 * @returns An empty array if the input is invalid.
78 */
79export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
80	if (!rawServers) return [];
81
82	let parsed: unknown;
83
84	if (typeof rawServers === 'string') {
85		const trimmed = rawServers.trim();
86
87		if (!trimmed) return [];
88
89		try {
90			parsed = JSON.parse(trimmed);
91		} catch (error) {
92			console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
93
94			return [];
95		}
96	} else {
97		parsed = rawServers;
98	}
99
100	if (!Array.isArray(parsed)) return [];
101
102	return parsed.map((entry, index) => {
103		const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
104		const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
105		const id =
106			typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
107				? (entry as { id: string }).id.trim()
108				: `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
109
110		return {
111			displayName: (entry as { displayName?: string })?.displayName,
112			enabled: Boolean((entry as { enabled?: unknown })?.enabled),
113			headers: headers || undefined,
114			id,
115			name: (entry as { name?: string })?.name,
116			url,
117			useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
118		} satisfies MCPServerSettingsEntry;
119	});
120}
121
122/**
123 * Get the appropriate icon component for a log level
124 *
125 * @param level - MCP log level
126 * @returns Lucide icon component
127 */
128export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
129	switch (level) {
130		case MCPLogLevel.ERROR:
131			return XCircle;
132		case MCPLogLevel.WARN:
133			return AlertTriangle;
134		default:
135			return Info;
136	}
137}
138
139/**
140 * Get the appropriate CSS class for a log level
141 *
142 * @param level - MCP log level
143 * @returns Tailwind CSS class string
144 */
145export function getMcpLogLevelClass(level: MCPLogLevel): string {
146	switch (level) {
147		case MCPLogLevel.ERROR:
148			return 'text-destructive';
149		case MCPLogLevel.WARN:
150			return 'text-yellow-600 dark:text-yellow-500';
151		default:
152			return 'text-muted-foreground';
153	}
154}
155
156/**
157 * Check if a MIME type represents an image.
158 *
159 * @param mimeType - The MIME type to check
160 * @returns True if the MIME type starts with 'image/'
161 */
162export function isImageMimeType(mimeType?: MimeTypeUnion): boolean {
163	return mimeType?.startsWith(MIME_TYPE_PREFIXES.IMAGE) ?? false;
164}
165
166/**
167 * Parse a resource URI into path segments, stripping the protocol prefix.
168 *
169 * @param uri - The resource URI to parse
170 * @returns Array of non-empty path segments
171 */
172export function parseResourcePath(uri: string): string[] {
173	try {
174		const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
175
176		return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
177	} catch {
178		return [uri];
179	}
180}
181
182/**
183 * Convert a path part into a human-readable display name.
184 * Strips file extensions and converts kebab-case/snake_case to Title Case.
185 *
186 * @param pathPart - The path segment to convert
187 * @returns Human-readable display name
188 */
189export function getDisplayName(pathPart: string): string {
190	const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
191
192	return withoutExt
193		.split(DISPLAY_NAME_SEPARATOR_REGEX)
194		.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
195		.join(' ');
196}
197
198/**
199 * Get the display name from a resource, extracting the last path segment from the URI.
200 *
201 * @param resource - The MCP resource info
202 * @returns Display name string
203 */
204export function getResourceDisplayName(resource: MCPResourceInfo): string {
205	try {
206		const parts = parseResourcePath(resource.uri);
207
208		return parts[parts.length - 1] || resource.name || resource.uri;
209	} catch {
210		return resource.name || resource.uri;
211	}
212}
213
214/**
215 * Determine if a MIME type and/or URI represents code content.
216 *
217 * @param mimeType - Optional MIME type string
218 * @param uri - Optional URI string
219 * @returns True if the content is code
220 */
221export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
222	const mime = mimeType?.toLowerCase() || '';
223	const u = uri?.toLowerCase() || '';
224
225	return (
226		mime.includes(MIME_TYPE_SUBSTRINGS.JSON) ||
227		mime.includes(MIME_TYPE_SUBSTRINGS.JAVASCRIPT) ||
228		mime.includes(MIME_TYPE_SUBSTRINGS.TYPESCRIPT) ||
229		CODE_FILE_EXTENSION_REGEX.test(u)
230	);
231}
232
233/**
234 * Determine if a MIME type and/or URI represents image content.
235 *
236 * @param mimeType - Optional MIME type string
237 * @param uri - Optional URI string
238 * @returns True if the content is an image
239 */
240export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
241	const mime = mimeType?.toLowerCase() || '';
242	const u = uri?.toLowerCase() || '';
243
244	return mime.startsWith(MIME_TYPE_PREFIXES.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
245}
246
247/**
248 * Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI.
249 *
250 * @param mimeType - Optional MIME type of the resource
251 * @param uri - Optional URI of the resource
252 * @returns Lucide icon component
253 */
254export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component {
255	const mime = mimeType?.toLowerCase() || '';
256	const u = uri?.toLowerCase() || '';
257
258	if (mime.startsWith(MIME_TYPE_PREFIXES.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
259		return Image;
260	}
261
262	if (
263		mime.includes(MIME_TYPE_SUBSTRINGS.JSON) ||
264		mime.includes(MIME_TYPE_SUBSTRINGS.JAVASCRIPT) ||
265		mime.includes(MIME_TYPE_SUBSTRINGS.TYPESCRIPT) ||
266		CODE_FILE_EXTENSION_REGEX.test(u)
267	) {
268		return Code;
269	}
270
271	if (mime.includes(MIME_TYPE_PREFIXES.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
272		return FileText;
273	}
274
275	if (u.includes(URI_PATTERNS.DATABASE_KEYWORD) || u.includes(URI_PATTERNS.DATABASE_SCHEME)) {
276		return Database;
277	}
278
279	return File;
280}
281
282/**
283 * Extract text content from MCP resource content array.
284 *
285 * @param content - Array of MCP resource content items
286 * @returns Joined text content string
287 */
288export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
289	if (!content) return '';
290
291	return content
292		.filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
293		.map((c) => c.text)
294		.join(RESOURCE_TEXT_CONTENT_SEPARATOR);
295}
296
297/**
298 * Extract blob content from MCP resource content array.
299 *
300 * @param content - Array of MCP resource content items
301 * @returns Array of blob content items
302 */
303export function getResourceBlobContent(
304	content: MCPResourceContent[] | null | undefined
305): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> {
306	if (!content) return [];
307
308	return content.filter(
309		(c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c
310	);
311}
312
313/**
314 * Trigger a file download from text content.
315 *
316 * @param text - The text content to download
317 * @param mimeType - MIME type for the blob
318 * @param filename - Suggested filename
319 */
320export function downloadResourceContent(
321	text: string,
322	mimeType: MimeTypeUnion = MimeTypeText.PLAIN,
323	filename: string = DEFAULT_RESOURCE_FILENAME
324): void {
325	const blob = new Blob([text], { type: mimeType });
326	const url = URL.createObjectURL(blob);
327	const a = document.createElement('a');
328
329	a.href = url;
330	a.download = filename;
331	document.body.appendChild(a);
332	a.click();
333	document.body.removeChild(a);
334	URL.revokeObjectURL(url);
335}
336
337/**
338 * Validates that an icon URI uses a safe scheme (https: or data:).
339 */
340function isValidMcpIconUri(src: string): boolean {
341	try {
342		if (src.startsWith(UrlProtocol.DATA)) return true;
343
344		const url = new URL(src);
345
346		return url.protocol === UrlProtocol.HTTPS;
347	} catch {
348		return false;
349	}
350}
351
352/**
353 * Selects the best icon URL from an MCP icons array.
354 * Follows security guidelines from the MCP specification:
355 * - Only allows https: and data: URIs
356 * - Filters to supported MIME types
357 *
358 * Selection priority:
359 * 1. Icon matching the current color scheme (dark/light)
360 * 2. Universal icon (no theme specified); if exactly 2, assumes [0]=light, [1]=dark
361 * 3. First valid icon as last resort
362 */
363export function getMcpIconUrl(icons: MCPResourceIcon[] | undefined, isDark = false): string | null {
364	if (!icons?.length) return null;
365
366	const validIcons = icons.filter((icon) => {
367		if (!icon.src || !isValidMcpIconUri(icon.src)) return false;
368
369		if (icon.mimeType && !MCP_ALLOWED_ICON_MIME_TYPES.has(icon.mimeType)) return false;
370
371		return true;
372	});
373
374	if (validIcons.length === 0) return null;
375
376	const preferredTheme = isDark ? ColorMode.DARK : ColorMode.LIGHT;
377	// 1. Prefer icon explicitly matching the current color scheme
378	const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
379
380	if (themedIcon) return themedIcon.src;
381
382	// 2. Handle universal icons (no theme specified)
383	const universalIcons = validIcons.filter((icon) => !icon.theme);
384
385	if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
386		// Heuristic: two theme-less icons → assume [0] = light, [1] = dark
387		return universalIcons[isDark ? 1 : 0].src;
388	}
389
390	if (universalIcons.length > 0) {
391		return universalIcons[0].src;
392	}
393
394	// 3. Last resort: use opposite-theme icon
395	return validIcons[0].src;
396}
397
398/**
399 * Construct a fallback favicon URL from the MCP server URL.
400 * e.g. https://mcp.example.com/sse -> https://example.com/favicon.ico
401 */
402export function getMcpServerFaviconFallback(serverUrl: string): string | null {
403	try {
404		const url = new URL(serverUrl);
405		const rootDomain = extractRootDomain(url);
406
407		if (!rootDomain) return null;
408
409		const origin = `${url.protocol}//${rootDomain}`;
410		const candidates = ['favicon.ico', 'favicon.png'];
411
412		for (const path of candidates) {
413			const faviconUrl = `${origin}/${path}`;
414
415			if (isValidMcpIconUri(faviconUrl)) {
416				return faviconUrl;
417			}
418		}
419	} catch {
420		// Invalid URL, return null
421	}
422
423	return null;
424}
425
426/**
427 * Resolves the raw label for a server: user-defined display name first,
428 * then server-reported title or name when the health check succeeded,
429 * then the configured name (admin baseline or legacy data), then URL.
430 */
431function getMcpServerBaseLabel(
432	server: MCPServerDisplayInfo,
433	healthState?: HealthCheckState
434): string {
435	if (server.displayName) return server.displayName;
436
437	if (healthState?.status === HealthCheckStatus.SUCCESS)
438		return (
439			healthState.serverInfo?.title || healthState.serverInfo?.name || server.name || server.url
440		);
441
442	return server.name || server.url;
443}
444
445/**
446 * Returns the display label for a server, suffixed with a positional
447 * counter when several configured servers resolve to the same base label
448 * (e.g. two endpoints of the same host reporting an identical name).
449 * Numbering follows config order, so it is stable across renders.
450 */
451export function getMcpServerLabel(
452	server: MCPServerDisplayInfo,
453	servers: MCPServerDisplayInfo[],
454	healthChecks: Record<string, HealthCheckState>
455): string {
456	const label = getMcpServerBaseLabel(server, healthChecks[server.id]);
457	const twins = servers.filter((s) => getMcpServerBaseLabel(s, healthChecks[s.id]) === label);
458
459	if (twins.length < 2) return label;
460
461	const position = twins.findIndex((s) => s.id === server.id);
462
463	return position < 0 ? label : `${label} (${position + 1})`;
464}