refactor: Cleanup
This commit is contained in:
parent
56b34bf63b
commit
b0ba550928
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue