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
17.1 KiB579 linesraw
1import {
2	ATTACHMENT_SAVED_REGEX,
3	MARKDOWN,
4	NEWLINE,
5	REASONING_TAGS,
6	SEARCH_SUMMARY,
7	TOOL_RESULT_JSON_OPEN_REGEX
8} from '$lib/constants';
9import {
10	AgenticSectionType,
11	AttachmentType,
12	ContinueIntentKind,
13	MessageRole,
14	ToolResultKind
15} from '$lib/enums';
16import type { AgenticSection, ContinueIntent, ToolResultLine } from '$lib/types/agentic';
17import type { ApiChatCompletionToolCall } from '$lib/types/api';
18import type {
19	DatabaseMessage,
20	DatabaseMessageExtra,
21	DatabaseMessageExtraImageFile
22} from '$lib/types/database';
23
24/**
25 * Derives display sections from a single assistant message and its direct tool results.
26 *
27 * @param message - The assistant message
28 * @param toolMessages - Tool result messages for this assistant's tool_calls
29 * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
30 */
31function deriveSingleTurnSections(
32	message: DatabaseMessage,
33	toolMessages: DatabaseMessage[] = [],
34	streamingToolCalls: ApiChatCompletionToolCall[] = [],
35	isStreaming: boolean = false
36): AgenticSection[] {
37	const sections: AgenticSection[] = [];
38
39	// 1. Reasoning content (from dedicated field)
40	if (message.reasoningContent) {
41		const toolCalls = parseToolCalls(message.toolCalls);
42		const hasContentAfterReasoning =
43			!!message.content?.trim() || toolCalls.length > 0 || streamingToolCalls.length > 0;
44		const isPending = isStreaming && !hasContentAfterReasoning;
45
46		sections.push({
47			content: message.reasoningContent,
48			type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
49			wasInterrupted: !isStreaming && !hasContentAfterReasoning
50		});
51	}
52
53	// 2. Text content
54	if (message.content?.trim()) {
55		sections.push({
56			content: message.content,
57			type: AgenticSectionType.TEXT
58		});
59	}
60
61	// 3. Persisted tool calls (from message.toolCalls field)
62	const toolCalls = parseToolCalls(message.toolCalls);
63	// Index tool messages by toolCallId for O(1) lookup instead of O(n) find()
64	const toolMsgById = new Map<string, DatabaseMessage>();
65
66	for (const tm of toolMessages) {
67		if (tm.toolCallId && !toolMsgById.has(tm.toolCallId)) {
68			toolMsgById.set(tm.toolCallId, tm);
69		}
70	}
71
72	for (const tc of toolCalls) {
73		const resultMsg = tc.id ? toolMsgById.get(tc.id) : undefined;
74		// Only show as pending/loading if we're actively streaming; otherwise it's just a tool call without result
75		const type = resultMsg
76			? AgenticSectionType.TOOL_CALL
77			: isStreaming
78				? AgenticSectionType.TOOL_CALL_PENDING
79				: AgenticSectionType.TOOL_CALL;
80
81		sections.push({
82			content: resultMsg?.content || '',
83			toolArgs: tc.function?.arguments,
84			toolCallId: tc.id,
85			toolCwd: resultMsg?.toolCwd,
86			toolName: tc.function?.name,
87			toolResult: resultMsg?.content,
88			toolResultExtras: resultMsg?.extra,
89			type
90		});
91	}
92
93	// 4. Streaming tool calls (not yet persisted - currently being received)
94	const persistedIds = new Set(toolCalls.map((t) => t.id).filter(Boolean));
95
96	for (const tc of streamingToolCalls) {
97		// Skip if already in persisted tool calls
98		if (tc.id && persistedIds.has(tc.id)) continue;
99
100		sections.push({
101			content: '',
102			toolArgs: tc.function?.arguments,
103			toolCallId: tc.id,
104			toolName: tc.function?.name,
105			type: AgenticSectionType.TOOL_CALL_STREAMING
106		});
107	}
108
109	return sections;
110}
111
112interface TurnSectionsCacheEntry {
113	content: string | undefined;
114	extra: DatabaseMessageExtra[] | undefined;
115	reasoningContent: string | undefined;
116	toolCalls: string | undefined;
117	toolMessageContents: (string | undefined)[];
118	toolMessageExtras: (DatabaseMessageExtra[] | undefined)[];
119	toolMessages: DatabaseMessage[];
120	sections: AgenticSection[];
121}
122
123const turnSectionsCache = new WeakMap<DatabaseMessage, TurnSectionsCacheEntry>();
124
125function isTurnCacheValid(
126	entry: TurnSectionsCacheEntry,
127	message: DatabaseMessage,
128	toolMessages: DatabaseMessage[]
129): boolean {
130	if (
131		entry.content !== message.content ||
132		entry.reasoningContent !== message.reasoningContent ||
133		entry.toolCalls !== message.toolCalls ||
134		entry.extra !== message.extra
135	) {
136		return false;
137	}
138
139	if (entry.toolMessages.length !== toolMessages.length) return false;
140
141	for (let i = 0; i < toolMessages.length; i++) {
142		if (entry.toolMessages[i] !== toolMessages[i]) return false;
143
144		if (entry.toolMessageContents[i] !== toolMessages[i].content) return false;
145
146		if (entry.toolMessageExtras[i] !== toolMessages[i].extra) return false;
147	}
148
149	return true;
150}
151
152/**
153 * deriveSingleTurnSections with structural reuse for completed turns.
154 *
155 * deriveAgenticSections runs in a $derived invalidated per streamed chunk, but
156 * only the last turn actually changes. Messages mutate in place and are never
157 * replaced, so a WeakMap keyed by the turn's assistant message plus reference
158 * checks on every field deriveSingleTurnSections reads detects any change. A
159 * cache hit also returns the same section objects, keeping downstream props
160 * stable so tool blocks skip their per-chunk re-derive. The streaming turn
161 * recomputes uncached on every chunk.
162 */
163function deriveTurnSections(
164	message: DatabaseMessage,
165	toolMessages: DatabaseMessage[],
166	streamingToolCalls: ApiChatCompletionToolCall[],
167	isStreaming: boolean
168): AgenticSection[] {
169	if (isStreaming || streamingToolCalls.length > 0) {
170		return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
171	}
172
173	const cached = turnSectionsCache.get(message);
174
175	if (cached && isTurnCacheValid(cached, message, toolMessages)) {
176		return cached.sections;
177	}
178
179	const sections = deriveSingleTurnSections(message, toolMessages, [], false);
180
181	turnSectionsCache.set(message, {
182		content: message.content,
183		extra: message.extra,
184		reasoningContent: message.reasoningContent,
185		sections,
186		toolCalls: message.toolCalls,
187		toolMessageContents: toolMessages.map((tm) => tm.content),
188		toolMessageExtras: toolMessages.map((tm) => tm.extra),
189		toolMessages
190	});
191
192	return sections;
193}
194
195/**
196 * Derives display sections from structured message data.
197 *
198 * Handles both single-turn (one assistant + its tool results) and multi-turn
199 * agentic sessions (multiple assistant + tool messages grouped together).
200 *
201 * When `toolMessages` contains continuation assistant messages (from multi-turn
202 * agentic flows), they are processed in order to produce sections across all turns.
203 *
204 * @param message - The first/anchor assistant message
205 * @param toolMessages - Tool result messages and continuation assistant messages
206 * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
207 * @param isStreaming - Whether the message is currently being streamed
208 */
209export function deriveAgenticSections(
210	message: DatabaseMessage,
211	toolMessages: DatabaseMessage[] = [],
212	streamingToolCalls: ApiChatCompletionToolCall[] = [],
213	isStreaming: boolean = false
214): AgenticSection[] {
215	const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
216
217	if (!hasAssistantContinuations) {
218		return deriveTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
219	}
220
221	const sections: AgenticSection[] = [];
222	const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
223
224	sections.push(...deriveTurnSections(message, firstTurnToolMsgs, [], false));
225
226	let i = firstTurnToolMsgs.length;
227
228	while (i < toolMessages.length) {
229		const msg = toolMessages[i];
230
231		if (msg.role === MessageRole.ASSISTANT) {
232			const turnToolMsgs = collectToolMessages(toolMessages, i + 1);
233			const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
234
235			sections.push(
236				...deriveTurnSections(
237					msg,
238					turnToolMsgs,
239					isLastTurn ? streamingToolCalls : [],
240					isLastTurn && isStreaming
241				)
242			);
243
244			i += 1 + turnToolMsgs.length;
245		} else {
246			i++;
247		}
248	}
249
250	return sections;
251}
252
253/**
254 * Build the raw text representation shown in the "raw output" view of an
255 * assistant message. Each section is formatted as it would appear in the
256 * model-facing transcript, joined by blank lines.
257 */
258export function buildAssistantRawOutput(sections: AgenticSection[]): string {
259	const parts: string[] = [];
260
261	for (const section of sections) {
262		switch (section.type) {
263			case AgenticSectionType.REASONING:
264			case AgenticSectionType.REASONING_PENDING:
265				parts.push(`${REASONING_TAGS.START}${NEWLINE}${section.content}${REASONING_TAGS.END}`);
266
267				break;
268
269			case AgenticSectionType.TEXT:
270				parts.push(section.content);
271
272				break;
273
274			case AgenticSectionType.TOOL_CALL:
275			case AgenticSectionType.TOOL_CALL_PENDING:
276			case AgenticSectionType.TOOL_CALL_STREAMING: {
277				const callObj: Record<string, unknown> = { name: section.toolName };
278
279				if (section.toolArgs) {
280					try {
281						callObj.arguments = JSON.parse(section.toolArgs);
282					} catch {
283						callObj.arguments = section.toolArgs;
284					}
285				}
286
287				parts.push(JSON.stringify(callObj, null, 2));
288
289				if (section.toolResult) {
290					parts.push(`${NEWLINE}${section.toolResult}`);
291				}
292
293				break;
294			}
295		}
296	}
297
298	return parts.join(`${NEWLINE}${NEWLINE}`);
299}
300
301/**
302 * Collect consecutive tool messages starting at `startIndex`.
303 */
304function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] {
305	const result: DatabaseMessage[] = [];
306
307	for (let i = startIndex; i < messages.length; i++) {
308		if (messages[i].role === MessageRole.TOOL) {
309			result.push(messages[i]);
310		} else {
311			break;
312		}
313	}
314
315	return result;
316}
317
318/**
319 * Split a tool-result blob into a list and an optional "Total matches: N"
320 * summary. Both file-glob and grep tools emit this format on the server:
321 *
322 *   <matches>
323 *   ---
324 *   Total matches: 42
325 *
326 * Returns the lines and exposes a callback for capturing the total so each
327 * caller can stash it on its own meta type without taking a return-tuple.
328 */
329export function splitSearchSummaryList(
330	text: string,
331	captureTotal: (n: number) => void
332): { lines: string[] } {
333	const separatorIndex = text.indexOf(SEARCH_SUMMARY.SEPARATOR);
334	const matchesText = separatorIndex === -1 ? text : text.slice(0, separatorIndex);
335	const summaryText =
336		separatorIndex === -1 ? '' : text.slice(separatorIndex + SEARCH_SUMMARY.SEPARATOR.length);
337	const totalMatch = summaryText.match(SEARCH_SUMMARY.TOTAL_REGEX);
338
339	if (totalMatch) {
340		captureTotal(parseInt(totalMatch[1], 10));
341	}
342
343	const lines = matchesText
344		.split(NEWLINE)
345		.map((line) => line.trim())
346		.filter((line) => line.length > 0);
347
348	return { lines };
349}
350
351/** Bounded cache for parseToolResultWithMedia results. */
352const TOOL_RESULT_LINES_CACHE_MAX_SIZE = 32;
353const toolResultLinesCache = new Map<string, ToolResultLine[]>();
354
355/**
356 * Parse tool result text into lines, matching media attachments (images and audio) by name.
357 * Memoized: called per render during streaming on unchanged tool result
358 * strings with unchanged extras.
359 */
360export function parseToolResultWithMedia(
361	toolResult: string,
362	extras?: DatabaseMessageExtra[]
363): ToolResultLine[] {
364	// Cache key includes image attachment names so we recompute when
365	// attachments change, even if the count stays the same.
366	const imageNames = (extras ?? [])
367		.filter((e): e is DatabaseMessageExtraImageFile => e.type === AttachmentType.IMAGE)
368		.map((e) => e.name)
369		.join(NEWLINE);
370	const cacheKey = `${imageNames}:${toolResult}`;
371	const cached = toolResultLinesCache.get(cacheKey);
372
373	if (cached !== undefined) return cached;
374
375	const lines = toolResult.split(NEWLINE);
376	const result = lines.map((line) => {
377		const match = line.match(ATTACHMENT_SAVED_REGEX);
378
379		if (!match || !extras) return { text: line };
380
381		const attachmentName = match[1];
382		const media = extras.find(
383			(e): e is DatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile =>
384				(e.type === AttachmentType.IMAGE || e.type === AttachmentType.AUDIO) &&
385				e.name === attachmentName
386		);
387
388		return { media, text: line };
389	});
390
391	if (toolResultLinesCache.size >= TOOL_RESULT_LINES_CACHE_MAX_SIZE) {
392		toolResultLinesCache.delete(toolResultLinesCache.keys().next().value!);
393	}
394
395	toolResultLinesCache.set(cacheKey, result);
396
397	return result;
398}
399
400/** Bounded cache for classifyToolResult results. */
401const CLASSIFY_CACHE_MAX_SIZE = 32;
402const classifyCache = new Map<string, ToolResultKind>();
403
404/**
405 * Pick a renderer tier for a tool's result content.
406 *
407 *   json     - trimmed content starts with `{` or `[` and parses cleanly.
408 *   markdown - content shows structural markdown markers (headers, code
409 *              fences, links, lists, blockquotes, tables) and should render
410 *              through MarkdownContent for proper formatting.
411 *   text     - everything else, rendered as plain text lines (with image
412 *              attachment resolution as a side effect).
413 * Memoized: called per render during streaming on unchanged content.
414 */
415export function classifyToolResult(content: string | undefined): ToolResultKind {
416	if (!content) return ToolResultKind.TEXT;
417
418	const cached = classifyCache.get(content);
419
420	if (cached !== undefined) return cached;
421
422	const trimmed = content.trim();
423
424	if (!trimmed) return ToolResultKind.TEXT;
425
426	let result: ToolResultKind = ToolResultKind.TEXT;
427
428	// Strongest signal: JSON object/array round-trips through JSON.parse.
429	if (TOOL_RESULT_JSON_OPEN_REGEX.test(trimmed)) {
430		try {
431			JSON.parse(trimmed);
432			result = ToolResultKind.JSON;
433		} catch (error) {
434			console.error('[agentic] tool result looked like JSON but failed to parse:', error);
435		}
436	}
437
438	if (result === ToolResultKind.TEXT && looksLikeMarkdown(trimmed)) {
439		result = ToolResultKind.MARKDOWN;
440	}
441
442	if (classifyCache.size >= CLASSIFY_CACHE_MAX_SIZE) {
443		classifyCache.delete(classifyCache.keys().next().value!);
444	}
445
446	classifyCache.set(content, result);
447
448	return result;
449}
450
451/**
452 * Heuristic detector for "is this content a markdown document rather than
453 * plain text?". True when at least one well-known structural marker shows
454 * up - headers, code fences, links, bold, lists, blockquotes, tables.
455 * Each marker is specific enough that plain tool-output prose rarely
456 * trips it, but plain text starting with `# 5` will - acceptable false
457 * positive for the gain in formatting for tool results like search
458 * summaries that come back already-mardown.
459 */
460function looksLikeMarkdown(content: string): boolean {
461	// Code fences are unambiguous - triple backticks or tildes at line start.
462	if (MARKDOWN.CODE_FENCE_REGEX.test(content)) return true;
463
464	const lines = content.split(NEWLINE);
465
466	for (const line of lines) {
467		if (MARKDOWN.ATX_HEADING_REGEX.test(line)) return true;
468
469		if (MARKDOWN.BLOCKQUOTE_REGEX.test(line)) return true;
470
471		if (MARKDOWN.LIST_BULLET_REGEX.test(line)) return true;
472
473		if (MARKDOWN.LIST_NUMBERED_REGEX.test(line)) return true;
474	}
475
476	// Inline structural markers anywhere in the body.
477	if (MARKDOWN.LINK_REGEX.test(content)) return true;
478
479	if (MARKDOWN.BOLD_REGEX.test(content)) return true;
480
481	// Tables: a pipe-bearing header line followed by a separator row.
482	if (lines.length >= 2) {
483		const head = lines[0];
484		const sep = lines[1];
485
486		if (head.includes('|') && MARKDOWN.TABLE_SEPARATOR_REGEX.test(sep)) return true;
487	}
488
489	return false;
490}
491
492/** Bounded cache for parsed tool-call JSON blobs. */
493const TOOL_CALLS_CACHE_MAX_SIZE = 64;
494const toolCallsParseCache = new Map<string, ApiChatCompletionToolCall[]>();
495
496/**
497 * Safely parse the toolCalls JSON string from a DatabaseMessage.
498 * Memoized: the same JSON string is re-parsed on every render during
499 * streaming, which is wasted CPU since tool calls don't change mid-stream.
500 */
501function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
502	if (!toolCallsJson) return [];
503
504	const cached = toolCallsParseCache.get(toolCallsJson);
505
506	if (cached) return cached;
507
508	let result: ApiChatCompletionToolCall[];
509
510	try {
511		const parsed = JSON.parse(toolCallsJson);
512
513		result = Array.isArray(parsed) ? parsed : [];
514	} catch {
515		result = [];
516	}
517
518	if (toolCallsParseCache.size >= TOOL_CALLS_CACHE_MAX_SIZE) {
519		toolCallsParseCache.delete(toolCallsParseCache.keys().next().value!);
520	}
521
522	toolCallsParseCache.set(toolCallsJson, result);
523
524	return result;
525}
526
527/**
528 * Check if a message has agentic content (tool calls or is part of an agentic flow).
529 */
530export function hasAgenticContent(
531	message: DatabaseMessage,
532	toolMessages: DatabaseMessage[] = []
533): boolean {
534	if (message.toolCalls) {
535		const tc = parseToolCalls(message.toolCalls);
536
537		if (tc.length > 0) return true;
538	}
539
540	return toolMessages.length > 0;
541}
542
543/**
544 * Decide how a Continue click on messages[idx] should resume generation.
545 * Pure function over the persisted history snapshot.
546 */
547export function classifyContinueIntent(messages: DatabaseMessage[], idx: number): ContinueIntent {
548	const target = messages[idx];
549
550	// Defensive default: callers already filter by role, stay deterministic.
551	if (!target || target.role !== MessageRole.ASSISTANT) {
552		return { kind: ContinueIntentKind.APPEND_TEXT };
553	}
554
555	const hasToolCalls = parseToolCalls(target.toolCalls).length > 0;
556
557	if (!hasToolCalls) {
558		return { kind: ContinueIntentKind.APPEND_TEXT };
559	}
560
561	// Walk consecutive trailing tool results. The agentic loop only emits tool
562	// messages directly after the assistant turn that owns them, so the first
563	// non tool message marks the boundary.
564	let lastTrailingTool = idx;
565
566	for (let i = idx + 1; i < messages.length; i++) {
567		if (messages[i].role === MessageRole.TOOL) {
568			lastTrailingTool = i;
569		} else {
570			break;
571		}
572	}
573
574	if (lastTrailingTool > idx) {
575		return { kind: ContinueIntentKind.NEXT_TURN, truncateAfter: lastTrailingTool };
576	}
577
578	return { kind: ContinueIntentKind.RERUN_TURN, truncateAfter: idx - 1 };
579}