2 import { TruncatedText } from '$lib/components/app/misc';
3 import { Card } from '$lib/components/ui/card';
4 import * as Tooltip from '$lib/components/ui/tooltip';
5 import { McpPromptVariant } from '$lib/enums';
6 import { mcpStore } from '$lib/stores/mcp.svelte';
7 import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
8 import { SvelteMap } from 'svelte/reactivity';
10 interface ContentPart {
12 argKey: string | null;
17 prompt: DatabaseMessageExtraMcpPrompt;
18 variant?: McpPromptVariant;
24 class: className = '',
28 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 [{ argKey: null, text: prompt.content || '' }];
41 const parts: ContentPart[] = [];
43 let remaining = prompt.content;
45 const valueToKey = new SvelteMap<string, string>();
47 for (const [key, value] of argumentEntries) {
48 if (value && value.trim()) {
49 valueToKey.set(value, key);
53 const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
55 while (remaining.length > 0) {
56 let earliestMatch: { index: number; value: string; key: string } | null = null;
58 for (const value of sortedValues) {
59 const index = remaining.indexOf(value);
61 if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
62 earliestMatch = { index, key: valueToKey.get(value)!, value };
67 if (earliestMatch.index > 0) {
68 parts.push({ argKey: null, text: remaining.slice(0, earliestMatch.index) });
71 parts.push({ argKey: earliestMatch.key, text: earliestMatch.value });
72 remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
74 parts.push({ argKey: null, text: remaining });
83 let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
84 let isAttachment = $derived(variant === McpPromptVariant.ATTACHMENT);
85 let textSizeClass = $derived(isAttachment ? 'text-xs' : 'text-md');
86 let paddingClass = $derived(isAttachment ? 'px-3 py-2' : 'px-3.75 py-2.5');
87 let maxHeightStyle = $derived(
88 isAttachment ? 'max-height: 6rem;' : 'max-height: var(--max-message-height);'
91 const serverFavicon = $derived(mcpStore.getServerFavicon(prompt.serverName));
92 const serverDisplayName = $derived(mcpStore.getServerDisplayName(prompt.serverName));
95 <div class="flex flex-col gap-2 {className}">
96 <div class="flex items-center justify-between gap-2">
97 <div class="inline-flex flex-wrap items-center gap-1.25 text-xs text-muted-foreground">
104 class="h-3.5 w-3.5 shrink-0 rounded-sm"
106 (e.currentTarget as HTMLImageElement).style.display = 'none';
113 <span>{serverDisplayName}</span>
117 <TruncatedText text={prompt.name} />
121 <div class="flex flex-wrap justify-end gap-1">
122 {#each argumentEntries as [key, value] (key)}
125 <!-- svelte-ignore a11y_no_static_element_interactions -->
127 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 &&
128 hoveredArgKey !== key
131 onmouseenter={() => (hoveredArgKey = key)}
132 onmouseleave={() => (hoveredArgKey = null)}
139 <span class="max-w-xs break-all">{value}</span>
149 class="relative overflow-hidden rounded-[1.125rem] border border-destructive/50 bg-destructive/10 backdrop-blur-md"
152 class="overflow-y-auto {paddingClass}"
153 style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
155 <span class="{textSizeClass} text-destructive">{loadError}</span>
160 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"
163 class="overflow-y-auto {paddingClass}"
164 style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
166 <div class="space-y-2">
167 <div class="h-3 w-3/4 animate-pulse rounded bg-foreground/20"></div>
169 <div class="h-3 w-full animate-pulse rounded bg-foreground/20"></div>
171 <div class="h-3 w-5/6 animate-pulse rounded bg-foreground/20"></div>
175 {:else if hasContent}
177 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"
180 class="overflow-y-auto {paddingClass}"
181 style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
183 <span class="{textSizeClass} whitespace-pre-wrap">
184 <!-- This formatting is needed to keep the text in proper shape -->
185 <!-- svelte-ignore a11y_no_static_element_interactions -->
186 {#each contentParts as part, i (i)}{#if part.argKey}<span
187 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 &&
188 hoveredArgKey !== part.argKey
191 onmouseenter={() => (hoveredArgKey = part.argKey)}
192 onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
193 >{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"