This commit is contained in:
Samuel Path 2025-09-19 22:51:57 +03:00 committed by GitHub
commit ff6b8b9a14
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1684 additions and 141 deletions

View file

@ -45,6 +45,11 @@ describe('createToolEndCallback', () => {
let req, res, artifactPromises, createToolEndCallback;
let logger;
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
beforeEach(() => {
jest.clearAllMocks();
@ -81,11 +86,6 @@ describe('createToolEndCallback', () => {
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
// Wait for all promises to resolve
@ -122,11 +122,6 @@ describe('createToolEndCallback', () => {
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
@ -162,11 +157,6 @@ describe('createToolEndCallback', () => {
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
@ -194,11 +184,6 @@ describe('createToolEndCallback', () => {
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
@ -230,11 +215,6 @@ describe('createToolEndCallback', () => {
// No artifact property
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
expect(artifactPromises).toHaveLength(0);
@ -255,11 +235,6 @@ describe('createToolEndCallback', () => {
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
@ -300,39 +275,24 @@ describe('createToolEndCallback', () => {
},
};
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output }, metadata);
const results = await Promise.all(artifactPromises);
expect(results[0][Tools.ui_resources]).toEqual(complexData);
});
it('should handle when output is undefined', async () => {
it('should ignore tool call when output is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback({ output: undefined }, metadata);
expect(artifactPromises).toHaveLength(0);
expect(res.write).not.toHaveBeenCalled();
});
it('should handle when data parameter is undefined', async () => {
it('should ignore tool call when data parameter is undefined', async () => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
const metadata = {
run_id: 'run456',
thread_id: 'thread789',
};
await toolEndCallback(undefined, metadata);
expect(artifactPromises).toHaveLength(0);

View file

@ -0,0 +1,429 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { visit } from 'unist-util-visit';
import { Artifact, artifactPlugin } from './Artifact';
import * as utils from '~/utils';
import { useMessageContext, useArtifactContext, useChatContext } from '~/Providers';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import { useUIResources } from '~/hooks';
import { useGetMessagesByConvoId } from '~/data-provider';
// Mock all external dependencies
jest.mock('unist-util-visit');
jest.mock('~/utils');
jest.mock('~/Providers');
jest.mock('~/hooks/Messages/useSubmitMessage');
jest.mock('~/hooks');
jest.mock('~/data-provider');
jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: ({ resource }: any) => (
<div data-testid="ui-resource-renderer">{`UI Resource: ${resource?.uri}`}</div>
),
}));
jest.mock('../Chat/Messages/Content/UIResourceCarousel', () => ({
__esModule: true,
default: ({ uiResources }: any) => (
<div data-testid="ui-resource-carousel">{`Carousel: ${uiResources?.length} resources`}</div>
),
}));
jest.mock('./ArtifactButton', () => ({
__esModule: true,
default: ({ artifact }: any) => (
<div data-testid="artifact-button">{`Button: ${artifact?.title}`}</div>
),
}));
const mockVisit = visit as jest.MockedFunction<typeof visit>;
const mockExtractContent = utils.extractContent as jest.MockedFunction<typeof utils.extractContent>;
const mockGetLatestText = utils.getLatestText as jest.MockedFunction<typeof utils.getLatestText>;
const _mockHandleUIAction = utils.handleUIAction as jest.MockedFunction<
typeof utils.handleUIAction
>;
const mockLogger = { log: jest.fn() };
(utils as any).logger = mockLogger;
const mockUseMessageContext = useMessageContext as jest.MockedFunction<typeof useMessageContext>;
const mockUseArtifactContext = useArtifactContext as jest.MockedFunction<typeof useArtifactContext>;
const mockUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
const mockUseSubmitMessage = useSubmitMessage as jest.MockedFunction<typeof useSubmitMessage>;
const mockUseUIResources = useUIResources as jest.MockedFunction<typeof useUIResources>;
const mockUseGetMessagesByConvoId = useGetMessagesByConvoId as jest.MockedFunction<
typeof useGetMessagesByConvoId
>;
const TestWrapper = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<RecoilRoot>
<BrowserRouter>{children}</BrowserRouter>
</RecoilRoot>
</QueryClientProvider>
);
};
describe('artifactPlugin', () => {
beforeEach(() => {
jest.clearAllMocks();
mockVisit.mockImplementation((tree, selector, visitor) => {
if (typeof visitor === 'function') {
const mockNode = { type: 'textDirective', name: 'artifact', attributes: {} };
const mockParent = { children: [mockNode] };
visitor(mockNode as any, 0, mockParent as any);
}
});
});
it('should replace text directives with colon-prefixed text', () => {
const plugin = artifactPlugin();
const tree = {};
mockVisit.mockImplementation((tree, selector, visitor) => {
if (typeof visitor === 'function') {
const mockNode = { type: 'textDirective', name: 'test' };
const mockParent = { children: [mockNode] };
visitor(mockNode as any, 0, mockParent as any);
expect(mockParent.children[0]).toEqual({
type: 'text',
value: ':test',
});
}
});
plugin(tree as any);
});
it('should process artifact nodes by setting hName and hProperties', () => {
const plugin = artifactPlugin();
const tree = {};
mockVisit.mockImplementation((tree, selector, visitor) => {
if (typeof visitor === 'function') {
const mockNode = {
type: 'containerDirective',
name: 'artifact',
attributes: { type: 'code' },
data: {},
};
const result = visitor(mockNode as any, 0, null);
expect(result.data).toEqual({
hName: 'artifact',
hProperties: { type: 'code' },
});
}
});
plugin(tree as any);
});
it('should return early for non-artifact nodes', () => {
const plugin = artifactPlugin();
const tree = {};
mockVisit.mockImplementation((tree, selector, visitor) => {
if (typeof visitor === 'function') {
const mockNode = { type: 'containerDirective', name: 'other' };
const result = visitor(mockNode as any, 0, null);
expect(result).toBeUndefined();
}
});
plugin(tree as any);
});
});
describe('Artifact Component', () => {
const defaultProps = {
type: 'code',
title: 'Test Artifact',
identifier: 'test-id',
children: 'console.log("test");',
node: {},
};
const mockContextValues = {
messageContext: { messageId: 'msg-123' },
artifactContext: { getNextIndex: jest.fn(() => 0), resetCounter: jest.fn() },
chatContext: { conversation: { conversationId: 'conv-123' } },
submitMessage: jest.fn(),
uiResources: { getUIResourceById: jest.fn() },
messages: [{ messageId: 'msg-123', text: 'Test message :::artifact\ncode\n:::' }],
};
beforeEach(() => {
jest.clearAllMocks();
mockUseMessageContext.mockReturnValue(mockContextValues.messageContext as any);
mockUseArtifactContext.mockReturnValue(mockContextValues.artifactContext as any);
mockUseChatContext.mockReturnValue(mockContextValues.chatContext as any);
mockUseSubmitMessage.mockReturnValue({ submitMessage: mockContextValues.submitMessage } as any);
mockUseUIResources.mockReturnValue(mockContextValues.uiResources as any);
mockUseGetMessagesByConvoId.mockReturnValue({ data: mockContextValues.messages } as any);
mockExtractContent.mockReturnValue('console.log("test");');
mockGetLatestText.mockReturnValue('Test message :::artifact\ncode\n:::');
Object.defineProperty(window, 'location', {
value: { pathname: '/c/conv-123' },
writable: true,
});
});
it('should render loading spinner when artifact is incomplete', () => {
mockGetLatestText.mockReturnValue('Test message :::artifact\nincomplete');
render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
expect(screen.getByText('Generating content...')).toBeInTheDocument();
expect(document.querySelector('.spinner')).not.toBeNull();
});
it('should render ArtifactButton for standard artifacts', async () => {
render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('artifact-button')).toBeInTheDocument();
expect(screen.getByText('Button: Test Artifact')).toBeInTheDocument();
});
});
it('should render UIResourceRenderer for mcp-ui-single type', async () => {
const uiResource = { uri: 'test-uri', name: 'Test Resource' };
mockContextValues.uiResources.getUIResourceById.mockReturnValue(uiResource);
render(
<TestWrapper>
<Artifact {...defaultProps} type="mcp-ui-single" />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('ui-resource-renderer')).toBeInTheDocument();
expect(screen.getByText('UI Resource: test-uri')).toBeInTheDocument();
});
});
it('should render error message for mcp-ui-single when resource not found', async () => {
mockContextValues.uiResources.getUIResourceById.mockReturnValue(undefined);
render(
<TestWrapper>
<Artifact {...defaultProps} type="mcp-ui-single" />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByText('?? console.log("test"); ??')).toBeInTheDocument();
});
});
it('should render UIResourceCarousel for mcp-ui-carousel type', async () => {
const uiResources = [
{ uri: 'uri1', name: 'Resource 1' },
{ uri: 'uri2', name: 'Resource 2' },
];
mockExtractContent.mockReturnValue('uri1, uri2');
mockContextValues.uiResources.getUIResourceById
.mockReturnValueOnce(uiResources[0])
.mockReturnValueOnce(uiResources[1]);
render(
<TestWrapper>
<Artifact {...defaultProps} type="mcp-ui-carousel" />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('ui-resource-carousel')).toBeInTheDocument();
expect(screen.getByText('Carousel: 2 resources')).toBeInTheDocument();
});
});
it('should render error message for mcp-ui-carousel when no resources found', async () => {
mockExtractContent.mockReturnValue('invalid-uri1, invalid-uri2');
mockContextValues.uiResources.getUIResourceById.mockReturnValue(undefined);
render(
<TestWrapper>
<Artifact {...defaultProps} type="mcp-ui-carousel" />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByText('???')).toBeInTheDocument();
});
});
it('should handle default values when props are missing', async () => {
const propsWithoutDefaults = {
children: 'test content',
node: {},
};
render(
<TestWrapper>
<Artifact {...propsWithoutDefaults} />
</TestWrapper>,
);
await waitFor(() => {
expect(mockLogger.log).toHaveBeenCalledWith(
'artifacts',
'updateArtifact: content.length',
expect.any(Number),
);
});
});
it('should not update artifacts state when not in conversation path', async () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/settings' },
writable: true,
});
render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('artifact-button')).toBeInTheDocument();
});
});
it('should call resetCounter on mount', () => {
render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
expect(mockContextValues.artifactContext.resetCounter).toHaveBeenCalled();
});
it('should handle empty message text', () => {
mockGetLatestText.mockReturnValue('');
render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
expect(screen.getByText('Generating content...')).toBeInTheDocument();
});
it('should handle missing conversation', () => {
mockUseChatContext.mockReturnValue({ conversation: null } as any);
mockUseGetMessagesByConvoId.mockReturnValue({ data: undefined } as any);
render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
expect(screen.getByText('Generating content...')).toBeInTheDocument();
});
describe('Edge cases', () => {
it('should handle malformed carousel content', async () => {
mockExtractContent.mockReturnValue(' , , uri1 , ');
mockContextValues.uiResources.getUIResourceById
.mockReturnValueOnce(undefined)
.mockReturnValueOnce(undefined)
.mockReturnValueOnce({ uri: 'uri1', name: 'Resource 1' })
.mockReturnValueOnce(undefined);
render(
<TestWrapper>
<Artifact {...defaultProps} type="mcp-ui-carousel" />
</TestWrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('ui-resource-carousel')).toBeInTheDocument();
expect(screen.getByText('Carousel: 1 resources')).toBeInTheDocument();
});
});
it('should handle artifact completion check edge cases', () => {
const testCases = [
{ text: ':::artifact\ncontent', expected: false },
{ text: 'content\n:::', expected: false },
{ text: ':::artifact\ncontent\n:::', expected: true },
{ text: ':::artifact content :::', expected: true },
];
testCases.forEach(({ text, expected }) => {
mockGetLatestText.mockReturnValue(text);
const { unmount } = render(
<TestWrapper>
<Artifact {...defaultProps} />
</TestWrapper>,
);
if (expected) {
expect(screen.queryByText('Generating content...')).not.toBeInTheDocument();
} else {
expect(screen.getByText('Generating content...')).toBeInTheDocument();
}
unmount();
});
});
it('should handle throttled updates correctly', async () => {
const { rerender } = render(
<TestWrapper>
<Artifact {...defaultProps} title="Title 1" />
</TestWrapper>,
);
rerender(
<TestWrapper>
<Artifact {...defaultProps} title="Title 2" />
</TestWrapper>,
);
await waitFor(() => {
expect(mockLogger.log).toHaveBeenCalled();
});
});
it('should generate correct artifact key with special characters', async () => {
render(
<TestWrapper>
<Artifact
{...defaultProps}
title="Test Title With Spaces"
identifier="special-chars-123"
type="code/javascript"
/>
</TestWrapper>,
);
await waitFor(() => {
expect(mockLogger.log).toHaveBeenCalledWith(
'artifacts',
'updateArtifact: content.length',
expect.any(Number),
);
});
});
});
});

View file

@ -1,14 +1,20 @@
import React, { useEffect, useCallback, useRef, useState } from 'react';
import throttle from 'lodash/throttle';
import { visit } from 'unist-util-visit';
import { useSetRecoilState } from 'recoil';
import { UIResourceRenderer } from '@mcp-ui/client';
import { useLocation } from 'react-router-dom';
import { Spinner } from '@librechat/client';
import { useSetRecoilState } from 'recoil';
import { visit } from 'unist-util-visit';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
import throttle from 'lodash/throttle';
import { useMessageContext, useArtifactContext, useChatContext } from '~/Providers';
import { logger, extractContent, getLatestText, handleUIAction } from '~/utils';
import UIResourceCarousel from '../Chat/Messages/Content/UIResourceCarousel';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import { useGetMessagesByConvoId } from '~/data-provider';
import type { Artifact, UIResource } from '~/common';
import { artifactsState } from '~/store/artifacts';
import { logger, extractContent } from '~/utils';
import ArtifactButton from './ArtifactButton';
import { useUIResources } from '~/hooks';
export const artifactPlugin: Pluggable = () => {
return (tree) => {
@ -39,6 +45,20 @@ const defaultTitle = 'untitled';
const defaultType = 'unknown';
const defaultIdentifier = 'lc-no-identifier';
const shouldShowArtifactSidebar = (type: string): boolean => {
const unsupportedTypes = ['mcp-ui-single', 'mcp-ui-carousel'];
return !unsupportedTypes.includes(type);
};
// Check if artifact content is complete (has proper closing :::)
const isArtifactComplete = (messageText: string): boolean => {
if (!messageText) return false;
const hasStart = messageText.includes(':::artifact');
const hasEnd = messageText.replace(':::artifact', '').trim().includes(':::');
return hasStart && hasEnd;
};
export function Artifact({
node: _node,
...props
@ -49,10 +69,16 @@ export function Artifact({
const location = useLocation();
const { messageId } = useMessageContext();
const { getNextIndex, resetCounter } = useArtifactContext();
const { submitMessage } = useSubmitMessage();
const artifactIndex = useRef(getNextIndex(false)).current;
const { conversation } = useChatContext();
const { data: messages } = useGetMessagesByConvoId(conversation?.conversationId ?? '', {
enabled: !!conversation?.conversationId,
});
const setArtifacts = useSetRecoilState(artifactsState);
const [artifact, setArtifact] = useState<Artifact | null>(null);
const { getUIResourceById } = useUIResources();
const throttledUpdateRef = useRef(
throttle((updateFn: () => void) => {
@ -60,6 +86,9 @@ export function Artifact({
}, 25),
);
const message = messages?.find((m) => m.messageId === messageId);
const messageText = message ? getLatestText(message) : '';
const updateArtifact = useCallback(() => {
const content = extractContent(props.children);
logger.log('artifacts', 'updateArtifact: content.length', content.length);
@ -92,19 +121,21 @@ export function Artifact({
return setArtifact(currentArtifact);
}
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
prevArtifacts[artifactKey]?.content === content
) {
return prevArtifacts;
}
if (shouldShowArtifactSidebar(type)) {
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
prevArtifacts[artifactKey]?.content === content
) {
return prevArtifacts;
}
return {
...prevArtifacts,
[artifactKey]: currentArtifact,
};
});
return {
...prevArtifacts,
[artifactKey]: currentArtifact,
};
});
}
setArtifact(currentArtifact);
});
@ -124,5 +155,49 @@ export function Artifact({
updateArtifact();
}, [updateArtifact, resetCounter]);
if (!isArtifactComplete(messageText)) {
return (
<div className="my-4 flex items-center justify-center rounded-lg border border-border-light bg-surface-primary p-8">
<div className="flex flex-col items-center">
<Spinner size={24} className="mb-2" />
<span className="text-sm text-text-secondary">Generating content...</span>
</div>
</div>
);
}
if (artifact?.type === 'mcp-ui-single') {
// Get the UI resource by URI
const uri = artifact.content?.trim() ?? '';
const uiResource = getUIResourceById(uri);
if (!uiResource) {
return <div className="text-sm text-muted-foreground">?? {artifact.content} ??</div>;
}
return (
<UIResourceRenderer
resource={uiResource}
onUIAction={async (result) => handleUIAction(result, submitMessage)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
}}
/>
);
}
if (artifact?.type === 'mcp-ui-carousel') {
// Parse comma-separated URIs
const content = artifact.content ?? '';
const uris = content.split(',').map((uri) => uri.trim());
const uiResources = uris
.map((uri) => getUIResourceById(uri))
.filter((resource): resource is UIResource => resource !== undefined);
if (uiResources.length === 0) {
return <div className="text-sm text-muted-foreground">???</div>;
}
return <UIResourceCarousel uiResources={uiResources} />;
}
return <ArtifactButton artifact={artifact} />;
}

View file

@ -1,9 +1,9 @@
import { useMemo, useState, useEffect, useRef, useLayoutEffect } from 'react';
import { Button } from '@librechat/client';
import { TriangleAlert } from 'lucide-react';
import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider';
import { useLocalize, useProgress, useUIResources } from '~/hooks';
import type { TAttachment } from 'librechat-data-provider';
import { useLocalize, useProgress } from '~/hooks';
import { Button } from '@librechat/client';
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
import ProgressText from './ProgressText';
@ -28,6 +28,7 @@ export default function ToolCall({
expires_at?: number;
}) {
const localize = useLocalize();
const { storeUIResourcesFromAttachments } = useUIResources();
const [showInfo, setShowInfo] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
@ -99,6 +100,13 @@ export default function ToolCall({
const progress = useProgress(initialProgress);
const cancelled = (!isSubmitting && progress < 1) || error === true;
// Store UI resources when output is available
useEffect(() => {
if (output && attachments && !isSubmitting) {
storeUIResourcesFromAttachments(attachments);
}
}, [output, attachments, isSubmitting, storeUIResourcesFromAttachments]);
const getFinishedText = () => {
if (cancelled) {
return localize('com_ui_cancelled');

View file

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { UIResourceRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import React, { useState } from 'react';
import useSubmitMessage from '~/hooks/Messages/useSubmitMessage';
import type { UIResource } from '~/common';
import { handleUIAction } from '~/utils';
interface UIResourceCarouselProps {
uiResources: UIResource[];
@ -11,6 +13,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
const [showRightArrow, setShowRightArrow] = useState(true);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const { submitMessage } = useSubmitMessage();
const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return;
@ -111,9 +114,7 @@ const UIResourceCarousel: React.FC<UIResourceCarouselProps> = React.memo(({ uiRe
mimeType: uiResource.mimeType,
text: uiResource.text,
}}
onUIAction={async (result) => {
console.log('Action:', result);
}}
onUIAction={async (result) => handleUIAction(result, submitMessage)}
htmlProps={{
autoResizeIframe: { width: true, height: true },
}}

View file

@ -5,6 +5,10 @@ import { Tools } from 'librechat-data-provider';
import ToolCall from '../ToolCall';
// Mock dependencies
const mockStoreUIResourcesFromAttachments = jest.fn();
const mockGetUIResourceById = jest.fn();
const mockClearUIResources = jest.fn();
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: any) => {
const translations: Record<string, string> = {
@ -21,6 +25,12 @@ jest.mock('~/hooks', () => ({
return translations[key] || key;
},
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
useUIResources: () => ({
uiResources: {},
storeUIResourcesFromAttachments: mockStoreUIResourcesFromAttachments,
getUIResourceById: mockGetUIResourceById,
clearUIResources: mockClearUIResources,
}),
}));
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
@ -84,23 +94,30 @@ describe('ToolCall', () => {
return render(<RecoilRoot>{component}</RecoilRoot>);
};
const createAttachment = (overrides = {}) => ({
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
filename: 'test.json',
filepath: '/test/test.json',
expiresAt: Date.now() + 3600000, // 1 hour from now
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Click me' },
},
...overrides,
});
beforeEach(() => {
jest.clearAllMocks();
mockStoreUIResourcesFromAttachments.mockClear();
mockGetUIResourceById.mockClear();
mockClearUIResources.mockClear();
});
describe('attachments prop passing', () => {
it('should pass attachments to ToolCallInfo when provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Click me' },
},
},
];
const attachments = [createAttachment()];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
@ -113,6 +130,36 @@ describe('ToolCall', () => {
expect(attachmentsData).toBe(JSON.stringify(attachments));
});
it('should call storeUIResourcesFromAttachments when tool completes with attachments', () => {
const attachments = [createAttachment()];
renderWithRecoil(
<ToolCall
{...mockProps}
attachments={attachments}
output="Test output"
isSubmitting={false}
/>,
);
expect(mockStoreUIResourcesFromAttachments).toHaveBeenCalledWith(attachments);
});
it('should not call storeUIResourcesFromAttachments when still submitting', () => {
const attachments = [createAttachment()];
renderWithRecoil(
<ToolCall
{...mockProps}
attachments={attachments}
output="Test output"
isSubmitting={true} // Still submitting
/>,
);
expect(mockStoreUIResourcesFromAttachments).not.toHaveBeenCalled();
});
it('should pass empty array when no attachments', () => {
renderWithRecoil(<ToolCall {...mockProps} />);
@ -125,16 +172,15 @@ describe('ToolCall', () => {
it('should pass multiple attachments of different types', () => {
const attachments = [
{
type: Tools.ui_resources,
createAttachment({
messageId: 'msg1',
toolCallId: 'tool1',
conversationId: 'conv1',
[Tools.ui_resources]: {
'0': { type: 'form', fields: [] },
},
},
{
}),
createAttachment({
type: Tools.web_search,
messageId: 'msg2',
toolCallId: 'tool2',
@ -142,7 +188,8 @@ describe('ToolCall', () => {
[Tools.web_search]: {
results: ['result1', 'result2'],
},
},
[Tools.ui_resources]: undefined,
}),
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
@ -158,15 +205,11 @@ describe('ToolCall', () => {
describe('attachment group rendering', () => {
it('should render AttachmentGroup when attachments are provided', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
createAttachment({
[Tools.ui_resources]: {
'0': { type: 'chart', data: [] },
},
},
}),
];
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
@ -207,15 +250,11 @@ describe('ToolCall', () => {
it('should pass all required props to ToolCallInfo', () => {
const attachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
createAttachment({
[Tools.ui_resources]: {
'0': { type: 'button', label: 'Test' },
},
},
}),
];
// Use a name with domain separator (_action_) and domain separator (---)
@ -290,9 +329,9 @@ describe('ToolCall', () => {
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={0.5}
cancelled={true}
initialProgress={0.5}
// Note: cancelled state is determined by (!isSubmitting && progress < 1) || error === true
isSubmitting={false}
/>,
);
@ -304,9 +343,7 @@ describe('ToolCall', () => {
<ToolCall
{...mockProps}
auth="https://auth.example.com"
authDomain="example.com"
progress={1}
cancelled={false}
initialProgress={1} // Complete progress
/>,
);
@ -316,7 +353,7 @@ describe('ToolCall', () => {
describe('edge cases', () => {
it('should handle undefined args', () => {
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
renderWithRecoil(<ToolCall {...mockProps} args={''} />);
fireEvent.click(screen.getByText('Completed testFunction'));
@ -336,7 +373,7 @@ describe('ToolCall', () => {
});
it('should handle missing domain', () => {
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
renderWithRecoil(<ToolCall {...mockProps} />);
fireEvent.click(screen.getByText('Completed testFunction'));
@ -347,11 +384,7 @@ describe('ToolCall', () => {
it('should handle complex nested attachments', () => {
const complexAttachments = [
{
type: Tools.ui_resources,
messageId: 'msg123',
toolCallId: 'tool456',
conversationId: 'conv789',
createAttachment({
[Tools.ui_resources]: {
'0': {
type: 'nested',
@ -364,7 +397,7 @@ describe('ToolCall', () => {
},
},
},
},
}),
];
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);

View file

@ -1,8 +1,9 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import '@testing-library/jest-dom';
import React from 'react';
import UIResourceCarousel from '../UIResourceCarousel';
import type { UIResource } from '~/common';
import { handleUIAction } from '~/utils';
// Mock the UIResourceRenderer component
jest.mock('@mcp-ui/client', () => ({
@ -13,6 +14,20 @@ jest.mock('@mcp-ui/client', () => ({
),
}));
// Mock useSubmitMessage hook
const mockSubmitMessage = jest.fn();
jest.mock('~/hooks/Messages/useSubmitMessage', () => ({
__esModule: true,
default: () => ({
submitMessage: mockSubmitMessage,
}),
}));
// Mock handleUIAction utility
jest.mock('~/utils', () => ({
handleUIAction: jest.fn(),
}));
// Mock scrollTo
const mockScrollTo = jest.fn();
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
@ -29,8 +44,12 @@ describe('UIResourceCarousel', () => {
{ uri: 'resource5', mimeType: 'text/html', text: 'Resource 5' },
];
const mockHandleUIAction = handleUIAction as jest.MockedFunction<typeof handleUIAction>;
beforeEach(() => {
jest.clearAllMocks();
mockSubmitMessage.mockClear();
mockHandleUIAction.mockClear();
// Reset scroll properties
Object.defineProperty(HTMLElement.prototype, 'scrollLeft', {
configurable: true,
@ -141,18 +160,48 @@ describe('UIResourceCarousel', () => {
});
});
it('handles UIResource actions', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
it('handles UIResource actions using handleUIAction', async () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
const renderer = screen.getByTestId('ui-resource-renderer');
fireEvent.click(renderer);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith('Action:', { action: 'test' });
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage);
});
});
it('calls handleUIAction with correct parameters for multiple resources', async () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 3)} />);
const renderers = screen.getAllByTestId('ui-resource-renderer');
// Click the second renderer
fireEvent.click(renderers[1]);
await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage);
expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
});
consoleSpy.mockRestore();
// Click the third renderer
fireEvent.click(renderers[2]);
await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledTimes(2);
});
});
it('passes correct submitMessage function to handleUIAction', async () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 1)} />);
const renderer = screen.getByTestId('ui-resource-renderer');
fireEvent.click(renderer);
await waitFor(() => {
expect(mockHandleUIAction).toHaveBeenCalledWith({ action: 'test' }, mockSubmitMessage);
expect(mockHandleUIAction).toHaveBeenCalledTimes(1);
});
});
it('applies correct dimensions to resource containers', () => {

View file

@ -0,0 +1,361 @@
import { renderHook, act } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import React from 'react';
import type { TAttachment } from 'librechat-data-provider';
import { useUIResources } from '../useUIResources';
import { Tools } from 'librechat-data-provider';
import type { UIResource } from '~/common';
// Mock data
const mockUIResource1: UIResource = {
uri: 'resource1',
mimeType: 'text/html',
text: 'Resource 1 content',
};
const mockUIResource2: UIResource = {
uri: 'resource2',
mimeType: 'text/html',
text: 'Resource 2 content',
};
const mockUIResource3: UIResource = {
uri: 'resource3',
mimeType: 'application/json',
text: 'Resource 3 content',
};
const mockAttachmentWithUIResources: TAttachment = {
type: Tools.ui_resources,
[Tools.ui_resources]: [mockUIResource1, mockUIResource2],
};
const mockAttachmentWithMultipleUIResources: TAttachment = {
type: Tools.ui_resources,
[Tools.ui_resources]: [mockUIResource3],
};
const mockAttachmentNonUIResource: TAttachment = {
type: 'other' as any,
content: 'Some other content',
};
// Wrapper component for Recoil
const RecoilWrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useUIResources', () => {
it('initializes with empty state', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
expect(result.current.uiResources).toBeNull();
});
it('stores UI resources from attachments correctly', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
});
});
it('filters out non-UI resource attachments', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([
mockAttachmentWithUIResources,
mockAttachmentNonUIResource,
]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
});
});
it('handles multiple attachments with UI resources', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([
mockAttachmentWithUIResources,
mockAttachmentWithMultipleUIResources,
]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
resource3: mockUIResource3,
});
});
it('appends new UI resources to existing state', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
// First, store some UI resources
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
});
// Then, add more UI resources
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithMultipleUIResources]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
resource3: mockUIResource3,
});
});
it('overwrites UI resources with same URI', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
const updatedResource1: UIResource = {
uri: 'resource1',
mimeType: 'text/plain',
text: 'Updated Resource 1 content',
};
const attachmentWithUpdatedResource: TAttachment = {
type: Tools.ui_resources,
[Tools.ui_resources]: [updatedResource1],
};
// First, store original UI resources
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
// Then, update with same URI
act(() => {
result.current.storeUIResourcesFromAttachments([attachmentWithUpdatedResource]);
});
expect(result.current.uiResources).toEqual({
resource1: updatedResource1, // Should be updated
resource2: mockUIResource2, // Should remain unchanged
});
});
it('handles undefined attachments gracefully', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments(undefined);
});
expect(result.current.uiResources).toBeNull();
});
it('handles empty attachments array', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([]);
});
expect(result.current.uiResources).toBeNull();
});
it('handles attachments with no UI resources', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentNonUIResource]);
});
expect(result.current.uiResources).toBeNull();
});
it('handles UI resources with missing URI', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
const resourceWithoutURI: UIResource = {
uri: '',
mimeType: 'text/html',
text: 'Resource without URI',
};
const attachmentWithInvalidResource: TAttachment = {
type: Tools.ui_resources,
[Tools.ui_resources]: [mockUIResource1, resourceWithoutURI, mockUIResource2],
};
act(() => {
result.current.storeUIResourcesFromAttachments([attachmentWithInvalidResource]);
});
// Should only store resources with valid URIs
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
});
});
it('retrieves UI resource by ID correctly', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
const resource = result.current.getUIResourceById('resource1');
expect(resource).toEqual(mockUIResource1);
});
it('returns undefined for non-existent resource ID', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
const resource = result.current.getUIResourceById('non-existent');
expect(resource).toBeUndefined();
});
it('returns undefined when getting resource from empty state', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
const resource = result.current.getUIResourceById('resource1');
expect(resource).toBeUndefined();
});
it('clears UI resources correctly', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
// First, store some UI resources
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
});
// Then, clear them
act(() => {
result.current.clearUIResources();
});
expect(result.current.uiResources).toBeNull();
});
it('maintains function referential equality on re-renders', () => {
const { result, rerender } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
const initialFunctions = {
storeUIResourcesFromAttachments: result.current.storeUIResourcesFromAttachments,
getUIResourceById: result.current.getUIResourceById,
clearUIResources: result.current.clearUIResources,
};
rerender();
// Functions should maintain referential equality due to useCallback
expect(result.current.storeUIResourcesFromAttachments).toBe(
initialFunctions.storeUIResourcesFromAttachments,
);
expect(result.current.getUIResourceById).toBe(initialFunctions.getUIResourceById);
expect(result.current.clearUIResources).toBe(initialFunctions.clearUIResources);
});
it('updates getUIResourceById when state changes', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
// Initially no resource
expect(result.current.getUIResourceById('resource1')).toBeUndefined();
// Add resource
act(() => {
result.current.storeUIResourcesFromAttachments([mockAttachmentWithUIResources]);
});
// Now resource should be available
expect(result.current.getUIResourceById('resource1')).toEqual(mockUIResource1);
// Clear resources
act(() => {
result.current.clearUIResources();
});
// Resource should no longer be available
expect(result.current.getUIResourceById('resource1')).toBeUndefined();
});
it('handles complex nested UI resources structure', () => {
const { result } = renderHook(() => useUIResources(), {
wrapper: RecoilWrapper,
});
// Create an attachment with nested array structure
const complexAttachment: TAttachment = {
type: Tools.ui_resources,
[Tools.ui_resources]: [mockUIResource1, mockUIResource2, mockUIResource3],
};
act(() => {
result.current.storeUIResourcesFromAttachments([complexAttachment]);
});
expect(result.current.uiResources).toEqual({
resource1: mockUIResource1,
resource2: mockUIResource2,
resource3: mockUIResource3,
});
// Verify all resources are accessible
expect(result.current.getUIResourceById('resource1')).toEqual(mockUIResource1);
expect(result.current.getUIResourceById('resource2')).toEqual(mockUIResource2);
expect(result.current.getUIResourceById('resource3')).toEqual(mockUIResource3);
});
});

View file

@ -27,9 +27,10 @@ export { default as useNewConvo } from './useNewConvo';
export { default as useLocalize } from './useLocalize';
export { default as useChatBadges } from './useChatBadges';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useUIResources } from './useUIResources';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useDocumentTitle } from './useDocumentTitle';
export { default as useSpeechToText } from './Input/useSpeechToText';
export { default as useTextToSpeech } from './Input/useTextToSpeech';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
export { useResourcePermissions } from './useResourcePermissions';
export { default as useResourcePermissions } from './useResourcePermissions';

View file

@ -24,3 +24,5 @@ export const useResourcePermissions = (resourceType: ResourceType, resourceId: s
permissionBits: data?.permissionBits || 0,
};
};
export default useResourcePermissions;

View file

@ -0,0 +1,54 @@
import { useRecoilState } from 'recoil';
import { useCallback } from 'react';
import type { TAttachment } from 'librechat-data-provider';
import { Tools } from 'librechat-data-provider';
import type { UIResource } from '~/common';
import store from '~/store';
export function useUIResources() {
const [uiResources, setUIResources] = useRecoilState(store.uiResourcesState);
const storeUIResourcesFromAttachments = useCallback(
(attachments?: TAttachment[]) => {
if (!attachments) return;
const resources = attachments
.filter((attachment) => attachment.type === Tools.ui_resources)
.flatMap((attachment) => attachment[Tools.ui_resources] as UIResource[]);
if (resources.length === 0) return;
setUIResources((prevState) => {
const newState = { ...prevState };
resources.forEach((resource) => {
// Use the full URI as the key
if (resource.uri) {
newState[resource.uri] = resource;
}
});
return newState;
});
},
[setUIResources],
);
const getUIResourceById = useCallback(
(id: string): UIResource | undefined => {
return uiResources?.[id];
},
[uiResources],
);
const clearUIResources = useCallback(() => {
setUIResources(null);
}, [setUIResources]);
return {
uiResources,
storeUIResourcesFromAttachments,
getUIResourceById,
clearUIResources,
};
}
export default useUIResources;

View file

@ -61,3 +61,8 @@ export const visibleArtifacts = atom<Record<string, Artifact | undefined> | null
},
] as const,
});
export const uiResourcesState = atom<Record<string, any> | null>({
key: 'uiResourcesState',
default: null,
});

View file

@ -0,0 +1,467 @@
import React from 'react';
import {
capitalizeFirstLetter,
handleDoubleClick,
extractContent,
normalizeLayout,
handleUIAction,
} from '../index';
// Mock DOM methods for handleDoubleClick tests
const mockCreateRange = jest.fn();
const mockSelectNodeContents = jest.fn();
const mockRemoveAllRanges = jest.fn();
const mockAddRange = jest.fn();
const mockGetSelection = jest.fn();
// Setup DOM mocks
Object.defineProperty(document, 'createRange', {
value: mockCreateRange,
});
Object.defineProperty(window, 'getSelection', {
value: mockGetSelection,
});
describe('Utils Index Functions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCreateRange.mockReturnValue({
selectNodeContents: mockSelectNodeContents,
});
mockGetSelection.mockReturnValue({
removeAllRanges: mockRemoveAllRanges,
addRange: mockAddRange,
});
});
describe('capitalizeFirstLetter', () => {
it('capitalizes the first letter of a regular string', () => {
expect(capitalizeFirstLetter('hello')).toBe('Hello');
expect(capitalizeFirstLetter('world')).toBe('World');
expect(capitalizeFirstLetter('javascript')).toBe('Javascript');
});
it('handles single character strings', () => {
expect(capitalizeFirstLetter('a')).toBe('A');
expect(capitalizeFirstLetter('z')).toBe('Z');
expect(capitalizeFirstLetter('1')).toBe('1');
});
it('handles strings that are already capitalized', () => {
expect(capitalizeFirstLetter('Hello')).toBe('Hello');
expect(capitalizeFirstLetter('WORLD')).toBe('WORLD');
});
it('handles empty strings', () => {
expect(capitalizeFirstLetter('')).toBe('');
});
it('handles strings with special characters', () => {
expect(capitalizeFirstLetter('åpple')).toBe('Åpple');
expect(capitalizeFirstLetter('über')).toBe('Über');
expect(capitalizeFirstLetter('!hello')).toBe('!hello');
expect(capitalizeFirstLetter(' hello')).toBe(' hello');
});
it('handles numbers at the beginning', () => {
expect(capitalizeFirstLetter('123abc')).toBe('123abc');
expect(capitalizeFirstLetter('9test')).toBe('9test');
});
});
describe('handleDoubleClick', () => {
const mockEvent = {
target: document.createElement('div'),
} as React.MouseEvent<HTMLElement>;
it('creates a range and selects node contents', () => {
handleDoubleClick(mockEvent);
expect(mockCreateRange).toHaveBeenCalled();
expect(mockSelectNodeContents).toHaveBeenCalledWith(mockEvent.target);
expect(mockGetSelection).toHaveBeenCalled();
expect(mockRemoveAllRanges).toHaveBeenCalled();
expect(mockAddRange).toHaveBeenCalled();
});
it('handles null selection gracefully', () => {
mockGetSelection.mockReturnValue(null);
expect(() => handleDoubleClick(mockEvent)).not.toThrow();
expect(mockCreateRange).toHaveBeenCalled();
expect(mockSelectNodeContents).toHaveBeenCalledWith(mockEvent.target);
expect(mockGetSelection).toHaveBeenCalled();
expect(mockRemoveAllRanges).not.toHaveBeenCalled();
expect(mockAddRange).not.toHaveBeenCalled();
});
it('handles different target elements', () => {
const spanElement = document.createElement('span');
const spanEvent = { target: spanElement } as React.MouseEvent<HTMLElement>;
handleDoubleClick(spanEvent);
expect(mockSelectNodeContents).toHaveBeenCalledWith(spanElement);
});
});
describe('extractContent', () => {
it('returns string content directly', () => {
expect(extractContent('hello world')).toBe('hello world');
expect(extractContent('')).toBe('');
expect(extractContent('123')).toBe('123');
});
it('extracts content from simple React elements', () => {
const element = React.createElement('div', {}, 'Hello World');
expect(extractContent(element)).toBe('Hello World');
});
it('extracts content from nested React elements', () => {
const nestedElement = React.createElement(
'div',
{},
React.createElement('span', {}, 'Nested Content'),
);
expect(extractContent(nestedElement)).toBe('Nested Content');
});
it('handles arrays of content', () => {
const arrayContent = ['Hello', ' ', 'World'];
expect(extractContent(arrayContent)).toBe('Hello World');
});
it('handles complex nested arrays and elements', () => {
const complexContent = ['Start', React.createElement('div', {}, 'Middle'), 'End'];
expect(extractContent(complexContent)).toBe('StartMiddleEnd');
});
it('handles deeply nested React elements', () => {
const deepElement = React.createElement(
'div',
{},
React.createElement('span', {}, React.createElement('em', {}, 'Deep Content')),
);
expect(extractContent(deepElement)).toBe('Deep Content');
});
it('handles elements without children', () => {
const emptyElement = React.createElement('div', {});
expect(extractContent(emptyElement)).toBe('');
});
it('handles mixed content types in arrays', () => {
const mixedArray = [
'Text1',
React.createElement('span', {}, 'Element1'),
// Numbers are not handled in the actual function
React.createElement('div', {}, ['Nested', 'Array']),
];
expect(extractContent(mixedArray)).toBe('Text1Element1NestedArray');
});
it('handles null and undefined gracefully', () => {
expect(extractContent(null)).toBe('');
expect(extractContent(undefined)).toBe('');
});
it('handles objects with props structure', () => {
const propsObject = {
props: {
children: 'Props Content',
},
};
// The actual function only handles React elements, not arbitrary objects
expect(extractContent(propsObject)).toBe('');
});
});
describe('normalizeLayout', () => {
it('returns layout as-is when sum is already 100', () => {
const layout = [25, 50, 25];
expect(normalizeLayout(layout)).toEqual([25, 50, 25]);
});
it('handles layout within tolerance of 100', () => {
const layout = [25.005, 49.995, 25]; // Sum: 100.000
expect(normalizeLayout(layout)).toEqual([25, 49.99, 25]);
});
it('normalizes layout when sum is not 100', () => {
const layout = [30, 40, 20]; // Sum: 90
const result = normalizeLayout(layout);
const sum = result.reduce((acc, val) => acc + val, 0);
expect(Math.abs(sum - 100)).toBeLessThan(0.01);
});
it('handles layout with sum greater than 100', () => {
const layout = [40, 50, 30]; // Sum: 120
const result = normalizeLayout(layout);
const sum = result.reduce((acc, val) => acc + val, 0);
expect(Math.abs(sum - 100)).toBeLessThan(0.01);
});
it('handles single element layout', () => {
const layout = [80];
const result = normalizeLayout(layout);
expect(result).toEqual([100]);
});
it('handles two element layout', () => {
const layout = [30, 40]; // Sum: 70
const result = normalizeLayout(layout);
const sum = result.reduce((acc, val) => acc + val, 0);
expect(Math.abs(sum - 100)).toBeLessThan(0.01);
});
it('handles very small numbers', () => {
const layout = [0.1, 0.2, 0.3]; // Sum: 0.6
const result = normalizeLayout(layout);
const sum = result.reduce((acc, val) => acc + val, 0);
expect(Math.abs(sum - 100)).toBeLessThan(0.01);
});
it('handles large numbers', () => {
const layout = [1000, 2000, 3000]; // Sum: 6000
const result = normalizeLayout(layout);
const sum = result.reduce((acc, val) => acc + val, 0);
expect(Math.abs(sum - 100)).toBeLessThan(0.01);
});
it('adjusts the last element to ensure exact sum of 100', () => {
const layout = [33.333, 33.333, 33.333]; // Sum: 99.999
const result = normalizeLayout(layout);
expect(result[result.length - 1]).toBe(33.33); // Last element adjusted
});
it('handles empty array', () => {
const layout: number[] = [];
const result = normalizeLayout(layout);
// Empty array will return empty array since no elements to process
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(0);
});
it('preserves decimal precision correctly', () => {
const layout = [25.1234, 25.5678, 49.3088]; // Sum: 100
const result = normalizeLayout(layout);
// Should maintain 2 decimal places
result.forEach((value) => {
const decimalPlaces = (value.toString().split('.')[1] || '').length;
expect(decimalPlaces).toBeLessThanOrEqual(2);
});
});
});
describe('handleUIAction', () => {
const mockSubmitMessage = jest.fn();
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation();
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation();
beforeEach(() => {
jest.clearAllMocks();
mockSubmitMessage.mockResolvedValue(undefined);
});
afterAll(() => {
mockConsoleLog.mockRestore();
mockConsoleError.mockRestore();
});
it('handles intent type correctly', async () => {
const result = {
type: 'intent',
payload: {
intent: 'search',
params: { query: 'test search' },
},
};
await handleUIAction(result, mockSubmitMessage);
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('intent'),
});
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('search'),
});
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('test search'),
});
});
it('handles tool type correctly', async () => {
const result = {
type: 'tool',
payload: {
toolName: 'calculator',
params: { operation: 'add', values: [1, 2] },
},
};
await handleUIAction(result, mockSubmitMessage);
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('tool'),
});
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('calculator'),
});
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('add'),
});
});
it('handles prompt type correctly', async () => {
const result = {
type: 'prompt',
payload: {
prompt: 'Write a story about a robot',
},
};
await handleUIAction(result, mockSubmitMessage);
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('prompt'),
});
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('Write a story about a robot'),
});
});
it('ignores unsupported types', async () => {
const result = {
type: 'unsupported',
payload: { data: 'test' },
};
await handleUIAction(result, mockSubmitMessage);
expect(mockSubmitMessage).not.toHaveBeenCalled();
});
it('logs messages appropriately', async () => {
const result = {
type: 'intent',
payload: {
intent: 'test',
params: {},
},
};
await handleUIAction(result, mockSubmitMessage);
// Function should be called successfully (this test verifies the flow works)
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('intent'),
});
});
it('handles submitMessage errors gracefully', async () => {
const error = new Error('Submit failed');
mockSubmitMessage.mockRejectedValue(error);
const result = {
type: 'intent',
payload: {
intent: 'test',
params: {},
},
};
// Function should not throw but handle error internally
await expect(handleUIAction(result, mockSubmitMessage)).resolves.toBeUndefined();
// Verify submitMessage was called (error handling happened)
expect(mockSubmitMessage).toHaveBeenCalled();
});
it('handles missing payload properties gracefully', async () => {
const result = {
type: 'intent',
payload: {}, // Missing intent and params
};
await handleUIAction(result, mockSubmitMessage);
expect(mockSubmitMessage).toHaveBeenCalledWith({
text: expect.stringContaining('undefined'),
});
});
it('formats JSON correctly in messages', async () => {
const result = {
type: 'tool',
payload: {
toolName: 'test',
params: { complex: { nested: 'object' }, array: [1, 2, 3] },
},
};
await handleUIAction(result, mockSubmitMessage);
const callArgs = mockSubmitMessage.mock.calls[0][0];
expect(callArgs.text).toContain('```json');
expect(callArgs.text).toContain('"complex"');
expect(callArgs.text).toContain('"nested": "object"');
expect(callArgs.text).toContain('"array": [\n 1,\n 2,\n 3\n ]');
});
it('handles null/undefined result gracefully', async () => {
// The function will throw when trying to destructure null/undefined
await expect(handleUIAction(null, mockSubmitMessage)).rejects.toThrow();
await expect(handleUIAction(undefined, mockSubmitMessage)).rejects.toThrow();
});
it('handles result without type property', async () => {
const result = {
payload: { data: 'test' },
};
await handleUIAction(result, mockSubmitMessage);
expect(mockSubmitMessage).not.toHaveBeenCalled();
});
it('constructs correct message text for each type', async () => {
// Test intent message structure
const intentResult = {
type: 'intent',
payload: { intent: 'search', params: { q: 'test' } },
};
await handleUIAction(intentResult, mockSubmitMessage);
let messageText = mockSubmitMessage.mock.calls[0][0].text;
expect(messageText).toContain('message of type `intent`');
expect(messageText).toContain('Execute the intent');
mockSubmitMessage.mockClear();
// Test tool message structure
const toolResult = {
type: 'tool',
payload: { toolName: 'calc', params: {} },
};
await handleUIAction(toolResult, mockSubmitMessage);
messageText = mockSubmitMessage.mock.calls[0][0].text;
expect(messageText).toContain('message of type `tool`');
expect(messageText).toContain('Execute the tool');
mockSubmitMessage.mockClear();
// Test prompt message structure
const promptResult = {
type: 'prompt',
payload: { prompt: 'Hello' },
};
await handleUIAction(promptResult, mockSubmitMessage);
messageText = mockSubmitMessage.mock.calls[0][0].text;
expect(messageText).toContain('message of type `prompt`');
expect(messageText).toContain('Execute the intention of the prompt');
});
});
});

View file

@ -123,3 +123,59 @@ export const normalizeLayout = (layout: number[]) => {
return normalizedLayout;
};
export const handleUIAction = async (result: any, submitMessage: any) => {
const supportedTypes = ['intent', 'tool', 'prompt'];
const { type, payload } = result;
if (!supportedTypes.includes(type)) {
return;
}
let messageText = '';
if (type === 'intent') {
const { intent, params } = payload;
messageText = `The user clicked a button in an embedded UI Resource, and we got a message of type \`intent\`.
The intent is \`${intent}\` and the params are:
\`\`\`json
${JSON.stringify(params, null, 2)}
\`\`\`
Execute the intent that is mentioned in the message using the tools available to you.
`;
} else if (type === 'tool') {
const { toolName, params } = payload;
messageText = `The user clicked a button in an embedded UI Resource, and we got a message of type \`tool\`.
The tool name is \`${toolName}\` and the params are:
\`\`\`json
${JSON.stringify(params, null, 2)}
\`\`\`
Execute the tool that is mentioned in the message using the tools available to you.
`;
} else if (type === 'prompt') {
const { prompt } = payload;
messageText = `The user clicked a button in an embedded UI Resource, and we got a message of type \`prompt\`.
The prompt is:
\`\`\`
${prompt}
\`\`\`
Execute the intention of the prompt that is mentioned in the message using the tools available to you.
`;
}
console.log('About to submit message:', messageText);
try {
await submitMessage({ text: messageText });
console.log('Message submitted successfully');
} catch (error) {
console.error('Error submitting message:', error);
}
};

View file

@ -1,3 +1,4 @@
import { uiResourcesInstructions } from '../utils';
import { formatToolContent } from '../parsers';
import type * as t from '../types';
@ -182,12 +183,12 @@ describe('formatToolContent', () => {
{
type: 'text',
text:
'Resource Text: {"items": []}\n' +
'Resource URI: ui://carousel\n' +
'Resource: carousel\n' +
'Resource Description: A carousel component\n' +
'Resource MIME Type: application/json',
},
{ type: 'text', text: uiResourcesInstructions },
]);
expect(artifacts).toEqual({
ui_resources: {
@ -288,12 +289,12 @@ describe('formatToolContent', () => {
type: 'text',
text:
'Some text\n\n' +
'Resource Text: {"label": "Click me"}\n' +
'Resource URI: ui://button\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: file://data.csv\n' +
'Resource: Data file',
},
{ type: 'text', text: uiResourcesInstructions },
]);
expect(artifacts).toEqual({
ui_resources: {
@ -332,11 +333,9 @@ describe('formatToolContent', () => {
},
{
type: 'text',
text:
'Resource Text: {"type": "line"}\n' +
'Resource URI: ui://graph\n' +
'Resource MIME Type: application/json',
text: 'Resource URI: ui://graph\n' + 'Resource MIME Type: application/json',
},
{ type: 'text', text: uiResourcesInstructions },
]);
expect(artifacts).toEqual({
content: [
@ -414,7 +413,6 @@ describe('formatToolContent', () => {
type: 'text',
text:
'Middle section\n\n' +
'Resource Text: {"type": "bar"}\n' +
'Resource URI: ui://chart\n' +
'Resource MIME Type: application/json\n\n' +
'Resource URI: https://api.example.com/data\n' +
@ -422,6 +420,7 @@ describe('formatToolContent', () => {
'Resource Description: External data source',
},
{ type: 'text', text: 'Conclusion' },
{ type: 'text', text: uiResourcesInstructions },
]);
expect(artifacts).toEqual({
content: [

View file

@ -1,6 +1,6 @@
import { Tools } from 'librechat-data-provider';
import type { UIResource } from 'librechat-data-provider';
import type * as t from './types';
import { uiResourcesInstructions } from './utils';
import { Tools } from 'librechat-data-provider';
const RECOGNIZED_PROVIDERS = new Set([
'google',
@ -146,12 +146,14 @@ export function formatToolContent(
},
resource: (item) => {
if (item.resource.uri.startsWith('ui://')) {
uiResources.push(item.resource as UIResource);
const isUiResource = item.resource.uri.startsWith('ui://');
if (isUiResource) {
uiResources.push(item.resource as t.UIResource);
}
const resourceText = [];
if (item.resource.text != null && item.resource.text) {
if (!isUiResource && item.resource.text != null && item.resource.text) {
resourceText.push(`Resource Text: ${item.resource.text}`);
}
if (item.resource.uri.length) {
@ -192,6 +194,10 @@ export function formatToolContent(
};
}
if (uiResources.length) {
formattedContent.push({ type: 'text', text: uiResourcesInstructions });
}
if (CONTENT_ARRAY_PROVIDERS.has(provider)) {
return [formattedContent, artifacts];
}

View file

@ -84,11 +84,7 @@ export type Provider =
export type FormattedContent =
| {
type: 'text';
metadata?: {
type: string;
data: UIResource[];
};
text?: string;
text: string;
}
| {
type: 'image';

View file

@ -12,7 +12,7 @@ export function normalizeServerName(serverName: string): string {
}
/** Replace non-matching characters with underscores.
This preserves the general structure while ensuring compatibility.
This preserves the general structure while ensuring compatibility.
Trims leading/trailing underscores
*/
const normalized = serverName.replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/^_+|_+$/g, '');
@ -45,3 +45,44 @@ export function sanitizeUrlForLogging(url: string | URL): string {
return '[invalid URL]';
}
}
export const uiResourcesInstructions = `The tool response contains UI resources (i.e. Resource URI starting with "ui://"),
include relevant UI elements in the next message using the following syntax.
This is how these UI resources look like:
Resource Text: https://mcpstorefront.com/img/storefront/product.component.html?store_domain=allbirds.com&product_handle=mens-tree-runner-go-blizzard-bold-red&product_id=gid://shopify/Product/7091625328720&mode=tool
Resource URI: ui://product/gid://shopify/Product/7091625328720
Resource MIME Type: text/uri-list
If there is only one UI resource, use the following syntax:
:::artifact{identifier="a-unique-identifier" type="mcp-ui-single" title="The most descriptive title for the UI resource"}
ui://product/gid://shopify/Product/7091625328720
:::
Note: You only need to include the URI, not the full JSON. The system will automatically retrieve the full resource data.
If there are multiple UI resources, like this:
Resource Text: https://mcpstorefront.com/img/storefront/product.component.html?store_domain=allbirds.com&product_handle=mens-tree-runner-go-blizzard-bold-red&product_id=gid://shopify/Product/7091625328720&mode=tool
Resource URI: ui://product/gid://shopify/Product/7091625328720
Resource MIME Type: text/uri-list
Resource Text: https://mcpstorefront.com/img/storefront/product.component.html?store_domain=allbirds.com&product_handle=mens-tree-runners-blizzard-bold-red&product_id=gid://shopify/Product/7091621527632&mode=tool
Resource URI: ui://product/gid://shopify/Product/7091621527632
Resource MIME Type: text/uri-list
Resource Text: https://mcpstorefront.com/img/storefront/product.component.html?store_domain=allbirds.com&product_handle=womens-tree-runners-blizzard-bold-red&product_id=gid://shopify/Product/7091621888080&mode=tool
Resource URI: ui://product/gid://shopify/Product/7091621888080
Resource MIME Type: text/uri-list
Use the following syntax:
:::artifact{identifier="a-unique-identifier" type="mcp-ui-carousel" title="The most descriptive title for the UI resources"}
ui://product/gid://shopify/Product/7091625328720, ui://product/gid://shopify/Product/7091621527632, ui://product/gid://shopify/Product/7091621888080
:::
Note: You only need to include comma-separated URIs, not the full JSON array. The system will automatically retrieve the full resource data.
Make sure that all artifacts both start and end with :::`;