feat: MCP Prompts implementation improvements
This commit is contained in:
parent
801ef93522
commit
14911e51fc
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue