💻 refactor: Migrate MCP UI resources from index-based to ID-based referencing

- Replace index-based resource markers with stable resource IDs
- Update plugin to parse \ui{resourceId} format instead of \ui0
- Refactor components to use useMessagesOperations instead of useSubmitMessage
- Add ShareMessagesProvider for UI resources in share view
- Add useConversationUIResources hook for cross-turn resource lookups
- Update parsers to generate resource IDs from content hashes
- Update all tests to use resource IDs instead of indices
- Add sandbox permissions for iframe popups
- Remove deprecated MCP tool context instructions
This commit is contained in:
Pierre-Luc Godin 2025-10-16 14:51:38 -04:00 committed by Dustin Healy
parent 08103ffb22
commit 649036903f
20 changed files with 636 additions and 599 deletions

View file

@ -335,15 +335,6 @@ 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 */

View file

@ -28,6 +28,10 @@ interface MessagesViewContextValue {
const MessagesViewContext = createContext<MessagesViewContextValue | undefined>(undefined); const MessagesViewContext = createContext<MessagesViewContextValue | undefined>(undefined);
// Export the context so it can be provided by other providers (e.g., ShareMessagesProvider)
export { MessagesViewContext };
export type { MessagesViewContextValue };
export function MessagesViewProvider({ children }: { children: React.ReactNode }) { export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
const chatContext = useChatContext(); const chatContext = useChatContext();
const addedChatContext = useAddedChatContext(); const addedChatContext = useAddedChatContext();

View file

@ -1,7 +1,7 @@
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 { useMessagesOperations } from '~/Providers';
import { handleUIAction } from '~/utils'; import { handleUIAction } from '~/utils';
interface UIResourceCarouselProps { interface UIResourceCarouselProps {
@ -13,7 +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 { ask } = useMessagesOperations();
const handleScroll = React.useCallback(() => { const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return; if (!scrollContainerRef.current) return;
@ -114,7 +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) => handleUIAction(result, submitMessage)} onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{ htmlProps={{
autoResizeIframe: { width: true, height: true }, autoResizeIframe: { width: true, height: true },
}} }}

View file

@ -3,21 +3,20 @@ import { render, screen } from '@testing-library/react';
import Markdown from '../Markdown'; import Markdown from '../Markdown';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin'; import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { useMessageContext, useChatContext } from '~/Providers'; import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider'; import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
// Mocks for hooks used by MCPUIResource when rendered inside Markdown. // Mocks for hooks used by MCPUIResource when rendered inside Markdown.
// Keep Provider components intact while mocking only the hooks we use. // Keep Provider components intact while mocking only the hooks we use.
jest.mock('~/Providers', () => ({ jest.mock('~/Providers', () => ({
...jest.requireActual('~/Providers'), ...jest.requireActual('~/Providers'),
useMessageContext: jest.fn(), useMessageContext: jest.fn(),
useChatContext: jest.fn(), useMessagesConversation: jest.fn(),
useMessagesOperations: jest.fn(),
})); }));
jest.mock('~/data-provider'); jest.mock('~/data-provider');
jest.mock('~/hooks'); jest.mock('~/hooks');
jest.mock('~/hooks/Messages/useSubmitMessage');
// Mock @mcp-ui/client to render identifiable elements for assertions // Mock @mcp-ui/client to render identifiable elements for assertions
jest.mock('@mcp-ui/client', () => ({ jest.mock('@mcp-ui/client', () => ({
@ -27,37 +26,52 @@ jest.mock('@mcp-ui/client', () => ({
})); }));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>; const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>; const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
typeof useMessagesConversation
>;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction< const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId typeof useGetMessagesByConvoId
>; >;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>; 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)', () => { describe('Markdown with MCP UI markers (resource IDs)', () => {
let currentTestMessages: any[] = [];
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg-weather' } as any); mockUseMessageContext.mockReturnValue({ messageId: 'msg-weather' } as any);
mockUseChatContext.mockReturnValue({ conversation: { conversationId: 'conv1' } } as any); mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv1' },
conversationId: 'conv1',
} as any);
mockUseMessagesOperations.mockReturnValue({
ask: jest.fn(),
getMessages: () => currentTestMessages,
} as any);
mockUseLocalize.mockReturnValue(((key: string) => key) 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', () => { it('renders two UIResourceRenderer components for markers with resource IDs across separate attachments', () => {
// Two tool responses, each produced one ui_resources attachment // Two tool responses, each produced one ui_resources attachment
const paris = { const paris = {
resourceId: 'abc123',
uri: 'ui://weather/paris', uri: 'ui://weather/paris',
mimeType: 'text/html', mimeType: 'text/html',
text: '<div>Paris Weather</div>', text: '<div>Paris Weather</div>',
}; };
const nyc = { const nyc = {
resourceId: 'def456',
uri: 'ui://weather/nyc', uri: 'ui://weather/nyc',
mimeType: 'text/html', mimeType: 'text/html',
text: '<div>NYC Weather</div>', text: '<div>NYC Weather</div>',
}; };
const messages = [ currentTestMessages = [
{ {
messageId: 'msg-weather', messageId: 'msg-weather',
attachments: [ attachments: [
@ -67,7 +81,7 @@ describe('Markdown with MCP UI markers (two attachments ui0 ui1)', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ data: messages } as any); mockUseGetMessagesByConvoId.mockReturnValue({ data: currentTestMessages } as any);
const content = [ const content = [
'Here are the current weather conditions for both Paris and New York:', 'Here are the current weather conditions for both Paris and New York:',
@ -75,7 +89,7 @@ describe('Markdown with MCP UI markers (two attachments ui0 ui1)', () => {
'- Paris: Slight rain, 53°F, humidity 76%, wind 9 mph.', '- Paris: Slight rain, 53°F, humidity 76%, wind 9 mph.',
'- New York: Clear sky, 63°F, humidity 91%, wind 6 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`, `Browse these weather cards for more details ${UI_RESOURCE_MARKER}{abc123} ${UI_RESOURCE_MARKER}{def456}`,
].join('\n'); ].join('\n');
render( render(

View file

@ -13,12 +13,11 @@ jest.mock('@mcp-ui/client', () => ({
), ),
})); }));
// Mock useSubmitMessage hook // Mock useMessagesOperations hook
const mockSubmitMessage = jest.fn(); const mockAsk = jest.fn();
jest.mock('~/hooks/Messages/useSubmitMessage', () => ({ jest.mock('~/Providers', () => ({
__esModule: true, useMessagesOperations: () => ({
default: () => ({ ask: mockAsk,
submitMessage: mockSubmitMessage,
}), }),
})); }));
@ -47,7 +46,7 @@ describe('UIResourceCarousel', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
mockSubmitMessage.mockClear(); mockAsk.mockClear();
mockHandleUIAction.mockClear(); mockHandleUIAction.mockClear();
// Reset scroll properties // Reset scroll properties
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', { Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
@ -166,7 +165,7 @@ describe('UIResourceCarousel', () => {
fireEvent.click(renderer); fireEvent.click(renderer);
await waitFor(() => { await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage); expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk);
}); });
}); });
@ -179,7 +178,7 @@ describe('UIResourceCarousel', () => {
fireEvent.click(renderers[1]); fireEvent.click(renderers[1]);
await waitFor(() => { await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage); expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk);
expect(mockHandleUIAction).toHaveBeenCalledTimes(1); expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
}); });
@ -191,14 +190,14 @@ describe('UIResourceCarousel', () => {
}); });
}); });
it('passes correct submitMessage function to handleUIAction', async () => { it('passes correct ask function to handleUIAction', async () => {
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(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage); expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk);
expect(mockHandleUIAction).toHaveBeenCalledTimes(1); expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
}); });
}); });

View file

@ -1,52 +1,40 @@
import React, { useMemo } from 'react'; import React from 'react';
import { UIResourceRenderer } from '@mcp-ui/client'; import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from '~/common';
import { handleUIAction } from '~/utils'; import { handleUIAction } from '~/utils';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage'; import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessageContext, useChatContext } from '~/Providers'; import { useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
interface MCPUIResourceProps { interface MCPUIResourceProps {
node: { node: {
properties: { properties: {
resourceIndex: number; resourceId: string;
}; };
}; };
} }
/** /**
* Component that renders an MCP UI resource based on message context and index * Component that renders an MCP UI resource based on its resource ID.
* Works in both main app and share view.
*/ */
export function MCPUIResource(props: MCPUIResourceProps) { export function MCPUIResource(props: MCPUIResourceProps) {
const { resourceIndex } = props.node.properties; const { resourceId } = props.node.properties;
const localize = useLocalize(); const localize = useLocalize();
const { submitMessage } = useSubmitMessage(); const { ask } = useMessagesOperations();
const { messageId } = useMessageContext(); const { conversation } = useMessagesConversation();
const { conversation } = useChatContext();
const { data: messages } = useGetMessagesByConvoId(conversation?.conversationId ?? '', {
enabled: !!conversation?.conversationId,
});
const uiResource = useMemo(() => { const conversationResourceMap = useConversationUIResources(
const targetMessage = messages?.find((m) => m.messageId === messageId); conversation?.conversationId ?? undefined,
);
if (!targetMessage?.attachments) { const uiResource = conversationResourceMap.get(resourceId ?? '');
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) { if (!uiResource) {
return ( return (
<span className="inline-flex items-center rounded bg-gray-100 px-2 py-1 text-xs font-medium text-gray-600"> <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() })} {localize('com_ui_ui_resource_not_found', {
0: resourceId ?? '',
})}
</span> </span>
); );
} }
@ -56,9 +44,10 @@ export function MCPUIResource(props: MCPUIResourceProps) {
<span className="mx-1 inline-block w-full align-middle"> <span className="mx-1 inline-block w-full align-middle">
<UIResourceRenderer <UIResourceRenderer
resource={uiResource} resource={uiResource}
onUIAction={async (result) => handleUIAction(result, submitMessage)} onUIAction={async (result) => handleUIAction(result, ask)}
htmlProps={{ htmlProps={{
autoResizeIframe: { width: true, height: true }, autoResizeIframe: { width: true, height: true },
sandboxPermissions: 'allow-popups allow-popups-to-escape-sandbox',
}} }}
/> />
</span> </span>

View file

@ -1,43 +1,33 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useGetMessagesByConvoId } from '~/data-provider'; import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useMessageContext, useChatContext } from '~/Providers'; import { useMessagesConversation } from '~/Providers';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel'; import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import type { UIResource } from '~/common'; import type { UIResource } from 'librechat-data-provider';
interface MCPUIResourceCarouselProps { interface MCPUIResourceCarouselProps {
node: { node: {
properties: { properties: {
resourceIndices: number[]; resourceIds?: string[];
}; };
}; };
} }
/**
* Component that renders multiple MCP UI resources in a carousel.
* Works in both main app and share view.
*/
export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) { export function MCPUIResourceCarousel(props: MCPUIResourceCarouselProps) {
const { messageId } = useMessageContext(); const { conversation } = useMessagesConversation();
const { conversation } = useChatContext();
const { data: messages } = useGetMessagesByConvoId(conversation?.conversationId ?? '', { const conversationResourceMap = useConversationUIResources(
enabled: !!conversation?.conversationId, conversation?.conversationId ?? undefined,
}); );
const uiResources = useMemo(() => { const uiResources = useMemo(() => {
const { resourceIndices } = props.node.properties; const { resourceIds = [] } = props.node.properties;
const targetMessage = messages?.find((m) => m.messageId === messageId); return resourceIds.map((id) => conversationResourceMap.get(id)).filter(Boolean) as UIResource[];
}, [props.node.properties, conversationResourceMap]);
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) { if (uiResources.length === 0) {
return null; return null;

View file

@ -1,17 +1,14 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResource } from '../MCPUIResource'; import { MCPUIResource } from '../MCPUIResource';
import { useMessageContext, useChatContext } from '~/Providers'; import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import { handleUIAction } from '~/utils'; import { handleUIAction } from '~/utils';
// Mock dependencies // Mock dependencies
jest.mock('~/Providers'); jest.mock('~/Providers');
jest.mock('~/data-provider');
jest.mock('~/hooks'); jest.mock('~/hooks');
jest.mock('~/hooks/Messages/useSubmitMessage');
jest.mock('~/utils'); jest.mock('~/utils');
jest.mock('@mcp-ui/client', () => ({ jest.mock('@mcp-ui/client', () => ({
@ -25,113 +22,105 @@ jest.mock('@mcp-ui/client', () => ({
})); }));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>; const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>; const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction< typeof useMessagesConversation
typeof useGetMessagesByConvoId >;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
>; >;
const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>; const mockUseLocalize = useLocalize as jest.MockedFunction<typeof useLocalize>;
const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction<typeof useSubmitMessage>;
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>; const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
describe('MCPUIResource', () => { describe('MCPUIResource', () => {
const mockLocalize = (key: string, values?: any) => { const mockLocalize = (key: string, values?: any) => {
const translations: Record<string, string> = { const translations: Record<string, string> = {
com_ui_ui_resource_not_found: `UI resource at index ${values?.[0]} not found`, com_ui_ui_resource_not_found: `UI resource ${values?.[0]} not found`,
com_ui_ui_resource_error: `Error rendering UI resource: ${values?.[0]}`, com_ui_ui_resource_error: `Error rendering UI resource: ${values?.[0]}`,
}; };
return translations[key] || key; return translations[key] || key;
}; };
const mockSubmitMessageFn = jest.fn(); const mockAskFn = jest.fn();
const renderWithRecoil = (ui: React.ReactNode) => render(<RecoilRoot>{ui}</RecoilRoot>);
// Store the current test's messages so getMessages can return them
let currentTestMessages: any[] = [];
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any); mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
mockUseChatContext.mockReturnValue({ mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv123' }, conversation: { conversationId: 'conv123' },
conversationId: 'conv123',
} as any);
mockUseMessagesOperations.mockReturnValue({
ask: mockAskFn,
getMessages: () => currentTestMessages,
regenerate: jest.fn(),
handleContinue: jest.fn(),
setMessages: jest.fn(),
} as any); } as any);
mockUseLocalize.mockReturnValue(mockLocalize as any); mockUseLocalize.mockReturnValue(mockLocalize as any);
mockUseSubmitMessage.mockReturnValue({ submitMessage: mockSubmitMessageFn } as any);
}); });
describe('resource fetching', () => { describe('resource fetching', () => {
it('should fetch and render UI resource from message attachments', () => { it('should fetch and render UI resource from message attachments', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
{ {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' }, {
resourceId: 'resource-1',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Test Resource</p>',
},
], ],
}, },
], ],
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
const renderer = screen.getByTestId('ui-resource-renderer'); const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument(); expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource'); expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource');
}); });
it('should show not found message when resource index is out of bounds', () => { it('should show not found message when resourceId does not exist', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
{ {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ uri: 'ui://test/resource', mimeType: 'text/html', text: '<p>Test Resource</p>' }, {
resourceId: 'resource-1',
uri: 'ui://test/resource',
mimeType: 'text/html',
text: '<p>Test Resource</p>',
},
], ],
}, },
], ],
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'nonexistent-id' } }} />);
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 5 } }} />); expect(screen.getByText('UI resource nonexistent-id not found')).toBeInTheDocument();
expect(screen.getByText('UI resource at index 5 not found')).toBeInTheDocument();
expect(screen.queryByTestId('ui-resource-renderer')).not.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', () => { it('should show not found when no ui_resources attachments', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
@ -143,19 +132,47 @@ describe('MCPUIResource', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />); expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
});
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument(); it('should resolve resources by resourceId across conversation messages', () => {
mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any);
currentTestMessages = [
{
messageId: 'msg-previous',
attachments: [
{
type: 'ui_resources',
ui_resources: [
{
resourceId: 'abc123',
uri: 'ui://test/resource-id',
mimeType: 'text/html',
text: '<p>Resource via ID</p>',
},
],
},
],
},
{
messageId: 'msg-current',
attachments: [],
},
];
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'abc123' } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource-id');
}); });
}); });
describe('UI action handling', () => { describe('UI action handling', () => {
it('should handle UI actions with handleUIAction', async () => { it('should handle UI actions with handleUIAction', async () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
@ -163,6 +180,7 @@ describe('MCPUIResource', () => {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ {
resourceId: 'resource-1',
uri: 'ui://test/resource', uri: 'ui://test/resource',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Interactive Resource</p>', text: '<p>Interactive Resource</p>',
@ -173,83 +191,82 @@ describe('MCPUIResource', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
data: mockMessages,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />);
const renderer = screen.getByTestId('ui-resource-renderer'); const renderer = screen.getByTestId('ui-resource-renderer');
renderer.click(); renderer.click();
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessageFn); expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAskFn);
}); });
}); });
describe('edge cases', () => { describe('edge cases', () => {
it('should handle empty messages array', () => { it('should handle empty messages array', () => {
mockUseGetMessagesByConvoId.mockReturnValue({ currentTestMessages = [];
data: [],
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />); renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument(); expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
}); });
it('should handle null messages data', () => { it('should handle null messages data', () => {
mockUseGetMessagesByConvoId.mockReturnValue({ currentTestMessages = [];
data: null,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />); renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument(); expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
}); });
it('should handle missing conversation', () => { it('should handle missing conversation', () => {
mockUseChatContext.mockReturnValue({ currentTestMessages = [];
mockUseMessagesConversation.mockReturnValue({
conversation: null, conversation: null,
conversationId: null,
} as any); } as any);
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
data: null,
} as any);
render(<MCPUIResource node={{ properties: { resourceIndex: 0 } }} />); expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument();
expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument();
}); });
it('should handle multiple attachments of ui_resources type', () => { it('should handle multiple attachments of ui_resources type', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
{ {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ uri: 'ui://test/resource1', mimeType: 'text/html', text: '<p>Resource 1</p>' }, {
resourceId: 'resource-1',
uri: 'ui://test/resource1',
mimeType: 'text/html',
text: '<p>Resource 1</p>',
},
], ],
}, },
{ {
type: 'ui_resources', type: 'ui_resources',
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>' }, resourceId: 'resource-2',
uri: 'ui://test/resource2',
mimeType: 'text/html',
text: '<p>Resource 2</p>',
},
{
resourceId: 'resource-3',
uri: 'ui://test/resource3',
mimeType: 'text/html',
text: '<p>Resource 3</p>',
},
], ],
}, },
], ],
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-2' } }} />);
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'); const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument(); expect(renderer).toBeInTheDocument();

View file

@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel'; import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel';
import { useMessageContext, useChatContext } from '~/Providers'; import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers';
import { useGetMessagesByConvoId } from '~/data-provider';
// Mock dependencies // Mock dependencies
jest.mock('~/Providers'); jest.mock('~/Providers');
jest.mock('~/data-provider');
jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({ jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
__esModule: true, __esModule: true,
@ -20,232 +19,113 @@ jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({
})); }));
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>; const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>; const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction<
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction< typeof useMessagesConversation
typeof useGetMessagesByConvoId >;
const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction<
typeof useMessagesOperations
>; >;
describe('MCPUIResourceCarousel', () => { describe('MCPUIResourceCarousel', () => {
// Store the current test's messages so getMessages can return them
let currentTestMessages: any[] = [];
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
currentTestMessages = [];
mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any); mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any);
mockUseChatContext.mockReturnValue({ mockUseMessagesConversation.mockReturnValue({
conversation: { conversationId: 'conv123' }, conversation: { conversationId: 'conv123' },
conversationId: 'conv123',
} as any);
mockUseMessagesOperations.mockReturnValue({
getMessages: () => currentTestMessages,
ask: jest.fn(),
regenerate: jest.fn(),
handleContinue: jest.fn(),
setMessages: jest.fn(),
} as any); } as any);
}); });
const renderWithRecoil = (ui: React.ReactNode) => render(<RecoilRoot>{ui}</RecoilRoot>);
describe('multiple resource fetching', () => { describe('multiple resource fetching', () => {
it('should fetch multiple resources by indices', () => { it('should fetch resources by resourceIds across conversation messages', () => {
const mockMessages = [ mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any);
currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg-origin',
attachments: [ attachments: [
{ {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ {
uri: 'ui://test/resource0', resourceId: 'id-1',
uri: 'ui://test/resource-id1',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Resource 0 content</p>', text: '<p>Resource via ID 1</p>',
}, },
{ {
uri: 'ui://test/resource1', resourceId: 'id-2',
uri: 'ui://test/resource-id2',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Resource 1 content</p>', text: '<p>Resource via ID 2</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', messageId: 'msg-current',
attachments: [ 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({ renderWithRecoil(
data: mockMessages, <MCPUIResourceCarousel node={{ properties: { resourceIds: ['id-2', 'id-1'] } }} />,
} 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'); const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '2'); expect(carousel).toHaveAttribute('data-resource-count', '2');
expect(screen.getByTestId('resource-0')).toHaveAttribute( expect(screen.getByTestId('resource-0')).toHaveAttribute(
'data-resource-uri', 'data-resource-uri',
'ui://test/resource0', 'ui://test/resource-id2',
); );
expect(screen.getByTestId('resource-1')).toHaveAttribute( expect(screen.getByTestId('resource-1')).toHaveAttribute(
'data-resource-uri', 'data-resource-uri',
'ui://test/resource1', 'ui://test/resource-id1',
); );
}); });
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', () => { describe('error handling', () => {
it('should return null when no attachments', () => { it('should return null when no attachments', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: undefined, attachments: undefined,
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ const { container } = renderWithRecoil(
data: mockMessages, <MCPUIResourceCarousel node={{ properties: { resourceIds: ['id1', 'id2'] } }} />,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1] } }} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument(); expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument();
}); });
it('should return null when message not found', () => { it('should return null when resources not found', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'different-msg', messageId: 'msg123',
attachments: [ attachments: [
{ {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ {
resourceId: 'existing-id',
uri: 'ui://test/resource', uri: 'ui://test/resource',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Resource content</p>', text: '<p>Resource content</p>',
@ -256,19 +136,15 @@ describe('MCPUIResourceCarousel', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ const { container } = renderWithRecoil(
data: mockMessages, <MCPUIResourceCarousel node={{ properties: { resourceIds: ['non-existent-id'] } }} />,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it('should return null when no ui_resources attachments', () => { it('should return null when no ui_resources attachments', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
@ -280,12 +156,8 @@ describe('MCPUIResourceCarousel', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ const { container } = renderWithRecoil(
data: mockMessages, <MCPUIResourceCarousel node={{ properties: { resourceIds: ['id1', 'id2'] } }} />,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0, 1] } }} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
@ -293,8 +165,8 @@ describe('MCPUIResourceCarousel', () => {
}); });
describe('edge cases', () => { describe('edge cases', () => {
it('should handle empty resourceIndices array', () => { it('should handle empty resourceIds array', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
@ -302,6 +174,7 @@ describe('MCPUIResourceCarousel', () => {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ {
resourceId: 'test-id',
uri: 'ui://test/resource', uri: 'ui://test/resource',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Resource content</p>', text: '<p>Resource content</p>',
@ -312,19 +185,15 @@ describe('MCPUIResourceCarousel', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ const { container } = renderWithRecoil(
data: mockMessages, <MCPUIResourceCarousel node={{ properties: { resourceIds: [] } }} />,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [] } }} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it('should handle duplicate indices', () => { it('should handle duplicate resource IDs', () => {
const mockMessages = [ currentTestMessages = [
{ {
messageId: 'msg123', messageId: 'msg123',
attachments: [ attachments: [
@ -332,14 +201,16 @@ describe('MCPUIResourceCarousel', () => {
type: 'ui_resources', type: 'ui_resources',
ui_resources: [ ui_resources: [
{ {
uri: 'ui://test/resource0', resourceId: 'id-a',
uri: 'ui://test/resource-a',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Resource 0 content</p>', text: '<p>Resource A content</p>',
}, },
{ {
uri: 'ui://test/resource1', resourceId: 'id-b',
uri: 'ui://test/resource-b',
mimeType: 'text/html', mimeType: 'text/html',
text: '<p>Resource 1 content</p>', text: '<p>Resource B content</p>',
}, },
], ],
}, },
@ -347,105 +218,43 @@ describe('MCPUIResourceCarousel', () => {
}, },
]; ];
mockUseGetMessagesByConvoId.mockReturnValue({ renderWithRecoil(
data: mockMessages, <MCPUIResourceCarousel
} as any); node={{ properties: { resourceIds: ['id-a', 'id-a', 'id-b', 'id-b', 'id-a'] } }}
/>,
);
// 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'); const carousel = screen.getByTestId('ui-resource-carousel');
expect(carousel).toHaveAttribute('data-resource-count', '5'); expect(carousel).toHaveAttribute('data-resource-count', '5');
const resources = screen.getAllByTestId(/resource-\d/); const resources = screen.getAllByTestId(/resource-\d/);
expect(resources).toHaveLength(5); expect(resources).toHaveLength(5);
expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource0'); expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a');
expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource0'); expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a');
expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource1'); expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource-b');
expect(resources[3]).toHaveAttribute('data-resource-uri', 'ui://test/resource1'); expect(resources[3]).toHaveAttribute('data-resource-uri', 'ui://test/resource-b');
expect(resources[4]).toHaveAttribute('data-resource-uri', 'ui://test/resource0'); expect(resources[4]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a');
});
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', () => { it('should handle null messages data', () => {
mockUseGetMessagesByConvoId.mockReturnValue({ currentTestMessages = [];
data: null,
} as any);
const { container } = render( const { container } = renderWithRecoil(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />, <MCPUIResourceCarousel node={{ properties: { resourceIds: ['test-id'] } }} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();
}); });
it('should handle missing conversation', () => { it('should handle missing conversation', () => {
mockUseChatContext.mockReturnValue({ mockUseMessagesConversation.mockReturnValue({
conversation: null, conversation: null,
conversationId: null,
} as any); } as any);
currentTestMessages = [];
mockUseGetMessagesByConvoId.mockReturnValue({ const { container } = renderWithRecoil(
data: null, <MCPUIResourceCarousel node={{ properties: { resourceIds: ['test-id'] } }} />,
} as any);
const { container } = render(
<MCPUIResourceCarousel node={{ properties: { resourceIndices: [0] } }} />,
); );
expect(container.firstChild).toBeNull(); expect(container.firstChild).toBeNull();

View file

@ -22,26 +22,21 @@ describe('mcpUIResourcePlugin', () => {
describe('single resource markers', () => { describe('single resource markers', () => {
it('should replace single UI resource marker with mcp-ui-resource node', () => { it('should replace single UI resource marker with mcp-ui-resource node', () => {
const tree = createTree([createTextNode(`Here is a resource ${UI_RESOURCE_MARKER}0`)]); const tree = createTree([createTextNode(`Here is a resource ${UI_RESOURCE_MARKER}{abc123}`)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
expect(children).toHaveLength(2); expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Here is a resource ' }); expect(children[0]).toEqual({ type: 'text', value: 'Here is a resource ' });
expect(children[1]).toEqual({ expect(children[1].type).toBe('mcp-ui-resource');
type: 'mcp-ui-resource', expect(children[1].data.hProperties).toMatchObject({
data: { resourceId: 'abc123',
hName: 'mcp-ui-resource',
hProperties: {
resourceIndex: 0,
},
},
}); });
}); });
it('should handle multiple single resource markers', () => { it('should handle multiple single resource markers', () => {
const tree = createTree([ const tree = createTree([
createTextNode(`First ${UI_RESOURCE_MARKER}0 and second ${UI_RESOURCE_MARKER}1`), createTextNode(`First ${UI_RESOURCE_MARKER}{id1} and second ${UI_RESOURCE_MARKER}{id2}`),
]); ]);
processTree(tree); processTree(tree);
@ -49,24 +44,24 @@ describe('mcpUIResourcePlugin', () => {
expect(children).toHaveLength(4); expect(children).toHaveLength(4);
expect(children[0]).toEqual({ type: 'text', value: 'First ' }); expect(children[0]).toEqual({ type: 'text', value: 'First ' });
expect(children[1].type).toBe('mcp-ui-resource'); expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties.resourceIndex).toBe(0); expect(children[1].data.hProperties).toMatchObject({ resourceId: 'id1' });
expect(children[2]).toEqual({ type: 'text', value: ' and second ' }); expect(children[2]).toEqual({ type: 'text', value: ' and second ' });
expect(children[3].type).toBe('mcp-ui-resource'); expect(children[3].type).toBe('mcp-ui-resource');
expect(children[3].data.hProperties.resourceIndex).toBe(1); expect(children[3].data.hProperties).toMatchObject({ resourceId: 'id2' });
}); });
it('should handle large index numbers', () => { it('should handle hex IDs', () => {
const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}42`)]); const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}{a3f2b8c1d4}`)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
expect(children[1].data.hProperties.resourceIndex).toBe(42); expect(children[1].data.hProperties).toMatchObject({ resourceId: 'a3f2b8c1d4' });
}); });
}); });
describe('carousel markers', () => { describe('carousel markers', () => {
it('should replace carousel marker with mcp-ui-carousel node', () => { it('should replace carousel marker with mcp-ui-carousel node', () => {
const tree = createTree([createTextNode(`Carousel ${UI_RESOURCE_MARKER}0,1,2`)]); const tree = createTree([createTextNode(`Carousel ${UI_RESOURCE_MARKER}{id1,id2,id3}`)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
@ -77,25 +72,68 @@ describe('mcpUIResourcePlugin', () => {
data: { data: {
hName: 'mcp-ui-carousel', hName: 'mcp-ui-carousel',
hProperties: { hProperties: {
resourceIndices: [0, 1, 2], resourceIds: ['id1', 'id2', 'id3'],
}, },
}, },
}); });
}); });
it('should handle large index numbers in carousel', () => { it('should handle multiple IDs in carousel', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}100,200,300`)]); const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{alpha,beta,gamma}`)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
expect(children[0].data.hProperties.resourceIndices).toEqual([100, 200, 300]); expect(children[0].data.hProperties.resourceIds).toEqual(['alpha', 'beta', 'gamma']);
});
});
describe('id-based markers', () => {
it('should replace single ID marker with mcp-ui-resource node', () => {
const tree = createTree([createTextNode(`Check this ${UI_RESOURCE_MARKER}{abc123}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(2);
expect(children[0]).toEqual({ type: 'text', value: 'Check this ' });
expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties).toEqual({
resourceId: 'abc123',
});
});
it('should replace carousel ID marker with mcp-ui-carousel node', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{one,two,three}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({
type: 'mcp-ui-carousel',
data: {
hName: 'mcp-ui-carousel',
hProperties: {
resourceIds: ['one', 'two', 'three'],
},
},
});
});
it('should ignore empty IDs', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0]).toEqual({ type: 'text', value: `${UI_RESOURCE_MARKER}{}` });
}); });
}); });
describe('mixed content', () => { describe('mixed content', () => {
it('should handle text before and after markers', () => { it('should handle text before and after markers', () => {
const tree = createTree([ const tree = createTree([
createTextNode(`Before ${UI_RESOURCE_MARKER}0 middle ${UI_RESOURCE_MARKER}1,2 after`), createTextNode(
`Before ${UI_RESOURCE_MARKER}{id1} middle ${UI_RESOURCE_MARKER}{id2,id3} after`,
),
]); ]);
processTree(tree); processTree(tree);
@ -109,7 +147,7 @@ describe('mcpUIResourcePlugin', () => {
}); });
it('should handle marker at start of text', () => { it('should handle marker at start of text', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}0 after`)]); const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{id1} after`)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
@ -119,7 +157,7 @@ describe('mcpUIResourcePlugin', () => {
}); });
it('should handle marker at end of text', () => { it('should handle marker at end of text', () => {
const tree = createTree([createTextNode(`Before ${UI_RESOURCE_MARKER}0`)]); const tree = createTree([createTextNode(`Before ${UI_RESOURCE_MARKER}{id1}`)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
@ -129,15 +167,17 @@ describe('mcpUIResourcePlugin', () => {
}); });
it('should handle consecutive markers', () => { it('should handle consecutive markers', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}0${UI_RESOURCE_MARKER}1`)]); const tree = createTree([
createTextNode(`${UI_RESOURCE_MARKER}{id1}${UI_RESOURCE_MARKER}{id2}`),
]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
expect(children).toHaveLength(2); expect(children).toHaveLength(2);
expect(children[0].type).toBe('mcp-ui-resource'); expect(children[0].type).toBe('mcp-ui-resource');
expect(children[0].data.hProperties.resourceIndex).toBe(0); expect(children[0].data.hProperties).toEqual({ resourceId: 'id1' });
expect(children[1].type).toBe('mcp-ui-resource'); expect(children[1].type).toBe('mcp-ui-resource');
expect(children[1].data.hProperties.resourceIndex).toBe(1); expect(children[1].data.hProperties).toEqual({ resourceId: 'id2' });
}); });
}); });
@ -175,7 +215,7 @@ describe('mcpUIResourcePlugin', () => {
children: [ children: [
{ {
type: 'paragraph', type: 'paragraph',
children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}0`)], children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}{id1}`)],
}, },
], ],
} as Node; } as Node;
@ -205,45 +245,57 @@ describe('mcpUIResourcePlugin', () => {
}); });
describe('pattern validation', () => { describe('pattern validation', () => {
it('should not match invalid patterns', () => { it('should not match marker alone', () => {
const invalidPatterns = [ const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}`)]);
`${UI_RESOURCE_MARKER}`, processTree(tree);
`${UI_RESOURCE_MARKER}abc`, const children = (tree as any).children;
`${UI_RESOURCE_MARKER}-1`, expect(children).toHaveLength(1);
`${UI_RESOURCE_MARKER},1`, expect(children[0].type).toBe('text');
`ui0`, // missing marker });
it('should not match marker without braces', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}abc`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should not match marker with leading comma', () => {
const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{,id}`)]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should not match marker without backslash', () => {
const tree = createTree([createTextNode('ui{id}')]);
processTree(tree);
const children = (tree as any).children;
expect(children).toHaveLength(1);
expect(children[0].type).toBe('text');
});
it('should handle valid hex ID patterns', () => {
const validPatterns = [
{ input: `${UI_RESOURCE_MARKER}{abc123}`, id: 'abc123' },
{ input: `${UI_RESOURCE_MARKER}{a3f2b8c1d4}`, id: 'a3f2b8c1d4' },
{ input: `${UI_RESOURCE_MARKER}{1234567890}`, id: '1234567890' },
{ input: `${UI_RESOURCE_MARKER}{abcdef0123}`, id: 'abcdef0123' },
{ input: `${UI_RESOURCE_MARKER}{deadbeef}`, id: 'deadbeef' },
{ input: `${UI_RESOURCE_MARKER}{a1b2c3}`, id: 'a1b2c3' },
]; ];
invalidPatterns.forEach((pattern) => { validPatterns.forEach(({ input, id }) => {
const tree = createTree([createTextNode(pattern)]); const tree = createTree([createTextNode(input)]);
processTree(tree); processTree(tree);
const children = (tree as any).children; const children = (tree as any).children;
expect(children).toHaveLength(1); expect(children).toHaveLength(1);
expect(children[0].type).toBe('text'); expect(children[0].type).toBe('mcp-ui-resource');
expect(children[0].value).toBe(pattern); expect(children[0].data.hProperties).toEqual({ resourceId: id });
}); });
}); });
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(',');
});
}); });
}); });

View file

@ -3,7 +3,8 @@ import type { Node } from 'unist';
import type { UIResourceNode } from './types'; import type { UIResourceNode } from './types';
export const UI_RESOURCE_MARKER = '\\ui'; export const UI_RESOURCE_MARKER = '\\ui';
export const UI_RESOURCE_PATTERN = new RegExp(`\\${UI_RESOURCE_MARKER}(\\d+(?:,\\d+)*)`, 'g'); // Pattern matches: \ui{id1} or \ui{id1,id2,id3} and captures everything between the braces
export const UI_RESOURCE_PATTERN = /\\ui\{([\w]+(?:,[\w]+)*)\}/g;
/** /**
* Process text nodes and replace UI resource markers with components * Process text nodes and replace UI resource markers with components
@ -25,8 +26,11 @@ function processTree(tree: Node) {
while ((match = UI_RESOURCE_PATTERN.exec(originalValue)) !== null) { while ((match = UI_RESOURCE_PATTERN.exec(originalValue)) !== null) {
const matchIndex = match.index; const matchIndex = match.index;
const matchText = match[0]; const matchText = match[0];
const indicesString = match[1]; const idGroup = match[1];
const indices = indicesString.split(',').map(Number); const idValues = idGroup
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (matchIndex > currentPosition) { if (matchIndex > currentPosition) {
const textBeforeMatch = originalValue.substring(currentPosition, matchIndex); const textBeforeMatch = originalValue.substring(currentPosition, matchIndex);
@ -35,26 +39,29 @@ function processTree(tree: Node) {
} }
} }
if (indices.length === 1) { if (idValues.length === 1) {
segments.push({ segments.push({
type: 'mcp-ui-resource', type: 'mcp-ui-resource',
data: { data: {
hName: 'mcp-ui-resource', hName: 'mcp-ui-resource',
hProperties: { hProperties: {
resourceIndex: indices[0], resourceId: idValues[0],
}, },
}, },
}); });
} else { } else if (idValues.length > 1) {
segments.push({ segments.push({
type: 'mcp-ui-carousel', type: 'mcp-ui-carousel',
data: { data: {
hName: 'mcp-ui-carousel', hName: 'mcp-ui-carousel',
hProperties: { hProperties: {
resourceIndices: indices, resourceIds: idValues,
}, },
}, },
}); });
} else {
// Unable to parse marker; keep original text
segments.push({ type: 'text', value: matchText });
} }
currentPosition = matchIndex + matchText.length; currentPosition = matchIndex + matchText.length;

View file

@ -4,8 +4,8 @@ export interface UIResourceNode {
data?: { data?: {
hName: string; hName: string;
hProperties: { hProperties: {
resourceIndex?: number; resourceId?: string;
resourceIndices?: number[]; resourceIds?: string[];
}; };
}; };
children?: UIResourceNode[]; children?: UIResourceNode[];

View file

@ -0,0 +1,44 @@
import React, { useMemo } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { MessagesViewContext } from '~/Providers/MessagesViewContext';
import type { MessagesViewContextValue } from '~/Providers/MessagesViewContext';
interface ShareMessagesProviderProps {
messages: TMessage[];
children: React.ReactNode;
}
/**
* Minimal MessagesViewContext provider for share view.
* Provides conversation data needed by message components.
* Uses the same MessagesViewContext as the main app for compatibility with existing hooks.
*
* Note: conversationId is set to undefined because share view is read-only and doesn't
* need to check Recoil state for in-flight messages during streaming.
*/
export function ShareMessagesProvider({ messages, children }: ShareMessagesProviderProps) {
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
conversation: { conversationId: 'shared-conversation' },
conversationId: undefined,
// These are required by the context but not used in share view
ask: () => Promise.resolve(),
regenerate: () => {},
handleContinue: () => {},
latestMessage: messages[messages.length - 1] ?? null,
isSubmitting: false,
isSubmittingFamily: false,
abortScroll: false,
setAbortScroll: () => {},
index: 0,
setLatestMessage: () => {},
getMessages: () => messages,
setMessages: () => {},
}),
[messages],
);
return (
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
);
}

View file

@ -21,6 +21,7 @@ import { ShareArtifactsContainer } from './ShareArtifacts';
import { useLocalize, useDocumentTitle } from '~/hooks'; import { useLocalize, useDocumentTitle } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { ShareContext } from '~/Providers'; import { ShareContext } from '~/Providers';
import { ShareMessagesProvider } from './ShareMessagesProvider';
import MessagesView from './MessagesView'; import MessagesView from './MessagesView';
import Footer from '../Chat/Footer'; import Footer from '../Chat/Footer';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -108,7 +109,9 @@ function SharedView() {
onLangChange={handleLangChange} onLangChange={handleLangChange}
settingsLabel={localize('com_nav_settings')} settingsLabel={localize('com_nav_settings')}
/> />
<MessagesView messagesTree={messagesTree} conversationId={data.conversationId} /> <ShareMessagesProvider messages={data.messages}>
<MessagesView messagesTree={messagesTree} conversationId="shared-conversation" />
</ShareMessagesProvider>
</> </>
); );
} else { } else {

View file

@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { Tools } from 'librechat-data-provider';
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useMessagesOperations } from '~/Providers';
import store from '~/store';
/**
* Hook to collect all UI resources in a conversation, indexed by resource ID.
* This enables cross-turn resource references in the conversation.
* Works in both main app (using React Query cache) and share view (using context messages).
*
* @param conversationId - The ID of the conversation to collect resources from
* @returns A Map of resource IDs to UIResource objects
*/
export function useConversationUIResources(
conversationId: string | undefined,
): Map<string, UIResource> {
const { getMessages } = useMessagesOperations();
const conversationAttachmentsMap = useRecoilValue(
store.conversationAttachmentsSelector(conversationId),
);
return useMemo(() => {
const map = new Map<string, UIResource>();
const collectResources = (attachments?: TAttachment[]) => {
attachments
?.filter((attachment) => attachment?.type === Tools.ui_resources)
.forEach((attachment) => {
const resources = attachment?.[Tools.ui_resources];
if (Array.isArray(resources)) {
resources.forEach((resource) => {
if (resource?.resourceId) {
map.set(resource.resourceId, resource);
}
});
}
});
};
// Collect from messages (works in both main app and share view)
getMessages()?.forEach((message) => {
collectResources(message.attachments);
});
// Collect from in-flight messages (Recoil state during streaming - only when we have a conversationId)
if (conversationId) {
Object.values(conversationAttachmentsMap).forEach(collectResources);
}
return map;
}, [conversationId, getMessages, conversationAttachmentsMap]);
}

View file

@ -1,4 +1,4 @@
import { atom } from 'recoil'; import { atom, selectorFamily } from 'recoil';
import { TAttachment } from 'librechat-data-provider'; import { TAttachment } from 'librechat-data-provider';
import { atomWithLocalStorage } from './utils'; import { atomWithLocalStorage } from './utils';
import { BadgeItem } from '~/common'; import { BadgeItem } from '~/common';
@ -10,6 +10,43 @@ const messageAttachmentsMap = atom<Record<string, TAttachment[] | undefined>>({
default: {}, default: {},
}); });
/**
* Selector to get attachments for a specific conversation.
*/
const conversationAttachmentsSelector = selectorFamily<
Record<string, TAttachment[]>,
string | undefined
>({
key: 'conversationAttachments',
get:
(conversationId) =>
({ get }) => {
if (!conversationId) {
return {};
}
const attachmentsMap = get(messageAttachmentsMap);
const result: Record<string, TAttachment[]> = {};
// Filter to only include attachments for this conversation
Object.entries(attachmentsMap).forEach(([messageId, attachments]) => {
if (!attachments) {
return;
}
const relevantAttachments = attachments.filter(
(attachment) => attachment.conversationId === conversationId,
);
if (relevantAttachments.length > 0) {
result[messageId] = relevantAttachments;
}
});
return result;
},
});
const queriesEnabled = atom<boolean>({ const queriesEnabled = atom<boolean>({
key: 'queriesEnabled', key: 'queriesEnabled',
default: true, default: true,
@ -30,6 +67,7 @@ const chatBadges = atomWithLocalStorage<Pick<BadgeItem, 'id'>[]>('chatBadges', [
export default { export default {
hideBannerHint, hideBannerHint,
messageAttachmentsMap, messageAttachmentsMap,
conversationAttachmentsSelector,
queriesEnabled, queriesEnabled,
isEditingBadges, isEditingBadges,
chatBadges, chatBadges,

View file

@ -125,7 +125,7 @@ export const normalizeLayout = (layout: number[]) => {
return normalizedLayout; return normalizedLayout;
}; };
export const handleUIAction = async (result: any, submitMessage: any) => { export const handleUIAction = async (result: any, ask: any) => {
const supportedTypes = ['intent', 'tool', 'prompt']; const supportedTypes = ['intent', 'tool', 'prompt'];
const { type, payload } = result; const { type, payload } = result;
@ -174,7 +174,7 @@ Execute the intention of the prompt that is mentioned in the message using the t
console.log('About to submit message:', messageText); console.log('About to submit message:', messageText);
try { try {
await submitMessage({ text: messageText }); await ask({ text: messageText });
console.log('Message submitted successfully'); console.log('Message submitted successfully');
} catch (error) { } catch (error) {
console.error('Error submitting message:', error); console.error('Error submitting message:', error);

View file

@ -176,25 +176,23 @@ describe('formatToolContent', () => {
}; };
const [content, artifacts] = formatToolContent(result, 'openai'); const [content, artifacts] = formatToolContent(result, 'openai');
expect(content).toEqual([
{ expect(Array.isArray(content)).toBe(true);
type: 'text', const textContent = Array.isArray(content) ? content[0] : { text: '' };
text: expect(textContent).toMatchObject({ type: 'text' });
'Resource URI: ui://carousel\n' + expect(textContent.text).toContain('UI Resource ID:');
'Resource MIME Type: application/json', expect(textContent.text).toContain('UI Resource Marker: \\ui{');
}, expect(textContent.text).toContain('Resource URI: ui://carousel');
]); expect(textContent.text).toContain('Resource MIME Type: application/json');
expect(artifacts).toEqual({
ui_resources: { const uiResourceArtifact = artifacts?.ui_resources?.data?.[0];
data: [ expect(uiResourceArtifact).toBeTruthy();
{ expect(uiResourceArtifact).toMatchObject({
uri: 'ui://carousel', uri: 'ui://carousel',
mimeType: 'application/json', mimeType: 'application/json',
text: '{"items": []}', text: '{"items": []}',
},
],
},
}); });
expect(uiResourceArtifact?.resourceId).toEqual(expect.any(String));
}); });
it('should handle regular resources', () => { it('should handle regular resources', () => {
@ -270,27 +268,22 @@ describe('formatToolContent', () => {
}; };
const [content, artifacts] = formatToolContent(result, 'openai'); const [content, artifacts] = formatToolContent(result, 'openai');
expect(content).toEqual([ expect(Array.isArray(content)).toBe(true);
{ const textEntry = Array.isArray(content) ? content[0] : { text: '' };
type: 'text', expect(textEntry).toMatchObject({ type: 'text' });
text: expect(textEntry.text).toContain('Some text');
'Some text\n\n' + expect(textEntry.text).toContain('UI Resource Marker: \\ui{');
'Resource URI: ui://button\n' + expect(textEntry.text).toContain('Resource URI: ui://button');
'Resource MIME Type: application/json\n\n' + expect(textEntry.text).toContain('Resource MIME Type: application/json');
'Resource URI: file://data.csv', expect(textEntry.text).toContain('Resource URI: file://data.csv');
},
]); const uiResource = artifacts?.ui_resources?.data?.[0];
expect(artifacts).toEqual({ expect(uiResource).toMatchObject({
ui_resources: { uri: 'ui://button',
data: [ mimeType: 'application/json',
{ text: '{"label": "Click me"}',
uri: 'ui://button',
mimeType: 'application/json',
text: '{"label": "Click me"}',
},
],
},
}); });
expect(uiResource?.resourceId).toEqual(expect.any(String));
}); });
it('should handle both images and UI resources in artifacts', () => { it('should handle both images and UI resources in artifacts', () => {
@ -310,16 +303,14 @@ describe('formatToolContent', () => {
}; };
const [content, artifacts] = formatToolContent(result, 'openai'); const [content, artifacts] = formatToolContent(result, 'openai');
expect(content).toEqual([ expect(Array.isArray(content)).toBe(true);
{ if (Array.isArray(content)) {
type: 'text', expect(content[0]).toMatchObject({ type: 'text', text: 'Content with multimedia' });
text: 'Content with multimedia', expect(content[1].type).toBe('text');
}, expect(content[1].text).toContain('UI Resource Marker: \\ui{');
{ expect(content[1].text).toContain('Resource URI: ui://graph');
type: 'text', expect(content[1].text).toContain('Resource MIME Type: application/json');
text: 'Resource URI: ui://graph\n' + 'Resource MIME Type: application/json', }
},
]);
expect(artifacts).toEqual({ expect(artifacts).toEqual({
content: [ content: [
{ {
@ -333,6 +324,7 @@ describe('formatToolContent', () => {
uri: 'ui://graph', uri: 'ui://graph',
mimeType: 'application/json', mimeType: 'application/json',
text: '{"type": "line"}', text: '{"type": "line"}',
resourceId: expect.any(String),
}, },
], ],
}, },
@ -388,19 +380,21 @@ describe('formatToolContent', () => {
}; };
const [content, artifacts] = formatToolContent(result, 'anthropic'); const [content, artifacts] = formatToolContent(result, 'anthropic');
expect(content).toEqual([ expect(Array.isArray(content)).toBe(true);
{ type: 'text', text: 'Introduction' }, if (Array.isArray(content)) {
{ expect(content[0]).toEqual({ type: 'text', text: 'Introduction' });
type: 'text', expect(content[1].type).toBe('text');
text: expect(content[1].text).toContain('Middle section');
'Middle section\n\n' + expect(content[1].text).toContain('UI Resource ID:');
'Resource URI: ui://chart\n' + expect(content[1].text).toContain('UI Resource Marker: \\ui{');
'Resource MIME Type: application/json\n\n' + expect(content[1].text).toContain('Resource URI: ui://chart');
'Resource URI: https://api.example.com/data', expect(content[1].text).toContain('Resource MIME Type: application/json');
}, expect(content[1].text).toContain('Resource URI: https://api.example.com/data');
{ type: 'text', text: 'Conclusion' }, expect(content[2].type).toBe('text');
]); expect(content[2].text).toContain('Conclusion');
expect(artifacts).toEqual({ expect(content[2].text).toContain('UI Resource Markers Available:');
}
expect(artifacts).toMatchObject({
content: [ content: [
{ {
type: 'image_url', type: 'image_url',
@ -417,6 +411,7 @@ describe('formatToolContent', () => {
uri: 'ui://chart', uri: 'ui://chart',
mimeType: 'application/json', mimeType: 'application/json',
text: '{"type": "bar"}', text: '{"type": "bar"}',
resourceId: expect.any(String),
}, },
], ],
}, },

View file

@ -1,7 +1,12 @@
import crypto from 'node:crypto';
import { Tools } from 'librechat-data-provider'; import { Tools } from 'librechat-data-provider';
import type { UIResource } from 'librechat-data-provider'; import type { UIResource } from 'librechat-data-provider';
import type * as t from './types'; import type * as t from './types';
function generateResourceId(text: string): string {
return crypto.createHash('sha256').update(text).digest('hex').substring(0, 10);
}
const RECOGNIZED_PROVIDERS = new Set([ const RECOGNIZED_PROVIDERS = new Set([
'google', 'google',
'anthropic', 'anthropic',
@ -133,22 +138,33 @@ export function formatToolContent(
resource: (item) => { resource: (item) => {
const isUiResource = item.resource.uri.startsWith('ui://'); const isUiResource = item.resource.uri.startsWith('ui://');
const resourceText: string[] = [];
if (isUiResource) { if (isUiResource) {
uiResources.push(item.resource as UIResource); const contentToHash = item.resource.text || item.resource.uri || '';
} const resourceId = generateResourceId(contentToHash);
const uiResource: UIResource = {
...item.resource,
resourceId,
};
const resourceText = []; uiResources.push(uiResource);
if (!isUiResource && 'text' in item.resource && item.resource.text) { resourceText.push(`UI Resource ID: ${resourceId}`);
resourceText.push(`UI Resource Marker: \\ui{${resourceId}}`);
} else if ('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) {
resourceText.push(`Resource URI: ${item.resource.uri}`); resourceText.push(`Resource URI: ${item.resource.uri}`);
} }
if (item.resource.mimeType != null && item.resource.mimeType) { if (item.resource.mimeType != null && item.resource.mimeType) {
resourceText.push(`Resource MIME Type: ${item.resource.mimeType}`); resourceText.push(`Resource MIME Type: ${item.resource.mimeType}`);
} }
currentTextBlock += (currentTextBlock ? '\n\n' : '') + resourceText.join('\n');
if (resourceText.length) {
currentTextBlock += (currentTextBlock ? '\n\n' : '') + resourceText.join('\n');
}
}, },
}; };
@ -162,6 +178,21 @@ export function formatToolContent(
} }
} }
if (uiResources.length > 0) {
const uiInstructions = `
UI Resource Markers Available:
- Each resource above includes a stable ID and a marker hint like \`\\ui{abc123}\`
- You should usually introduce what you're showing before placing the marker
- For a single resource: \\ui{resource-id}
- For multiple resources shown separately: \\ui{resource-id-a} \\ui{resource-id-b}
- For multiple resources in a carousel: \\ui{resource-id-a,resource-id-b,resource-id-c}
- The UI will be rendered inline where you place the marker
- Format: \\ui{resource-id} or \\ui{id1,id2,id3} using the IDs provided above`;
currentTextBlock += uiInstructions;
}
if (CONTENT_ARRAY_PROVIDERS.has(provider) && currentTextBlock) { if (CONTENT_ARRAY_PROVIDERS.has(provider) && currentTextBlock) {
formattedContent.push({ type: 'text', text: currentTextBlock }); formattedContent.push({ type: 'text', text: currentTextBlock });
} }

View file

@ -583,9 +583,8 @@ export type MemoryArtifact = {
}; };
export type UIResource = { export type UIResource = {
type?: string; resourceId: string;
data?: unknown; uri: string;
uri?: string;
mimeType?: string; mimeType?: string;
text?: string; text?: string;
[key: string]: unknown; [key: string]: unknown;