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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(LT_REGEX, '<')
|
||||
.replace(GT_REGEX, '>');
|
||||
}
|
||||
}
|
||||
|
||||
export { trimCodePadding };
|
||||
|
||||
/**
|
||||
* Detects if markdown ends with an incomplete code block (opened but not closed).
|
||||
* Returns the code block info if found, null otherwise.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user