webui: Agentic Loop + MCP Client with support for Tools, Resources and Prompts (#18655)
This commit is contained in:
committed by
GitHub
parent
2850bc6a13
commit
f6235a41ef
@@ -0,0 +1,284 @@
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS, TRIM_NEWLINES_REGEX } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Represents a parsed section of agentic content
|
||||
*/
|
||||
export interface AgenticSection {
|
||||
type: AgenticSectionType;
|
||||
content: string;
|
||||
toolName?: string;
|
||||
toolArgs?: string;
|
||||
toolResult?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a segment of content that may contain reasoning blocks
|
||||
*/
|
||||
type ReasoningSegment = {
|
||||
type:
|
||||
| AgenticSectionType.TEXT
|
||||
| AgenticSectionType.REASONING
|
||||
| AgenticSectionType.REASONING_PENDING;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses agentic content into structured sections
|
||||
*
|
||||
* Main parsing function that processes content containing:
|
||||
* - Tool calls (completed, pending, or streaming)
|
||||
* - Reasoning blocks (completed or streaming)
|
||||
* - Regular text content
|
||||
*
|
||||
* The parser handles chronological display of agentic flow output, maintaining
|
||||
* the order of operations and properly identifying different states of tool calls
|
||||
* and reasoning blocks during streaming.
|
||||
*
|
||||
* @param rawContent - The raw content string to parse
|
||||
* @returns Array of structured agentic sections ready for display
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const content = "Some text <<<AGENTIC_TOOL_CALL>>>tool_name...";
|
||||
* const sections = parseAgenticContent(content);
|
||||
* // Returns: [{ type: 'text', content: 'Some text' }, { type: 'tool_call_streaming', ... }]
|
||||
* ```
|
||||
*/
|
||||
export function parseAgenticContent(rawContent: string): AgenticSection[] {
|
||||
if (!rawContent) return [];
|
||||
|
||||
const segments = splitReasoningSegments(rawContent);
|
||||
const sections: AgenticSection[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.type === AgenticSectionType.TEXT) {
|
||||
sections.push(...parseToolCallContent(segment.content));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment.type === AgenticSectionType.REASONING) {
|
||||
if (segment.content.trim()) {
|
||||
sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
sections.push({
|
||||
type: AgenticSectionType.REASONING_PENDING,
|
||||
content: segment.content
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses content containing tool call markers
|
||||
*
|
||||
* Identifies and extracts tool calls from content, handling:
|
||||
* - Completed tool calls with name, arguments, and results
|
||||
* - Pending tool calls (execution in progress)
|
||||
* - Streaming tool calls (arguments being received)
|
||||
* - Early-stage tool calls (just started)
|
||||
*
|
||||
* @param rawContent - The raw content string to parse
|
||||
* @returns Array of agentic sections representing tool calls and text
|
||||
*/
|
||||
function parseToolCallContent(rawContent: string): AgenticSection[] {
|
||||
if (!rawContent) return [];
|
||||
|
||||
const sections: AgenticSection[] = [];
|
||||
|
||||
const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
|
||||
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
const textBefore = rawContent.slice(lastIndex, match.index).trim();
|
||||
if (textBefore) {
|
||||
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
const toolName = match[1];
|
||||
const toolArgs = match[2];
|
||||
const toolResult = match[3].replace(TRIM_NEWLINES_REGEX, '');
|
||||
|
||||
sections.push({
|
||||
type: AgenticSectionType.TOOL_CALL,
|
||||
content: toolResult,
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolResult
|
||||
});
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
const remainingContent = rawContent.slice(lastIndex);
|
||||
|
||||
const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
|
||||
const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
|
||||
const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
|
||||
|
||||
if (pendingMatch) {
|
||||
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
|
||||
|
||||
if (pendingIndex > 0) {
|
||||
const textBefore = remainingContent.slice(0, pendingIndex).trim();
|
||||
|
||||
if (textBefore) {
|
||||
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
const toolName = pendingMatch[1];
|
||||
const toolArgs = pendingMatch[2];
|
||||
const streamingResult = (pendingMatch[3] || '').replace(TRIM_NEWLINES_REGEX, '');
|
||||
|
||||
sections.push({
|
||||
type: AgenticSectionType.TOOL_CALL_PENDING,
|
||||
content: streamingResult,
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolResult: streamingResult || undefined
|
||||
});
|
||||
} else if (partialWithNameMatch) {
|
||||
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
|
||||
|
||||
if (pendingIndex > 0) {
|
||||
const textBefore = remainingContent.slice(0, pendingIndex).trim();
|
||||
if (textBefore) {
|
||||
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
const partialArgs = partialWithNameMatch[2] || '';
|
||||
|
||||
sections.push({
|
||||
type: AgenticSectionType.TOOL_CALL_STREAMING,
|
||||
content: '',
|
||||
toolName: partialWithNameMatch[1],
|
||||
toolArgs: partialArgs || undefined,
|
||||
toolResult: undefined
|
||||
});
|
||||
} else if (earlyMatch) {
|
||||
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
|
||||
|
||||
if (pendingIndex > 0) {
|
||||
const textBefore = remainingContent.slice(0, pendingIndex).trim();
|
||||
if (textBefore) {
|
||||
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
|
||||
|
||||
sections.push({
|
||||
type: AgenticSectionType.TOOL_CALL_STREAMING,
|
||||
content: '',
|
||||
toolName: nameMatch?.[1],
|
||||
toolArgs: undefined,
|
||||
toolResult: undefined
|
||||
});
|
||||
} else if (lastIndex < rawContent.length) {
|
||||
let remainingText = rawContent.slice(lastIndex).trim();
|
||||
|
||||
const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
|
||||
|
||||
if (partialMarkerMatch) {
|
||||
remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
|
||||
}
|
||||
|
||||
if (remainingText) {
|
||||
sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0 && rawContent.trim()) {
|
||||
sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips partial marker from text content
|
||||
*
|
||||
* Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
|
||||
* at the end of streaming content.
|
||||
*
|
||||
* @param text - The text content to process
|
||||
* @returns Text with partial markers removed
|
||||
*/
|
||||
function stripPartialMarker(text: string): string {
|
||||
const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
|
||||
|
||||
if (partialMarkerMatch) {
|
||||
return text.slice(0, partialMarkerMatch.index).trim();
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits raw content into segments based on reasoning blocks
|
||||
*
|
||||
* Identifies and extracts reasoning content wrapped in REASONING_TAGS.START/END markers,
|
||||
* separating it from regular text content. Handles both complete and incomplete
|
||||
* (streaming) reasoning blocks.
|
||||
*
|
||||
* @param rawContent - The raw content string to parse
|
||||
* @returns Array of reasoning segments with their types and content
|
||||
*/
|
||||
function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
|
||||
if (!rawContent) return [];
|
||||
|
||||
const segments: ReasoningSegment[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < rawContent.length) {
|
||||
const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
|
||||
|
||||
if (startIndex === -1) {
|
||||
const remainingText = rawContent.slice(cursor);
|
||||
|
||||
if (remainingText) {
|
||||
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (startIndex > cursor) {
|
||||
const textBefore = rawContent.slice(cursor, startIndex);
|
||||
|
||||
if (textBefore) {
|
||||
segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
|
||||
}
|
||||
}
|
||||
|
||||
const contentStart = startIndex + REASONING_TAGS.START.length;
|
||||
const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
|
||||
|
||||
if (endIndex === -1) {
|
||||
const pendingContent = rawContent.slice(contentStart);
|
||||
|
||||
segments.push({
|
||||
type: AgenticSectionType.REASONING_PENDING,
|
||||
content: stripPartialMarker(pendingContent)
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const reasoningContent = rawContent.slice(contentStart, endIndex);
|
||||
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
|
||||
cursor = endIndex + REASONING_TAGS.END.length;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { base } from '$app/paths';
|
||||
import { getJsonHeaders, getAuthHeaders } from './api-headers';
|
||||
import { UrlPrefix } from '$lib/enums';
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* API Fetch Utilities
|
||||
@@ -50,7 +50,9 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
|
||||
const headers = { ...baseHeaders, ...customHeaders };
|
||||
|
||||
const url =
|
||||
path.startsWith(UrlPrefix.HTTP) || path.startsWith(UrlPrefix.HTTPS) ? path : `${base}${path}`;
|
||||
path.startsWith(UrlProtocol.HTTP) || path.startsWith(UrlProtocol.HTTPS)
|
||||
? path
|
||||
: `${base}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { FileTypeCategory } from '$lib/enums';
|
||||
import { AttachmentType, FileTypeCategory, SpecialFileType } from '$lib/enums';
|
||||
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
|
||||
import type {
|
||||
AttachmentDisplayItemsOptions,
|
||||
ChatUploadedFile,
|
||||
DatabaseMessageExtra
|
||||
} from '$lib/types';
|
||||
|
||||
export interface AttachmentDisplayItemsOptions {
|
||||
uploadedFiles?: ChatUploadedFile[];
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
/**
|
||||
* Check if an uploaded file is an MCP prompt
|
||||
*/
|
||||
function isMcpPromptUpload(file: ChatUploadedFile): boolean {
|
||||
return file.type === SpecialFileType.MCP_PROMPT && !!file.mcpPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an attachment is an MCP prompt
|
||||
*/
|
||||
function isMcpPromptAttachment(attachment: DatabaseMessageExtra): boolean {
|
||||
return attachment.type === AttachmentType.MCP_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an attachment is an MCP resource
|
||||
*/
|
||||
function isMcpResourceAttachment(attachment: DatabaseMessageExtra): boolean {
|
||||
return attachment.type === AttachmentType.MCP_RESOURCE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +58,9 @@ export function getAttachmentDisplayItems(
|
||||
size: file.size,
|
||||
preview: file.preview,
|
||||
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
|
||||
isMcpPrompt: isMcpPromptUpload(file),
|
||||
isLoading: file.isLoading,
|
||||
loadError: file.loadError,
|
||||
uploadedFile: file,
|
||||
textContent: file.textContent
|
||||
});
|
||||
@@ -45,12 +69,16 @@ export function getAttachmentDisplayItems(
|
||||
// Add stored attachments (ChatMessage)
|
||||
for (const [index, attachment] of attachments.entries()) {
|
||||
const isImage = isImageFile(attachment);
|
||||
const isMcpPrompt = isMcpPromptAttachment(attachment);
|
||||
const isMcpResource = isMcpResourceAttachment(attachment);
|
||||
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
name: attachment.name,
|
||||
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
|
||||
isImage,
|
||||
isMcpPrompt,
|
||||
isMcpResource,
|
||||
attachment,
|
||||
attachmentIndex: index,
|
||||
textContent: 'content' in attachment ? attachment.content : undefined
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
DatabaseMessageExtra,
|
||||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
DatabaseMessageExtraMcpPrompt,
|
||||
DatabaseMessageExtraMcpResource,
|
||||
ClipboardTextAttachment,
|
||||
ClipboardMcpPromptAttachment,
|
||||
ClipboardAttachment,
|
||||
ParsedClipboardContent
|
||||
} from '$lib/types';
|
||||
|
||||
@@ -101,11 +105,20 @@ export function formatMessageForClipboard(
|
||||
extras?: DatabaseMessageExtra[],
|
||||
asPlainText: boolean = false
|
||||
): string {
|
||||
// Filter only text attachments (TEXT type and legacy CONTEXT type)
|
||||
// Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types)
|
||||
const textAttachments =
|
||||
extras?.filter(
|
||||
(extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
|
||||
extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
|
||||
(
|
||||
extra
|
||||
): extra is
|
||||
| DatabaseMessageExtraTextFile
|
||||
| DatabaseMessageExtraLegacyContext
|
||||
| DatabaseMessageExtraMcpPrompt
|
||||
| DatabaseMessageExtraMcpResource =>
|
||||
extra.type === AttachmentType.TEXT ||
|
||||
extra.type === AttachmentType.LEGACY_CONTEXT ||
|
||||
extra.type === AttachmentType.MCP_PROMPT ||
|
||||
extra.type === AttachmentType.MCP_RESOURCE
|
||||
) ?? [];
|
||||
|
||||
if (textAttachments.length === 0) {
|
||||
@@ -120,11 +133,24 @@ export function formatMessageForClipboard(
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
|
||||
type: AttachmentType.TEXT,
|
||||
name: att.name,
|
||||
content: att.content
|
||||
}));
|
||||
const clipboardAttachments: ClipboardAttachment[] = textAttachments.map((att) => {
|
||||
if (att.type === AttachmentType.MCP_PROMPT) {
|
||||
const mcpAtt = att as DatabaseMessageExtraMcpPrompt;
|
||||
return {
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: mcpAtt.name,
|
||||
serverName: mcpAtt.serverName,
|
||||
promptName: mcpAtt.promptName,
|
||||
content: mcpAtt.content,
|
||||
arguments: mcpAtt.arguments
|
||||
} as ClipboardMcpPromptAttachment;
|
||||
}
|
||||
return {
|
||||
type: AttachmentType.TEXT,
|
||||
name: att.name,
|
||||
content: att.content
|
||||
} as ClipboardTextAttachment;
|
||||
});
|
||||
|
||||
return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
|
||||
}
|
||||
@@ -139,7 +165,8 @@ export function formatMessageForClipboard(
|
||||
export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
|
||||
const defaultResult: ParsedClipboardContent = {
|
||||
message: clipboardText,
|
||||
textAttachments: []
|
||||
textAttachments: [],
|
||||
mcpPromptAttachments: []
|
||||
};
|
||||
|
||||
if (!clipboardText.startsWith('"')) {
|
||||
@@ -181,17 +208,28 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
|
||||
if (!remainingPart || !remainingPart.startsWith('[')) {
|
||||
return {
|
||||
message,
|
||||
textAttachments: []
|
||||
textAttachments: [],
|
||||
mcpPromptAttachments: []
|
||||
};
|
||||
}
|
||||
|
||||
const attachments = JSON.parse(remainingPart) as unknown[];
|
||||
|
||||
const validAttachments: ClipboardTextAttachment[] = [];
|
||||
const validTextAttachments: ClipboardTextAttachment[] = [];
|
||||
const validMcpPromptAttachments: ClipboardMcpPromptAttachment[] = [];
|
||||
|
||||
for (const att of attachments) {
|
||||
if (isValidTextAttachment(att)) {
|
||||
validAttachments.push({
|
||||
if (isValidMcpPromptAttachment(att)) {
|
||||
validMcpPromptAttachments.push({
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: att.name,
|
||||
serverName: att.serverName,
|
||||
promptName: att.promptName,
|
||||
content: att.content,
|
||||
arguments: att.arguments
|
||||
});
|
||||
} else if (isValidTextAttachment(att)) {
|
||||
validTextAttachments.push({
|
||||
type: AttachmentType.TEXT,
|
||||
name: att.name,
|
||||
content: att.content
|
||||
@@ -201,13 +239,42 @@ export function parseClipboardContent(clipboardText: string): ParsedClipboardCon
|
||||
|
||||
return {
|
||||
message,
|
||||
textAttachments: validAttachments
|
||||
textAttachments: validTextAttachments,
|
||||
mcpPromptAttachments: validMcpPromptAttachments
|
||||
};
|
||||
} catch {
|
||||
return defaultResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate an MCP prompt attachment object
|
||||
* @param obj The object to validate
|
||||
* @returns true if the object is a valid MCP prompt attachment
|
||||
*/
|
||||
function isValidMcpPromptAttachment(obj: unknown): obj is {
|
||||
type: string;
|
||||
name: string;
|
||||
serverName: string;
|
||||
promptName: string;
|
||||
content: string;
|
||||
arguments?: Record<string, string>;
|
||||
} {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
(record.type === AttachmentType.MCP_PROMPT || record.type === 'MCP_PROMPT') &&
|
||||
typeof record.name === 'string' &&
|
||||
typeof record.serverName === 'string' &&
|
||||
typeof record.promptName === 'string' &&
|
||||
typeof record.content === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to validate a text attachment object
|
||||
* @param obj The object to validate
|
||||
@@ -240,5 +307,5 @@ export function hasClipboardAttachments(clipboardText: string): boolean {
|
||||
}
|
||||
|
||||
const parsed = parseClipboardContent(clipboardText);
|
||||
return parsed.textAttachments.length > 0;
|
||||
return parsed.textAttachments.length > 0 || parsed.mcpPromptAttachments.length > 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { convertPDFToImage, convertPDFToText } from './pdf-processing';
|
||||
import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
|
||||
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
|
||||
import { FileTypeCategory, AttachmentType } from '$lib/enums';
|
||||
import { FileTypeCategory, AttachmentType, SpecialFileType } from '$lib/enums';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { getFileTypeCategory } from '$lib/utils';
|
||||
@@ -34,6 +34,19 @@ export async function parseFilesToMessageExtras(
|
||||
const emptyFiles: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type === SpecialFileType.MCP_PROMPT && file.mcpPrompt) {
|
||||
extras.push({
|
||||
type: AttachmentType.MCP_PROMPT,
|
||||
name: file.name,
|
||||
serverName: file.mcpPrompt.serverName,
|
||||
promptName: file.mcpPrompt.promptName,
|
||||
content: file.textContent ?? '',
|
||||
arguments: file.mcpPrompt.arguments
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
|
||||
if (file.preview) {
|
||||
let base64Url = file.preview;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* CORS Proxy utility for routing requests through llama-server's CORS proxy.
|
||||
*/
|
||||
|
||||
import { base } from '$app/paths';
|
||||
import { CORS_PROXY_ENDPOINT, CORS_PROXY_URL_PARAM } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Build a proxied URL that routes through llama-server's CORS proxy.
|
||||
* @param targetUrl - The original URL to proxy
|
||||
* @returns URL pointing to the CORS proxy with target encoded
|
||||
*/
|
||||
export function buildProxiedUrl(targetUrl: string): URL {
|
||||
const proxyPath = `${base}${CORS_PROXY_ENDPOINT}`;
|
||||
const proxyUrl = new URL(proxyPath, window.location.origin);
|
||||
|
||||
proxyUrl.searchParams.set(CORS_PROXY_URL_PARAM, targetUrl);
|
||||
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a proxied URL string for use in fetch requests.
|
||||
* @param targetUrl - The original URL to proxy
|
||||
* @returns Proxied URL as string
|
||||
*/
|
||||
export function getProxiedUrlString(targetUrl: string): string {
|
||||
return buildProxiedUrl(targetUrl).href;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Favicon utility functions for extracting favicons from URLs.
|
||||
*/
|
||||
|
||||
import { getProxiedUrlString } from './cors-proxy';
|
||||
import {
|
||||
GOOGLE_FAVICON_BASE_URL,
|
||||
DEFAULT_FAVICON_SIZE,
|
||||
DOMAIN_SEPARATOR,
|
||||
ROOT_DOMAIN_MIN_PARTS
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Gets a favicon URL for a given URL using Google's favicon service.
|
||||
* Returns null if the URL is invalid.
|
||||
*
|
||||
* @param urlString - The URL to get the favicon for
|
||||
* @returns The favicon URL or null if invalid
|
||||
*/
|
||||
export function getFaviconUrl(urlString: string): string | null {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
const hostnameParts = url.hostname.split(DOMAIN_SEPARATOR);
|
||||
const rootDomain =
|
||||
hostnameParts.length >= ROOT_DOMAIN_MIN_PARTS
|
||||
? hostnameParts.slice(-ROOT_DOMAIN_MIN_PARTS).join(DOMAIN_SEPARATOR)
|
||||
: url.hostname;
|
||||
|
||||
const googleFaviconUrl = `${GOOGLE_FAVICON_BASE_URL}?domain=${rootDomain}&sz=${DEFAULT_FAVICON_SIZE}`;
|
||||
return getProxiedUrlString(googleFaviconUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Header utilities for parsing and serializing HTTP headers.
|
||||
* Generic utilities not specific to MCP.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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): { key: string; value: string }[] {
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
|
||||
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: { key: string; value: string }[]): 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);
|
||||
}
|
||||
@@ -31,9 +31,15 @@ export {
|
||||
getPreviousSibling
|
||||
} from './branching';
|
||||
|
||||
// Code
|
||||
export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code';
|
||||
|
||||
// Config helpers
|
||||
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
|
||||
|
||||
// CORS Proxy
|
||||
export { buildProxiedUrl, getProxiedUrlString } from './cors-proxy';
|
||||
|
||||
// Conversation utilities
|
||||
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
||||
|
||||
@@ -100,12 +106,51 @@ export { isTextFileByName, readFileAsText, isLikelyTextFile } from './text-files
|
||||
// Debounce utilities
|
||||
export { debounce } from './debounce';
|
||||
|
||||
// Sanitization utilities
|
||||
export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
|
||||
|
||||
// Image error fallback utilities
|
||||
export { getImageErrorFallbackHtml } from './image-error-fallback';
|
||||
|
||||
// MCP utilities
|
||||
export {
|
||||
detectMcpTransportFromUrl,
|
||||
parseMcpServerSettings,
|
||||
getMcpLogLevelIcon,
|
||||
getMcpLogLevelClass,
|
||||
isImageMimeType,
|
||||
parseResourcePath,
|
||||
getDisplayName,
|
||||
getResourceDisplayName,
|
||||
isCodeResource,
|
||||
isImageResource,
|
||||
getResourceIcon,
|
||||
getResourceTextContent,
|
||||
getResourceBlobContent,
|
||||
downloadResourceContent
|
||||
} from './mcp';
|
||||
|
||||
// URI Template utilities
|
||||
export {
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
isTemplateComplete,
|
||||
normalizeResourceUri,
|
||||
type UriTemplateVariable
|
||||
} from './uri-template';
|
||||
|
||||
// Data URL utilities
|
||||
export { createBase64DataUrl } from './data-url';
|
||||
|
||||
// Header utilities
|
||||
export { parseHeadersToArray, serializeHeaders } from './headers';
|
||||
|
||||
// Favicon utilities
|
||||
export { getFaviconUrl } from './favicon';
|
||||
|
||||
// Agentic content parsing utilities
|
||||
export { parseAgenticContent, type AgenticSection } from './agentic';
|
||||
|
||||
// Cache utilities
|
||||
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
|
||||
|
||||
@@ -117,3 +162,7 @@ export {
|
||||
createTimeoutSignal,
|
||||
withAbortSignal
|
||||
} from './abort';
|
||||
|
||||
// Cryptography utilities
|
||||
|
||||
export { uuid } from './uuid';
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { MCPServerSettingsEntry, MCPResourceContent, MCPResourceInfo } from '$lib/types';
|
||||
import {
|
||||
MCPTransportType,
|
||||
MCPLogLevel,
|
||||
UrlProtocol,
|
||||
MimeTypePrefix,
|
||||
MimeTypeIncludes,
|
||||
UriPattern,
|
||||
MimeTypeText
|
||||
} from '$lib/enums';
|
||||
import {
|
||||
DEFAULT_MCP_CONFIG,
|
||||
MCP_SERVER_ID_PREFIX,
|
||||
IMAGE_FILE_EXTENSION_REGEX,
|
||||
CODE_FILE_EXTENSION_REGEX,
|
||||
TEXT_FILE_EXTENSION_REGEX,
|
||||
PROTOCOL_PREFIX_REGEX,
|
||||
FILE_EXTENSION_REGEX,
|
||||
DISPLAY_NAME_SEPARATOR_REGEX,
|
||||
PATH_SEPARATOR,
|
||||
RESOURCE_TEXT_CONTENT_SEPARATOR,
|
||||
DEFAULT_RESOURCE_FILENAME
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
Database,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Code,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
XCircle
|
||||
} from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import type { MimeTypeUnion } from '$lib/types/common';
|
||||
|
||||
/**
|
||||
* Detects the MCP transport type from a URL.
|
||||
* WebSocket URLs (ws:// or wss://) use 'websocket', others use 'streamable_http'.
|
||||
*/
|
||||
export function detectMcpTransportFromUrl(url: string): MCPTransportType {
|
||||
const normalized = url.trim().toLowerCase();
|
||||
|
||||
return normalized.startsWith(UrlProtocol.WEBSOCKET) ||
|
||||
normalized.startsWith(UrlProtocol.WEBSOCKET_SECURE)
|
||||
? MCPTransportType.WEBSOCKET
|
||||
: MCPTransportType.STREAMABLE_HTTP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses MCP server settings from a JSON string or array.
|
||||
* requestTimeoutSeconds is not user-configurable in the UI, so we always use the default value.
|
||||
* @param rawServers - The raw servers to parse
|
||||
* @returns An empty array if the input is invalid.
|
||||
*/
|
||||
export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEntry[] {
|
||||
if (!rawServers) return [];
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
if (typeof rawServers === 'string') {
|
||||
const trimmed = rawServers.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn('[MCP] Failed to parse mcpServers JSON, ignoring value:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
parsed = rawServers;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.map((entry, index) => {
|
||||
const url = typeof entry?.url === 'string' ? entry.url.trim() : '';
|
||||
const headers = typeof entry?.headers === 'string' ? entry.headers.trim() : undefined;
|
||||
const id =
|
||||
typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
|
||||
? (entry as { id: string }).id.trim()
|
||||
: `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined,
|
||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate icon component for a log level
|
||||
*
|
||||
* @param level - MCP log level
|
||||
* @returns Lucide icon component
|
||||
*/
|
||||
export function getMcpLogLevelIcon(level: MCPLogLevel): Component {
|
||||
switch (level) {
|
||||
case MCPLogLevel.ERROR:
|
||||
return XCircle;
|
||||
case MCPLogLevel.WARN:
|
||||
return AlertTriangle;
|
||||
default:
|
||||
return Info;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate CSS class for a log level
|
||||
*
|
||||
* @param level - MCP log level
|
||||
* @returns Tailwind CSS class string
|
||||
*/
|
||||
export function getMcpLogLevelClass(level: MCPLogLevel): string {
|
||||
switch (level) {
|
||||
case MCPLogLevel.ERROR:
|
||||
return 'text-destructive';
|
||||
case MCPLogLevel.WARN:
|
||||
return 'text-yellow-600 dark:text-yellow-500';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a MIME type represents an image.
|
||||
*
|
||||
* @param mimeType - The MIME type to check
|
||||
* @returns True if the MIME type starts with 'image/'
|
||||
*/
|
||||
export function isImageMimeType(mimeType?: MimeTypeUnion): boolean {
|
||||
return mimeType?.startsWith(MimeTypePrefix.IMAGE) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a resource URI into path segments, stripping the protocol prefix.
|
||||
*
|
||||
* @param uri - The resource URI to parse
|
||||
* @returns Array of non-empty path segments
|
||||
*/
|
||||
export function parseResourcePath(uri: string): string[] {
|
||||
try {
|
||||
const withoutProtocol = uri.replace(PROTOCOL_PREFIX_REGEX, '');
|
||||
return withoutProtocol.split(PATH_SEPARATOR).filter((p) => p.length > 0);
|
||||
} catch {
|
||||
return [uri];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a path part into a human-readable display name.
|
||||
* Strips file extensions and converts kebab-case/snake_case to Title Case.
|
||||
*
|
||||
* @param pathPart - The path segment to convert
|
||||
* @returns Human-readable display name
|
||||
*/
|
||||
export function getDisplayName(pathPart: string): string {
|
||||
const withoutExt = pathPart.replace(FILE_EXTENSION_REGEX, '');
|
||||
return withoutExt
|
||||
.split(DISPLAY_NAME_SEPARATOR_REGEX)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name from a resource, extracting the last path segment from the URI.
|
||||
*
|
||||
* @param resource - The MCP resource info
|
||||
* @returns Display name string
|
||||
*/
|
||||
export function getResourceDisplayName(resource: MCPResourceInfo): string {
|
||||
try {
|
||||
const parts = parseResourcePath(resource.uri);
|
||||
return parts[parts.length - 1] || resource.name || resource.uri;
|
||||
} catch {
|
||||
return resource.name || resource.uri;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a MIME type and/or URI represents code content.
|
||||
*
|
||||
* @param mimeType - Optional MIME type string
|
||||
* @param uri - Optional URI string
|
||||
* @returns True if the content is code
|
||||
*/
|
||||
export function isCodeResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(u)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a MIME type and/or URI represents image content.
|
||||
*
|
||||
* @param mimeType - Optional MIME type string
|
||||
* @param uri - Optional URI string
|
||||
* @returns True if the content is an image
|
||||
*/
|
||||
export function isImageResource(mimeType?: MimeTypeUnion, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate Lucide icon component for an MCP resource based on its MIME type and URI.
|
||||
*
|
||||
* @param mimeType - Optional MIME type of the resource
|
||||
* @param uri - Optional URI of the resource
|
||||
* @returns Lucide icon component
|
||||
*/
|
||||
export function getResourceIcon(mimeType?: MimeTypeUnion, uri?: string): Component {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
|
||||
if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return Image;
|
||||
}
|
||||
|
||||
if (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(u)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
|
||||
if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) {
|
||||
return Database;
|
||||
}
|
||||
|
||||
return File;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from MCP resource content array.
|
||||
*
|
||||
* @param content - Array of MCP resource content items
|
||||
* @returns Joined text content string
|
||||
*/
|
||||
export function getResourceTextContent(content: MCPResourceContent[] | null | undefined): string {
|
||||
if (!content) return '';
|
||||
return content
|
||||
.filter((c): c is { uri: string; mimeType?: MimeTypeUnion; text: string } => 'text' in c)
|
||||
.map((c) => c.text)
|
||||
.join(RESOURCE_TEXT_CONTENT_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract blob content from MCP resource content array.
|
||||
*
|
||||
* @param content - Array of MCP resource content items
|
||||
* @returns Array of blob content items
|
||||
*/
|
||||
export function getResourceBlobContent(
|
||||
content: MCPResourceContent[] | null | undefined
|
||||
): Array<{ uri: string; mimeType?: MimeTypeUnion; blob: string }> {
|
||||
if (!content) return [];
|
||||
|
||||
return content.filter(
|
||||
(c): c is { uri: string; mimeType?: MimeTypeUnion; blob: string } => 'blob' in c
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download from text content.
|
||||
*
|
||||
* @param text - The text content to download
|
||||
* @param mimeType - MIME type for the blob
|
||||
* @param filename - Suggested filename
|
||||
*/
|
||||
export function downloadResourceContent(
|
||||
text: string,
|
||||
mimeType: MimeTypeUnion = MimeTypeText.PLAIN,
|
||||
filename: string = DEFAULT_RESOURCE_FILENAME
|
||||
): void {
|
||||
const blob = new Blob([text], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
KEY_VALUE_PAIR_KEY_MAX_LENGTH,
|
||||
KEY_VALUE_PAIR_VALUE_MAX_LENGTH,
|
||||
KEY_VALUE_PAIR_UNSAFE_KEY_RE,
|
||||
KEY_VALUE_PAIR_UNSAFE_VALUE_RE
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Strip control characters unsafe in identifier/header-name contexts and cap length.
|
||||
* Removes all C0 controls (including TAB) and DEL.
|
||||
*/
|
||||
export function sanitizeKeyValuePairKey(raw: string): string {
|
||||
return raw.replace(KEY_VALUE_PAIR_UNSAFE_KEY_RE, '').slice(0, KEY_VALUE_PAIR_KEY_MAX_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip control characters that enable header injection; allow TAB; cap length.
|
||||
* Removes null bytes, CR/LF and other C0/DEL controls while keeping TAB (\x09),
|
||||
* which is a valid header-value continuation character per RFC 7230.
|
||||
*/
|
||||
export function sanitizeKeyValuePairValue(raw: string): string {
|
||||
return raw.replace(KEY_VALUE_PAIR_UNSAFE_VALUE_RE, '').slice(0, KEY_VALUE_PAIR_VALUE_MAX_LENGTH);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
TEMPLATE_EXPRESSION_REGEX,
|
||||
URI_SCHEME_SEPARATOR,
|
||||
URI_TEMPLATE_OPERATORS,
|
||||
URI_TEMPLATE_SEPARATORS,
|
||||
VARIABLE_EXPLODE_MODIFIER_REGEX,
|
||||
VARIABLE_PREFIX_MODIFIER_REGEX,
|
||||
LEADING_SLASHES_REGEX
|
||||
} from '../constants';
|
||||
|
||||
/**
|
||||
* Normalize a resource URI for comparison.
|
||||
*
|
||||
* URI template expansion (especially with path operators like {/var})
|
||||
* can produce URIs that differ from listed resource URIs in slash placement.
|
||||
* For example, the template `svelte://{/slug*}.md` with slug="svelte/$effect"
|
||||
* expands to `svelte:///svelte/$effect.md`, while the listed resource URI is
|
||||
* `svelte://svelte/$effect.md`.
|
||||
*
|
||||
* This function strips extra leading slashes after the scheme to normalize
|
||||
* both forms to the same string for comparison purposes.
|
||||
*
|
||||
* @param uri - The URI to normalize
|
||||
* @returns Normalized URI string
|
||||
*/
|
||||
export function normalizeResourceUri(uri: string): string {
|
||||
const schemeEnd = uri.indexOf(URI_SCHEME_SEPARATOR);
|
||||
if (schemeEnd === -1) return uri;
|
||||
|
||||
const scheme = uri.substring(0, schemeEnd);
|
||||
const rest = uri
|
||||
.substring(schemeEnd + URI_SCHEME_SEPARATOR.length)
|
||||
.replace(LEADING_SLASHES_REGEX, '');
|
||||
|
||||
return `${scheme}${URI_SCHEME_SEPARATOR}${rest}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed variable from a URI template expression.
|
||||
*/
|
||||
export interface UriTemplateVariable {
|
||||
/** Variable name */
|
||||
name: string;
|
||||
/** Operator prefix (+, #, /, etc.) or empty string */
|
||||
operator: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all variable names from a URI template string.
|
||||
*
|
||||
* @param template - URI template string (RFC 6570)
|
||||
* @returns Array of unique variable descriptors
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* extractTemplateVariables("file:///{path}")
|
||||
* // => [{ name: "path", operator: "" }]
|
||||
*
|
||||
* extractTemplateVariables("db://{schema}/{table}")
|
||||
* // => [{ name: "schema", operator: "" }, { name: "table", operator: "" }]
|
||||
* ```
|
||||
*/
|
||||
export function extractTemplateVariables(template: string): UriTemplateVariable[] {
|
||||
const variables: UriTemplateVariable[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
let match;
|
||||
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
|
||||
|
||||
while ((match = TEMPLATE_EXPRESSION_REGEX.exec(template)) !== null) {
|
||||
const operator = match[1] || '';
|
||||
const varList = match[2];
|
||||
|
||||
// RFC 6570 allows comma-separated variable lists: {x,y,z}
|
||||
for (const varSpec of varList.split(',')) {
|
||||
// Strip explode modifier (*) and prefix modifier (:N)
|
||||
const name = varSpec
|
||||
.replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
|
||||
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
|
||||
.trim();
|
||||
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
variables.push({ name, operator });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a URI template with the given variable values.
|
||||
* Implements a simplified RFC 6570 Level 2 expansion.
|
||||
*
|
||||
* @param template - URI template string
|
||||
* @param values - Map of variable name to value
|
||||
* @returns Expanded URI string
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* expandTemplate("file:///{path}", { path: "src/main.rs" })
|
||||
* // => "file:///src/main.rs"
|
||||
* ```
|
||||
*/
|
||||
export function expandTemplate(template: string, values: Record<string, string>): string {
|
||||
TEMPLATE_EXPRESSION_REGEX.lastIndex = 0;
|
||||
|
||||
return template.replace(
|
||||
TEMPLATE_EXPRESSION_REGEX,
|
||||
(_match, operator: string, varList: string) => {
|
||||
const varNames = varList
|
||||
.split(',')
|
||||
.map((v: string) =>
|
||||
v
|
||||
.replace(VARIABLE_EXPLODE_MODIFIER_REGEX, '')
|
||||
.replace(VARIABLE_PREFIX_MODIFIER_REGEX, '')
|
||||
.trim()
|
||||
);
|
||||
|
||||
const expandedParts = varNames
|
||||
.map((name: string) => values[name] ?? '')
|
||||
.filter((v: string) => v !== '');
|
||||
|
||||
if (expandedParts.length === 0) return '';
|
||||
|
||||
switch (operator) {
|
||||
case URI_TEMPLATE_OPERATORS.RESERVED:
|
||||
// Reserved expansion: no encoding
|
||||
return expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA);
|
||||
case URI_TEMPLATE_OPERATORS.FRAGMENT:
|
||||
// Fragment expansion
|
||||
return (
|
||||
URI_TEMPLATE_OPERATORS.FRAGMENT + expandedParts.join(URI_TEMPLATE_SEPARATORS.COMMA)
|
||||
);
|
||||
case URI_TEMPLATE_OPERATORS.PATH_SEGMENT:
|
||||
// Path segments
|
||||
return URI_TEMPLATE_SEPARATORS.SLASH + expandedParts.join(URI_TEMPLATE_SEPARATORS.SLASH);
|
||||
case URI_TEMPLATE_OPERATORS.LABEL:
|
||||
// Label expansion
|
||||
return (
|
||||
URI_TEMPLATE_SEPARATORS.PERIOD + expandedParts.join(URI_TEMPLATE_SEPARATORS.PERIOD)
|
||||
);
|
||||
case URI_TEMPLATE_OPERATORS.PATH_PARAM:
|
||||
// Path-style parameters
|
||||
return varNames
|
||||
.filter((_: string, i: number) => expandedParts[i])
|
||||
.map(
|
||||
(name: string, i: number) =>
|
||||
`${URI_TEMPLATE_SEPARATORS.SEMICOLON}${name}=${expandedParts[i]}`
|
||||
)
|
||||
.join('');
|
||||
case URI_TEMPLATE_OPERATORS.FORM_QUERY:
|
||||
// Form-style query
|
||||
return (
|
||||
URI_TEMPLATE_SEPARATORS.QUERY_PREFIX +
|
||||
varNames
|
||||
.filter((_: string, i: number) => expandedParts[i])
|
||||
.map(
|
||||
(name: string, i: number) =>
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
|
||||
)
|
||||
.join(URI_TEMPLATE_SEPARATORS.COMMA)
|
||||
);
|
||||
case URI_TEMPLATE_OPERATORS.FORM_CONTINUATION:
|
||||
// Form-style query continuation
|
||||
return (
|
||||
URI_TEMPLATE_SEPARATORS.QUERY_CONTINUATION +
|
||||
varNames
|
||||
.filter((_: string, i: number) => expandedParts[i])
|
||||
.map(
|
||||
(name: string, i: number) =>
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(expandedParts[i])}`
|
||||
)
|
||||
.join(URI_TEMPLATE_SEPARATORS.COMMA)
|
||||
);
|
||||
default:
|
||||
// Simple string expansion (default operator)
|
||||
return expandedParts
|
||||
.map((v: string) => encodeURIComponent(v))
|
||||
.join(URI_TEMPLATE_SEPARATORS.COMMA);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether all required variables in a template have been provided.
|
||||
*
|
||||
* @param template - URI template string
|
||||
* @param values - Map of variable name to value
|
||||
* @returns true if all variables have non-empty values
|
||||
*/
|
||||
export function isTemplateComplete(template: string, values: Record<string, string>): boolean {
|
||||
const variables = extractTemplateVariables(template);
|
||||
|
||||
return variables.every((v) => (values[v.name] ?? '').trim() !== '');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function uuid(): string {
|
||||
return globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).substring(2);
|
||||
}
|
||||
Reference in New Issue
Block a user