refactor: Cleanup

This commit is contained in:
Aleksander Grygier 2026-01-08 12:03:36 +01:00
parent 56b34bf63b
commit b0ba550928
6 changed files with 134 additions and 97 deletions

View File

@ -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<number, boolean> = $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 =
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:(.+?)>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/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(
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:(.+?)>>>([\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(
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:([\s\S]*)$/
);
const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
// Very early match (just START marker, maybe partial NAME)
const earlyMatch = remainingContent.match(/<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/);
const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
if (pendingMatch) {
// Add text before pending tool call
const pendingIndex = remainingContent.indexOf('<<<AGENTIC_TOOL_CALL_START>>>');
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('<<<AGENTIC_TOOL_CALL_START>>>');
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('<<<AGENTIC_TOOL_CALL_START>>>');
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(/<<<TOOL_NAME:([^>]+)>>>/);
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 "<<<AGENTIC" etc.)
const partialMarkerMatch = remainingText.match(/<<<[A-Z_]*$/);
const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
}
@ -222,15 +202,6 @@
return sections;
}
function formatToolArgs(args: string): string {
try {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
} catch {
return args;
}
}
</script>
<div class="agentic-content">
@ -256,7 +227,7 @@
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatToolArgs(section.toolArgs)}
code={formatJsonPretty(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"
@ -285,7 +256,7 @@
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatToolArgs(section.toolArgs)}
code={formatJsonPretty(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"

View File

@ -15,6 +15,7 @@
mcpHasHealthCheck,
mcpRunHealthCheck
} from '$lib/stores/mcp.svelte';
import { extractServerNameFromUrl, getFaviconUrl } from '$lib/utils/mcp';
interface Props {
class?: string;
@ -95,32 +96,13 @@
function getServerDisplayName(server: MCPServerSettingsEntry): string {
if (server.name) return server.name;
try {
const url = new URL(server.url);
const host = url.hostname.replace(/^(www\.|mcp\.)/, '');
const name = host.split('.')[0] || 'Unknown';
return name.charAt(0).toUpperCase() + name.slice(1);
} catch {
return 'New Server';
}
}
function getFaviconUrl(server: MCPServerSettingsEntry): string | null {
try {
const url = new URL(server.url);
const hostnameParts = url.hostname.split('.');
const rootDomain =
hostnameParts.length >= 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 @@
<div class="flex items-center justify-between gap-2 px-2 py-2">
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server)}
{#if getFaviconUrl(server.url)}
<img
src={getFaviconUrl(server)}
src={getFaviconUrl(server.url)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {

View File

@ -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<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}
// Local state for header pairs
let headerPairs = $state<HeaderPair[]>(parseHeadersToArray(headers));

View File

@ -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: '<<<AGENTIC_TOOL_CALL_START>>>',
TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
TOOL_ARGS_PREFIX: '<<<TOOL_ARGS_BASE64:',
TAG_SUFFIX: '>>>'
} as const;
// Regex patterns for parsing agentic content
export const AGENTIC_REGEX = {
// Matches completed tool calls (with END marker)
COMPLETED_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:(.+?)>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
// Matches pending tool call (has NAME and ARGS but no END)
PENDING_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:(.+?)>>>([\s\S]*)$/,
// Matches partial tool call (has START and NAME, ARGS still streaming)
PARTIAL_WITH_NAME:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:([\s\S]*)$/,
// Matches early tool call (just START marker)
EARLY_MATCH: /<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/,
// Matches partial marker at end of content
PARTIAL_MARKER: /<<<[A-Z_]*$/,
// Matches tool name inside content
TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
} as const;

View File

@ -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;
}
}

View File

@ -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<string, string> = {};
for (const pair of validPairs) {
obj[pair.key.trim()] = pair.value;
}
return JSON.stringify(obj);
}