From 649036903f07258c430ad58993198a74588abf33 Mon Sep 17 00:00:00 2001 From: Pierre-Luc Godin Date: Thu, 16 Oct 2025 14:51:38 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=BB=20refactor:=20Migrate=20MCP=20UI?= =?UTF-8?q?=20resources=20from=20index-based=20to=20ID-based=20referencing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- api/app/clients/tools/util/handleTools.js | 9 - client/src/Providers/MessagesViewContext.tsx | 4 + .../Messages/Content/UIResourceCarousel.tsx | 6 +- .../Content/__tests__/Markdown.mcpui.test.tsx | 40 +- .../__tests__/UIResourceCarousel.test.tsx | 21 +- .../MCPUIResource/MCPUIResource.tsx | 47 +-- .../MCPUIResource/MCPUIResourceCarousel.tsx | 42 +-- .../__tests__/MCPUIResource.test.tsx | 205 +++++----- .../__tests__/MCPUIResourceCarousel.test.tsx | 355 ++++-------------- .../MCPUIResource/__tests__/plugin.test.ts | 170 ++++++--- client/src/components/MCPUIResource/plugin.ts | 21 +- client/src/components/MCPUIResource/types.ts | 4 +- .../Share/ShareMessagesProvider.tsx | 44 +++ client/src/components/Share/ShareView.tsx | 5 +- .../Messages/useConversationUIResources.ts | 55 +++ client/src/store/misc.ts | 40 +- client/src/utils/index.ts | 4 +- .../api/src/mcp/__tests__/parsers.test.ts | 117 +++--- packages/api/src/mcp/parsers.ts | 41 +- packages/data-provider/src/schemas.ts | 5 +- 20 files changed, 636 insertions(+), 599 deletions(-) create mode 100644 client/src/components/Share/ShareMessagesProvider.tsx create mode 100644 client/src/hooks/Messages/useConversationUIResources.ts diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 18094a0bdf..c9abb0c3e3 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -335,15 +335,6 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })} }; continue; } 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); if (toolName === Constants.mcp_server) { /** Placeholder used for UI purposes */ diff --git a/client/src/Providers/MessagesViewContext.tsx b/client/src/Providers/MessagesViewContext.tsx index 630ad277f1..137fffbcd0 100644 --- a/client/src/Providers/MessagesViewContext.tsx +++ b/client/src/Providers/MessagesViewContext.tsx @@ -28,6 +28,10 @@ interface MessagesViewContextValue { const MessagesViewContext = createContext(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 }) { const chatContext = useChatContext(); const addedChatContext = useAddedChatContext(); diff --git a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx index 1f64b47fd8..15c420755b 100644 --- a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx +++ b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { UIResourceRenderer } from '@mcp-ui/client'; import type { UIResource } from 'librechat-data-provider'; -import useSubmitMessage from '~/hooks/Messages/useSubmitMessage'; +import { useMessagesOperations } from '~/Providers'; import { handleUIAction } from '~/utils'; interface UIResourceCarouselProps { @@ -13,7 +13,7 @@ const UIResourceCarousel: React.FC = React.memo(({ uiRe const [showRightArrow, setShowRightArrow] = useState(true); const [isContainerHovered, setIsContainerHovered] = useState(false); const scrollContainerRef = React.useRef(null); - const { submitMessage } = useSubmitMessage(); + const { ask } = useMessagesOperations(); const handleScroll = React.useCallback(() => { if (!scrollContainerRef.current) return; @@ -114,7 +114,7 @@ const UIResourceCarousel: React.FC = React.memo(({ uiRe mimeType: uiResource.mimeType, text: uiResource.text, }} - onUIAction={async (result) => handleUIAction(result, submitMessage)} + onUIAction={async (result) => handleUIAction(result, ask)} htmlProps={{ autoResizeIframe: { width: true, height: true }, }} diff --git a/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx index 108fdf30e0..6df66c9e15 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/Markdown.mcpui.test.tsx @@ -3,21 +3,20 @@ 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 { useMessageContext, useMessagesConversation, useMessagesOperations } 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(), + useMessagesConversation: jest.fn(), + useMessagesOperations: 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', () => ({ @@ -27,37 +26,52 @@ jest.mock('@mcp-ui/client', () => ({ })); const mockUseMessageContext = useMessageContext as jest.MockedFunction; -const mockUseChatContext = useChatContext as jest.MockedFunction; +const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction< + typeof useMessagesConversation +>; +const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction< + typeof useMessagesOperations +>; const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction< typeof useGetMessagesByConvoId >; const mockUseLocalize = useLocalize as jest.MockedFunction; -const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction; -describe('Markdown with MCP UI markers (two attachments ui0 ui1)', () => { +describe('Markdown with MCP UI markers (resource IDs)', () => { + let currentTestMessages: any[] = []; + beforeEach(() => { jest.clearAllMocks(); + currentTestMessages = []; 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); - 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 const paris = { + resourceId: 'abc123', uri: 'ui://weather/paris', mimeType: 'text/html', text: '
Paris Weather
', }; const nyc = { + resourceId: 'def456', uri: 'ui://weather/nyc', mimeType: 'text/html', text: '
NYC Weather
', }; - const messages = [ + currentTestMessages = [ { messageId: 'msg-weather', 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 = [ '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.', '- 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'); render( diff --git a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx index 7ec7a2601b..6d208c2cf2 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx @@ -13,12 +13,11 @@ jest.mock('@mcp-ui/client', () => ({ ), })); -// Mock useSubmitMessage hook -const mockSubmitMessage = jest.fn(); -jest.mock('~/hooks/Messages/useSubmitMessage', () => ({ - __esModule: true, - default: () => ({ - submitMessage: mockSubmitMessage, +// Mock useMessagesOperations hook +const mockAsk = jest.fn(); +jest.mock('~/Providers', () => ({ + useMessagesOperations: () => ({ + ask: mockAsk, }), })); @@ -47,7 +46,7 @@ describe('UIResourceCarousel', () => { beforeEach(() => { jest.clearAllMocks(); - mockSubmitMessage.mockClear(); + mockAsk.mockClear(); mockHandleUIAction.mockClear(); // Reset scroll properties Object.defineProperty(HTMLElement.prototype, 'scrollLeft', { @@ -166,7 +165,7 @@ describe('UIResourceCarousel', () => { fireEvent.click(renderer); await waitFor(() => { - expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage); + expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk); }); }); @@ -179,7 +178,7 @@ describe('UIResourceCarousel', () => { fireEvent.click(renderers[1]); await waitFor(() => { - expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage); + expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk); 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(); const renderer = screen.getByTestId('ui-resource-renderer'); fireEvent.click(renderer); await waitFor(() => { - expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage); + expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAsk); expect(mockHandleUIAction).toHaveBeenCalledTimes(1); }); }); diff --git a/client/src/components/MCPUIResource/MCPUIResource.tsx b/client/src/components/MCPUIResource/MCPUIResource.tsx index 7f4e44f837..785bfac234 100644 --- a/client/src/components/MCPUIResource/MCPUIResource.tsx +++ b/client/src/components/MCPUIResource/MCPUIResource.tsx @@ -1,52 +1,40 @@ -import React, { useMemo } from 'react'; +import React 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 { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources'; +import { useMessagesConversation, useMessagesOperations } from '~/Providers'; import { useLocalize } from '~/hooks'; interface MCPUIResourceProps { node: { 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) { - const { resourceIndex } = props.node.properties; + const { resourceId } = 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 { ask } = useMessagesOperations(); + const { conversation } = useMessagesConversation(); - const uiResource = useMemo(() => { - const targetMessage = messages?.find((m) => m.messageId === messageId); + const conversationResourceMap = useConversationUIResources( + conversation?.conversationId ?? undefined, + ); - 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]); + const uiResource = conversationResourceMap.get(resourceId ?? ''); if (!uiResource) { return ( - {localize('com_ui_ui_resource_not_found', { 0: resourceIndex.toString() })} + {localize('com_ui_ui_resource_not_found', { + 0: resourceId ?? '', + })} ); } @@ -56,9 +44,10 @@ export function MCPUIResource(props: MCPUIResourceProps) { handleUIAction(result, submitMessage)} + onUIAction={async (result) => handleUIAction(result, ask)} htmlProps={{ autoResizeIframe: { width: true, height: true }, + sandboxPermissions: 'allow-popups allow-popups-to-escape-sandbox', }} /> diff --git a/client/src/components/MCPUIResource/MCPUIResourceCarousel.tsx b/client/src/components/MCPUIResource/MCPUIResourceCarousel.tsx index 00359d24b2..cf32318491 100644 --- a/client/src/components/MCPUIResource/MCPUIResourceCarousel.tsx +++ b/client/src/components/MCPUIResource/MCPUIResourceCarousel.tsx @@ -1,43 +1,33 @@ import React, { useMemo } from 'react'; -import { useGetMessagesByConvoId } from '~/data-provider'; -import { useMessageContext, useChatContext } from '~/Providers'; +import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources'; +import { useMessagesConversation } from '~/Providers'; import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel'; -import type { UIResource } from '~/common'; +import type { UIResource } from 'librechat-data-provider'; interface MCPUIResourceCarouselProps { node: { 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) { - const { messageId } = useMessageContext(); - const { conversation } = useChatContext(); - const { data: messages } = useGetMessagesByConvoId(conversation?.conversationId ?? '', { - enabled: !!conversation?.conversationId, - }); + const { conversation } = useMessagesConversation(); + + const conversationResourceMap = useConversationUIResources( + conversation?.conversationId ?? undefined, + ); const uiResources = useMemo(() => { - const { resourceIndices } = props.node.properties; + const { resourceIds = [] } = 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]); + return resourceIds.map((id) => conversationResourceMap.get(id)).filter(Boolean) as UIResource[]; + }, [props.node.properties, conversationResourceMap]); if (uiResources.length === 0) { return null; diff --git a/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx b/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx index 24b38aa5be..53896bb6fe 100644 --- a/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx +++ b/client/src/components/MCPUIResource/__tests__/MCPUIResource.test.tsx @@ -1,17 +1,14 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; import { MCPUIResource } from '../MCPUIResource'; -import { useMessageContext, useChatContext } from '~/Providers'; -import { useGetMessagesByConvoId } from '~/data-provider'; +import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers'; 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', () => ({ @@ -25,113 +22,105 @@ jest.mock('@mcp-ui/client', () => ({ })); const mockUseMessageContext = useMessageContext as jest.MockedFunction; -const mockUseChatContext = useChatContext as jest.MockedFunction; -const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction< - typeof useGetMessagesByConvoId +const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction< + typeof useMessagesConversation +>; +const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction< + typeof useMessagesOperations >; const mockUseLocalize = useLocalize as jest.MockedFunction; -const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction; const mockHandleUIAction = handleUIAction as jest.MockedFunction; describe('MCPUIResource', () => { const mockLocalize = (key: string, values?: any) => { const translations: Record = { - 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]}`, }; return translations[key] || key; }; - const mockSubmitMessageFn = jest.fn(); + const mockAskFn = jest.fn(); + + const renderWithRecoil = (ui: React.ReactNode) => render({ui}); + + // Store the current test's messages so getMessages can return them + let currentTestMessages: any[] = []; beforeEach(() => { jest.clearAllMocks(); + currentTestMessages = []; mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any); - mockUseChatContext.mockReturnValue({ + mockUseMessagesConversation.mockReturnValue({ conversation: { conversationId: 'conv123' }, + conversationId: 'conv123', + } as any); + mockUseMessagesOperations.mockReturnValue({ + ask: mockAskFn, + getMessages: () => currentTestMessages, + regenerate: jest.fn(), + handleContinue: jest.fn(), + setMessages: jest.fn(), } 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 = [ + currentTestMessages = [ { messageId: 'msg123', attachments: [ { type: 'ui_resources', ui_resources: [ - { uri: 'ui://test/resource', mimeType: 'text/html', text: '

Test Resource

' }, + { + resourceId: 'resource-1', + uri: 'ui://test/resource', + mimeType: 'text/html', + text: '

Test Resource

', + }, ], }, ], }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - render(); + renderWithRecoil(); 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 = [ + it('should show not found message when resourceId does not exist', () => { + currentTestMessages = [ { messageId: 'msg123', attachments: [ { type: 'ui_resources', ui_resources: [ - { uri: 'ui://test/resource', mimeType: 'text/html', text: '

Test Resource

' }, + { + resourceId: 'resource-1', + uri: 'ui://test/resource', + mimeType: 'text/html', + text: '

Test Resource

', + }, ], }, ], }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); + renderWithRecoil(); - render(); - - expect(screen.getByText('UI resource at index 5 not found')).toBeInTheDocument(); + expect(screen.getByText('UI resource nonexistent-id 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: '

Test Resource

' }, - ], - }, - ], - }, - ]; - - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - render(); - - expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument(); - }); - it('should show not found when no ui_resources attachments', () => { - const mockMessages = [ + currentTestMessages = [ { messageId: 'msg123', attachments: [ @@ -143,19 +132,47 @@ describe('MCPUIResource', () => { }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); + renderWithRecoil(); - render(); + 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: '

Resource via ID

', + }, + ], + }, + ], + }, + { + messageId: 'msg-current', + attachments: [], + }, + ]; + + renderWithRecoil(); + + const renderer = screen.getByTestId('ui-resource-renderer'); + expect(renderer).toBeInTheDocument(); + expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource-id'); }); }); describe('UI action handling', () => { it('should handle UI actions with handleUIAction', async () => { - const mockMessages = [ + currentTestMessages = [ { messageId: 'msg123', attachments: [ @@ -163,6 +180,7 @@ describe('MCPUIResource', () => { type: 'ui_resources', ui_resources: [ { + resourceId: 'resource-1', uri: 'ui://test/resource', mimeType: 'text/html', text: '

Interactive Resource

', @@ -173,83 +191,82 @@ describe('MCPUIResource', () => { }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - render(); + renderWithRecoil(); const renderer = screen.getByTestId('ui-resource-renderer'); renderer.click(); - expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessageFn); + expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockAskFn); }); }); describe('edge cases', () => { it('should handle empty messages array', () => { - mockUseGetMessagesByConvoId.mockReturnValue({ - data: [], - } as any); + currentTestMessages = []; - render(); + renderWithRecoil(); - 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', () => { - mockUseGetMessagesByConvoId.mockReturnValue({ - data: null, - } as any); + currentTestMessages = []; - render(); + renderWithRecoil(); - 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', () => { - mockUseChatContext.mockReturnValue({ + currentTestMessages = []; + mockUseMessagesConversation.mockReturnValue({ conversation: null, + conversationId: null, } as any); - mockUseGetMessagesByConvoId.mockReturnValue({ - data: null, - } as any); + renderWithRecoil(); - render(); - - expect(screen.getByText('UI resource at index 0 not found')).toBeInTheDocument(); + expect(screen.getByText('UI resource resource-1 not found')).toBeInTheDocument(); }); it('should handle multiple attachments of ui_resources type', () => { - const mockMessages = [ + currentTestMessages = [ { messageId: 'msg123', attachments: [ { type: 'ui_resources', ui_resources: [ - { uri: 'ui://test/resource1', mimeType: 'text/html', text: '

Resource 1

' }, + { + resourceId: 'resource-1', + uri: 'ui://test/resource1', + mimeType: 'text/html', + text: '

Resource 1

', + }, ], }, { type: 'ui_resources', ui_resources: [ - { uri: 'ui://test/resource2', mimeType: 'text/html', text: '

Resource 2

' }, - { uri: 'ui://test/resource3', mimeType: 'text/html', text: '

Resource 3

' }, + { + resourceId: 'resource-2', + uri: 'ui://test/resource2', + mimeType: 'text/html', + text: '

Resource 2

', + }, + { + resourceId: 'resource-3', + uri: 'ui://test/resource3', + mimeType: 'text/html', + text: '

Resource 3

', + }, ], }, ], }, ]; - 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(); + renderWithRecoil(); const renderer = screen.getByTestId('ui-resource-renderer'); expect(renderer).toBeInTheDocument(); diff --git a/client/src/components/MCPUIResource/__tests__/MCPUIResourceCarousel.test.tsx b/client/src/components/MCPUIResource/__tests__/MCPUIResourceCarousel.test.tsx index 74594e577d..a9f7962ab0 100644 --- a/client/src/components/MCPUIResource/__tests__/MCPUIResourceCarousel.test.tsx +++ b/client/src/components/MCPUIResource/__tests__/MCPUIResourceCarousel.test.tsx @@ -1,12 +1,11 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; import { MCPUIResourceCarousel } from '../MCPUIResourceCarousel'; -import { useMessageContext, useChatContext } from '~/Providers'; -import { useGetMessagesByConvoId } from '~/data-provider'; +import { useMessageContext, useMessagesConversation, useMessagesOperations } from '~/Providers'; // Mock dependencies jest.mock('~/Providers'); -jest.mock('~/data-provider'); jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({ __esModule: true, @@ -20,232 +19,113 @@ jest.mock('../../Chat/Messages/Content/UIResourceCarousel', () => ({ })); const mockUseMessageContext = useMessageContext as jest.MockedFunction; -const mockUseChatContext = useChatContext as jest.MockedFunction; -const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction< - typeof useGetMessagesByConvoId +const mockUseMessagesConversation = useMessagesConversation as jest.MockedFunction< + typeof useMessagesConversation +>; +const mockUseMessagesOperations = useMessagesOperations as jest.MockedFunction< + typeof useMessagesOperations >; describe('MCPUIResourceCarousel', () => { + // Store the current test's messages so getMessages can return them + let currentTestMessages: any[] = []; + beforeEach(() => { jest.clearAllMocks(); + currentTestMessages = []; mockUseMessageContext.mockReturnValue({ messageId: 'msg123' } as any); - mockUseChatContext.mockReturnValue({ + mockUseMessagesConversation.mockReturnValue({ 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); }); + const renderWithRecoil = (ui: React.ReactNode) => render({ui}); + describe('multiple resource fetching', () => { - it('should fetch multiple resources by indices', () => { - const mockMessages = [ + it('should fetch resources by resourceIds across conversation messages', () => { + mockUseMessageContext.mockReturnValue({ messageId: 'msg-current' } as any); + currentTestMessages = [ { - messageId: 'msg123', + messageId: 'msg-origin', attachments: [ { type: 'ui_resources', ui_resources: [ { - uri: 'ui://test/resource0', + resourceId: 'id-1', + uri: 'ui://test/resource-id1', mimeType: 'text/html', - text: '

Resource 0 content

', + text: '

Resource via ID 1

', }, { - uri: 'ui://test/resource1', + resourceId: 'id-2', + uri: 'ui://test/resource-id2', mimeType: 'text/html', - text: '

Resource 1 content

', - }, - { - uri: 'ui://test/resource2', - mimeType: 'text/html', - text: '

Resource 2 content

', - }, - { - uri: 'ui://test/resource3', - mimeType: 'text/html', - text: '

Resource 3 content

', + text: '

Resource via ID 2

', }, ], }, ], }, - ]; - - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - render(); - - 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: '

Resource 0 content

', - }, - { - uri: 'ui://test/resource1', - mimeType: 'text/html', - text: '

Resource 1 content

', - }, - { - uri: 'ui://test/resource2', - mimeType: 'text/html', - text: '

Resource 2 content

', - }, - ], - }, - ], + messageId: 'msg-current', + attachments: [], }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - render(); - - 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: '

Resource 0 content

', - }, - { - uri: 'ui://test/resource1', - mimeType: 'text/html', - text: '

Resource 1 content

', - }, - ], - }, - ], - }, - ]; - - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - // Request indices 0, 1, 2, 3 but only 0 and 1 exist - render(); + renderWithRecoil( + , + ); 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', + 'ui://test/resource-id2', ); expect(screen.getByTestId('resource-1')).toHaveAttribute( '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: '

Resource 0 content

', - }, - { - uri: 'ui://test/resource1', - mimeType: 'text/html', - text: '

Resource 1 content

', - }, - ], - }, - ], - }, - ]; - - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - // Request indices that don't exist - render(); - - expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument(); - }); }); describe('error handling', () => { it('should return null when no attachments', () => { - const mockMessages = [ + currentTestMessages = [ { messageId: 'msg123', attachments: undefined, }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - const { container } = render( - , + const { container } = renderWithRecoil( + , ); expect(container.firstChild).toBeNull(); expect(screen.queryByTestId('ui-resource-carousel')).not.toBeInTheDocument(); }); - it('should return null when message not found', () => { - const mockMessages = [ + it('should return null when resources not found', () => { + currentTestMessages = [ { - messageId: 'different-msg', + messageId: 'msg123', attachments: [ { type: 'ui_resources', ui_resources: [ { + resourceId: 'existing-id', uri: 'ui://test/resource', mimeType: 'text/html', text: '

Resource content

', @@ -256,19 +136,15 @@ describe('MCPUIResourceCarousel', () => { }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - const { container } = render( - , + const { container } = renderWithRecoil( + , ); expect(container.firstChild).toBeNull(); }); it('should return null when no ui_resources attachments', () => { - const mockMessages = [ + currentTestMessages = [ { messageId: 'msg123', attachments: [ @@ -280,12 +156,8 @@ describe('MCPUIResourceCarousel', () => { }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - const { container } = render( - , + const { container } = renderWithRecoil( + , ); expect(container.firstChild).toBeNull(); @@ -293,8 +165,8 @@ describe('MCPUIResourceCarousel', () => { }); describe('edge cases', () => { - it('should handle empty resourceIndices array', () => { - const mockMessages = [ + it('should handle empty resourceIds array', () => { + currentTestMessages = [ { messageId: 'msg123', attachments: [ @@ -302,6 +174,7 @@ describe('MCPUIResourceCarousel', () => { type: 'ui_resources', ui_resources: [ { + resourceId: 'test-id', uri: 'ui://test/resource', mimeType: 'text/html', text: '

Resource content

', @@ -312,19 +185,15 @@ describe('MCPUIResourceCarousel', () => { }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - const { container } = render( - , + const { container } = renderWithRecoil( + , ); expect(container.firstChild).toBeNull(); }); - it('should handle duplicate indices', () => { - const mockMessages = [ + it('should handle duplicate resource IDs', () => { + currentTestMessages = [ { messageId: 'msg123', attachments: [ @@ -332,14 +201,16 @@ describe('MCPUIResourceCarousel', () => { type: 'ui_resources', ui_resources: [ { - uri: 'ui://test/resource0', + resourceId: 'id-a', + uri: 'ui://test/resource-a', mimeType: 'text/html', - text: '

Resource 0 content

', + text: '

Resource A content

', }, { - uri: 'ui://test/resource1', + resourceId: 'id-b', + uri: 'ui://test/resource-b', mimeType: 'text/html', - text: '

Resource 1 content

', + text: '

Resource B content

', }, ], }, @@ -347,105 +218,43 @@ describe('MCPUIResourceCarousel', () => { }, ]; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); + renderWithRecoil( + , + ); - // Request same index multiple times - render(); - - // 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: '

Resource 0 content

', - }, - { - uri: 'ui://test/resource1', - mimeType: 'text/html', - text: '

Resource 1 content

', - }, - ], - }, - { - type: 'ui_resources', - ui_resources: [ - { uri: 'ui://test/resource2', mimeType: 'text/html', text: '

Resource 2

' }, - { uri: 'ui://test/resource3', mimeType: 'text/html', text: '

Resource 3

' }, - ], - }, - ], - }, - ]; - - mockUseGetMessagesByConvoId.mockReturnValue({ - data: mockMessages, - } as any); - - // Resources from both attachments should be accessible - render(); - - 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', - ); + expect(resources[0]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a'); + expect(resources[1]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a'); + expect(resources[2]).toHaveAttribute('data-resource-uri', 'ui://test/resource-b'); + expect(resources[3]).toHaveAttribute('data-resource-uri', 'ui://test/resource-b'); + expect(resources[4]).toHaveAttribute('data-resource-uri', 'ui://test/resource-a'); }); it('should handle null messages data', () => { - mockUseGetMessagesByConvoId.mockReturnValue({ - data: null, - } as any); + currentTestMessages = []; - const { container } = render( - , + const { container } = renderWithRecoil( + , ); expect(container.firstChild).toBeNull(); }); it('should handle missing conversation', () => { - mockUseChatContext.mockReturnValue({ + mockUseMessagesConversation.mockReturnValue({ conversation: null, + conversationId: null, } as any); + currentTestMessages = []; - mockUseGetMessagesByConvoId.mockReturnValue({ - data: null, - } as any); - - const { container } = render( - , + const { container } = renderWithRecoil( + , ); expect(container.firstChild).toBeNull(); diff --git a/client/src/components/MCPUIResource/__tests__/plugin.test.ts b/client/src/components/MCPUIResource/__tests__/plugin.test.ts index 9b00ff889c..a478554918 100644 --- a/client/src/components/MCPUIResource/__tests__/plugin.test.ts +++ b/client/src/components/MCPUIResource/__tests__/plugin.test.ts @@ -22,26 +22,21 @@ describe('mcpUIResourcePlugin', () => { 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`)]); + const tree = createTree([createTextNode(`Here is a resource ${UI_RESOURCE_MARKER}{abc123}`)]); 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, - }, - }, + expect(children[1].type).toBe('mcp-ui-resource'); + expect(children[1].data.hProperties).toMatchObject({ + resourceId: 'abc123', }); }); it('should handle multiple single resource markers', () => { 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); @@ -49,24 +44,24 @@ describe('mcpUIResourcePlugin', () => { 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[1].data.hProperties).toMatchObject({ resourceId: 'id1' }); 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); + expect(children[3].data.hProperties).toMatchObject({ resourceId: 'id2' }); }); - it('should handle large index numbers', () => { - const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}42`)]); + it('should handle hex IDs', () => { + const tree = createTree([createTextNode(`Resource ${UI_RESOURCE_MARKER}{a3f2b8c1d4}`)]); processTree(tree); 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', () => { 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); const children = (tree as any).children; @@ -77,25 +72,68 @@ describe('mcpUIResourcePlugin', () => { data: { hName: 'mcp-ui-carousel', hProperties: { - resourceIndices: [0, 1, 2], + resourceIds: ['id1', 'id2', 'id3'], }, }, }); }); - it('should handle large index numbers in carousel', () => { - const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}100,200,300`)]); + it('should handle multiple IDs in carousel', () => { + const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}{alpha,beta,gamma}`)]); processTree(tree); 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', () => { it('should handle text before and after markers', () => { 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); @@ -109,7 +147,7 @@ describe('mcpUIResourcePlugin', () => { }); 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); const children = (tree as any).children; @@ -119,7 +157,7 @@ describe('mcpUIResourcePlugin', () => { }); 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); const children = (tree as any).children; @@ -129,15 +167,17 @@ describe('mcpUIResourcePlugin', () => { }); 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); 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[0].data.hProperties).toEqual({ resourceId: 'id1' }); 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: [ { type: 'paragraph', - children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}0`)], + children: [createTextNode(`Text with ${UI_RESOURCE_MARKER}{id1}`)], }, ], } as Node; @@ -205,45 +245,57 @@ describe('mcpUIResourcePlugin', () => { }); 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 + it('should not match marker alone', () => { + const tree = createTree([createTextNode(`${UI_RESOURCE_MARKER}`)]); + processTree(tree); + const children = (tree as any).children; + expect(children).toHaveLength(1); + expect(children[0].type).toBe('text'); + }); + + 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) => { - const tree = createTree([createTextNode(pattern)]); + validPatterns.forEach(({ input, id }) => { + const tree = createTree([createTextNode(input)]); processTree(tree); const children = (tree as any).children; - expect(children).toHaveLength(1); - expect(children[0].type).toBe('text'); - expect(children[0].value).toBe(pattern); + expect(children[0].type).toBe('mcp-ui-resource'); + 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(','); - }); }); }); diff --git a/client/src/components/MCPUIResource/plugin.ts b/client/src/components/MCPUIResource/plugin.ts index 28d3d7f41a..436e7d862c 100644 --- a/client/src/components/MCPUIResource/plugin.ts +++ b/client/src/components/MCPUIResource/plugin.ts @@ -3,7 +3,8 @@ 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'); +// 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 @@ -25,8 +26,11 @@ function processTree(tree: Node) { 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); + const idGroup = match[1]; + const idValues = idGroup + .split(',') + .map((value) => value.trim()) + .filter(Boolean); if (matchIndex > currentPosition) { const textBeforeMatch = originalValue.substring(currentPosition, matchIndex); @@ -35,26 +39,29 @@ function processTree(tree: Node) { } } - if (indices.length === 1) { + if (idValues.length === 1) { segments.push({ type: 'mcp-ui-resource', data: { hName: 'mcp-ui-resource', hProperties: { - resourceIndex: indices[0], + resourceId: idValues[0], }, }, }); - } else { + } else if (idValues.length > 1) { segments.push({ type: 'mcp-ui-carousel', data: { hName: 'mcp-ui-carousel', hProperties: { - resourceIndices: indices, + resourceIds: idValues, }, }, }); + } else { + // Unable to parse marker; keep original text + segments.push({ type: 'text', value: matchText }); } currentPosition = matchIndex + matchText.length; diff --git a/client/src/components/MCPUIResource/types.ts b/client/src/components/MCPUIResource/types.ts index f7dff19612..6c559597df 100644 --- a/client/src/components/MCPUIResource/types.ts +++ b/client/src/components/MCPUIResource/types.ts @@ -4,8 +4,8 @@ export interface UIResourceNode { data?: { hName: string; hProperties: { - resourceIndex?: number; - resourceIndices?: number[]; + resourceId?: string; + resourceIds?: string[]; }; }; children?: UIResourceNode[]; diff --git a/client/src/components/Share/ShareMessagesProvider.tsx b/client/src/components/Share/ShareMessagesProvider.tsx new file mode 100644 index 0000000000..8fec3573c1 --- /dev/null +++ b/client/src/components/Share/ShareMessagesProvider.tsx @@ -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( + () => ({ + 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 ( + {children} + ); +} diff --git a/client/src/components/Share/ShareView.tsx b/client/src/components/Share/ShareView.tsx index f58064acc8..9f75569c6d 100644 --- a/client/src/components/Share/ShareView.tsx +++ b/client/src/components/Share/ShareView.tsx @@ -21,6 +21,7 @@ import { ShareArtifactsContainer } from './ShareArtifacts'; import { useLocalize, useDocumentTitle } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; import { ShareContext } from '~/Providers'; +import { ShareMessagesProvider } from './ShareMessagesProvider'; import MessagesView from './MessagesView'; import Footer from '../Chat/Footer'; import { cn } from '~/utils'; @@ -108,7 +109,9 @@ function SharedView() { onLangChange={handleLangChange} settingsLabel={localize('com_nav_settings')} /> - + + + ); } else { diff --git a/client/src/hooks/Messages/useConversationUIResources.ts b/client/src/hooks/Messages/useConversationUIResources.ts new file mode 100644 index 0000000000..2333f64e5f --- /dev/null +++ b/client/src/hooks/Messages/useConversationUIResources.ts @@ -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 { + const { getMessages } = useMessagesOperations(); + + const conversationAttachmentsMap = useRecoilValue( + store.conversationAttachmentsSelector(conversationId), + ); + + return useMemo(() => { + const map = new Map(); + + 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]); +} diff --git a/client/src/store/misc.ts b/client/src/store/misc.ts index 284382403e..5b649cbe02 100644 --- a/client/src/store/misc.ts +++ b/client/src/store/misc.ts @@ -1,4 +1,4 @@ -import { atom } from 'recoil'; +import { atom, selectorFamily } from 'recoil'; import { TAttachment } from 'librechat-data-provider'; import { atomWithLocalStorage } from './utils'; import { BadgeItem } from '~/common'; @@ -10,6 +10,43 @@ const messageAttachmentsMap = atom>({ default: {}, }); +/** + * Selector to get attachments for a specific conversation. + */ +const conversationAttachmentsSelector = selectorFamily< + Record, + string | undefined +>({ + key: 'conversationAttachments', + get: + (conversationId) => + ({ get }) => { + if (!conversationId) { + return {}; + } + + const attachmentsMap = get(messageAttachmentsMap); + const result: Record = {}; + + // 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({ key: 'queriesEnabled', default: true, @@ -30,6 +67,7 @@ const chatBadges = atomWithLocalStorage[]>('chatBadges', [ export default { hideBannerHint, messageAttachmentsMap, + conversationAttachmentsSelector, queriesEnabled, isEditingBadges, chatBadges, diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index d8159a49c1..41ed7f9c37 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -125,7 +125,7 @@ export const normalizeLayout = (layout: number[]) => { return normalizedLayout; }; -export const handleUIAction = async (result: any, submitMessage: any) => { +export const handleUIAction = async (result: any, ask: any) => { const supportedTypes = ['intent', 'tool', 'prompt']; 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); try { - await submitMessage({ text: messageText }); + await ask({ text: messageText }); console.log('Message submitted successfully'); } catch (error) { console.error('Error submitting message:', error); diff --git a/packages/api/src/mcp/__tests__/parsers.test.ts b/packages/api/src/mcp/__tests__/parsers.test.ts index 596e67fa6a..586d6f292d 100644 --- a/packages/api/src/mcp/__tests__/parsers.test.ts +++ b/packages/api/src/mcp/__tests__/parsers.test.ts @@ -176,25 +176,23 @@ describe('formatToolContent', () => { }; const [content, artifacts] = formatToolContent(result, 'openai'); - expect(content).toEqual([ - { - type: 'text', - text: - 'Resource URI: ui://carousel\n' + - 'Resource MIME Type: application/json', - }, - ]); - expect(artifacts).toEqual({ - ui_resources: { - data: [ - { - uri: 'ui://carousel', - mimeType: 'application/json', - text: '{"items": []}', - }, - ], - }, + + expect(Array.isArray(content)).toBe(true); + const textContent = Array.isArray(content) ? content[0] : { text: '' }; + expect(textContent).toMatchObject({ type: 'text' }); + expect(textContent.text).toContain('UI Resource ID:'); + 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'); + + const uiResourceArtifact = artifacts?.ui_resources?.data?.[0]; + expect(uiResourceArtifact).toBeTruthy(); + expect(uiResourceArtifact).toMatchObject({ + uri: 'ui://carousel', + mimeType: 'application/json', + text: '{"items": []}', }); + expect(uiResourceArtifact?.resourceId).toEqual(expect.any(String)); }); it('should handle regular resources', () => { @@ -270,27 +268,22 @@ describe('formatToolContent', () => { }; const [content, artifacts] = formatToolContent(result, 'openai'); - expect(content).toEqual([ - { - type: 'text', - text: - 'Some text\n\n' + - 'Resource URI: ui://button\n' + - 'Resource MIME Type: application/json\n\n' + - 'Resource URI: file://data.csv', - }, - ]); - expect(artifacts).toEqual({ - ui_resources: { - data: [ - { - uri: 'ui://button', - mimeType: 'application/json', - text: '{"label": "Click me"}', - }, - ], - }, + expect(Array.isArray(content)).toBe(true); + const textEntry = Array.isArray(content) ? content[0] : { text: '' }; + expect(textEntry).toMatchObject({ type: 'text' }); + expect(textEntry.text).toContain('Some text'); + expect(textEntry.text).toContain('UI Resource Marker: \\ui{'); + expect(textEntry.text).toContain('Resource URI: ui://button'); + expect(textEntry.text).toContain('Resource MIME Type: application/json'); + expect(textEntry.text).toContain('Resource URI: file://data.csv'); + + const uiResource = artifacts?.ui_resources?.data?.[0]; + expect(uiResource).toMatchObject({ + 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', () => { @@ -310,16 +303,14 @@ describe('formatToolContent', () => { }; const [content, artifacts] = formatToolContent(result, 'openai'); - expect(content).toEqual([ - { - type: 'text', - text: 'Content with multimedia', - }, - { - type: 'text', - text: 'Resource URI: ui://graph\n' + 'Resource MIME Type: application/json', - }, - ]); + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + expect(content[0]).toMatchObject({ type: 'text', 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'); + expect(content[1].text).toContain('Resource MIME Type: application/json'); + } expect(artifacts).toEqual({ content: [ { @@ -333,6 +324,7 @@ describe('formatToolContent', () => { uri: 'ui://graph', mimeType: 'application/json', text: '{"type": "line"}', + resourceId: expect.any(String), }, ], }, @@ -388,19 +380,21 @@ describe('formatToolContent', () => { }; const [content, artifacts] = formatToolContent(result, 'anthropic'); - expect(content).toEqual([ - { type: 'text', text: 'Introduction' }, - { - type: 'text', - text: - 'Middle section\n\n' + - 'Resource URI: ui://chart\n' + - 'Resource MIME Type: application/json\n\n' + - 'Resource URI: https://api.example.com/data', - }, - { type: 'text', text: 'Conclusion' }, - ]); - expect(artifacts).toEqual({ + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + expect(content[0]).toEqual({ type: 'text', text: 'Introduction' }); + expect(content[1].type).toBe('text'); + expect(content[1].text).toContain('Middle section'); + expect(content[1].text).toContain('UI Resource ID:'); + expect(content[1].text).toContain('UI Resource Marker: \\ui{'); + expect(content[1].text).toContain('Resource URI: ui://chart'); + expect(content[1].text).toContain('Resource MIME Type: application/json'); + expect(content[1].text).toContain('Resource URI: https://api.example.com/data'); + expect(content[2].type).toBe('text'); + expect(content[2].text).toContain('Conclusion'); + expect(content[2].text).toContain('UI Resource Markers Available:'); + } + expect(artifacts).toMatchObject({ content: [ { type: 'image_url', @@ -417,6 +411,7 @@ describe('formatToolContent', () => { uri: 'ui://chart', mimeType: 'application/json', text: '{"type": "bar"}', + resourceId: expect.any(String), }, ], }, diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts index 09d04e5ac2..edd0f3ed10 100644 --- a/packages/api/src/mcp/parsers.ts +++ b/packages/api/src/mcp/parsers.ts @@ -1,7 +1,12 @@ +import crypto from 'node:crypto'; import { Tools } from 'librechat-data-provider'; import type { UIResource } from 'librechat-data-provider'; 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([ 'google', 'anthropic', @@ -133,22 +138,33 @@ export function formatToolContent( resource: (item) => { const isUiResource = item.resource.uri.startsWith('ui://'); + const resourceText: string[] = []; 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 = []; - if (!isUiResource && 'text' in item.resource && item.resource.text) { + uiResources.push(uiResource); + 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}`); } + if (item.resource.uri.length) { resourceText.push(`Resource URI: ${item.resource.uri}`); } if (item.resource.mimeType != null && 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) { formattedContent.push({ type: 'text', text: currentTextBlock }); } diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index ff60fc9881..7dabc549db 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -583,9 +583,8 @@ export type MemoryArtifact = { }; export type UIResource = { - type?: string; - data?: unknown; - uri?: string; + resourceId: string; + uri: string; mimeType?: string; text?: string; [key: string]: unknown;