mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01:00
💻 feat: deeper MCP UI integration in the chat UI using plugins
--------- Co-authored-by: Samuel Path <samuel.path@shopify.com> Co-authored-by: Pierre-Luc Godin <pierreluc.godin@shopify.com>
This commit is contained in:
parent
b8a149e563
commit
08103ffb22
22 changed files with 1441 additions and 54 deletions
|
|
@ -335,6 +335,15 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
} else if (tool && mcpToolPattern.test(tool)) {
|
} else if (tool && mcpToolPattern.test(tool)) {
|
||||||
|
toolContextMap[tool] = `# MCP Tool \`${tool}\`:
|
||||||
|
When this tool returns UI resources (URIs starting with "ui://"):
|
||||||
|
- You should usually introduce what you're showing before the marker
|
||||||
|
- For a single resource: \\ui0
|
||||||
|
- For multiple resources shown separately: \\ui0 \\ui1
|
||||||
|
- For multiple resources in a carousel: \\ui0,1,2
|
||||||
|
- The UI will be rendered inline where you place the marker
|
||||||
|
- Format: \\ui{index} or \\ui{index1,index2,index3} where indices are 0-based, in the order they appear in the tool response`;
|
||||||
|
|
||||||
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
|
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
|
||||||
if (toolName === Constants.mcp_server) {
|
if (toolName === Constants.mcp_server) {
|
||||||
/** Placeholder used for UI purposes */
|
/** Placeholder used for UI purposes */
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,10 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [
|
||||||
0: { type: 'button', label: 'Click me' },
|
{ type: 'button', label: 'Click me' },
|
||||||
1: { type: 'input', placeholder: 'Enter text' },
|
{ type: 'input', placeholder: 'Enter text' },
|
||||||
},
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -100,10 +100,10 @@ describe('createToolEndCallback', () => {
|
||||||
messageId: 'run456',
|
messageId: 'run456',
|
||||||
toolCallId: 'tool123',
|
toolCallId: 'tool123',
|
||||||
conversationId: 'thread789',
|
conversationId: 'thread789',
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: [
|
||||||
0: { type: 'button', label: 'Click me' },
|
{ type: 'button', label: 'Click me' },
|
||||||
1: { type: 'input', placeholder: 'Enter text' },
|
{ type: 'input', placeholder: 'Enter text' },
|
||||||
},
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,9 +115,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [{ type: 'carousel', items: [] }],
|
||||||
0: { type: 'carousel', items: [] },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -136,9 +134,7 @@ describe('createToolEndCallback', () => {
|
||||||
messageId: 'run456',
|
messageId: 'run456',
|
||||||
toolCallId: 'tool123',
|
toolCallId: 'tool123',
|
||||||
conversationId: 'thread789',
|
conversationId: 'thread789',
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
|
||||||
0: { type: 'carousel', items: [] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -155,9 +151,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [{ type: 'test' }],
|
||||||
0: { type: 'test' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -184,9 +178,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {
|
data: [{ type: 'chart', data: [] }],
|
||||||
0: { type: 'chart', data: [] },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
[Tools.web_search]: {
|
[Tools.web_search]: {
|
||||||
results: ['result1', 'result2'],
|
results: ['result1', 'result2'],
|
||||||
|
|
@ -209,9 +201,7 @@ describe('createToolEndCallback', () => {
|
||||||
// Check ui_resources attachment
|
// Check ui_resources attachment
|
||||||
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
||||||
expect(uiResourceAttachment).toBeTruthy();
|
expect(uiResourceAttachment).toBeTruthy();
|
||||||
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
|
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
|
||||||
0: { type: 'chart', data: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check web_search attachment
|
// Check web_search attachment
|
||||||
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
||||||
|
|
@ -250,7 +240,7 @@ describe('createToolEndCallback', () => {
|
||||||
tool_call_id: 'tool123',
|
tool_call_id: 'tool123',
|
||||||
artifact: {
|
artifact: {
|
||||||
[Tools.ui_resources]: {
|
[Tools.ui_resources]: {
|
||||||
data: {},
|
data: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -268,7 +258,7 @@ describe('createToolEndCallback', () => {
|
||||||
messageId: 'run456',
|
messageId: 'run456',
|
||||||
toolCallId: 'tool123',
|
toolCallId: 'tool123',
|
||||||
conversationId: 'thread789',
|
conversationId: 'thread789',
|
||||||
[Tools.ui_resources]: {},
|
[Tools.ui_resources]: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ import rehypeHighlight from 'rehype-highlight';
|
||||||
import remarkDirective from 'remark-directive';
|
import remarkDirective from 'remark-directive';
|
||||||
import type { Pluggable } from 'unified';
|
import type { Pluggable } from 'unified';
|
||||||
import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation';
|
import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation';
|
||||||
|
import {
|
||||||
|
mcpUIResourcePlugin,
|
||||||
|
MCPUIResource,
|
||||||
|
MCPUIResourceCarousel,
|
||||||
|
} from '~/components/MCPUIResource';
|
||||||
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
||||||
import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
|
import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
|
||||||
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
|
||||||
|
|
@ -55,6 +60,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||||
artifactPlugin,
|
artifactPlugin,
|
||||||
[remarkMath, { singleDollarTextMath: false }],
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
unicodeCitation,
|
unicodeCitation,
|
||||||
|
mcpUIResourcePlugin,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
|
|
@ -86,6 +92,8 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||||
citation: Citation,
|
citation: Citation,
|
||||||
'highlighted-text': HighlightedText,
|
'highlighted-text': HighlightedText,
|
||||||
'composite-citation': CompositeCitation,
|
'composite-citation': CompositeCitation,
|
||||||
|
'mcp-ui-resource': MCPUIResource,
|
||||||
|
'mcp-ui-carousel': MCPUIResourceCarousel,
|
||||||
} as {
|
} as {
|
||||||
[nodeType: string]: React.ElementType;
|
[nodeType: string]: React.ElementType;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
import type { UIResource } from 'librechat-data-provider';
|
import type { UIResource } from 'librechat-data-provider';
|
||||||
|
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
|
||||||
|
import { handleUIAction } from '~/utils';
|
||||||
|
|
||||||
interface UIResourceCarouselProps {
|
interface UIResourceCarouselProps {
|
||||||
uiResources: UIResource[];
|
uiResources: UIResource[];
|
||||||
|
|
@ -11,6 +13,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
||||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||||
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
const [isContainerHovered, setIsContainerHovered] = useState(false);
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const { submitMessage } = useSubmitMessage();
|
||||||
|
|
||||||
const handleScroll = React.useCallback(() => {
|
const handleScroll = React.useCallback(() => {
|
||||||
if (!scrollContainerRef.current) return;
|
if (!scrollContainerRef.current) return;
|
||||||
|
|
@ -111,9 +114,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
|
||||||
mimeType: uiResource.mimeType,
|
mimeType: uiResource.mimeType,
|
||||||
text: uiResource.text,
|
text: uiResource.text,
|
||||||
}}
|
}}
|
||||||
onUIAction={async (result) => {
|
onUIAction={async (result) => handleUIAction(result, submitMessage)}
|
||||||
console.log('Action:', result);
|
|
||||||
}}
|
|
||||||
htmlProps={{
|
htmlProps={{
|
||||||
autoResizeIframe: { width: true, height: true },
|
autoResizeIframe: { width: true, height: true },
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import Markdown from '../Markdown';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
|
||||||
|
import { useMessageContext, useChatContext } from '~/Providers';
|
||||||
|
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
|
||||||
|
|
||||||
|
// Mocks for hooks used by MCPUIResource when rendered inside Markdown.
|
||||||
|
// Keep Provider components intact while mocking only the hooks we use.
|
||||||
|
jest.mock('~/Providers', () => ({
|
||||||
|
...jest.requireActual('~/Providers'),
|
||||||
|
useMessageContext: jest.fn(),
|
||||||
|
useChatContext: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('~/data-provider');
|
||||||
|
jest.mock('~/hooks');
|
||||||
|
jest.mock('~/hooks/Messages/useSubmitMessage');
|
||||||
|
|
||||||
|
// Mock @mcp-ui/client to render identifiable elements for assertions
|
||||||
|
jest.mock('@mcp-ui/client', () => ({
|
||||||
|
UIResourceRenderer: ({ resource }: any) => (
|
||||||
|
<div data-testid="ui-resource-renderer" data-resource-uri={resource?.uri} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
|
||||||
|
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
|
||||||
|
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
|
||||||
|
typeof useGetMessagesByConvoId
|
||||||
|
>;
|
||||||
|
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
|
||||||
|
const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction<typeof useSubmitMessage>;
|
||||||
|
|
||||||
|
describe('Markdown with MCP UI markers (two attachments ui0 ui1)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockUseMessageContext.mockReturnValue({ messageId: 'msg-weather' } as any);
|
||||||
|
mockUseChatContext.mockReturnValue({ conversation: { conversationId: 'conv1' } } as any);
|
||||||
|
mockUseLocalize.mockReturnValue(((key: string) => key) as any);
|
||||||
|
mockUseSubmitMessage.mockReturnValue({ submitMessage: jest.fn() } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders two UIResourceRenderer components for markers ui0 and ui1 across separate attachments', () => {
|
||||||
|
// Two tool responses, each produced one ui_resources attachment
|
||||||
|
const paris = {
|
||||||
|
uri: 'ui://weather/paris',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<div>Paris Weather</div>',
|
||||||
|
};
|
||||||
|
const nyc = {
|
||||||
|
uri: 'ui://weather/nyc',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<div>NYC Weather</div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg-weather',
|
||||||
|
attachments: [
|
||||||
|
{ type: 'ui_resources', ui_resources: [paris] },
|
||||||
|
{ type: 'ui_resources', ui_resources: [nyc] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({ data: messages } as any);
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
'Here are the current weather conditions for both Paris and New York:',
|
||||||
|
'',
|
||||||
|
'- Paris: Slight rain, 53°F, humidity 76%, wind 9 mph.',
|
||||||
|
'- New York: Clear sky, 63°F, humidity 91%, wind 6 mph.',
|
||||||
|
'',
|
||||||
|
`Browse these weather cards for more details ${UI_RESOURCE_MARKER}0 ${UI_RESOURCE_MARKER}1`,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RecoilRoot>
|
||||||
|
<Markdown content={content} isLatestMessage={false} />
|
||||||
|
</RecoilRoot>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderers = screen.getAllByTestId('ui-resource-renderer');
|
||||||
|
expect(renderers).toHaveLength(2);
|
||||||
|
expect(renderers[0]).toHaveAttribute('data-resource-uri', 'ui://weather/paris');
|
||||||
|
expect(renderers[1]).toHaveAttribute('data-resource-uri', 'ui://weather/nyc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react';
|
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import type { UIResource } from 'librechat-data-provider';
|
import type { UIResource } from 'librechat-data-provider';
|
||||||
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
|
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
|
||||||
|
import { handleUIAction } from '~/utils';
|
||||||
|
|
||||||
// Mock the UIResourceRenderer component
|
// Mock the UIResourceRenderer component
|
||||||
jest.mock('@mcp-ui/client', () => ({
|
jest.mock('@mcp-ui/client', () => ({
|
||||||
|
|
@ -13,6 +13,20 @@ jest.mock('@mcp-ui/client', () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock useSubmitMessage hook
|
||||||
|
const mockSubmitMessage = jest.fn();
|
||||||
|
jest.mock('~/hooks/Messages/useSubmitMessage', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => ({
|
||||||
|
submitMessage: mockSubmitMessage,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock handleUIAction utility
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
handleUIAction: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock scrollTo
|
// Mock scrollTo
|
||||||
const mockScrollTo = jest.fn();
|
const mockScrollTo = jest.fn();
|
||||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||||
|
|
@ -29,8 +43,12 @@ describe('UIResourceCarousel', () => {
|
||||||
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
|
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockSubmitMessage.mockClear();
|
||||||
|
mockHandleUIAction.mockClear();
|
||||||
// Reset scroll properties
|
// Reset scroll properties
|
||||||
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
|
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
|
|
@ -141,18 +159,48 @@ describe('UIResourceCarousel', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles UIResource actions', async () => {
|
it('handles UIResource actions using handleUIAction', async () => {
|
||||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
||||||
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
|
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
|
||||||
|
|
||||||
const renderer = screen.getByTestId('ui-resource-renderer');
|
const renderer = screen.getByTestId('ui-resource-renderer');
|
||||||
fireEvent.click(renderer);
|
fireEvent.click(renderer);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Action:', { action: 'test' });
|
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
it('calls handleUIAction with correct parameters for multiple resources', async () => {
|
||||||
|
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 3)} />);
|
||||||
|
|
||||||
|
const renderers = screen.getAllByTestId('ui-resource-renderer');
|
||||||
|
|
||||||
|
// Click the second renderer
|
||||||
|
fireEvent.click(renderers[1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage);
|
||||||
|
expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click the third renderer
|
||||||
|
fireEvent.click(renderers[2]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandleUIAction).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes correct submitMessage function to handleUIAction', async () => {
|
||||||
|
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
|
||||||
|
|
||||||
|
const renderer = screen.getByTestId('ui-resource-renderer');
|
||||||
|
fireEvent.click(renderer);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage);
|
||||||
|
expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies correct dimensions to resource containers', () => {
|
it('applies correct dimensions to resource containers', () => {
|
||||||
|
|
|
||||||
74
client/src/components/MCPUIResource/MCPUIResource.tsx
Normal file
74
client/src/components/MCPUIResource/MCPUIResource.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
|
import type { UIResource } from '~/common';
|
||||||
|
import { handleUIAction } from '~/utils';
|
||||||
|
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
|
||||||
|
import { useMessageContext, useChatContext } from '~/Providers';
|
||||||
|
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface MCPUIResourceProps {
|
||||||
|
node: {
|
||||||
|
properties: {
|
||||||
|
resourceIndex: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders an MCP UI resource based on message context and index
|
||||||
|
*/
|
||||||
|
export function MCPUIResource(props: MCPUIResourceProps) {
|
||||||
|
const { resourceIndex } = props.node.properties;
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { submitMessage } = useSubmitMessage();
|
||||||
|
const { messageId } = useMessageContext();
|
||||||
|
const { conversation } = useChatContext();
|
||||||
|
const { data: messages } = useGetMessagesByConvoId(conversation?.conversationId ?? '', {
|
||||||
|
enabled: !!conversation?.conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiResource = useMemo(() => {
|
||||||
|
const targetMessage = messages?.find((m) => m.messageId === messageId);
|
||||||
|
|
||||||
|
if (!targetMessage?.attachments) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten all UI resources across attachments so indices are global
|
||||||
|
const allResources: UIResource[] = targetMessage.attachments
|
||||||
|
.filter((a) => a.type === 'ui_resources' && a['ui_resources'])
|
||||||
|
.flatMap((a) => a['ui_resources'] as UIResource[]);
|
||||||
|
|
||||||
|
return allResources[resourceIndex] ?? null;
|
||||||
|
}, [messages, messageId, resourceIndex]);
|
||||||
|
|
||||||
|
if (!uiResource) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600">
|
||||||
|
{localize('com_ui_ui_resource_not_found', { 0: resourceIndex.toString() })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<span className="mx-1 inline-block w-full align-middle">
|
||||||
|
<UIResourceRenderer
|
||||||
|
resource={uiResource}
|
||||||
|
onUIAction={async (result) => handleUIAction(result, submitMessage)}
|
||||||
|
htmlProps={{
|
||||||
|
autoResizeIframe: { width: true, height: true },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering UI resource:', error);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded bg-red-50 px-2 py-1 text-xs font-medium text-red-600">
|
||||||
|
{localize('com_ui_ui_resource_error', { 0: uiResource.name })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||||
|
import { useMessageContext, useChatContext } from '~/Providers';
|
||||||
|
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
|
||||||
|
import type { UIResource } from '~/common';
|
||||||
|
|
||||||
|
interface MCPUIResourceCarouselProps {
|
||||||
|
node: {
|
||||||
|
properties: {
|
||||||
|
resourceIndices: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) {
|
||||||
|
const { messageId } = useMessageContext();
|
||||||
|
const { conversation } = useChatContext();
|
||||||
|
const { data: messages } = useGetMessagesByConvoId(conversation?.conversationId ?? '', {
|
||||||
|
enabled: !!conversation?.conversationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiResources = useMemo(() => {
|
||||||
|
const { resourceIndices } = props.node.properties;
|
||||||
|
|
||||||
|
const targetMessage = messages?.find((m) => m.messageId === messageId);
|
||||||
|
|
||||||
|
if (!targetMessage?.attachments) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allResources: UIResource[] = targetMessage.attachments
|
||||||
|
.filter((a) => a.type === 'ui_resources' && a['ui_resources'])
|
||||||
|
.flatMap((a) => a['ui_resources'] as UIResource[]);
|
||||||
|
|
||||||
|
const selectedResources: UIResource[] = resourceIndices
|
||||||
|
.map((i) => allResources[i])
|
||||||
|
.filter(Boolean) as UIResource[];
|
||||||
|
|
||||||
|
return selectedResources;
|
||||||
|
}, [props.node.properties, messages, messageId]);
|
||||||
|
|
||||||
|
if (uiResources.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <UIResourceCarousel uiResources={uiResources} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MCPUIResource } from '../MCPUIResource';
|
||||||
|
import { useMessageContext, useChatContext } from '~/Providers';
|
||||||
|
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
|
||||||
|
import { handleUIAction } from '~/utils';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/Providers');
|
||||||
|
jest.mock('~/data-provider');
|
||||||
|
jest.mock('~/hooks');
|
||||||
|
jest.mock('~/hooks/Messages/useSubmitMessage');
|
||||||
|
jest.mock('~/utils');
|
||||||
|
|
||||||
|
jest.mock('@mcp-ui/client', () => ({
|
||||||
|
UIResourceRenderer: ({ resource, onUIAction }: any) => (
|
||||||
|
<div
|
||||||
|
data-testid="ui-resource-renderer"
|
||||||
|
data-resource-uri={resource?.uri}
|
||||||
|
onClick={() => onUIAction({ action: 'test' })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
|
||||||
|
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
|
||||||
|
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
|
||||||
|
typeof useGetMessagesByConvoId
|
||||||
|
>;
|
||||||
|
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
|
||||||
|
const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction<typeof useSubmitMessage>;
|
||||||
|
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
|
||||||
|
|
||||||
|
describe('MCPUIResource', () => {
|
||||||
|
const mockLocalize = (key: string, values?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
com_ui_ui_resource_not_found: `UI resource at index ${values?.[0]} not found`,
|
||||||
|
com_ui_ui_resource_error: `Error rendering UI resource: ${values?.[0]}`,
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSubmitMessageFn = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
|
||||||
|
mockUseChatContext.mockReturnValue({
|
||||||
|
conversation: { conversationId: 'conv123' },
|
||||||
|
} as any);
|
||||||
|
mockUseLocalize.mockReturnValue(mockLocalize as any);
|
||||||
|
mockUseSubmitMessage.mockReturnValue({ submitMessage: mockSubmitMessageFn } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resource fetching', () => {
|
||||||
|
it('should fetch and render UI resource from message attachments', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
const renderer = screen.getByTestId('ui-resource-renderer');
|
||||||
|
expect(renderer).toBeInTheDocument();
|
||||||
|
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show not found message when resource index is out of bounds', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 5 } }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('UI resource at index 5 not found')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('ui-resource-renderer')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show not found when target message is not found', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'different-msg',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show not found when no ui_resources attachments', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'web_search',
|
||||||
|
web_search: { results: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UI action handling', () => {
|
||||||
|
it('should handle UI actions with handleUIAction', async () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Interactive Resource</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
const renderer = screen.getByTestId('ui-resource-renderer');
|
||||||
|
renderer.click();
|
||||||
|
|
||||||
|
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessageFn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty messages array', () => {
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null messages data', () => {
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing conversation', () => {
|
||||||
|
mockUseChatContext.mockReturnValue({
|
||||||
|
conversation: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple attachments of ui_resources type', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{ uri: 'ui://test/resource1', mimeType: 'text/html', text: '<p>Resource 1</p>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{ uri: 'ui://test/resource2', mimeType: 'text/html', text: '<p>Resource 2</p>' },
|
||||||
|
{ uri: 'ui://test/resource3', mimeType: 'text/html', text: '<p>Resource 3</p>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// With global indexing across attachments, index 1 should pick the second overall resource
|
||||||
|
// Flattened order: [resource1, resource2, resource3]
|
||||||
|
render(<MCPUIResource node={{ properties: { resourceIndex: 1 } }} />);
|
||||||
|
|
||||||
|
const renderer = screen.getByTestId('ui-resource-renderer');
|
||||||
|
expect(renderer).toBeInTheDocument();
|
||||||
|
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,454 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
|
||||||
|
import { useMessageContext, useChatContext } from '~/Providers';
|
||||||
|
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/Providers');
|
||||||
|
jest.mock('~/data-provider');
|
||||||
|
|
||||||
|
jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ uiResources }: any) => (
|
||||||
|
<div data-testid="ui-resource-carousel" data-resource-count={uiResources.length}>
|
||||||
|
{uiResources.map((resource: any, index: number) => (
|
||||||
|
<div key={index} data-testid={`resource-${index}`} data-resource-uri={resource.uri} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
|
||||||
|
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
|
||||||
|
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
|
||||||
|
typeof useGetMessagesByConvoId
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe('MCPUIResourceCarousel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
|
||||||
|
mockUseChatContext.mockReturnValue({
|
||||||
|
conversation: { conversationId: 'conv123' },
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple resource fetching', () => {
|
||||||
|
it('should fetch multiple resources by indices', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource0',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 0 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 1 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource2',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 2 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource3',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 3 content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 2, 3] } }} />);
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('ui-resource-carousel');
|
||||||
|
expect(carousel).toBeInTheDocument();
|
||||||
|
expect(carousel).toHaveAttribute('data-resource-count', '3');
|
||||||
|
|
||||||
|
expect(screen.getByTestId('resource-0')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource0',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('resource-1')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource2',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('resource-2')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource3',
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId('resource-3')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve resource order based on indices', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource0',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 0 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 1 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource2',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 2 content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [2, 0, 1] } }} />);
|
||||||
|
|
||||||
|
const resources = screen.getAllByTestId(/resource-\d/);
|
||||||
|
expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource2');
|
||||||
|
expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
|
||||||
|
expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('partial matches', () => {
|
||||||
|
it('should only include valid resource indices', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource0',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 0 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 1 content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Request indices 0, 1, 2, 3 but only 0 and 1 exist
|
||||||
|
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1, 2, 3] } }} />);
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('ui-resource-carousel');
|
||||||
|
expect(carousel).toHaveAttribute('data-resource-count', '2');
|
||||||
|
|
||||||
|
expect(screen.getByTestId('resource-0')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource0',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('resource-1')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all invalid indices', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource0',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 0 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 1 content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Request indices that don't exist
|
||||||
|
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [5, 6, 7] } }} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should return null when no attachments', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1] } }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when message not found', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'different-msg',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no ui_resources attachments', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'web_search',
|
||||||
|
web_search: { results: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1] } }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty resourceIndices array', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [] } }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle duplicate indices', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource0',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 0 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 1 content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Request same index multiple times
|
||||||
|
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 0, 1, 1, 0] } }} />);
|
||||||
|
|
||||||
|
// Should render each resource multiple times
|
||||||
|
const carousel = screen.getByTestId('ui-resource-carousel');
|
||||||
|
expect(carousel).toHaveAttribute('data-resource-count', '5');
|
||||||
|
|
||||||
|
const resources = screen.getAllByTestId(/resource-\d/);
|
||||||
|
expect(resources).toHaveLength(5);
|
||||||
|
expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
|
||||||
|
expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
|
||||||
|
expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource1');
|
||||||
|
expect(resources[3]).toHaveAttribute('data-resource-uri', 'ui://test/resource1');
|
||||||
|
expect(resources[4]).toHaveAttribute('data-resource-uri', 'ui://test/resource0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple ui_resources attachments', () => {
|
||||||
|
const mockMessages = [
|
||||||
|
{
|
||||||
|
messageId: 'msg123',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource0',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 0 content</p>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uri: 'ui://test/resource1',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
text: '<p>Resource 1 content</p>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ui_resources',
|
||||||
|
ui_resources: [
|
||||||
|
{ uri: 'ui://test/resource2', mimeType: 'text/html', text: '<p>Resource 2</p>' },
|
||||||
|
{ uri: 'ui://test/resource3', mimeType: 'text/html', text: '<p>Resource 3</p>' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: mockMessages,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Resources from both attachments should be accessible
|
||||||
|
render(<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 2, 3] } }} />);
|
||||||
|
|
||||||
|
const carousel = screen.getByTestId('ui-resource-carousel');
|
||||||
|
expect(carousel).toHaveAttribute('data-resource-count', '3');
|
||||||
|
|
||||||
|
// Note: indices 2 and 3 are from the second attachment and become accessible in the flattened array
|
||||||
|
expect(screen.getByTestId('resource-0')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource0',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('resource-1')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource2',
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('resource-2')).toHaveAttribute(
|
||||||
|
'data-resource-uri',
|
||||||
|
'ui://test/resource3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null messages data', () => {
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing conversation', () => {
|
||||||
|
mockUseChatContext.mockReturnValue({
|
||||||
|
conversation: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
mockUseGetMessagesByConvoId.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
249
client/src/components/MCPUIResource/__tests__/plugin.test.ts
Normal file
249
client/src/components/MCPUIResource/__tests__/plugin.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { mcpUIResourcePlugin, UI_RESOURCE_MARKER } from '../plugin';
|
||||||
|
import type { Node } from 'unist';
|
||||||
|
import type { UIResourceNode } from '../types';
|
||||||
|
|
||||||
|
describe('mcpUIResourcePlugin', () => {
|
||||||
|
const createTextNode = (value: string): UIResourceNode => ({
|
||||||
|
type: 'text',
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTree = (nodes: UIResourceNode[]): Node =>
|
||||||
|
({
|
||||||
|
type: 'root',
|
||||||
|
children: nodes,
|
||||||
|
}) as Node;
|
||||||
|
|
||||||
|
const processTree = (tree: Node) => {
|
||||||
|
const plugin = mcpUIResourcePlugin();
|
||||||
|
plugin(tree);
|
||||||
|
return tree;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('single resource markers', () => {
|
||||||
|
it('should replace single UI resource marker with mcp-ui-resource node', () => {
|
||||||
|
const tree = createTree([createTextNode(`Here is a resource ${UI_RESOURCE_MARKER}0`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children[0]).toEqual({ type: 'text', value: 'Here is a resource ' });
|
||||||
|
expect(children[1]).toEqual({
|
||||||
|
type: 'mcp-ui-resource',
|
||||||
|
data: {
|
||||||
|
hName: 'mcp-ui-resource',
|
||||||
|
hProperties: {
|
||||||
|
resourceIndex: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple single resource markers', () => {
|
||||||
|
const tree = createTree([
|
||||||
|
createTextNode(`First ${UI_RESOURCE_MARKER}0 and second ${UI_RESOURCE_MARKER}1`),
|
||||||
|
]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(4);
|
||||||
|
expect(children[0]).toEqual({ type: 'text', value: 'First ' });
|
||||||
|
expect(children[1].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children[1].data.hProperties.resourceIndex).toBe(0);
|
||||||
|
expect(children[2]).toEqual({ type: 'text', value: ' and second ' });
|
||||||
|
expect(children[3].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children[3].data.hProperties.resourceIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large index numbers', () => {
|
||||||
|
const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}42`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children[1].data.hProperties.resourceIndex).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('carousel markers', () => {
|
||||||
|
it('should replace carousel marker with mcp-ui-carousel node', () => {
|
||||||
|
const tree = createTree([createTextNode(`Carousel ${UI_RESOURCE_MARKER}0,1,2`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children[0]).toEqual({ type: 'text', value: 'Carousel ' });
|
||||||
|
expect(children[1]).toEqual({
|
||||||
|
type: 'mcp-ui-carousel',
|
||||||
|
data: {
|
||||||
|
hName: 'mcp-ui-carousel',
|
||||||
|
hProperties: {
|
||||||
|
resourceIndices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large index numbers in carousel', () => {
|
||||||
|
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}100,200,300`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children[0].data.hProperties.resourceIndices).toEqual([100, 200, 300]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mixed content', () => {
|
||||||
|
it('should handle text before and after markers', () => {
|
||||||
|
const tree = createTree([
|
||||||
|
createTextNode(`Before ${UI_RESOURCE_MARKER}0 middle ${UI_RESOURCE_MARKER}1,2 after`),
|
||||||
|
]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(5);
|
||||||
|
expect(children[0].value).toBe('Before ');
|
||||||
|
expect(children[1].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children[2].value).toBe(' middle ');
|
||||||
|
expect(children[3].type).toBe('mcp-ui-carousel');
|
||||||
|
expect(children[4].value).toBe(' after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle marker at start of text', () => {
|
||||||
|
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}0 after`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children[0].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children[1].value).toBe(' after');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle marker at end of text', () => {
|
||||||
|
const tree = createTree([createTextNode(`Before ${UI_RESOURCE_MARKER}0`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children[0].value).toBe('Before ');
|
||||||
|
expect(children[1].type).toBe('mcp-ui-resource');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle consecutive markers', () => {
|
||||||
|
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}0${UI_RESOURCE_MARKER}1`)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(2);
|
||||||
|
expect(children[0].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children[0].data.hProperties.resourceIndex).toBe(0);
|
||||||
|
expect(children[1].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children[1].data.hProperties.resourceIndex).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty text nodes', () => {
|
||||||
|
const tree = createTree([createTextNode('')]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
expect(children[0]).toEqual({ type: 'text', value: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle text without markers', () => {
|
||||||
|
const tree = createTree([createTextNode('No markers here')]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
expect(children[0]).toEqual({ type: 'text', value: 'No markers here' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-text nodes', () => {
|
||||||
|
const tree = createTree([{ type: 'paragraph', children: [] }]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
expect(children[0].type).toBe('paragraph');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested structures', () => {
|
||||||
|
const tree = {
|
||||||
|
type: 'root',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}0`)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Node;
|
||||||
|
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const paragraph = (tree as any).children[0];
|
||||||
|
const textNodes = paragraph.children;
|
||||||
|
expect(textNodes).toHaveLength(2);
|
||||||
|
expect(textNodes[0].value).toBe('Text with ');
|
||||||
|
expect(textNodes[1].type).toBe('mcp-ui-resource');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not process nodes without value property', () => {
|
||||||
|
const tree = createTree([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
// no value property
|
||||||
|
} as UIResourceNode,
|
||||||
|
]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
expect(children[0].type).toBe('text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pattern validation', () => {
|
||||||
|
it('should not match invalid patterns', () => {
|
||||||
|
const invalidPatterns = [
|
||||||
|
`${UI_RESOURCE_MARKER}`,
|
||||||
|
`${UI_RESOURCE_MARKER}abc`,
|
||||||
|
`${UI_RESOURCE_MARKER}-1`,
|
||||||
|
`${UI_RESOURCE_MARKER},1`,
|
||||||
|
`ui0`, // missing marker
|
||||||
|
];
|
||||||
|
|
||||||
|
invalidPatterns.forEach((pattern) => {
|
||||||
|
const tree = createTree([createTextNode(pattern)]);
|
||||||
|
processTree(tree);
|
||||||
|
|
||||||
|
const children = (tree as any).children;
|
||||||
|
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
expect(children[0].type).toBe('text');
|
||||||
|
expect(children[0].value).toBe(pattern);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial matches correctly', () => {
|
||||||
|
// Test that ui1.2 matches ui1 and leaves .2
|
||||||
|
const tree1 = createTree([createTextNode(`${UI_RESOURCE_MARKER}1.2`)]);
|
||||||
|
processTree(tree1);
|
||||||
|
const children1 = (tree1 as any).children;
|
||||||
|
expect(children1).toHaveLength(2);
|
||||||
|
expect(children1[0].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children1[0].data.hProperties.resourceIndex).toBe(1);
|
||||||
|
expect(children1[1].value).toBe('.2');
|
||||||
|
|
||||||
|
// Test that ui1, matches as single resource followed by comma
|
||||||
|
const tree2 = createTree([createTextNode(`${UI_RESOURCE_MARKER}1,`)]);
|
||||||
|
processTree(tree2);
|
||||||
|
const children2 = (tree2 as any).children;
|
||||||
|
expect(children2).toHaveLength(2);
|
||||||
|
expect(children2[0].type).toBe('mcp-ui-resource');
|
||||||
|
expect(children2[0].data.hProperties.resourceIndex).toBe(1);
|
||||||
|
expect(children2[1].value).toBe(',');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
4
client/src/components/MCPUIResource/index.ts
Normal file
4
client/src/components/MCPUIResource/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { mcpUIResourcePlugin } from './plugin';
|
||||||
|
export { MCPUIResource } from './MCPUIResource';
|
||||||
|
export { MCPUIResourceCarousel } from './MCPUIResourceCarousel';
|
||||||
|
export * from './types';
|
||||||
84
client/src/components/MCPUIResource/plugin.ts
Normal file
84
client/src/components/MCPUIResource/plugin.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { visit } from 'unist-util-visit';
|
||||||
|
import type { Node } from 'unist';
|
||||||
|
import type { UIResourceNode } from './types';
|
||||||
|
|
||||||
|
export const UI_RESOURCE_MARKER = '\\ui';
|
||||||
|
export const UI_RESOURCE_PATTERN = new RegExp(`\\${UI_RESOURCE_MARKER}(\\d+(?:,\\d+)*)`, 'g');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process text nodes and replace UI resource markers with components
|
||||||
|
*/
|
||||||
|
function processTree(tree: Node) {
|
||||||
|
visit(tree, 'text', (node, index, parent) => {
|
||||||
|
const textNode = node as UIResourceNode;
|
||||||
|
const parentNode = parent as UIResourceNode;
|
||||||
|
|
||||||
|
if (typeof textNode.value !== 'string') return;
|
||||||
|
|
||||||
|
const originalValue = textNode.value;
|
||||||
|
const segments: Array<UIResourceNode> = [];
|
||||||
|
|
||||||
|
let currentPosition = 0;
|
||||||
|
UI_RESOURCE_PATTERN.lastIndex = 0;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = UI_RESOURCE_PATTERN.exec(originalValue)) !== null) {
|
||||||
|
const matchIndex = match.index;
|
||||||
|
const matchText = match[0];
|
||||||
|
const indicesString = match[1];
|
||||||
|
const indices = indicesString.split(',').map(Number);
|
||||||
|
|
||||||
|
if (matchIndex > currentPosition) {
|
||||||
|
const textBeforeMatch = originalValue.substring(currentPosition, matchIndex);
|
||||||
|
if (textBeforeMatch) {
|
||||||
|
segments.push({ type: 'text', value: textBeforeMatch });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indices.length === 1) {
|
||||||
|
segments.push({
|
||||||
|
type: 'mcp-ui-resource',
|
||||||
|
data: {
|
||||||
|
hName: 'mcp-ui-resource',
|
||||||
|
hProperties: {
|
||||||
|
resourceIndex: indices[0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
segments.push({
|
||||||
|
type: 'mcp-ui-carousel',
|
||||||
|
data: {
|
||||||
|
hName: 'mcp-ui-carousel',
|
||||||
|
hProperties: {
|
||||||
|
resourceIndices: indices,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPosition = matchIndex + matchText.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPosition < originalValue.length) {
|
||||||
|
const remainingText = originalValue.substring(currentPosition);
|
||||||
|
if (remainingText) {
|
||||||
|
segments.push({ type: 'text', value: remainingText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length > 0 && index !== undefined) {
|
||||||
|
parentNode.children?.splice(index, 1, ...segments);
|
||||||
|
return index + segments.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remark plugin for processing MCP UI resource markers
|
||||||
|
*/
|
||||||
|
export function mcpUIResourcePlugin() {
|
||||||
|
return (tree: Node) => {
|
||||||
|
processTree(tree);
|
||||||
|
};
|
||||||
|
}
|
||||||
12
client/src/components/MCPUIResource/types.ts
Normal file
12
client/src/components/MCPUIResource/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface UIResourceNode {
|
||||||
|
type: string;
|
||||||
|
value?: string;
|
||||||
|
data?: {
|
||||||
|
hName: string;
|
||||||
|
hProperties: {
|
||||||
|
resourceIndex?: number;
|
||||||
|
resourceIndices?: number[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
children?: UIResourceNode[];
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,6 @@ export { default as useLocalStorage } from './useLocalStorage';
|
||||||
export { default as useDocumentTitle } from './useDocumentTitle';
|
export { default as useDocumentTitle } from './useDocumentTitle';
|
||||||
export { default as useSpeechToText } from './Input/useSpeechToText';
|
export { default as useSpeechToText } from './Input/useSpeechToText';
|
||||||
export { default as useTextToSpeech } from './Input/useTextToSpeech';
|
export { default as useTextToSpeech } from './Input/useTextToSpeech';
|
||||||
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
|
||||||
export { useResourcePermissions } from './useResourcePermissions';
|
|
||||||
export { default as useLocalizedConfig } from './useLocalizedConfig';
|
export { default as useLocalizedConfig } from './useLocalizedConfig';
|
||||||
|
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
||||||
|
export { default as useResourcePermissions } from './useResourcePermissions';
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,5 @@ export const useResourcePermissions = (resourceType: ResourceType, resourceId: s
|
||||||
permissionBits: data?.permissionBits || 0,
|
permissionBits: data?.permissionBits || 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default useResourcePermissions;
|
||||||
|
|
|
||||||
|
|
@ -1318,6 +1318,8 @@
|
||||||
"com_ui_trust_app": "I trust this application",
|
"com_ui_trust_app": "I trust this application",
|
||||||
"com_ui_try_adjusting_search": "Try adjusting your search terms",
|
"com_ui_try_adjusting_search": "Try adjusting your search terms",
|
||||||
"com_ui_ui_resources": "UI Resources",
|
"com_ui_ui_resources": "UI Resources",
|
||||||
|
"com_ui_ui_resource_error": "UI Resource Error ({{0}})",
|
||||||
|
"com_ui_ui_resource_not_found": "UI Resource not found (index: {{0}})",
|
||||||
"com_ui_unarchive": "Unarchive",
|
"com_ui_unarchive": "Unarchive",
|
||||||
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
||||||
"com_ui_unavailable": "Unavailable",
|
"com_ui_unavailable": "Unavailable",
|
||||||
|
|
|
||||||
|
|
@ -124,3 +124,59 @@ export const normalizeLayout = (layout: number[]) => {
|
||||||
|
|
||||||
return normalizedLayout;
|
return normalizedLayout;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleUIAction = async (result: any, submitMessage: any) => {
|
||||||
|
const supportedTypes = ['intent', 'tool', 'prompt'];
|
||||||
|
|
||||||
|
const { type, payload } = result;
|
||||||
|
|
||||||
|
if (!supportedTypes.includes(type)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageText = '';
|
||||||
|
|
||||||
|
if (type === 'intent') {
|
||||||
|
const { intent, params } = payload;
|
||||||
|
messageText = `The user clicked a button in an embedded UI Resource, and we got a message of type \`intent\`.
|
||||||
|
The intent is \`${intent}\` and the params are:
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
${JSON.stringify(params, null, 2)}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Execute the intent that is mentioned in the message using the tools available to you.
|
||||||
|
`;
|
||||||
|
} else if (type === 'tool') {
|
||||||
|
const { toolName, params } = payload;
|
||||||
|
messageText = `The user clicked a button in an embedded UI Resource, and we got a message of type \`tool\`.
|
||||||
|
The tool name is \`${toolName}\` and the params are:
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
${JSON.stringify(params, null, 2)}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Execute the tool that is mentioned in the message using the tools available to you.
|
||||||
|
`;
|
||||||
|
} else if (type === 'prompt') {
|
||||||
|
const { prompt } = payload;
|
||||||
|
messageText = `The user clicked a button in an embedded UI Resource, and we got a message of type \`prompt\`.
|
||||||
|
The prompt is:
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
${prompt}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Execute the intention of the prompt that is mentioned in the message using the tools available to you.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('About to submit message:', messageText);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitMessage({ text: messageText });
|
||||||
|
console.log('Message submitted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,6 @@ describe('formatToolContent', () => {
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text:
|
text:
|
||||||
'Resource Text: {"items": []}\n' +
|
|
||||||
'Resource URI: ui://carousel\n' +
|
'Resource URI: ui://carousel\n' +
|
||||||
'Resource MIME Type: application/json',
|
'Resource MIME Type: application/json',
|
||||||
},
|
},
|
||||||
|
|
@ -276,7 +275,6 @@ describe('formatToolContent', () => {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text:
|
text:
|
||||||
'Some text\n\n' +
|
'Some text\n\n' +
|
||||||
'Resource Text: {"label": "Click me"}\n' +
|
|
||||||
'Resource URI: ui://button\n' +
|
'Resource URI: ui://button\n' +
|
||||||
'Resource MIME Type: application/json\n\n' +
|
'Resource MIME Type: application/json\n\n' +
|
||||||
'Resource URI: file://data.csv',
|
'Resource URI: file://data.csv',
|
||||||
|
|
@ -319,10 +317,7 @@ describe('formatToolContent', () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text:
|
text: 'Resource URI: ui://graph\n' + 'Resource MIME Type: application/json',
|
||||||
'Resource Text: {"type": "line"}\n' +
|
|
||||||
'Resource URI: ui://graph\n' +
|
|
||||||
'Resource MIME Type: application/json',
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(artifacts).toEqual({
|
expect(artifacts).toEqual({
|
||||||
|
|
@ -399,7 +394,6 @@ describe('formatToolContent', () => {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text:
|
text:
|
||||||
'Middle section\n\n' +
|
'Middle section\n\n' +
|
||||||
'Resource Text: {"type": "bar"}\n' +
|
|
||||||
'Resource URI: ui://chart\n' +
|
'Resource URI: ui://chart\n' +
|
||||||
'Resource MIME Type: application/json\n\n' +
|
'Resource MIME Type: application/json\n\n' +
|
||||||
'Resource URI: https://api.example.com/data',
|
'Resource URI: https://api.example.com/data',
|
||||||
|
|
|
||||||
|
|
@ -132,12 +132,14 @@ export function formatToolContent(
|
||||||
},
|
},
|
||||||
|
|
||||||
resource: (item) => {
|
resource: (item) => {
|
||||||
if (item.resource.uri.startsWith('ui://')) {
|
const isUiResource = item.resource.uri.startsWith('ui://');
|
||||||
|
|
||||||
|
if (isUiResource) {
|
||||||
uiResources.push(item.resource as UIResource);
|
uiResources.push(item.resource as UIResource);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceText = [];
|
const resourceText = [];
|
||||||
if ('text' in item.resource && item.resource.text != null && item.resource.text) {
|
if (!isUiResource && 'text' in item.resource && item.resource.text) {
|
||||||
resourceText.push(`Resource Text: ${item.resource.text}`);
|
resourceText.push(`Resource Text: ${item.resource.text}`);
|
||||||
}
|
}
|
||||||
if (item.resource.uri.length) {
|
if (item.resource.uri.length) {
|
||||||
|
|
@ -165,10 +167,14 @@ export function formatToolContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
let artifacts: t.Artifacts = undefined;
|
let artifacts: t.Artifacts = undefined;
|
||||||
if (imageUrls.length || uiResources.length) {
|
if (imageUrls.length) {
|
||||||
|
artifacts = { content: imageUrls };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiResources.length) {
|
||||||
artifacts = {
|
artifacts = {
|
||||||
...(imageUrls.length && { content: imageUrls }),
|
...artifacts,
|
||||||
...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }),
|
[Tools.ui_resources]: { data: uiResources },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,7 @@ export type Provider =
|
||||||
export type FormattedContent =
|
export type FormattedContent =
|
||||||
| {
|
| {
|
||||||
type: 'text';
|
type: 'text';
|
||||||
metadata?: {
|
text: string;
|
||||||
type: string;
|
|
||||||
data: UIResource[];
|
|
||||||
};
|
|
||||||
text?: string;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'image';
|
type: 'image';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue