From b7d1de68c394929afedf10b7ffba891bce24ad4a Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 26 Jan 2026 09:54:44 +0100 Subject: [PATCH] refactor: Cleanup --- .../app/chat/ChatForm/ChatForm.svelte | 242 ++++++++++------ .../ChatMessageAgenticContent.svelte | 260 +++-------------- .../app/chat/ChatScreen/ChatScreenForm.svelte | 14 +- tools/server/webui/src/lib/utils/agentic.ts | 271 ++++++++++++++++++ tools/server/webui/src/lib/utils/index.ts | 3 + 5 files changed, 476 insertions(+), 314 deletions(-) create mode 100644 tools/server/webui/src/lib/utils/agentic.ts 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';