diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
index 2188c8f0c2..07f85e3577 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
@@ -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();
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
index 33f6cd66cc..3273bc6b7e 100644
--- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
+++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
@@ -1,9 +1,26 @@
@@ -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'}
+
Arguments:
+
{#if isStreaming}
{/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'}
+
Arguments:
+
Result:
+
{#if isPending}
{/if}
@@ -387,6 +196,7 @@
{:else if section.type === AgenticSectionType.REASONING_PENDING}
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
{@const reasoningSubtitle = isStreaming ? 'streaming...' : 'incomplete'}
+
{
- 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 @@
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 <<>>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;
+}
diff --git a/tools/server/webui/src/lib/utils/index.ts b/tools/server/webui/src/lib/utils/index.ts
index 5f53c25b45..8baa5b744c 100644
--- a/tools/server/webui/src/lib/utils/index.ts
+++ b/tools/server/webui/src/lib/utils/index.ts
@@ -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';