mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🎨 feat: MCP UI basic integration (#9299)
This commit is contained in:
parent
20b29bbfa6
commit
d16f93b5f7
8 changed files with 365 additions and 14 deletions
|
@ -37,6 +37,7 @@
|
|||
"@headlessui/react": "^2.1.2",
|
||||
"@librechat/client": "*",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@mcp-ui/client": "^5.7.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
|
|
|
@ -630,3 +630,10 @@ declare global {
|
|||
google_tag_manager?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export type UIResource = {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
text: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import UIResourceGrid from './UIResourceGrid';
|
||||
import type { UIResource } from '~/common';
|
||||
|
||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||
return (
|
||||
|
@ -51,6 +54,21 @@ export default function ToolCallInfo({
|
|||
: localize('com_assistants_attempt_info');
|
||||
}
|
||||
|
||||
// Extract ui_resources from the output to display them in the UI
|
||||
let uiResources: UIResource[] = [];
|
||||
if (output?.includes('ui_resources')) {
|
||||
const parsedOutput = JSON.parse(output);
|
||||
const uiResourcesItem = parsedOutput.find(
|
||||
(contentItem) => contentItem.metadata === 'ui_resources',
|
||||
);
|
||||
if (uiResourcesItem?.text) {
|
||||
uiResources = JSON.parse(atob(uiResourcesItem.text)) as UIResource[];
|
||||
}
|
||||
output = JSON.stringify(
|
||||
parsedOutput.filter((contentItem) => contentItem.metadata !== 'ui_resources'),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-2">
|
||||
<div style={{ opacity: 1 }}>
|
||||
|
@ -66,6 +84,26 @@ export default function ToolCallInfo({
|
|||
<div>
|
||||
<OptimizedCodeBlock text={formatText(output)} maxHeight={250} />
|
||||
</div>
|
||||
{uiResources.length > 0 && (
|
||||
<div className="my-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_ui_resources')}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{uiResources.length > 1 && <UIResourceGrid uiResources={uiResources} />}
|
||||
|
||||
{uiResources.length === 1 && (
|
||||
<UIResourceRenderer
|
||||
resource={uiResources[0]}
|
||||
onUIAction={async (result) => {
|
||||
console.log('Action:', result);
|
||||
}}
|
||||
htmlProps={{
|
||||
autoResizeIframe: { width: true, height: true },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
145
client/src/components/Chat/Messages/Content/UIResourceGrid.tsx
Normal file
145
client/src/components/Chat/Messages/Content/UIResourceGrid.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { UIResource } from '~/common';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface UIResourceGridProps {
|
||||
uiResources: UIResource[];
|
||||
}
|
||||
|
||||
const UIResourceGrid: React.FC<UIResourceGridProps> = React.memo(({ uiResources }) => {
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = React.useCallback(() => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
}, []);
|
||||
|
||||
const scroll = React.useCallback((direction: 'left' | 'right') => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
|
||||
const viewportWidth = scrollContainerRef.current.clientWidth;
|
||||
const scrollAmount = Math.floor(viewportWidth * 0.9);
|
||||
const currentScroll = scrollContainerRef.current.scrollLeft;
|
||||
const newScroll =
|
||||
direction === 'left' ? currentScroll - scrollAmount : currentScroll + scrollAmount;
|
||||
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: newScroll,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
handleScroll();
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
}, [handleScroll]);
|
||||
|
||||
if (uiResources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-4 pt-3"
|
||||
onMouseEnter={() => setIsContainerHovered(true)}
|
||||
onMouseLeave={() => setIsContainerHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-none absolute left-0 top-0 z-10 h-full w-24 bg-gradient-to-r from-surface-primary to-transparent transition-opacity duration-500 ease-in-out ${
|
||||
showLeftArrow ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`pointer-events-none absolute right-0 top-0 z-10 h-full w-24 bg-gradient-to-l from-surface-primary to-transparent transition-opacity duration-500 ease-in-out ${
|
||||
showRightArrow ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{showLeftArrow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('left')}
|
||||
className={`absolute left-2 top-1/2 z-20 -translate-y-1/2 rounded-xl bg-white p-2 text-gray-800 shadow-lg transition-all duration-200 hover:scale-110 hover:bg-gray-100 hover:shadow-xl active:scale-95 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-300 ${
|
||||
isContainerHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="hide-scrollbar flex gap-4 overflow-x-auto scroll-smooth"
|
||||
>
|
||||
{uiResources.map((uiResource, index) => {
|
||||
const height = 360;
|
||||
const width = 230;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-shrink-0 transform-gpu transition-all duration-300 ease-out animate-in fade-in-0 slide-in-from-bottom-5"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minHeight: `${height}px`,
|
||||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<UIResourceRenderer
|
||||
resource={{
|
||||
uri: uiResource.uri,
|
||||
mimeType: uiResource.mimeType,
|
||||
text: uiResource.text,
|
||||
}}
|
||||
onUIAction={async (result) => {
|
||||
console.log('Action:', result);
|
||||
}}
|
||||
htmlProps={{
|
||||
autoResizeIframe: { width: true, height: true },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showRightArrow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scroll('right')}
|
||||
className={`absolute right-2 top-1/2 z-20 -translate-y-1/2 rounded-xl bg-white p-2 text-gray-800 shadow-lg transition-all duration-200 hover:scale-110 hover:bg-gray-100 hover:shadow-xl active:scale-95 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-300 ${
|
||||
isContainerHovered ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default UIResourceGrid;
|
|
@ -1198,6 +1198,7 @@
|
|||
"com_ui_travel": "Travel",
|
||||
"com_ui_trust_app": "I trust this application",
|
||||
"com_ui_try_adjusting_search": "Try adjusting your search terms",
|
||||
"com_ui_ui_resources": "UI Resources",
|
||||
"com_ui_unarchive": "Unarchive",
|
||||
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
||||
"com_ui_unknown": "Unknown",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue