From 7c9be63a748690450b34f1c76496f03e7ba253d3 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 28 Jan 2026 18:28:03 +0100 Subject: [PATCH] refactor: Refine Chat Message Processing State Display --- .../app/chat/ChatMessages/ChatMessage.svelte | 9 ++- .../ChatMessageAgenticContent.svelte | 4 +- .../ChatMessages/ChatMessageAssistant.svelte | 41 +++++++++++- .../app/chat/ChatMessages/ChatMessages.svelte | 23 +++++-- .../ChatScreenProcessingInfo.svelte | 2 +- .../lib/hooks/use-processing-state.svelte.ts | 67 +++++++++++++++++++ 6 files changed, 135 insertions(+), 11 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index 0734e9f889..03803490ba 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -17,10 +17,16 @@ interface Props { class?: string; message: DatabaseMessage; + isLastAssistantMessage?: boolean; siblingInfo?: ChatMessageSiblingInfo | null; } - let { class: className = '', message, siblingInfo = null }: Props = $props(); + let { + class: className = '', + message, + isLastAssistantMessage = false, + siblingInfo = null + }: Props = $props(); const chatActions = getChatActionsContext(); @@ -278,6 +284,7 @@ bind:textareaElement class={className} {deletionInfo} + {isLastAssistantMessage} {message} messageContent={message.content} onConfirmDelete={handleConfirmDelete} 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 39d7d44653..19ec0091ec 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 @@ -67,7 +67,7 @@ {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} {@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'} + {@const streamingSubtitle = isStreaming ? '' : 'incomplete'} {:else if section.type === AgenticSectionType.REASONING_PENDING} {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'} - {@const reasoningSubtitle = isStreaming ? 'streaming...' : 'incomplete'} + {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'} void; @@ -51,6 +52,7 @@ let { class: className = '', deletionInfo, + isLastAssistantMessage = false, message, messageContent, onConfirmDelete, @@ -100,6 +102,25 @@ let displayedModel = $derived(message.model ?? null); + let isCurrentlyLoading = $derived(isLoading()); + let isStreaming = $derived(isChatStreaming()); + let hasNoContent = $derived(!message?.content?.trim()); + let isActivelyProcessing = $derived(isCurrentlyLoading || isStreaming); + + let showProcessingInfoTop = $derived( + message?.role === MessageRole.ASSISTANT && + isActivelyProcessing && + hasNoContent && + isLastAssistantMessage + ); + + let showProcessingInfoBottom = $derived( + message?.role === MessageRole.ASSISTANT && + isActivelyProcessing && + !hasNoContent && + isLastAssistantMessage + ); + function handleCopyModel() { void copyToClipboard(displayedModel ?? ''); } @@ -111,7 +132,7 @@ }); $effect(() => { - if (isLoading() && !message?.content?.trim()) { + if (showProcessingInfoTop || showProcessingInfoBottom) { processingState.startMonitoring(); } }); @@ -122,11 +143,13 @@ role="group" aria-label="Assistant message with actions" > - {#if message?.role === MessageRole.ASSISTANT && isLoading() && !message?.content?.trim()} + {#if showProcessingInfoTop}
- {processingState.getPromptProgressText() ?? processingState.getProcessingMessage()} + {processingState.getPromptProgressText() ?? + processingState.getProcessingMessage() ?? + 'Processing...'}
@@ -193,6 +216,18 @@ {/if} + {#if showProcessingInfoBottom} +
+
+ + {processingState.getPromptProgressText() ?? + processingState.getProcessingMessage() ?? + 'Processing...'} + +
+
+ {/if} +
{#if displayedModel}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte index be6c87d2bd..07e526e555 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte @@ -100,16 +100,26 @@ return []; } - // Filter out system messages if showSystemMessage is false const filteredMessages = currentConfig.showSystemMessage ? messages : messages.filter((msg) => msg.type !== MessageRole.SYSTEM); - return filteredMessages.map((message) => { + let lastAssistantIndex = -1; + for (let i = filteredMessages.length - 1; i >= 0; i--) { + if (filteredMessages[i].role === MessageRole.ASSISTANT) { + lastAssistantIndex = i; + break; + } + } + + return filteredMessages.map((message, index) => { const siblingInfo = getMessageSiblings(allConversationMessages, message.id); + const isLastAssistantMessage = + message.role === MessageRole.ASSISTANT && index === lastAssistantIndex; return { message, + isLastAssistantMessage, siblingInfo: siblingInfo || { message, siblingIds: [message.id], @@ -122,7 +132,12 @@
- {#each displayMessages as { message, siblingInfo } (message.id)} - + {#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)} + {/each}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte index bf52b1f0b6..cc7b22cfd8 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte @@ -11,7 +11,7 @@ let isCurrentConversationLoading = $derived(isLoading()); let isStreaming = $derived(isChatStreaming()); let hasProcessingData = $derived(processingState.processingState !== null); - let processingDetails = $derived(processingState.getProcessingDetails()); + let processingDetails = $derived(processingState.getTechnicalDetails()); let showProcessingInfo = $derived( isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData diff --git a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts index c9cb47dc1d..b8d4616d0e 100644 --- a/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts +++ b/tools/server/webui/src/lib/hooks/use-processing-state.svelte.ts @@ -6,6 +6,7 @@ import type { ApiProcessingState, LiveProcessingStats, LiveGenerationStats } fro export interface UseProcessingStateReturn { readonly processingState: ApiProcessingState | null; getProcessingDetails(): string[]; + getTechnicalDetails(): string[]; getProcessingMessage(): string; getPromptProgressText(): string | null; getLiveProcessingStats(): LiveProcessingStats | null; @@ -126,6 +127,71 @@ export function useProcessingState(): UseProcessingStateReturn { const details: string[] = []; + // Show prompt processing progress with ETA during preparation phase + if (stateToUse.promptProgress) { + const { processed, total, time_ms, cache } = stateToUse.promptProgress; + const actualProcessed = processed - cache; + const actualTotal = total - cache; + + if (actualProcessed < actualTotal && actualProcessed > 0) { + const percent = Math.round((actualProcessed / actualTotal) * 100); + const eta = getETASecs(actualProcessed, actualTotal, time_ms); + + if (eta !== undefined) { + const etaSecs = Math.ceil(eta); + details.push(`Processing ${percent}% (ETA: ${etaSecs}s)`); + } else { + details.push(`Processing ${percent}%`); + } + } + } + + // Always show context info when we have valid data + if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) { + const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); + + details.push( + `Context: ${stateToUse.contextUsed}/${stateToUse.contextTotal} (${contextPercent}%)` + ); + } + + if (stateToUse.outputTokensUsed > 0) { + // Handle infinite max_tokens (-1) case + if (stateToUse.outputTokensMax <= 0) { + details.push(`Output: ${stateToUse.outputTokensUsed}/∞`); + } else { + const outputPercent = Math.round( + (stateToUse.outputTokensUsed / stateToUse.outputTokensMax) * 100 + ); + + details.push( + `Output: ${stateToUse.outputTokensUsed}/${stateToUse.outputTokensMax} (${outputPercent}%)` + ); + } + } + + if (stateToUse.tokensPerSecond && stateToUse.tokensPerSecond > 0) { + details.push(`${stateToUse.tokensPerSecond.toFixed(1)} ${STATS_UNITS.TOKENS_PER_SECOND}`); + } + + if (stateToUse.speculative) { + details.push('Speculative decoding enabled'); + } + + return details; + } + + /** + * Returns technical details without the progress message (for bottom bar) + */ + function getTechnicalDetails(): string[] { + const stateToUse = processingState || lastKnownState; + if (!stateToUse) { + return []; + } + + const details: string[] = []; + // Always show context info when we have valid data if (stateToUse.contextUsed >= 0 && stateToUse.contextTotal > 0) { const contextPercent = Math.round((stateToUse.contextUsed / stateToUse.contextTotal) * 100); @@ -239,6 +305,7 @@ export function useProcessingState(): UseProcessingStateReturn { return processingState; }, getProcessingDetails, + getTechnicalDetails, getProcessingMessage, getPromptProgressText, getLiveProcessingStats,