feat: MCP Resources
This commit is contained in:
parent
c21bd358ce
commit
3287a41af9
|
|
@ -0,0 +1,99 @@
|
|||
<script lang="ts">
|
||||
import { FileText, Database, Image, Code, File } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import {
|
||||
IMAGE_FILE_EXTENSION_REGEX,
|
||||
CODE_FILE_EXTENSION_REGEX,
|
||||
TEXT_FILE_EXTENSION_REGEX
|
||||
} from '$lib/constants/mcp-resource';
|
||||
import { MimeTypePrefix, MimeTypeIncludes, UriPattern } from '$lib/enums';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIconRemove } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
extra: DatabaseMessageExtraMcpResource;
|
||||
readonly?: boolean;
|
||||
onRemove?: () => void;
|
||||
onClick?: (event?: MouseEvent) => void;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { extra, readonly = true, onRemove, onClick, class: className }: Props = $props();
|
||||
|
||||
function getResourceIcon(mimeType?: string, uri?: string) {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
|
||||
if (mime.startsWith(MimeTypePrefix.IMAGE) || IMAGE_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return Image;
|
||||
}
|
||||
if (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
CODE_FILE_EXTENSION_REGEX.test(u)
|
||||
) {
|
||||
return Code;
|
||||
}
|
||||
if (mime.includes(MimeTypePrefix.TEXT) || TEXT_FILE_EXTENSION_REGEX.test(u)) {
|
||||
return FileText;
|
||||
}
|
||||
if (u.includes(UriPattern.DATABASE_KEYWORD) || u.includes(UriPattern.DATABASE_SCHEME)) {
|
||||
return Database;
|
||||
}
|
||||
return File;
|
||||
}
|
||||
|
||||
const ResourceIcon = $derived(getResourceIcon(extra.mimeType, extra.uri));
|
||||
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'flex flex-shrink-0 items-center gap-2 rounded-md border border-border/50 bg-muted/30 p-0.5 px-2 text-sm',
|
||||
onClick && 'cursor-pointer hover:bg-muted/50',
|
||||
className
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
disabled={!onClick}
|
||||
>
|
||||
<ResourceIcon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
<span class="max-w-[150px] truncate">
|
||||
{extra.name}
|
||||
</span>
|
||||
|
||||
{#if !readonly && onRemove}
|
||||
<ActionIconRemove class="bg-transparent" id={extra.uri} onRemove={() => onRemove?.()} />
|
||||
{/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>
|
||||
|
|
@ -1,15 +1,17 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentMcpPrompt,
|
||||
ChatAttachmentMcpResourceStored,
|
||||
ChatAttachmentThumbnailImage,
|
||||
ChatAttachmentThumbnailFile,
|
||||
HorizontalScrollCarousel,
|
||||
DialogChatAttachmentPreview,
|
||||
DialogChatAttachmentsViewAll
|
||||
DialogChatAttachmentsViewAll,
|
||||
DialogMcpResourcePreview
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import { getAttachmentDisplayItems } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -52,6 +54,8 @@
|
|||
let isScrollable = $state(false);
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
|
||||
let mcpResourcePreviewOpen = $state(false);
|
||||
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
|
||||
let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
|
||||
let viewAllDialogOpen = $state(false);
|
||||
|
||||
|
|
@ -70,6 +74,13 @@
|
|||
previewDialogOpen = true;
|
||||
}
|
||||
|
||||
function openMcpResourcePreview(extra: DatabaseMessageExtraMcpResource, event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
mcpResourcePreviewExtra = extra;
|
||||
mcpResourcePreviewOpen = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (carouselRef && displayItems.length) {
|
||||
carouselRef.resetScroll();
|
||||
|
|
@ -111,6 +122,15 @@
|
|||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
|
||||
<ChatAttachmentMcpResourceStored
|
||||
class="flex-shrink-0 {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
extra={item.attachment as DatabaseMessageExtraMcpResource}
|
||||
{readonly}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
onClick={(event) =>
|
||||
openMcpResourcePreview(item.attachment as DatabaseMessageExtraMcpResource, event)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
|
||||
|
|
@ -182,6 +202,14 @@
|
|||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{:else if item.isMcpResource && item.attachment?.type === AttachmentType.MCP_RESOURCE}
|
||||
<ChatAttachmentMcpResourceStored
|
||||
extra={item.attachment as DatabaseMessageExtraMcpResource}
|
||||
{readonly}
|
||||
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
|
||||
onClick={(event) =>
|
||||
openMcpResourcePreview(item.attachment as DatabaseMessageExtraMcpResource, event)}
|
||||
/>
|
||||
{:else if item.isImage && item.preview}
|
||||
<ChatAttachmentThumbnailImage
|
||||
class="cursor-pointer"
|
||||
|
|
@ -238,3 +266,7 @@
|
|||
{imageClass}
|
||||
{activeModelId}
|
||||
/>
|
||||
|
||||
{#if mcpResourcePreviewExtra}
|
||||
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -545,6 +545,9 @@
|
|||
<DialogMcpResources
|
||||
bind:open={isResourcePickerOpen}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
onAttach={(resource) => {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
}}
|
||||
onOpenChange={(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
preSelectedResourceUri = undefined;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,12 @@ export { default as ChatAttachmentMcpPrompt } from './ChatAttachments/ChatAttach
|
|||
*/
|
||||
export { default as ChatAttachmentMcpResource } from './ChatAttachments/ChatAttachmentMcpResource.svelte';
|
||||
|
||||
/**
|
||||
* Displays a stored MCP Resource attachment (from database extras) with icon,
|
||||
* name, and server info tooltip. Compact chip style matching live resource display.
|
||||
*/
|
||||
export { default as ChatAttachmentMcpResourceStored } from './ChatAttachments/ChatAttachmentMcpResourceStored.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,135 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Download } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { SyntaxHighlightedCode, ActionIconCopyToClipboard } from '$lib/components/app';
|
||||
import { getLanguageFromFilename } from '$lib/utils';
|
||||
import { MimeTypePrefix, MimeTypeIncludes } from '$lib/enums';
|
||||
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
extra: DatabaseMessageExtraMcpResource;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onOpenChange, extra }: Props = $props();
|
||||
|
||||
const serverName = $derived(mcpStore.getServerDisplayName(extra.serverName));
|
||||
const favicon = $derived(mcpStore.getServerFavicon(extra.serverName));
|
||||
|
||||
function isCode(mimeType?: string, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return (
|
||||
mime.includes(MimeTypeIncludes.JSON) ||
|
||||
mime.includes(MimeTypeIncludes.JAVASCRIPT) ||
|
||||
mime.includes(MimeTypeIncludes.TYPESCRIPT) ||
|
||||
/\.(js|ts|json|yaml|yml|xml|html|css|py|rs|go|java|cpp|c|h|rb|sh|toml)$/i.test(u)
|
||||
);
|
||||
}
|
||||
|
||||
function isImage(mimeType?: string, uri?: string): boolean {
|
||||
const mime = mimeType?.toLowerCase() || '';
|
||||
const u = uri?.toLowerCase() || '';
|
||||
return mime.startsWith(MimeTypePrefix.IMAGE) || /\.(png|jpg|jpeg|gif|svg|webp)$/i.test(u);
|
||||
}
|
||||
|
||||
function getLanguage(): string {
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.JSON)) return 'json';
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.JAVASCRIPT)) return 'javascript';
|
||||
if (extra.mimeType?.includes(MimeTypeIncludes.TYPESCRIPT)) return 'typescript';
|
||||
// Try to detect from URI/name
|
||||
const name = extra.name || extra.uri || '';
|
||||
return getLanguageFromFilename(name) || 'plaintext';
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!extra.content) return;
|
||||
|
||||
const blob = new Blob([extra.content], { type: extra.mimeType || 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = extra.name || 'resource.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="pr-8">{extra.name}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">{extra.uri}</span>
|
||||
|
||||
{#if serverName}
|
||||
<span class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
·
|
||||
{#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}
|
||||
{serverName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if extra.mimeType}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-xs">{extra.mimeType}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex justify-end gap-1">
|
||||
<ActionIconCopyToClipboard
|
||||
text={extra.content}
|
||||
canCopy={!!extra.content}
|
||||
ariaLabel="Copy content"
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
onclick={handleDownload}
|
||||
disabled={!extra.content}
|
||||
title="Download content"
|
||||
>
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-auto">
|
||||
{#if isImage(extra.mimeType, extra.uri) && extra.content}
|
||||
<div class="flex items-center justify-center">
|
||||
<img
|
||||
src={extra.content.startsWith('data:')
|
||||
? extra.content
|
||||
: `data:${extra.mimeType || 'image/png'};base64,${extra.content}`}
|
||||
alt={extra.name}
|
||||
class="max-h-[70vh] max-w-full rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
{:else if isCode(extra.mimeType, extra.uri) && extra.content}
|
||||
<SyntaxHighlightedCode code={extra.content} language={getLanguage()} maxHeight="70vh" />
|
||||
{:else if extra.content}
|
||||
<pre
|
||||
class="max-h-[70vh] overflow-auto rounded-md border bg-muted/30 p-4 font-mono text-sm break-words whitespace-pre-wrap">{extra.content}</pre>
|
||||
{:else}
|
||||
<div class="py-8 text-center text-sm text-muted-foreground">No content available</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
|
@ -473,3 +473,27 @@ export { default as DialogModelInformation } from './DialogModelInformation.svel
|
|||
* ```
|
||||
*/
|
||||
export { default as DialogMcpResources } from './DialogMcpResources.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpResourcePreview** - MCP resource content preview
|
||||
*
|
||||
* Dialog for previewing the content of a stored MCP resource attachment.
|
||||
* Displays the resource content with syntax highlighting for code,
|
||||
* image rendering for images, and plain text for other content.
|
||||
*
|
||||
* **Features:**
|
||||
* - Syntax highlighted code preview
|
||||
* - Image rendering for image resources
|
||||
* - Copy to clipboard and download actions
|
||||
* - Server name and favicon display
|
||||
* - MIME type badge
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogMcpResourcePreview
|
||||
* bind:open={previewOpen}
|
||||
* extra={mcpResourceExtra}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export const ATTACHMENT_LABEL_FILE = 'File';
|
||||
export const ATTACHMENT_LABEL_PDF_FILE = 'PDF File';
|
||||
export const ATTACHMENT_LABEL_MCP_PROMPT = 'MCP Prompt';
|
||||
export const ATTACHMENT_LABEL_MCP_RESOURCE = 'MCP Resource';
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export enum AttachmentType {
|
|||
AUDIO = 'AUDIO',
|
||||
IMAGE = 'IMAGE',
|
||||
MCP_PROMPT = 'MCP_PROMPT',
|
||||
MCP_RESOURCE = 'MCP_RESOURCE',
|
||||
PDF = 'PDF',
|
||||
TEXT = 'TEXT',
|
||||
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils';
|
|||
import { AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
import {
|
||||
ATTACHMENT_LABEL_PDF_FILE,
|
||||
ATTACHMENT_LABEL_MCP_PROMPT
|
||||
ATTACHMENT_LABEL_MCP_PROMPT,
|
||||
ATTACHMENT_LABEL_MCP_RESOURCE
|
||||
} from '$lib/constants/attachment-labels';
|
||||
import {
|
||||
AttachmentType,
|
||||
|
|
@ -12,7 +13,7 @@ import {
|
|||
UrlPrefix
|
||||
} from '$lib/enums';
|
||||
import type { ApiChatMessageContentPart, ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
|
||||
import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
|
||||
/**
|
||||
|
|
@ -796,6 +797,23 @@ export class ChatService {
|
|||
});
|
||||
}
|
||||
|
||||
const mcpResources = message.extra.filter(
|
||||
(extra: DatabaseMessageExtra): extra is DatabaseMessageExtraMcpResource =>
|
||||
extra.type === AttachmentType.MCP_RESOURCE
|
||||
);
|
||||
|
||||
for (const mcpResource of mcpResources) {
|
||||
contentParts.push({
|
||||
type: ContentPartType.TEXT,
|
||||
text: formatAttachmentText(
|
||||
ATTACHMENT_LABEL_MCP_RESOURCE,
|
||||
mcpResource.name,
|
||||
mcpResource.content,
|
||||
mcpResource.serverName
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
const result: ApiChatMessageData = {
|
||||
role: message.role as MessageRole,
|
||||
content: contentParts
|
||||
|
|
|
|||
|
|
@ -447,6 +447,11 @@ class ChatStore {
|
|||
if (!content.trim() && (!extras || extras.length === 0)) return;
|
||||
const activeConv = conversationsStore.activeConversation;
|
||||
if (activeConv && this.isChatLoadingInternal(activeConv.id)) return;
|
||||
|
||||
// Consume MCP resource attachments - converts them to extras and clears the live store
|
||||
const resourceExtras = mcpStore.consumeResourceAttachmentsAsExtras();
|
||||
const allExtras = resourceExtras.length > 0 ? [...(extras || []), ...resourceExtras] : extras;
|
||||
|
||||
let isNewConversation = false;
|
||||
if (!activeConv) {
|
||||
await conversationsStore.createConversation();
|
||||
|
|
@ -478,7 +483,7 @@ class ChatStore {
|
|||
content,
|
||||
MessageType.TEXT,
|
||||
parentIdForUserMessage ?? '-1',
|
||||
extras
|
||||
allExtras
|
||||
);
|
||||
if (isNewConversation && content)
|
||||
await conversationsStore.updateConversationName(currentConv.id, content.trim());
|
||||
|
|
@ -686,6 +691,7 @@ class ChatStore {
|
|||
}
|
||||
};
|
||||
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
|
||||
|
||||
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
|
||||
if (agenticConfig.enabled) {
|
||||
const agenticResult = await agenticStore.runAgenticFlow({
|
||||
|
|
@ -698,29 +704,16 @@ class ChatStore {
|
|||
});
|
||||
if (agenticResult.handled) return;
|
||||
}
|
||||
const resourceContext = mcpStore.getResourceContextForChat();
|
||||
let messagesWithResources = allMessages;
|
||||
|
||||
if (resourceContext) {
|
||||
messagesWithResources = allMessages.map((msg, idx) => {
|
||||
if (idx === allMessages.length - 1 && msg.role === MessageRole.USER) {
|
||||
return {
|
||||
...msg,
|
||||
content: resourceContext + '\n\n' + msg.content
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
mcpStore.clearResourceAttachments();
|
||||
}
|
||||
const completionOptions = {
|
||||
...this.getApiOptions(),
|
||||
...(effectiveModel ? { model: effectiveModel } : {}),
|
||||
...streamCallbacks
|
||||
};
|
||||
|
||||
await ChatService.sendMessage(
|
||||
messagesWithResources,
|
||||
{
|
||||
...this.getApiOptions(),
|
||||
...(effectiveModel ? { model: effectiveModel } : {}),
|
||||
...streamCallbacks
|
||||
},
|
||||
allMessages,
|
||||
completionOptions,
|
||||
assistantMessage.convId,
|
||||
abortController.signal
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
*/
|
||||
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import type {
|
||||
MCPResource,
|
||||
MCPResourceTemplate,
|
||||
|
|
@ -524,6 +525,43 @@ class MCPResourceStore {
|
|||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert current resource attachments to DatabaseMessageExtra[] for persisting with a message.
|
||||
* Each attachment becomes a DatabaseMessageExtraMcpResource stored on the user message.
|
||||
*/
|
||||
toMessageExtras(): import('$lib/types').DatabaseMessageExtraMcpResource[] {
|
||||
const extras: import('$lib/types').DatabaseMessageExtraMcpResource[] = [];
|
||||
|
||||
for (const attachment of this._attachments) {
|
||||
if (attachment.error) continue;
|
||||
if (!attachment.content || attachment.content.length === 0) continue;
|
||||
|
||||
const resourceName = attachment.resource.title || attachment.resource.name;
|
||||
const contentParts: string[] = [];
|
||||
|
||||
for (const content of attachment.content) {
|
||||
if ('text' in content && content.text) {
|
||||
contentParts.push(content.text);
|
||||
} else if ('blob' in content && content.blob) {
|
||||
contentParts.push(`[Binary content: ${content.mimeType || 'unknown type'}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
extras.push({
|
||||
type: AttachmentType.MCP_RESOURCE,
|
||||
name: resourceName,
|
||||
uri: attachment.resource.uri,
|
||||
serverName: attachment.resource.serverName,
|
||||
content: contentParts.join('\n'),
|
||||
mimeType: attachment.resource.mimeType
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return extras;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpResourceStore = new MCPResourceStore();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { browser } from '$app/environment';
|
|||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { parseMcpServerSettings, detectMcpTransportFromUrl } from '$lib/utils';
|
||||
import { parseMcpServerSettings, detectMcpTransportFromUrl, getFaviconUrl } from '$lib/utils';
|
||||
import { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus, MCPRefType } from '$lib/enums';
|
||||
import {
|
||||
DEFAULT_MCP_CONFIG,
|
||||
|
|
@ -298,17 +298,13 @@ class MCPStore {
|
|||
|
||||
/**
|
||||
* Get favicon URL for an MCP server by its ID.
|
||||
* Uses Google's favicon service for consistent display.
|
||||
* Returns null if server is not found.
|
||||
*/
|
||||
getServerFavicon(serverId: string): string | null {
|
||||
const server = this.getServerById(serverId);
|
||||
if (!server) return null;
|
||||
try {
|
||||
const url = new URL(server.url);
|
||||
return `${url.origin}/favicon.ico`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return getFaviconUrl(server.url);
|
||||
}
|
||||
|
||||
isAnyServerLoading(): boolean {
|
||||
|
|
@ -1425,6 +1421,18 @@ class MCPStore {
|
|||
getResourceContextForChat(): string {
|
||||
return mcpResourceStore.formatAttachmentsForContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert current resource attachments to DatabaseMessageExtra[] and clear them.
|
||||
* Called during message send to persist resources with the user message.
|
||||
*/
|
||||
consumeResourceAttachmentsAsExtras(): import('$lib/types').DatabaseMessageExtraMcpResource[] {
|
||||
const extras = mcpResourceStore.toMessageExtras();
|
||||
if (extras.length > 0) {
|
||||
mcpResourceStore.clearAttachments();
|
||||
}
|
||||
return extras;
|
||||
}
|
||||
}
|
||||
|
||||
export const mcpStore = new MCPStore();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface ChatAttachmentDisplayItem {
|
|||
preview?: string;
|
||||
isImage: boolean;
|
||||
isMcpPrompt?: boolean;
|
||||
isMcpResource?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadError?: string;
|
||||
uploadedFile?: ChatUploadedFile;
|
||||
|
|
|
|||
|
|
@ -61,12 +61,22 @@ export interface DatabaseMessageExtraMcpPrompt {
|
|||
arguments?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface DatabaseMessageExtraMcpResource {
|
||||
type: AttachmentType.MCP_RESOURCE;
|
||||
name: string;
|
||||
uri: string;
|
||||
serverName: string;
|
||||
content: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export type DatabaseMessageExtra =
|
||||
| DatabaseMessageExtraImageFile
|
||||
| DatabaseMessageExtraTextFile
|
||||
| DatabaseMessageExtraAudioFile
|
||||
| DatabaseMessageExtraPdfFile
|
||||
| DatabaseMessageExtraMcpPrompt
|
||||
| DatabaseMessageExtraMcpResource
|
||||
| DatabaseMessageExtraLegacyContext;
|
||||
|
||||
export interface DatabaseMessage {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export type {
|
|||
DatabaseMessageExtraImageFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
DatabaseMessageExtraMcpPrompt,
|
||||
DatabaseMessageExtraMcpResource,
|
||||
DatabaseMessageExtraPdfFile,
|
||||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtra,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ function isMcpPromptAttachment(attachment: DatabaseMessageExtra): boolean {
|
|||
return attachment.type === AttachmentType.MCP_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an attachment is an MCP resource
|
||||
*/
|
||||
function isMcpResourceAttachment(attachment: DatabaseMessageExtra): boolean {
|
||||
return attachment.type === AttachmentType.MCP_RESOURCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file type category from an uploaded file, checking both MIME type and extension
|
||||
*/
|
||||
|
|
@ -63,6 +70,7 @@ export function getAttachmentDisplayItems(
|
|||
for (const [index, attachment] of attachments.entries()) {
|
||||
const isImage = isImageFile(attachment);
|
||||
const isMcpPrompt = isMcpPromptAttachment(attachment);
|
||||
const isMcpResource = isMcpResourceAttachment(attachment);
|
||||
|
||||
items.push({
|
||||
id: `attachment-${index}`,
|
||||
|
|
@ -70,6 +78,7 @@ export function getAttachmentDisplayItems(
|
|||
preview: isImage && 'base64Url' in attachment ? attachment.base64Url : undefined,
|
||||
isImage,
|
||||
isMcpPrompt,
|
||||
isMcpResource,
|
||||
attachment,
|
||||
attachmentIndex: index,
|
||||
textContent: 'content' in attachment ? attachment.content : undefined
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
DatabaseMessageExtraTextFile,
|
||||
DatabaseMessageExtraLegacyContext,
|
||||
DatabaseMessageExtraMcpPrompt,
|
||||
DatabaseMessageExtraMcpResource,
|
||||
ClipboardTextAttachment,
|
||||
ClipboardMcpPromptAttachment,
|
||||
ClipboardAttachment,
|
||||
|
|
@ -104,7 +105,7 @@ export function formatMessageForClipboard(
|
|||
extras?: DatabaseMessageExtra[],
|
||||
asPlainText: boolean = false
|
||||
): string {
|
||||
// Filter text-like attachments (TEXT, LEGACY_CONTEXT, and MCP_PROMPT types)
|
||||
// Filter text-like attachments (TEXT, LEGACY_CONTEXT, MCP_PROMPT, and MCP_RESOURCE types)
|
||||
const textAttachments =
|
||||
extras?.filter(
|
||||
(
|
||||
|
|
@ -112,10 +113,12 @@ export function formatMessageForClipboard(
|
|||
): extra is
|
||||
| DatabaseMessageExtraTextFile
|
||||
| DatabaseMessageExtraLegacyContext
|
||||
| DatabaseMessageExtraMcpPrompt =>
|
||||
| DatabaseMessageExtraMcpPrompt
|
||||
| DatabaseMessageExtraMcpResource =>
|
||||
extra.type === AttachmentType.TEXT ||
|
||||
extra.type === AttachmentType.LEGACY_CONTEXT ||
|
||||
extra.type === AttachmentType.MCP_PROMPT
|
||||
extra.type === AttachmentType.MCP_PROMPT ||
|
||||
extra.type === AttachmentType.MCP_RESOURCE
|
||||
) ?? [];
|
||||
|
||||
if (textAttachments.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
import { MCPTransportType, MCPLogLevel, UrlPrefix, MimeTypePrefix } from '$lib/enums';
|
||||
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
|
||||
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
import { Info, AlertTriangle, XCircle } from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
|
|
@ -51,12 +51,13 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
|
|||
const id =
|
||||
typeof (entry as { id?: unknown })?.id === 'string' && (entry as { id?: string }).id?.trim()
|
||||
? (entry as { id: string }).id.trim()
|
||||
: `server-${index + 1}`;
|
||||
: `${MCP_SERVER_ID_PREFIX}${index + 1}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined,
|
||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||
|
|
|
|||
Loading…
Reference in New Issue