feat: Display and manage servers in ChatForm actions

This commit is contained in:
Aleksander Grygier 2026-01-02 19:37:41 +01:00
parent 74345d8785
commit 260375819d
2 changed files with 206 additions and 18 deletions

View File

@ -1,17 +1,20 @@
<script lang="ts">
import { Paperclip } from '@lucide/svelte';
import { Paperclip, Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import { FileTypeCategory } from '$lib/enums';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
showMcpOption?: boolean;
onFileUpload?: (fileType?: FileTypeCategory) => void;
onMcpClick?: () => void;
}
let {
@ -19,14 +22,12 @@
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
onFileUpload
showMcpOption = false,
onFileUpload,
onMcpClick
}: Props = $props();
const fileUploadTooltipText = $derived.by(() => {
return !hasVisionModality
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
: 'Attach files';
});
const fileUploadTooltipText = 'Add files or MCP servers';
function handleFileUpload(fileType?: FileTypeCategory) {
onFileUpload?.(fileType);
@ -43,9 +44,9 @@
{disabled}
type="button"
>
<span class="sr-only">Attach files</span>
<span class="sr-only">{fileUploadTooltipText}</span>
<Paperclip class="h-4 w-4" />
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
@ -123,6 +124,18 @@
</Tooltip.Content>
{/if}
</Tooltip.Root>
{#if showMcpOption}
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onMcpClick?.()}
>
<McpLogo style="width: 1rem; height: 1rem;" />
<span>MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>

View File

@ -1,20 +1,33 @@
<script lang="ts">
import { Square } from '@lucide/svelte';
import { onMount } from 'svelte';
import { Square, Settings, ChevronDown } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { cn } from '$lib/components/ui/utils';
import {
ChatFormActionFileAttachments,
ChatFormActionRecord,
ChatFormActionSubmit,
DialogMcpServersSettings,
ModelsSelector
} from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { config, settingsStore } 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, usedModalities } from '$lib/stores/conversations.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
import { parseMcpServerSettings } from '$lib/config/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import {
mcpGetHealthCheckState,
mcpHasHealthCheck,
mcpRunHealthCheck
} from '$lib/stores/mcp.svelte';
interface Props {
canSend?: boolean;
@ -161,16 +174,173 @@
}
}
});
let showMcpDialog = $state(false);
// MCP servers state
let mcpServers = $derived<MCPServerSettingsEntry[]>(
parseMcpServerSettings(currentConfig.mcpServers)
);
let enabledMcpServers = $derived(mcpServers.filter((s) => s.enabled && s.url.trim()));
// Filter out servers with health check errors
let healthyEnabledMcpServers = $derived(
enabledMcpServers.filter((s) => {
const healthState = mcpGetHealthCheckState(s.id);
return healthState.status !== 'error';
})
);
let hasEnabledMcpServers = $derived(enabledMcpServers.length > 0);
let hasMcpServers = $derived(mcpServers.length > 0);
// Count of extra servers beyond the 3 shown as favicons (excluding error servers)
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
// Toggle server enabled state
function toggleServer(serverId: string, enabled: boolean) {
const servers = mcpServers.map((s) => (s.id === serverId ? { ...s, enabled } : s));
settingsStore.updateConfig('mcpServers', JSON.stringify(servers));
}
// Get display name for server
function getServerDisplayName(server: MCPServerSettingsEntry): string {
if (server.name) return server.name;
try {
const url = new URL(server.url);
const host = url.hostname.replace(/^(www\.|mcp\.)/, '');
const name = host.split('.')[0] || 'Unknown';
return name.charAt(0).toUpperCase() + name.slice(1);
} catch {
return 'New Server';
}
}
// Get favicon URLs for enabled servers (max 3)
function getFaviconUrl(server: MCPServerSettingsEntry): string | null {
try {
const url = new URL(server.url);
const hostnameParts = url.hostname.split('.');
const rootDomain =
hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : url.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s) }))
.filter((f) => f.url !== null)
);
// Run health checks on mount if there are enabled servers
onMount(() => {
if (hasEnabledMcpServers) {
for (const server of enabledMcpServers) {
if (!mcpHasHealthCheck(server.id)) {
mcpRunHealthCheck(server);
}
}
}
});
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<ChatFormActionFileAttachments
class="mr-auto"
{disabled}
{hasAudioModality}
{hasVisionModality}
{onFileUpload}
/>
<div class="mr-auto flex items-center gap-1.5">
<ChatFormActionFileAttachments
{disabled}
{hasAudioModality}
{hasVisionModality}
showMcpOption={!hasMcpServers}
onMcpClick={() => (showMcpDialog = true)}
{onFileUpload}
/>
{#if hasMcpServers}
<DropdownMenu.Root>
<DropdownMenu.Trigger {disabled}>
<button
type="button"
class={cn(
'inline-flex cursor-pointer items-center rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
hasEnabledMcpServers ? 'text-foreground' : 'text-muted-foreground'
)}
{disabled}
aria-label="MCP Servers"
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
<span class="mx-1.5 font-medium"> MCP </span>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<img
src={favicon.url}
alt=""
class="h-3.5 w-3.5 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/each}
</div>
{#if hasEnabledMcpServers && extraServersCount > 0}
<span class="ml-1 text-muted-foreground">+{extraServersCount}</span>
{/if}
{/if}
<ChevronDown class="h-3 w-3.5" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-64">
<div class="max-h-64 overflow-y-auto">
{#each mcpServers as server (server.id)}
{@const healthState = mcpGetHealthCheckState(server.id)}
{@const hasError = healthState.status === 'error'}
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server)}
<img
src={getFaviconUrl(server)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerDisplayName(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{/if}
</div>
<Switch
checked={server.enabled}
onCheckedChange={(checked) => toggleServer(server.id, checked)}
disabled={hasError}
/>
</div>
{/each}
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => (showMcpDialog = true)}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
<ModelsSelector
{disabled}
@ -202,3 +372,8 @@
/>
{/if}
</div>
<DialogMcpServersSettings
bind:open={showMcpDialog}
onOpenChange={(open) => (showMcpDialog = open)}
/>