ui: added single line reasoning preview (#23601)
* 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 <aleksander.grygier@gmail.com> * 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 <aleksander.grygier@gmail.com> * 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 <aleksander.grygier@gmail.com>
This commit is contained in:
co-authored by
Aleksander Grygier
parent
0dbfa66a1f
commit
526977068f
@@ -31,7 +31,8 @@
|
|||||||
agenticPendingPermissionRequest,
|
agenticPendingPermissionRequest,
|
||||||
agenticResolvePermission,
|
agenticResolvePermission,
|
||||||
agenticPendingContinueRequest,
|
agenticPendingContinueRequest,
|
||||||
agenticResolveContinue
|
agenticResolveContinue,
|
||||||
|
agenticLastError
|
||||||
} from '$lib/stores/agentic.svelte';
|
} from '$lib/stores/agentic.svelte';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@
|
|||||||
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
||||||
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
|
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
|
||||||
|
|
||||||
|
const hasReasoningError = $derived(
|
||||||
|
isLastAssistantMessage ? !!agenticLastError(message.convId) : false
|
||||||
|
);
|
||||||
|
|
||||||
let permissionDismissed = $state(false);
|
let permissionDismissed = $state(false);
|
||||||
|
|
||||||
const pendingPermission = $derived(
|
const pendingPermission = $derived(
|
||||||
@@ -293,11 +298,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</CollapsibleContentBlock>
|
</CollapsibleContentBlock>
|
||||||
{:else if section.type === AgenticSectionType.REASONING}
|
{:else if section.type === AgenticSectionType.REASONING}
|
||||||
|
{@const reasoningSubtitle = section.wasInterrupted
|
||||||
|
? hasReasoningError
|
||||||
|
? 'Error'
|
||||||
|
: 'Cancelled'
|
||||||
|
: isStreaming
|
||||||
|
? ''
|
||||||
|
: undefined}
|
||||||
|
|
||||||
<CollapsibleContentBlock
|
<CollapsibleContentBlock
|
||||||
open={isExpanded(index, section)}
|
open={isExpanded(index, section)}
|
||||||
class="my-2"
|
class="my-2"
|
||||||
icon={Brain}
|
icon={Brain}
|
||||||
title="Reasoning"
|
title="Reasoning"
|
||||||
|
subtitle={reasoningSubtitle}
|
||||||
|
rawContent={section.content}
|
||||||
onToggle={() => toggleExpanded(index, section)}
|
onToggle={() => toggleExpanded(index, section)}
|
||||||
>
|
>
|
||||||
<div class="pt-3">
|
<div class="pt-3">
|
||||||
@@ -308,7 +323,7 @@
|
|||||||
</CollapsibleContentBlock>
|
</CollapsibleContentBlock>
|
||||||
{:else if section.type === AgenticSectionType.REASONING_PENDING}
|
{:else if section.type === AgenticSectionType.REASONING_PENDING}
|
||||||
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
|
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
|
||||||
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
|
{@const reasoningSubtitle = isStreaming ? '' : hasReasoningError ? 'Error' : 'Cancelled'}
|
||||||
|
|
||||||
<CollapsibleContentBlock
|
<CollapsibleContentBlock
|
||||||
open={isExpanded(index, section)}
|
open={isExpanded(index, section)}
|
||||||
@@ -316,6 +331,7 @@
|
|||||||
icon={Brain}
|
icon={Brain}
|
||||||
title={reasoningTitle}
|
title={reasoningTitle}
|
||||||
subtitle={reasoningSubtitle}
|
subtitle={reasoningSubtitle}
|
||||||
|
rawContent={section.content}
|
||||||
{isStreaming}
|
{isStreaming}
|
||||||
onToggle={() => toggleExpanded(index, section)}
|
onToggle={() => toggleExpanded(index, section)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
import { Card } from '$lib/components/ui/card';
|
import { Card } from '$lib/components/ui/card';
|
||||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
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 { Snippet } from 'svelte';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
@@ -14,6 +17,8 @@
|
|||||||
iconClass?: string;
|
iconClass?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
preview?: string;
|
||||||
|
rawContent?: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
onToggle?: () => void;
|
onToggle?: () => void;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
@@ -26,6 +31,8 @@
|
|||||||
iconClass = 'h-4 w-4',
|
iconClass = 'h-4 w-4',
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
preview,
|
||||||
|
rawContent,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
onToggle,
|
onToggle,
|
||||||
children
|
children
|
||||||
@@ -33,6 +40,20 @@
|
|||||||
|
|
||||||
let contentContainer: HTMLDivElement | undefined = $state();
|
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();
|
const autoScroll = createAutoScrollController();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -58,16 +79,31 @@
|
|||||||
class={className}
|
class={className}
|
||||||
>
|
>
|
||||||
<Card class="gap-0 border-muted bg-muted/30 py-0">
|
<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">
|
<Collapsible.Trigger class="flex w-full cursor-pointer items-start justify-between gap-2 p-3">
|
||||||
<div class="flex items-center gap-2 text-muted-foreground">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
{#if IconComponent}
|
<div class="flex items-center gap-2 text-muted-foreground">
|
||||||
<IconComponent class={iconClass} />
|
{#if IconComponent}
|
||||||
{/if}
|
<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}
|
{#if subtitle}
|
||||||
<span class="text-xs italic">{subtitle}</span>
|
<span class="text-xs italic">{subtitle}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,30 @@ export const MEDIUM_DURATION_THRESHOLD = 10;
|
|||||||
|
|
||||||
/** Default display value when no performance time is available */
|
/** Default display value when no performance time is available */
|
||||||
export const DEFAULT_PERFORMANCE_TIME = '0s';
|
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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export interface AgenticSection {
|
|||||||
toolArgs?: string;
|
toolArgs?: string;
|
||||||
toolResult?: string;
|
toolResult?: string;
|
||||||
toolResultExtras?: DatabaseMessageExtra[];
|
toolResultExtras?: DatabaseMessageExtra[];
|
||||||
|
wasInterrupted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,7 +52,8 @@ function deriveSingleTurnSections(
|
|||||||
const isPending = isStreaming && !hasContentAfterReasoning;
|
const isPending = isStreaming && !hasContentAfterReasoning;
|
||||||
sections.push({
|
sections.push({
|
||||||
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
|
type: isPending ? AgenticSectionType.REASONING_PENDING : AgenticSectionType.REASONING,
|
||||||
content: message.reasoningContent
|
content: message.reasoningContent,
|
||||||
|
wasInterrupted: !isStreaming && !hasContentAfterReasoning
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import {
|
|||||||
SECONDS_PER_MINUTE,
|
SECONDS_PER_MINUTE,
|
||||||
SECONDS_PER_HOUR,
|
SECONDS_PER_HOUR,
|
||||||
SHORT_DURATION_THRESHOLD,
|
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';
|
} from '$lib/constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,3 +155,33 @@ export function formatAttachmentText(
|
|||||||
const header = extra ? `${name} (${extra})` : name;
|
const header = extra ? `${name} (${extra})` : name;
|
||||||
return `\n\n--- ${label}: ${header} ---\n${content}`;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ export {
|
|||||||
formatJsonPretty,
|
formatJsonPretty,
|
||||||
formatTime,
|
formatTime,
|
||||||
formatPerformanceTime,
|
formatPerformanceTime,
|
||||||
formatAttachmentText
|
formatAttachmentText,
|
||||||
|
formatReasoningPreview
|
||||||
} from './formatters';
|
} from './formatters';
|
||||||
|
|
||||||
// IME utilities
|
// IME utilities
|
||||||
|
|||||||
Reference in New Issue
Block a user