feat: MCP Prompts implementation improvements

This commit is contained in:
Aleksander Grygier 2026-01-24 20:30:52 +01:00
parent 801ef93522
commit 14911e51fc
9 changed files with 397 additions and 366 deletions

View File

@ -1,12 +1,7 @@
<script lang="ts">
import { ChevronDown, ChevronUp, Eye, Pencil, Check, X } from '@lucide/svelte';
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
import { Card } from '$lib/components/ui/card';
import { MarkdownContent } from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { RemoveButton } from '$lib/components/app';
import McpPromptContent from '../McpPromptContent.svelte';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
@ -14,9 +9,7 @@
readonly?: boolean;
isLoading?: boolean;
loadError?: string;
isEditingAllowed?: boolean;
onRemove?: () => void;
onArgumentsChange?: (args: Record<string, string>) => void;
}
let {
@ -25,247 +18,18 @@
readonly = false,
isLoading = false,
loadError,
isEditingAllowed = false,
onRemove,
onArgumentsChange
onRemove
}: Props = $props();
let showArguments = $state(false);
let showContent = $state(false);
let isEditingArguments = $state(false);
let editedArguments = $state<Record<string, string>>({});
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
const currentConfig = config();
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
function getServerFavicon(): string | null {
const server = serverSettingsMap.get(prompt.serverName);
return server ? getFaviconUrl(server.url) : null;
}
function getServerDisplayName(): string {
const server = serverSettingsMap.get(prompt.serverName);
if (!server) return prompt.serverName;
const healthState = mcpStore.getHealthCheckState(server.id);
return getMcpServerLabel(server, healthState);
}
function toggleArguments(event: MouseEvent) {
event.stopPropagation();
showArguments = !showArguments;
}
function toggleContent(event: MouseEvent) {
event.stopPropagation();
showContent = !showContent;
}
function startEditingArguments(event: MouseEvent) {
event.stopPropagation();
editedArguments = { ...(prompt.arguments ?? {}) };
isEditingArguments = true;
showArguments = true;
}
function saveArguments(event: MouseEvent) {
event.stopPropagation();
onArgumentsChange?.(editedArguments);
isEditingArguments = false;
}
function cancelEditingArguments(event: MouseEvent) {
event.stopPropagation();
isEditingArguments = false;
editedArguments = {};
}
function updateArgument(key: string, value: string) {
editedArguments = { ...editedArguments, [key]: value };
}
</script>
<div class="flex flex-col {className}">
<div
class="group relative flex flex-col overflow-hidden rounded-lg border bg-gradient-to-br {loadError
? 'border-destructive/50 from-destructive/10 to-destructive/5 dark:border-destructive/30 dark:from-destructive/20 dark:to-destructive/10'
: 'border-purple-200 from-purple-50 to-indigo-50 dark:border-purple-800 dark:from-purple-950/50 dark:to-indigo-950/50'}"
>
<div class="flex items-start gap-2 p-3">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-1.5 text-xs text-purple-600 dark:text-purple-400">
{#if getServerFavicon()}
<img
src={getServerFavicon()}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<div class="group relative {className}">
<McpPromptContent {prompt} variant="attachment" {isLoading} {loadError} />
<span>{getServerDisplayName()}</span>
</div>
<div class="font-medium text-foreground">
{prompt.name}
{#if isLoading}
<span class="ml-1 text-xs font-normal text-muted-foreground">Loading...</span>
{/if}
</div>
{#if loadError}
<p class="mt-1 text-xs text-destructive">{loadError}</p>
{/if}
{#if hasContent}
<button
type="button"
onclick={toggleContent}
class="mt-1 flex items-center gap-1 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
>
{#if showContent}
<ChevronUp class="h-3 w-3" />
<span>Hide content</span>
{:else}
<Eye class="h-3 w-3" />
<span>Show content</span>
{/if}
</button>
{/if}
{#if hasArguments}
<div class="mt-1 flex items-center gap-2">
<button
type="button"
onclick={toggleArguments}
class="flex items-center gap-1 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
>
{#if showArguments}
<ChevronUp class="h-3 w-3" />
<span>Hide arguments</span>
{:else}
<ChevronDown class="h-3 w-3" />
<span>Show arguments ({Object.keys(prompt.arguments ?? {}).length})</span>
{/if}
</button>
{#if isEditingAllowed && !isEditingArguments && onArgumentsChange}
<button
type="button"
onclick={startEditingArguments}
class="flex items-center gap-1 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
aria-label="Edit arguments"
>
<Pencil class="h-3 w-3" />
<span>Edit</span>
</button>
{/if}
</div>
{/if}
</div>
{#if !readonly && onRemove}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onRemove?.();
}}
class="hover:text-destructive-foreground absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-background/80 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive"
aria-label="Remove prompt"
>
<span class="text-xs">×</span>
</button>
{/if}
</div>
{#if showArguments && hasArguments}
<div
class="border-t border-purple-200 bg-purple-50/50 px-3 py-2 dark:border-purple-800 dark:bg-purple-950/30"
>
{#if isEditingArguments}
<div class="space-y-2">
{#each Object.entries(editedArguments) as [key, value] (key)}
<div class="flex flex-col gap-1">
<label
for="arg-{key}"
class="text-xs font-medium text-purple-600 dark:text-purple-400"
>
{key}
</label>
<input
id="arg-{key}"
type="text"
{value}
oninput={(e) => updateArgument(key, e.currentTarget.value)}
class="w-full rounded border border-purple-300 bg-white px-2 py-1 text-xs text-foreground outline-none focus:border-purple-500 dark:border-purple-700 dark:bg-purple-950/50"
/>
</div>
{/each}
<div class="flex justify-end gap-2 pt-1">
<button
type="button"
onclick={cancelEditingArguments}
class="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-purple-100 dark:hover:bg-purple-900/50"
>
<X class="h-3 w-3" />
<span>Cancel</span>
</button>
<button
type="button"
onclick={saveArguments}
class="flex items-center gap-1 rounded bg-purple-500 px-2 py-1 text-xs text-white hover:bg-purple-600"
>
<Check class="h-3 w-3" />
<span>Save</span>
</button>
</div>
</div>
{:else}
<div class="space-y-1">
{#each Object.entries(prompt.arguments ?? {}) as [key, value] (key)}
<div class="flex gap-2 text-xs">
<span class="shrink-0 font-medium text-purple-600 dark:text-purple-400">{key}:</span
>
<span class="truncate text-muted-foreground">{value}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if showContent && hasContent}
<Card
class="mt-2 max-h-64 overflow-y-auto rounded-[1.125rem] border-none bg-purple-500/10 px-3.75 py-2.5 text-foreground backdrop-blur-md dark:bg-purple-500/20"
style="overflow-wrap: anywhere; word-break: break-word;"
{#if !readonly && onRemove}
<div
class="absolute top-8 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
{#if currentConfig.renderUserContentAsMarkdown}
<div class="text-sm">
<MarkdownContent class="markdown-user-content text-foreground" content={prompt.content} />
</div>
{:else}
<span class="text-sm whitespace-pre-wrap">
{prompt.content}
</span>
{/if}
</Card>
<RemoveButton id={prompt.name} onRemove={() => onRemove?.()} />
</div>
{/if}
</div>

View File

@ -28,9 +28,6 @@
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
// For MCP prompt argument editing
isEditingAllowed?: boolean;
onMcpPromptArgumentsChange?: (fileId: string, args: Record<string, string>) => void;
}
let {
@ -45,9 +42,7 @@
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
activeModelId,
isEditingAllowed = false,
onMcpPromptArgumentsChange
activeModelId
}: Props = $props();
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
@ -158,11 +153,7 @@
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
{isEditingAllowed}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
onArgumentsChange={onMcpPromptArgumentsChange
? (args) => onMcpPromptArgumentsChange(item.id, args)
: undefined}
/>
{/if}
{:else if item.isImage && item.preview}
@ -247,11 +238,7 @@
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
{isEditingAllowed}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
onArgumentsChange={onMcpPromptArgumentsChange
? (args) => onMcpPromptArgumentsChange(item.id, args)
: undefined}
/>
{/if}
{:else if item.isImage && item.preview}

View File

@ -1,10 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { Plus, MessageSquare, Zap } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { McpLogo } from '$lib/components/app';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
interface Props {
class?: string;
@ -30,6 +32,14 @@
onMcpServersClick
}: Props = $props();
let isNewChat = $derived(!page.params.id);
let systemMessageTooltip = $derived(
isNewChat
? 'Add custom system message for a new conversation'
: 'Inject custom system message at the beginning of the conversation'
);
let dropdownOpen = $state(false);
function handleMcpPromptClick() {
@ -49,7 +59,7 @@
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
@ -69,128 +79,122 @@
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-48">
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
disabled={!hasVisionModality}
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !hasVisionModality}
<Tooltip.Content usePortal={false}>
{#if hasVisionModality}
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
disabled
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Images require vision models to be processed</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
</Tooltip.Root>
{/if}
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2"
disabled={!hasAudioModality}
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !hasAudioModality}
<Tooltip.Content usePortal={false}>
{#if hasAudioModality}
<DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item class="audio-button flex cursor-pointer items-center gap-2" disabled>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Audio files require audio models to be processed</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
</Tooltip.Root>
{/if}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.text class="h-4 w-4" />
<span>Text Files</span>
</DropdownMenu.Item>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
{#if !hasVisionModality}
<Tooltip.Content usePortal={false}>
{#if hasVisionModality}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
</Tooltip.Root>
{/if}
<DropdownMenu.Separator />
<Tooltip.Root>
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onSystemPromptClick?.()}
>
<MessageSquare class="h-4 w-4" />
<span>System Message</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content usePortal={false}>
<p>Add a custom system message for this conversation</p>
<Tooltip.Content side="right">
<p>{systemMessageTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{#if hasMcpPromptsSupport}
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpPromptClick}
>
<Zap class="h-4 w-4" />
<span>MCP Prompt</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content usePortal={false}>
<p>Insert a prompt from an MCP server</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<DropdownMenu.Separator />
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpServersClick}
>
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpServersClick}
>
<McpLogo class="h-4 w-4" />
<Tooltip.Content usePortal={false}>
<p>Configure MCP servers for agentic tool execution</p>
</Tooltip.Content>
</Tooltip.Root>
<span>MCP Servers</span>
</DropdownMenu.Item>
{#if hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpPromptClick}
>
<Zap class="h-4 w-4" />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>

View File

@ -36,7 +36,6 @@
value?: string;
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onMcpPromptArgumentsChange?: (fileId: string, args: Record<string, string>) => void;
onStop?: () => void;
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
@ -56,7 +55,6 @@
value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onMcpPromptArgumentsChange,
onStop,
onSubmit,
onSystemPromptClick,
@ -342,14 +340,6 @@
}
}
function handleMcpPromptArgsChange(fileId: string, args: Record<string, string>) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === fileId && f.mcpPrompt ? { ...f, mcpPrompt: { ...f.mcpPrompt, arguments: args } } : f
);
onUploadedFilesChange?.(uploadedFiles);
onMcpPromptArgumentsChange?.(fileId, args);
}
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
@ -390,8 +380,6 @@
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
isEditingAllowed={true}
onMcpPromptArgumentsChange={handleMcpPromptArgsChange}
/>
<div

View File

@ -6,12 +6,14 @@
import { DatabaseService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { MessageRole } from '$lib/enums';
import { MessageRole, AttachmentType } from '$lib/enums';
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
import ChatMessageUser from './ChatMessageUser.svelte';
import ChatMessageSystem from './ChatMessageSystem.svelte';
import ChatMessageMcpPrompt from './ChatMessageMcpPrompt.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
@ -67,6 +69,20 @@
let shouldBranchAfterEdit = $state(false);
let textareaElement: HTMLTextAreaElement | undefined = $state();
let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null;
if (message.content.trim()) return null;
if (!message.extra || message.extra.length !== 1) return null;
const extra = message.extra[0];
if (extra.type === AttachmentType.MCP_PROMPT) {
return extra as DatabaseMessageExtraMcpPrompt;
}
return null;
});
// Auto-start edit mode if this message is the pending edit target
$effect(() => {
const pendingId = pendingEditMessageId();
@ -250,6 +266,21 @@
{showDeleteDialog}
{siblingInfo}
/>
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt
class={className}
{deletionInfo}
{message}
mcpPrompt={mcpPromptExtra}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
{onNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if message.role === MessageRole.USER}
<ChatMessageUser
class={className}

View File

@ -0,0 +1,68 @@
<script lang="ts">
import ChatMessageActions from './ChatMessageActions.svelte';
import McpPromptContent from '../McpPromptContent.svelte';
import { MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
message: DatabaseMessage;
mcpPrompt: DatabaseMessageExtraMcpPrompt;
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
}
let {
class: className = '',
message,
mcpPrompt,
siblingInfo = null,
showDeleteDialog,
deletionInfo,
onCopy,
onEdit,
onDelete,
onConfirmDelete,
onNavigateToSibling,
onShowDeleteDialogChange
}: Props = $props();
</script>
<div
aria-label="MCP Prompt message with actions"
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
<McpPromptContent prompt={mcpPrompt} variant="message" class="w-full max-w-[80%]" />
{#if message.timestamp}
<div class="max-w-[80%]">
<ChatMessageActions
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role={MessageRole.USER}
/>
</div>
{/if}
</div>

View File

@ -421,7 +421,7 @@
>
<div class="w-full max-w-[48rem] px-4">
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<p class="text-lg text-muted-foreground">
{serverStore.props?.modalities?.audio

View File

@ -0,0 +1,186 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import type { DatabaseMessageExtraMcpPrompt, MCPServerSettingsEntry } from '$lib/types';
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { SvelteMap } from 'svelte/reactivity';
interface ContentPart {
text: string;
argKey: string | null;
}
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
variant?: 'message' | 'attachment';
isLoading?: boolean;
loadError?: string;
}
let {
class: className = '',
prompt,
variant = 'message',
isLoading = false,
loadError
}: Props = $props();
let hoveredArgKey = $state<string | null>(null);
let argumentEntries = $derived(Object.entries(prompt.arguments ?? {}));
let hasArguments = $derived(prompt.arguments && Object.keys(prompt.arguments).length > 0);
let hasContent = $derived(prompt.content && prompt.content.trim().length > 0);
let serverSettingsMap = $derived.by(() => {
const servers = mcpStore.getServers();
const map = new SvelteMap<string, MCPServerSettingsEntry>();
for (const server of servers) {
map.set(server.id, server);
}
return map;
});
function getServerFavicon(): string | null {
const server = serverSettingsMap.get(prompt.serverName);
return server ? getFaviconUrl(server.url) : null;
}
function getServerDisplayName(): string {
const server = serverSettingsMap.get(prompt.serverName);
if (!server) return prompt.serverName;
const healthState = mcpStore.getHealthCheckState(server.id);
return getMcpServerLabel(server, healthState);
}
let contentParts = $derived.by((): ContentPart[] => {
if (!prompt.content || !hasArguments) {
return [{ text: prompt.content || '', argKey: null }];
}
const parts: ContentPart[] = [];
let remaining = prompt.content;
const valueToKey = new SvelteMap<string, string>();
for (const [key, value] of argumentEntries) {
if (value && value.trim()) {
valueToKey.set(value, key);
}
}
const sortedValues = [...valueToKey.keys()].sort((a, b) => b.length - a.length);
while (remaining.length > 0) {
let earliestMatch: { index: number; value: string; key: string } | null = null;
for (const value of sortedValues) {
const index = remaining.indexOf(value);
if (index !== -1 && (earliestMatch === null || index < earliestMatch.index)) {
earliestMatch = { index, value, key: valueToKey.get(value)! };
}
}
if (earliestMatch) {
if (earliestMatch.index > 0) {
parts.push({ text: remaining.slice(0, earliestMatch.index), argKey: null });
}
parts.push({ text: earliestMatch.value, argKey: earliestMatch.key });
remaining = remaining.slice(earliestMatch.index + earliestMatch.value.length);
} else {
parts.push({ text: remaining, argKey: null });
break;
}
}
return parts;
});
let showArgBadges = $derived(hasArguments && !isLoading && !loadError);
let isAttachment = $derived(variant === 'attachment');
let textSizeClass = $derived(isAttachment ? 'text-sm' : 'text-md');
let maxHeightStyle = $derived(
isAttachment
? 'max-height: 10rem; overflow-wrap: anywhere; word-break: break-word;'
: 'max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;'
);
</script>
<div class="flex flex-col gap-2 {className}">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
{#if getServerFavicon()}
<img
src={getServerFavicon()}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{getServerDisplayName()}</span>
<span>·</span>
<span>{prompt.name}</span>
</div>
{#if showArgBadges}
<div class="flex flex-wrap justify-end gap-1">
{#each argumentEntries as [key] (key)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
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 &&
hoveredArgKey !== key
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = key)}
onmouseleave={() => (hoveredArgKey = null)}
>
{key}
</span>
{/each}
</div>
{/if}
</div>
{#if loadError}
<Card
class="relative overflow-y-auto rounded-[1.125rem] border border-destructive/50 bg-destructive/10 px-3.75 py-2.5 backdrop-blur-md"
style={maxHeightStyle}
>
<span class="{textSizeClass} text-destructive">{loadError}</span>
</Card>
{:else if isLoading}
<Card
class="relative overflow-y-auto rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-3.75 py-2.5 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
style={maxHeightStyle}
>
<span class="{textSizeClass} text-purple-500 italic dark:text-purple-400">
Loading prompt content...
</span>
</Card>
{:else if hasContent}
<Card
class="relative overflow-y-auto rounded-[1.125rem] border border-purple-200 bg-purple-500/10 px-3.75 py-2.5 text-foreground backdrop-blur-md dark:border-purple-800 dark:bg-purple-500/20"
style={maxHeightStyle}
>
<span class="{textSizeClass} whitespace-pre-wrap">
<!-- This formatting is needed to keep the text in proper shape -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each contentParts as part, i (i)}{#if part.argKey}<span
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 &&
hoveredArgKey !== part.argKey
? 'opacity-30'
: ''}"
onmouseenter={() => (hoveredArgKey = part.argKey)}
onmouseleave={() => (hoveredArgKey = null)}>{part.text}</span
>{:else}<span class="transition-opacity {hoveredArgKey ? 'opacity-30' : ''}"
>{part.text}</span
>{/if}{/each}</span
>
</Card>
{/if}
</div>

View File

@ -23,11 +23,14 @@ export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageActions.svelte';
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
export { default as ChatMessageMcpPrompt } from './chat/ChatMessages/ChatMessageMcpPrompt.svelte';
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as CollapsibleContentBlock } from './chat/ChatMessages/CollapsibleContentBlock.svelte';
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as McpPromptContent } from './chat/McpPromptContent.svelte';
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as KeyValuePairs } from './misc/KeyValuePairs.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';