feat: MCP Prompts WIP

This commit is contained in:
Aleksander Grygier 2026-01-24 01:26:17 +01:00
parent 3d88d0b6b2
commit 8428741034
31 changed files with 1838 additions and 490 deletions

View File

@ -25,6 +25,8 @@ import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
import { getEnabledServersForConversation } from '$lib/utils/mcp';
import { mcpClient } from '$lib/clients/mcp.client';
import type { McpServerOverride } from '$lib/types/database';
interface ConversationsStoreStateCallbacks {
@ -159,6 +161,9 @@ export class ConversationsClient {
this.store.setActiveMessages(messages);
}
// Run MCP health checks for enabled servers in this conversation
this.runMcpHealthChecksForConversation(conversation.mcpServerOverrides);
return true;
} catch (error) {
console.error('Failed to load conversation:', error);
@ -166,6 +171,32 @@ export class ConversationsClient {
}
}
/**
* Runs MCP health checks for servers enabled in a conversation.
* Runs asynchronously in the background without blocking conversation loading.
* @param mcpServerOverrides - The conversation's MCP server overrides
*/
private runMcpHealthChecksForConversation(mcpServerOverrides?: McpServerOverride[]): void {
if (!mcpServerOverrides?.length) {
return;
}
const enabledServers = getEnabledServersForConversation(config(), mcpServerOverrides);
if (enabledServers.length === 0) {
return;
}
console.log(
`[ConversationsClient] Running health checks for ${enabledServers.length} MCP server(s)`
);
// Run health checks in background (don't await)
mcpClient.runHealthChecksForServers(enabledServers).catch((error) => {
console.warn('[ConversationsClient] MCP health checks failed:', error);
});
}
/**
*
*

View File

@ -40,11 +40,12 @@ import type {
ClientCapabilities,
MCPCapabilitiesInfo,
MCPConnectionLog,
MCPPromptInfo,
GetPromptResult,
Tool,
Prompt
} from '$lib/types';
import type {
ListChangedHandlers,
} from '@modelcontextprotocol/sdk/types.js';
import type { ListChangedHandlers } from '@modelcontextprotocol/sdk/types.js';
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import { MCPError } from '$lib/errors';
@ -168,7 +169,7 @@ export class MCPClient {
clientInfo,
capabilities,
undefined,
listChangedHandlers,
listChangedHandlers
);
return { name, connection };
@ -253,6 +254,18 @@ export class MCPClient {
this.handleToolsListChanged(serverName, tools ?? []);
}
},
prompts: {
onChanged: (error: Error | null, prompts: Prompt[] | null) => {
if (error) {
console.warn(`[MCPClient][${serverName}] Prompts list changed error:`, error);
return;
}
console.log(
`[MCPClient][${serverName}] Prompts list changed, ${prompts?.length ?? 0} prompts`
);
this.handlePromptsListChanged(serverName);
}
}
};
}
@ -292,6 +305,18 @@ export class MCPClient {
});
}
/**
* Handle prompts list changed notification from a server.
* Triggers a refresh of the prompts cache if needed.
*/
private handlePromptsListChanged(serverName: string): void {
// Prompts are fetched on-demand, so we just log the change
// The UI will get fresh prompts on next getAllPrompts() call
console.log(
`[MCPClient][${serverName}] Prompts list updated - will be refreshed on next fetch`
);
}
/**
* Acquire a reference to MCP connections for an agentic flow.
* Call this when starting an agentic flow to prevent premature shutdown.
@ -489,6 +514,77 @@ export class MCPClient {
return this.toolsIndex.get(toolName);
}
/**
*
*
* Prompts
*
*
*/
/**
* Get all prompts from all connected servers that support prompts.
*/
async getAllPrompts(): Promise<MCPPromptInfo[]> {
const results: MCPPromptInfo[] = [];
for (const [serverName, connection] of this.connections) {
if (!connection.serverCapabilities?.prompts) continue;
const prompts = await MCPService.listPrompts(connection);
for (const prompt of prompts) {
results.push({
name: prompt.name,
description: prompt.description,
title: prompt.title,
serverName,
arguments: prompt.arguments?.map((arg) => ({
name: arg.name,
description: arg.description,
required: arg.required
}))
});
}
}
return results;
}
/**
* Get a prompt by name from a specific server.
* Returns the prompt messages ready to be used in chat.
* Throws an error if the server is not found or prompt execution fails.
*/
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>
): Promise<GetPromptResult> {
const connection = this.connections.get(serverName);
if (!connection) {
const errorMsg = `Server "${serverName}" not found for prompt "${promptName}"`;
console.error(`[MCPClient] ${errorMsg}`);
throw new Error(errorMsg);
}
return MCPService.getPrompt(connection, promptName, args);
}
/**
* Check if any connected server supports prompts.
*/
hasPromptsSupport(): boolean {
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
/**
*
*
@ -583,6 +679,47 @@ export class MCPClient {
throw new MCPError(`Invalid tool arguments type: ${typeof args}`, -32602);
}
/**
*
*
* Completions
*
*
*/
/**
* Get completion suggestions for a prompt argument.
* Used for autocompleting prompt argument values.
*
* @param serverName - Name of the server hosting the prompt
* @param promptName - Name of the prompt
* @param argumentName - Name of the argument being completed
* @param argumentValue - Current partial value of the argument
* @returns Completion suggestions or null if not supported/error
*/
async getPromptCompletions(
serverName: string,
promptName: string,
argumentName: string,
argumentValue: string
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
const connection = this.connections.get(serverName);
if (!connection) {
console.warn(`[MCPClient] Server "${serverName}" is not connected`);
return null;
}
if (!connection.serverCapabilities?.completions) {
return null;
}
return MCPService.complete(
connection,
{ type: 'ref/prompt', name: promptName },
{ name: argumentName, value: argumentValue }
);
}
/**
*
*

View File

@ -0,0 +1,271 @@
<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';
interface Props {
class?: string;
prompt: DatabaseMessageExtraMcpPrompt;
readonly?: boolean;
isLoading?: boolean;
loadError?: string;
isEditingAllowed?: boolean;
onRemove?: () => void;
onArgumentsChange?: (args: Record<string, string>) => void;
}
let {
class: className = '',
prompt,
readonly = false,
isLoading = false,
loadError,
isEditingAllowed = false,
onRemove,
onArgumentsChange
}: 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}
<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 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>
{/if}
</div>

View File

@ -1,9 +1,15 @@
<script lang="ts">
import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
import {
ChatAttachmentMcpPrompt,
ChatAttachmentThumbnailImage,
ChatAttachmentThumbnailFile
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
import { getAttachmentDisplayItems } from '$lib/utils';
import { AttachmentType } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
class?: string;
@ -22,6 +28,9 @@
limitToSingleRow?: boolean;
// For vision modality check
activeModelId?: string;
// For MCP prompt argument editing
isEditingAllowed?: boolean;
onMcpPromptArgumentsChange?: (fileId: string, args: Record<string, string>) => void;
}
let {
@ -36,7 +45,9 @@
imageHeight = 'h-24',
imageWidth = 'w-auto',
limitToSingleRow = false,
activeModelId
activeModelId,
isEditingAllowed = false,
onMcpPromptArgumentsChange
}: Props = $props();
let displayItems = $derived(getAttachmentDisplayItems({ uploadedFiles, attachments }));
@ -124,7 +135,37 @@
onscroll={updateScrollButtons}
>
{#each displayItems as item (item.id)}
{#if item.isImage && item.preview}
{#if item.isMcpPrompt}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {limitToSingleRow
? 'first:ml-4 last:mr-4'
: ''}"
prompt={mcpPrompt}
{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}
<ChatAttachmentThumbnailImage
class="flex-shrink-0 cursor-pointer {limitToSingleRow
? 'first:ml-4 last:mr-4'
@ -185,7 +226,35 @@
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
{#if item.isImage && item.preview}
{#if item.isMcpPrompt}
{@const mcpPrompt =
item.attachment?.type === AttachmentType.MCP_PROMPT
? (item.attachment as DatabaseMessageExtraMcpPrompt)
: item.uploadedFile?.mcpPrompt
? {
type: AttachmentType.MCP_PROMPT as const,
name: item.name,
serverName: item.uploadedFile.mcpPrompt.serverName,
promptName: item.uploadedFile.mcpPrompt.promptName,
content: item.textContent ?? '',
arguments: item.uploadedFile.mcpPrompt.arguments
}
: null}
{#if mcpPrompt}
<ChatAttachmentMcpPrompt
class="max-w-[300px] min-w-[200px]"
prompt={mcpPrompt}
{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}
<ChatAttachmentThumbnailImage
class="cursor-pointer"
id={item.id}

View File

@ -1,27 +1,6 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormHelperText,
ChatFormTextarea
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { MimeTypeText } from '$lib/enums';
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/browser-only';
import { ChatFormHelperText, ChatFormInputArea } from '$lib/components/app';
import { onMount } from 'svelte';
interface Props {
@ -50,190 +29,22 @@
uploadedFiles = $bindable([])
}: Props = $props();
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let isRecording = $state(false);
let inputAreaRef: ChatFormInputArea | undefined = $state(undefined);
let message = $state('');
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let previousIsLoading = $state(isLoading);
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Check if model is selected (in ROUTER mode)
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let isRouter = $derived(isRouterMode());
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
// Get active model ID for capability detection
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
// First try user-selected model
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
// Fallback to conversation model
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
function checkModelSelected(): boolean {
if (!hasModelSelected) {
// Open the model selector
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
function handleFileSelect(files: File[]) {
onFileUpload?.(files);
}
function handleFileUpload() {
fileInputRef?.click();
}
async function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
if (!checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
message = '';
uploadedFiles = [];
textareaRef?.resetHeight();
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
}
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFileUpload?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
message = parsed.message;
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFileUpload?.(attachmentFiles);
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
async function handleSubmit() {
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFileUpload?.([textFile]);
}
}
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
(!message.trim() && uploadedFiles.length === 0) ||
disabled ||
isLoading ||
hasLoadingAttachments
)
return;
}
if (isRecording) {
try {
const audioBlob = await audioRecorder.stopRecording();
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
onFileUpload?.([audioFile]);
isRecording = false;
} catch (error) {
console.error('Failed to stop recording:', error);
isRecording = false;
}
} else {
try {
await audioRecorder.startRecording();
isRecording = true;
} catch (error) {
console.error('Failed to start recording:', error);
}
}
}
function handleStop() {
onStop?.();
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
// Check if model is selected first
if (!checkModelSelected()) return;
if (!inputAreaRef?.checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
@ -241,7 +52,7 @@
message = '';
uploadedFiles = [];
textareaRef?.resetHeight();
inputAreaRef?.resetHeight();
const success = await onSend?.(messageToSend, filesToSend);
@ -251,68 +62,46 @@
}
}
function handleFilesAdd(files: File[]) {
onFileUpload?.(files);
}
function handleUploadedFileRemove(fileId: string) {
onFileRemove?.(fileId);
}
onMount(() => {
setTimeout(() => textareaRef?.focus(), 10);
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
setTimeout(() => inputAreaRef?.focus(), 10);
});
afterNavigate(() => {
setTimeout(() => textareaRef?.focus(), 10);
setTimeout(() => inputAreaRef?.focus(), 10);
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => textareaRef?.focus(), 10);
setTimeout(() => inputAreaRef?.focus(), 10);
}
previousIsLoading = isLoading;
});
</script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
onsubmit={handleSubmit}
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''} {className}"
data-slot="chat-form"
>
<ChatAttachmentsList
<div class="relative mx-auto max-w-[48rem]">
<ChatFormInputArea
bind:this={inputAreaRef}
bind:value={message}
bind:uploadedFiles
{onFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
class={className}
{disabled}
{isLoading}
showMcpPromptButton={true}
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
onSystemPromptClick={onSystemPromptAdd}
onUploadedFileRemove={handleUploadedFileRemove}
/>
<div
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-2 py-1 md:py-0"
bind:value={message}
onKeydown={handleKeydown}
{disabled}
/>
<ChatFormActions
bind:this={chatFormActionsRef}
canSend={message.trim().length > 0 || uploadedFiles.length > 0}
hasText={message.trim().length > 0}
{disabled}
{isLoading}
{isRecording}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
onStop={handleStop}
onSystemPromptClick={onSystemPromptAdd}
/>
</div>
</form>
</div>
<ChatFormHelperText show={showHelperText} />

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { Plus, MessageSquare } from '@lucide/svelte';
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';
@ -11,8 +11,10 @@
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpServersClick?: () => void;
}
@ -21,13 +23,20 @@
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpServersClick
}: Props = $props();
let dropdownOpen = $state(false);
function handleMcpPromptClick() {
dropdownOpen = false;
onMcpPromptClick?.();
}
function handleMcpServersClick() {
dropdownOpen = false;
onMcpServersClick?.();
@ -137,7 +146,7 @@
>
<MessageSquare class="h-4 w-4" />
<span>System Prompt</span>
<span>System Message</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
@ -146,6 +155,25 @@
</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">

View File

@ -9,13 +9,14 @@
McpActiveServersAvatars,
ModelsSelector
} from '$lib/components/app';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
interface Props {
canSend?: boolean;
@ -29,6 +30,7 @@
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
}
let {
@ -42,7 +44,8 @@
onFileUpload,
onMicClick,
onStop,
onSystemPromptClick
onSystemPromptClick,
onMcpPromptClick
}: Props = $props();
let currentConfig = $derived(config());
@ -157,6 +160,11 @@
}
let showMcpDialog = $state(false);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
return mcpStore.hasEnabledServers(perChatOverrides);
});
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
@ -165,8 +173,10 @@
{disabled}
{hasAudioModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
onMcpServersClick={() => (showMcpDialog = true)}
/>
@ -187,9 +197,10 @@
<Button
type="button"
onclick={onStop}
class="h-8 w-8 bg-transparent p-0 hover:bg-destructive/20"
class="h-8 w-8 rounded-full bg-transparent p-0 hover:bg-destructive/20"
>
<span class="sr-only">Stop</span>
<Square class="h-8 w-8 fill-destructive stroke-destructive" />
</Button>
{:else if shouldShowRecordButton}

View File

@ -0,0 +1,423 @@
<script lang="ts">
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormPromptPicker,
ChatFormTextarea
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { MimeTypeText } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/browser-only';
import { onMount } from 'svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
class?: string;
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
uploadedFiles?: ChatUploadedFile[];
value?: string;
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onMcpPromptArgumentsChange?: (fileId: string, args: Record<string, string>) => void;
onStop?: () => void;
onSubmit?: () => void;
onSystemPromptClick?: () => void;
onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
let {
attachments = [],
class: className = '',
disabled = false,
isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onMcpPromptArgumentsChange,
onStop,
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange
}: Props = $props();
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let isRecording = $state(false);
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
export function focus() {
textareaRef?.focus();
}
export function resetHeight() {
textareaRef?.resetHeight();
}
export function openModelSelector() {
chatFormActionsRef?.openModelSelector();
}
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
function handleFileUpload() {
fileInputRef?.click();
}
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
if (value.startsWith('/') && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return;
}
if (event.key === 'Escape' && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
onSubmit?.();
}
}
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
value = '';
onValueChange?.('');
isPromptPickerOpen = false;
promptSearchQuery = '';
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: 0,
type: 'mcp-prompt',
file: new File([], 'loading'),
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === 'text') {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join('\n\n');
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}.txt`, { type: 'text/plain' })
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
if (isRecording) {
try {
const audioBlob = await audioRecorder.stopRecording();
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
onFilesAdd?.([audioFile]);
isRecording = false;
} catch (error) {
console.error('Failed to stop recording:', error);
isRecording = false;
}
} else {
try {
await audioRecorder.startRecording();
isRecording = true;
} catch (error) {
console.error('Failed to start recording:', error);
}
}
}
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();
});
</script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<div class="relative {className}">
<ChatFormPromptPicker
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={handlePromptPickerClose}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
/>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
>
<ChatAttachmentsList
{attachments}
bind:uploadedFiles
onFileRemove={handleFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
isEditingAllowed={true}
onMcpPromptArgumentsChange={handleMcpPromptArgsChange}
/>
<div
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-2 py-1 md:py-0"
bind:this={textareaRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
<ChatFormActions
bind:this={chatFormActionsRef}
canSend={canSubmit}
hasText={value.trim().length > 0}
{disabled}
{isLoading}
{isRecording}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
{onSystemPromptClick}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
/>
</div>
</div>
</div>

View File

@ -0,0 +1,526 @@
<script lang="ts">
import { mcpClient } from '$lib/clients/mcp.client';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getFaviconUrl, getMcpServerLabel } from '$lib/utils/mcp';
import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types';
import { fly } from 'svelte/transition';
import { SvelteMap } from 'svelte/reactivity';
import { debounce } from '$lib/utils/debounce';
import { SearchInput } from '$lib/components/app';
interface Props {
class?: string;
isOpen?: boolean;
searchQuery?: string;
onClose?: () => void;
onPromptLoadStart?: (
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) => void;
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
onPromptLoadError?: (placeholderId: string, error: string) => void;
}
let {
class: className = '',
isOpen = false,
searchQuery = '',
onClose,
onPromptLoadStart,
onPromptLoadComplete,
onPromptLoadError
}: Props = $props();
let prompts = $state<MCPPromptInfo[]>([]);
let isLoading = $state(false);
let selectedPrompt = $state<MCPPromptInfo | null>(null);
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
let promptError = $state<string | null>(null);
let suggestions = $state<Record<string, string[]>>({});
let loadingSuggestions = $state<Record<string, boolean>>({});
let activeAutocomplete = $state<string | null>(null);
let autocompleteIndex = $state(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(serverId: string): string | null {
const server = serverSettingsMap.get(serverId);
return server ? getFaviconUrl(server.url) : null;
}
function getServerLabel(serverId: string): string {
const server = serverSettingsMap.get(serverId);
if (!server) return serverId;
const healthState = mcpStore.getHealthCheckState(serverId);
return getMcpServerLabel(server, healthState);
}
$effect(() => {
if (isOpen) {
loadPrompts();
selectedIndex = 0;
} else {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}
});
$effect(() => {
if (filteredPrompts.length > 0 && selectedIndex >= filteredPrompts.length) {
selectedIndex = 0;
}
});
async function loadPrompts() {
isLoading = true;
try {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const initialized = await mcpClient.ensureInitialized(perChatOverrides);
if (!initialized) {
prompts = [];
return;
}
prompts = await mcpClient.getAllPrompts();
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to load prompts:', error);
prompts = [];
} finally {
isLoading = false;
}
}
function handlePromptClick(prompt: MCPPromptInfo) {
const requiredArgs = prompt.arguments?.filter((arg) => arg.required) ?? [];
if (requiredArgs.length > 0) {
selectedPrompt = prompt;
promptArgs = {};
promptError = null;
} else {
executePrompt(prompt, {});
}
}
async function executePrompt(prompt: MCPPromptInfo, args: Record<string, string>) {
promptError = null;
const placeholderId = crypto.randomUUID();
const nonEmptyArgs = Object.fromEntries(
Object.entries(args).filter(([, value]) => value.trim() !== '')
);
const argsToPass = Object.keys(nonEmptyArgs).length > 0 ? nonEmptyArgs : undefined;
onPromptLoadStart?.(placeholderId, prompt, argsToPass);
onClose?.();
try {
const result = await mcpClient.getPrompt(prompt.serverName, prompt.name, args);
onPromptLoadComplete?.(placeholderId, result);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error executing prompt';
onPromptLoadError?.(placeholderId, errorMessage);
}
}
function handleArgumentSubmit(event: SubmitEvent) {
event.preventDefault();
if (selectedPrompt) {
executePrompt(selectedPrompt, promptArgs);
}
}
const fetchCompletions = debounce(async (argName: string, value: string) => {
if (!selectedPrompt || value.length < 1) {
suggestions[argName] = [];
return;
}
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Fetching completions for:', {
serverName: selectedPrompt.serverName,
promptName: selectedPrompt.name,
argName,
value
});
}
loadingSuggestions[argName] = true;
try {
const result = await mcpClient.getPromptCompletions(
selectedPrompt.serverName,
selectedPrompt.name,
argName,
value
);
if (import.meta.env.DEV) {
console.log('[ChatFormPromptPicker] Autocomplete result:', {
argName,
value,
result,
suggestionsCount: result?.values.length ?? 0
});
}
if (result && result.values.length > 0) {
// Filter out empty strings from suggestions
const filteredValues = result.values.filter((v) => v.trim() !== '');
if (filteredValues.length > 0) {
suggestions[argName] = filteredValues;
activeAutocomplete = argName;
autocompleteIndex = 0;
} else {
suggestions[argName] = [];
}
} else {
suggestions[argName] = [];
}
} catch (error) {
console.error('[ChatFormPromptPicker] Failed to fetch completions:', error);
suggestions[argName] = [];
} finally {
loadingSuggestions[argName] = false;
}
}, 200);
function handleArgInput(argName: string, value: string) {
promptArgs[argName] = value;
fetchCompletions(argName, value);
}
function selectSuggestion(argName: string, value: string) {
promptArgs[argName] = value;
suggestions[argName] = [];
activeAutocomplete = null;
}
function handleArgKeydown(event: KeyboardEvent, argName: string) {
const argSuggestions = suggestions[argName] ?? [];
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
} else if (event.key === 'Enter' && argSuggestions[autocompleteIndex]) {
event.preventDefault();
event.stopPropagation();
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
} else if (event.key === 'Escape') {
event.preventDefault();
suggestions[argName] = [];
activeAutocomplete = null;
}
}
function handleArgBlur(argName: string) {
// Delay to allow click on suggestion
setTimeout(() => {
if (activeAutocomplete === argName) {
suggestions[argName] = [];
activeAutocomplete = null;
}
}, 150);
}
export function handleKeydown(event: KeyboardEvent): boolean {
if (!isOpen) return false;
if (event.key === 'Escape') {
event.preventDefault();
if (selectedPrompt) {
selectedPrompt = null;
promptArgs = {};
} else {
onClose?.();
}
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
if (selectedIndex < filteredPrompts.length - 1) {
selectedIndex++;
}
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (selectedIndex > 0) {
selectedIndex--;
}
return true;
}
if (event.key === 'Enter' && !selectedPrompt) {
event.preventDefault();
if (filteredPrompts[selectedIndex]) {
handlePromptClick(filteredPrompts[selectedIndex]);
}
return true;
}
return false;
}
let filteredPrompts = $derived.by(() => {
const sortedServers = mcpStore.getServersSorted();
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
const sortedPrompts = [...prompts].sort((a, b) => {
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
return orderA - orderB;
});
const query = (searchQuery || internalSearchQuery).toLowerCase();
if (!query) return sortedPrompts;
return sortedPrompts.filter(
(prompt) =>
prompt.name.toLowerCase().includes(query) ||
prompt.title?.toLowerCase().includes(query) ||
prompt.description?.toLowerCase().includes(query)
);
});
let showSearchInput = $derived(prompts.length > 3);
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute right-0 bottom-full left-0 z-50 mb-3 {className}"
transition:fly={{ y: 10, duration: 150 }}
onkeydown={handleKeydown}
>
<div class="overflow-hidden rounded-xl border border-border/50 bg-popover shadow-xl">
{#if selectedPrompt}
<div class="p-4">
<!-- Header matching list item style -->
<div class="flex items-start gap-3">
{#if getServerFavicon(selectedPrompt.serverName)}
<img
src={getServerFavicon(selectedPrompt.serverName)}
alt=""
class="mt-0.5 h-5 w-5 shrink-0 rounded"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<div class="min-w-0 flex-1">
<div class="text-xs text-muted-foreground">
{getServerLabel(selectedPrompt.serverName)}
</div>
<div class="flex items-center gap-2">
<span class="font-medium">
{selectedPrompt.title || selectedPrompt.name}
</span>
{#if selectedPrompt.arguments?.length}
<span class="rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
{selectedPrompt.arguments.length} arg{selectedPrompt.arguments.length > 1
? 's'
: ''}
</span>
{/if}
</div>
{#if selectedPrompt.description}
<p class="mt-1 text-sm text-muted-foreground">
{selectedPrompt.description}
</p>
{/if}
</div>
</div>
<form onsubmit={handleArgumentSubmit} class="space-y-3 pt-4">
{#each selectedPrompt.arguments ?? [] as arg (arg.name)}
<div class="relative grid gap-1">
<label
for="arg-{arg.name}"
class="mb-1 flex items-center gap-2 text-sm text-muted-foreground"
>
<span>
{arg.name}
{#if arg.required}<span class="text-destructive">*</span>{/if}
</span>
{#if loadingSuggestions[arg.name]}
<span class="text-xs text-muted-foreground/50">...</span>
{/if}
</label>
<input
id="arg-{arg.name}"
type="text"
value={promptArgs[arg.name] ?? ''}
oninput={(e) => handleArgInput(arg.name, e.currentTarget.value)}
onkeydown={(e) => handleArgKeydown(e, arg.name)}
onblur={() => handleArgBlur(arg.name)}
onfocus={() => {
if ((suggestions[arg.name]?.length ?? 0) > 0) {
activeAutocomplete = arg.name;
}
}}
placeholder={arg.description || arg.name}
required={arg.required}
autocomplete="off"
class="w-full rounded-lg border border-border/50 bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
/>
{#if activeAutocomplete === arg.name && (suggestions[arg.name]?.length ?? 0) > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ y: -5, duration: 100 }}
>
{#each suggestions[arg.name] ?? [] as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => selectSuggestion(arg.name, suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i ===
autocompleteIndex
? 'bg-accent'
: ''}"
>
{suggestion}
</button>
{/each}
</div>
{/if}
</div>
{/each}
{#if promptError}
<div
class="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive"
role="alert"
>
<span class="shrink-0"></span>
<span>{promptError}</span>
</div>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => {
selectedPrompt = null;
promptArgs = {};
promptError = null;
}}
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
>
Use Prompt
</button>
</div>
</form>
</div>
{:else}
<div>
{#if showSearchInput}
<div class="p-2 pb-0">
<SearchInput placeholder="Search prompts..." bind:value={internalSearchQuery} />
</div>
{/if}
<div class="max-h-64 overflow-y-auto p-2">
{#if isLoading}
<div class="flex items-center justify-center py-6 text-sm text-muted-foreground">
Loading prompts...
</div>
{:else if filteredPrompts.length === 0}
<div class="py-6 text-center text-sm text-muted-foreground">
{prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'}
</div>
{:else}
{#each filteredPrompts as prompt, index (prompt.serverName + ':' + prompt.name)}
<button
type="button"
onclick={() => handlePromptClick(prompt)}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent {index ===
selectedIndex
? 'bg-accent'
: ''}"
>
<div class="min-w-0 flex-1">
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if getServerFavicon(prompt.serverName)}
<img
src={getServerFavicon(prompt.serverName)}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{getServerLabel(prompt.serverName)}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-medium">{prompt.title || prompt.name}</span>
{#if prompt.arguments && prompt.arguments.length > 0}
<span class="text-xs text-muted-foreground">
({prompt.arguments.length} arg{prompt.arguments.length > 1 ? 's' : ''})
</span>
{/if}
</div>
{#if prompt.description}
<p class="truncate text-sm text-muted-foreground">
{prompt.description}
</p>
{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@ -5,6 +5,7 @@
interface Props {
class?: string;
disabled?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
@ -14,6 +15,7 @@
let {
class: className = '',
disabled = false,
onInput,
onKeydown,
onPaste,
placeholder = 'Ask anything...',
@ -52,7 +54,10 @@
class:cursor-not-allowed={disabled}
{disabled}
onkeydown={onKeydown}
oninput={(event) => autoResizeTextarea(event.currentTarget)}
oninput={(event) => {
autoResizeTextarea(event.currentTarget);
onInput?.();
}}
onpaste={onPaste}
{placeholder}
></textarea>

View File

@ -11,6 +11,7 @@
import ChatMessageAssistant from './ChatMessageAssistant.svelte';
import ChatMessageUser from './ChatMessageUser.svelte';
import ChatMessageSystem from './ChatMessageSystem.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props {
class?: string;
@ -216,8 +217,8 @@
return editedExtras;
}
const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
const result = await parseFilesToMessageExtras(editedUploadedFiles);
const plainFiles = $state.snapshot(editedUploadedFiles);
const result = await parseFilesToMessageExtras(plainFiles);
const newExtras = result?.extras || [];
return [...editedExtras, ...newExtras];
@ -251,7 +252,6 @@
/>
{:else if message.role === MessageRole.USER}
<ChatMessageUser
bind:textareaElement
class={className}
{deletionInfo}
{editedContent}
@ -264,7 +264,6 @@
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onEditKeydown={handleEditKeydown}
onEditedContentChange={handleEditedContentChange}
onEditedExtrasChange={handleEditedExtrasChange}
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}

View File

@ -1,15 +1,9 @@
<script lang="ts">
import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
import { X, AlertTriangle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { MimeTypeText } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { ChatFormInputArea, DialogConfirmation } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { autoResizeTextarea, parseClipboardContent } from '$lib/utils';
interface Props {
editedContent: string;
@ -21,11 +15,9 @@
onCancelEdit: () => void;
onSaveEdit: () => void;
onSaveEditOnly?: () => void;
onEditKeydown: (event: KeyboardEvent) => void;
onEditedContentChange: (content: string) => void;
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
textareaElement?: HTMLTextAreaElement;
}
let {
@ -38,24 +30,14 @@
onCancelEdit,
onSaveEdit,
onSaveEditOnly,
onEditKeydown,
onEditedContentChange,
onEditedExtrasChange,
onEditedUploadedFilesChange,
textareaElement = $bindable()
onEditedUploadedFilesChange
}: Props = $props();
let fileInputElement: HTMLInputElement | undefined = $state();
let inputAreaRef: ChatFormInputArea | undefined = $state(undefined);
let saveWithoutRegenerate = $state(false);
let showDiscardDialog = $state(false);
let isRouter = $derived(isRouterMode());
let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let hasUnsavedChanges = $derived.by(() => {
if (editedContent !== originalContent) return true;
@ -77,16 +59,6 @@
let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const files = Array.from(input.files);
processNewFiles(files);
input.value = '';
}
function handleGlobalKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
@ -102,23 +74,6 @@
}
}
function handleRemoveExistingAttachment(index: number) {
if (!onEditedExtrasChange) return;
const newExtras = [...editedExtras];
newExtras.splice(index, 1);
onEditedExtrasChange(newExtras);
}
function handleRemoveUploadedFile(fileId: string) {
if (!onEditedUploadedFilesChange) return;
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
onEditedUploadedFilesChange(newFiles);
}
function handleSubmit() {
if (!canSubmit) return;
@ -131,79 +86,36 @@
saveWithoutRegenerate = false;
}
async function processNewFiles(files: File[]) {
function handleAttachmentRemove(index: number) {
if (!onEditedExtrasChange) return;
const newExtras = [...editedExtras];
newExtras.splice(index, 1);
onEditedExtrasChange(newExtras);
}
function handleUploadedFileRemove(fileId: string) {
if (!onEditedUploadedFilesChange) return;
const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
onEditedUploadedFilesChange(newFiles);
}
async function handleFilesAdd(files: File[]) {
if (!onEditedUploadedFilesChange) return;
const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
const processed = await processFilesToChatUploaded(files);
onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
onEditedUploadedFilesChange([...editedUploadedFiles, processed].flat());
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
processNewFiles(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
onEditedContentChange(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
processNewFiles(attachmentFiles);
setTimeout(() => {
textareaElement?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
processNewFiles([textFile]);
}
function handleUploadedFilesChange(files: ChatUploadedFile[]) {
onEditedUploadedFilesChange?.(files);
}
$effect(() => {
if (textareaElement) {
autoResizeTextarea(textareaElement);
}
});
$effect(() => {
chatStore.setEditModeActive(processNewFiles);
chatStore.setEditModeActive(handleFilesAdd);
return () => {
chatStore.clearEditMode();
@ -213,82 +125,20 @@
<svelte:window onkeydown={handleGlobalKeydown} />
<input
bind:this={fileInputElement}
type="file"
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<div
class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
data-slot="edit-form"
>
<ChatAttachmentsList
<div class="relative w-full max-w-[80%]">
<ChatFormInputArea
bind:this={inputAreaRef}
value={editedContent}
attachments={editedExtras}
uploadedFiles={editedUploadedFiles}
readonly={false}
onFileRemove={(fileId) => {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
handleRemoveExistingAttachment(index);
}
} else {
handleRemoveUploadedFile(fileId);
}
}}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
placeholder="Edit your message..."
onValueChange={onEditedContentChange}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
onUploadedFilesChange={handleUploadedFilesChange}
onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit}
/>
<div class="relative min-h-[48px] px-5 py-3">
<textarea
bind:this={textareaElement}
bind:value={editedContent}
class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
onkeydown={onEditKeydown}
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
onEditedContentChange(e.currentTarget.value);
}}
onpaste={handlePaste}
placeholder="Edit your message..."
></textarea>
<div class="flex w-full items-center gap-3" style="container-type: inline-size">
<Button
class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
onclick={() => fileInputElement?.click()}
type="button"
title="Add attachment"
>
<span class="sr-only">Attach files</span>
<Paperclip class="h-4 w-4" />
</Button>
<div class="flex-1"></div>
{#if isRouter}
<ModelsSelector forceForegroundText={true} useGlobalSelection={true} />
{/if}
<Button
class="h-8 w-8 shrink-0 rounded-full p-0"
onclick={handleSubmit}
disabled={!canSubmit}
type="button"
title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
>
<span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
<ArrowUp class="h-5 w-5" />
</Button>
</div>
</div>
</div>
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">

View File

@ -24,7 +24,6 @@
onCancelEdit: () => void;
onSaveEdit: () => void;
onSaveEditOnly?: () => void;
onEditKeydown: (event: KeyboardEvent) => void;
onEditedContentChange: (content: string) => void;
onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
@ -34,7 +33,6 @@
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
textareaElement?: HTMLTextAreaElement;
}
let {
@ -50,7 +48,6 @@
onCancelEdit,
onSaveEdit,
onSaveEditOnly,
onEditKeydown,
onEditedContentChange,
onEditedExtrasChange,
onEditedUploadedFilesChange,
@ -59,8 +56,7 @@
onDelete,
onConfirmDelete,
onNavigateToSibling,
onShowDeleteDialogChange,
textareaElement = $bindable()
onShowDeleteDialogChange
}: Props = $props();
let isMultiline = $state(false);
@ -99,7 +95,6 @@
>
{#if isEditing}
<ChatMessageEditForm
bind:textareaElement
{editedContent}
{editedExtras}
{editedUploadedFiles}
@ -109,7 +104,6 @@
{onCancelEdit}
{onSaveEdit}
{onSaveEditOnly}
{onEditKeydown}
{onEditedContentChange}
{onEditedExtrasChange}
{onEditedUploadedFilesChange}

View File

@ -220,8 +220,9 @@
}
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const result = files
? await parseFilesToMessageExtras(files, activeModelId ?? undefined)
const plainFiles = files ? $state.snapshot(files) : undefined;
const result = plainFiles
? await parseFilesToMessageExtras(plainFiles, activeModelId ?? undefined)
: undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) {

View File

@ -1,5 +1,6 @@
// Chat
export { default as ChatAttachmentMcpPrompt } from './chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte';
export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
@ -13,6 +14,8 @@ export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/Chat
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormInputArea } from './chat/ChatForm/ChatFormInputArea.svelte';
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
export { default as AgenticContent } from './chat/ChatMessages/AgenticContent.svelte';

View File

@ -10,9 +10,7 @@
import { Skeleton } from '$lib/components/ui/skeleton';
// Use store methods for consistent sorting logic
let rawServers = $derived(mcpStore.getServers());
let servers = $derived(mcpStore.getServersSorted());
let isLoading = $derived(mcpStore.isAnyServerLoading());
// New server form state
let isAddingServer = $state(false);
@ -115,13 +113,15 @@
</div>
{/if}
{#if rawServers.length > 0}
{#if servers.length > 0}
<div class="space-y-3">
{#if isLoading}
<!-- Show skeleton cards while health checks are in progress -->
{#each rawServers as server (server.id)}
{#each servers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const isServerLoading =
healthState.status === 'idle' || healthState.status === 'connecting'}
{#if isServerLoading}
<Card.Root class="grid gap-3 p-4">
<!-- Header: favicon + name + version ... toggle -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Skeleton class="h-5 w-5 rounded" />
@ -131,32 +131,26 @@
<Skeleton class="h-6 w-11 rounded-full" />
</div>
<!-- Capability badges -->
<div class="flex flex-wrap gap-1.5">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-12 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<!-- Tools & Connection info -->
<div class="space-y-1.5">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-4 w-52" />
</div>
<!-- Protocol version -->
<Skeleton class="h-3.5 w-36" />
<!-- Action buttons -->
<div class="flex justify-end gap-2">
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
</div>
</Card.Root>
{/each}
{:else}
{#each servers as server (server.id)}
{:else}
<McpServerCard
{server}
faviconUrl={getFaviconUrl(server.url)}
@ -165,8 +159,8 @@
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/each}
{/if}
{/if}
{/each}
</div>
{/if}
</div>

View File

@ -85,7 +85,7 @@
/>
</div>
<div class={cn('overflow-y-auto', 'max-h-[10rem]')}>
<div class={cn('overflow-y-auto', 'max-h-[16rem]')}>
{@render children()}
{#if isEmpty}

View File

@ -12,7 +12,8 @@
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
secondary:
'dark:bg-secondary dark:text-secondary-foreground bg-background shadow-sm text-foreground hover:bg-muted-foreground/20',
ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10',
link: 'text-primary underline-offset-4 hover:underline'
},

View File

@ -15,7 +15,7 @@
bind:checked
data-slot="switch"
class={cn(
'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
'peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
className
)}
{...restProps}

View File

@ -2,7 +2,7 @@ export const BOX_BORDER =
'border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border';
export const INPUT_CLASSES = `
bg-muted/50 dark:bg-muted/75
bg-muted/60 dark:bg-muted/75
${BOX_BORDER}
shadow-sm
outline-none

View File

@ -4,6 +4,7 @@
export enum AttachmentType {
AUDIO = 'AUDIO',
IMAGE = 'IMAGE',
MCP_PROMPT = 'MCP_PROMPT',
PDF = 'PDF',
TEXT = 'TEXT',
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility

View File

@ -2,6 +2,7 @@ import { getJsonHeaders } from '$lib/utils';
import { AGENTIC_REGEX } from '$lib/constants/agentic';
import { AttachmentType } from '$lib/enums';
import type { ApiChatMessageContentPart } from '$lib/types/api';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
/**
* ChatService - Low-level API communication layer for Chat Completions
@ -724,6 +725,18 @@ export class ChatService {
}
}
const mcpPrompts = message.extra.filter(
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpPrompt =>
extra.type === AttachmentType.MCP_PROMPT
);
for (const mcpPrompt of mcpPrompts) {
contentParts.push({
type: 'text',
text: `\n\n--- MCP Prompt: ${mcpPrompt.name} (${mcpPrompt.serverName}) ---\n${mcpPrompt.content}`
});
}
return {
role: message.role as 'user' | 'assistant' | 'system',
content: contentParts

View File

@ -19,7 +19,9 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import type {
Tool,
ListChangedHandlers,
Prompt,
GetPromptResult,
ListChangedHandlers
} from '@modelcontextprotocol/sdk/types.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type {
@ -152,6 +154,7 @@ export class MCPService {
* Connect to a single MCP server with detailed phase tracking.
* Returns connection object with client, transport, discovered tools, and connection details.
* @param onPhase - Optional callback for connection phase changes
* @param listChangedHandlers - Optional handlers for list changed notifications
*/
static async connect(
serverName: string,
@ -159,7 +162,7 @@ export class MCPService {
clientInfo?: Implementation,
capabilities?: ClientCapabilities,
onPhase?: MCPPhaseCallback,
listChangedHandlers?: ListChangedHandlers,
listChangedHandlers?: ListChangedHandlers
): Promise<MCPConnection> {
const startTime = performance.now();
const effectiveClientInfo = clientInfo ?? DEFAULT_MCP_CONFIG.clientInfo;
@ -296,10 +299,42 @@ export class MCPService {
return result.tools ?? [];
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Failed to list tools:`, error);
return [];
}
}
/**
* List prompts from a connection.
*/
static async listPrompts(connection: MCPConnection): Promise<Prompt[]> {
try {
const result = await connection.client.listPrompts();
return result.prompts ?? [];
} catch (error) {
console.warn(`[MCPService][${connection.serverName}] Failed to list prompts:`, error);
return [];
}
}
/**
* Get a specific prompt with arguments.
*/
static async getPrompt(
connection: MCPConnection,
name: string,
args?: Record<string, string>
): Promise<GetPromptResult> {
try {
return await connection.client.getPrompt({ name, arguments: args });
} catch (error) {
console.error(`[MCPService][${connection.serverName}] Failed to get prompt:`, error);
throw error;
}
}
/**
* Execute a tool call on a connection.
*/
@ -369,4 +404,38 @@ export class MCPService {
return JSON.stringify(content);
}
/**
*
*
* Completions Operations
*
*
*/
/**
* Request completion suggestions from a server.
* Used for autocompleting prompt arguments or resource URI templates.
*
* @param connection - The MCP connection to use
* @param ref - Reference to the prompt or resource template
* @param argument - The argument being completed (name and current value)
* @returns Completion result with suggested values
*/
static async complete(
connection: MCPConnection,
ref: { type: 'ref/prompt'; name: string } | { type: 'ref/resource'; uri: string },
argument: { name: string; value: string }
): Promise<{ values: string[]; total?: number; hasMore?: boolean } | null> {
try {
const result = await connection.client.complete({
ref,
argument
});
return result.completion;
} catch (error) {
console.error(`[MCPService] Failed to get completions:`, error);
return null;
}
}
}

View File

@ -20,7 +20,13 @@
*/
import { mcpClient } from '$lib/clients/mcp.client';
import type { HealthCheckState, MCPServerSettingsEntry, McpServerUsageStats } from '$lib/types';
import type {
HealthCheckState,
MCPServerSettingsEntry,
McpServerUsageStats,
MCPPromptInfo,
GetPromptResult
} from '$lib/types';
import type { McpServerOverride } from '$lib/types/database';
import { buildMcpClientConfig, parseMcpServerSettings, getMcpServerLabel } from '$lib/utils/mcp';
import { HealthCheckStatus } from '$lib/enums';
@ -179,7 +185,9 @@ class MCPStore {
const servers = this.getServers();
return servers.some((s) => {
const state = this.getHealthCheckState(s.id);
return state.status === HealthCheckStatus.Idle || state.status === HealthCheckStatus.Connecting;
return (
state.status === HealthCheckStatus.Idle || state.status === HealthCheckStatus.Connecting
);
});
}
@ -189,12 +197,12 @@ class MCPStore {
*/
getServersSorted(): MCPServerSettingsEntry[] {
const servers = this.getServers();
// Don't sort while any server is still loading - prevents UI jumping
if (this.isAnyServerLoading()) {
return servers;
}
// Sort alphabetically by display label once all health checks are done
return [...servers].sort((a, b) => {
const labelA = getMcpServerLabel(a, this.getHealthCheckState(a.id));
@ -286,6 +294,38 @@ class MCPStore {
stats[serverId] = (stats[serverId] || 0) + 1;
settingsStore.updateConfig('mcpServerUsageStats', JSON.stringify(stats));
}
/**
*
* Prompts
*
*/
/**
* Check if any connected server supports prompts
*/
hasPromptsSupport(): boolean {
return mcpClient.hasPromptsSupport();
}
/**
* Get all prompts from all connected servers
*/
async getAllPrompts(): Promise<MCPPromptInfo[]> {
return mcpClient.getAllPrompts();
}
/**
* Get a specific prompt by name from a server.
* Throws an error if the server is not found or prompt execution fails.
*/
async getPrompt(
serverName: string,
promptName: string,
args?: Record<string, string>
): Promise<GetPromptResult> {
return mcpClient.getPrompt(serverName, promptName, args);
}
}
export const mcpStore = new MCPStore();

View File

@ -6,6 +6,13 @@ export interface ChatUploadedFile {
file: File;
preview?: string;
textContent?: string;
mcpPrompt?: {
serverName: string;
promptName: string;
arguments?: Record<string, string>;
};
isLoading?: boolean;
loadError?: string;
}
export interface ChatAttachmentDisplayItem {
@ -14,6 +21,9 @@ export interface ChatAttachmentDisplayItem {
size?: number;
preview?: string;
isImage: boolean;
isMcpPrompt?: boolean;
isLoading?: boolean;
loadError?: string;
uploadedFile?: ChatUploadedFile;
attachment?: DatabaseMessageExtra;
attachmentIndex?: number;

View File

@ -52,11 +52,21 @@ export interface DatabaseMessageExtraTextFile {
content: string;
}
export interface DatabaseMessageExtraMcpPrompt {
type: AttachmentType.MCP_PROMPT;
name: string;
serverName: string;
promptName: string;
content: string;
arguments?: Record<string, string>;
}
export type DatabaseMessageExtra =
| DatabaseMessageExtraImageFile
| DatabaseMessageExtraTextFile
| DatabaseMessageExtraAudioFile
| DatabaseMessageExtraPdfFile
| DatabaseMessageExtraMcpPrompt
| DatabaseMessageExtraLegacyContext;
export interface DatabaseMessage {
@ -66,8 +76,8 @@ export interface DatabaseMessage {
timestamp: number;
role: ChatRole;
content: string;
parent: string;
thinking: string;
parent: string | null;
thinking?: string;
toolCalls?: string;
children: string[];
extra?: DatabaseMessageExtra[];

View File

@ -48,6 +48,7 @@ export type {
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile,
DatabaseMessageExtraLegacyContext,
DatabaseMessageExtraMcpPrompt,
DatabaseMessageExtraPdfFile,
DatabaseMessageExtraTextFile,
DatabaseMessageExtra,
@ -79,6 +80,7 @@ export type {
MCPServerInfo,
MCPCapabilitiesInfo,
MCPToolInfo,
MCPPromptInfo,
MCPConnectionDetails,
MCPPhaseCallback,
MCPConnection,
@ -94,4 +96,7 @@ export type {
ToolCallParams,
ToolExecutionResult,
Tool,
Prompt,
GetPromptResult,
PromptMessage
} from './mcp';

View File

@ -4,10 +4,13 @@ import type {
ServerCapabilities as SDKServerCapabilities,
Implementation as SDKImplementation,
Tool,
CallToolResult
CallToolResult,
Prompt,
GetPromptResult,
PromptMessage
} from '@modelcontextprotocol/sdk/types.js';
export type { Tool, CallToolResult };
export type { Tool, CallToolResult, Prompt, GetPromptResult, PromptMessage };
export type ClientCapabilities = SDKClientCapabilities;
export type ServerCapabilities = SDKServerCapabilities;
export type Implementation = SDKImplementation;
@ -64,6 +67,21 @@ export interface MCPToolInfo {
title?: string;
}
/**
* Prompt information for display
*/
export interface MCPPromptInfo {
name: string;
description?: string;
title?: string;
serverName: string;
arguments?: Array<{
name: string;
description?: string;
required?: boolean;
}>;
}
/**
* Full connection details for visualization
*/

View File

@ -1,4 +1,4 @@
import { FileTypeCategory } from '$lib/enums';
import { AttachmentType, FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory, getFileTypeCategoryByExtension, isImageFile } from '$lib/utils';
export interface AttachmentDisplayItemsOptions {
@ -6,6 +6,20 @@ export interface AttachmentDisplayItemsOptions {
attachments?: DatabaseMessageExtra[];
}
/**
* Check if an uploaded file is an MCP prompt
*/
function isMcpPromptUpload(file: ChatUploadedFile): boolean {
return file.type === 'mcp-prompt' && !!file.mcpPrompt;
}
/**
* Check if an attachment is an MCP prompt
*/
function isMcpPromptAttachment(attachment: DatabaseMessageExtra): boolean {
return attachment.type === AttachmentType.MCP_PROMPT;
}
/**
* Gets the file type category from an uploaded file, checking both MIME type and extension
*/
@ -37,6 +51,9 @@ export function getAttachmentDisplayItems(
size: file.size,
preview: file.preview,
isImage: getUploadedFileCategory(file) === FileTypeCategory.IMAGE,
isMcpPrompt: isMcpPromptUpload(file),
isLoading: file.isLoading,
loadError: file.loadError,
uploadedFile: file,
textContent: file.textContent
});
@ -45,12 +62,14 @@ export function getAttachmentDisplayItems(
// Add stored attachments (ChatMessage)
for (const [index, attachment] of attachments.entries()) {
const isImage = isImageFile(attachment);
const isMcpPrompt = isMcpPromptAttachment(attachment);
items.push({
id: `attachment-${index}`,
name: attachment.name,
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
isImage,
isMcpPrompt,
attachment,
attachmentIndex: index,
textContent: 'content' in attachment ? attachment.content : undefined

View File

@ -38,6 +38,18 @@ export async function parseFilesToMessageExtras(
const emptyFiles: string[] = [];
for (const file of files) {
if (file.type === 'mcp-prompt' && file.mcpPrompt) {
extras.push({
type: AttachmentType.MCP_PROMPT,
name: file.name,
serverName: file.mcpPrompt.serverName,
promptName: file.mcpPrompt.promptName,
content: file.textContent ?? '',
arguments: file.mcpPrompt.arguments
});
continue;
}
if (getFileTypeCategory(file.type) === FileTypeCategory.IMAGE) {
if (file.preview) {
let base64Url = file.preview;

View File

@ -253,6 +253,25 @@ export function buildMcpClientConfig(
};
}
/**
* Gets enabled MCP servers for a conversation based on per-chat overrides.
* Returns servers that are both globally enabled AND enabled for this chat.
* @param config - Global settings configuration
* @param perChatOverrides - Per-chat server overrides
* @returns Array of enabled server settings entries
*/
export function getEnabledServersForConversation(
config: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
if (!perChatOverrides?.length) {
return [];
}
const allServers = parseMcpServerSettings(config.mcpServers);
return allServers.filter((server) => checkServerEnabled(server, perChatOverrides));
}
/**
* Get the appropriate icon component for a log level
*