char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
0afb805b1
char/aliceai-t5-moe
1import type { SearchResult } from '$lib/types/search' ; 2 3/** 4* Parsers for MCP web-search tool responses shaped like: 5* 6* Title: <text> 7* URL: <https url> 8* Published: <iso date or N/A> 9* Author: <name or N/A> 10* Highlights: 11* <multi-line excerpt> 12* --- 13* Title: <next result> 14* ... 15* 16* The model is content-driven (any tool emitting `Title:` / `URL:` lines 17* separated by `---` qualifies), so it adapts to other web-search MCP 18* servers without hardcoding tool names. 19*/ 20 21const SEPARATOR_LINE_RE = / ^\s*---\s*$ / ; 22const URL_SCHEME_RE = / ^https?:\/\/ / i ; 23// Match either Unix or Windows line endings so chunking/parsing handles 24// payloads written by either scheme without off-by-one mismatches. 25const LINE_BREAK_RE = / \r?\n / ; 26// Sentinel the search-result wire format uses when a field is absent 27// (e.g. `Author: N/A`). Treated identically to a missing field so the 28// rendered card hides the row either way. 29const NOT_AVAILABLE_VALUE = 'N/A' ; 30// Section header that announces the start of the multi-line Highlights 31// block. Everything from that line onward (until the next `---` 32// separator or end of chunk) is captured verbatim as highlight text 33// instead of being re-scanned for `Title:`/`URL:`/... field lines. 34const HIGHLIGHTS_SECTION_HEADER = 'Highlights:' ; 35// Field name conventionally used by web-search tools (Exa etc.) as the 36// user-supplied query parameter. Extracted so future tool schemas that 37// adopt the same convention stay grep-compatible with this parser. 38const SEARCH_TOOL_QUERY_FIELD = 'query' ; 39// URL schemes the favicon helper will resolve to a hosted favicon. Any 40// other scheme (e.g. data:, blob:) intentionally returns null so the UI 41// can fall back to a generic globe icon. 42const RESOLVABLE_URL_PROTOCOLS :readonly string [] = [ 'https:' , 'http:' ]; 43// Conventional favicon path served by virtually every web host. 44// Appended to the URL origin as a best-effort lookup target; ignore 45// 404s at render time. 46const FAVICON_PATH = '/favicon.ico' ; 47 48// Wire-format field names emitted by the search-result parser. String 49// values match the keys the chunk parser writes into the `fields` map 50// (and that callers read off `SearchResult`), so `FieldKey.TITLE` is a 51// drop-in for the literal `'title'`. 52enum FieldKey { 53AUTHOR = 'author' , 54PUBLISHED = 'published' , 55TITLE = 'title' , 56URL = 'url' 57} 58const FIELD_PREFIXES :ReadonlyArray <{ key :FieldKey ; prefix :string }> = [ 59{ key :FieldKey . TITLE , prefix :'Title:' }, 60{ key :FieldKey . URL , prefix :'URL:' }, 61{ key :FieldKey . PUBLISHED , prefix :'Published:' }, 62{ key :FieldKey . AUTHOR , prefix :'Author:' } 63]; 64 65/** 66* Split a tool result string into individual search-result chunks by 67* scanning line-by-line for `---` separator rows. Handles multi-line 68* safely (line-aware, not regex on the full string) so trailing / 69* leading / consecutive separators are not lost. 70*/ 71function splitChunks ( text :string ) :string [] { 72const lines = text . split ( LINE_BREAK_RE ); 73const chunks :string [] = []; 74 75let buffer :string [] = []; 76 77for ( const line of lines ) { 78if ( SEPARATOR_LINE_RE . test ( line )) { 79if ( buffer . length > 0 ) { 80chunks . push ( buffer . join ( '\n' )); 81buffer = []; 82} 83} else { 84buffer . push ( line ); 85} 86} 87 88if ( buffer . length > 0 ) chunks . push ( buffer . join ( '\n' )); 89 90return chunks ; 91} 92 93/** 94* Parse a single chunk into a SearchResult. Returns null when the chunk 95* has neither a title nor a URL — those are required for an entry to be 96* actionable (otherwise it is almost certainly malformed or a stray 97* separator line). 98*/ 99function parseChunk ( chunk :string ) :SearchResult | null { 100const trimmed = chunk . trim (); 101 102if ( ! trimmed ) return null ; 103 104const lines = chunk . split ( LINE_BREAK_RE ); 105const fields :Record < FieldKey , string | undefined > = { 106[ FieldKey . AUTHOR ] :undefined , 107[ FieldKey . PUBLISHED ] :undefined , 108[ FieldKey . TITLE ] :undefined , 109[ FieldKey . URL ] :undefined 110}; 111const highlightLines :string [] = []; 112 113let inHighlights = false ; 114 115for ( const line of lines ) { 116if ( ! inHighlights && line . trim () === HIGHLIGHTS_SECTION_HEADER ) { 117inHighlights = true ; 118 119continue ; 120} 121 122if ( inHighlights ) { 123highlightLines . push ( line ); 124 125continue ; 126} 127 128for ( const { key, prefix} of FIELD_PREFIXES ) { 129if ( ! line . startsWith ( prefix )) continue ; 130 131const value = line . slice ( prefix . length ). trim (); 132 133if ( value && value !== NOT_AVAILABLE_VALUE ) { 134fields [ key ] = value ; 135} 136 137break ; 138} 139} 140 141if ( ! fields [ FieldKey . TITLE ] || ! fields [ FieldKey . URL ] || ! URL_SCHEME_RE . test ( fields [ FieldKey . URL ])) 142return null ; 143 144const highlights = highlightLines . join ( '\n' ). trim (); 145const result :SearchResult = { 146title :fields [ FieldKey . TITLE ], 147url :fields [ FieldKey . URL ] 148}; 149 150if ( fields [ FieldKey . PUBLISHED ]) result . published = fields [ FieldKey . PUBLISHED ]; 151 152if ( fields [ FieldKey . AUTHOR ]) result . author = fields [ FieldKey . AUTHOR ]; 153 154if ( highlights ) result . highlights = highlights ; 155 156return result ; 157} 158 159const EMPTY_SEARCH_RESULTS :SearchResult [] = []; 160 161/** 162* Cheap prefilter for the wire format: a parseable result needs both a 163* `Title:` and a `URL:` field line, so a blob missing either substring can 164* never yield a result. Two substring scans cost far less than the 165* line-split parse for the megabyte tool results exec and file tools emit. 166*/ 167export function looksLikeSearchResult ( text :string | undefined | null ) :boolean { 168if ( ! text ) return false ; 169 170return text . includes ( 'Title:' ) && text . includes ( 'URL:' ); 171} 172 173/** Bounded cache for extractSearchResults results. */ 174const SEARCH_RESULTS_CACHE_MAX_SIZE = 32 ; 175const searchResultsCache = new Map < string , SearchResult []>(); 176 177/** 178* Extract a SearchResult[] from a tool-result string. Returns `[]` when 179* the input does not match the expected shape — useful for branching 180* between dedicated search-results rendering and the generic tool-call 181* block. Memoized: called per render during streaming on unchanged 182* tool result strings. 183*/ 184export function extractSearchResults ( text :string | undefined | null ) :SearchResult [] { 185if ( ! text || ! looksLikeSearchResult ( text )) return EMPTY_SEARCH_RESULTS ; 186 187const cached = searchResultsCache . get ( text ); 188 189if ( cached ) return cached ; 190 191const results :SearchResult [] = []; 192 193for ( const chunk of splitChunks ( text )) { 194const parsed = parseChunk ( chunk ); 195 196if ( parsed ) results . push ( parsed ); 197} 198 199if ( searchResultsCache . size >= SEARCH_RESULTS_CACHE_MAX_SIZE ) { 200searchResultsCache . delete ( searchResultsCache . keys (). next (). value ! ); 201} 202 203searchResultsCache . set ( text , results ); 204 205return results ; 206} 207 208/** Bounded cache for extractSearchQuery results. */ 209const SEARCH_QUERY_CACHE_MAX_SIZE = 32 ; 210const searchQueryCache = new Map < string , string >(); 211 212/** 213* Best-effort extraction of the search query out of a tool call's JSON 214* argument blob. Currently looks for a `query` field (the convention 215* used by Exa and most web-search MCP servers); returns an empty string 216* if it cannot be located. Memoized: called per render during streaming 217* on unchanged tool args strings. 218*/ 219export function extractSearchQuery ( toolArgs :string | undefined | null ) :string { 220if ( ! toolArgs ) return '' ; 221 222const cached = searchQueryCache . get ( toolArgs ); 223 224if ( cached !== undefined ) return cached ; 225 226let result = '' ; 227 228try { 229const parsed :unknown = JSON . parse ( toolArgs ); 230 231if ( parsed && typeof parsed === 'object' && ! Array . isArray ( parsed )) { 232const candidate = ( parsed as Record < string , unknown >)[ SEARCH_TOOL_QUERY_FIELD ]; 233 234if ( typeof candidate === 'string' ) result = candidate . trim (); 235} 236} catch { 237result = '' ; 238} 239 240if ( searchQueryCache . size >= SEARCH_QUERY_CACHE_MAX_SIZE ) { 241searchQueryCache . delete ( searchQueryCache . keys (). next (). value ! ); 242} 243 244searchQueryCache . set ( toolArgs , result ); 245 246return result ; 247} 248 249/** 250* Resolve a best-effort favicon URL for a search result, derived from the 251* result's origin (`https://host/favicon.ico`). Returns `null` when the 252* URL is malformed, has no recognizable host, or uses a non-http(s) 253* scheme — callers should fall back to a generic globe icon. 254*/ 255export function faviconForUrl ( url :string ) :string | null { 256try { 257const parsed = new URL ( url ); 258 259if ( ! RESOLVABLE_URL_PROTOCOLS . includes ( parsed . protocol )) return null ; 260 261return ` ${ parsed . protocol } // ${ parsed . host } ${ FAVICON_PATH } ` ; 262} catch { 263return null ; 264} 265} 266 267// Web-search MCP servers broadly follow the `web_search` token convention 268// for their primary tool, but the rich pill UI makes assumptions about 269// both the request shape (single `query` string) and the response shape 270// (Title:/URL:/Published:/Author:/Highlights blocks). Adding a tool here 271// is a deliberate signal that the renderer is known to handle its output. 272// Continued maintenance note: when broadening this list, verify both the 273// tool schema and the response format against the supported spec above. 274export const SUPPORTED_WEB_SEARCH_TOOL_NAMES :readonly string [] = [ 'web_search_exa' ]; 275 276/** 277* True when the tool's name is in the explicit allow-list of web-search 278* tools above. Returned to the dispatcher so it can route the call's UI 279* early (before results arrive) without false-firing on non-web-search 280* tools that also happen to accept a `query` argument. 281*/ 282export function isWebSearchToolName ( toolName :string | undefined | null ) :boolean { 283if ( ! toolName ) return false ; 284 285return SUPPORTED_WEB_SEARCH_TOOL_NAMES . includes ( toolName ); 286}