|
|
<script lang="ts"> |
|
|
import { MessageToolUpdateType, type MessageToolUpdate } from "$lib/types/MessageUpdate"; |
|
|
import { |
|
|
isMessageToolCallUpdate, |
|
|
isMessageToolErrorUpdate, |
|
|
isMessageToolProgressUpdate, |
|
|
isMessageToolResultUpdate, |
|
|
} from "$lib/utils/messageUpdates"; |
|
|
import { formatToolProgressLabel } from "$lib/utils/toolProgress"; |
|
|
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); |
|
|
let toolProgress = $derived.by(() => { |
|
|
for (let i = tool.length - 1; i >= 0; i -= 1) { |
|
|
const update = tool[i]; |
|
|
if (isMessageToolProgressUpdate(update)) return update; |
|
|
} |
|
|
return undefined; |
|
|
}); |
|
|
let progressLabel = $derived.by(() => formatToolProgressLabel(toolProgress)); |
|
|
|
|
|
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), |
|
|
})); |
|
|
|
|
|
|
|
|
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 flex-col items-start gap-1 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> |
|
|
{#if isExecuting && toolProgress} |
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{progressLabel}</span> |
|
|
{/if} |
|
|
</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="scrollbar-custom max-h-60 overflow-y-auto 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} |
|
|
|