mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 03:10:15 +01:00
🚦 feat: Simplify MCP UI integration and add unit tests (#9418)
This commit is contained in:
parent
f9b12517b0
commit
6d791e3e12
7 changed files with 953 additions and 20 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import UIResourceGrid from './UIResourceGrid';
|
||||
import UIResourceCarousel from './UIResourceCarousel';
|
||||
import type { UIResource } from '~/common';
|
||||
|
||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||
|
|
@ -57,16 +57,21 @@ export default function ToolCallInfo({
|
|||
// 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[];
|
||||
try {
|
||||
const parsedOutput = JSON.parse(output);
|
||||
const uiResourcesItem = parsedOutput.find(
|
||||
(contentItem) => contentItem.metadata?.type === 'ui_resources',
|
||||
);
|
||||
if (uiResourcesItem?.metadata?.data) {
|
||||
uiResources = uiResourcesItem.metadata.data;
|
||||
output = JSON.stringify(
|
||||
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// If JSON parsing fails, keep original output
|
||||
console.error('Failed to parse output:', error);
|
||||
}
|
||||
output = JSON.stringify(
|
||||
parsedOutput.filter((contentItem) => contentItem.metadata !== 'ui_resources'),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -90,7 +95,7 @@ export default function ToolCallInfo({
|
|||
</div>
|
||||
)}
|
||||
<div>
|
||||
{uiResources.length > 1 && <UIResourceGrid uiResources={uiResources} />}
|
||||
{uiResources.length > 1 && <UIResourceCarousel uiResources={uiResources} />}
|
||||
|
||||
{uiResources.length === 1 && (
|
||||
<UIResourceRenderer
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { UIResourceRenderer } from '@mcp-ui/client';
|
|||
import type { UIResource } from '~/common';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface UIResourceGridProps {
|
||||
interface UIResourceCarouselProps {
|
||||
uiResources: UIResource[];
|
||||
}
|
||||
|
||||
const UIResourceGrid: React.FC<UIResourceGridProps> = React.memo(({ uiResources }) => {
|
||||
const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiResources }) => {
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||
|
|
@ -142,4 +142,4 @@ const UIResourceGrid: React.FC<UIResourceGridProps> = React.memo(({ uiResources
|
|||
);
|
||||
});
|
||||
|
||||
export default UIResourceGrid;
|
||||
export default UIResourceCarousel;
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import ToolCallInfo from '../ToolCallInfo';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import UIResourceCarousel from '../UIResourceCarousel';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_assistants_domain_info: `Used ${values?.[0]}`,
|
||||
com_assistants_function_use: `Used ${values?.[0]}`,
|
||||
com_assistants_action_attempt: `Attempted to use ${values?.[0]}`,
|
||||
com_assistants_attempt_info: 'Attempted to use function',
|
||||
com_ui_result: 'Result',
|
||||
com_ui_ui_resources: 'UI Resources',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@mcp-ui/client', () => ({
|
||||
UIResourceRenderer: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../UIResourceCarousel', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
// Add TextEncoder/TextDecoder polyfill for Jest environment
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
|
||||
if (typeof global.TextEncoder === 'undefined') {
|
||||
global.TextEncoder = TextEncoder as any;
|
||||
global.TextDecoder = TextDecoder as any;
|
||||
}
|
||||
|
||||
describe('ToolCallInfo', () => {
|
||||
const mockProps = {
|
||||
input: '{"test": "input"}',
|
||||
function_name: 'testFunction',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ui_resources extraction', () => {
|
||||
it('should extract single ui_resource from output', () => {
|
||||
const uiResource = {
|
||||
type: 'text',
|
||||
data: 'Test resource',
|
||||
};
|
||||
|
||||
const output = JSON.stringify([
|
||||
{ type: 'text', text: 'Regular output' },
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [uiResource],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Should render UIResourceRenderer for single resource
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: uiResource,
|
||||
onUIAction: expect.any(Function),
|
||||
htmlProps: {
|
||||
autoResizeIframe: { width: true, height: true },
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Should not render carousel for single resource
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should extract multiple ui_resources from output', () => {
|
||||
const uiResources = [
|
||||
{ type: 'text', data: 'Resource 1' },
|
||||
{ type: 'text', data: 'Resource 2' },
|
||||
{ type: 'text', data: 'Resource 3' },
|
||||
];
|
||||
|
||||
const output = JSON.stringify([
|
||||
{ type: 'text', text: 'Regular output' },
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: uiResources,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Should render carousel for multiple resources
|
||||
expect(UIResourceCarousel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uiResources,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Should not render individual UIResourceRenderer
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter out ui_resources from displayed output', () => {
|
||||
const regularContent = [
|
||||
{ type: 'text', text: 'Regular output 1' },
|
||||
{ type: 'text', text: 'Regular output 2' },
|
||||
];
|
||||
|
||||
const output = JSON.stringify([
|
||||
...regularContent,
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'UI Resource' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Check that the displayed output doesn't contain ui_resources
|
||||
const codeBlocks = container.querySelectorAll('code');
|
||||
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
|
||||
|
||||
expect(outputCode).toContain('Regular output 1');
|
||||
expect(outputCode).toContain('Regular output 2');
|
||||
expect(outputCode).not.toContain('ui_resources');
|
||||
});
|
||||
|
||||
it('should handle output without ui_resources', () => {
|
||||
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle malformed ui_resources gracefully', () => {
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
metadata: 'ui_resources', // metadata should be an object, not a string
|
||||
text: 'some text content',
|
||||
},
|
||||
]);
|
||||
|
||||
// Component should not throw error and should render without UI resources
|
||||
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Should render the component without crashing
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
// UIResourceCarousel should not be called since the metadata structure is invalid
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle ui_resources as plain text without breaking', () => {
|
||||
const outputWithTextOnly =
|
||||
'This output contains ui_resources as plain text but not as a proper structure';
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
|
||||
|
||||
// Should render normally without errors
|
||||
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Result')).toBeInTheDocument();
|
||||
|
||||
// The output text should be displayed in a code block
|
||||
const codeBlocks = screen.getAllByText((content, element) => {
|
||||
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
|
||||
});
|
||||
expect(codeBlocks.length).toBeGreaterThan(0);
|
||||
|
||||
// Should not render UI resources components
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering logic', () => {
|
||||
it('should render UI Resources heading when ui_resources exist', () => {
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'Test' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(screen.getByText('UI Resources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render UI Resources heading when no ui_resources', () => {
|
||||
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass correct props to UIResourceRenderer', () => {
|
||||
const uiResource = {
|
||||
type: 'form',
|
||||
data: { fields: [{ name: 'test', type: 'text' }] },
|
||||
};
|
||||
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [uiResource],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: uiResource,
|
||||
onUIAction: expect.any(Function),
|
||||
htmlProps: {
|
||||
autoResizeIframe: { width: true, height: true },
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should console.log when UIAction is triggered', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'Test' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
|
||||
typeof UIResourceRenderer
|
||||
>;
|
||||
const onUIAction = mockUIResourceRenderer.mock.calls[0]?.[0]?.onUIAction;
|
||||
const testResult = { action: 'submit', data: { test: 'value' } };
|
||||
|
||||
if (onUIAction) {
|
||||
await onUIAction(testResult as any);
|
||||
}
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Action:', testResult);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import UIResourceCarousel from '../UIResourceCarousel';
|
||||
import type { UIResource } from '~/common';
|
||||
|
||||
// Mock the UIResourceRenderer component
|
||||
jest.mock('@mcp-ui/client', () => ({
|
||||
UIResourceRenderer: ({ resource, onUIAction }: any) => (
|
||||
<div data-testid="ui-resource-renderer" onClick={() => onUIAction({ action: 'test' })}>
|
||||
{resource.text || 'UI Resource'}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock scrollTo
|
||||
const mockScrollTo = jest.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: mockScrollTo,
|
||||
});
|
||||
|
||||
describe('UIResourceCarousel', () => {
|
||||
const mockUIResources: UIResource[] = [
|
||||
{ uri: 'resource1', mimeType: 'text/html', text: 'Resource 1' },
|
||||
{ uri: 'resource2', mimeType: 'text/html', text: 'Resource 2' },
|
||||
{ uri: 'resource3', mimeType: 'text/html', text: 'Resource 3' },
|
||||
{ uri: 'resource4', mimeType: 'text/html', text: 'Resource 4' },
|
||||
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset scroll properties
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
|
||||
configurable: true,
|
||||
value: 0,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when no resources provided', () => {
|
||||
const { container } = render(<UIResourceCarousel uiResources={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders all UI resources', () => {
|
||||
render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const renderers = screen.getAllByTestId('ui-resource-renderer');
|
||||
expect(renderers).toHaveLength(5);
|
||||
expect(screen.getByText('Resource 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Resource 5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows/hides navigation arrows on hover', async () => {
|
||||
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const carouselContainer = container.querySelector('.relative.mb-4.pt-3');
|
||||
|
||||
// Initially arrows should be hidden (opacity-0)
|
||||
const leftArrow = screen.queryByLabelText('Scroll left');
|
||||
const rightArrow = screen.queryByLabelText('Scroll right');
|
||||
|
||||
// Right arrow should exist but left should not (at start)
|
||||
expect(leftArrow).not.toBeInTheDocument();
|
||||
expect(rightArrow).toBeInTheDocument();
|
||||
expect(rightArrow).toHaveClass('opacity-0');
|
||||
|
||||
// Hover over container
|
||||
fireEvent.mouseEnter(carouselContainer!);
|
||||
await waitFor(() => {
|
||||
expect(rightArrow).toHaveClass('opacity-100');
|
||||
});
|
||||
|
||||
// Leave hover
|
||||
fireEvent.mouseLeave(carouselContainer!);
|
||||
await waitFor(() => {
|
||||
expect(rightArrow).toHaveClass('opacity-0');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles scroll navigation', async () => {
|
||||
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const scrollContainer = container.querySelector('.hide-scrollbar');
|
||||
|
||||
// Simulate being scrolled to show left arrow
|
||||
Object.defineProperty(scrollContainer, 'scrollLeft', {
|
||||
configurable: true,
|
||||
value: 200,
|
||||
});
|
||||
|
||||
// Trigger scroll event
|
||||
fireEvent.scroll(scrollContainer!);
|
||||
|
||||
// Both arrows should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Scroll left')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Scroll right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Hover to make arrows interactive
|
||||
const carouselContainer = container.querySelector('.relative.mb-4.pt-3');
|
||||
fireEvent.mouseEnter(carouselContainer!);
|
||||
|
||||
// Click right arrow
|
||||
fireEvent.click(screen.getByLabelText('Scroll right'));
|
||||
expect(mockScrollTo).toHaveBeenCalledWith({
|
||||
left: 650, // 200 + (500 * 0.9)
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
// Click left arrow
|
||||
fireEvent.click(screen.getByLabelText('Scroll left'));
|
||||
expect(mockScrollTo).toHaveBeenCalledWith({
|
||||
left: -250, // 200 - (500 * 0.9)
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides right arrow when scrolled to end', async () => {
|
||||
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const scrollContainer = container.querySelector('.hide-scrollbar');
|
||||
|
||||
// Simulate scrolled to end
|
||||
Object.defineProperty(scrollContainer, 'scrollLeft', {
|
||||
configurable: true,
|
||||
value: 490, // scrollWidth - clientWidth - 10
|
||||
});
|
||||
|
||||
fireEvent.scroll(scrollContainer!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Scroll left')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Scroll right')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles UIResource actions', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
|
||||
|
||||
const renderer = screen.getByTestId('ui-resource-renderer');
|
||||
fireEvent.click(renderer);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Action:', { action: 'test' });
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applies correct dimensions to resource containers', () => {
|
||||
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
|
||||
const containers = screen
|
||||
.getAllByTestId('ui-resource-renderer')
|
||||
.map((el) => el.parentElement?.parentElement);
|
||||
|
||||
containers.forEach((container, index) => {
|
||||
expect(container).toHaveStyle({
|
||||
width: '230px',
|
||||
minHeight: '360px',
|
||||
animationDelay: `${index * 100}ms`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct gradient overlays based on scroll position', () => {
|
||||
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
|
||||
// At start, left gradient should be hidden, right should be visible
|
||||
const leftGradient = container.querySelector('.bg-gradient-to-r');
|
||||
const rightGradient = container.querySelector('.bg-gradient-to-l');
|
||||
|
||||
expect(leftGradient).toHaveClass('opacity-0');
|
||||
expect(rightGradient).toHaveClass('opacity-100');
|
||||
});
|
||||
|
||||
it('cleans up event listeners on unmount', () => {
|
||||
const { container, unmount } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const scrollContainer = container.querySelector('.hide-scrollbar');
|
||||
|
||||
const removeEventListenerSpy = jest.spyOn(scrollContainer!, 'removeEventListener');
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
});
|
||||
|
||||
it('renders with animation delays for each resource', () => {
|
||||
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 3)} />);
|
||||
const resourceContainers = screen
|
||||
.getAllByTestId('ui-resource-renderer')
|
||||
.map((el) => el.parentElement?.parentElement);
|
||||
|
||||
resourceContainers.forEach((container, index) => {
|
||||
expect(container).toHaveStyle({
|
||||
animationDelay: `${index * 100}ms`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('memoizes component properly', () => {
|
||||
const { rerender } = render(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const firstRender = screen.getAllByTestId('ui-resource-renderer');
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<UIResourceCarousel uiResources={mockUIResources} />);
|
||||
const secondRender = screen.getAllByTestId('ui-resource-renderer');
|
||||
|
||||
// Component should not re-render with same props (React.memo)
|
||||
expect(firstRender.length).toBe(secondRender.length);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue