feat: Improve MCP Server selection UI + lazy load health checks
This commit is contained in:
parent
cafb9c09d3
commit
8a95ec3ea6
|
|
@ -494,6 +494,36 @@ export class MCPClient {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health checks for multiple servers that don't have a recent check.
|
||||
* Useful for lazy-loading health checks when UI is opened.
|
||||
* @param servers - Array of servers to check
|
||||
* @param skipIfChecked - If true, skip servers that already have a health check result
|
||||
*/
|
||||
async runHealthChecksForServers(
|
||||
servers: {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
}[],
|
||||
skipIfChecked = true
|
||||
): Promise<void> {
|
||||
const serversToCheck = skipIfChecked
|
||||
? servers.filter((s) => !mcpStore.hasHealthCheck(s.id) && s.url.trim())
|
||||
: servers.filter((s) => s.url.trim());
|
||||
|
||||
if (serversToCheck.length === 0) return;
|
||||
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
for (let i = 0; i < serversToCheck.length; i += BATCH_SIZE) {
|
||||
const batch = serversToCheck.slice(i, i + BATCH_SIZE);
|
||||
await Promise.all(batch.map((server) => this.runHealthCheck(server)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check for a specific server configuration.
|
||||
* Creates a temporary connection to test connectivity and list tools.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
hasVisionModality?: boolean;
|
||||
onFileUpload?: () => void;
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpServersClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -20,18 +22,26 @@
|
|||
hasAudioModality = false,
|
||||
hasVisionModality = false,
|
||||
onFileUpload,
|
||||
onSystemPromptClick
|
||||
onSystemPromptClick,
|
||||
onMcpServersClick
|
||||
}: Props = $props();
|
||||
|
||||
let dropdownOpen = $state(false);
|
||||
|
||||
function handleMcpServersClick() {
|
||||
dropdownOpen = false;
|
||||
onMcpServersClick?.();
|
||||
}
|
||||
|
||||
const fileUploadTooltipText = $derived.by(() => {
|
||||
return !hasVisionModality
|
||||
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
|
||||
: 'Attach files';
|
||||
: 'Add files, prompts and MCP Servers';
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Root bind:open={dropdownOpen}>
|
||||
<DropdownMenu.Trigger name="Attach files" {disabled}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
|
|
@ -67,7 +77,7 @@
|
|||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Content usePortal={false}>
|
||||
<p>Images require vision models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
|
|
@ -87,7 +97,7 @@
|
|||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasAudioModality}
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Content usePortal={false}>
|
||||
<p>Audio files require audio models to be processed</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
|
|
@ -115,7 +125,7 @@
|
|||
</Tooltip.Trigger>
|
||||
|
||||
{#if !hasVisionModality}
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Content usePortal={false}>
|
||||
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
|
|
@ -134,10 +144,28 @@
|
|||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Content usePortal={false}>
|
||||
<p>Add a custom system message for this conversation</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="w-full">
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpServersClick}
|
||||
>
|
||||
<McpLogo class="h-4 w-4" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content usePortal={false}>
|
||||
<p>Configure MCP servers for agentic tool execution</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
|
||||
interface Props {
|
||||
canSend?: boolean;
|
||||
|
|
@ -157,6 +158,7 @@
|
|||
}
|
||||
|
||||
let showMcpDialog = $state(false);
|
||||
let hasAvailableMcpServers = $derived(mcpStore.hasAvailableServers());
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
|
||||
|
|
@ -167,9 +169,12 @@
|
|||
{hasVisionModality}
|
||||
{onFileUpload}
|
||||
{onSystemPromptClick}
|
||||
onMcpServersClick={() => (showMcpDialog = true)}
|
||||
/>
|
||||
|
||||
<McpSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
|
||||
{#if hasAvailableMcpServers}
|
||||
<McpSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { McpLogo, McpSettingsSection } from '$lib/components/app';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
|
||||
interface Props {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
|
@ -10,6 +11,12 @@
|
|||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
mcpClient.runHealthChecksForServers(mcpStore.getServers());
|
||||
}
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
|
|
@ -18,26 +25,23 @@
|
|||
<Dialog.Root {open} onOpenChange={handleClose}>
|
||||
<Dialog.Content
|
||||
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
|
||||
md:h-[80vh] md:max-h-[80vh] md:min-h-0 md:rounded-lg"
|
||||
md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
|
||||
style="max-width: 56rem;"
|
||||
>
|
||||
<div class="border-b border-border/30 p-4 md:p-6">
|
||||
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
|
||||
<Dialog.Title class="inline-flex items-center text-lg font-semibold">
|
||||
<McpLogo class="mr-2 inline h-4 w-4" />
|
||||
|
||||
MCP Servers
|
||||
</Dialog.Title>
|
||||
|
||||
<Dialog.Description class="text-sm text-muted-foreground">
|
||||
Add and configure MCP servers to enable agentic tool execution capabilities.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-6">
|
||||
<McpSettingsSection />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-border/30 p-4 md:p-6">
|
||||
<Button onclick={handleClose}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@
|
|||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { mcpClient } from '$lib/clients';
|
||||
import { mcpClient } from '$lib/clients/mcp.client';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
|
|
@ -94,22 +93,18 @@
|
|||
await conversationsStore.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
function handleDropdownOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpClient.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, 3)
|
||||
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
|
||||
.filter((f) => f.url !== null)
|
||||
);
|
||||
|
||||
let serversWithUrls = $derived(mcpServers.filter((s) => s.url.trim()));
|
||||
|
||||
onMount(() => {
|
||||
for (const server of serversWithUrls) {
|
||||
if (!mcpStore.hasHealthCheck(server.id)) {
|
||||
mcpClient.runHealthCheck(server);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if hasMcpServers}
|
||||
|
|
@ -119,6 +114,7 @@
|
|||
emptyMessage="No servers found"
|
||||
isEmpty={filteredMcpServers().length === 0}
|
||||
{disabled}
|
||||
onOpenChange={handleDropdownOpen}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import type { MCPServerSettingsEntry, HealthCheckState } from '$lib/types';
|
||||
import { getMcpServerLabel } from '$lib/utils/mcp';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
|
|
@ -26,9 +27,11 @@
|
|||
|
||||
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
||||
let displayName = $derived(getMcpServerLabel(server, healthState));
|
||||
let isIdle = $derived(healthState.status === HealthCheckStatus.Idle);
|
||||
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.Connecting);
|
||||
let isConnected = $derived(healthState.status === HealthCheckStatus.Success);
|
||||
let isError = $derived(healthState.status === HealthCheckStatus.Error);
|
||||
let showSkeleton = $derived(isIdle || isHealthChecking);
|
||||
let errorMessage = $derived(
|
||||
healthState.status === HealthCheckStatus.Error ? healthState.message : undefined
|
||||
);
|
||||
|
|
@ -65,12 +68,6 @@
|
|||
let showDeleteDialog = $state(false);
|
||||
let editFormRef: McpServerCardEditForm | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
if (!mcpStore.hasHealthCheck(server.id) && server.enabled && server.url.trim()) {
|
||||
mcpClient.runHealthCheck(server);
|
||||
}
|
||||
});
|
||||
|
||||
function handleHealthCheck() {
|
||||
mcpClient.runHealthCheck(server);
|
||||
}
|
||||
|
|
@ -137,21 +134,44 @@
|
|||
{/if}
|
||||
|
||||
<div class="grid gap-3">
|
||||
{#if isConnected && instructions}
|
||||
<McpServerInfo {instructions} />
|
||||
{/if}
|
||||
{#if showSkeleton}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
<Skeleton class="h-3 w-24" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Skeleton class="h-5 w-16 rounded-full" />
|
||||
<Skeleton class="h-5 w-20 rounded-full" />
|
||||
<Skeleton class="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tools.length > 0}
|
||||
<McpServerCardToolsList {tools} />
|
||||
{/if}
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4 rounded" />
|
||||
<Skeleton class="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if isConnected && instructions}
|
||||
<McpServerInfo {instructions} />
|
||||
{/if}
|
||||
|
||||
{#if connectionLogs.length > 0}
|
||||
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
|
||||
{#if tools.length > 0}
|
||||
<McpServerCardToolsList {tools} />
|
||||
{/if}
|
||||
|
||||
{#if connectionLogs.length > 0}
|
||||
<McpConnectionLogs logs={connectionLogs} {connectionTimeMs} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-4">
|
||||
{#if protocolVersion}
|
||||
{#if showSkeleton}
|
||||
<Skeleton class="h-3 w-28" />
|
||||
{:else if protocolVersion}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-[10px] text-muted-foreground">
|
||||
Protocol version: {protocolVersion}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold">Manage Servers</h4>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,22 +9,28 @@
|
|||
side = 'top',
|
||||
children,
|
||||
arrowClasses,
|
||||
usePortal = true,
|
||||
...restProps
|
||||
}: TooltipPrimitive.ContentProps & {
|
||||
arrowClasses?: string;
|
||||
usePortal?: boolean;
|
||||
} = $props();
|
||||
|
||||
const contentClass = $derived(
|
||||
cn(
|
||||
'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
className
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Portal>
|
||||
{#snippet tooltipContent()}
|
||||
<TooltipPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tooltip-content"
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
'z-50 w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
class={contentClass}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
|
@ -44,4 +50,12 @@
|
|||
{/snippet}
|
||||
</TooltipPrimitive.Arrow>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
{/snippet}
|
||||
|
||||
{#if usePortal}
|
||||
<TooltipPrimitive.Portal>
|
||||
{@render tooltipContent()}
|
||||
</TooltipPrimitive.Portal>
|
||||
{:else}
|
||||
{@render tooltipContent()}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class MCPStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update health check state from MCPClient
|
||||
* Update health check state from MCPClient.
|
||||
*/
|
||||
updateHealthCheck(serverId: string, state: HealthCheckState): void {
|
||||
this._healthChecks = { ...this._healthChecks, [serverId]: state };
|
||||
|
|
@ -136,7 +136,7 @@ class MCPStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clear health check state for a specific server
|
||||
* Clear health check state for a specific server.
|
||||
*/
|
||||
clearHealthCheck(serverId: string): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
|
@ -145,7 +145,7 @@ class MCPStore {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clear all health check states
|
||||
* Clear all health check states.
|
||||
*/
|
||||
clearAllHealthChecks(): void {
|
||||
this._healthChecks = {};
|
||||
|
|
|
|||
Loading…
Reference in New Issue