Files
open-webui/src/lib/components/chat/Messages/StructuredOutputRenderer.svelte
T
2026-08-23 14:40:48 -04:00

220 lines
6.5 KiB
Svelte

<script lang="ts">
import Collapsible from '$lib/components/common/Collapsible.svelte';
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import TerminalOutputFile from './TerminalOutputFile.svelte';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
import { settings } from '$lib/stores';
import { toast } from 'svelte-sonner';
import Markdown from './Markdown.svelte';
import ConsecutiveDetailsGroup from './Markdown/ConsecutiveDetailsGroup.svelte';
import {
buildOutputDisplayItems,
type OutputDetailToken,
type OutputDisplayItem,
type OutputItem
} from './structuredOutput';
export let id = '';
export let chatId = '';
export let messageId = '';
export let output: OutputItem[] = [];
export let done = true;
export let model = null;
export let save = false;
export let preview = false;
export let compactPreview = false;
export let renderMarkdown = true;
export let editCodeBlock = true;
export let topPadding = false;
export let sourceIds: string[] = [];
export let formatMessageContent: (content: string) => string = (content) => content;
export let onSave: any = () => {};
export let onSourceClick: any = () => {};
export let onTaskClick: any = () => {};
export let onUpdate: any = () => {};
export let onPreview: any = () => {};
export let onToolCallResolved: any = () => {};
const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary;
const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes;
let resolvingCallId = '';
const resolveToolCall = async (callId: string, approved: boolean) => {
if (!chatId || !messageId || !callId || resolvingCallId) {
return;
}
resolvingCallId = callId;
try {
const res = await resolveChatMessageToolCall(
localStorage.token,
chatId,
messageId,
callId,
approved ? 'approve' : 'reject'
);
onToolCallResolved(res);
} catch (err) {
toast.error(String(err));
} finally {
resolvingCallId = '';
}
};
$: detailButtonClassName = `py-0.5 ${
compactPreview ? 'text-xs' : 'text-[0.9375rem]'
} text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition`;
$: displayItems = buildOutputDisplayItems(output) as OutputDisplayItem[];
</script>
{#each displayItems as displayItem (displayItem.id)}
{#if displayItem.type === 'message'}
{#if renderMarkdown}
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}`}
{chatId}
{messageId}
content={formatMessageContent(displayItem.text)}
{model}
{save}
{preview}
{compactPreview}
{done}
{editCodeBlock}
{topPadding}
{sourceIds}
{onSourceClick}
{onTaskClick}
{onToolCallResolved}
{onSave}
{onUpdate}
{onPreview}
/>
</div>
{:else}
<div class="whitespace-pre-wrap text-[0.9375rem]">{displayItem.text}</div>
{/if}
{:else if displayItem.type === 'detail_group'}
<ConsecutiveDetailsGroup
id={`${id}-${displayItem.id}`}
tokens={displayItem.tokens}
messageDone={done}
{compactPreview}
resolvable={!!chatId && !!messageId && save}
{resolvingCallId}
onResolve={resolveToolCall}
>
<div slot="content">
{#each displayItem.tokens as detailToken, detailIndex}
{#if detailToken.attributes?.type === 'tool_calls'}
<ToolCallDisplay
id={`${id}-${displayItem.id}-${detailIndex}-tool-call`}
attributes={detailToken.attributes}
resultContent={detailToken.text}
grouped={true}
resolvable={!!chatId && !!messageId && save}
resolving={resolvingCallId === detailToken.attributes?.id}
onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)}
open={$settings?.expandDetails ?? false}
className="w-full"
buttonClassName={detailButtonClassName}
/>
{:else if detailToken.text?.length > 0}
<Collapsible
title={getDetailTitle(detailToken)}
open={$settings?.expandDetails ?? false}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full"
buttonClassName={detailButtonClassName}
>
<div class="mb-1.5" slot="content">
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
{chatId}
{messageId}
content={detailToken.text}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
{onToolCallResolved}
/>
</div>
</div>
</Collapsible>
{:else}
<Collapsible
title={getDetailTitle(detailToken)}
open={false}
disabled={true}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full"
buttonClassName={detailButtonClassName}
/>
{/if}
{/each}
</div>
</ConsecutiveDetailsGroup>
{:else if displayItem.type === 'file'}
<TerminalOutputFile item={displayItem.item} {chatId} />
{:else}
{@const detailToken = displayItem.token}
{#if detailToken.attributes?.type === 'tool_calls'}
<ToolCallDisplay
id={`${id}-${displayItem.id}-tool-call`}
attributes={detailToken.attributes}
resultContent={detailToken.text}
resolvable={!!chatId && !!messageId && save}
resolving={resolvingCallId === detailToken.attributes?.id}
onResolve={(approved) => resolveToolCall(detailToken.attributes?.id ?? '', approved)}
open={$settings?.expandDetails ?? false}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
/>
{:else if detailToken.text?.length > 0}
<Collapsible
title={getDetailTitle(detailToken)}
open={$settings?.expandDetails ?? false}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
>
<div class="mb-1.5" slot="content">
<div class="markdown-prose">
<Markdown
id={`${id}-${displayItem.id}-detail`}
{chatId}
{messageId}
content={detailToken.text}
{done}
{save}
{preview}
{compactPreview}
{editCodeBlock}
{onToolCallResolved}
/>
</div>
</div>
</Collapsible>
{:else}
<Collapsible
title={getDetailTitle(detailToken)}
open={false}
disabled={true}
attributes={getDetailAttributes(detailToken)}
messageDone={done}
className="w-full space-y-2"
buttonClassName={detailButtonClassName}
/>
{/if}
{/if}
{/each}