char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
1import { extractRootDomain } from './url' ; 2import { 3AlertTriangle , 4Code , 5Database , 6File , 7FileText , 8Image , 9Info , 10XCircle 11} from '@lucide/svelte' ; 12import { 13CODE_FILE_EXTENSION_REGEX , 14DEFAULT_RESOURCE_FILENAME , 15DISPLAY_NAME_SEPARATOR_REGEX , 16EXPECTED_THEMED_ICON_PAIR_COUNT , 17FILE_EXTENSION_REGEX , 18IMAGE_FILE_EXTENSION_REGEX , 19MCP_ALLOWED_ICON_MIME_TYPES , 20MCP_SERVER_ID_PREFIX , 21MCP_SSE , 22MIME_TYPE_PREFIXES , 23MIME_TYPE_SUBSTRINGS , 24PATH_SEPARATOR , 25PROTOCOL_PREFIX_REGEX , 26RESOURCE_TEXT_CONTENT_SEPARATOR , 27TEXT_FILE_EXTENSION_REGEX , 28URI_PATTERNS 29} from '$lib/constants' ; 30import { 31ColorMode , 32HealthCheckStatus , 33MCPLogLevel , 34MCPTransportType , 35MimeTypeText , 36UrlProtocol 37} from '$lib/enums' ; 38import type { 39HealthCheckState , 40MCPResourceContent , 41MCPResourceIcon , 42MCPResourceInfo , 43MCPServerDisplayInfo , 44MCPServerSettingsEntry 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 { 54const normalized = url . trim (). toLowerCase (); 55 56if ( 57normalized . startsWith ( UrlProtocol . WEBSOCKET ) || 58normalized . startsWith ( UrlProtocol . WEBSOCKET_SECURE ) 59) { 60return MCPTransportType . WEBSOCKET ; 61} 62 63if ( 64normalized . endsWith ( MCP_SSE . ENDPOINT ) || 65normalized . endsWith ( MCP_SSE . ENDPOINT_SLASH ) || 66normalized . includes ( MCP_SSE . ENDPOINT_QUERY ) 67) { 68return MCPTransportType . SSE ; 69} 70 71return 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 [] { 80if ( ! rawServers ) return []; 81 82let parsed :unknown ; 83 84if ( typeof rawServers === 'string' ) { 85const trimmed = rawServers . trim (); 86 87if ( ! trimmed ) return []; 88 89try { 90parsed = JSON . parse ( trimmed ); 91} catch ( error ) { 92console . warn ( '[MCP] Failed to parse mcpServers JSON, ignoring value:' , error ); 93 94return []; 95} 96} else { 97parsed = rawServers ; 98} 99 100if ( ! Array . isArray ( parsed )) return []; 101 102return parsed . map (( entry , index ) => { 103const url = typeof entry ?. url === 'string' ?entry . url . trim () :'' ; 104const headers = typeof entry ?. headers === 'string' ?entry . headers . trim () :undefined ; 105const id = 106typeof ( 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 110return { 111displayName :( entry as { displayName ?:string })?. displayName , 112enabled :Boolean (( entry as { enabled ?:unknown })?. enabled ), 113headers :headers || undefined , 114 id, 115name :( entry as { name ?:string })?. name , 116 url, 117useProxy :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 { 129switch ( level ) { 130case MCPLogLevel . ERROR : 131return XCircle ; 132case MCPLogLevel . WARN : 133return AlertTriangle ; 134default : 135return 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 { 146switch ( level ) { 147case MCPLogLevel . ERROR : 148return 'text-destructive' ; 149case MCPLogLevel . WARN : 150return 'text-yellow-600 dark:text-yellow-500' ; 151default : 152return '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 { 163return 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 [] { 173try { 174const withoutProtocol = uri . replace ( PROTOCOL_PREFIX_REGEX , '' ); 175 176return withoutProtocol . split ( PATH_SEPARATOR ). filter (( p ) => p . length > 0 ); 177} catch { 178return [ 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 { 190const withoutExt = pathPart . replace ( FILE_EXTENSION_REGEX , '' ); 191 192return 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 { 205try { 206const parts = parseResourcePath ( resource . uri ); 207 208return parts [ parts . length - 1 ] || resource . name || resource . uri ; 209} catch { 210return 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 { 222const mime = mimeType ?. toLowerCase () || '' ; 223const u = uri ?. toLowerCase () || '' ; 224 225return ( 226mime . includes ( MIME_TYPE_SUBSTRINGS . JSON ) || 227mime . includes ( MIME_TYPE_SUBSTRINGS . JAVASCRIPT ) || 228mime . includes ( MIME_TYPE_SUBSTRINGS . TYPESCRIPT ) || 229CODE_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 { 241const mime = mimeType ?. toLowerCase () || '' ; 242const u = uri ?. toLowerCase () || '' ; 243 244return 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 { 255const mime = mimeType ?. toLowerCase () || '' ; 256const u = uri ?. toLowerCase () || '' ; 257 258if ( mime . startsWith ( MIME_TYPE_PREFIXES . IMAGE ) || IMAGE_FILE_EXTENSION_REGEX . test ( u )) { 259return Image ; 260} 261 262if ( 263mime . includes ( MIME_TYPE_SUBSTRINGS . JSON ) || 264mime . includes ( MIME_TYPE_SUBSTRINGS . JAVASCRIPT ) || 265mime . includes ( MIME_TYPE_SUBSTRINGS . TYPESCRIPT ) || 266CODE_FILE_EXTENSION_REGEX . test ( u ) 267) { 268return Code ; 269} 270 271if ( mime . includes ( MIME_TYPE_PREFIXES . TEXT ) || TEXT_FILE_EXTENSION_REGEX . test ( u )) { 272return FileText ; 273} 274 275if ( u . includes ( URI_PATTERNS . DATABASE_KEYWORD ) || u . includes ( URI_PATTERNS . DATABASE_SCHEME )) { 276return Database ; 277} 278 279return 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 { 289if ( ! content ) return '' ; 290 291return 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 ( 304content :MCPResourceContent [] | null | undefined 305) :Array <{ uri :string ; mimeType ?:MimeTypeUnion ; blob :string }> { 306if ( ! content ) return []; 307 308return 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 ( 321text :string , 322mimeType :MimeTypeUnion = MimeTypeText . PLAIN , 323filename :string = DEFAULT_RESOURCE_FILENAME 324) :void { 325const blob = new Blob ([ text ], { type :mimeType }); 326const url = URL . createObjectURL ( blob ); 327const a = document . createElement ( 'a' ); 328 329a . href = url ; 330a . download = filename ; 331document . body . appendChild ( a ); 332a . click (); 333document . body . removeChild ( a ); 334URL . revokeObjectURL ( url ); 335} 336 337/** 338* Validates that an icon URI uses a safe scheme (https: or data:). 339*/ 340function isValidMcpIconUri ( src :string ) :boolean { 341try { 342if ( src . startsWith ( UrlProtocol . DATA )) return true ; 343 344const url = new URL ( src ); 345 346return url . protocol === UrlProtocol . HTTPS ; 347} catch { 348return 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 { 364if ( ! icons ?. length ) return null ; 365 366const validIcons = icons . filter (( icon ) => { 367if ( ! icon . src || ! isValidMcpIconUri ( icon . src )) return false ; 368 369if ( icon . mimeType && ! MCP_ALLOWED_ICON_MIME_TYPES . has ( icon . mimeType )) return false ; 370 371return true ; 372}); 373 374if ( validIcons . length === 0 ) return null ; 375 376const preferredTheme = isDark ?ColorMode . DARK :ColorMode . LIGHT ; 377// 1. Prefer icon explicitly matching the current color scheme 378const themedIcon = validIcons . find (( icon ) => icon . theme === preferredTheme ); 379 380if ( themedIcon ) return themedIcon . src ; 381 382// 2. Handle universal icons (no theme specified) 383const universalIcons = validIcons . filter (( icon ) => ! icon . theme ); 384 385if ( universalIcons . length === EXPECTED_THEMED_ICON_PAIR_COUNT ) { 386// Heuristic: two theme-less icons → assume [0] = light, [1] = dark 387return universalIcons [ isDark ?1 :0 ]. src ; 388} 389 390if ( universalIcons . length > 0 ) { 391return universalIcons [ 0 ]. src ; 392} 393 394// 3. Last resort: use opposite-theme icon 395return 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 { 403try { 404const url = new URL ( serverUrl ); 405const rootDomain = extractRootDomain ( url ); 406 407if ( ! rootDomain ) return null ; 408 409const origin = ` ${ url . protocol } // ${ rootDomain } ` ; 410const candidates = [ 'favicon.ico' , 'favicon.png' ]; 411 412for ( const path of candidates ) { 413const faviconUrl = ` ${ origin } / ${ path } ` ; 414 415if ( isValidMcpIconUri ( faviconUrl )) { 416return faviconUrl ; 417} 418} 419} catch { 420// Invalid URL, return null 421} 422 423return 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 ( 432server :MCPServerDisplayInfo , 433healthState ?:HealthCheckState 434) :string { 435if ( server . displayName ) return server . displayName ; 436 437if ( healthState ?. status === HealthCheckStatus . SUCCESS ) 438return ( 439healthState . serverInfo ?. title || healthState . serverInfo ?. name || server . name || server . url 440); 441 442return 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 ( 452server :MCPServerDisplayInfo , 453servers :MCPServerDisplayInfo [], 454healthChecks :Record < string , HealthCheckState > 455) :string { 456const label = getMcpServerBaseLabel ( server , healthChecks [ server . id ]); 457const twins = servers . filter (( s ) => getMcpServerBaseLabel ( s , healthChecks [ s . id ]) === label ); 458 459if ( twins . length < 2 ) return label ; 460 461const position = twins . findIndex (( s ) => s . id === server . id ); 462 463return position < 0 ?label :` ${ label } ( ${ position + 1 } )` ; 464}