Add MCP integration and update AI SDK for MCP compatibility

This commit is contained in:
Jimmyyy 2025-05-14 15:33:09 +08:00 committed by Nirmal Arya
parent bb8fc48bea
commit f73d1392e7
15 changed files with 5704 additions and 3966 deletions

View File

@ -7,6 +7,8 @@ import { WORK_DIR } from '~/utils/constants';
import WithTooltip from '~/components/ui/Tooltip';
import type { Message } from 'ai';
import type { ProviderInfo } from '~/types/model';
import { ToolInvocation } from './ToolInvocation';
import type { ToolInvocationAnnotation } from '~/types/context';
interface AssistantMessageProps {
content: string;
@ -81,46 +83,50 @@ export const AssistantMessage = memo(
totalTokens: number;
} = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value;
// Extract tool invocations from annotations
const toolInvocations = filteredAnnotations.filter(
(annotation) => annotation.type === 'toolInvocation',
) as ToolInvocationAnnotation[];
return (
<div className="overflow-hidden w-full">
<>
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
{(codeContext || chatSummary) && (
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
{chatSummary && (
<div className="max-w-chat">
<div className="max-w-chat">
{chatSummary && (
<div className="summary max-h-96 flex flex-col">
<h2 className="border border-bolt-elements-borderColor rounded-md p4">Summary</h2>
<div style={{ zoom: 0.7 }} className="overflow-y-auto m4">
<Markdown>{chatSummary}</Markdown>
</div>
</div>
{codeContext && (
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
<h2>Context</h2>
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
{codeContext.map((x) => {
const normalized = normalizedFilePath(x);
return (
<Fragment key={normalized}>
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openArtifactInWorkbench(normalized);
}}
>
{normalized}
</code>
</Fragment>
);
})}
</div>
)}
{codeContext && (
<div className="code-context flex flex-col p4 border border-bolt-elements-borderColor rounded-md">
<h2>Context</h2>
<div className="flex gap-4 mt-4 bolt" style={{ zoom: 0.6 }}>
{codeContext.map((x) => {
const normalized = normalizedFilePath(x);
return (
<Fragment key={normalized}>
<code
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openArtifactInWorkbench(normalized);
}}
>
{normalized}
</code>
</Fragment>
);
})}
</div>
)}
</div>
)}
</div>
)}
<div className="context"></div>
</Popover>
)}
@ -158,6 +164,9 @@ export const AssistantMessage = memo(
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
{content}
</Markdown>
{/* Display tool invocations if present */}
{toolInvocations.length > 0 && <ToolInvocation toolInvocations={toolInvocations} />}
</div>
);
},

View File

@ -17,6 +17,10 @@ import styles from './BaseChat.module.scss';
import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons';
import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
import GitCloneButton from './GitCloneButton';
import { McpConnection } from './MCPConnection';
import FilePreview from './FilePreview';
import { ModelSelector } from '~/components/chat/ModelSelector';
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
import type { ProviderInfo } from '~/types/model';
import StarterTemplates from './StarterTemplates';
import type { ActionAlert, SupabaseAlert, DeployAlert } from '~/types/actions';

View File

@ -4,7 +4,7 @@
*/
import { useStore } from '@nanostores/react';
import type { Message } from 'ai';
import { useChat } from 'ai/react';
import { useChat } from '@ai-sdk/react';
import { useAnimate } from 'framer-motion';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify';
@ -27,6 +27,8 @@ import { logStore } from '~/lib/stores/logs';
import { streamingState } from '~/lib/stores/streaming';
import { filesToArtifacts } from '~/utils/fileUtils';
import { supabaseConnection } from '~/lib/stores/supabase';
import { useMCPConfig } from '~/lib/hooks/useMCPConfig';
import type { TextUIPart, FileUIPart, Attachment } from '@ai-sdk/ui-utils';
const toastAnimation = cssTransition({
enter: 'animated fadeInRight',
@ -127,6 +129,7 @@ export const ChatImpl = memo(
const actionAlert = useStore(workbenchStore.alert);
const deployAlert = useStore(workbenchStore.deployAlert);
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
const { config: mcpConfig } = useMCPConfig();
const selectedProject = supabaseConn.stats?.projects?.find(
(project) => project.id === supabaseConn.selectedProjectId,
);
@ -178,6 +181,7 @@ export const ChatImpl = memo(
anonKey: supabaseConn?.credentials?.anonKey,
},
},
mcpConfig: mcpConfig || undefined,
},
sendExtraMessageFields: true,
onError: (e) => {
@ -222,12 +226,7 @@ export const ChatImpl = memo(
runAnimation();
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
},
] as any, // Type assertion to bypass compiler check
content: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${prompt}`,
});
}
}, [model, provider, searchParams]);
@ -300,6 +299,59 @@ export const ChatImpl = memo(
setChatStarted(true);
};
// Helper function to create message parts array from text and images
const createMessageParts = (text: string, images: string[] = []): Array<TextUIPart | FileUIPart> => {
// Create an array of properly typed message parts
const parts: Array<TextUIPart | FileUIPart> = [
{
type: 'text',
text,
},
];
// Add image parts if any
images.forEach((imageData) => {
// Extract correct MIME type from the data URL
const mimeType = imageData.split(';')[0].split(':')[1] || 'image/jpeg';
// Create file part according to AI SDK format
parts.push({
type: 'file',
mimeType,
data: imageData.replace(/^data:image\/[^;]+;base64,/, ''),
});
});
return parts;
};
// Helper function to convert File[] to Attachment[] for AI SDK
const filesToAttachments = async (files: File[]): Promise<Attachment[] | undefined> => {
if (files.length === 0) {
return undefined;
}
const attachments = await Promise.all(
files.map(
(file) =>
new Promise<Attachment>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve({
name: file.name,
contentType: file.type,
url: reader.result as string,
});
};
reader.readAsDataURL(file);
}),
),
);
return attachments;
};
const sendMessage = async (_event: React.UIEvent, messageInput?: string) => {
const messageContent = messageInput || input;
@ -340,20 +392,16 @@ export const ChatImpl = memo(
if (temResp) {
const { assistantMessage, userMessage } = temResp;
// Format message with text in the proper parts structure
const userMessageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`;
setMessages([
{
id: `1-${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
content: userMessageText,
parts: createMessageParts(userMessageText, imageDataList),
},
{
id: `2-${new Date().getTime()}`,
@ -367,7 +415,13 @@ export const ChatImpl = memo(
annotations: ['hidden'],
},
]);
reload();
const reloadOptions =
uploadedFiles.length > 0
? { experimental_attachments: await filesToAttachments(uploadedFiles) }
: undefined;
reload(reloadOptions);
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
@ -385,23 +439,21 @@ export const ChatImpl = memo(
}
// If autoSelectTemplate is disabled or template selection failed, proceed with normal message
const userMessageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`;
const attachments = uploadedFiles.length > 0 ? await filesToAttachments(uploadedFiles) : undefined;
setMessages([
{
id: `${new Date().getTime()}`,
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
content: userMessageText,
parts: createMessageParts(userMessageText, imageDataList),
experimental_attachments: attachments,
},
]);
reload();
reload(attachments ? { experimental_attachments: attachments } : undefined);
setFakeLoading(false);
setInput('');
Cookies.remove(PROMPT_COOKIE_KEY);
@ -426,35 +478,35 @@ export const ChatImpl = memo(
if (modifiedFiles !== undefined) {
const userUpdateArtifact = filesToArtifacts(modifiedFiles, `${Date.now()}`);
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
});
const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${userUpdateArtifact}${finalMessageContent}`;
const attachmentOptions =
uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined;
append(
{
role: 'user',
content: messageText,
parts: createMessageParts(messageText, imageDataList),
},
attachmentOptions,
);
workbenchStore.resetAllFileModifications();
} else {
append({
role: 'user',
content: [
{
type: 'text',
text: `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`,
},
...imageDataList.map((imageData) => ({
type: 'image',
image: imageData,
})),
] as any,
});
const messageText = `[Model: ${model}]\n\n[Provider: ${provider.name}]\n\n${finalMessageContent}`;
const attachmentOptions =
uploadedFiles.length > 0 ? { experimental_attachments: await filesToAttachments(uploadedFiles) } : undefined;
append(
{
role: 'user',
content: messageText,
parts: createMessageParts(messageText, imageDataList),
},
attachmentOptions,
);
}
setInput('');

View File

@ -0,0 +1,416 @@
import { useEffect, useState } from 'react';
import { classNames } from '~/utils/classNames';
import { Dialog, DialogRoot, DialogClose, DialogTitle, DialogButton } from '~/components/ui/Dialog';
import { useMCPConfig, type MCPConfig } from '~/lib/hooks/useMCPConfig';
import { IconButton } from '~/components/ui/IconButton';
// Example MCP configuration that users can load
const EXAMPLE_MCP_CONFIG: MCPConfig = {
mcpServers: {
everything: {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-everything'],
},
git: {
command: 'uvx',
args: ['mcp-server-git'],
},
'remote-sse': {
type: 'sse',
url: 'http://localhost:8000/sse',
},
},
};
type ServerStatus = Record<string, boolean>;
type ServerErrors = Record<string, string>;
type ServerTools = Record<string, any>;
export function McpConnection() {
const { config, updateConfig, lastUpdate, isLoading } = useMCPConfig();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [configText, setConfigText] = useState('');
const [error, setError] = useState<string | null>(null);
const [serverStatus, setServerStatus] = useState<ServerStatus>({});
const [serverErrors, setServerErrors] = useState<ServerErrors>({});
const [serverTools, setServerTools] = useState<ServerTools>({});
const [checkingServers, setCheckingServers] = useState(false);
const [configTextParsed, setConfigTextParsed] = useState<MCPConfig | null>(null);
const [expandedServer, setExpandedServer] = useState<string | null>(null);
const [isInitialLoad, setIsInitialLoad] = useState(true);
// Initialize config text from config
useEffect(() => {
if (config) {
setConfigText(JSON.stringify(config, null, 2));
setConfigTextParsed(config);
} else {
setConfigText(JSON.stringify({ mcpServers: {} }, null, 2));
setConfigTextParsed({ mcpServers: {} });
}
}, [config, lastUpdate]);
// Check server availability on initial load
useEffect(() => {
if (isInitialLoad && configTextParsed?.mcpServers && Object.keys(configTextParsed.mcpServers).length > 0) {
checkServerAvailability();
setIsInitialLoad(false);
}
}, [configTextParsed, isInitialLoad]);
// Reset initial load flag when config is updated externally
useEffect(() => {
setIsInitialLoad(true);
}, [lastUpdate]);
// Parse the textarea content when it changes
useEffect(() => {
try {
const parsed = JSON.parse(configText) as MCPConfig;
setConfigTextParsed(parsed);
if (error?.includes('JSON')) {
setError(null);
}
} catch (e) {
setConfigTextParsed(null);
setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`);
}
}, [configText, error]);
const handleSave = () => {
try {
const parsed = JSON.parse(configText) as MCPConfig;
updateConfig(parsed);
setError(null);
setIsDialogOpen(false);
} catch {
setError('Invalid JSON format');
}
};
const handleLoadExample = () => {
setConfigText(JSON.stringify(EXAMPLE_MCP_CONFIG, null, 2));
setError(null);
};
const checkServerAvailability = async () => {
try {
const parsed = JSON.parse(configText) as MCPConfig;
if (!parsed?.mcpServers) {
setError('No servers configured or invalid configuration');
return;
}
setCheckingServers(true);
setError(null);
setServerErrors({});
setServerTools({});
try {
const response = await fetch('/api/mcp-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mcpServers: parsed.mcpServers }),
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (typeof data === 'object' && data !== null && 'serverStatus' in data && 'serverErrors' in data) {
setServerStatus(data.serverStatus as ServerStatus);
setServerErrors((data.serverErrors as ServerErrors) || ({} as ServerErrors));
if ('serverTools' in data) {
setServerTools((data.serverTools as ServerTools) || ({} as ServerTools));
}
} else {
throw new Error('Invalid response format from server');
}
} catch (e) {
setError(`Failed to check server availability: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setCheckingServers(false);
}
} catch (e) {
setError(`Invalid JSON format: ${e instanceof Error ? e.message : String(e)}`);
setCheckingServers(false);
}
};
const toggleServerExpanded = (serverName: string) => {
setExpandedServer(expandedServer === serverName ? null : serverName);
};
const handleDialogOpen = (open: boolean) => {
setIsDialogOpen(open);
if (
open &&
configTextParsed?.mcpServers &&
Object.keys(configTextParsed.mcpServers).length > 0 &&
Object.keys(serverStatus).length === 0
) {
checkServerAvailability();
}
};
const formatToolSchema = (toolName: string, toolSchema: any) => {
if (!toolSchema) {
return null;
}
try {
const parameters = toolSchema.parameters?.properties || {};
return (
<div className="mt-2 ml-4 p-2 rounded-md bg-bolt-elements-background-depth-2 text-xs font-mono">
<div className="font-medium mb-1">{toolName}</div>
<div className="text-bolt-elements-textSecondary">{toolSchema.description || 'No description available'}</div>
{Object.keys(parameters).length > 0 && (
<div className="mt-2">
<div className="font-medium mb-1">Parameters:</div>
<div className="ml-2 space-y-1">
{Object.entries(parameters).map(([paramName, paramDetails]: [string, any]) => (
<div key={paramName}>
<span className="text-bolt-elements-textAccent">{paramName}</span>
{paramDetails.required && <span className="text-red-500 ml-1">*</span>}
<span className="text-bolt-elements-textSecondary ml-2">
{paramDetails.type || 'any'}
{paramDetails.description ? ` - ${paramDetails.description}` : ''}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
} catch (e) {
return (
<div className="mt-2 ml-4 p-2 rounded-md bg-red-100 dark:bg-red-900 text-xs">
Error parsing tool schema: {e instanceof Error ? e.message : String(e)}
</div>
);
}
};
const StatusBadge = ({ status }: { status: 'checking' | 'available' | 'unavailable' }) => {
const badgeStyles = {
checking: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
available: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
unavailable: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
const text = {
checking: 'Checking...',
available: 'Available',
unavailable: 'Unavailable',
};
const icon =
status === 'checking' ? (
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
) : null;
return (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 ${badgeStyles[status]}`}>
{icon}
{text[status]}
</span>
);
};
const renderServerList = () => {
if (!configTextParsed?.mcpServers) {
return <p className="text-sm text-bolt-elements-textSecondary">Invalid configuration or no servers defined</p>;
}
const serverEntries = Object.entries(configTextParsed.mcpServers);
if (serverEntries.length === 0) {
return <p className="text-sm text-bolt-elements-textSecondary">No MCP servers configured</p>;
}
return (
<div className="space-y-2">
{serverEntries.map(([serverName, serverConfig]) => {
const isAvailable = serverStatus[serverName];
const statusKnown = serverName in serverStatus;
const errorMessage = serverErrors[serverName];
const serverToolsData = serverTools[serverName];
const isExpanded = expandedServer === serverName;
return (
<div key={serverName} className="flex flex-col py-1 px-2 rounded-md bg-bolt-elements-background-depth-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
onClick={() => toggleServerExpanded(serverName)}
className="flex items-center gap-1 text-bolt-elements-textPrimary"
>
<div className={`i-ph:${isExpanded ? 'caret-down' : 'caret-right'} w-3 h-3`} />
<span className="font-medium">{serverName}</span>
</div>
{serverConfig.type === 'sse' ? (
<span className="text-xs text-bolt-elements-textSecondary">SSE: {serverConfig.url}</span>
) : (
<span className="text-xs text-bolt-elements-textSecondary">
{serverConfig.command} {serverConfig.args?.join(' ')}
</span>
)}
</div>
{checkingServers ? (
<StatusBadge status="checking" />
) : (
statusKnown && <StatusBadge status={isAvailable ? 'available' : 'unavailable'} />
)}
</div>
{/* Display error message if server is unavailable */}
{statusKnown && !isAvailable && errorMessage && (
<div className="mt-1 ml-4 text-xs text-red-600 dark:text-red-400">Error: {errorMessage}</div>
)}
{/* Display tool schemas if server is expanded */}
{isExpanded && statusKnown && isAvailable && serverToolsData && (
<div className="mt-2">
<div className="text-xs font-medium ml-2 mb-1">Available Tools:</div>
{Object.keys(serverToolsData).length === 0 ? (
<div className="ml-4 text-xs text-bolt-elements-textSecondary">No tools available</div>
) : (
<div className="mt-1 space-y-2">
{Object.entries(serverToolsData).map(([toolName, toolSchema]) => (
<div key={toolName}>{formatToolSchema(toolName, toolSchema)}</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
};
return (
<div className="relative">
<div className="flex">
<IconButton onClick={() => setIsDialogOpen(!isDialogOpen)} title="Configure MCP" className="transition-all">
{isLoading ? (
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin" />
) : (
<img className="w-4 h-4" height="20" width="20" src="/icons/mcp.svg" alt="MCP" />
)}
</IconButton>
</div>
<DialogRoot open={isDialogOpen} onOpenChange={handleDialogOpen}>
{isDialogOpen && (
<Dialog className="max-w-4xl w-full p-6">
<div className="space-y-4 max-h-[80vh] overflow-y-auto pr-2">
<DialogTitle>
<img className="w-5 h-5" height="24" width="24" src="/icons/mcp.svg" alt="MCP" />
MCP Configuration
</DialogTitle>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-sm text-bolt-elements-textSecondary">Configured MCP Servers</label>
<button
onClick={checkServerAvailability}
disabled={
checkingServers ||
!configTextParsed ||
Object.keys(configTextParsed?.mcpServers || {}).length === 0
}
className={classNames(
'px-3 py-1 rounded-md text-xs flex items-center gap-1',
'border border-bolt-elements-borderColor',
'hover:bg-bolt-elements-background-depth-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{checkingServers ? (
<div className="i-svg-spinners:90-ring-with-bg w-3 h-3 text-bolt-elements-loader-progress animate-spin" />
) : (
<div className="i-ph:arrow-counter-clockwise w-3 h-3" />
)}
Check availability
</button>
</div>
{renderServerList()}
</div>
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Configuration JSON</label>
<textarea
value={configText}
onChange={(e) => setConfigText(e.target.value)}
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm font-mono h-72',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border',
error ? 'border-bolt-elements-icon-error' : 'border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-focus',
)}
/>
{error && <p className="mt-2 text-sm text-bolt-elements-icon-error">{error}</p>}
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
The MCP configuration format is identical to the one used in Claude Desktop.
<a
href="https://modelcontextprotocol.io/examples"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-link hover:underline inline-flex items-center gap-1"
>
View example servers
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
</div>
<div className="flex justify-between gap-2 mt-6">
<button
onClick={handleLoadExample}
className="px-4 py-2 rounded-lg text-sm border border-bolt-elements-borderColor
bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary
hover:bg-bolt-elements-background-depth-3"
>
Load Example
</button>
<div className="flex gap-2">
<DialogClose asChild>
<DialogButton type="secondary">Cancel</DialogButton>
</DialogClose>
<button
onClick={handleSave}
className="px-4 py-2 rounded-lg text-sm flex items-center gap-2
bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent
hover:bg-bolt-elements-item-backgroundActive"
>
<div className="i-ph:floppy-disk w-4 h-4" />
Save Configuration
</button>
</div>
</div>
</div>
</Dialog>
)}
</DialogRoot>
</div>
);
}

View File

@ -0,0 +1,188 @@
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useRef, useState } from 'react';
import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki';
import type { ToolInvocationAnnotation } from '~/types/context';
import { classNames } from '~/utils/classNames';
import { cubicEasingFn } from '~/utils/easings';
import { logger } from '~/utils/logger';
const highlighterOptions = {
langs: ['json'],
themes: ['light-plus', 'dark-plus'],
};
const jsonHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> =
import.meta.hot?.data.jsonHighlighter ?? (await createHighlighter(highlighterOptions));
if (import.meta.hot) {
import.meta.hot.data.jsonHighlighter = jsonHighlighter;
}
interface ToolInvocationProps {
toolInvocations: ToolInvocationAnnotation[];
}
export const ToolInvocation = memo(({ toolInvocations }: ToolInvocationProps) => {
const userToggledDetails = useRef(false);
const [showDetails, setShowDetails] = useState(false);
const toggleDetails = () => {
userToggledDetails.current = true;
setShowDetails(!showDetails);
};
if (toolInvocations.length === 0) {
return null;
}
return (
<div className="tool-invocation border border-bolt-elements-borderColor flex flex-col overflow-hidden rounded-lg w-full transition-border duration-150 mt-4">
<div className="flex">
<button
className="flex items-stretch bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover w-full overflow-hidden"
onClick={toggleDetails}
>
<div className="p-4">
<div className={'i-ph:wrench'} style={{ fontSize: '2rem' }}></div>
</div>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<div className="px-5 p-3.5 w-full text-left">
<div className="w-full text-bolt-elements-textPrimary font-medium leading-5 text-sm">
MCP Tool Invocations
</div>
<div className="w-full w-full text-bolt-elements-textSecondary text-xs mt-0.5">
{toolInvocations.length} tool{toolInvocations.length > 1 ? 's' : ''} used
</div>
</div>
</button>
<div className="bg-bolt-elements-artifacts-borderColor w-[1px]" />
<AnimatePresence>
<motion.button
initial={{ width: 0 }}
animate={{ width: 'auto' }}
exit={{ width: 0 }}
transition={{ duration: 0.15, ease: cubicEasingFn }}
className="bg-bolt-elements-artifacts-background hover:bg-bolt-elements-artifacts-backgroundHover"
onClick={toggleDetails}
>
<div className="p-4">
<div className={showDetails ? 'i-ph:caret-up-bold' : 'i-ph:caret-down-bold'}></div>
</div>
</motion.button>
</AnimatePresence>
</div>
<AnimatePresence>
{showDetails && (
<motion.div
className="details"
initial={{ height: 0 }}
animate={{ height: 'auto' }}
exit={{ height: '0px' }}
transition={{ duration: 0.15 }}
>
<div className="bg-bolt-elements-artifacts-borderColor h-[1px]" />
<div className="p-5 text-left bg-bolt-elements-actions-background">
<ToolList toolInvocations={toolInvocations} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
});
interface JsonCodeBlockProps {
className?: string;
code: string;
}
function JsonCodeBlock({ className, code }: JsonCodeBlockProps) {
let formattedCode = code;
try {
if (typeof code !== 'string') {
formattedCode = JSON.stringify(code, null, 2);
} else if (!code.trim().startsWith('{') && !code.trim().startsWith('[')) {
// Not JSON, keep as is
} else {
formattedCode = JSON.stringify(JSON.parse(code), null, 2);
}
} catch (e) {
// If parsing fails, keep original code
logger.error('Failed to parse JSON', { error: e });
}
return (
<div
className={classNames('text-xs rounded-md overflow-hidden', className)}
dangerouslySetInnerHTML={{
__html: jsonHighlighter.codeToHtml(formattedCode, {
lang: 'json',
theme: 'dark-plus',
}),
}}
style={{
padding: '0',
margin: '0',
}}
></div>
);
}
interface ToolListProps {
toolInvocations: ToolInvocationAnnotation[];
}
const toolVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
const ToolList = memo(({ toolInvocations }: ToolListProps) => {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<ul className="list-none space-y-4">
{toolInvocations.map((tool, index) => {
const isLast = index === toolInvocations.length - 1;
return (
<motion.li
key={index}
variants={toolVariants}
initial="hidden"
animate="visible"
transition={{
duration: 0.2,
ease: cubicEasingFn,
}}
>
<div className="flex items-center gap-1.5 text-sm mb-2">
<div className="text-lg text-bolt-elements-icon-success">
<div className="i-ph:check"></div>
</div>
<div className="font-semibold">{tool.toolName}</div>
</div>
<div className="ml-6 mb-2">
<div className="text-bolt-elements-textSecondary text-xs mb-1">Parameters:</div>
<div className="bg-[#1E1E1E] p-3 rounded-md">
<JsonCodeBlock className="mb-0" code={JSON.stringify(tool.parameters)} />
</div>
<div className="text-bolt-elements-textSecondary text-xs mt-3 mb-1">Result:</div>
<div
className={classNames('bg-[#1E1E1E] p-3 rounded-md', {
'mb-3.5': !isLast,
})}
>
<JsonCodeBlock className="mb-0" code={JSON.stringify(tool.result)} />
</div>
</div>
</motion.li>
);
})}
</ul>
</motion.div>
);
});

View File

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { db } from '~/lib/persistence/useChatHistory';
import { getMCPConfig, saveMCPConfig } from '~/lib/persistence/db';
import { logStore } from '~/lib/stores/logs';
export interface MCPConfig {
mcpServers: Record<
string,
{
command?: string;
args?: string[];
url?: string;
env?: Record<string, string>;
type?: string;
}
>;
}
// Create an event dispatcher to notify when the config changes
export const mcpConfigEvents = {
listeners: new Set<() => void>(),
addListener(callback: () => void) {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
},
notifyChange() {
this.listeners.forEach((callback) => callback());
},
};
export function useMCPConfig() {
const [config, setConfig] = useState<MCPConfig | null>(null);
const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
const [isLoading, setIsLoading] = useState(true);
const loadConfigFromDB = async () => {
if (!db) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const savedConfig = await getMCPConfig(db);
if (savedConfig) {
setConfig(savedConfig);
} else {
setConfig(null);
}
} catch (error) {
logStore.logError('Failed to load MCP config', error as Error, {
component: 'MCPConfig',
action: 'load',
type: 'error',
message: 'Failed to load MCP configuration from IndexedDB',
});
console.error('Error loading MCP config:', error);
setConfig(null);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadConfigFromDB();
// Add listener for config changes from other components
const cleanup = mcpConfigEvents.addListener(() => {
loadConfigFromDB();
setLastUpdate(Date.now());
});
return cleanup;
}, []);
const updateConfig = async (newConfig: MCPConfig) => {
if (!db) {
toast.error('Database is not available');
return;
}
try {
await saveMCPConfig(db, newConfig);
setConfig(newConfig);
setLastUpdate(Date.now());
// Notify other components that the config has changed
mcpConfigEvents.notifyChange();
toast.success('MCP configuration saved');
} catch (error) {
logStore.logError('Failed to save MCP config', error as Error, {
component: 'MCPConfig',
action: 'save',
type: 'error',
message: 'Failed to save MCP configuration to IndexedDB',
});
toast.error('Failed to save MCP configuration');
}
};
return {
config,
updateConfig,
lastUpdate,
isLoading,
};
}

View File

@ -2,11 +2,13 @@ import type { Message } from 'ai';
import { createScopedLogger } from '~/utils/logger';
import type { ChatHistoryItem } from './useChatHistory';
import type { Snapshot } from './types'; // Import Snapshot type
import type { MCPConfig } from '~/lib/hooks/useMCPConfig';
export interface IChatMetadata {
gitUrl: string;
gitBranch?: string;
netlifySiteId?: string;
apiActionIds?: string[]; // IDs of API actions associated with this chat
}
const logger = createScopedLogger('ChatHistory');
@ -19,7 +21,7 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
}
return new Promise((resolve) => {
const request = indexedDB.open('boltHistory', 2);
const request = indexedDB.open('boltHistory', 3); // Increment version to trigger upgrade
request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
const db = (event.target as IDBOpenDBRequest).result;
@ -38,6 +40,13 @@ export async function openDatabase(): Promise<IDBDatabase | undefined> {
db.createObjectStore('snapshots', { keyPath: 'chatId' });
}
}
// Add mcpConfig store for version 3
if (oldVersion < 3) {
if (!db.objectStoreNames.contains('mcpConfig')) {
db.createObjectStore('mcpConfig', { keyPath: 'id' });
}
}
};
request.onsuccess = (event: Event) => {
@ -341,3 +350,66 @@ export async function deleteSnapshot(db: IDBDatabase, chatId: string): Promise<v
};
});
}
// MCP Configuration functions
/**
* Save MCP configuration to IndexedDB
* @param db The IndexedDB database instance
* @param config The MCP configuration to save
*/
export async function saveMCPConfig(db: IDBDatabase, config: MCPConfig): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('mcpConfig', 'readwrite');
const store = transaction.objectStore('mcpConfig');
const configObject = {
id: 'default', // We use a fixed ID since we only have one config
config,
updatedAt: new Date().toISOString(),
};
const request = store.put(configObject);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
* Get MCP configuration from IndexedDB
* @param db The IndexedDB database instance
* @returns The MCP configuration or null if not found
*/
export async function getMCPConfig(db: IDBDatabase): Promise<MCPConfig | null> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('mcpConfig', 'readonly');
const store = transaction.objectStore('mcpConfig');
const request = store.get('default');
request.onsuccess = () => {
if (request.result) {
resolve(request.result.config);
} else {
resolve(null);
}
};
request.onerror = () => reject(request.error);
});
}
/**
* Delete MCP configuration from IndexedDB
* @param db The IndexedDB database instance
*/
export async function deleteMCPConfig(db: IDBDatabase): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction('mcpConfig', 'readwrite');
const store = transaction.objectStore('mcpConfig');
const request = store.delete('default');
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}

140
app/lib/services/mcp.ts Normal file
View File

@ -0,0 +1,140 @@
import { experimental_createMCPClient } from 'ai';
import { Experimental_StdioMCPTransport } from 'ai/mcp-stdio';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('mcp-service');
// MCP config types
export type StdioMCPConfig = {
type: 'stdio';
command: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
};
export type SSEMCPConfig = {
type: 'sse';
url: string;
};
export type MCPConfig = StdioMCPConfig | SSEMCPConfig;
export interface MCPClient {
tools: () => Promise<any>;
close: () => Promise<void>;
}
type ServerConfig = {
command?: string;
args?: string[];
url?: string;
env?: Record<string, string>;
type?: string;
cwd?: string;
};
/**
* Creates a single MCP client for a server configuration
*/
export async function createMCPClient(serverName: string, serverConfig: ServerConfig): Promise<MCPClient | null> {
if (!serverConfig) {
throw new Error(`Invalid configuration for server "${serverName}"`);
}
const isSSE = serverConfig.type === 'sse' || (!serverConfig.command && serverConfig.url);
if (isSSE && !serverConfig.url) {
throw new Error(`Missing URL for SSE server "${serverName}"`);
}
if (!isSSE && !serverConfig.command) {
throw new Error(`Missing command for stdio server "${serverName}"`);
}
const client = isSSE
? await createSSEClient(serverName, serverConfig.url!)
: await createStdioClient(serverName, serverConfig);
// Verify that the client can get tools
try {
await client.tools();
return client;
} catch (e) {
throw new Error(`Server connection established but failed to get available tools: ${errorToString(e)}`);
}
}
async function createSSEClient(serverName: string, url: string): Promise<MCPClient> {
logger.debug(`Creating SSE MCP client for ${serverName} with URL: ${url}`);
try {
return await experimental_createMCPClient({
transport: { type: 'sse', url },
});
} catch (e) {
throw new Error(`Failed to connect to SSE endpoint "${url}": ${errorToString(e)}`);
}
}
async function createStdioClient(serverName: string, config: ServerConfig): Promise<MCPClient> {
const { command, args, env, cwd } = config;
logger.debug(`Creating stdio MCP client for '${serverName}' with command: '${command}' ${args?.join(' ') || ''}`);
try {
const transport = new Experimental_StdioMCPTransport({
command: command!,
args,
env,
cwd,
});
return await experimental_createMCPClient({ transport });
} catch (e) {
throw new Error(`Failed to start command "${command}": ${errorToString(e)}`);
}
}
export async function createMCPClients(mcpConfig?: {
mcpServers: Record<string, ServerConfig>;
}): Promise<{ tools: Record<string, any>; clients: MCPClient[] }> {
const tools = {};
const clients: MCPClient[] = [];
if (!mcpConfig?.mcpServers) {
return { tools, clients };
}
for (const [serverName, serverConfig] of Object.entries(mcpConfig.mcpServers)) {
try {
const client = await createMCPClient(serverName, serverConfig);
if (client) {
clients.push(client);
const toolSet = await client.tools();
Object.assign(tools, toolSet);
}
} catch (error) {
logger.error(`Failed to initialize MCP client for server: ${serverName}`, error);
// Continue to the next server rather than failing completely
}
}
return { tools, clients };
}
export async function closeMCPClients(clients: MCPClient[]): Promise<void> {
const closePromises = clients.map((client) =>
client.close().catch((e) => logger.error('Error closing MCP client:', e)),
);
await Promise.allSettled(closePromises);
}
// Helper function to consistently format errors
function errorToString(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

View File

@ -11,6 +11,7 @@ import type { ContextAnnotation, ProgressAnnotation } from '~/types/context';
import { WORK_DIR } from '~/utils/constants';
import { createSummary } from '~/lib/.server/llm/create-summary';
import { extractPropertiesFromMessage } from '~/lib/.server/llm/utils';
import { createMCPClients, closeMCPClients } from '~/lib/services/mcp';
export async function action(args: ActionFunctionArgs) {
return chatAction(args);
@ -37,7 +38,7 @@ function parseCookies(cookieHeader: string): Record<string, string> {
}
async function chatAction({ context, request }: ActionFunctionArgs) {
const { messages, files, promptId, contextOptimization, supabase, chatMode } = await request.json<{
const { messages, files, promptId, contextOptimization, supabase, chatMode, mcpConfig } = await request.json<{
messages: Messages;
files: any;
promptId?: string;
@ -51,6 +52,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
supabaseUrl?: string;
};
};
mcpConfig?: {
mcpServers: Record<string, any>;
};
}>();
const cookieHeader = request.headers.get('Cookie');
@ -69,6 +73,9 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const encoder: TextEncoder = new TextEncoder();
let progressCounter: number = 1;
// Initialize MCP clients if configuration is provided
const { tools, clients } = await createMCPClients(mcpConfig);
try {
const totalMessageContent = messages.reduce((acc, message) => acc + message.content, '');
logger.debug(`Total message length: ${totalMessageContent.split(' ').length}, words`);
@ -190,7 +197,67 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
const options: StreamingOptions = {
supabaseConnection: supabase,
toolChoice: 'none',
tools,
maxSteps: 6,
onStepFinish: ({ toolCalls, toolResults }) => {
if (toolCalls && toolCalls.length > 0) {
toolCalls.forEach((toolCall, index) => {
const toolName = toolCall.toolName;
const toolResult = toolResults?.[index];
logger.debug(`MCP Tool '${toolName}' executed with args: ${JSON.stringify(toolCall.args)}`);
// Add progress indicator to frontend that tool is running
dataStream.writeData({
type: 'progress',
label: 'tool',
status: 'in-progress',
order: progressCounter++,
message: `Running tool: ${toolName}`,
} satisfies ProgressAnnotation);
if (toolResult) {
logger.debug(`MCP Tool '${toolName}' result: ${JSON.stringify(toolResult)}`);
// Add progress indicator that tool completed
dataStream.writeData({
type: 'progress',
label: 'tool',
status: 'complete',
order: progressCounter++,
message: `Tool executed: ${toolName}`,
} satisfies ProgressAnnotation);
// Add tool invocation annotation
dataStream.writeMessageAnnotation({
type: 'toolInvocation',
toolName,
parameters: toolCall.args,
result: toolResult,
});
} else {
logger.warn(`MCP Tool '${toolName}' didn't return a result`);
// Add progress indicator that tool failed
dataStream.writeData({
type: 'progress',
label: 'tool',
status: 'complete',
order: progressCounter++,
message: `Tool executed with no result: ${toolName}`,
} satisfies ProgressAnnotation);
// Add tool invocation annotation even without result
dataStream.writeMessageAnnotation({
type: 'toolInvocation',
toolName,
parameters: toolCall.args,
result: null,
});
}
});
}
},
onFinish: async ({ text: content, finishReason, usage }) => {
logger.debug('usage', JSON.stringify(usage));
@ -218,6 +285,11 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
} satisfies ProgressAnnotation);
await new Promise((resolve) => setTimeout(resolve, 0));
// Close MCP clients
if (clients.length > 0) {
await closeMCPClients(clients);
}
// stream.close();
return;
}
@ -357,6 +429,11 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
} catch (error: any) {
logger.error(error);
// Clean up MCP clients if they exist
if (clients && clients.length > 0) {
await closeMCPClients(clients).catch((e) => logger.error('Error closing MCP clients during error handling:', e));
}
if (error.message?.includes('API key')) {
throw new Response('Invalid or missing API key', {
status: 401,

View File

@ -0,0 +1,58 @@
import { type ActionFunctionArgs } from '@remix-run/cloudflare';
import { createScopedLogger } from '~/utils/logger';
import { createMCPClient } from '~/lib/services/mcp';
const logger = createScopedLogger('api.mcp-check');
export async function action({ request }: ActionFunctionArgs) {
try {
const body = (await request.json()) as { mcpServers?: Record<string, any> };
const { mcpServers } = body;
if (!mcpServers || typeof mcpServers !== 'object') {
return Response.json({ error: 'Invalid MCP servers configuration' }, { status: 400 });
}
const serverStatus: Record<string, boolean> = {};
const serverErrors: Record<string, string> = {};
const serverTools: Record<string, any> = {};
// Check each server in parallel
const checkPromises = Object.entries(mcpServers).map(async ([serverName, serverConfig]) => {
try {
const client = await createMCPClient(serverName, serverConfig);
if (client) {
serverStatus[serverName] = true;
// Get tools from the client
try {
const tools = await client.tools();
serverTools[serverName] = tools;
} catch (toolError) {
logger.error(`Failed to get tools from server ${serverName}:`, toolError);
serverErrors[serverName] =
`Connected but failed to get tools: ${toolError instanceof Error ? toolError.message : String(toolError)}`;
}
await client.close();
} else {
serverStatus[serverName] = false;
serverErrors[serverName] = 'Failed to create client';
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to check MCP server ${serverName}:`, error);
serverStatus[serverName] = false;
serverErrors[serverName] = errorMessage;
}
});
await Promise.all(checkPromises);
return Response.json({ serverStatus, serverErrors, serverTools });
} catch (error) {
logger.error('Error checking MCP servers:', error);
return Response.json({ error: 'Failed to check MCP servers' }, { status: 500 });
}
}

View File

@ -16,3 +16,10 @@ export type ProgressAnnotation = {
order: number;
message: string;
};
export type ToolInvocationAnnotation = {
type: 'toolInvocation';
toolName: string;
parameters: Record<string, unknown>;
result: unknown;
};

1
icons/mcp.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>

After

Width:  |  Height:  |  Size: 978 B

View File

@ -51,6 +51,8 @@
"@ai-sdk/google": "0.0.52",
"@ai-sdk/mistral": "0.0.43",
"@ai-sdk/openai": "1.1.2",
"@ai-sdk/react": "^1.2.12",
"@ai-sdk/ui-utils": "^1.2.11",
"@codemirror/autocomplete": "^6.18.3",
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-cpp": "^6.0.2",
@ -104,7 +106,7 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"ai": "4.1.2",
"ai": "4.3.15",
"chalk": "^5.4.1",
"chart.js": "^4.4.7",
"class-variance-authority": "^0.7.0",

File diff suppressed because it is too large Load Diff

1
public/icons/mcp.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelContextProtocol</title><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path></svg>

After

Width:  |  Height:  |  Size: 978 B