mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
Merge 2fc4441bd5
into 68c9f668c1
This commit is contained in:
commit
ff6b8b9a14
18 changed files with 1684 additions and 141 deletions
|
@ -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);
|
||||
|
|
429
client/src/components/Artifacts/Artifact.test.tsx
Normal file
429
client/src/components/Artifacts/Artifact.test.tsx
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 },
|
||||
}}
|
||||
|
|
|
@ -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} />);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
361
client/src/hooks/__tests__/useUIResources.test.tsx
Normal file
361
client/src/hooks/__tests__/useUIResources.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -24,3 +24,5 @@ export const useResourcePermissions = (resourceType: ResourceType, resourceId: s
|
|||
permissionBits: data?.permissionBits || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export default useResourcePermissions;
|
||||
|
|
54
client/src/hooks/useUIResources.ts
Normal file
54
client/src/hooks/useUIResources.ts
Normal 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;
|
|
@ -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,
|
||||
});
|
||||
|
|
467
client/src/utils/__tests__/index.test.tsx
Normal file
467
client/src/utils/__tests__/index.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -84,11 +84,7 @@ export type Provider =
|
|||
export type FormattedContent =
|
||||
| {
|
||||
type: 'text';
|
||||
metadata?: {
|
||||
type: string;
|
||||
data: UIResource[];
|
||||
};
|
||||
text?: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'image';
|
||||
|
|
|
@ -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 :::`;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue