import type { SearchResult } from '$lib/types/search'; /** * Parsers for MCP web-search tool responses shaped like: * * Title: * URL: * Published: * Author: * Highlights: * * --- * Title: * ... * * The model is content-driven (any tool emitting `Title:` / `URL:` lines * separated by `---` qualifies), so it adapts to other web-search MCP * servers without hardcoding tool names. */ const SEPARATOR_LINE_RE = /^\s*---\s*$/; const URL_SCHEME_RE = /^https?:\/\//i; // Match either Unix or Windows line endings so chunking/parsing handles // payloads written by either scheme without off-by-one mismatches. const LINE_BREAK_RE = /\r?\n/; // Sentinel the search-result wire format uses when a field is absent // (e.g. `Author: N/A`). Treated identically to a missing field so the // rendered card hides the row either way. const NOT_AVAILABLE_VALUE = 'N/A'; // Section header that announces the start of the multi-line Highlights // block. Everything from that line onward (until the next `---` // separator or end of chunk) is captured verbatim as highlight text // instead of being re-scanned for `Title:`/`URL:`/... field lines. const HIGHLIGHTS_SECTION_HEADER = 'Highlights:'; // Field name conventionally used by web-search tools (Exa etc.) as the // user-supplied query parameter. Extracted so future tool schemas that // adopt the same convention stay grep-compatible with this parser. const SEARCH_TOOL_QUERY_FIELD = 'query'; // URL schemes the favicon helper will resolve to a hosted favicon. Any // other scheme (e.g. data:, blob:) intentionally returns null so the UI // can fall back to a generic globe icon. const RESOLVABLE_URL_PROTOCOLS: readonly string[] = ['https:', 'http:']; // Conventional favicon path served by virtually every web host. // Appended to the URL origin as a best-effort lookup target; ignore // 404s at render time. const FAVICON_PATH = '/favicon.ico'; // Wire-format field names emitted by the search-result parser. String // values match the keys the chunk parser writes into the `fields` map // (and that callers read off `SearchResult`), so `FieldKey.TITLE` is a // drop-in for the literal `'title'`. enum FieldKey { TITLE = 'title', URL = 'url', PUBLISHED = 'published', AUTHOR = 'author' } const FIELD_PREFIXES: ReadonlyArray<{ key: FieldKey; prefix: string }> = [ { key: FieldKey.TITLE, prefix: 'Title:' }, { key: FieldKey.URL, prefix: 'URL:' }, { key: FieldKey.PUBLISHED, prefix: 'Published:' }, { key: FieldKey.AUTHOR, prefix: 'Author:' } ]; /** * Split a tool result string into individual search-result chunks by * scanning line-by-line for `---` separator rows. Handles multi-line * safely (line-aware, not regex on the full string) so trailing / * leading / consecutive separators are not lost. */ function splitChunks(text: string): string[] { const lines = text.split(LINE_BREAK_RE); const chunks: string[] = []; let buffer: string[] = []; for (const line of lines) { if (SEPARATOR_LINE_RE.test(line)) { if (buffer.length > 0) { chunks.push(buffer.join('\n')); buffer = []; } } else { buffer.push(line); } } if (buffer.length > 0) chunks.push(buffer.join('\n')); return chunks; } /** * Parse a single chunk into a SearchResult. Returns null when the chunk * has neither a title nor a URL — those are required for an entry to be * actionable (otherwise it is almost certainly malformed or a stray * separator line). */ function parseChunk(chunk: string): SearchResult | null { const trimmed = chunk.trim(); if (!trimmed) return null; const lines = chunk.split(LINE_BREAK_RE); const fields: Record = { [FieldKey.AUTHOR]: undefined, [FieldKey.PUBLISHED]: undefined, [FieldKey.TITLE]: undefined, [FieldKey.URL]: undefined }; const highlightLines: string[] = []; let inHighlights = false; for (const line of lines) { if (!inHighlights && line.trim() === HIGHLIGHTS_SECTION_HEADER) { inHighlights = true; continue; } if (inHighlights) { highlightLines.push(line); continue; } for (const { key, prefix } of FIELD_PREFIXES) { if (!line.startsWith(prefix)) continue; const value = line.slice(prefix.length).trim(); if (value && value !== NOT_AVAILABLE_VALUE) { fields[key] = value; } break; } } if (!fields[FieldKey.TITLE] || !fields[FieldKey.URL] || !URL_SCHEME_RE.test(fields[FieldKey.URL])) return null; const highlights = highlightLines.join('\n').trim(); const result: SearchResult = { title: fields[FieldKey.TITLE], url: fields[FieldKey.URL] }; if (fields[FieldKey.PUBLISHED]) result.published = fields[FieldKey.PUBLISHED]; if (fields[FieldKey.AUTHOR]) result.author = fields[FieldKey.AUTHOR]; if (highlights) result.highlights = highlights; return result; } /** Bounded cache for extractSearchResults results. */ const SEARCH_RESULTS_CACHE_MAX_SIZE = 32; const searchResultsCache = new Map(); /** * Extract a SearchResult[] from a tool-result string. Returns `[]` when * the input does not match the expected shape — useful for branching * between dedicated search-results rendering and the generic tool-call * block. Memoized: called per render during streaming on unchanged * tool result strings. */ export function extractSearchResults(text: string | undefined | null): SearchResult[] { if (!text) return []; const cached = searchResultsCache.get(text); if (cached) return cached; const results: SearchResult[] = []; for (const chunk of splitChunks(text)) { const parsed = parseChunk(chunk); if (parsed) results.push(parsed); } if (searchResultsCache.size >= SEARCH_RESULTS_CACHE_MAX_SIZE) { searchResultsCache.delete(searchResultsCache.keys().next().value!); } searchResultsCache.set(text, results); return results; } /** Bounded cache for extractSearchQuery results. */ const SEARCH_QUERY_CACHE_MAX_SIZE = 32; const searchQueryCache = new Map(); /** * Best-effort extraction of the search query out of a tool call's JSON * argument blob. Currently looks for a `query` field (the convention * used by Exa and most web-search MCP servers); returns an empty string * if it cannot be located. Memoized: called per render during streaming * on unchanged tool args strings. */ export function extractSearchQuery(toolArgs: string | undefined | null): string { if (!toolArgs) return ''; const cached = searchQueryCache.get(toolArgs); if (cached !== undefined) return cached; let result = ''; try { const parsed: unknown = JSON.parse(toolArgs); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const candidate = (parsed as Record)[SEARCH_TOOL_QUERY_FIELD]; if (typeof candidate === 'string') result = candidate.trim(); } } catch { result = ''; } if (searchQueryCache.size >= SEARCH_QUERY_CACHE_MAX_SIZE) { searchQueryCache.delete(searchQueryCache.keys().next().value!); } searchQueryCache.set(toolArgs, result); return result; } /** * Resolve a best-effort favicon URL for a search result, derived from the * result's origin (`https://host/favicon.ico`). Returns `null` when the * URL is malformed, has no recognizable host, or uses a non-http(s) * scheme — callers should fall back to a generic globe icon. */ export function faviconForUrl(url: string): string | null { try { const parsed = new URL(url); if (!RESOLVABLE_URL_PROTOCOLS.includes(parsed.protocol)) return null; return `${parsed.protocol}//${parsed.host}${FAVICON_PATH}`; } catch { return null; } } // Web-search MCP servers broadly follow the `web_search` token convention // for their primary tool, but the rich pill UI makes assumptions about // both the request shape (single `query` string) and the response shape // (Title:/URL:/Published:/Author:/Highlights blocks). Adding a tool here // is a deliberate signal that the renderer is known to handle its output. // Continued maintenance note: when broadening this list, verify both the // tool schema and the response format against the supported spec above. export const SUPPORTED_WEB_SEARCH_TOOL_NAMES: readonly string[] = ['web_search_exa']; /** * True when the tool's name is in the explicit allow-list of web-search * tools above. Returned to the dispatcher so it can route the call's UI * early (before results arrive) without false-firing on non-web-search * tools that also happen to accept a `query` argument. */ export function isWebSearchToolName(toolName: string | undefined | null): boolean { if (!toolName) return false; return SUPPORTED_WEB_SEARCH_TOOL_NAMES.includes(toolName); }