Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/src/components/Chat/Messages/Content/ContentParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Sources from '~/components/Web/Sources';
import { mapAttachments } from '~/utils/map';
import { EditTextPart } from './Parts';
import { useLocalize } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import store from '~/store';
import Part from './Part';

Expand Down Expand Up @@ -53,6 +54,7 @@ const ContentParts = memo(
setSiblingIdx,
}: ContentPartsProps) => {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const [showThinking, setShowThinking] = useRecoilState<boolean>(store.showThinking);
const [isExpanded, setIsExpanded] = useState(showThinking);
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
Expand Down Expand Up @@ -139,7 +141,7 @@ const ContentParts = memo(
}
label={
effectiveIsSubmitting && isLast
? localize('com_ui_thinking')
? startupConfig?.interface?.thinkingIndicatorText || localize('com_ui_thinking')
: localize('com_ui_thoughts')
}
/>
Expand Down
30 changes: 26 additions & 4 deletions client/src/components/Chat/Messages/Content/ToolCallInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useLocalize } from '~/hooks';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import UIResourceCarousel from './UIResourceCarousel';
import type { TAttachment, UIResource } from 'librechat-data-provider';

Expand Down Expand Up @@ -38,6 +37,9 @@ export default function ToolCallInfo({
attachments?: TAttachment[];
}) {
const localize = useLocalize();
const [UIResourceRenderer, setUIResourceRenderer] = useState<React.ComponentType<any> | null>(
null,
);
const formatText = (text: string) => {
try {
return JSON.stringify(JSON.parse(text), null, 2);
Expand All @@ -64,6 +66,26 @@ export default function ToolCallInfo({
return attachment[Tools.ui_resources] as UIResource[];
}) ?? [];

useEffect(() => {
if (!uiResources || uiResources.length === 0) {
return;
}
let mounted = true;
(async () => {
try {
const { UIResourceRenderer: Renderer } = await import('@mcp-ui/client');
if (mounted && Renderer) {
setUIResourceRenderer(() => Renderer);
}
} catch {
return;
}
})();
return () => {
mounted = false;
};
}, [uiResources]);

return (
<div className="w-full p-2">
<div style={{ opacity: 1 }}>
Expand All @@ -87,10 +109,10 @@ export default function ToolCallInfo({
<div>
{uiResources.length > 1 && <UIResourceCarousel uiResources={uiResources} />}

{uiResources.length === 1 && (
{uiResources.length === 1 && UIResourceRenderer && (
<UIResourceRenderer
resource={uiResources[0]}
onUIAction={async (result) => {
onUIAction={async (result: unknown) => {
console.log('Action:', result);
}}
htmlProps={{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import { Tools } from 'librechat-data-provider';
import { UIResourceRenderer } from '@mcp-ui/client';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import type { TAttachment } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
Expand All @@ -21,8 +20,10 @@ jest.mock('~/hooks', () => ({
},
}));

const mockUIResourceRenderer = jest.fn(() => null);

jest.mock('@mcp-ui/client', () => ({
UIResourceRenderer: jest.fn(() => null),
UIResourceRenderer: mockUIResourceRenderer,
}));

jest.mock('../UIResourceCarousel', () => ({
Expand All @@ -49,7 +50,7 @@ describe('ToolCallInfo', () => {
});

describe('ui_resources from attachments', () => {
it('should render single ui_resource from attachments', () => {
it('should render single ui_resource from attachments', async () => {
const uiResource = {
type: 'text',
data: 'Test resource',
Expand All @@ -69,22 +70,24 @@ describe('ToolCallInfo', () => {
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);

// Should render UIResourceRenderer for single resource
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
}),
expect.any(Object),
await waitFor(() =>
expect(mockUIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
}),
expect.any(Object),
),
);

// Should not render carousel for single resource
expect(UIResourceCarousel).not.toHaveBeenCalled();
});

it('should render carousel for multiple ui_resources from attachments', () => {
it('should render carousel for multiple ui_resources from attachments', async () => {
// To test multiple resources, we can use a single attachment with multiple resources
const attachments: TAttachment[] = [
{
Expand All @@ -104,22 +107,24 @@ describe('ToolCallInfo', () => {
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);

// Should render carousel for multiple resources
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
}),
expect.any(Object),
await waitFor(() =>
expect(UIResourceCarousel).toHaveBeenCalledWith(
expect.objectContaining({
uiResources: [
{ type: 'text', data: 'Resource 1' },
{ type: 'text', data: 'Resource 2' },
{ type: 'text', data: 'Resource 3' },
],
}),
expect.any(Object),
),
);

// Should not render individual UIResourceRenderer
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(mockUIResourceRenderer).not.toHaveBeenCalled();
});

it('should handle attachments with normal output', () => {
it('should handle attachments with normal output', async () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
Expand Down Expand Up @@ -147,15 +152,15 @@ describe('ToolCallInfo', () => {
expect(outputCode).toContain('Regular output 2');

// UI resources should be rendered via attachments
expect(UIResourceRenderer).toHaveBeenCalled();
await waitFor(() => expect(mockUIResourceRenderer).toHaveBeenCalled());
});

it('should handle no attachments', () => {
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);

render(<ToolCallInfo {...mockProps} output={output} />);

expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(mockUIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});

Expand All @@ -164,7 +169,7 @@ describe('ToolCallInfo', () => {

render(<ToolCallInfo {...mockProps} attachments={attachments} />);

expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(mockUIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});

Expand All @@ -184,7 +189,7 @@ describe('ToolCallInfo', () => {
render(<ToolCallInfo {...mockProps} attachments={attachments} />);

// Should not render UI resources components for non-ui_resources attachments
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(mockUIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});
});
Expand Down Expand Up @@ -213,7 +218,7 @@ describe('ToolCallInfo', () => {
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
});

it('should pass correct props to UIResourceRenderer', () => {
it('should pass correct props to UIResourceRenderer', async () => {
const uiResource = {
type: 'form',
data: { fields: [{ name: 'test', type: 'text' }] },
Expand All @@ -232,15 +237,17 @@ describe('ToolCallInfo', () => {
// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);

expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
}),
expect.any(Object),
await waitFor(() =>
expect(mockUIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: uiResource,
onUIAction: expect.any(Function),
htmlProps: {
autoResizeIframe: { width: true, height: true },
},
}),
expect.any(Object),
),
);
});

Expand All @@ -257,17 +264,15 @@ describe('ToolCallInfo', () => {
},
];

// Need output for ui_resources to render
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);

const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
typeof UIResourceRenderer
>;
const onUIAction = mockUIResourceRenderer.mock.calls[0]?.[0]?.onUIAction;
await waitFor(() => expect(mockUIResourceRenderer).toHaveBeenCalled());
const firstCall = mockUIResourceRenderer.mock.calls[0] as any[];
const onUIAction = firstCall?.[0]?.onUIAction;
const testResult = { action: 'submit', data: { test: 'value' } };

if (onUIAction) {
await onUIAction(testResult as any);
if (onUIAction && typeof onUIAction === 'function') {
await onUIAction(testResult);
}

expect(consoleSpy).toHaveBeenCalledWith('Action:', testResult);
Expand All @@ -291,11 +296,11 @@ describe('ToolCallInfo', () => {
render(<ToolCallInfo {...mockProps} output={output} />);

// Since we now use attachments, ui_resources in output should be ignored
expect(UIResourceRenderer).not.toHaveBeenCalled();
expect(mockUIResourceRenderer).not.toHaveBeenCalled();
expect(UIResourceCarousel).not.toHaveBeenCalled();
});

it('should prioritize attachments over output ui_resources', () => {
it('should prioritize attachments over output ui_resources', async () => {
const attachments: TAttachment[] = [
{
type: Tools.ui_resources,
Expand All @@ -318,11 +323,13 @@ describe('ToolCallInfo', () => {
render(<ToolCallInfo {...mockProps} output={output} attachments={attachments} />);

// Should use attachments, not output
expect(UIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
}),
expect.any(Object),
await waitFor(() =>
expect(mockUIResourceRenderer).toHaveBeenCalledWith(
expect.objectContaining({
resource: { type: 'attachment', data: 'From attachments' },
}),
expect.any(Object),
),
);
});
});
Expand Down
1 change: 1 addition & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ interface:
marketplace:
use: false
fileCitations: true
# thinkingIndicatorText: "Thinking..." # Text shown while AI is generating a response (default: "Thinking...")
# Temporary chat retention period in hours (default: 720, min: 1, max: 8760)
# temporaryChatRetention: 1

Expand Down
1 change: 1 addition & 0 deletions packages/data-provider/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@ export const interfaceSchema = z
.optional(),
fileSearch: z.boolean().optional(),
fileCitations: z.boolean().optional(),
thinkingIndicatorText: z.string().optional(),
})
.default({
endpointsMenu: true,
Expand Down
1 change: 1 addition & 0 deletions packages/data-schemas/src/app/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function loadDefaultInterface({
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
mcpServers: interfaceConfig?.mcpServers ?? defaults.mcpServers,
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
thinkingIndicatorText: interfaceConfig?.thinkingIndicatorText,

// Permissions - only include if explicitly configured
bookmarks: interfaceConfig?.bookmarks,
Expand Down