refactor: Cleanup

This commit is contained in:
Aleksander Grygier 2026-01-26 09:54:44 +01:00
parent 0a66568fc9
commit b7d1de68c3
5 changed files with 476 additions and 314 deletions

View File

@ -26,14 +26,19 @@
import { onMount } from 'svelte';
interface Props {
// Data
attachments?: DatabaseMessageExtra[];
uploadedFiles?: ChatUploadedFile[];
value?: string;
// UI State
class?: string;
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
uploadedFiles?: ChatUploadedFile[];
value?: string;
// Event Handlers
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onStop?: () => void;
@ -63,23 +68,49 @@
onValueChange
}: Props = $props();
/**
*
*
* STATE
*
*
*/
// Component References
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let isRecording = $state(false);
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
let isRouter = $derived(isRouterMode());
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Prompt Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
/**
*
*
* DERIVED STATE
*
*
*/
// Configuration
let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
// Model Selection Logic
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelOptions();
@ -101,12 +132,7 @@
return null;
});
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
// Form Validation State
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
@ -114,11 +140,19 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
/**
*
*
* PUBLIC API
*
*
*/
export function focus() {
textareaRef?.focus();
}
export function resetHeight() {
export function resetTextareaHeight() {
textareaRef?.resetHeight();
}
@ -126,6 +160,10 @@
chatFormActionsRef?.openModelSelector();
}
/**
* Check if a model is selected, open selector if not
* @returns true if model is selected, false otherwise
*/
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
@ -134,6 +172,14 @@
return true;
}
/**
*
*
* EVENT HANDLERS - File Management
*
*
*/
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
@ -142,6 +188,25 @@
fileInputRef?.click();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
/**
*
*
* EVENT HANDLERS - Input & Keyboard
*
*
*/
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
@ -175,6 +240,70 @@
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
/**
*
*
* EVENT HANDLERS - Prompt Picker
*
*
*/
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
@ -246,72 +375,13 @@
textareaRef?.focus();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
/**
*
*
* EVENT HANDLERS - Audio Recording
*
*
*/
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
@ -341,6 +411,14 @@
}
}
/**
*
*
* LIFECYCLE
*
*
*/
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();

View File

@ -1,9 +1,26 @@
<script lang="ts">
/**
* AgenticContent - Chronological display of agentic flow output
* ChatMessageAgenticContent - Chronological display of agentic flow output
*
* Parses content with tool call and reasoning markers and displays them inline
* with text content. Tool calls and reasoning are shown as collapsible blocks.
* This component renders assistant messages containing agentic workflow markers,
* displaying tool calls and reasoning blocks as interactive collapsible sections.
*
* Features:
* - Parses content with tool call markers (<<<AGENTIC_TOOL_CALL>>>, etc.)
* - Displays reasoning blocks (<<<REASONING>>>, etc.)
* - Shows tool execution states: streaming, pending, completed
* - Collapsible blocks with user-configurable default states
* - Real-time streaming support with loading indicators
*
* @component
* @example
* ```svelte
* <ChatMessageAgenticContent
* content={message.content}
* message={message}
* isStreaming={true}
* />
* ```
*/
import {
@ -14,30 +31,24 @@
import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte';
import { AgenticSectionType } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
import { formatJsonPretty } from '$lib/utils';
import { parseAgenticContent, type AgenticSection } from '$lib/utils/agentic';
import type { DatabaseMessage } from '$lib/types/database';
interface Props {
/** Optional database message for context */
message?: DatabaseMessage;
/** Raw content string to parse and display */
content: string;
/** Whether content is currently streaming */
isStreaming?: boolean;
}
interface AgenticSection {
type: AgenticSectionType;
content: string;
toolName?: string;
toolArgs?: string;
toolResult?: string;
}
let { content, message, isStreaming = false }: Props = $props();
const sections = $derived(parseAgenticContent(content));
let expandedStates: Record<number, boolean> = $state({});
const sections = $derived(parseAgenticContent(content));
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
@ -48,9 +59,11 @@
) {
return showToolCallInProgress;
}
if (section.type === AgenticSectionType.REASONING_PENDING) {
return showThoughtInProgress;
}
return false;
}
@ -58,224 +71,15 @@
if (expandedStates[index] !== undefined) {
return expandedStates[index];
}
return getDefaultExpanded(section);
}
function toggleExpanded(index: number, section: AgenticSection) {
const currentState = isExpanded(index, section);
expandedStates[index] = !currentState;
}
type ReasoningSegment = {
type:
| AgenticSectionType.TEXT
| AgenticSectionType.REASONING
| AgenticSectionType.REASONING_PENDING;
content: string;
};
function stripPartialMarker(text: string): string {
const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
return text.slice(0, partialMarkerMatch.index).trim();
}
return text;
}
function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
if (!rawContent) return [];
const segments: ReasoningSegment[] = [];
let cursor = 0;
while (cursor < rawContent.length) {
const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
if (startIndex === -1) {
const remainingText = rawContent.slice(cursor);
if (remainingText) {
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
break;
}
if (startIndex > cursor) {
const textBefore = rawContent.slice(cursor, startIndex);
if (textBefore) {
segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const contentStart = startIndex + REASONING_TAGS.START.length;
const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
if (endIndex === -1) {
const pendingContent = rawContent.slice(contentStart);
segments.push({
type: AgenticSectionType.REASONING_PENDING,
content: stripPartialMarker(pendingContent)
});
break;
}
const reasoningContent = rawContent.slice(contentStart, endIndex);
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
cursor = endIndex + REASONING_TAGS.END.length;
}
return segments;
}
function parseToolCallContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
const sections: AgenticSection[] = [];
const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
let lastIndex = 0;
let match;
while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
if (match.index > lastIndex) {
const textBefore = rawContent.slice(lastIndex, match.index).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = match[1];
const toolArgs = match[2]; // Direct JSON
const toolResult = match[3].replace(/^\n+|\n+$/g, '');
sections.push({
type: AgenticSectionType.TOOL_CALL,
content: toolResult,
toolName,
toolArgs,
toolResult
});
lastIndex = match.index + match[0].length;
}
const remainingContent = rawContent.slice(lastIndex);
const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
if (pendingMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = pendingMatch[1];
const toolArgs = pendingMatch[2]; // Direct JSON
// Capture streaming result content (everything after TOOL_ARGS_END marker)
const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, '');
sections.push({
type: AgenticSectionType.TOOL_CALL_PENDING,
content: streamingResult,
toolName,
toolArgs,
toolResult: streamingResult || undefined
});
} else if (partialWithNameMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const partialArgs = partialWithNameMatch[2] || ''; // Direct JSON streaming
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: partialWithNameMatch[1],
toolArgs: partialArgs || undefined,
toolResult: undefined
});
} else if (earlyMatch) {
// Just START marker, show streaming state
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
// Try to extract tool name if present
const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: nameMatch?.[1],
toolArgs: undefined,
toolResult: undefined
});
} else if (lastIndex < rawContent.length) {
// Add remaining text after last completed tool call
// But strip any partial markers that might be starting
let remainingText = rawContent.slice(lastIndex).trim();
// Check for partial marker at the end (e.g., "<<<" or "<<<AGENTIC" etc.)
const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
}
if (remainingText) {
sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
}
// If no tool calls found, return content as single text section
if (sections.length === 0 && rawContent.trim()) {
sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
}
return sections;
}
function parseAgenticContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
const segments = splitReasoningSegments(rawContent);
const sections: AgenticSection[] = [];
for (const segment of segments) {
if (segment.type === 'text') {
sections.push(...parseToolCallContent(segment.content));
continue;
}
if (segment.type === 'reasoning') {
if (segment.content.trim()) {
sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
}
continue;
}
sections.push({
type: AgenticSectionType.REASONING_PENDING,
content: segment.content
});
}
return sections;
}
</script>
<div class="agentic-content">
@ -288,6 +92,7 @@
{@const streamingIcon = isStreaming ? Loader2 : AlertTriangle}
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'}
{@const streamingSubtitle = isStreaming ? 'streaming...' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
@ -301,6 +106,7 @@
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
{#if isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
@ -329,6 +135,7 @@
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
{@const toolIcon = isPending ? Loader2 : Wrench}
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"
@ -342,6 +149,7 @@
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
language="json"
@ -354,6 +162,7 @@
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Result:</span>
{#if isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
@ -387,6 +196,7 @@
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
{@const reasoningSubtitle = isStreaming ? 'streaming...' : 'incomplete'}
<CollapsibleContentBlock
open={isExpanded(index, section)}
class="my-2"

View File

@ -31,7 +31,7 @@
uploadedFiles = $bindable([])
}: Props = $props();
let inputAreaRef: ChatForm | undefined = $state(undefined);
let chatFormRef: ChatForm | undefined = $state(undefined);
let message = $state(initialMessage);
let previousIsLoading = $state(isLoading);
let previousInitialMessage = $state(initialMessage);
@ -59,7 +59,7 @@
)
return;
if (!inputAreaRef?.checkModelSelected()) return;
if (!chatFormRef?.checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
@ -67,7 +67,7 @@
message = '';
uploadedFiles = [];
inputAreaRef?.resetHeight();
chatFormRef?.resetTextareaHeight();
const success = await onSend?.(messageToSend, filesToSend);
@ -86,16 +86,16 @@
}
onMount(() => {
setTimeout(() => inputAreaRef?.focus(), 10);
setTimeout(() => chatFormRef?.focus(), 10);
});
afterNavigate(() => {
setTimeout(() => inputAreaRef?.focus(), 10);
setTimeout(() => chatFormRef?.focus(), 10);
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => inputAreaRef?.focus(), 10);
setTimeout(() => chatFormRef?.focus(), 10);
}
previousIsLoading = isLoading;
@ -104,7 +104,7 @@
<div class="relative mx-auto max-w-[48rem]">
<ChatForm
bind:this={inputAreaRef}
bind:this={chatFormRef}
bind:value={message}
bind:uploadedFiles
class={className}

View File

@ -0,0 +1,271 @@
import { AgenticSectionType } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic';
/**
* Represents a parsed section of agentic content
*/
export interface AgenticSection {
type: AgenticSectionType;
content: string;
toolName?: string;
toolArgs?: string;
toolResult?: string;
}
/**
* Represents a segment of content that may contain reasoning blocks
*/
type ReasoningSegment = {
type:
| AgenticSectionType.TEXT
| AgenticSectionType.REASONING
| AgenticSectionType.REASONING_PENDING;
content: string;
};
/**
* Strips partial marker from text content
*
* Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
* at the end of streaming content.
*
* @param text - The text content to process
* @returns Text with partial markers removed
*/
function stripPartialMarker(text: string): string {
const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
return text.slice(0, partialMarkerMatch.index).trim();
}
return text;
}
/**
* Splits raw content into segments based on reasoning blocks
*
* Identifies and extracts reasoning content wrapped in REASONING_TAGS.START/END markers,
* separating it from regular text content. Handles both complete and incomplete
* (streaming) reasoning blocks.
*
* @param rawContent - The raw content string to parse
* @returns Array of reasoning segments with their types and content
*/
function splitReasoningSegments(rawContent: string): ReasoningSegment[] {
if (!rawContent) return [];
const segments: ReasoningSegment[] = [];
let cursor = 0;
while (cursor < rawContent.length) {
const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
if (startIndex === -1) {
const remainingText = rawContent.slice(cursor);
if (remainingText) {
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
break;
}
if (startIndex > cursor) {
const textBefore = rawContent.slice(cursor, startIndex);
if (textBefore) {
segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const contentStart = startIndex + REASONING_TAGS.START.length;
const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
if (endIndex === -1) {
const pendingContent = rawContent.slice(contentStart);
segments.push({
type: AgenticSectionType.REASONING_PENDING,
content: stripPartialMarker(pendingContent)
});
break;
}
const reasoningContent = rawContent.slice(contentStart, endIndex);
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
cursor = endIndex + REASONING_TAGS.END.length;
}
return segments;
}
/**
* Parses content containing tool call markers
*
* Identifies and extracts tool calls from content, handling:
* - Completed tool calls with name, arguments, and results
* - Pending tool calls (execution in progress)
* - Streaming tool calls (arguments being received)
* - Early-stage tool calls (just started)
*
* @param rawContent - The raw content string to parse
* @returns Array of agentic sections representing tool calls and text
*/
function parseToolCallContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
const sections: AgenticSection[] = [];
const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
let lastIndex = 0;
let match;
while ((match = completedToolCallRegex.exec(rawContent)) !== null) {
if (match.index > lastIndex) {
const textBefore = rawContent.slice(lastIndex, match.index).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = match[1];
const toolArgs = match[2];
const toolResult = match[3].replace(/^\n+|\n+$/g, '');
sections.push({
type: AgenticSectionType.TOOL_CALL,
content: toolResult,
toolName,
toolArgs,
toolResult
});
lastIndex = match.index + match[0].length;
}
const remainingContent = rawContent.slice(lastIndex);
const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
if (pendingMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = pendingMatch[1];
const toolArgs = pendingMatch[2];
const streamingResult = (pendingMatch[3] || '').replace(/^\n+|\n+$/g, '');
sections.push({
type: AgenticSectionType.TOOL_CALL_PENDING,
content: streamingResult,
toolName,
toolArgs,
toolResult: streamingResult || undefined
});
} else if (partialWithNameMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const partialArgs = partialWithNameMatch[2] || '';
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: partialWithNameMatch[1],
toolArgs: partialArgs || undefined,
toolResult: undefined
});
} else if (earlyMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: nameMatch?.[1],
toolArgs: undefined,
toolResult: undefined
});
} else if (lastIndex < rawContent.length) {
let remainingText = rawContent.slice(lastIndex).trim();
const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
}
if (remainingText) {
sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
}
if (sections.length === 0 && rawContent.trim()) {
sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
}
return sections;
}
/**
* Parses agentic content into structured sections
*
* Main parsing function that processes content containing:
* - Tool calls (completed, pending, or streaming)
* - Reasoning blocks (completed or streaming)
* - Regular text content
*
* The parser handles chronological display of agentic flow output, maintaining
* the order of operations and properly identifying different states of tool calls
* and reasoning blocks during streaming.
*
* @param rawContent - The raw content string to parse
* @returns Array of structured agentic sections ready for display
*
* @example
* ```typescript
* const content = "Some text <<<AGENTIC_TOOL_CALL>>>tool_name...";
* const sections = parseAgenticContent(content);
* // Returns: [{ type: 'text', content: 'Some text' }, { type: 'tool_call_streaming', ... }]
* ```
*/
export function parseAgenticContent(rawContent: string): AgenticSection[] {
if (!rawContent) return [];
const segments = splitReasoningSegments(rawContent);
const sections: AgenticSection[] = [];
for (const segment of segments) {
if (segment.type === 'text') {
sections.push(...parseToolCallContent(segment.content));
continue;
}
if (segment.type === 'reasoning') {
if (segment.content.trim()) {
sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
}
continue;
}
sections.push({
type: AgenticSectionType.REASONING_PENDING,
content: segment.content
});
}
return sections;
}

View File

@ -115,3 +115,6 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities
export { getFaviconUrl } from './favicon';
// Agentic content parsing utilities
export { parseAgenticContent, type AgenticSection } from './agentic';