refactor: Cleanup
This commit is contained in:
parent
0a66568fc9
commit
b7d1de68c3
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue