diff --git a/tools/server/webui/src/lib/clients/conversations.client.ts b/tools/server/webui/src/lib/clients/conversations.client.ts index cadaeb6780..0ae654697e 100644 --- a/tools/server/webui/src/lib/clients/conversations.client.ts +++ b/tools/server/webui/src/lib/clients/conversations.client.ts @@ -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); + }); + } + /** * * diff --git a/tools/server/webui/src/lib/clients/mcp.client.ts b/tools/server/webui/src/lib/clients/mcp.client.ts index 41b53f5576..6d685485fd 100644 --- a/tools/server/webui/src/lib/clients/mcp.client.ts +++ b/tools/server/webui/src/lib/clients/mcp.client.ts @@ -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 { + 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 + ): Promise { + 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 } + ); + } + /** * * diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte new file mode 100644 index 0000000000..b8b27067e8 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentMcpPrompt.svelte @@ -0,0 +1,271 @@ + + +
+
+
+
+
+ {#if getServerFavicon()} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} + + {getServerDisplayName()} +
+ +
+ {prompt.name} + + {#if isLoading} + Loading... + {/if} +
+ + {#if loadError} +

{loadError}

+ {/if} + + {#if hasContent} + + {/if} + + {#if hasArguments} +
+ + + {#if isEditingAllowed && !isEditingArguments && onArgumentsChange} + + {/if} +
+ {/if} +
+ + {#if !readonly && onRemove} + + {/if} +
+ + {#if showArguments && hasArguments} +
+ {#if isEditingArguments} +
+ {#each Object.entries(editedArguments) as [key, value] (key)} +
+ + 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" + /> +
+ {/each} + +
+ + +
+
+ {:else} +
+ {#each Object.entries(prompt.arguments ?? {}) as [key, value] (key)} +
+ {key}: + {value} +
+ {/each} +
+ {/if} +
+ {/if} +
+ + {#if showContent && hasContent} + + {#if currentConfig.renderUserContentAsMarkdown} +
+ +
+ {:else} + + {prompt.content} + + {/if} +
+ {/if} +
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte index a1f5af54e8..211144af9e 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte @@ -1,9 +1,15 @@ - - -
- + - -
- - - 0 || uploadedFiles.length > 0} - hasText={message.trim().length > 0} - {disabled} - {isLoading} - {isRecording} - {uploadedFiles} - onFileUpload={handleFileUpload} - onMicClick={handleMicClick} - onStop={handleStop} - onSystemPromptClick={onSystemPromptAdd} - /> -
- + diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte index b0471d2300..a55b3e8db1 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte @@ -1,5 +1,5 @@
@@ -165,8 +173,10 @@ {disabled} {hasAudioModality} {hasVisionModality} + {hasMcpPromptsSupport} {onFileUpload} {onSystemPromptClick} + {onMcpPromptClick} onMcpServersClick={() => (showMcpDialog = true)} /> @@ -187,9 +197,10 @@ {:else if shouldShowRecordButton} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormInputArea.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormInputArea.svelte new file mode 100644 index 0000000000..de4a73960a --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormInputArea.svelte @@ -0,0 +1,423 @@ + + + + +
+ + +
+ + +
+ { + handleInput(); + onValueChange?.(value); + }} + {disabled} + {placeholder} + /> + + 0} + {disabled} + {isLoading} + {isRecording} + {uploadedFiles} + onFileUpload={handleFileUpload} + onMicClick={handleMicClick} + {onStop} + {onSystemPromptClick} + onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined} + /> +
+
+
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker.svelte new file mode 100644 index 0000000000..172634bab5 --- /dev/null +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker.svelte @@ -0,0 +1,526 @@ + + +{#if isOpen} + +
+
+ {#if selectedPrompt} +
+ +
+ {#if getServerFavicon(selectedPrompt.serverName)} + { + (e.currentTarget as HTMLImageElement).style.display = 'none'; + }} + /> + {/if} +
+
+ {getServerLabel(selectedPrompt.serverName)} +
+
+ + {selectedPrompt.title || selectedPrompt.name} + + {#if selectedPrompt.arguments?.length} + + {selectedPrompt.arguments.length} arg{selectedPrompt.arguments.length > 1 + ? 's' + : ''} + + {/if} +
+ {#if selectedPrompt.description} +

+ {selectedPrompt.description} +

+ {/if} +
+
+ +
+ {#each selectedPrompt.arguments ?? [] as arg (arg.name)} +
+ + + 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} +
+ {#each suggestions[arg.name] ?? [] as suggestion, i (suggestion)} + + {/each} +
+ {/if} +
+ {/each} + + {#if promptError} + + {/if} + +
+ + + +
+
+
+ {:else} +
+ {#if showSearchInput} +
+ +
+ {/if} + +
+ {#if isLoading} +
+ Loading prompts... +
+ {:else if filteredPrompts.length === 0} +
+ {prompts.length === 0 ? 'No MCP prompts available' : 'No prompts found'} +
+ {:else} + {#each filteredPrompts as prompt, index (prompt.serverName + ':' + prompt.name)} + + {/each} + {/if} +
+
+ {/if} +
+
+{/if} diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte index 19b763f55e..f0855b9dbe 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte @@ -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} > diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte index fda89570f9..c8a8acb1ce 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte @@ -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} - 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 @@ - - -
- + { - 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} /> - -
- - -
- - -
- - {#if isRouter} - - {/if} - - -
-
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte index 092ee2cb9a..24979cf299 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte @@ -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} { - 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) { diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index 58918e052d..cf5fffc8c8 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -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'; diff --git a/tools/server/webui/src/lib/components/app/mcp/McpSettingsSection.svelte b/tools/server/webui/src/lib/components/app/mcp/McpSettingsSection.svelte index 908d5c63a1..ea194341cf 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpSettingsSection.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpSettingsSection.svelte @@ -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 @@
{/if} - {#if rawServers.length > 0} + {#if servers.length > 0}
- {#if isLoading} - - {#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} -
@@ -131,32 +131,26 @@
-
-
- -
- {/each} - {:else} - {#each servers as server (server.id)} + {:else} mcpStore.updateServer(server.id, updates)} onDelete={() => mcpStore.removeServer(server.id)} /> - {/each} - {/if} + {/if} + {/each}
{/if}
diff --git a/tools/server/webui/src/lib/components/app/misc/SearchableDropdownMenu.svelte b/tools/server/webui/src/lib/components/app/misc/SearchableDropdownMenu.svelte index 94ab8f320d..7b13da4f94 100644 --- a/tools/server/webui/src/lib/components/app/misc/SearchableDropdownMenu.svelte +++ b/tools/server/webui/src/lib/components/app/misc/SearchableDropdownMenu.svelte @@ -85,7 +85,7 @@ />
-
+
{@render children()} {#if isEmpty} diff --git a/tools/server/webui/src/lib/components/ui/button/button.svelte b/tools/server/webui/src/lib/components/ui/button/button.svelte index e1171b3286..d29358c8e0 100644 --- a/tools/server/webui/src/lib/components/ui/button/button.svelte +++ b/tools/server/webui/src/lib/components/ui/button/button.svelte @@ -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' }, diff --git a/tools/server/webui/src/lib/components/ui/switch/switch.svelte b/tools/server/webui/src/lib/components/ui/switch/switch.svelte index 5a5975e137..e0848790d3 100644 --- a/tools/server/webui/src/lib/components/ui/switch/switch.svelte +++ b/tools/server/webui/src/lib/components/ui/switch/switch.svelte @@ -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} diff --git a/tools/server/webui/src/lib/constants/css-classes.ts b/tools/server/webui/src/lib/constants/css-classes.ts index af2c5dd984..46076e55f6 100644 --- a/tools/server/webui/src/lib/constants/css-classes.ts +++ b/tools/server/webui/src/lib/constants/css-classes.ts @@ -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 diff --git a/tools/server/webui/src/lib/enums/attachment.ts b/tools/server/webui/src/lib/enums/attachment.ts index 7c7d0da994..1a3ad5dbbb 100644 --- a/tools/server/webui/src/lib/enums/attachment.ts +++ b/tools/server/webui/src/lib/enums/attachment.ts @@ -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 diff --git a/tools/server/webui/src/lib/services/chat.service.ts b/tools/server/webui/src/lib/services/chat.service.ts index 5bf8cd9a0e..bc7b3c05f1 100644 --- a/tools/server/webui/src/lib/services/chat.service.ts +++ b/tools/server/webui/src/lib/services/chat.service.ts @@ -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 diff --git a/tools/server/webui/src/lib/services/mcp.service.ts b/tools/server/webui/src/lib/services/mcp.service.ts index 8f0a7cd9e3..51226f5f9d 100644 --- a/tools/server/webui/src/lib/services/mcp.service.ts +++ b/tools/server/webui/src/lib/services/mcp.service.ts @@ -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 { 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 { + 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 + ): Promise { + 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; + } + } } diff --git a/tools/server/webui/src/lib/stores/mcp.svelte.ts b/tools/server/webui/src/lib/stores/mcp.svelte.ts index 2f296860ac..b14eb50fcf 100644 --- a/tools/server/webui/src/lib/stores/mcp.svelte.ts +++ b/tools/server/webui/src/lib/stores/mcp.svelte.ts @@ -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 { + 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 + ): Promise { + return mcpClient.getPrompt(serverName, promptName, args); + } } export const mcpStore = new MCPStore(); diff --git a/tools/server/webui/src/lib/types/chat.d.ts b/tools/server/webui/src/lib/types/chat.d.ts index c2730b9c73..f35aacaf07 100644 --- a/tools/server/webui/src/lib/types/chat.d.ts +++ b/tools/server/webui/src/lib/types/chat.d.ts @@ -6,6 +6,13 @@ export interface ChatUploadedFile { file: File; preview?: string; textContent?: string; + mcpPrompt?: { + serverName: string; + promptName: string; + arguments?: Record; + }; + 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; diff --git a/tools/server/webui/src/lib/types/database.d.ts b/tools/server/webui/src/lib/types/database.d.ts index ed2a6a6a12..52732229ca 100644 --- a/tools/server/webui/src/lib/types/database.d.ts +++ b/tools/server/webui/src/lib/types/database.d.ts @@ -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; +} + 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[]; diff --git a/tools/server/webui/src/lib/types/index.ts b/tools/server/webui/src/lib/types/index.ts index a09433b14e..59ab8988ad 100644 --- a/tools/server/webui/src/lib/types/index.ts +++ b/tools/server/webui/src/lib/types/index.ts @@ -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'; diff --git a/tools/server/webui/src/lib/types/mcp.d.ts b/tools/server/webui/src/lib/types/mcp.d.ts index 0017e4dba8..0869eca53f 100644 --- a/tools/server/webui/src/lib/types/mcp.d.ts +++ b/tools/server/webui/src/lib/types/mcp.d.ts @@ -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 */ diff --git a/tools/server/webui/src/lib/utils/attachment-display.ts b/tools/server/webui/src/lib/utils/attachment-display.ts index 750aaa38d7..1ca920bce7 100644 --- a/tools/server/webui/src/lib/utils/attachment-display.ts +++ b/tools/server/webui/src/lib/utils/attachment-display.ts @@ -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 diff --git a/tools/server/webui/src/lib/utils/convert-files-to-extra.ts b/tools/server/webui/src/lib/utils/convert-files-to-extra.ts index 6eb50f6dce..ac4810357a 100644 --- a/tools/server/webui/src/lib/utils/convert-files-to-extra.ts +++ b/tools/server/webui/src/lib/utils/convert-files-to-extra.ts @@ -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; diff --git a/tools/server/webui/src/lib/utils/mcp.ts b/tools/server/webui/src/lib/utils/mcp.ts index dad9009403..8f43a0c056 100644 --- a/tools/server/webui/src/lib/utils/mcp.ts +++ b/tools/server/webui/src/lib/utils/mcp.ts @@ -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 *