mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02: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 React from 'react';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
import UIResourceGrid from './UIResourceGrid';
|
import UIResourceCarousel from './UIResourceCarousel';
|
||||||
import type { UIResource } from '~/common';
|
import type { UIResource } from '~/common';
|
||||||
|
|
||||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||||
|
@ -57,17 +57,22 @@ export default function ToolCallInfo({
|
||||||
// Extract ui_resources from the output to display them in the UI
|
// Extract ui_resources from the output to display them in the UI
|
||||||
let uiResources: UIResource[] = [];
|
let uiResources: UIResource[] = [];
|
||||||
if (output?.includes('ui_resources')) {
|
if (output?.includes('ui_resources')) {
|
||||||
|
try {
|
||||||
const parsedOutput = JSON.parse(output);
|
const parsedOutput = JSON.parse(output);
|
||||||
const uiResourcesItem = parsedOutput.find(
|
const uiResourcesItem = parsedOutput.find(
|
||||||
(contentItem) => contentItem.metadata === 'ui_resources',
|
(contentItem) => contentItem.metadata?.type === 'ui_resources',
|
||||||
);
|
);
|
||||||
if (uiResourcesItem?.text) {
|
if (uiResourcesItem?.metadata?.data) {
|
||||||
uiResources = JSON.parse(atob(uiResourcesItem.text)) as UIResource[];
|
uiResources = uiResourcesItem.metadata.data;
|
||||||
}
|
|
||||||
output = JSON.stringify(
|
output = JSON.stringify(
|
||||||
parsedOutput.filter((contentItem) => contentItem.metadata !== 'ui_resources'),
|
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If JSON parsing fails, keep original output
|
||||||
|
console.error('Failed to parse output:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-2">
|
<div className="w-full p-2">
|
||||||
|
@ -90,7 +95,7 @@ export default function ToolCallInfo({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
{uiResources.length > 1 && <UIResourceGrid uiResources={uiResources} />}
|
{uiResources.length > 1 && <UIResourceCarousel uiResources={uiResources} />}
|
||||||
|
|
||||||
{uiResources.length === 1 && (
|
{uiResources.length === 1 && (
|
||||||
<UIResourceRenderer
|
<UIResourceRenderer
|
||||||
|
|
|
@ -2,11 +2,11 @@ import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
import type { UIResource } from '~/common';
|
import type { UIResource } from '~/common';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
interface UIResourceGridProps {
|
interface UIResourceCarouselProps {
|
||||||
uiResources: UIResource[];
|
uiResources: UIResource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const UIResourceGrid: React.FC<UIResourceGridProps> = React.memo(({ uiResources }) => {
|
const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiResources }) => {
|
||||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
417
packages/api/src/mcp/__tests__/parsers.test.ts
Normal file
417
packages/api/src/mcp/__tests__/parsers.test.ts
Normal file
|
@ -0,0 +1,417 @@
|
||||||
|
import { formatToolContent } from '../parsers';
|
||||||
|
import type * as t from '../types';
|
||||||
|
|
||||||
|
describe('formatToolContent', () => {
|
||||||
|
describe('unrecognized providers', () => {
|
||||||
|
it('should return string for unrecognized provider', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Hello world' },
|
||||||
|
{ type: 'text', text: 'Another text' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'unknown' as t.Provider);
|
||||||
|
expect(content).toBe('Hello world\n\nAnother text');
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "(No response)" for empty content with unrecognized provider', () => {
|
||||||
|
const result: t.MCPToolCallResponse = { content: [] };
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'unknown' as t.Provider);
|
||||||
|
expect(content).toBe('(No response)');
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "(No response)" for undefined result with unrecognized provider', () => {
|
||||||
|
const result: t.MCPToolCallResponse = undefined;
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'unknown' as t.Provider);
|
||||||
|
expect(content).toBe('(No response)');
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recognized providers - content array providers', () => {
|
||||||
|
const contentArrayProviders: t.Provider[] = ['google', 'anthropic', 'openai', 'azureopenai'];
|
||||||
|
|
||||||
|
contentArrayProviders.forEach((provider) => {
|
||||||
|
describe(`${provider} provider`, () => {
|
||||||
|
it('should format text content as content array', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'First text' },
|
||||||
|
{ type: 'text', text: 'Second text' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, provider);
|
||||||
|
expect(content).toEqual([{ type: 'text', text: 'First text\n\nSecond text' }]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should separate text blocks when images are present', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Before image' },
|
||||||
|
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||||
|
{ type: 'text', text: 'After image' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, provider);
|
||||||
|
expect(content).toEqual([
|
||||||
|
{ type: 'text', text: 'Before image' },
|
||||||
|
{ type: 'text', text: 'After image' },
|
||||||
|
]);
|
||||||
|
expect(artifacts).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/png;base64,base64data' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty content', () => {
|
||||||
|
const result: t.MCPToolCallResponse = { content: [] };
|
||||||
|
const [content, artifacts] = formatToolContent(result, provider);
|
||||||
|
expect(content).toEqual([{ type: 'text', text: '(No response)' }]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recognized providers - string providers', () => {
|
||||||
|
const stringProviders: t.Provider[] = ['openrouter', 'xai', 'deepseek', 'ollama', 'bedrock'];
|
||||||
|
|
||||||
|
stringProviders.forEach((provider) => {
|
||||||
|
describe(`${provider} provider`, () => {
|
||||||
|
it('should format content as string', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'First text' },
|
||||||
|
{ type: 'text', text: 'Second text' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, provider);
|
||||||
|
expect(content).toBe('First text\n\nSecond text');
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle images with string output', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Some text' },
|
||||||
|
{ type: 'image', data: 'base64data', mimeType: 'image/png' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, provider);
|
||||||
|
expect(content).toBe('Some text');
|
||||||
|
expect(artifacts).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/png;base64,base64data' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('image handling', () => {
|
||||||
|
it('should handle images with http URLs', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [{ type: 'image', data: 'https://example.com/image.png', mimeType: 'image/png' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [_content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(artifacts).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'https://example.com/image.png' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle images with base64 data', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [{ type: 'image', data: 'iVBORw0KGgoAAAA...', mimeType: 'image/png' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [_content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(artifacts).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAA...' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resource handling', () => {
|
||||||
|
it('should handle UI resources', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'ui://carousel',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"items": []}',
|
||||||
|
name: 'carousel',
|
||||||
|
description: 'A carousel component',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
metadata: {
|
||||||
|
type: 'ui_resources',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://carousel',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"items": []}',
|
||||||
|
name: 'carousel',
|
||||||
|
description: 'A carousel component',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle regular resources', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'file://document.pdf',
|
||||||
|
name: 'Document',
|
||||||
|
description: 'Important document',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
text: 'Document content',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text:
|
||||||
|
'Resource Text: Document content\n' +
|
||||||
|
'Resource URI: file://document.pdf\n' +
|
||||||
|
'Resource: Document\n' +
|
||||||
|
'Resource Description: Important document\n' +
|
||||||
|
'Resource MIME Type: application/pdf',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle resources with partial data', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'https://example.com/resource',
|
||||||
|
name: 'Example Resource',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Resource URI: https://example.com/resource\n' + 'Resource: Example Resource',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed UI and regular resources', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Some text' },
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'ui://button',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"label": "Click me"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'file://data.csv',
|
||||||
|
name: 'Data file',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Some text\n\n' + 'Resource URI: file://data.csv\n' + 'Resource: Data file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
metadata: {
|
||||||
|
type: 'ui_resources',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://button',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"label": "Click me"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unknown content types', () => {
|
||||||
|
it('should stringify unknown content types', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Normal text' },
|
||||||
|
{ type: 'unknown', data: 'some data' } as unknown as t.ToolContentPart,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Normal text\n\n' + JSON.stringify({ type: 'unknown', data: 'some data' }, null, 2),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complex scenarios', () => {
|
||||||
|
it('should handle mixed content with all types', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Introduction' },
|
||||||
|
{ type: 'image', data: 'image1.png', mimeType: 'image/png' },
|
||||||
|
{ type: 'text', text: 'Middle section' },
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'ui://chart',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"type": "bar"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'https://api.example.com/data',
|
||||||
|
name: 'API Data',
|
||||||
|
description: 'External data source',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'image', data: 'https://example.com/image2.jpg', mimeType: 'image/jpeg' },
|
||||||
|
{ type: 'text', text: 'Conclusion' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'anthropic');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{ type: 'text', text: 'Introduction' },
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text:
|
||||||
|
'Middle section\n\n' +
|
||||||
|
'Resource URI: https://api.example.com/data\n' +
|
||||||
|
'Resource: API Data\n' +
|
||||||
|
'Resource Description: External data source',
|
||||||
|
},
|
||||||
|
{ type: 'text', text: 'Conclusion' },
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: '',
|
||||||
|
metadata: {
|
||||||
|
type: 'ui_resources',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://chart',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"type": "bar"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/png;base64,image1.png' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'https://example.com/image2.jpg' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error responses gracefully', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [{ type: 'text', text: 'Error occurred' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([{ type: 'text', text: 'Error occurred' }]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metadata in responses', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
_meta: { timestamp: Date.now(), source: 'test' },
|
||||||
|
content: [{ type: 'text', text: 'Response with metadata' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'google');
|
||||||
|
expect(content).toEqual([{ type: 'text', text: 'Response with metadata' }]);
|
||||||
|
expect(artifacts).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -111,7 +111,7 @@ export function formatToolContent(
|
||||||
const formattedContent: t.FormattedContent[] = [];
|
const formattedContent: t.FormattedContent[] = [];
|
||||||
const imageUrls: t.FormattedContent[] = [];
|
const imageUrls: t.FormattedContent[] = [];
|
||||||
let currentTextBlock = '';
|
let currentTextBlock = '';
|
||||||
let uiResources: t.UIResource[] = [];
|
const uiResources: t.UIResource[] = [];
|
||||||
|
|
||||||
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
|
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
|
||||||
|
|
||||||
|
@ -183,7 +183,14 @@ export function formatToolContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiResources.length) {
|
if (uiResources.length) {
|
||||||
formattedContent.push({ type: 'text', metadata: 'ui_resources', text: btoa(JSON.stringify(uiResources))});
|
formattedContent.push({
|
||||||
|
type: 'text',
|
||||||
|
metadata: {
|
||||||
|
type: 'ui_resources',
|
||||||
|
data: uiResources,
|
||||||
|
},
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifacts = imageUrls.length ? { content: imageUrls } : undefined;
|
const artifacts = imageUrls.length ? { content: imageUrls } : undefined;
|
||||||
|
|
|
@ -69,13 +69,25 @@ export type MCPToolCallResponse =
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Provider = 'google' | 'anthropic' | 'openAI';
|
export type Provider =
|
||||||
|
| 'google'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'openai'
|
||||||
|
| 'azureopenai'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'xai'
|
||||||
|
| 'deepseek'
|
||||||
|
| 'ollama'
|
||||||
|
| 'bedrock';
|
||||||
|
|
||||||
export type FormattedContent =
|
export type FormattedContent =
|
||||||
| {
|
| {
|
||||||
type: 'text';
|
type: 'text';
|
||||||
text: string;
|
metadata?: {
|
||||||
metadata?: string;
|
type: string;
|
||||||
|
data: UIResource[];
|
||||||
|
}
|
||||||
|
text?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'image';
|
type: 'image';
|
||||||
|
@ -109,7 +121,7 @@ export type UIResource = {
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
text: string;
|
text: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type ImageFormatter = (item: ImageContent) => FormattedContent;
|
export type ImageFormatter = (item: ImageContent) => FormattedContent;
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue