chat-ui / src /lib /components /chat /ToolUpdate.svelte
victor's picture
victor HF Staff
transcribe: bill to org
e725f05
raw
history blame
8.41 kB
<script lang="ts">
import { MessageToolUpdateType, type MessageToolUpdate } from "$lib/types/MessageUpdate";
import {
isMessageToolCallUpdate,
isMessageToolErrorUpdate,
isMessageToolResultUpdate,
} from "$lib/utils/messageUpdates";
import LucideHammer from "~icons/lucide/hammer";
import LucideCheck from "~icons/lucide/check";
import { ToolResultStatus, type ToolFront } from "$lib/types/Tool";
import { page } from "$app/state";
import CarbonChevronRight from "~icons/carbon/chevron-right";
import BlockWrapper from "./BlockWrapper.svelte";
interface Props {
tool: MessageToolUpdate[];
loading?: boolean;
hasNext?: boolean;
}
let { tool, loading = false, hasNext = false }: Props = $props();
let isOpen = $state(false);
let toolFnName = $derived(tool.find(isMessageToolCallUpdate)?.call.name);
let toolError = $derived(tool.some(isMessageToolErrorUpdate));
let toolDone = $derived(tool.some(isMessageToolResultUpdate));
let isExecuting = $derived(!toolDone && !toolError && loading);
let toolSuccess = $derived(toolDone && !toolError);
const availableTools: ToolFront[] = $derived.by(
() => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []
);
type ToolOutput = Record<string, unknown>;
type McpImageContent = {
type: "image";
data: string;
mimeType: string;
};
const formatValue = (value: unknown): string => {
if (value == null) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
return String(value);
};
const getOutputText = (output: ToolOutput): string | undefined => {
const maybeText = output["text"];
if (typeof maybeText !== "string") return undefined;
return maybeText;
};
const isImageBlock = (value: unknown): value is McpImageContent => {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return (
obj["type"] === "image" &&
typeof obj["data"] === "string" &&
typeof obj["mimeType"] === "string"
);
};
const getImageBlocks = (output: ToolOutput): McpImageContent[] => {
const blocks = output["content"];
if (!Array.isArray(blocks)) return [];
return blocks.filter(isImageBlock);
};
const getMetadataEntries = (output: ToolOutput): Array<[string, unknown]> => {
return Object.entries(output).filter(
([key, value]) => value != null && key !== "content" && key !== "text"
);
};
interface ParsedToolOutput {
text?: string;
images: McpImageContent[];
metadata: Array<[string, unknown]>;
}
const parseToolOutputs = (outputs: ToolOutput[]): ParsedToolOutput[] =>
outputs.map((output) => ({
text: getOutputText(output),
images: getImageBlocks(output),
metadata: getMetadataEntries(output),
}));
// Icon styling based on state
let iconBg = $derived(
toolError ? "bg-red-100 dark:bg-red-900/40" : "bg-purple-100 dark:bg-purple-900/40"
);
let iconRing = $derived(
toolError ? "ring-red-200 dark:ring-red-500/30" : "ring-purple-200 dark:ring-purple-500/30"
);
</script>
{#snippet icon()}
{#if toolSuccess}
<LucideCheck class="size-3.5 text-purple-600 dark:text-purple-400" />
{:else}
<LucideHammer
class="size-3.5 {toolError
? 'text-red-500 dark:text-red-400'
: 'text-purple-600 dark:text-purple-400'}"
/>
{/if}
{/snippet}
{#if toolFnName}
<BlockWrapper {icon} {iconBg} {iconRing} {hasNext} loading={isExecuting}>
<!-- Header row -->
<div class="flex w-full select-none items-center gap-2">
<button
type="button"
class="flex flex-1 cursor-pointer items-center gap-2 text-left"
onclick={() => (isOpen = !isOpen)}
>
<span
class="text-sm font-medium {isExecuting
? 'text-purple-700 dark:text-purple-300'
: toolError
? 'text-red-600 dark:text-red-400'
: 'text-gray-700 dark:text-gray-300'}"
>
{toolError ? "Error calling" : toolDone ? "Called" : "Calling"} tool
<code
class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs text-gray-500 opacity-90 dark:bg-gray-800 dark:text-gray-400"
>
{availableTools.find((entry) => entry.name === toolFnName)?.displayName ?? toolFnName}
</code>
</span>
</button>
<button
type="button"
class="cursor-pointer"
onclick={() => (isOpen = !isOpen)}
aria-label={isOpen ? "Collapse" : "Expand"}
>
<CarbonChevronRight
class="size-4 text-gray-400 transition-transform duration-200 {isOpen ? 'rotate-90' : ''}"
/>
</button>
</div>
<!-- Expandable content -->
{#if isOpen}
<div class="mt-2 space-y-3">
{#each tool as update, i (`${update.subtype}-${i}`)}
{#if update.subtype === MessageToolUpdateType.Call}
<div class="space-y-1">
<div
class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
Input
</div>
<div
class="rounded-md border border-gray-100 bg-white p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
>
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
update.call.parameters
)}</pre>
</div>
</div>
{:else if update.subtype === MessageToolUpdateType.Error}
<div class="space-y-1">
<div
class="text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400"
>
Error
</div>
<div
class="rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400"
>
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{update.message}</pre>
</div>
</div>
{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Success && update.result.display}
<div class="space-y-1">
<div class="flex items-center gap-2">
<div
class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
Output
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-emerald-500"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="m9 12 2 2 4-4"></path>
</svg>
</div>
<div
class="scrollbar-custom rounded-md border border-gray-100 bg-white p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
>
{#each parseToolOutputs(update.result.outputs) as parsedOutput}
<div class="space-y-2">
{#if parsedOutput.text}
<pre
class="scrollbar-custom max-h-60 overflow-y-auto whitespace-pre-wrap break-all font-mono text-xs">{parsedOutput.text}</pre>
{/if}
{#if parsedOutput.images.length > 0}
<div class="flex flex-wrap gap-2">
{#each parsedOutput.images as image, imageIndex}
<img
alt={`Tool result image ${imageIndex + 1}`}
class="max-h-60 rounded border border-gray-200 dark:border-gray-700"
src={`data:${image.mimeType};base64,${image.data}`}
/>
{/each}
</div>
{/if}
{#if parsedOutput.metadata.length > 0}
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
Object.fromEntries(parsedOutput.metadata)
)}</pre>
{/if}
</div>
{/each}
</div>
</div>
{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Error && update.result.display}
<div class="space-y-1">
<div
class="text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400"
>
Error
</div>
<div
class="rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400"
>
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{update.result
.message}</pre>
</div>
</div>
{/if}
{/each}
</div>
{/if}
</BlockWrapper>
{/if}