ui: Agentic Content UX improvements (#25450)

* feat: Add shimmer text animation for processing state indicators

* feat: Redesign CollapsibleContentBlock component with improved UX

* feat: Add conditional setting display support with dependsOn field

* feat: Add showAgenticTurnStats setting for per-turn statistics

* feat: Update ChatMessageAgenticContent with improved UI and new features

* feat: Enhance file read tool UI/UX

* feat: Refine styling of collapsible content and code preview blocks

* feat: add terminal variant to CollapsibleContentBlock

* feat: add built-in tools UI registry

* feat: extract ChatMessageReasoningBlock and ChatMessageToolCallBlock

* refactor: simplify ChatMessageAgenticContent to use extracted blocks

* fix: correct markdown content block margin spacing

* fix: reorganize SettingsChatFields layout and reset button positioning

* fix: use direct map access in agentic store session methods

* refactor: remove reasoning preview/throttle system from CollapsibleContentBlock

* feat: add auto-scroll to reasoning block and remove showThoughtInProgress

* feat: add ChatMessageToolCallDateTime component and support for new tool types

* feat: improve auto-scroll reliability in reasoning block with RAF coalescing and MutationObserver

* feat: show MCP server favicon for tools without a built-in icon

* feat: add search-results parsing utilities and tests

* feat: add ChatMessageToolCallSearchResults component

* feat: integrate search results rendering into ChatMessageAgenticContent

* feat: display tool call input alongside output in ChatMessageToolCallBlock

* style: use muted foreground color in reasoning block content

* chore: Format

* feat: Refine reasoning block layout and make pending thoughts display configurable

* feat: Stream tool call code blocks with auto-scroll and handle partial JSON

* feat: add streaming permission gate infrastructure

* feat: wire permission gate into the agentic loop

* fix: bail out on abort and skip already-approved tool calls

* fix: clear partial tool calls on abort and savePartialResponse

* test: cover partial tool call cleanup end-to-end

* refactor: Remove streaming permission gate logic

* fix: Correct autoscroll and streaming gates for tool calls and reasoning blocks

* refactor: Chat Message Assistant componentization

* fix: Show health metadata for disabled MCP servers and promote connections on enable

* fix: Inherit global enabled state for missing MCP per-chat overrides

* refactor: Cleanup

* refactor: Split ChatMessageToolCallBlock into dedicated components

* feat: Add live streaming and auto-scroll for tool execution output

* feat: Add line numbers and change markers to file edit diffs

* chore: Formatting

* feat: Add type definitions and utilities for recommended MCP servers

* feat: Add recommended MCP servers configuration and storage key

* feat: Add McpServerCardCompact component for recommended servers

* feat: Add recommended servers section to Add New Server dialog

* feat: Update McpServerForm to support authorization requirements

* feat: Add select-none classes for text selection prevention

* feat: Add recommended MCP server icon assets

* refactor: Store dismissed MCP recommendations as a boolean flag

* feat: Render tool results as JSON or Markdown based on detected content type

* feat: UI improvement

* feat: Render search block early and update heading to show execution state

* fix: Prevent non-web-search tools from triggering the search UI block

* refactor: Cleanup

* refactor: Extract hardcoded icon size classes into shared constants

* refactor: Extract hardcoded tool result separator into a shared constant

* refactor: Tool Calls UI/logic

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup
This commit is contained in:
Aleksander Grygier
2026-07-15 20:31:45 +02:00
committed by GitHub
parent 3b53219361
commit 32beb244f5
146 changed files with 5960 additions and 1053 deletions
+179 -6
View File
@@ -1,12 +1,32 @@
import { AgenticSectionType, ContinueIntentKind, MessageRole } from '$lib/enums';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import {
AgenticSectionType,
AttachmentType,
ContinueIntentKind,
MessageRole,
ToolResultKind
} from '$lib/enums';
import {
ATTACHMENT_SAVED_REGEX,
MARKDOWN_ATX_HEADING_REGEX,
MARKDOWN_BOLD_REGEX,
MARKDOWN_BLOCKQUOTE_REGEX,
MARKDOWN_CODE_FENCE_REGEX,
MARKDOWN_LINK_REGEX,
MARKDOWN_LIST_BULLET_REGEX,
MARKDOWN_LIST_NUMBERED_REGEX,
MARKDOWN_TABLE_SEPARATOR_REGEX,
NEWLINE,
REASONING_TAGS,
SEARCH_SUMMARY_SEPARATOR,
SEARCH_SUMMARY_TOTAL_REGEX,
TOOL_RESULT_JSON_OPEN_REGEX
} from '$lib/constants';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
import { AttachmentType } from '$lib/enums';
/**
* Represents a parsed section of agentic content for display
@@ -18,6 +38,11 @@ export interface AgenticSection {
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
/** ID of the model-side tool call (matches tool_calls[i].id). Lets
* downstream consumers correlate a section with the agentic loop's
* currently-executing tool, e.g. to drive live-streaming UI state
* by matching against agenticStore.executingToolCallId. */
toolCallId?: string;
wasInterrupted?: boolean;
}
@@ -81,7 +106,8 @@ function deriveSingleTurnSections(
toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra
toolResultExtras: resultMsg?.extra,
toolCallId: tc.id
});
}
@@ -93,7 +119,8 @@ function deriveSingleTurnSections(
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments
toolArgs: tc.function?.arguments,
toolCallId: tc.id
});
}
@@ -158,6 +185,52 @@ export function deriveAgenticSections(
return sections;
}
/**
* Build the raw text representation shown in the "raw output" view of an
* assistant message. Each section is formatted as it would appear in the
* model-facing transcript, joined by blank lines.
*/
export function buildAssistantRawOutput(sections: AgenticSection[]): string {
const parts: string[] = [];
for (const section of sections) {
switch (section.type) {
case AgenticSectionType.REASONING:
case AgenticSectionType.REASONING_PENDING:
parts.push(`${REASONING_TAGS.START}${NEWLINE}${section.content}${REASONING_TAGS.END}`);
break;
case AgenticSectionType.TEXT:
parts.push(section.content);
break;
case AgenticSectionType.TOOL_CALL:
case AgenticSectionType.TOOL_CALL_PENDING:
case AgenticSectionType.TOOL_CALL_STREAMING: {
const callObj: Record<string, unknown> = { name: section.toolName };
if (section.toolArgs) {
try {
callObj.arguments = JSON.parse(section.toolArgs);
} catch {
callObj.arguments = section.toolArgs;
}
}
parts.push(JSON.stringify(callObj, null, 2));
if (section.toolResult) {
parts.push(`${NEWLINE}${section.toolResult}`);
}
break;
}
}
}
return parts.join(`${NEWLINE}${NEWLINE}`);
}
/**
* Collect consecutive tool messages starting at `startIndex`.
*/
@@ -175,6 +248,39 @@ function collectToolMessages(messages: DatabaseMessage[], startIndex: number): D
return result;
}
/**
* Split a tool-result blob into a list and an optional "Total matches: N"
* summary. Both file-glob and grep tools emit this format on the server:
*
* <matches>
* ---
* Total matches: 42
*
* Returns the lines and exposes a callback for capturing the total so each
* caller can stash it on its own meta type without taking a return-tuple.
*/
export function splitSearchSummaryList(
text: string,
captureTotal: (n: number) => void
): { lines: string[] } {
const separatorIndex = text.indexOf(SEARCH_SUMMARY_SEPARATOR);
const matchesText = separatorIndex === -1 ? text : text.slice(0, separatorIndex);
const summaryText =
separatorIndex === -1 ? '' : text.slice(separatorIndex + SEARCH_SUMMARY_SEPARATOR.length);
const totalMatch = summaryText.match(SEARCH_SUMMARY_TOTAL_REGEX);
if (totalMatch) {
captureTotal(parseInt(totalMatch[1], 10));
}
const lines = matchesText
.split(NEWLINE)
.map((line) => line.trim())
.filter((line) => line.length > 0);
return { lines };
}
/**
* Parse tool result text into lines, matching image attachments by name.
*/
@@ -182,7 +288,7 @@ export function parseToolResultWithImages(
toolResult: string,
extras?: DatabaseMessageExtra[]
): ToolResultLine[] {
const lines = toolResult.split(NEWLINE_SEPARATOR);
const lines = toolResult.split(NEWLINE);
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
@@ -197,6 +303,73 @@ export function parseToolResultWithImages(
});
}
/**
* Pick a renderer tier for a tool's result content.
*
* json - trimmed content starts with `{` or `[` and parses cleanly.
* markdown - content shows structural markdown markers (headers, code
* fences, links, lists, blockquotes, tables) and should render
* through MarkdownContent for proper formatting.
* text - everything else, rendered as plain text lines (with image
* attachment resolution as a side effect).
*/
export function classifyToolResult(content: string | undefined): ToolResultKind {
if (!content) return ToolResultKind.TEXT;
const trimmed = content.trim();
if (!trimmed) return ToolResultKind.TEXT;
// Strongest signal: JSON object/array round-trips through JSON.parse.
if (TOOL_RESULT_JSON_OPEN_REGEX.test(trimmed)) {
try {
JSON.parse(trimmed);
return ToolResultKind.JSON;
} catch (error) {
console.error('[agentic] tool result looked like JSON but failed to parse:', error);
}
}
if (looksLikeMarkdown(trimmed)) return ToolResultKind.MARKDOWN;
return ToolResultKind.TEXT;
}
/**
* Heuristic detector for "is this content a markdown document rather than
* plain text?". True when at least one well-known structural marker shows
* up - headers, code fences, links, bold, lists, blockquotes, tables.
* Each marker is specific enough that plain tool-output prose rarely
* trips it, but plain text starting with `# 5` will - acceptable false
* positive for the gain in formatting for tool results like search
* summaries that come back already-mardown.
*/
function looksLikeMarkdown(content: string): boolean {
// Code fences are unambiguous - triple backticks or tildes at line start.
if (MARKDOWN_CODE_FENCE_REGEX.test(content)) return true;
const lines = content.split(NEWLINE);
for (const line of lines) {
if (MARKDOWN_ATX_HEADING_REGEX.test(line)) return true;
if (MARKDOWN_BLOCKQUOTE_REGEX.test(line)) return true;
if (MARKDOWN_LIST_BULLET_REGEX.test(line)) return true;
if (MARKDOWN_LIST_NUMBERED_REGEX.test(line)) return true;
}
// Inline structural markers anywhere in the body.
if (MARKDOWN_LINK_REGEX.test(content)) return true;
if (MARKDOWN_BOLD_REGEX.test(content)) return true;
// Tables: a pipe-bearing header line followed by a separator row.
if (lines.length >= 2) {
const head = lines[0];
const sep = lines[1];
if (head.includes('|') && MARKDOWN_TABLE_SEPARATOR_REGEX.test(sep)) return true;
}
return false;
}
/**
* Safely parse the toolCalls JSON string from a DatabaseMessage.
*/
+7 -2
View File
@@ -1,5 +1,10 @@
import { config } from '$lib/stores/settings.svelte';
import { CORS_PROXY_HEADER_PREFIX, REDACTED_HEADERS } from '$lib/constants';
import {
AUTHORIZATION_HEADER,
BEARER_PREFIX,
CORS_PROXY_HEADER_PREFIX,
REDACTED_HEADERS
} from '$lib/constants';
import { redactValue } from './redact';
/**
@@ -10,7 +15,7 @@ export function getAuthHeaders(): Record<string, string> {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
return apiKey ? { [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}` } : {};
}
/**
+2 -1
View File
@@ -1,6 +1,7 @@
import { base } from '$app/paths';
import { error } from '@sveltejs/kit';
import { browser } from '$app/environment';
import { AUTHORIZATION_HEADER, BEARER_PREFIX } from '$lib/constants';
import { config } from '$lib/stores/settings.svelte';
/**
@@ -24,7 +25,7 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${apiKey}`
};
const response = await fetch(`${base}/props`, { headers });
+23 -4
View File
@@ -6,7 +6,9 @@ import {
AMPERSAND_REGEX,
LT_REGEX,
GT_REGEX,
FENCE_PATTERN
FENCE_PATTERN,
TRIM_LEADING_PADDING_REGEX,
TRIM_TRAILING_PADDING_REGEX
} from '$lib/constants';
export interface IncompleteCodeBlock {
@@ -15,6 +17,19 @@ export interface IncompleteCodeBlock {
openingIndex: number;
}
/**
* Strips empty lines (whitespace-only) from the start and end of code.
*
* Tool call payloads frequently arrive with surrounding whitespace from LLM
* formatting (`"\nfunction ...\n"`). Preserving those newlines makes hljs emit
* a leading/trailing empty line that `<pre>` then renders as a phantom row,
* pushing real content away from the box edge. The trim keeps the body intact
* so internal blank lines are still rendered as such.
*/
function trimCodePadding(code: string): string {
return code.replace(TRIM_LEADING_PADDING_REGEX, '').replace(TRIM_TRAILING_PADDING_REGEX, '');
}
/**
* Highlights code using highlight.js
* @param code - The code to highlight
@@ -24,24 +39,28 @@ export interface IncompleteCodeBlock {
export function highlightCode(code: string, language: string): string {
if (!code) return '';
const trimmed = trimCodePadding(code);
try {
const lang = language.toLowerCase();
const isSupported = hljs.getLanguage(lang);
if (isSupported) {
return hljs.highlight(code, { language: lang }).value;
return hljs.highlight(trimmed, { language: lang }).value;
} else {
return hljs.highlightAuto(code).value;
return hljs.highlightAuto(trimmed).value;
}
} catch {
// Fallback to escaped plain text
return code
return trimmed
.replace(AMPERSAND_REGEX, '&amp;')
.replace(LT_REGEX, '&lt;')
.replace(GT_REGEX, '&gt;');
}
}
export { trimCodePadding };
/**
* Detects if markdown ends with an incomplete code block (opened but not closed).
* Returns the code block info if found, null otherwise.
+105
View File
@@ -0,0 +1,105 @@
/**
* Line-level unified diff for tool result rendering.
*
* Pure functions: no DOM, no Svelte, no highlight.js dependency. The
* returned `DiffLine[]` carries enough information both to render a
* custom diff block (per-entry kind/text) and to fold back into a
* unified-diff-format string for off-the-shelf highlighter languages
* (`renderUnifiedDiff`).
*
* Algorithm: LCS dynamic programming with a soft "remove before add"
* tiebreak so the resulting diff reads `(old -> new)` left to right.
* O(m*n) time/space which is fine for the handful of lines an
* `edit_file` snippet typically carries.
*/
import { DiffLineKind } from '$lib/enums';
export interface DiffLine {
kind: DiffLineKind;
text: string;
/** 1-indexed line number in the OLD content. Undefined for `add` lines. */
oldLine?: number;
/** 1-indexed line number in the NEW content. Undefined for `remove` lines. */
newLine?: number;
}
export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
const oldLines = splitLines(oldText);
const newLines = splitLines(newText);
const m = oldLines.length;
const n = newLines.length;
if (m === 0 && n === 0) return [];
if (m === 0) return newLines.map((t, k) => ({ kind: DiffLineKind.ADD, text: t, newLine: k + 1 }));
if (n === 0)
return oldLines.map((t, k) => ({ kind: DiffLineKind.REMOVE, text: t, oldLine: k + 1 }));
const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
const result: DiffLine[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (oldLines[i - 1] === newLines[j - 1]) {
result.push({
kind: DiffLineKind.CONTEXT,
text: oldLines[i - 1],
oldLine: i,
newLine: j
});
i--;
j--;
} else if (lcs[i - 1][j] >= lcs[i][j - 1]) {
result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
i--;
} else {
result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
j--;
}
}
while (i > 0) {
result.push({ kind: DiffLineKind.REMOVE, text: oldLines[i - 1], oldLine: i });
i--;
}
while (j > 0) {
result.push({ kind: DiffLineKind.ADD, text: newLines[j - 1], newLine: j });
j--;
}
result.reverse();
return result;
}
/** Folds `DiffLine[]` into a unified-diff-format text (`` ` ``/`+`/`-` prefixes).
* Pass to a diff-aware highlighter (e.g., SyntaxHighlightedCode with
* `language="diff"`) for colorization.
*/
export function renderUnifiedDiff(lines: DiffLine[]): string {
if (lines.length === 0) return '';
return lines.map((l) => prefixFor(l.kind) + l.text).join('\n');
}
/** Column-1 marker for a `DiffLine`: ` `, `+`, or `-`. */
export function prefixFor(kind: DiffLineKind): string {
if (kind === DiffLineKind.ADD) return '+';
if (kind === DiffLineKind.REMOVE) return '-';
return ' ';
}
function splitLines(text: string): string[] {
if (text === '') return [];
const parts = text.split('\n');
if (parts[parts.length - 1] === '') parts.pop();
return parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
}
+1 -35
View File
@@ -3,11 +3,7 @@ import {
SECONDS_PER_MINUTE,
SECONDS_PER_HOUR,
SHORT_DURATION_THRESHOLD,
MEDIUM_DURATION_THRESHOLD,
MAX_PREVIEW_LENGTH,
STRIP_MARKDOWN_INLINE_REGEX,
STRIP_MARKDOWN_CAPTURE_PATTERNS,
NEWLINE_SEPARATOR
MEDIUM_DURATION_THRESHOLD
} from '$lib/constants';
/**
@@ -155,33 +151,3 @@ export function formatAttachmentText(
const header = extra ? `${name} (${extra})` : name;
return `\n\n--- ${label}: ${header} ---\n${content}`;
}
export function formatReasoningPreview(content: string): { preview: string; overflow: number } {
if (!content) return { preview: '', overflow: 0 };
const lines = content.split(NEWLINE_SEPARATOR);
let lastLine = '';
for (let i = lines.length - 1; i >= 0; i--) {
let cleaned = lines[i].trim();
if (!cleaned) continue;
cleaned = cleaned.replace(STRIP_MARKDOWN_INLINE_REGEX, '');
for (const [pattern, replacement] of STRIP_MARKDOWN_CAPTURE_PATTERNS) {
cleaned = cleaned.replace(pattern, replacement);
}
if (cleaned.length > 0) {
lastLine = cleaned;
break;
}
}
const fullLength = lastLine.length;
const overflow = Math.max(0, fullLength - MAX_PREVIEW_LENGTH);
if (fullLength > MAX_PREVIEW_LENGTH) {
lastLine = lastLine.slice(0, MAX_PREVIEW_LENGTH) + '...';
}
return { preview: lastLine, overflow };
}
+52 -4
View File
@@ -30,7 +30,12 @@ export {
} from './branching';
// Code
export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from './code';
export {
highlightCode,
detectIncompleteCodeBlock,
trimCodePadding,
type IncompleteCodeBlock
} from './code';
// Config helpers
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
@@ -39,7 +44,7 @@ export { setConfigValue, getConfigValue, configToParameterRecord } from './confi
export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
// URL utilities
export { extractRootDomain, sanitizeExternalUrl } from './url';
export { extractRootDomain, sanitizeExternalUrl, canonicalizeServerUrl } from './url';
// Progress helpers
export { modelLoadFraction, modelLoadProgressText } from './progress';
@@ -76,8 +81,7 @@ export {
formatJsonPretty,
formatTime,
formatPerformanceTime,
formatAttachmentText,
formatReasoningPreview
formatAttachmentText
} from './formatters';
// IME utilities
@@ -117,6 +121,10 @@ export { sanitizeKeyValuePairKey, sanitizeKeyValuePairValue } from './sanitize';
// Image error fallback utilities
export { getImageErrorFallbackHtml } from './image-error-fallback';
// SSE-with-JSON stream iterator (used by built-in tool streaming, decoupled
// from chat.service.ts which embeds its own SSE parser for resume support)
export { parseSseJsonStream, type SseJsonEvent } from './sse';
// MCP utilities
export {
detectMcpTransportFromUrl,
@@ -153,12 +161,39 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
buildAssistantRawOutput,
parseToolResultWithImages,
splitSearchSummaryList,
hasAgenticContent,
classifyToolResult,
type AgenticSection,
type ToolResultLine
} from './agentic';
// Line-level unified diff for tool result rendering (`edit_file` block)
export { computeLineDiff, prefixFor, renderUnifiedDiff, type DiffLine } from './compute-line-diff';
// Partial-incremental JSON parser for streaming tool arguments
export { parsePartialJsonArgs } from './parse-partial-json-args';
// `exec_shell_command` result parsing
export { parseExecShellCommandError } from './parse-exec-shell-error';
export {
parseExecShellCommandExitStatus,
isExitCodeSummaryLine,
type ExecShellExitStatus
} from './parse-exec-shell-status';
// Search-result parsing (web-search / fetch MCP tools)
export {
SUPPORTED_WEB_SEARCH_TOOL_NAMES,
extractSearchResults,
extractSearchQuery,
faviconForUrl,
isWebSearchToolName,
type SearchResult
} from './search-results';
// Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
@@ -185,6 +220,19 @@ export {
withAbortSignal
} from './abort';
// Tool-call meta utilities. Parsers for each built-in tool live next to
// their renderer family under
// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
// This module only carries the helpers that genuinely cross tool
// boundaries (currently: parsing the tool-result blob into a JSON
// object).
export { tryParseToolResultObject } from './tool-call-meta';
// Per-tool UI metadata (label + icon) used by the tool-call chrome.
// Re-exported through $lib/utils so renderer components can read the
// label without depending on $lib/constants directly.
export { getBuiltinToolUi, type BuiltinToolUiEntry } from '$lib/constants/built-in-tools';
// Cryptography utilities
export { uuid } from './uuid';
+3 -1
View File
@@ -1,3 +1,5 @@
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
/**
* Normalizes a model name by extracting the filename from a path, but preserves Hugging Face repository format.
*
@@ -24,7 +26,7 @@ export function normalizeModelName(modelName: string): string {
return '';
}
const segments = trimmed.split(/[\\/]/);
const segments = trimmed.split(FILE_PATH_SEPARATOR_REGEX);
// If we have exactly 2 segments (one slash), treat it as Hugging Face repo format
// and preserve the full "org/model" format
@@ -0,0 +1,19 @@
export function parseExecShellCommandError(
toolResultString: string | undefined
): string | undefined {
if (!toolResultString) return undefined;
try {
const parsed: unknown = JSON.parse(toolResultString);
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
typeof (parsed as Record<string, unknown>).error === 'string'
) {
return (parsed as { error: string }).error;
}
} catch {
// Plain-text result = stdout/stderr, no structured error to surface.
}
return undefined;
}
@@ -0,0 +1,47 @@
/**
* Parsing helpers for `exec_shell_command` tool output.
*
* The server appends one final line to the response - an exit-code summary
* shaped as `[exit code: N]` (and optionally followed by `[exit due to timed
* out]`) - so the renderer can color that final line based on success/failure
* without parsing the entire output stream.
*/
export interface ExecShellExitStatus {
code: number;
timedOut: boolean;
/** Length-prefix slice for matching against the rendered lines list. */
rawText: string;
}
// Anchor to the absolute end so intermediate "[exit code: N]" string content
// (e.g. a shell echo) doesn't false-positive.
const EXIT_CODE_TAIL_REGEX = /\[exit code: (-?\d+)\](?: \[exit due to timed out\])?\s*$/;
export function parseExecShellCommandExitStatus(
toolResultString: string | undefined
): ExecShellExitStatus | undefined {
if (!toolResultString) return undefined;
const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
if (!match) return undefined;
return {
code: Number.parseInt(match[1], 10),
timedOut: match[0].includes('exit due to timed out'),
rawText: match[0]
};
}
/**
* Returns true when the supplied rendered line equals (trimmed) the
* trailing exit-code text. Used by the renderer to drop the duplicated
* representation (since the trailing line is replaced by a status badge).
*/
export function isExitCodeSummaryLine(
lineText: string,
status: ExecShellExitStatus | undefined
): boolean {
if (!status) return false;
return lineText.trim() === status.rawText.trim();
}
@@ -0,0 +1,83 @@
// JSON delimiters used while scanning partial streamed JSON. Single-char
// tokens so they only need eq-comparison, but naming them keeps the
// scanner readable and keeps the literal source-of-truth in one place.
const JSON_QUOTE = '"';
const JSON_BACKSLASH = '\\';
const JSON_OBJECT_OPEN = '{';
const JSON_OBJECT_CLOSE = '}';
const JSON_ARRAY_OPEN = '[';
const JSON_ARRAY_CLOSE = ']';
// Trailing punctuation to strip before re-closing a partial object/array.
// Matches an optional trailing comma plus any trailing whitespace; lets
// us re-emit a syntactically-valid JSON document without an orphaned
// comma when the model cut off mid-key.
const TRAILING_JSON_PUNCTUATION_REGEX = /,?\s*$/;
// Parse partial tool-arg JSON streamed token-by-token. Closes any
// unterminated string and dangling open containers (in reverse order),
// so parsers can still surface keys already received while the call
// is still in flight.
export function parsePartialJsonArgs(toolArgsString: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(toolArgsString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
let inString = false;
let escape = false;
const stack: ('{' | '[')[] = [];
for (let i = 0; i < toolArgsString.length; i++) {
const ch = toolArgsString[i];
if (escape) {
escape = false;
continue;
}
if (ch === JSON_BACKSLASH && inString) {
escape = true;
continue;
}
if (ch === JSON_QUOTE) {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === JSON_OBJECT_OPEN) stack.push(JSON_OBJECT_OPEN);
else if (ch === JSON_OBJECT_CLOSE) {
if (stack.length === 0 || stack[stack.length - 1] !== JSON_OBJECT_OPEN) return null;
stack.pop();
} else if (ch === JSON_ARRAY_OPEN) stack.push(JSON_ARRAY_OPEN);
else if (ch === JSON_ARRAY_CLOSE) {
if (stack.length === 0 || stack[stack.length - 1] !== JSON_ARRAY_OPEN) return null;
stack.pop();
}
}
let completed = toolArgsString;
if (escape) {
// Dangling escape at end of partial JSON: escape the trailing
// backslash as a literal so we can close the string cleanly.
completed += JSON_BACKSLASH;
}
if (inString) completed += JSON_QUOTE;
if (!inString) completed = completed.replace(TRAILING_JSON_PUNCTUATION_REGEX, '');
// Close in reverse nesting order: innermost container first.
for (let i = stack.length - 1; i >= 0; i--) {
completed += stack[i] === JSON_OBJECT_OPEN ? JSON_OBJECT_CLOSE : JSON_ARRAY_CLOSE;
}
try {
const parsed: unknown = JSON.parse(completed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Parsers for MCP web-search tool responses shaped like:
*
* Title: <text>
* URL: <https url>
* Published: <iso date or N/A>
* Author: <name or N/A>
* Highlights:
* <multi-line excerpt>
* ---
* Title: <next result>
* ...
*
* 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.
*/
export type SearchResult = {
title: string;
url: string;
published?: string;
author?: string;
highlights?: string;
};
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, string | undefined> = {
[FieldKey.TITLE]: undefined,
[FieldKey.URL]: undefined,
[FieldKey.PUBLISHED]: undefined,
[FieldKey.AUTHOR]: 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;
}
/**
* 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.
*/
export function extractSearchResults(text: string | undefined | null): SearchResult[] {
if (!text) return [];
const results: SearchResult[] = [];
for (const chunk of splitChunks(text)) {
const parsed = parseChunk(chunk);
if (parsed) results.push(parsed);
}
return results;
}
/**
* 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.
*/
export function extractSearchQuery(toolArgs: string | undefined | null): string {
if (!toolArgs) return '';
try {
const parsed: unknown = JSON.parse(toolArgs);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const candidate = (parsed as Record<string, unknown>)[SEARCH_TOOL_QUERY_FIELD];
if (typeof candidate === 'string') return candidate.trim();
}
} catch {
return '';
}
return '';
}
/**
* 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);
}
+71
View File
@@ -0,0 +1,71 @@
import {
SSE_DATA_PREFIX,
SSE_DONE_MARKER,
SSE_LINE_SEPARATOR,
SSE_RECORD_SEPARATOR
} from '$lib/constants';
/**
* Minimal SSE-with-JSON stream iterator.
*
* Yields one event per `\n\n`-separated record. Each event payload is the
* decoded `data:` field after JSON-parsing. A `[DONE]` sentinel terminates
* the stream early. Malformed records - any record whose `data:` payload
* fails `JSON.parse` - are skipped silently: usually a transient mid-stream
* fault that the caller should not have to special-case, and the noise of
* logging every occurrence on long-running streams outweighs the diagnostic
* value.
*
* Less ambitious than ChatService.handleStreamResponse (no resume, no byte
* offset tracking) - suitable for one-shot streams like `/tools?stream=true`
* where the consumer just reads chunks until done.
*/
export interface SseJsonEvent<T = unknown> {
data: T;
}
export async function* parseSseJsonStream<T = unknown>(
response: Response,
signal?: AbortSignal
): AsyncGenerator<SseJsonEvent<T>> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
if (signal?.aborted) return;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const records = buffer.split(SSE_RECORD_SEPARATOR);
buffer = records.pop() ?? '';
for (const record of records) {
if (!record) continue;
for (const line of record.split(SSE_LINE_SEPARATOR)) {
if (!line.startsWith(SSE_DATA_PREFIX)) continue;
const payload = line.slice(SSE_DATA_PREFIX.length).trim();
if (payload === SSE_DONE_MARKER) return;
if (!payload) continue;
try {
yield { data: JSON.parse(payload) as T };
} catch {
// Skip silently per the function contract above.
}
}
}
}
} finally {
try {
reader.releaseLock();
} catch (error) {
console.error('[sse] failed to release reader lock:', error);
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { NEWLINE_SEPARATOR } from '$lib/constants';
import { NEWLINE } from '$lib/constants';
/**
* Returns a shortened preview of the provided content capped at the given length.
@@ -14,7 +14,7 @@ export function getPreviewText(content: string, max = 150): string {
*/
export function generateConversationTitle(content: string, useFirstLine: boolean = false): string {
if (useFirstLine) {
const firstLine = content.split(NEWLINE_SEPARATOR).find((line) => line.trim().length > 0);
const firstLine = content.split(NEWLINE).find((line) => line.trim().length > 0);
return firstLine ? firstLine.trim() : content.trim();
}
+27
View File
@@ -0,0 +1,27 @@
// Generic helper for parsing tool-result blobs (the "out" side of a
// tool call). Used by the per-tool meta parsers under
// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
// Each tool needs to surface fields like `error`, `result`, `bytes`,
// `edits_applied` without repeating the try/JSON.parse/object guard inline.
/**
* Parse a tool-result blob into a JSON object, or `null` if it isn't
* one. Returns null for:
* - missing / empty input,
* - a JSON object that turns out to be an array or primitive,
* - any parse failure (always returns null rather than throwing).
*/
export function tryParseToolResultObject(
toolResultString: string | undefined
): Record<string, unknown> | null {
if (!toolResultString) return null;
try {
const parsed: unknown = JSON.parse(toolResultString);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
}
+41 -1
View File
@@ -1,4 +1,8 @@
import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants';
import {
TRAILING_SLASHES_REGEX,
TWO_PART_PUBLIC_SUFFIXES,
WILDCARD_PUBLIC_SUFFIXES
} from '$lib/constants';
import { UrlProtocol } from '$lib/enums';
/**
@@ -70,3 +74,39 @@ export function sanitizeExternalUrl(raw: string): string | null {
return null;
}
}
/**
* Canonicalize a server URL for "is this the same server?" checks across
* the user's settings and the recommended-server list. Lowercases scheme
* and host, drops the port entirely, and strips any trailing slashes off
* the path so a stored `https://api.example.com:8443/mcp/` matches the
* recommended `https://api.example.com/mcp`. Falls back to a cheap
* trim+lowercase+strip pass when the input isn't a parseable URL.
*
* Query strings are preserved deliberately - if the user entered one,
* it's part of their endpoint. The port is always stripped because the
* underlying `URL` parser is asymmetric (it auto-drops HTTPS default
* :443 but keeps HTTP default :80), so a half-hearted "drop default
* ports" policy never matches consistently across schemes.
*/
export function canonicalizeServerUrl(raw: string): string {
const trimmed = raw.trim();
try {
const parsed = new URL(trimmed);
const pathname = parsed.pathname.replace(TRAILING_SLASHES_REGEX, '');
// Aggressive: drop the port unconditionally. We only use this for
// equality checks between user-typed URLs and a hard-coded list of
// recommendations, where the port can never carry distinguishing
// information we care about (a different port = a different server,
// but two URLs that differ only in `:80` vs no-port are clearly the
// same intent). Lowercasing the hostname matches HTTP/HTTPS
// case-insensitivity - the URL parser does NOT lowercase it.
const host = parsed.hostname.toLowerCase();
return `${parsed.protocol}//${host}${pathname}${parsed.search}`;
} catch {
return trimmed.toLowerCase().replace(TRAILING_SLASHES_REGEX, '');
}
}