enh: consecutive details rendering
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
|
||||
import Sparkles from '$lib/components/icons/Sparkles.svelte';
|
||||
import CheckCircle from '$lib/components/icons/CheckCircle.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let id = '';
|
||||
export let tokens: Array<{
|
||||
summary?: string;
|
||||
attributes?: {
|
||||
type?: string;
|
||||
name?: string;
|
||||
done?: string;
|
||||
duration?: string;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
export let messageDone = true;
|
||||
|
||||
let open = false;
|
||||
|
||||
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
|
||||
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
|
||||
$: hasPending = tokens.some(
|
||||
(t) => t?.attributes?.done !== undefined && t?.attributes?.done !== 'true'
|
||||
);
|
||||
|
||||
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
|
||||
|
||||
$: summaryText = (() => {
|
||||
const parts = [];
|
||||
|
||||
if (toolCallCount > 0) {
|
||||
// Group by tool name and show counts
|
||||
const nameCounts = {};
|
||||
tokens
|
||||
.filter((t) => t?.attributes?.type === 'tool_calls')
|
||||
.forEach((t) => {
|
||||
const name = t?.attributes?.name ?? 'tool';
|
||||
nameCounts[name] = (nameCounts[name] || 0) + 1;
|
||||
});
|
||||
|
||||
const toolParts = Object.entries(nameCounts).map(([name, count]) =>
|
||||
count > 1 ? `${count} ${name}` : name
|
||||
);
|
||||
parts.push(...toolParts);
|
||||
}
|
||||
|
||||
if (codeInterpreterCount > 0) {
|
||||
if (codeInterpreterCount === 1) {
|
||||
parts.push($i18n.t('Ran {{COUNT}} analysis', { COUNT: codeInterpreterCount }));
|
||||
} else {
|
||||
parts.push($i18n.t('Ran {{COUNT}} analyses', { COUNT: codeInterpreterCount }));
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
|
||||
const detail = parts.join(', ');
|
||||
return detail;
|
||||
})();
|
||||
|
||||
$: prefixText = hasPending ? $i18n.t('Exploring') : $i18n.t('Explored');
|
||||
</script>
|
||||
|
||||
<div {id} class="w-full">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<button
|
||||
class="w-fit text-left text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition cursor-pointer"
|
||||
aria-label={$i18n.t('Toggle details')}
|
||||
aria-expanded={open}
|
||||
on:click={() => {
|
||||
open = !open;
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Status icon -->
|
||||
{#if hasPending}
|
||||
<div>
|
||||
<Spinner className="size-4" />
|
||||
</div>
|
||||
{:else if toolCallCount > 0}
|
||||
<div class="text-emerald-500 dark:text-emerald-400">
|
||||
<CheckCircle className="size-4" strokeWidth="2" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-gray-400 dark:text-gray-500">
|
||||
<Sparkles className="size-3.5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary text -->
|
||||
<div class="flex-1 line-clamp-1">
|
||||
<span class="text-gray-600 dark:text-gray-300 {hasPending ? 'shimmer' : ''}"
|
||||
>{prefixText}</span
|
||||
>
|
||||
{#if summaryText}
|
||||
<span class="text-gray-400 dark:text-gray-500">{summaryText}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<div class="flex shrink-0 self-center text-gray-400 dark:text-gray-500">
|
||||
{#if open}
|
||||
<ChevronUp strokeWidth="3.5" className="size-3" />
|
||||
{:else}
|
||||
<ChevronDown strokeWidth="3.5" className="size-3" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
|
||||
<div class="mb-0.5 space-y-0.5">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -20,6 +20,7 @@
|
||||
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Download from '$lib/components/icons/Download.svelte';
|
||||
import ConsecutiveDetailsGroup from './ConsecutiveDetailsGroup.svelte';
|
||||
|
||||
import HtmlToken from './HTMLToken.svelte';
|
||||
import Clipboard from '$lib/components/icons/Clipboard.svelte';
|
||||
@@ -52,6 +53,51 @@
|
||||
return 'h' + depth;
|
||||
};
|
||||
|
||||
const GROUPABLE_DETAIL_TYPES = new Set(['tool_calls', 'reasoning', 'code_interpreter']);
|
||||
|
||||
const isGroupableDetailToken = (token: Token & { attributes?: { type?: string } }) => {
|
||||
return token?.type === 'details' && GROUPABLE_DETAIL_TYPES.has(token?.attributes?.type ?? '');
|
||||
};
|
||||
|
||||
const getDisplayTokens = (tokenList: Token[] = []) => {
|
||||
const displayTokens = [];
|
||||
let detailGroup = [];
|
||||
|
||||
const flushDetailGroup = () => {
|
||||
if (detailGroup.length > 1) {
|
||||
displayTokens.push({
|
||||
type: 'detail_group',
|
||||
items: [...detailGroup]
|
||||
});
|
||||
} else if (detailGroup.length === 1) {
|
||||
displayTokens.push(detailGroup[0]);
|
||||
}
|
||||
|
||||
detailGroup = [];
|
||||
};
|
||||
|
||||
for (const token of tokenList) {
|
||||
if (isGroupableDetailToken(token)) {
|
||||
detailGroup.push(token);
|
||||
} else {
|
||||
flushDetailGroup();
|
||||
displayTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
flushDetailGroup();
|
||||
|
||||
return displayTokens;
|
||||
};
|
||||
|
||||
const getDetailTextContent = (token) => {
|
||||
return decode(token?.text || '')
|
||||
.replace(/<summary>.*?<\/summary>/gi, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
$: displayTokens = getDisplayTokens(tokens);
|
||||
|
||||
const exportTableToCSVHandler = (token, tokenIdx = 0) => {
|
||||
console.log('Exporting table to CSV');
|
||||
|
||||
@@ -92,7 +138,7 @@
|
||||
</script>
|
||||
|
||||
<!-- {JSON.stringify(tokens)} -->
|
||||
{#each tokens as token, tokenIdx (tokenIdx)}
|
||||
{#each displayTokens as token, tokenIdx (tokenIdx)}
|
||||
{#if token.type === 'hr'}
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
{:else if token.type === 'heading'}
|
||||
@@ -320,10 +366,55 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if token.type === 'detail_group'}
|
||||
<ConsecutiveDetailsGroup id={`${id}-${tokenIdx}-detail-group`} tokens={token.items} messageDone={done}>
|
||||
<div slot="content" class="space-y-1">
|
||||
{#each token.items as detailToken, detailIdx}
|
||||
{@const textContent = getDetailTextContent(detailToken)}
|
||||
|
||||
{#if detailToken?.attributes?.type === 'tool_calls'}
|
||||
<ToolCallDisplay
|
||||
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
|
||||
attributes={detailToken.attributes}
|
||||
open={false}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{:else if textContent.length > 0}
|
||||
<Collapsible
|
||||
title={detailToken.summary}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
attributes={detailToken?.attributes}
|
||||
className="w-full space-y-1"
|
||||
dir="auto"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<svelte:self
|
||||
id={`${id}-${tokenIdx}-${detailIdx}-d`}
|
||||
tokens={marked.lexer(decode(detailToken.text))}
|
||||
attributes={detailToken?.attributes}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{onTaskClick}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{:else}
|
||||
<Collapsible
|
||||
title={detailToken.summary}
|
||||
open={false}
|
||||
disabled={true}
|
||||
attributes={detailToken?.attributes}
|
||||
className="w-full space-y-1"
|
||||
dir="auto"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ConsecutiveDetailsGroup>
|
||||
{:else if token.type === 'details'}
|
||||
{@const textContent = decode(token.text || '')
|
||||
.replace(/<summary>.*?<\/summary>/gi, '')
|
||||
.trim()}
|
||||
{@const textContent = getDetailTextContent(token)}
|
||||
|
||||
{#if token?.attributes?.type === 'tool_calls'}
|
||||
<!-- Tool calls have dedicated handling with ToolCallDisplay component -->
|
||||
|
||||
@@ -138,9 +138,9 @@
|
||||
<!-- Label -->
|
||||
<div class="flex-1 line-clamp-1">
|
||||
<!-- Short label (below md) -->
|
||||
<span class="@md:hidden font-semibold text-black dark:text-white">{attributes.name}</span>
|
||||
<span class="@md:hidden text-black dark:text-white">{attributes.name}</span>
|
||||
<!-- Full label (md and above) -->
|
||||
<span class="hidden @md:inline">
|
||||
<span class="hidden @md:inline font-normal">
|
||||
{#if isDone}
|
||||
<Markdown
|
||||
id={`${componentId}-tool-call-title`}
|
||||
|
||||
Reference in New Issue
Block a user