diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte index cb8b2e4eec..cb55c12327 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/AgenticContent.svelte @@ -15,6 +15,8 @@ import { config } from '$lib/stores/settings.svelte'; import { Wrench, Loader2 } from '@lucide/svelte'; import { AgenticSectionType } from '$lib/types/agentic'; + import { AGENTIC_TAGS, AGENTIC_REGEX } from '$lib/constants/agentic'; + import { formatJsonPretty } from '$lib/utils/formatters'; interface Props { content: string; @@ -30,21 +32,17 @@ let { content }: Props = $props(); - // Parse content into chronological sections const sections = $derived(parseAgenticContent(content)); - // Track expanded state for each tool call (default expanded) let expandedStates: Record = $state({}); - // Get showToolCallInProgress setting const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean); function isExpanded(index: number, isPending: boolean): boolean { - // If showToolCallInProgress is enabled and tool is pending, force expand if (showToolCallInProgress && isPending) { return true; } - // Otherwise use stored state, defaulting to expanded only if showToolCallInProgress is true + return expandedStates[index] ?? showToolCallInProgress; } @@ -57,16 +55,12 @@ const sections: AgenticSection[] = []; - // Regex for completed tool calls (with END marker) - const completedToolCallRegex = - /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>/g; + const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g'); let lastIndex = 0; let match; - // First pass: find all completed tool calls while ((match = completedToolCallRegex.exec(rawContent)) !== null) { - // Add text before this tool call if (match.index > lastIndex) { const textBefore = rawContent.slice(lastIndex, match.index).trim(); if (textBefore) { @@ -74,7 +68,6 @@ } } - // Add completed tool call section const toolName = match[1]; const toolArgsBase64 = match[2]; let toolArgs = ''; @@ -96,26 +89,16 @@ lastIndex = match.index + match[0].length; } - // Check for pending tool call at the end (START without END) const remainingContent = rawContent.slice(lastIndex); - // Full pending match (has NAME and ARGS) - const pendingMatch = remainingContent.match( - /<<>>\n<<>>\n<<>>([\s\S]*)$/ - ); + const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL); - // Partial pending match (has START and NAME but ARGS still streaming) - // Capture everything after TOOL_ARGS_BASE64: until the end - const partialWithNameMatch = remainingContent.match( - /<<>>\n<<>>\n<<>>([\s\S]*)$/); + const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH); if (pendingMatch) { - // Add text before pending tool call - const pendingIndex = remainingContent.indexOf('<<>>'); + const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START); if (pendingIndex > 0) { const textBefore = remainingContent.slice(0, pendingIndex).trim(); if (textBefore) { @@ -123,7 +106,6 @@ } } - // Add pending tool call const toolName = pendingMatch[1]; const toolArgsBase64 = pendingMatch[2]; let toolArgs = ''; @@ -143,8 +125,7 @@ toolResult: streamingResult || undefined }); } else if (partialWithNameMatch) { - // Has START and NAME, ARGS still streaming - const pendingIndex = remainingContent.indexOf('<<>>'); + const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START); if (pendingIndex > 0) { const textBefore = remainingContent.slice(0, pendingIndex).trim(); if (textBefore) { @@ -152,7 +133,6 @@ } } - // Try to decode partial base64 args const partialArgsBase64 = partialWithNameMatch[2] || ''; let partialArgs = ''; if (partialArgsBase64) { @@ -181,7 +161,7 @@ }); } else if (earlyMatch) { // Just START marker, show streaming state - const pendingIndex = remainingContent.indexOf('<<>>'); + const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START); if (pendingIndex > 0) { const textBefore = remainingContent.slice(0, pendingIndex).trim(); if (textBefore) { @@ -190,7 +170,7 @@ } // Try to extract tool name if present - const nameMatch = earlyMatch[1]?.match(/<<]+)>>>/); + const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT); sections.push({ type: AgenticSectionType.TOOL_CALL_STREAMING, @@ -205,7 +185,7 @@ let remainingText = rawContent.slice(lastIndex).trim(); // Check for partial marker at the end (e.g., "<<<" or "<<
@@ -256,7 +227,7 @@
{#if section.toolArgs}
Arguments:
= 2 ? hostnameParts.slice(-2).join('.') : url.hostname; - return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`; - } catch { - return null; - } + return extractServerNameFromUrl(server.url); } let mcpFavicons = $derived( healthyEnabledMcpServers .slice(0, 3) - .map((s) => ({ id: s.id, url: getFaviconUrl(s) })) + .map((s) => ({ id: s.id, url: getFaviconUrl(s.url) })) .filter((f) => f.url !== null) ); @@ -189,9 +171,9 @@
- {#if getFaviconUrl(server)} + {#if getFaviconUrl(server.url)} { diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte index d1b7784348..9a39d6dfe6 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte @@ -2,6 +2,7 @@ import { Plus, X } from '@lucide/svelte'; import { Input } from '$lib/components/ui/input'; import { autoResizeTextarea } from '$lib/utils'; + import { type HeaderPair, parseHeadersToArray, serializeHeaders } from '$lib/utils/mcp'; interface Props { url: string; @@ -21,37 +22,6 @@ id = 'server' }: Props = $props(); - // Header pair type - type HeaderPair = { key: string; value: string }; - - // Parse headers JSON string to array of key-value pairs - function parseHeadersToArray(headersJson: string): HeaderPair[] { - if (!headersJson?.trim()) return []; - try { - const parsed = JSON.parse(headersJson); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return Object.entries(parsed).map(([key, value]) => ({ - key, - value: String(value) - })); - } - } catch { - // Invalid JSON, return empty - } - return []; - } - - // Serialize array of key-value pairs to JSON string - function serializeHeaders(pairs: HeaderPair[]): string { - const validPairs = pairs.filter((p) => p.key.trim()); - if (validPairs.length === 0) return ''; - const obj: Record = {}; - for (const pair of validPairs) { - obj[pair.key.trim()] = pair.value; - } - return JSON.stringify(obj); - } - // Local state for header pairs let headerPairs = $state(parseHeadersToArray(headers)); diff --git a/tools/server/webui/src/lib/constants/agentic.ts b/tools/server/webui/src/lib/constants/agentic.ts index 453549832a..ea06bab48b 100644 --- a/tools/server/webui/src/lib/constants/agentic.ts +++ b/tools/server/webui/src/lib/constants/agentic.ts @@ -6,3 +6,31 @@ export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = { maxToolPreviewLines: 25, filterReasoningAfterFirstTurn: true } as const; + +// Agentic tool call tag markers +export const AGENTIC_TAGS = { + TOOL_CALL_START: '<<>>', + TOOL_CALL_END: '<<>>', + TOOL_NAME_PREFIX: '<<>>' +} as const; + +// Regex patterns for parsing agentic content +export const AGENTIC_REGEX = { + // Matches completed tool calls (with END marker) + COMPLETED_TOOL_CALL: + /<<>>\n<<>>\n<<>>([\s\S]*?)<<>>/g, + // Matches pending tool call (has NAME and ARGS but no END) + PENDING_TOOL_CALL: + /<<>>\n<<>>\n<<>>([\s\S]*)$/, + // Matches partial tool call (has START and NAME, ARGS still streaming) + PARTIAL_WITH_NAME: + /<<>>\n<<>>\n<<>>([\s\S]*)$/, + // Matches partial marker at end of content + PARTIAL_MARKER: /<<<[A-Z_]*$/, + // Matches tool name inside content + TOOL_NAME_EXTRACT: /<<]+)>>>/ +} as const; diff --git a/tools/server/webui/src/lib/utils/formatters.ts b/tools/server/webui/src/lib/utils/formatters.ts index ae9f59a39c..8dd237542c 100644 --- a/tools/server/webui/src/lib/utils/formatters.ts +++ b/tools/server/webui/src/lib/utils/formatters.ts @@ -51,3 +51,19 @@ export function formatNumber(num: number | unknown): string { return num.toLocaleString(); } + +/** + * Format JSON string with pretty printing (2-space indentation) + * Returns original string if parsing fails + * + * @param jsonString - JSON string to format + * @returns Pretty-printed JSON string or original if invalid + */ +export function formatJsonPretty(jsonString: string): string { + try { + const parsed = JSON.parse(jsonString); + return JSON.stringify(parsed, null, 2); + } catch { + return jsonString; + } +} diff --git a/tools/server/webui/src/lib/utils/mcp.ts b/tools/server/webui/src/lib/utils/mcp.ts index 46efb36b4a..f2c3b206cc 100644 --- a/tools/server/webui/src/lib/utils/mcp.ts +++ b/tools/server/webui/src/lib/utils/mcp.ts @@ -1,5 +1,10 @@ import type { MCPTransportType } from '$lib/types/mcp'; +/** + * Represents a key-value pair for HTTP headers. + */ +export type HeaderPair = { key: string; value: string }; + /** * Detects the MCP transport type from a URL. * WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'. @@ -21,3 +26,68 @@ export function generateMcpServerId(id: unknown, index: number): string { } return `server-${index + 1}`; } + +/** + * Extracts a human-readable server name from a URL. + * Strips common prefixes like 'www.' and 'mcp.' and capitalizes the result. + */ +export function extractServerNameFromUrl(url: string): string { + try { + const parsedUrl = new URL(url); + const host = parsedUrl.hostname.replace(/^(www\.|mcp\.)/, ''); + const name = host.split('.')[0] || 'Unknown'; + return name.charAt(0).toUpperCase() + name.slice(1); + } catch { + return 'New Server'; + } +} + +/** + * Gets a favicon URL for an MCP server using Google's favicon service. + * Returns null if the URL is invalid. + */ +export function getFaviconUrl(serverUrl: string): string | null { + try { + const parsedUrl = new URL(serverUrl); + const hostnameParts = parsedUrl.hostname.split('.'); + const rootDomain = + hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : parsedUrl.hostname; + return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`; + } catch { + return null; + } +} + +/** + * Parses a JSON string of headers into an array of key-value pairs. + * Returns empty array if the JSON is invalid or empty. + */ +export function parseHeadersToArray(headersJson: string): HeaderPair[] { + if (!headersJson?.trim()) return []; + try { + const parsed = JSON.parse(headersJson); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return Object.entries(parsed).map(([key, value]) => ({ + key, + value: String(value) + })); + } + } catch { + // Invalid JSON, return empty + } + return []; +} + +/** + * Serializes an array of header key-value pairs to a JSON string. + * Filters out pairs with empty keys and returns empty string if no valid pairs. + */ +export function serializeHeaders(pairs: HeaderPair[]): string { + const validPairs = pairs.filter((p) => p.key.trim()); + if (validPairs.length === 0) return ''; + const obj: Record = {}; + for (const pair of validPairs) { + obj[pair.key.trim()] = pair.value; + } + return JSON.stringify(obj); +}