This commit is contained in:
Tim Baek
2026-02-06 23:21:09 +04:00
parent 284b97bd84
commit 9cf4d34832
3 changed files with 196 additions and 160 deletions
@@ -17,6 +17,7 @@
import KatexRenderer from './KatexRenderer.svelte';
import AlertRenderer, { alertComponent } from './AlertRenderer.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
@@ -323,7 +324,15 @@
.replace(/<summary>.*?<\/summary>/gi, '')
.trim()}
{#if textContent.length > 0}
{#if token?.attributes?.type === 'tool_calls'}
<!-- Tool calls have dedicated handling with ToolCallDisplay component -->
<ToolCallDisplay
id={`${id}-${tokenIdx}-tc`}
attributes={token.attributes}
open={false}
className="w-full space-y-1"
/>
{:else if textContent.length > 0}
<Collapsible
title={token.summary}
open={$settings?.expandDetails ?? false}
@@ -35,11 +35,6 @@
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import Spinner from './Spinner.svelte';
import CodeBlock from '../chat/Messages/CodeBlock.svelte';
import Markdown from '../chat/Messages/Markdown.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
import { settings } from '$lib/stores';
export let open = false;
@@ -62,162 +57,9 @@
$: onChange(open);
const collapsibleId = uuidv4();
function parseJSONString(str) {
try {
return parseJSONString(JSON.parse(str));
} catch (e) {
return str;
}
}
function formatJSONString(str) {
try {
const parsed = parseJSONString(str);
// If parsed is an object/array, then it's valid JSON
if (typeof parsed === 'object') {
return JSON.stringify(parsed, null, 2);
} else {
// It's a primitive value like a number, boolean, etc.
return `${JSON.stringify(String(parsed))}`;
}
} catch (e) {
// Not valid JSON, return as-is
return str;
}
}
</script>
<div {id} class={className}>
{#if attributes?.type === 'tool_calls'}
{@const args = decode(attributes?.arguments)}
{@const result = decode(attributes?.result ?? '')}
{@const files = parseJSONString(decode(attributes?.files ?? ''))}
{@const embeds = parseJSONString(decode(attributes?.embeds ?? ''))}
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
<div class="py-1 w-full cursor-pointer">
<div class=" w-full text-xs text-gray-500">
<div class="">
{attributes.name}
</div>
</div>
{#each embeds as embed, idx}
<div class="my-2" id={`${collapsibleId}-tool-calls-${attributes?.id}-embed-${idx}`}>
<FullHeightIframe
src={embed}
{args}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{:else}
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
if (!disabled) {
open = !open;
}
}}
>
<div
class=" w-full font-medium flex items-center justify-between gap-2 {attributes?.done &&
attributes?.done !== 'true'
? 'shimmer'
: ''}
"
>
{#if attributes?.done && attributes?.done !== 'true'}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div>
{#if !grow}
{#if open && !hide}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if attributes?.type === 'tool_calls'}
{#if attributes?.done === 'true'}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> ${formatJSONString(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${collapsibleId}-tool-calls-${attributes?.id}-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
{/if}
{:else}
<slot name="content" />
{/if}
</div>
{/if}
{/if}
{/if}
{#if attributes?.done === 'true'}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{:else if typeof file === 'object'}
{#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url}
<Image
id={`${collapsibleId}-tool-calls-${attributes?.id}-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/each}
{/if}
{/if}
{:else}
{#if title !== null}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -333,5 +175,4 @@
</div>
{/if}
{/if}
{/if}
</div>
@@ -0,0 +1,186 @@
<script lang="ts">
import { decode } from 'html-entities';
import { v4 as uuidv4 } from 'uuid';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import Spinner from './Spinner.svelte';
import Markdown from '../chat/Messages/Markdown.svelte';
import Image from './Image.svelte';
import FullHeightIframe from './FullHeightIframe.svelte';
export let id: string = '';
export let attributes: {
type?: string;
id?: string;
name?: string;
arguments?: string;
result?: string;
files?: string;
embeds?: string;
done?: string;
} = {};
export let open = false;
export let className = '';
export let buttonClassName =
'w-fit text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition';
const componentId = id || uuidv4();
function parseJSONString(str: string) {
try {
return parseJSONString(JSON.parse(str));
} catch (e) {
return str;
}
}
function formatJSONString(str: string) {
try {
const parsed = parseJSONString(str);
// If parsed is an object/array, then it's valid JSON
if (typeof parsed === 'object') {
return JSON.stringify(parsed, null, 2);
} else {
// It's a primitive value like a number, boolean, etc.
return `${JSON.stringify(String(parsed))}`;
}
} catch (e) {
// Not valid JSON, return as-is
return str;
}
}
// Decode and parse attributes
$: args = decode(attributes?.arguments ?? '');
$: result = decode(attributes?.result ?? '');
$: files = parseJSONString(decode(attributes?.files ?? ''));
$: embeds = parseJSONString(decode(attributes?.embeds ?? ''));
$: isDone = attributes?.done === 'true';
$: isExecuting = attributes?.done && attributes?.done !== 'true';
</script>
<div {id} class={className}>
{#if embeds && Array.isArray(embeds) && embeds.length > 0}
<!-- Embed Mode: Show iframes without collapsible behavior -->
<div class="py-1 w-full cursor-pointer">
<div class="w-full text-xs text-gray-500">
<div class="">
{attributes.name}
</div>
</div>
{#each embeds as embed, idx}
<div class="my-2" id={`${componentId}-tool-call-embed-${idx}`}>
<FullHeightIframe
src={embed}
{args}
allowScripts={true}
allowForms={true}
allowSameOrigin={true}
allowPopups={true}
/>
</div>
{/each}
</div>
{:else}
<!-- Standard collapsible tool call display -->
<div
class="{buttonClassName} cursor-pointer"
on:pointerup={() => {
open = !open;
}}
>
<div
class="w-full font-medium flex items-center justify-between gap-2 {isExecuting
? 'shimmer'
: ''}"
>
{#if isExecuting}
<div>
<Spinner className="size-4" />
</div>
{/if}
<div class="">
{#if isDone}
<Markdown
id={`${componentId}-tool-call-title`}
content={$i18n.t('View Result from **{{NAME}}**', {
NAME: attributes.name
})}
/>
{:else}
<Markdown
id={`${componentId}-tool-call-executing`}
content={$i18n.t('Executing **{{NAME}}**...', {
NAME: attributes.name
})}
/>
{/if}
</div>
<div class="flex self-center translate-y-[1px]">
{#if open}
<ChevronUp strokeWidth="3.5" className="size-3.5" />
{:else}
<ChevronDown strokeWidth="3.5" className="size-3.5" />
{/if}
</div>
</div>
</div>
{#if open}
<div transition:slide={{ duration: 300, easing: quintOut, axis: 'y' }}>
{#if isDone}
<Markdown
id={`${componentId}-tool-call-result`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> ${formatJSONString(result)}
> \`\`\``}
/>
{:else}
<Markdown
id={`${componentId}-tool-call-args`}
content={`> \`\`\`json
> ${formatJSONString(args)}
> \`\`\``}
/>
{/if}
</div>
{/if}
{/if}
<!-- Files display (images etc.) when done -->
{#if isDone}
{#if typeof files === 'object'}
{#each files ?? [] as file, idx}
{#if typeof file === 'string'}
{#if file.startsWith('data:image/')}
<Image
id={`${componentId}-tool-call-result-${idx}`}
src={file}
alt="Image"
/>
{/if}
{:else if typeof file === 'object'}
{#if (file.type === 'image' || (file?.content_type ?? '').startsWith('image/')) && file.url}
<Image
id={`${componentId}-tool-call-result-${idx}`}
src={file.url}
alt="Image"
/>
{/if}
{/if}
{/each}
{/if}
{/if}
</div>