feat: MCP Resources UI
feat: Implement MCP Resource Selection Dialog
This commit is contained in:
parent
1623547e2b
commit
23e4ef7495
|
|
@ -0,0 +1,124 @@
|
|||
<script lang="ts">
|
||||
import { FileText, X, Loader2, AlertCircle, Database, Image, Code, File } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { getFaviconUrl } from '$lib/utils';
|
||||
import type { MCPResourceAttachment, MCPResourceInfo } from '$lib/types';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
attachment: MCPResourceAttachment;
|
||||
onRemove?: (attachmentId: string) => void;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { attachment, onRemove, onClick, class: className }: Props = $props();
|
||||
|
||||
function getResourceIcon(resource: MCPResourceInfo) {
|
||||
const mimeType = resource.mimeType?.toLowerCase() || '';
|
||||
const uri = resource.uri.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mimeType.includes('json') ||
|
||||
mimeType.includes('javascript') ||
|
||||
mimeType.includes('typescript') ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
|
||||
return FileText;
|
||||
}
|
||||
if (uri.includes('database') || uri.includes('db://')) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
function getStatusClass(attachment: MCPResourceAttachment): string {
|
||||
if (attachment.error) return 'border-red-500/50 bg-red-500/10';
|
||||
if (attachment.loading) return 'border-border/50 bg-muted/30';
|
||||
return 'border-border/50 bg-muted/30';
|
||||
}
|
||||
|
||||
function getServerDisplayName(serverId: string): string {
|
||||
const server = mcpStore.getServerById(serverId);
|
||||
return server ? mcpStore.getServerLabel(server) : serverId;
|
||||
}
|
||||
|
||||
function getServerFavicon(serverId: string): string | null {
|
||||
const server = mcpStore.getServerById(serverId);
|
||||
return server ? getFaviconUrl(server.url) : null;
|
||||
}
|
||||
|
||||
const ResourceIcon = $derived(getResourceIcon(attachment.resource));
|
||||
const serverName = $derived(getServerDisplayName(attachment.resource.serverName));
|
||||
const favicon = $derived(getServerFavicon(attachment.resource.serverName));
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'flex flex-shrink-0 items-center gap-2 rounded-md border px-2 py-1 text-sm transition-colors',
|
||||
getStatusClass(attachment),
|
||||
onClick && 'cursor-pointer hover:bg-muted/50',
|
||||
className
|
||||
)}
|
||||
onclick={onClick}
|
||||
disabled={!onClick}
|
||||
>
|
||||
{#if attachment.loading}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
{:else if attachment.error}
|
||||
<AlertCircle class="h-3.5 w-3.5 text-red-500" />
|
||||
{:else}
|
||||
<ResourceIcon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="max-w-[150px] truncate">
|
||||
{attachment.resource.title || attachment.resource.name}
|
||||
</span>
|
||||
|
||||
{#if onRemove}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 w-5 p-0 hover:bg-destructive/20"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(attachment.id);
|
||||
}}
|
||||
title="Remove attachment"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<span class="truncate">
|
||||
{serverName}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
|
@ -58,6 +58,11 @@ export { default as ChatAttachmentsList } from './ChatAttachments/ChatAttachment
|
|||
*/
|
||||
export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttachmentMcpPrompt.svelte';
|
||||
|
||||
/**
|
||||
* Todo - add description
|
||||
*/
|
||||
export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
|
||||
|
||||
/**
|
||||
* Full-size attachment preview component for dialog display. Handles different file types:
|
||||
* images (full-size display), text files (syntax highlighted), PDFs (text extraction or image preview),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
<script lang="ts">
|
||||
import { FolderOpen, Plus, Loader2 } from '@lucide/svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpResources, mcpTotalResourceCount } from '$lib/stores/mcp-resources.svelte';
|
||||
import { McpResourceBrowser, McpResourcePreview } from '$lib/components/app';
|
||||
import type { MCPResourceInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
preSelectedUri?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
|
||||
|
||||
let selectedResources = $state<Set<string>>(new Set());
|
||||
let lastSelectedUri = $state<string | null>(null);
|
||||
let isAttaching = $state(false);
|
||||
|
||||
const totalCount = $derived(mcpTotalResourceCount());
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadResources();
|
||||
|
||||
if (preSelectedUri) {
|
||||
selectedResources = new Set([preSelectedUri]);
|
||||
lastSelectedUri = preSelectedUri;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
if (initialized) {
|
||||
await mcpStore.fetchAllResources();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
open = newOpen;
|
||||
onOpenChange?.(newOpen);
|
||||
if (!newOpen) {
|
||||
selectedResources = new Set();
|
||||
lastSelectedUri = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceSelect(resource: MCPResourceInfo, shiftKey: boolean = false) {
|
||||
if (shiftKey && lastSelectedUri) {
|
||||
const allResources = getAllResourcesFlat();
|
||||
const lastIndex = allResources.findIndex((r) => r.uri === lastSelectedUri);
|
||||
const currentIndex = allResources.findIndex((r) => r.uri === resource.uri);
|
||||
|
||||
if (lastIndex !== -1 && currentIndex !== -1) {
|
||||
const start = Math.min(lastIndex, currentIndex);
|
||||
const end = Math.max(lastIndex, currentIndex);
|
||||
const newSelection = new Set(selectedResources);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
newSelection.add(allResources[i].uri);
|
||||
}
|
||||
|
||||
selectedResources = newSelection;
|
||||
}
|
||||
} else {
|
||||
selectedResources = new Set([resource.uri]);
|
||||
lastSelectedUri = resource.uri;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceToggle(resource: MCPResourceInfo, checked: boolean) {
|
||||
const newSelection = new Set(selectedResources);
|
||||
if (checked) {
|
||||
newSelection.add(resource.uri);
|
||||
} else {
|
||||
newSelection.delete(resource.uri);
|
||||
}
|
||||
selectedResources = newSelection;
|
||||
lastSelectedUri = resource.uri;
|
||||
}
|
||||
|
||||
function getAllResourcesFlat(): MCPResourceInfo[] {
|
||||
const allResources: MCPResourceInfo[] = [];
|
||||
const resourcesMap = mcpResources();
|
||||
|
||||
for (const [serverName, serverRes] of resourcesMap.entries()) {
|
||||
for (const resource of serverRes.resources) {
|
||||
allResources.push({ ...resource, serverName });
|
||||
}
|
||||
}
|
||||
|
||||
return allResources;
|
||||
}
|
||||
|
||||
async function handleAttach() {
|
||||
if (selectedResources.size === 0) return;
|
||||
|
||||
isAttaching = true;
|
||||
try {
|
||||
const allResources = getAllResourcesFlat();
|
||||
const resourcesToAttach = allResources.filter((r) => selectedResources.has(r.uri));
|
||||
|
||||
for (const resource of resourcesToAttach) {
|
||||
await mcpStore.attachResource(resource.uri);
|
||||
onAttach?.(resource);
|
||||
}
|
||||
|
||||
handleOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach resources:', error);
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickAttach(resource: MCPResourceInfo) {
|
||||
isAttaching = true;
|
||||
try {
|
||||
await mcpStore.attachResource(resource.uri);
|
||||
onAttach?.(resource);
|
||||
} catch (error) {
|
||||
console.error('Failed to attach resource:', error);
|
||||
} finally {
|
||||
isAttaching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
|
||||
<Dialog.Header class="border-b px-6 py-4">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<FolderOpen class="h-5 w-5" />
|
||||
<span>MCP Resources</span>
|
||||
{#if totalCount > 0}
|
||||
<span class="text-sm font-normal text-muted-foreground">({totalCount})</span>
|
||||
{/if}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Browse and attach resources from connected MCP servers to your chat context.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex h-[500px]">
|
||||
<div class="w-72 shrink-0 overflow-y-auto border-r p-4">
|
||||
<McpResourceBrowser
|
||||
onSelect={handleResourceSelect}
|
||||
onToggle={handleResourceToggle}
|
||||
onAttach={handleQuickAttach}
|
||||
selectedUris={selectedResources}
|
||||
expandToUri={preSelectedUri}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if selectedResources.size === 1}
|
||||
{@const allResources = getAllResourcesFlat()}
|
||||
{@const selectedResource = allResources.find((r) => selectedResources.has(r.uri))}
|
||||
<McpResourcePreview resource={selectedResource ?? null} />
|
||||
{:else if selectedResources.size > 1}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{selectedResources.size} resources selected
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Select a resource to preview
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="border-t px-6 py-4">
|
||||
<Button variant="outline" onclick={() => handleOpenChange(false)}>Cancel</Button>
|
||||
<Button onclick={handleAttach} disabled={selectedResources.size === 0 || isAttaching}>
|
||||
{#if isAttaching}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
Attach {selectedResources.size > 0 ? `(${selectedResources.size})` : 'Resource'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -443,3 +443,33 @@ export { default as DialogConversationSelection } from './DialogConversationSele
|
|||
* ```
|
||||
*/
|
||||
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpResources** - MCP resources browser dialog
|
||||
*
|
||||
* Dialog for browsing and attaching MCP resources to chat context.
|
||||
* Displays resources from connected MCP servers in a tree structure
|
||||
* with preview panel and multi-select support.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses ShadCN Dialog with two-panel layout
|
||||
* - Left panel: McpResourceBrowser with tree navigation
|
||||
* - Right panel: McpResourcePreview for selected resource
|
||||
* - Integrates with mcpStore for resource fetching and attachment
|
||||
*
|
||||
* **Features:**
|
||||
* - Tree-based resource navigation by server and path
|
||||
* - Single and multi-select with shift+click
|
||||
* - Resource preview with content display
|
||||
* - Quick attach button per resource
|
||||
* - Batch attach for multiple selections
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogMcpResources
|
||||
* bind:open={showResources}
|
||||
* onAttach={handleResourceAttach}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpResources } from './DialogMcpResources.svelte';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,402 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
FileText,
|
||||
FolderOpen,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Database,
|
||||
File,
|
||||
Image,
|
||||
Code
|
||||
} from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
mcpResources,
|
||||
mcpTotalResourceCount,
|
||||
mcpResourcesLoading
|
||||
} from '$lib/stores/mcp-resources.svelte';
|
||||
import { getFaviconUrl } from '$lib/utils';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { MCPResource, MCPResourceInfo, MCPServerResources } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
onSelect?: (resource: MCPResourceInfo, shiftKey?: boolean) => void;
|
||||
onToggle?: (resource: MCPResourceInfo, checked: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
selectedUris?: Set<string>;
|
||||
expandToUri?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAttach,
|
||||
selectedUris = new Set(),
|
||||
expandToUri,
|
||||
class: className
|
||||
}: Props = $props();
|
||||
|
||||
let expandedServers = $state<Set<string>>(new Set());
|
||||
let expandedFolders = $state<Set<string>>(new Set());
|
||||
let hasAutoExpanded = $state(false);
|
||||
|
||||
const resources = $derived(mcpResources());
|
||||
const isLoading = $derived(mcpResourcesLoading());
|
||||
|
||||
$effect(() => {
|
||||
if (expandToUri && resources.size > 0 && !hasAutoExpanded) {
|
||||
autoExpandToResource(expandToUri);
|
||||
hasAutoExpanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
function autoExpandToResource(uri: string) {
|
||||
for (const [serverName, serverRes] of resources.entries()) {
|
||||
const resource = serverRes.resources.find((r) => r.uri === uri);
|
||||
if (resource) {
|
||||
const newExpandedServers = new Set(expandedServers);
|
||||
newExpandedServers.add(serverName);
|
||||
expandedServers = newExpandedServers;
|
||||
|
||||
const pathParts = parseResourcePath(uri);
|
||||
if (pathParts.length > 1) {
|
||||
const newExpandedFolders = new Set(expandedFolders);
|
||||
let currentPath = '';
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
currentPath = `${currentPath}/${pathParts[i]}`;
|
||||
const folderId = `${serverName}:${currentPath}`;
|
||||
newExpandedFolders.add(folderId);
|
||||
}
|
||||
expandedFolders = newExpandedFolders;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleServer(serverName: string) {
|
||||
const newSet = new Set(expandedServers);
|
||||
if (newSet.has(serverName)) {
|
||||
newSet.delete(serverName);
|
||||
} else {
|
||||
newSet.add(serverName);
|
||||
}
|
||||
expandedServers = newSet;
|
||||
}
|
||||
|
||||
function toggleFolder(folderId: string) {
|
||||
const newSet = new Set(expandedFolders);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
}
|
||||
expandedFolders = newSet;
|
||||
}
|
||||
|
||||
interface ResourceTreeNode {
|
||||
name: string;
|
||||
resource?: MCPResourceInfo;
|
||||
children: Map<string, ResourceTreeNode>;
|
||||
}
|
||||
|
||||
function parseResourcePath(uri: string): string[] {
|
||||
// Parse URI like "svelte://cli/overview.md" -> ["cli", "overview.md"]
|
||||
try {
|
||||
// Remove protocol (e.g., "svelte://")
|
||||
const withoutProtocol = uri.replace(/^[a-z]+:\/\//, '');
|
||||
// Split by / and filter empty parts
|
||||
return withoutProtocol.split('/').filter((p) => p.length > 0);
|
||||
} catch {
|
||||
return [uri];
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayName(pathPart: string): string {
|
||||
// Convert filename to display name: "overview.md" -> "Overview"
|
||||
const withoutExt = pathPart.replace(/\.[^.]+$/, '');
|
||||
// Convert kebab-case or snake_case to Title Case
|
||||
return withoutExt
|
||||
.split(/[-_]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildResourceTree(resourceList: MCPResource[], serverName: string): ResourceTreeNode {
|
||||
const root: ResourceTreeNode = { name: 'root', children: new Map() };
|
||||
|
||||
for (const resource of resourceList) {
|
||||
const pathParts = parseResourcePath(resource.uri);
|
||||
let current = root;
|
||||
|
||||
// Navigate/create folders for all but the last part
|
||||
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)!;
|
||||
}
|
||||
|
||||
// Add the resource at the leaf
|
||||
const fileName = pathParts[pathParts.length - 1] || resource.name;
|
||||
current.children.set(resource.uri, {
|
||||
name: fileName,
|
||||
resource: { ...resource, serverName },
|
||||
children: new Map()
|
||||
});
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
mcpStore.fetchAllResources();
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo, event: MouseEvent) {
|
||||
onSelect?.(resource, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(resource: MCPResourceInfo, checked: boolean) {
|
||||
onToggle?.(resource, checked);
|
||||
}
|
||||
|
||||
function handleAttachClick(e: Event, resource: MCPResourceInfo) {
|
||||
e.stopPropagation();
|
||||
onAttach?.(resource);
|
||||
}
|
||||
|
||||
function getResourceIcon(resource: MCPResourceInfo) {
|
||||
const mimeType = resource.mimeType?.toLowerCase() || '';
|
||||
const uri = resource.uri.toLowerCase();
|
||||
|
||||
if (mimeType.startsWith('image/') || /\.(png|jpg|jpeg|gif|svg|webp)$/.test(uri)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mimeType.includes('json') ||
|
||||
mimeType.includes('javascript') ||
|
||||
mimeType.includes('typescript') ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css)$/.test(uri)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mimeType.includes('text') || /\.(txt|md|log)$/.test(uri)) {
|
||||
return FileText;
|
||||
}
|
||||
if (uri.includes('database') || uri.includes('db://')) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
function isResourceSelected(resource: MCPResourceInfo): boolean {
|
||||
return selectedUris.has(resource.uri);
|
||||
}
|
||||
|
||||
function getServerDisplayName(serverId: string): string {
|
||||
const server = mcpStore.getServerById(serverId);
|
||||
return server ? mcpStore.getServerLabel(server) : serverId;
|
||||
}
|
||||
|
||||
function getServerFavicon(serverId: string): string | null {
|
||||
const server = mcpStore.getServerById(serverId);
|
||||
return server ? getFaviconUrl(server.url) : null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderTreeNode(
|
||||
node: ResourceTreeNode,
|
||||
serverName: string,
|
||||
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={() => toggleFolder(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 [...node.children.values()].sort((a, b) => {
|
||||
// Folders first, then files
|
||||
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);
|
||||
}) as child}
|
||||
{@render renderTreeNode(child, serverName, depth + 1, `${parentPath}/${node.name}`)}
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{:else if node.resource}
|
||||
{@const resource = node.resource}
|
||||
{@const ResourceIcon = getResourceIcon(resource)}
|
||||
{@const isSelected = isResourceSelected(resource)}
|
||||
{@const displayName = resource.title || getDisplayName(node.name)}
|
||||
<div class="group flex w-full items-center gap-2">
|
||||
{#if onToggle}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => handleCheckboxChange(resource, checked === true)}
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
class={cn(
|
||||
'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) => handleResourceClick(resource, e)}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="min-w-0 flex-1 text-left">
|
||||
<TruncatedText text={displayName} />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content class="z-[9999]">
|
||||
<p>{displayName}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{#if onAttach}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 px-1.5 text-xs opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"
|
||||
onclick={(e) => handleAttachClick(e, resource)}
|
||||
>
|
||||
Attach
|
||||
</Button>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class={cn('flex flex-col gap-2', className)}>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">Available resources</h3>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
title="Refresh resources"
|
||||
>
|
||||
{#if isLoading}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{:else}
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if resources.size === 0}
|
||||
<div class="py-4 text-center text-sm text-muted-foreground">
|
||||
{#if isLoading}
|
||||
Loading resources...
|
||||
{:else}
|
||||
No resources available
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#each [...resources.entries()] as [serverName, serverRes]}
|
||||
{@const isExpanded = expandedServers.has(serverName)}
|
||||
{@const hasResources = serverRes.resources.length > 0}
|
||||
{@const displayName = getServerDisplayName(serverName)}
|
||||
{@const favicon = getServerFavicon(serverName)}
|
||||
{@const resourceTree = buildResourceTree(serverRes.resources, serverName)}
|
||||
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleServer(serverName)}>
|
||||
<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}
|
||||
{#if favicon}
|
||||
<img
|
||||
src={favicon}
|
||||
alt=""
|
||||
class="h-4 w-4 shrink-0 rounded-sm"
|
||||
onerror={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<span class="font-medium">{displayName}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({serverRes.resources.length})
|
||||
</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 !hasResources}
|
||||
<div class="py-1 text-xs text-muted-foreground">No resources</div>
|
||||
{:else}
|
||||
{#each [...resourceTree.children.values()].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);
|
||||
}) as child}
|
||||
{@render renderTreeNode(child, serverName, 1, '')}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* McpResourcePicker - Wrapper component for DialogMcpResources
|
||||
*
|
||||
* This component re-exports DialogMcpResources for backward compatibility.
|
||||
* New code should use DialogMcpResources directly from dialogs.
|
||||
*/
|
||||
import { DialogMcpResources } from '$lib/components/app';
|
||||
import type { MCPResourceInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onAttach?: (resource: MCPResourceInfo) => void;
|
||||
preSelectedUri?: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), onOpenChange, onAttach, preSelectedUri }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DialogMcpResources bind:open {onOpenChange} {onAttach} {preSelectedUri} />
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
<script lang="ts">
|
||||
import { FileText, Loader2, AlertCircle, Image, Download, Copy, Check } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { MCPResourceInfo, MCPResourceContent } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
resource: MCPResourceInfo | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { resource, class: className }: Props = $props();
|
||||
|
||||
let content = $state<MCPResourceContent[] | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (resource) {
|
||||
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 getTextContent(): string {
|
||||
if (!content) return '';
|
||||
return content
|
||||
.filter((c): c is { uri: string; mimeType?: string; text: string } => 'text' in c)
|
||||
.map((c) => c.text)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function getBlobContent(): Array<{ uri: string; mimeType?: string; blob: string }> {
|
||||
if (!content) return [];
|
||||
return content.filter(
|
||||
(c): c is { uri: string; mimeType?: string; blob: string } => 'blob' in c
|
||||
);
|
||||
}
|
||||
|
||||
function isImageMimeType(mimeType?: string): boolean {
|
||||
return mimeType?.startsWith('image/') ?? false;
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const text = getTextContent();
|
||||
if (text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const text = getTextContent();
|
||||
if (!text || !resource) return;
|
||||
|
||||
const blob = new Blob([text], { type: resource.mimeType || 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = resource.name || 'resource.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn('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}
|
||||
<!-- Header -->
|
||||
<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 gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleCopy}
|
||||
disabled={isLoading || !getTextContent()}
|
||||
title="Copy content"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleDownload}
|
||||
disabled={isLoading || !getTextContent()}
|
||||
title="Download content"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[200px] overflow-auto rounded-md border bg-muted/30 p-3">
|
||||
{#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 = getTextContent()}
|
||||
{@const blobContent = getBlobContent()}
|
||||
|
||||
{#if textContent}
|
||||
<pre class="font-mono text-xs break-words whitespace-pre-wrap">{textContent}</pre>
|
||||
{/if}
|
||||
|
||||
{#each blobContent as blob}
|
||||
{#if isImageMimeType(blob.mimeType)}
|
||||
<img
|
||||
src={`data:${blob.mimeType};base64,${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>
|
||||
|
||||
<!-- Metadata -->
|
||||
{#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>
|
||||
|
|
@ -211,3 +211,50 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
|
|||
* Displays guidance text from the MCP server for users.
|
||||
*/
|
||||
export { default as McpServerInfo } from './McpServerInfo.svelte';
|
||||
|
||||
/**
|
||||
* **McpResourceBrowser** - 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 McpResourceBrowser } from './McpResourceBrowser.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';
|
||||
|
||||
/**
|
||||
* **McpResourcePicker** - MCP resource selection dialog
|
||||
*
|
||||
* Full dialog for browsing and attaching MCP resources.
|
||||
* Combines browser and preview panels.
|
||||
*
|
||||
* **Features:**
|
||||
* - Split panel layout (browser + preview)
|
||||
* - Resource selection with preview
|
||||
* - Quick attach from browser
|
||||
* - Attach selected resource action
|
||||
* - Auto-fetch resources on open
|
||||
*/
|
||||
export { default as McpResourcePicker } from './McpResourcePicker.svelte';
|
||||
|
|
|
|||
Loading…
Reference in New Issue