2 import { Card } from '$lib/components/ui/card';
3 import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
4 import { mcpStore } from '$lib/stores/mcp.svelte';
5 import { SvelteMap } from 'svelte/reactivity';
6 import { McpPromptVariant } from '$lib/enums';
7 import { TruncatedText } from '$lib/components/app/misc';
8 import * as Tooltip from '$lib/components/ui/tooltip';
10 interface ContentPart {
12 argKey: string | null;
17 prompt: DatabaseMessageExtraMcpPrompt;
18 variant?: McpPromptVariant;
24 class: className = '',
26 variant = McpPromptVariant.MESSAGE,
31 let hoveredArgKey = $state<string | null>(null);
32 let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
33 let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
34 let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
36 let contentParts = $derived.by((): ContentPart[] => {
37 if (!prompt.content || !hasArguments) {
38 return [{ text: prompt.content || '', argKey: null }];
41 const parts: ContentPart[] = [];
42 let remaining = prompt.content;
44 const valueToKey = new SvelteMap<string, string>();
45 for (const [key, value] of argumentEntries) {
46 if (value && value.trim()) {
47 valueToKey.set(value, key);
51 const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
53 while (remaining.length > 0) {
54 let earliestMatch: { index: number; value: string; key: string } | null = null;
56 for (const value of sortedValues) {
57 const index = remaining.indexOf(value);
58 if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
59 earliestMatch = { index, value, key: valueToKey.get(value)! };
64 if (earliestMatch.index > 0) {
65 parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
68 parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
69 remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
71 parts.push({ text: remaining, argKey: null });
80 let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
81 let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
82 let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
83 let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
84 let maxHeightStyle = $derived(
85 isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
88 const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
89 const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
92 <div class="flex flex-col gap-2 {className}">
93 <div class="flex items-center justify-between gap-2">
94 <div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
101 class="h-3.5 w-3.5 shrink-0 rounded-sm"
103 (e.currentTarget as HTMLImageElement).style.display = 'none';
110 <span>{serverDisplayName}</span>
114 <TruncatedText text={prompt.name} />
118 <div class="flex flex-wrap justify-end gap-1">
119 {#each argumentEntries as [key, value] (key)}
122 <!-- svelte-ignore a11y_no_static_element_interactions -->
124 class="rounded-sm bg-purple-200/60 px-1.5 py-0.5 text-[10px] leading-none text-purple-700 transition-opacity dark:bg-purple-800/40 dark:text-purple-300 {hoveredArgKey &&
125 hoveredArgKey !== key
128 onmouseenter={() => (hoveredArgKey = key)}
129 onmouseleave={() => (hoveredArgKey = null)}
136 <span class="max-w-xs break-all">{value}</span>
146 class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
149 class="overflow-y-auto {paddingClass}"
150 style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
152 <span class="{textSizeClass} text-destructive">{loadError}</span>
157 class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-1 py-2 backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
160 class="overflow-y-auto {paddingClass}"
161 style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
163 <div class="space-y-2">
164 <div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
166 <div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
168 <div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
172 {:else if hasContent}
174 class="relative overflow-hidden rounded-[1.125rem] border border-purple-200 bg-purple-500/10 py-0 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
177 class="overflow-y-auto {paddingClass}"
178 style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
180 <span class="{textSizeClass} whitespace-pre-wrap">
181 <!-- This formatting is needed to keep the text in proper shape -->
182 <!-- svelte-ignore a11y_no_static_element_interactions -->
183 {#each contentParts as part, i (i)}{#if part.argKey}<span
184 class="rounded-sm bg-purple-300/50 px-0.5 text-purple-900 transition-opacity dark:bg-purple-700/50 dark:text-purple-100 {hoveredArgKey &&
185 hoveredArgKey !== part.argKey
188 onmouseenter={() => (hoveredArgKey = part.argKey)}
189 onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
190 >{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"