ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)
* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
co-authored by
Xuan Son Nguyen
parent
49d1701bd2
commit
59778f0196
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
|
||||
import McpLogo from './McpLogo.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { class: className = '', onclick }: Props = $props();
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
|
||||
let enabledMcpServersForChat = $derived(
|
||||
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
|
||||
);
|
||||
let healthyEnabledMcpServers = $derived(
|
||||
enabledMcpServersForChat.filter((s) => {
|
||||
const healthState = mcpStore.getHealthCheckState(s.id);
|
||||
return healthState.status !== HealthCheckStatus.ERROR;
|
||||
})
|
||||
);
|
||||
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
|
||||
let extraServersCount = $derived(
|
||||
Math.max(0, healthyEnabledMcpServers.length - MAX_DISPLAYED_MCP_AVATARS)
|
||||
);
|
||||
let mcpFavicons = $derived(
|
||||
healthyEnabledMcpServers
|
||||
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name: mcpStore.getServerDisplayName(s.id),
|
||||
url: mcpStore.getServerFavicon(s.id)
|
||||
}))
|
||||
.filter((f) => f.url !== null)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !hasEnabledMcpServers}
|
||||
<button
|
||||
class={[
|
||||
'inline-flex cursor-pointer items-center gap-0.75 opacity-70 transition-opacity hover:opacity-100',
|
||||
className,
|
||||
'opacity-50 hover:opacity-100'
|
||||
]}
|
||||
{onclick}
|
||||
>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<McpLogo class="h-4 w-4" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>MCP Servers</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</button>
|
||||
{:else if mcpFavicons.length > 0}
|
||||
<button class={['inline-flex items-center gap-0.75', className]} {onclick}>
|
||||
<div class="flex -space-x-1">
|
||||
{#each mcpFavicons as favicon (favicon.id)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
|
||||
<img
|
||||
src={favicon.url}
|
||||
alt=""
|
||||
class="h-4 w-4"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{favicon.name}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if extraServersCount > 0}
|
||||
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { Wrench, Database, MessageSquare, FileText, Sparkles, ListChecks } from '@lucide/svelte';
|
||||
import type { MCPCapabilitiesInfo } from '$lib/types';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Props {
|
||||
capabilities?: MCPCapabilitiesInfo;
|
||||
}
|
||||
|
||||
let { capabilities }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if capabilities}
|
||||
{#if capabilities.server.tools}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-green-50 px-1.5 text-[10px] dark:bg-green-950">
|
||||
<Wrench class="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
|
||||
Tools
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.resources}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950">
|
||||
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
|
||||
Resources
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.prompts}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-purple-50 px-1.5 text-[10px] dark:bg-purple-950">
|
||||
<MessageSquare class="h-3 w-3 text-purple-600 dark:text-purple-400" />
|
||||
|
||||
Prompts
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.logging}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-orange-50 px-1.5 text-[10px] dark:bg-orange-950">
|
||||
<FileText class="h-3 w-3 text-orange-600 dark:text-orange-400" />
|
||||
|
||||
Logging
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.completions}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-cyan-50 px-1.5 text-[10px] dark:bg-cyan-950">
|
||||
<Sparkles class="h-3 w-3 text-cyan-600 dark:text-cyan-400" />
|
||||
|
||||
Completions
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.tasks}
|
||||
<Badge variant="outline" class="h-5 gap-1 bg-pink-50 px-1.5 text-[10px] dark:bg-pink-950">
|
||||
<ListChecks class="h-3 w-3 text-pink-600 dark:text-pink-400" />
|
||||
|
||||
Tasks
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import type { MCPConnectionLog } from '$lib/types';
|
||||
import { formatTime, getMcpLogLevelIcon, getMcpLogLevelClass } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
logs: MCPConnectionLog[];
|
||||
connectionTimeMs?: number;
|
||||
defaultExpanded?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { logs, connectionTimeMs, defaultExpanded = false, class: className }: Props = $props();
|
||||
|
||||
let isExpanded = $derived(defaultExpanded);
|
||||
|
||||
function formatLogDetails(details: unknown): string {
|
||||
if (details == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(details, null, 2);
|
||||
} catch {
|
||||
return String(details);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if logs.length > 0}
|
||||
<Collapsible.Root bind:open={isExpanded} class={className}>
|
||||
<div class="space-y-2">
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span>Connection Log ({logs.length})</span>
|
||||
|
||||
{#if connectionTimeMs !== undefined}
|
||||
<span class="ml-1">· Connected in {connectionTimeMs}ms</span>
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
|
||||
<Collapsible.Content class="mt-2">
|
||||
<div
|
||||
class="max-h-64 space-y-0.5 overflow-y-auto rounded bg-muted/50 p-2 font-mono text-[10px]"
|
||||
>
|
||||
{#each logs as log (log.timestamp.getTime() + log.message)}
|
||||
{@const IconComponent = getMcpLogLevelIcon(log.level)}
|
||||
|
||||
<div class={['flex items-start gap-1.5', getMcpLogLevelClass(log.level)]}>
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
|
||||
<IconComponent class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
|
||||
<span class="break-all">{log.message}</span>
|
||||
</div>
|
||||
|
||||
{#if log.details !== undefined}
|
||||
<details class="ml-11">
|
||||
<summary class="cursor-pointer text-[10px] text-muted-foreground"> details </summary>
|
||||
|
||||
<pre
|
||||
class="mt-1 overflow-x-auto rounded bg-background/70 p-2 text-[10px] break-all whitespace-pre-wrap text-foreground/80">
|
||||
{formatLogDetails(log.details)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,111 @@
|
||||
<script>
|
||||
let { class: className = '', style = '' } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
{style}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 174 174"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a8e498184"
|
||||
><defs
|
||||
><clipPath
|
||||
id="frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1"
|
||||
class="frame-clip frame-clip-def"
|
||||
><rect
|
||||
rx="0"
|
||||
ry="0"
|
||||
x="0"
|
||||
y="0"
|
||||
width="174.00000000000045"
|
||||
height="174"
|
||||
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
|
||||
/></clipPath
|
||||
></defs
|
||||
><g class="frame-container-wrapper"
|
||||
><g class="frame-container-blur"
|
||||
><g class="frame-container-shadows"
|
||||
><g clip-path="url(#frame-clip-320b5b95-d08d-8089-8007-585a8e498184-render-1)" fill="none"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a8e498184"
|
||||
><rect
|
||||
rx="0"
|
||||
ry="0"
|
||||
x="0"
|
||||
y="0"
|
||||
width="174.00000000000045"
|
||||
height="174"
|
||||
transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)"
|
||||
class="frame-background"
|
||||
/></g
|
||||
><g class="frame-children"
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a974337b1"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b1"
|
||||
><path
|
||||
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
style="fill: none;"
|
||||
/></g
|
||||
><g
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
id="strokes-b954dcef-3e3e-8015-8007-585acd4382b6-320b5b95-d08d-8089-8007-585a974337b1"
|
||||
class="strokes"
|
||||
><g class="stroke-shape"
|
||||
><path
|
||||
d="M15.5587158203125,81.5927734375L83.44091796875,13.7105712890625C92.813720703125,4.3380126953125,108.0096435546875,4.3380126953125,117.3817138671875,13.7105712890625L117.3817138671875,13.7105712890625C126.7547607421875,23.08306884765625,126.7547607421875,38.27911376953125,117.3817138671875,47.65167236328125L66.1168212890625,98.9169921875"
|
||||
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a974337b2"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b2"
|
||||
><path
|
||||
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
style="fill: none;"
|
||||
/></g
|
||||
><g
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
id="strokes-b954dcef-3e3e-8015-8007-585acd447743-320b5b95-d08d-8089-8007-585a974337b2"
|
||||
class="strokes"
|
||||
><g class="stroke-shape"
|
||||
><path
|
||||
d="M66.5587158203125,98.26885986328125L117.1165771484375,47.7105712890625C126.489501953125,38.3380126953125,141.6854248046875,38.3380126953125,151.0584716796875,47.7105712890625L151.4114990234375,48.0640869140625C160.7845458984375,57.43670654296875,160.7845458984375,72.6326904296875,151.4114990234375,82.00518798828125L90.018310546875,143.39886474609375C86.8941650390625,146.52288818359375,86.8941650390625,151.587890625,90.018310546875,154.71185302734375L102.62451171875,167.31890869140625"
|
||||
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
><g id="shape-320b5b95-d08d-8089-8007-585a974337b3"
|
||||
><g class="fills" id="fills-320b5b95-d08d-8089-8007-585a974337b3"
|
||||
><path
|
||||
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
style="fill: none;"
|
||||
/></g
|
||||
><g
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
id="strokes-b954dcef-3e3e-8015-8007-585acd44c5c9-320b5b95-d08d-8089-8007-585a974337b3"
|
||||
class="strokes"
|
||||
><g class="stroke-shape"
|
||||
><path
|
||||
d="M99.79296875,30.68115234375L49.588134765625,80.8857421875C40.215576171875,90.258056640625,40.215576171875,105.45404052734375,49.588134765625,114.82708740234375L49.588134765625,114.82708740234375C58.9608154296875,124.19903564453125,74.1566162109375,124.19903564453125,83.529296875,114.82708740234375L133.7340087890625,64.62225341796875"
|
||||
style="fill: none; stroke-width: 12; stroke: currentColor; stroke-opacity: 1;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></g
|
||||
></svg
|
||||
>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { FileText, Loader2, AlertCircle, Download } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
isImageMimeType,
|
||||
createBase64DataUrl,
|
||||
getResourceTextContent,
|
||||
getResourceBlobContent,
|
||||
downloadResourceContent
|
||||
} from '$lib/utils';
|
||||
import { MimeTypeApplication, MimeTypeText } from '$lib/enums';
|
||||
import { ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
resource: MCPResourceInfo | null;
|
||||
/** Pre-loaded content (e.g., from template resolution). Skips store fetch when provided. */
|
||||
preloadedContent?: MCPResourceContent[] | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { resource, preloadedContent, class: className }: Props = $props();
|
||||
|
||||
let content = $state<MCPResourceContent[] | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (resource) {
|
||||
if (preloadedContent) {
|
||||
content = preloadedContent;
|
||||
isLoading = false;
|
||||
error = null;
|
||||
} else {
|
||||
loadContent(resource.uri);
|
||||
}
|
||||
} else {
|
||||
content = null;
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadContent(uri: string) {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await mcpStore.readResource(uri);
|
||||
if (result) {
|
||||
content = result;
|
||||
} else {
|
||||
error = 'Failed to load resource content';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const text = getResourceTextContent(content);
|
||||
if (!text || !resource) return;
|
||||
downloadResourceContent(
|
||||
text,
|
||||
resource.mimeType || MimeTypeText.PLAIN,
|
||||
resource.name || 'resource.txt'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['flex flex-col gap-3', className]}>
|
||||
{#if !resource}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<FileText class="h-8 w-8 opacity-50" />
|
||||
|
||||
<span class="text-sm">Select a resource to preview</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate font-medium">{resource.title || resource.name}</h3>
|
||||
|
||||
<p class="truncate text-xs text-muted-foreground">{resource.uri}</p>
|
||||
|
||||
{#if resource.description}
|
||||
<p class="mt-1 text-sm text-muted-foreground">{resource.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<ActionIconCopyToClipboard
|
||||
text={getResourceTextContent(content)}
|
||||
canCopy={!isLoading && !!getResourceTextContent(content)}
|
||||
ariaLabel="Copy content"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleDownload}
|
||||
disabled={isLoading || !getResourceTextContent(content)}
|
||||
title="Download content"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3 break-all">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center gap-2 py-8 text-red-500">
|
||||
<AlertCircle class="h-6 w-6" />
|
||||
|
||||
<span class="text-sm">{error}</span>
|
||||
</div>
|
||||
{:else if content}
|
||||
{@const textContent = getResourceTextContent(content)}
|
||||
{@const blobContent = getResourceBlobContent(content)}
|
||||
|
||||
{#if textContent}
|
||||
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
|
||||
{/if}
|
||||
|
||||
{#each blobContent as blob (blob.uri)}
|
||||
{#if isImageMimeType(blob.mimeType ?? MimeTypeApplication.OCTET_STREAM)}
|
||||
<img
|
||||
src={createBase64DataUrl(
|
||||
blob.mimeType ?? MimeTypeApplication.OCTET_STREAM,
|
||||
blob.blob
|
||||
)}
|
||||
alt="Resource content"
|
||||
class="max-w-full rounded"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 rounded bg-muted p-2 text-sm text-muted-foreground">
|
||||
<FileText class="h-4 w-4" />
|
||||
|
||||
<span>Binary content ({blob.mimeType || 'unknown type'})</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !textContent && blobContent.length === 0}
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">No content available</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if resource.mimeType || resource.annotations}
|
||||
<div class="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{#if resource.mimeType}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">{resource.mimeType}</span>
|
||||
{/if}
|
||||
|
||||
{#if resource.annotations?.priority !== undefined}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">
|
||||
Priority: {resource.annotations.priority}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="rounded bg-muted px-1.5 py-0.5">
|
||||
Server: {resource.serverName}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { InputWithSuggestions } from '$lib/components/app';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { MIN_AUTOCOMPLETE_INPUT_LENGTH } from '$lib/constants';
|
||||
import type { MCPResourceTemplateInfo } from '$lib/types';
|
||||
import {
|
||||
debounce,
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
isTemplateComplete
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
template: MCPResourceTemplateInfo;
|
||||
onResolve: (uri: string, serverName: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { template, onResolve, onCancel }: Props = $props();
|
||||
|
||||
const variables = $derived(extractTemplateVariables(template.uriTemplate));
|
||||
|
||||
let values = $state<Record<string, string>>({});
|
||||
let suggestions = $state<Record<string, string[]>>({});
|
||||
let loadingSuggestions = $state<Record<string, boolean>>({});
|
||||
let activeAutocomplete = $state<string | null>(null);
|
||||
let autocompleteIndex = $state(0);
|
||||
|
||||
const expandedUri = $derived(expandTemplate(template.uriTemplate, values));
|
||||
const isComplete = $derived(isTemplateComplete(template.uriTemplate, values));
|
||||
|
||||
const fetchCompletions = debounce(async (argName: string, value: string) => {
|
||||
if (value.length < 1) {
|
||||
suggestions[argName] = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loadingSuggestions[argName] = true;
|
||||
|
||||
try {
|
||||
const result = await mcpStore.getResourceCompletions(
|
||||
template.serverName,
|
||||
template.uriTemplate,
|
||||
argName,
|
||||
value
|
||||
);
|
||||
|
||||
if (result && result.values.length > 0) {
|
||||
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('[McpResourceTemplateForm] Failed to fetch completions:', error);
|
||||
suggestions[argName] = [];
|
||||
} finally {
|
||||
loadingSuggestions[argName] = false;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
function handleArgInput(argName: string, value: string) {
|
||||
values[argName] = value;
|
||||
fetchCompletions(argName, value);
|
||||
}
|
||||
|
||||
function selectSuggestion(argName: string, value: string) {
|
||||
values[argName] = value;
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
|
||||
function handleArgKeydown(event: KeyboardEvent, argName: string) {
|
||||
const argSuggestions = suggestions[argName] ?? [];
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (argSuggestions.length > 0 && activeAutocomplete === argName) {
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (argSuggestions.length === 0 || activeAutocomplete !== argName) return;
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, 0);
|
||||
} else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectSuggestion(argName, argSuggestions[autocompleteIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleArgBlur(argName: string) {
|
||||
setTimeout(() => {
|
||||
if (activeAutocomplete === argName) {
|
||||
suggestions[argName] = [];
|
||||
activeAutocomplete = null;
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function handleArgFocus(argName: string) {
|
||||
const value = values[argName] ?? '';
|
||||
|
||||
if (value.length >= MIN_AUTOCOMPLETE_INPUT_LENGTH) {
|
||||
fetchCompletions(argName, value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isComplete) {
|
||||
onResolve(expandedUri, template.serverName);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-3">
|
||||
{#each variables as variable (variable.name)}
|
||||
<InputWithSuggestions
|
||||
name={variable.name}
|
||||
value={values[variable.name] ?? ''}
|
||||
suggestions={suggestions[variable.name] ?? []}
|
||||
isLoadingSuggestions={loadingSuggestions[variable.name] ?? false}
|
||||
isAutocompleteActive={activeAutocomplete === variable.name}
|
||||
autocompleteIndex={activeAutocomplete === variable.name ? autocompleteIndex : 0}
|
||||
onInput={(value) => handleArgInput(variable.name, value)}
|
||||
onKeydown={(e) => handleArgKeydown(e, variable.name)}
|
||||
onBlur={() => handleArgBlur(variable.name)}
|
||||
onFocus={() => handleArgFocus(variable.name)}
|
||||
onSelectSuggestion={(value) => selectSuggestion(variable.name, value)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if isComplete}
|
||||
<div class="rounded-md bg-muted/50 px-3 py-2">
|
||||
<p class="text-xs text-muted-foreground">Resolved URI:</p>
|
||||
|
||||
<p class="mt-0.5 font-mono text-xs break-all">{expandedUri}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" size="sm" variant="secondary" onclick={onCancel}>Cancel</Button>
|
||||
|
||||
<Button size="sm" type="submit" disabled={!isComplete}>Read Resource</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResources, mcpResourcesLoading } from '$lib/stores/mcp-resources.svelte';
|
||||
import type { MCPServerResources, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { parseResourcePath } from '$lib/utils';
|
||||
import McpResourcesBrowserHeader from './McpResourcesBrowserHeader.svelte';
|
||||
import McpResourcesBrowserEmptyState from './McpResourcesBrowserEmptyState.svelte';
|
||||
import McpResourcesBrowserServerItem from './McpResourcesBrowserServerItem.svelte';
|
||||
|
||||
interface Props {
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
|
||||
selectedUris?: Set<string>;
|
||||
selectedTemplateUri?: string | null;
|
||||
expandToUri?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onToggle,
|
||||
onTemplateSelect,
|
||||
selectedUris = new Set(),
|
||||
selectedTemplateUri,
|
||||
expandToUri,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let expandedServers = new SvelteSet<string>();
|
||||
let expandedFolders = new SvelteSet<string>();
|
||||
let searchQuery = $state('');
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
const filteredResources = $derived.by(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return resources;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = new SvelteMap();
|
||||
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const filteredResources = serverRes.resources.filter((r) => {
|
||||
return (
|
||||
r.title?.toLowerCase().includes(query) ||
|
||||
r.uri.toLowerCase().includes(query) ||
|
||||
serverName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const filteredTemplates = serverRes.templates.filter((t) => {
|
||||
return (
|
||||
t.name?.toLowerCase().includes(query) ||
|
||||
t.title?.toLowerCase().includes(query) ||
|
||||
t.uriTemplate.toLowerCase().includes(query) ||
|
||||
serverName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
if (filteredResources.length > 0 || filteredTemplates.length > 0 || query.trim()) {
|
||||
filtered.set(serverName, {
|
||||
...serverRes,
|
||||
resources: filteredResources,
|
||||
templates: filteredTemplates
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0) {
|
||||
autoExpandToResource(expandToUri);
|
||||
}
|
||||
});
|
||||
|
||||
function autoExpandToResource(uri: string) {
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const resource = serverRes.resources.find((r) => r.uri === uri);
|
||||
if (resource) {
|
||||
expandedServers.add(serverName);
|
||||
|
||||
const pathParts = parseResourcePath(uri);
|
||||
if (pathParts.length > 1) {
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${pathParts[i]}`;
|
||||
const folderId = `${serverName}:${currentPath}`;
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleServer(serverName: string) {
|
||||
if (expandedServers.has(serverName)) {
|
||||
expandedServers.delete(serverName);
|
||||
} else {
|
||||
expandedServers.add(serverName);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFolder(folderId: string) {
|
||||
if (expandedFolders.has(folderId)) {
|
||||
expandedFolders.delete(folderId);
|
||||
} else {
|
||||
expandedFolders.add(folderId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
mcpStore.fetchAllResources();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['flex flex-col gap-2', className]}>
|
||||
<McpResourcesBrowserHeader
|
||||
{isLoading}
|
||||
onRefresh={handleRefresh}
|
||||
onSearch={(q) => (searchQuery = q)}
|
||||
{searchQuery}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if filteredResources.size === 0}
|
||||
<McpResourcesBrowserEmptyState {isLoading} />
|
||||
{:else}
|
||||
{#each [...filteredResources.entries()] as [serverName, serverRes] (serverName)}
|
||||
<McpResourcesBrowserServerItem
|
||||
serverName={serverName as string}
|
||||
serverRes={serverRes as MCPServerResources}
|
||||
isExpanded={expandedServers.has(serverName as string)}
|
||||
{selectedUris}
|
||||
{selectedTemplateUri}
|
||||
{expandedFolders}
|
||||
onToggleServer={() => toggleServer(serverName as string)}
|
||||
onToggleFolder={toggleFolder}
|
||||
{onSelect}
|
||||
{onToggle}
|
||||
{onTemplateSelect}
|
||||
{searchQuery}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
let { isLoading }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">
|
||||
{#if isLoading}
|
||||
Loading resources...
|
||||
{:else}
|
||||
No resources available
|
||||
{/if}
|
||||
</div>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw, Loader2 } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { SearchInput } from '$lib/components/app/forms';
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
let { isLoading, onRefresh, onSearch, searchQuery = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="mb-2 flex items-center gap-4">
|
||||
<SearchInput
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onInput={(value) => onSearch?.(value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={onRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
</div>
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, ChevronDown, ChevronRight, Loader2, Braces } from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceInfo, MCPResourceTemplateInfo, MCPServerResources } from '$lib/types';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
type ResourceTreeNode,
|
||||
buildResourceTree,
|
||||
countTreeResources,
|
||||
sortTreeChildren
|
||||
} from './mcp-resources-browser';
|
||||
import { getDisplayName, getResourceIcon } from '$lib/utils';
|
||||
import { McpServerIdentity } from '$lib/components/app/mcp';
|
||||
|
||||
interface Props {
|
||||
serverName: string;
|
||||
serverRes: MCPServerResources;
|
||||
isExpanded: boolean;
|
||||
selectedUris: Set<string>;
|
||||
selectedTemplateUri?: string | null;
|
||||
expandedFolders: SvelteSet<string>;
|
||||
onToggleServer: () => void;
|
||||
onToggleFolder: (folderId: string) => void;
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onTemplateSelect?: (template: MCPResourceTemplateInfo) => void;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
serverName,
|
||||
serverRes,
|
||||
isExpanded,
|
||||
selectedUris,
|
||||
selectedTemplateUri,
|
||||
expandedFolders,
|
||||
onToggleServer,
|
||||
onToggleFolder,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onTemplateSelect,
|
||||
searchQuery = ''
|
||||
}: Props = $props();
|
||||
|
||||
let serverDisplayName = $derived(mcpStore.getServerDisplayName(serverName));
|
||||
let serverFaviconUrl = $derived(mcpStore.getServerFavicon(serverName));
|
||||
|
||||
const hasResources = $derived(serverRes.resources.length > 0);
|
||||
const hasTemplates = $derived(serverRes.templates.length > 0);
|
||||
const hasContent = $derived(hasResources || hasTemplates);
|
||||
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
|
||||
|
||||
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
|
||||
serverRes.templates.map((t) => ({
|
||||
uriTemplate: t.uriTemplate,
|
||||
name: t.name,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
mimeType: t.mimeType,
|
||||
serverName,
|
||||
annotations: t.annotations,
|
||||
icons: t.icons
|
||||
}))
|
||||
);
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
|
||||
onToggle?.(resource, checked);
|
||||
}
|
||||
|
||||
function isResourceSelected(resource: MCPResourceInfo): boolean {
|
||||
return selectedUris.has(resource.uri);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderTreeNode(node: ResourceTreeNode, depth: number, parentPath: string)}
|
||||
{@const isFolder = !node.resource && node.children.size > 0}
|
||||
{@const folderId = `${serverName}:${parentPath}/${node.name}`}
|
||||
{@const isFolderExpanded = expandedFolders.has(folderId)}
|
||||
|
||||
{#if isFolder}
|
||||
{@const folderCount = countTreeResources(node)}
|
||||
<Collapsible.Root open={isFolderExpanded} onOpenChange={() => onToggleFolder(folderId)}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isFolderExpanded}
|
||||
<ChevronDown class="h-3 w-3" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
<FolderOpen class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
<span class="font-medium">{node.name}</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">({folderCount})</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#each sortTreeChildren( [...node.children.values()] ) as child (child.resource?.uri || `${serverName}:${parentPath}/${node.name}/${child.name}`)}
|
||||
{@render renderTreeNode(child, depth + 1, `${parentPath}/${node.name}`)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else if node.resource}
|
||||
{@const resource = node.resource}
|
||||
{@const ResourceIcon = getResourceIcon(resource.mimeType, resource.uri)}
|
||||
{@const isSelected = isResourceSelected(resource)}
|
||||
{@const resourceDisplayName = resource.title || getDisplayName(node.name)}
|
||||
|
||||
<div class="group flex w-full items-center gap-2">
|
||||
{#if onToggle}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked: boolean | 'indeterminate') =>
|
||||
handleCheckboxChange(resource, checked === true)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class={[
|
||||
'flex flex-1 items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
isSelected && 'bg-muted'
|
||||
]}
|
||||
onclick={(e: MouseEvent) => handleResourceClick(resource, e)}
|
||||
title={resourceDisplayName}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-left">
|
||||
{resourceDisplayName}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Collapsible.Root open={isExpanded} onOpenChange={onToggleServer}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span class="inline-flex flex-col items-start gap-1 text-left">
|
||||
<div class="inline-flex min-w-0 items-center gap-1.5">
|
||||
<McpServerIdentity
|
||||
displayName={serverDisplayName}
|
||||
faviconUrl={serverFaviconUrl}
|
||||
iconClass="h-4 w-4"
|
||||
showVersion={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({serverRes.resources.length} resource{serverRes.resources.length !== 1
|
||||
? 's'
|
||||
: ''}{#if hasTemplates}, {serverRes.templates.length} template{serverRes.templates
|
||||
.length !== 1
|
||||
? 's'
|
||||
: ''}{/if})
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if serverRes.loading}
|
||||
<Loader2 class="ml-auto h-3 w-3 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
|
||||
{#if serverRes.error}
|
||||
<div class="py-1 text-xs text-red-500">
|
||||
Error: {serverRes.error}
|
||||
</div>
|
||||
{:else if !hasContent}
|
||||
<div class="py-1 text-xs text-muted-foreground">No resources</div>
|
||||
{:else}
|
||||
{#if hasResources}
|
||||
{#each sortTreeChildren( [...resourceTree.children.values()] ) as child (child.resource?.uri || `${serverName}:${child.name}`)}
|
||||
{@render renderTreeNode(child, 1, '')}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if hasTemplates && onTemplateSelect}
|
||||
{#if hasResources}
|
||||
<div class="my-1 border-t border-border/30"></div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="py-0.5 text-[11px] font-medium tracking-wide text-muted-foreground/70 uppercase"
|
||||
>
|
||||
Templates
|
||||
</div>
|
||||
|
||||
{#each templateInfos as template (template.uriTemplate)}
|
||||
<button
|
||||
class={[
|
||||
'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors',
|
||||
'hover:bg-muted/50',
|
||||
selectedTemplateUri === template.uriTemplate && 'bg-muted'
|
||||
]}
|
||||
onclick={() => onTemplateSelect(template)}
|
||||
title={template.uriTemplate}
|
||||
>
|
||||
<Braces class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="min-w-0 flex-1 truncate text-left">
|
||||
{template.title || template.name}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { MCPResource, MCPResourceInfo } from '$lib/types';
|
||||
import { parseResourcePath } from '$lib/utils';
|
||||
|
||||
export interface ResourceTreeNode {
|
||||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
isFiltered?: boolean;
|
||||
}
|
||||
|
||||
function resourceMatchesSearch(resource: MCPResource, query: string): boolean {
|
||||
return (
|
||||
resource.title?.toLowerCase().includes(query) || resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildResourceTree(
|
||||
resourceList: MCPResource[],
|
||||
serverName: string,
|
||||
searchQuery?: string
|
||||
): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
if (!searchQuery || !searchQuery.trim()) {
|
||||
for (const resource of resourceList) {
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { name: part, children: new Map() });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
// Build tree with filtering
|
||||
for (const resource of resourceList) {
|
||||
if (!resourceMatchesSearch(resource, query)) continue;
|
||||
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current.children.has(part)) {
|
||||
current.children.set(part, { name: part, children: new Map(), isFiltered: true });
|
||||
}
|
||||
current = current.children.get(part)!;
|
||||
}
|
||||
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map(),
|
||||
isFiltered: true
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupEmptyFolders(node: ResourceTreeNode): boolean {
|
||||
if (node.resource) return true;
|
||||
|
||||
const toDelete: string[] = [];
|
||||
for (const [name, child] of node.children.entries()) {
|
||||
if (!cleanupEmptyFolders(child)) {
|
||||
toDelete.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of toDelete) {
|
||||
node.children.delete(name);
|
||||
}
|
||||
|
||||
return node.children.size > 0;
|
||||
}
|
||||
|
||||
cleanupEmptyFolders(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
export function countTreeResources(node: ResourceTreeNode): number {
|
||||
if (node.resource) return 1;
|
||||
let count = 0;
|
||||
|
||||
for (const child of node.children.values()) {
|
||||
count += countTreeResources(child);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function sortTreeChildren(children: ResourceTreeNode[]): ResourceTreeNode[] {
|
||||
return children.sort((a, b) => {
|
||||
const aIsFolder = !a.resource && a.children.size > 0;
|
||||
const bIsFolder = !b.resource && b.children.size > 0;
|
||||
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
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 { HealthCheckStatus } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
McpServerCardActions,
|
||||
McpServerCardDeleteDialog,
|
||||
McpServerCardEditForm,
|
||||
McpServerCardHeader,
|
||||
McpServerCardToolsList,
|
||||
McpConnectionLogs,
|
||||
McpServerInfo
|
||||
} from '$lib/components/app/mcp';
|
||||
|
||||
interface Props {
|
||||
server: MCPServerSettingsEntry;
|
||||
enabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { server, enabled, onToggle, onUpdate, onDelete }: Props = $props();
|
||||
|
||||
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
||||
let displayName = $derived(mcpStore.getServerLabel(server));
|
||||
let faviconUrl = $derived(mcpStore.getServerFavicon(server.id));
|
||||
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
|
||||
);
|
||||
let tools = $derived(healthState.status === HealthCheckStatus.SUCCESS ? healthState.tools : []);
|
||||
|
||||
let connectionLogs = $derived(
|
||||
healthState.status === HealthCheckStatus.CONNECTING ||
|
||||
healthState.status === HealthCheckStatus.SUCCESS ||
|
||||
healthState.status === HealthCheckStatus.ERROR
|
||||
? healthState.logs
|
||||
: []
|
||||
);
|
||||
|
||||
let successState = $derived(
|
||||
healthState.status === HealthCheckStatus.SUCCESS ? healthState : null
|
||||
);
|
||||
let serverInfo = $derived(successState?.serverInfo);
|
||||
let capabilities = $derived(successState?.capabilities);
|
||||
let transportType = $derived(successState?.transportType);
|
||||
let protocolVersion = $derived(successState?.protocolVersion);
|
||||
let connectionTimeMs = $derived(successState?.connectionTimeMs);
|
||||
let instructions = $derived(successState?.instructions);
|
||||
|
||||
let isEditing = $derived(!server.url.trim());
|
||||
let showDeleteDialog = $state(false);
|
||||
let editFormRef: McpServerCardEditForm | null = $state(null);
|
||||
|
||||
function handleHealthCheck() {
|
||||
mcpStore.runHealthCheck(server);
|
||||
}
|
||||
|
||||
async function startEditing() {
|
||||
isEditing = true;
|
||||
await tick();
|
||||
editFormRef?.setInitialValues(server.url, server.headers || '', server.useProxy || false);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (server.url.trim()) {
|
||||
isEditing = false;
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditing(url: string, headers: string, useProxy: boolean) {
|
||||
onUpdate({
|
||||
url: url,
|
||||
headers: headers || undefined,
|
||||
useProxy: useProxy
|
||||
});
|
||||
isEditing = false;
|
||||
|
||||
if (server.enabled && url) {
|
||||
setTimeout(() => mcpStore.runHealthCheck({ ...server, url, useProxy }), 100);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root class="!gap-3 bg-muted/30 p-4">
|
||||
{#if isEditing}
|
||||
<McpServerCardEditForm
|
||||
bind:this={editFormRef}
|
||||
serverId={server.id}
|
||||
serverUrl={server.url}
|
||||
serverUseProxy={server.useProxy}
|
||||
onSave={saveEditing}
|
||||
onCancel={cancelEditing}
|
||||
/>
|
||||
{:else}
|
||||
<McpServerCardHeader
|
||||
{displayName}
|
||||
{faviconUrl}
|
||||
enabled={enabled ?? server.enabled}
|
||||
disabled={isError}
|
||||
{onToggle}
|
||||
{serverInfo}
|
||||
{capabilities}
|
||||
{transportType}
|
||||
/>
|
||||
|
||||
{#if isError && errorMessage}
|
||||
<p class="text-xs text-destructive">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#if isConnected && serverInfo?.description}
|
||||
<p class="line-clamp-2 text-xs text-muted-foreground">
|
||||
{serverInfo.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-3">
|
||||
{#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>
|
||||
|
||||
<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 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 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}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<McpServerCardActions
|
||||
{isHealthChecking}
|
||||
onEdit={startEditing}
|
||||
onRefresh={handleHealthCheck}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<McpServerCardDeleteDialog
|
||||
bind:open={showDeleteDialog}
|
||||
{displayName}
|
||||
onOpenChange={(open) => (showDeleteDialog = open)}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Trash2, RefreshCw, Pencil } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
isHealthChecking: boolean;
|
||||
onEdit: () => void;
|
||||
onRefresh: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { isHealthChecking, onEdit, onRefresh, onDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
|
||||
<Pencil class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
onclick={onRefresh}
|
||||
disabled={isHealthChecking}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onclick={onDelete}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
displayName: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), displayName, onOpenChange, onConfirm }: Props = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open {onOpenChange}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Server</AlertDialog.Title>
|
||||
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
|
||||
undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
|
||||
<AlertDialog.Action
|
||||
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
|
||||
onclick={onConfirm}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { McpServerForm } from '$lib/components/app/mcp';
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
serverUrl: string;
|
||||
serverUseProxy?: boolean;
|
||||
onSave: (url: string, headers: string, useProxy: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { serverId, serverUrl, serverUseProxy = false, onSave, onCancel }: Props = $props();
|
||||
|
||||
let editUrl = $derived(serverUrl);
|
||||
let editHeaders = $state('');
|
||||
let editUseProxy = $derived(serverUseProxy);
|
||||
|
||||
let urlError = $derived.by(() => {
|
||||
if (!editUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
new URL(editUrl);
|
||||
return null;
|
||||
} catch {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
});
|
||||
|
||||
let canSave = $derived(!urlError);
|
||||
|
||||
function handleSave() {
|
||||
if (!canSave) return;
|
||||
onSave(editUrl.trim(), editHeaders.trim(), editUseProxy);
|
||||
}
|
||||
|
||||
export function setInitialValues(url: string, headers: string, useProxy: boolean) {
|
||||
editUrl = url;
|
||||
editHeaders = headers;
|
||||
editUseProxy = useProxy;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="font-medium">Configure Server</p>
|
||||
|
||||
<McpServerForm
|
||||
url={editUrl}
|
||||
headers={editHeaders}
|
||||
useProxy={editUseProxy}
|
||||
onUrlChange={(v) => (editUrl = v)}
|
||||
onHeadersChange={(v) => (editHeaders = v)}
|
||||
onUseProxyChange={(v) => (editUseProxy = v)}
|
||||
urlError={editUrl ? urlError : null}
|
||||
id={serverId}
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onclick={onCancel}>Cancel</Button>
|
||||
|
||||
<Button size="sm" onclick={handleSave} disabled={!canSave}>
|
||||
{serverUrl.trim() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { McpCapabilitiesBadges, McpServerIdentity } from '$lib/components/app/mcp';
|
||||
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
|
||||
import { MCPTransportType } from '$lib/enums';
|
||||
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
displayName: string;
|
||||
faviconUrl?: string | null;
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
serverInfo?: MCPServerInfo;
|
||||
capabilities?: MCPCapabilitiesInfo;
|
||||
transportType?: MCPTransportType;
|
||||
}
|
||||
|
||||
let {
|
||||
displayName,
|
||||
faviconUrl,
|
||||
enabled,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
serverInfo,
|
||||
capabilities,
|
||||
transportType
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-3">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<McpServerIdentity
|
||||
{displayName}
|
||||
{faviconUrl}
|
||||
{serverInfo}
|
||||
iconClass="h-5 w-5"
|
||||
iconRounded="rounded"
|
||||
nameClass="leading-6 font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if capabilities || transportType}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if transportType}
|
||||
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
|
||||
{#if TransportIcon}
|
||||
<TransportIcon class="h-3 w-3" />
|
||||
{/if}
|
||||
|
||||
{MCP_TRANSPORT_LABELS[transportType] || transportType}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if capabilities}
|
||||
<McpCapabilitiesBadges {capabilities} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center pl-2">
|
||||
<Switch checked={enabled} {disabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
||||
interface Tool {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tools: Tool[];
|
||||
}
|
||||
|
||||
let { tools }: Props = $props();
|
||||
|
||||
let isExpanded = $state(false);
|
||||
let toolsCount = $derived(tools.length);
|
||||
</script>
|
||||
|
||||
<Collapsible.Root bind:open={isExpanded}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span>{toolsCount} tools available · Show details</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="mt-2">
|
||||
<div class="max-h-64 space-y-3 overflow-y-auto">
|
||||
{#each tools as tool (tool.name)}
|
||||
<div>
|
||||
<Badge variant="secondary">{tool.name}</Badge>
|
||||
|
||||
{#if tool.description}
|
||||
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
</script>
|
||||
|
||||
<Card.Root class="grid gap-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-5 w-5 rounded" />
|
||||
<Skeleton class="h-5 w-28" />
|
||||
<Skeleton class="h-5 w-12 rounded-full" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Skeleton class="h-4 w-40" />
|
||||
<Skeleton class="h-4 w-52" />
|
||||
</div>
|
||||
|
||||
<Skeleton class="h-3.5 w-36" />
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { KeyValuePairs } from '$lib/components/app';
|
||||
import type { KeyValuePair } from '$lib/types';
|
||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils';
|
||||
import { UrlProtocol } from '$lib/enums';
|
||||
import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { CLI_FLAGS } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
headers: string;
|
||||
useProxy?: boolean;
|
||||
onUrlChange: (url: string) => void;
|
||||
onHeadersChange: (headers: string) => void;
|
||||
onUseProxyChange?: (useProxy: boolean) => void;
|
||||
urlError?: string | null;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
url,
|
||||
headers,
|
||||
useProxy = false,
|
||||
onUrlChange,
|
||||
onHeadersChange,
|
||||
onUseProxyChange,
|
||||
urlError = null,
|
||||
id = 'server'
|
||||
}: Props = $props();
|
||||
|
||||
let isWebSocket = $derived(
|
||||
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET) ||
|
||||
url.toLowerCase().startsWith(UrlProtocol.WEBSOCKET_SECURE)
|
||||
);
|
||||
|
||||
let headerPairs = $derived<KeyValuePair[]>(parseHeadersToArray(headers));
|
||||
|
||||
function updateHeaderPairs(newPairs: KeyValuePair[]) {
|
||||
headerPairs = newPairs;
|
||||
onHeadersChange(serializeHeaders(newPairs));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div>
|
||||
<label for="server-url-{id}" class="mb-2 block text-xs font-medium">
|
||||
Server URL <span class="text-destructive">*</span>
|
||||
</label>
|
||||
|
||||
<Input
|
||||
id="server-url-{id}"
|
||||
type="url"
|
||||
placeholder={MCP_SERVER_URL_PLACEHOLDER}
|
||||
value={url}
|
||||
oninput={(e) => onUrlChange(e.currentTarget.value)}
|
||||
class={urlError ? 'border-destructive' : ''}
|
||||
/>
|
||||
|
||||
{#if urlError}
|
||||
<p class="mt-1.5 text-xs text-destructive">{urlError}</p>
|
||||
{/if}
|
||||
|
||||
{#if !isWebSocket && onUseProxyChange}
|
||||
<label
|
||||
class={[
|
||||
'mt-3 flex items-start gap-2',
|
||||
mcpStore.isProxyAvailable && 'cursor-pointer',
|
||||
!mcpStore.isProxyAvailable && 'opacity-80'
|
||||
]}
|
||||
>
|
||||
<Switch
|
||||
class="mt-1"
|
||||
id="use-proxy-{id}"
|
||||
checked={useProxy}
|
||||
disabled={!mcpStore.isProxyAvailable}
|
||||
onCheckedChange={(checked) => onUseProxyChange?.(checked)}
|
||||
/>
|
||||
|
||||
<span>
|
||||
<span class="text-xs text-muted-foreground">Use llama-server proxy</span>
|
||||
|
||||
<br />
|
||||
|
||||
{#if !mcpStore.isProxyAvailable}
|
||||
<span class="inline-flex gap-0.75 text-xs text-muted-foreground/60"
|
||||
>(Run <pre>llama-server</pre>
|
||||
with
|
||||
<pre>{CLI_FLAGS.MCP_PROXY}</pre>
|
||||
flag)</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<KeyValuePairs
|
||||
class="mt-2"
|
||||
pairs={headerPairs}
|
||||
onPairsChange={updateHeaderPairs}
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Value"
|
||||
addButtonLabel="Add"
|
||||
emptyMessage="No custom headers configured."
|
||||
sectionLabel="Custom Headers"
|
||||
sectionLabelOptional
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink } from '@lucide/svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { TruncatedText } from '$lib/components/app/misc';
|
||||
import { sanitizeExternalUrl } from '$lib/utils';
|
||||
import type { MCPServerInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
displayName?: string;
|
||||
faviconUrl?: string | null;
|
||||
serverInfo?: MCPServerInfo;
|
||||
iconClass?: string;
|
||||
iconRounded?: string;
|
||||
showVersion?: boolean;
|
||||
showWebsite?: boolean;
|
||||
nameClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
displayName,
|
||||
faviconUrl = null,
|
||||
serverInfo,
|
||||
iconClass = 'h-5 w-5',
|
||||
iconRounded = 'rounded-sm',
|
||||
showVersion = true,
|
||||
showWebsite = true,
|
||||
nameClass
|
||||
}: Props = $props();
|
||||
|
||||
let safeWebsiteUrl = $derived(
|
||||
serverInfo?.websiteUrl ? sanitizeExternalUrl(serverInfo.websiteUrl) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class={['shrink-0', iconRounded, iconClass]}
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<TruncatedText text={displayName ?? ''} class={nameClass ?? ''} />
|
||||
|
||||
{#if showVersion && serverInfo?.version}
|
||||
<Badge variant="secondary" class="h-4 min-w-0 shrink px-1 text-[10px]">
|
||||
<TruncatedText text={`v${serverInfo.version}`} />
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if showWebsite && safeWebsiteUrl}
|
||||
<a
|
||||
href={safeWebsiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Open website"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
|
||||
interface Props {
|
||||
instructions?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { instructions, class: className }: Props = $props();
|
||||
|
||||
let isExpanded = $state(false);
|
||||
</script>
|
||||
|
||||
{#if instructions}
|
||||
<Collapsible.Root bind:open={isExpanded} class={className}>
|
||||
<Collapsible.Trigger
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{#if isExpanded}
|
||||
<ChevronDown class="h-3.5 w-3.5" />
|
||||
{:else}
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span>Server instructions</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content class="mt-2">
|
||||
<p class="rounded bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
{instructions}
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
*
|
||||
* MCP (Model Context Protocol)
|
||||
*
|
||||
* Components for managing MCP server connections and displaying server status.
|
||||
* MCP enables agentic workflows by connecting to external tool servers.
|
||||
*
|
||||
* The MCP system integrates with:
|
||||
* - `mcpStore` for server CRUD operations and health checks
|
||||
* - `conversationsStore` for per-conversation server enable/disable
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **McpServersSettings** - MCP servers configuration section
|
||||
*
|
||||
* Settings section for configuring MCP server connections.
|
||||
* Displays server cards with status, tools, and management actions.
|
||||
* Used within the MCP tab of ChatSettings.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Manages add server form state locally
|
||||
* - Delegates server display to McpServerCard components
|
||||
* - Integrates with mcpStore for server operations
|
||||
* - Shows skeleton loading states during health checks
|
||||
*
|
||||
* **Features:**
|
||||
* - Add new MCP servers by URL with validation
|
||||
* - Server cards with connection status indicators
|
||||
* - Health check status (connected/disconnected/error)
|
||||
* - Tools list per server showing available capabilities
|
||||
* - Enable/disable toggle per conversation
|
||||
* - Edit/delete server actions
|
||||
* - Skeleton loading states during connection
|
||||
* - Empty state with helpful message
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServersSettings />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServersSettings } from '../settings/SettingsMcpServers.svelte';
|
||||
|
||||
/**
|
||||
* **McpActiveServersAvatars** - Active MCP servers indicator
|
||||
*
|
||||
* Compact avatar row showing favicons of active MCP servers.
|
||||
* Displays up to 3 server icons with "+N" counter for additional servers.
|
||||
* Clickable to open MCP settings dialog.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Filters servers by enabled status and health check
|
||||
* - Fetches favicons from server URLs
|
||||
* - Integrates with conversationsStore for per-chat server state
|
||||
*
|
||||
* **Features:**
|
||||
* - Overlapping favicon avatars (max 3 visible)
|
||||
* - "+N" counter for additional servers
|
||||
* - Click handler for settings navigation
|
||||
* - Disabled state support
|
||||
* - Only shows healthy, enabled servers
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpActiveServersAvatars
|
||||
* onSettingsClick={() => showMcpSettings = true}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte';
|
||||
|
||||
/**
|
||||
* **McpCapabilitiesBadges** - Server capabilities display
|
||||
*
|
||||
* Displays MCP server capabilities as colored badges.
|
||||
* Shows which features the server supports (tools, resources, prompts, etc.).
|
||||
*
|
||||
* **Features:**
|
||||
* - Tools badge (green) - server provides callable tools
|
||||
* - Resources badge (blue) - server provides data resources
|
||||
* - Prompts badge (purple) - server provides prompt templates
|
||||
* - Logging badge (orange) - server supports logging
|
||||
* - Completions badge (cyan) - server provides completions
|
||||
* - Tasks badge (pink) - server supports task management
|
||||
*/
|
||||
export { default as McpCapabilitiesBadges } from './McpCapabilitiesBadges.svelte';
|
||||
|
||||
/**
|
||||
* **McpConnectionLogs** - Connection log viewer
|
||||
*
|
||||
* Collapsible panel showing MCP server connection logs.
|
||||
* Displays timestamped log entries with level-based styling.
|
||||
*
|
||||
* **Features:**
|
||||
* - Collapsible log list with entry count
|
||||
* - Connection time display in milliseconds
|
||||
* - Log level icons and color coding
|
||||
* - Scrollable log container with max height
|
||||
* - Monospace font for log readability
|
||||
*/
|
||||
export { default as McpConnectionLogs } from './McpConnectionLogs.svelte';
|
||||
|
||||
/**
|
||||
* **McpServerForm** - Server URL and headers input form
|
||||
*
|
||||
* Reusable form for entering MCP server connection details.
|
||||
* Used in both add new server and edit server flows.
|
||||
*
|
||||
* **Features:**
|
||||
* - URL input with validation error display
|
||||
* - Custom headers key-value pairs editor
|
||||
* - Controlled component with change callbacks
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServerForm
|
||||
* url={serverUrl}
|
||||
* headers={serverHeaders}
|
||||
* onUrlChange={(v) => serverUrl = v}
|
||||
* onHeadersChange={(v) => serverHeaders = v}
|
||||
* urlError={validationError}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServerForm } from './McpServerForm.svelte';
|
||||
|
||||
/**
|
||||
* MCP protocol logo SVG component. Renders the official MCP icon
|
||||
* with customizable size via class and style props.
|
||||
*/
|
||||
export { default as McpLogo } from './McpLogo.svelte';
|
||||
|
||||
/**
|
||||
*
|
||||
* SERVER CARD
|
||||
*
|
||||
* Components for displaying individual MCP server status and controls.
|
||||
* McpServerCard is the main component, with sub-components for specific sections.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **McpServerCard** - Individual server display card
|
||||
*
|
||||
* Main component for displaying a single MCP server with all its details.
|
||||
* Manages edit mode, delete confirmation, and health check actions.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Composes header, tools list, logs, and actions sub-components
|
||||
* - Manages local edit/delete state
|
||||
* - Reads health state from mcpStore
|
||||
* - Triggers health checks via mcpStore
|
||||
*
|
||||
* **Features:**
|
||||
* - Server header with favicon, name, version, and toggle
|
||||
* - Capabilities badges display
|
||||
* - Tools list with descriptions
|
||||
* - Connection logs viewer
|
||||
* - Edit form for URL and headers
|
||||
* - Delete confirmation dialog
|
||||
* - Skeleton loading states
|
||||
*/
|
||||
export { default as McpServerCard } from './McpServerCard/McpServerCard.svelte';
|
||||
|
||||
/** Server card header with favicon, name, version badge, and enable toggle. */
|
||||
export { default as McpServerCardHeader } from './McpServerCard/McpServerCardHeader.svelte';
|
||||
|
||||
/** Action buttons row: edit, refresh, delete. */
|
||||
export { default as McpServerCardActions } from './McpServerCard/McpServerCardActions.svelte';
|
||||
|
||||
/** Collapsible tools list showing available server tools with descriptions. */
|
||||
export { default as McpServerCardToolsList } from './McpServerCard/McpServerCardToolsList.svelte';
|
||||
|
||||
/** Inline edit form for server URL and custom headers. */
|
||||
export { default as McpServerCardEditForm } from './McpServerCard/McpServerCardEditForm.svelte';
|
||||
|
||||
/** Delete confirmation dialog with server name display. */
|
||||
export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerCardDeleteDialog.svelte';
|
||||
|
||||
/** Skeleton loading state for server card during health checks. */
|
||||
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **McpServerIdentity** - Server identity display (icon, name, version)
|
||||
*
|
||||
* Reusable headless component for displaying server name, favicon/icon, and version badge.
|
||||
* Accepts all data via props with no store dependencies for predictable rendering.
|
||||
*
|
||||
* **Features:**
|
||||
* - Server favicon/icon with fallback
|
||||
* - Truncated display name with max-width
|
||||
* - Optional version badge (v1.2.3)
|
||||
* - Optional external link to server website
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <McpServerIdentity displayName={name} faviconUrl={iconUrl} serverInfo={info} />
|
||||
* ```
|
||||
*/
|
||||
export { default as McpServerIdentity } from './McpServerIdentity.svelte';
|
||||
|
||||
/**
|
||||
* **McpServerInfo** - Server instructions display
|
||||
*
|
||||
* Collapsible panel showing server-provided instructions.
|
||||
* Displays guidance text from the MCP server for users.
|
||||
*/
|
||||
export { default as McpServerInfo } from './McpServerInfo.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourcesBrowser** - MCP resources tree browser
|
||||
*
|
||||
* Tree view component showing resources grouped by server.
|
||||
* Supports resource selection and quick attach actions.
|
||||
*
|
||||
* **Features:**
|
||||
* - Collapsible server sections
|
||||
* - Resource icons based on MIME type
|
||||
* - Resource selection highlighting
|
||||
* - Quick attach button per resource
|
||||
* - Refresh all resources action
|
||||
* - Loading states per server
|
||||
*/
|
||||
export { default as McpResourcesBrowser } from './McpResourcesBrowser/McpResourcesBrowser.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourcePreview** - MCP resource content preview
|
||||
*
|
||||
* Preview panel showing resource content with metadata.
|
||||
* Supports text and binary content display.
|
||||
*
|
||||
* **Features:**
|
||||
* - Text content display with monospace formatting
|
||||
* - Image preview for image MIME types
|
||||
* - Copy to clipboard action
|
||||
* - Download content action
|
||||
* - Resource metadata display (MIME type, priority, server)
|
||||
* - Loading and error states
|
||||
*/
|
||||
export { default as McpResourcePreview } from './McpResourcePreview.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourceTemplateForm** - MCP resource template variable form
|
||||
*
|
||||
* Form for filling in resource template variables with auto-completion
|
||||
* via the Completions API. Shows live URI preview as variables are filled.
|
||||
*
|
||||
* **Features:**
|
||||
* - Template variable input fields
|
||||
* - Completions API integration for variable auto-complete
|
||||
* - Live URI preview as variables are filled
|
||||
* - Read resolved resource action
|
||||
*/
|
||||
export { default as McpResourceTemplateForm } from './McpResourceTemplateForm.svelte';
|
||||
Reference in New Issue
Block a user