mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-19 00:03:21 +00:00
281 lines
8.3 KiB
Svelte
281 lines
8.3 KiB
Svelte
<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 WrenchSolid from '../icons/WrenchSolid.svelte';
|
|
import CheckCircle from '../icons/CheckCircle.svelte';
|
|
import Image from './Image.svelte';
|
|
import FullHeightIframe from './FullHeightIframe.svelte';
|
|
import { settings } from '$lib/stores';
|
|
|
|
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 grouped = false;
|
|
export let className = '';
|
|
|
|
const RESULT_PREVIEW_LIMIT = 10000;
|
|
let expandedResult = false;
|
|
|
|
$: if (!open) expandedResult = false;
|
|
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) {
|
|
// Iteratively unwrap nested JSON-encoded strings. Same result as the previous
|
|
// recursive form, but without the stack-overflow-and-recover path it hit on
|
|
// scalar values (e.g. JSON.parse('5') -> 5 -> infinite self-recursion).
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let value: any = str;
|
|
while (typeof value === 'string') {
|
|
try {
|
|
value = JSON.parse(value);
|
|
} catch {
|
|
break;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function formatJSONString(str: string) {
|
|
try {
|
|
const parsed = parseJSONString(str);
|
|
if (typeof parsed === 'object') {
|
|
return JSON.stringify(parsed, null, 2);
|
|
} else {
|
|
return String(parsed);
|
|
}
|
|
} catch (e) {
|
|
return str;
|
|
}
|
|
}
|
|
|
|
function parseArguments(str: string): Record<string, unknown> | null {
|
|
try {
|
|
const parsed = parseJSONString(str);
|
|
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export let resultContent: string = '';
|
|
|
|
$: result = resultContent || decode(attributes?.result ?? '');
|
|
$: files = parseJSONString(decode(attributes?.files ?? ''));
|
|
$: embeds = parseJSONString(decode(attributes?.embeds ?? ''));
|
|
$: args =
|
|
open || (Array.isArray(embeds) && embeds.length > 0) ? decode(attributes?.arguments ?? '') : '';
|
|
$: isDone = attributes?.done === 'true';
|
|
$: isExecuting = attributes?.done && attributes?.done !== 'true';
|
|
|
|
$: parsedArgs = parseArguments(args);
|
|
$: parsedResult = parseJSONString(result);
|
|
</script>
|
|
|
|
<div {id} class={className}>
|
|
{#if !grouped && 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">
|
|
{attributes.name}
|
|
</div>
|
|
{#each embeds as embed, idx}
|
|
<div class="my-2" id={`${componentId}-tool-call-embed-${idx}`}>
|
|
<FullHeightIframe
|
|
src={embed}
|
|
{args}
|
|
allowScripts={true}
|
|
allowForms={$settings?.iframeSandboxAllowForms ?? false}
|
|
allowSameOrigin={$settings?.iframeSandboxAllowSameOrigin ?? false}
|
|
allowPopups={true}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<!-- Tool call display -->
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div
|
|
class="{buttonClassName} cursor-pointer"
|
|
on:pointerup={() => {
|
|
open = !open;
|
|
}}
|
|
>
|
|
<div
|
|
class="w-full max-w-full font-normal flex items-center gap-1.5 {isExecuting
|
|
? 'shimmer'
|
|
: ''}"
|
|
>
|
|
<!-- Status icon -->
|
|
{#if isExecuting}
|
|
<div>
|
|
<Spinner className="size-4" />
|
|
</div>
|
|
{:else if isDone}
|
|
<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">
|
|
<WrenchSolid className="size-3.5" />
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Label -->
|
|
<div class="flex-1 line-clamp-1">
|
|
<!-- Short label (below md) -->
|
|
<span class="@md:hidden text-black dark:text-white">{attributes.name}</span>
|
|
<!-- Full label (md and above) -->
|
|
<span class="hidden @md:inline font-normal">
|
|
{#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}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Chevron -->
|
|
<div class="flex shrink-0 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' }}>
|
|
<div class="border border-gray-50 dark:border-gray-850/30 rounded-2xl my-1.5 p-3 space-y-3">
|
|
<!-- Input -->
|
|
{#if args}
|
|
<div>
|
|
<div
|
|
class="text-[10px] uppercase tracking-wider font-normal text-gray-400 dark:text-gray-500 mb-1.5 px-1"
|
|
>
|
|
{$i18n.t('Input')}
|
|
</div>
|
|
|
|
{#if parsedArgs}
|
|
<div class="px-1 space-y-0.5">
|
|
{#each Object.entries(parsedArgs) as [key, value]}
|
|
<div class="flex gap-2 text-xs py-0.5">
|
|
<span class="font-normal text-gray-600 dark:text-gray-400 shrink-0"
|
|
>{key}</span
|
|
>
|
|
<span class="text-gray-800 dark:text-gray-200 break-all"
|
|
>{typeof value === 'object' ? JSON.stringify(value) : value}</span
|
|
>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="tool-call-body w-full max-w-none!">
|
|
<pre
|
|
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-2.5 overflow-x-auto">{formatJSONString(
|
|
args
|
|
)}</pre>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Output -->
|
|
{#if isDone && result}
|
|
<div>
|
|
<div
|
|
class="text-[10px] uppercase tracking-wider font-normal text-gray-400 dark:text-gray-500 mb-1.5 px-1"
|
|
>
|
|
{$i18n.t('Output')}
|
|
</div>
|
|
<div class="w-full max-w-none!">
|
|
{#if typeof parsedResult === 'object' && parsedResult !== null}
|
|
<pre
|
|
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre font-mono bg-gray-50 dark:bg-gray-900 rounded-lg p-2.5 overflow-x-auto">{JSON.stringify(
|
|
parsedResult,
|
|
null,
|
|
2
|
|
)}</pre>
|
|
{:else}
|
|
{@const resultStr = String(parsedResult)}
|
|
{@const isTruncated = resultStr.length > RESULT_PREVIEW_LIMIT && !expandedResult}
|
|
<pre
|
|
class="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap break-words font-mono">{isTruncated
|
|
? resultStr.slice(0, RESULT_PREVIEW_LIMIT)
|
|
: resultStr}</pre>
|
|
{#if isTruncated}
|
|
<button
|
|
class="mt-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
|
|
on:click|stopPropagation={() => {
|
|
expandedResult = true;
|
|
}}
|
|
>
|
|
{$i18n.t('Show all ({{COUNT}} characters)', {
|
|
COUNT: resultStr.length.toLocaleString()
|
|
})}
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</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>
|