]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: added single line reasoning preview (#23601)
authorMagicExists <redacted>
Thu, 4 Jun 2026 14:09:43 +0000 (21:09 +0700)
committerGitHub <redacted>
Thu, 4 Jun 2026 14:09:43 +0000 (16:09 +0200)
* webui: added single line reasoning preview.

* patch: reduce width slightly for the previewing section

* refactor: move formatter constants to the right file

* feat: reimplement reasoning preview with throttled dynamic per-line rendering

* chore: fix spacing

Co-authored-by: Aleksander Grygier <redacted>
* chore: refactor to requested changes

* refactor: grouped by capture pattern instead of block-level + inline

* ui: fax interrupt state only trigger for 1st reasoning message

* chore: make reasoning preview respects showThoughtInProgress setting

* chore; newline at EOF

Co-authored-by: Aleksander Grygier <redacted>
* fix: thread rawContent so collapsible content can handle compute preview

* patch: showThoughtInProgress accidentally blocks rawContent being passed

* chore: fix lint

* chore: change smoke test

---------

Co-authored-by: Aleksander Grygier <redacted>
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte
tools/ui/src/lib/constants/formatters.ts
tools/ui/src/lib/hooks/use-throttle.svelte.ts [new file with mode: 0644]
tools/ui/src/lib/utils/agentic.ts
tools/ui/src/lib/utils/formatters.ts
tools/ui/src/lib/utils/index.ts

index 3a9cc7e9356d50b1bb4ea72bd1b6d6479277af26..e21dff993ffd192a92ea3e652c76b9de412adfcf 100644 (file)
@@ -31,7 +31,8 @@
                agenticPendingPermissionRequest,
                agenticResolvePermission,
                agenticPendingContinueRequest,
-               agenticResolveContinue
+               agenticResolveContinue,
+               agenticLastError
        } from '$lib/stores/agentic.svelte';
        import { config } from '$lib/stores/settings.svelte';
 
        const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
        const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
 
+       const hasReasoningError = $derived(
+               isLastAssistantMessage ? !!agenticLastError(message.convId) : false
+       );
+
        let permissionDismissed = $state(false);
 
        const pendingPermission = $derived(
                        </div>
                </CollapsibleContentBlock>
        {:else if section.type === AgenticSectionType.REASONING}
+               {@const reasoningSubtitle = section.wasInterrupted
+                       ? hasReasoningError
+                               ? 'Error'
+                               : 'Cancelled'
+                       : isStreaming
+                               ? ''
+                               : undefined}
+
                <CollapsibleContentBlock
                        open={isExpanded(index, section)}
                        class="my-2"
                        icon={Brain}
                        title="Reasoning"
+                       subtitle={reasoningSubtitle}
+                       rawContent={section.content}
                        onToggle={() => toggleExpanded(index, section)}
                >
                        <div class="pt-3">
                </CollapsibleContentBlock>
        {:else if section.type === AgenticSectionType.REASONING_PENDING}
                {@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
-               {@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
+               {@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
 
                <CollapsibleContentBlock
                        open={isExpanded(index, section)}
                        icon={Brain}
                        title={reasoningTitle}
                        subtitle={reasoningSubtitle}
+                       rawContent={section.content}
                        {isStreaming}
                        onToggle={() => toggleExpanded(index, section)}
                >
index b7297ab6b1aa95cea9eafe7d0752cb8078863070..8bab55d19fb98df8ee912a2ccf8ec1a73660a923 100644 (file)
@@ -4,6 +4,9 @@
        import { buttonVariants } from '$lib/components/ui/button/index.js';
        import { Card } from '$lib/components/ui/card';
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
+       import { useThrottle } from '$lib/hooks/use-throttle.svelte';
+       import { formatReasoningPreview } from '$lib/utils';
+       import { config } from '$lib/stores/settings.svelte';
        import type { Snippet } from 'svelte';
        import type { Component } from 'svelte';
 
@@ -14,6 +17,8 @@
                iconClass?: string;
                title: string;
                subtitle?: string;
+               preview?: string;
+               rawContent?: string;
                isStreaming?: boolean;
                onToggle?: () => void;
                children: Snippet;
@@ -26,6 +31,8 @@
                iconClass = 'h-4 w-4',
                title,
                subtitle,
+               preview,
+               rawContent,
                isStreaming = false,
                onToggle,
                children
 
        let contentContainer: HTMLDivElement | undefined = $state();
 
+       const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
+
+       let previewKey = useThrottle(() => rawContent ?? preview ?? '', 500);
+       let displayedPreview = $state('');
+       let displayedOverflow = $state(0);
+
+       $effect(() => {
+               void previewKey.key;
+               const content = rawContent ?? preview ?? '';
+               const result = formatReasoningPreview(content);
+               displayedPreview = result.preview;
+               displayedOverflow = result.overflow;
+       });
+
        const autoScroll = createAutoScrollController();
 
        $effect(() => {
        class={className}
 >
        <Card class="gap-0 border-muted bg-muted/30 py-0">
-               <Collapsible.Trigger class="flex w-full cursor-pointer items-center justify-between p-3">
-                       <div class="flex items-center gap-2 text-muted-foreground">
-                               {#if IconComponent}
-                                       <IconComponent class={iconClass} />
-                               {/if}
+               <Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
+                       <div class="flex min-w-0 items-center gap-2">
+                               <div class="flex items-center gap-2 text-muted-foreground">
+                                       {#if IconComponent}
+                                               <IconComponent class={iconClass} />
+                                       {/if}
+
+                                       <span class="font-mono text-sm font-medium">{title}</span>
 
-                               <span class="font-mono text-sm font-medium">{title}</span>
+                                       {#if subtitle}
+                                               <span class="text-xs italic">{subtitle}</span>
+                                       {/if}
+                               </div>
 
-                               {#if subtitle}
-                                       <span class="text-xs italic">{subtitle}</span>
+                               {#if displayedPreview && !showThoughtInProgress}
+                                       <div class="flex min-w-0 items-baseline justify-between gap-2">
+                                               <div class="w-3/4 truncate text-xs text-muted-foreground/80">
+                                                       {displayedPreview}
+                                               </div>
+                                               {#if displayedOverflow > 0}
+                                                       <span class="shrink-0 text-xs text-muted-foreground/60"
+                                                               >{displayedOverflow}+ chars</span
+                                                       >
+                                               {/if}
+                                       </div>
                                {/if}
                        </div>
 
index d6d1b883ffe96e76d8b9fe8720b75e511b9b8d8b..c417faea43d93b5771895b53fde98fb7678354b3 100644 (file)
@@ -6,3 +6,30 @@ export const MEDIUM_DURATION_THRESHOLD = 10;
 
 /** Default display value when no performance time is available */
 export const DEFAULT_PERFORMANCE_TIME = '0s';
+
+/** Max length before reasoning preview is truncated */
+export const MAX_PREVIEW_LENGTH = 120;
+
+export const STRIP_MARKDOWN_CAPTURE_PATTERNS: [RegExp, string][] = [
+       [/^```(.*)/gm, '$1'],
+       [/(.*)```$/gm, '$1'],
+       [/`([^`]*)`/g, '$1'],
+       [/\*\*(.*?)\*\*/g, '$1'],
+       [/__(.*?)__/g, '$1'],
+       [/\*(.*?)\*/g, '$1'],
+       [/_(.*?)_/g, '$1']
+];
+
+/* eslint-disable no-misleading-character-class */
+export const STRIP_MARKDOWN_INLINE_REGEX = new RegExp(
+       [
+               '<[^>]*>',
+               '^>\\s*',
+               '^#{1,6}\\s+',
+               '^[\\s]*[-*+]\\s+',
+               '^[\\s]*\\d+[.)]\\s+',
+               '[\\u{1F600}-\\u{1F64F}\\u{1F300}-\\u{1F5FF}\\u{1F680}-\\u{1F6FF}\\u{1F1E0}-\\u{1F1FF}\\u{2600}-\\u{26FF}\\u{2700}-\\u{27BF}\\u{FE00}-\\u{FE0F}\\u{1F900}-\\u{1F9FF}\\u{1FA00}-\\u{1FA6F}\\u{1FA70}-\\u{1FAFF}\\u{200D}\\u{20E3}\\u{231A}-\\u{231B}\\u{23E9}-\\u{23F3}\\u{23F8}-\\u{23FA}\\u{25AA}-\\u{25AB}\\u{25B6}\\u{25C0}\\u{25FB}-\\u{25FE}\\u{2934}-\\u{2935}\\u{2B05}-\\u{2B07}\\u{2B1B}-\\u{2B1C}\\u{2B50}\\u{2B55}\\u{3030}\\u{303D}\\u{3297}\\u{3299}]'
+       ].join('|'),
+       'gmu'
+);
+/* eslint-enable no-misleading-character-class */
diff --git a/tools/ui/src/lib/hooks/use-throttle.svelte.ts b/tools/ui/src/lib/hooks/use-throttle.svelte.ts
new file mode 100644 (file)
index 0000000..0795519
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Creates a reactive throttle key that increments when `getValue()` changes
+ * and the throttle window has elapsed since the last increment.
+ *
+ * Useful for throttling animations that should not fire on every rapid update.
+ *
+ * @param getValue - A reactive getter for the value to watch
+ * @param ms - Throttle window in milliseconds
+ * @returns A reactive number that increments when the throttled value changes
+ */
+export function useThrottle(getValue: () => string | undefined, ms: number) {
+       let key = $state(0);
+       let throttleEnd = $state(0);
+       let lastValue: string | undefined = getValue();
+
+       $effect(() => {
+               const value = getValue();
+               if (value === lastValue) return;
+               const now = Date.now();
+               if (now >= throttleEnd) {
+                       lastValue = value;
+                       key++;
+                       throttleEnd = now + ms;
+               }
+       });
+
+       return {
+               get key() {
+                       return key;
+               }
+       };
+}
index 52ff35793063fffe85993bd273d25fe5a9675b8f..d19f03434e6bc3b49722dd865f04645abb4a4bfc 100644 (file)
@@ -18,6 +18,7 @@ export interface AgenticSection {
        toolArgs?: string;
        toolResult?: string;
        toolResultExtras?: DatabaseMessageExtra[];
+       wasInterrupted?: boolean;
 }
 
 /**
@@ -51,7 +52,8 @@ function deriveSingleTurnSections(
                const isPending = isStreaming && !hasContentAfterReasoning;
                sections.push({
                        type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
-                       content: message.reasoningContent
+                       content: message.reasoningContent,
+                       wasInterrupted: !isStreaming && !hasContentAfterReasoning
                });
        }
 
index 24a2c1c94c1899f11bf45ad0c864664861c1a6f5..de74ee8686d1bcf91e04392d48c77462e5eb3077 100644 (file)
@@ -3,7 +3,11 @@ import {
        SECONDS_PER_MINUTE,
        SECONDS_PER_HOUR,
        SHORT_DURATION_THRESHOLD,
-       MEDIUM_DURATION_THRESHOLD
+       MEDIUM_DURATION_THRESHOLD,
+       MAX_PREVIEW_LENGTH,
+       STRIP_MARKDOWN_INLINE_REGEX,
+       STRIP_MARKDOWN_CAPTURE_PATTERNS,
+       NEWLINE_SEPARATOR
 } from '$lib/constants';
 
 /**
@@ -151,3 +155,33 @@ export function formatAttachmentText(
        const header = extra ? `${name} (${extra})` : name;
        return `\n\n--- ${label}: ${header} ---\n${content}`;
 }
+
+export function formatReasoningPreview(content: string): { preview: string; overflow: number } {
+       if (!content) return { preview: '', overflow: 0 };
+
+       const lines = content.split(NEWLINE_SEPARATOR);
+       let lastLine = '';
+
+       for (let i = lines.length - 1; i >= 0; i--) {
+               let cleaned = lines[i].trim();
+               if (!cleaned) continue;
+
+               cleaned = cleaned.replace(STRIP_MARKDOWN_INLINE_REGEX, '');
+               for (const [pattern, replacement] of STRIP_MARKDOWN_CAPTURE_PATTERNS) {
+                       cleaned = cleaned.replace(pattern, replacement);
+               }
+
+               if (cleaned.length > 0) {
+                       lastLine = cleaned;
+                       break;
+               }
+       }
+
+       const fullLength = lastLine.length;
+       const overflow = Math.max(0, fullLength - MAX_PREVIEW_LENGTH);
+       if (fullLength > MAX_PREVIEW_LENGTH) {
+               lastLine = lastLine.slice(0, MAX_PREVIEW_LENGTH) + '...';
+       }
+
+       return { preview: lastLine, overflow };
+}
index 00aa49c41765740fd348cacdfb84a57e1b360cf0..637db8812c4dfd475220fe5b160bed987103fe20 100644 (file)
@@ -76,7 +76,8 @@ export {
        formatJsonPretty,
        formatTime,
        formatPerformanceTime,
-       formatAttachmentText
+       formatAttachmentText,
+       formatReasoningPreview
 } from './formatters';
 
 // IME utilities