char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Improve Chat Messages rendering performance (#28460)0afb805b1

char/aliceai-t5-moe
9.1 KiB286 linesraw
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 {
53	AUTHOR = 'author',
54	PUBLISHED = 'published',
55	TITLE = 'title',
56	URL = '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[] {
72	const lines = text.split(LINE_BREAK_RE);
73	const chunks: string[] = [];
74
75	let buffer: string[] = [];
76
77	for (const line of lines) {
78		if (SEPARATOR_LINE_RE.test(line)) {
79			if (buffer.length > 0) {
80				chunks.push(buffer.join('\n'));
81				buffer = [];
82			}
83		} else {
84			buffer.push(line);
85		}
86	}
87
88	if (buffer.length > 0) chunks.push(buffer.join('\n'));
89
90	return 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 {
100	const trimmed = chunk.trim();
101
102	if (!trimmed) return null;
103
104	const lines = chunk.split(LINE_BREAK_RE);
105	const fields: Record<FieldKey, string | undefined> = {
106		[FieldKey.AUTHOR]: undefined,
107		[FieldKey.PUBLISHED]: undefined,
108		[FieldKey.TITLE]: undefined,
109		[FieldKey.URL]: undefined
110	};
111	const highlightLines: string[] = [];
112
113	let inHighlights = false;
114
115	for (const line of lines) {
116		if (!inHighlights && line.trim() === HIGHLIGHTS_SECTION_HEADER) {
117			inHighlights = true;
118
119			continue;
120		}
121
122		if (inHighlights) {
123			highlightLines.push(line);
124
125			continue;
126		}
127
128		for (const { key, prefix } of FIELD_PREFIXES) {
129			if (!line.startsWith(prefix)) continue;
130
131			const value = line.slice(prefix.length).trim();
132
133			if (value && value !== NOT_AVAILABLE_VALUE) {
134				fields[key] = value;
135			}
136
137			break;
138		}
139	}
140
141	if (!fields[FieldKey.TITLE] || !fields[FieldKey.URL] || !URL_SCHEME_RE.test(fields[FieldKey.URL]))
142		return null;
143
144	const highlights = highlightLines.join('\n').trim();
145	const result: SearchResult = {
146		title: fields[FieldKey.TITLE],
147		url: fields[FieldKey.URL]
148	};
149
150	if (fields[FieldKey.PUBLISHED]) result.published = fields[FieldKey.PUBLISHED];
151
152	if (fields[FieldKey.AUTHOR]) result.author = fields[FieldKey.AUTHOR];
153
154	if (highlights) result.highlights = highlights;
155
156	return 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 {
168	if (!text) return false;
169
170	return 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[] {
185	if (!text || !looksLikeSearchResult(text)) return EMPTY_SEARCH_RESULTS;
186
187	const cached = searchResultsCache.get(text);
188
189	if (cached) return cached;
190
191	const results: SearchResult[] = [];
192
193	for (const chunk of splitChunks(text)) {
194		const parsed = parseChunk(chunk);
195
196		if (parsed) results.push(parsed);
197	}
198
199	if (searchResultsCache.size >= SEARCH_RESULTS_CACHE_MAX_SIZE) {
200		searchResultsCache.delete(searchResultsCache.keys().next().value!);
201	}
202
203	searchResultsCache.set(text, results);
204
205	return 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 {
220	if (!toolArgs) return '';
221
222	const cached = searchQueryCache.get(toolArgs);
223
224	if (cached !== undefined) return cached;
225
226	let result = '';
227
228	try {
229		const parsed: unknown = JSON.parse(toolArgs);
230
231		if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
232			const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD];
233
234			if (typeof candidate === 'string') result = candidate.trim();
235		}
236	} catch {
237		result = '';
238	}
239
240	if (searchQueryCache.size >= SEARCH_QUERY_CACHE_MAX_SIZE) {
241		searchQueryCache.delete(searchQueryCache.keys().next().value!);
242	}
243
244	searchQueryCache.set(toolArgs, result);
245
246	return 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 {
256	try {
257		const parsed = new URL(url);
258
259		if (!RESOLVABLE_URL_PROTOCOLS.includes(parsed.protocol)) return null;
260
261		return `${parsed.protocol}//${parsed.host}${FAVICON_PATH}`;
262	} catch {
263		return 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 {
283	if (!toolName) return false;
284
285	return SUPPORTED_WEB_SEARCH_TOOL_NAMES.includes(toolName);
286}