mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00
Merge branch 'main' into feat/secure-session-cookies
This commit is contained in:
commit
fcd352d6ee
20 changed files with 1178 additions and 270 deletions
342
api/server/controllers/agents/__tests__/callbacks.spec.js
Normal file
342
api/server/controllers/agents/__tests__/callbacks.spec.js
Normal file
|
@ -0,0 +1,342 @@
|
||||||
|
const { Tools } = require('librechat-data-provider');
|
||||||
|
|
||||||
|
// Mock all dependencies before requiring the module
|
||||||
|
jest.mock('nanoid', () => ({
|
||||||
|
nanoid: jest.fn(() => 'mock-id'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
sendEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/agents', () => ({
|
||||||
|
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
|
||||||
|
Providers: { GOOGLE: 'google' },
|
||||||
|
GraphEvents: {},
|
||||||
|
getMessageId: jest.fn(),
|
||||||
|
ToolEndHandler: jest.fn(),
|
||||||
|
handleToolCalls: jest.fn(),
|
||||||
|
ChatModelStreamHandler: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/Citations', () => ({
|
||||||
|
processFileCitations: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||||
|
processCodeOutput: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||||
|
loadAuthValues: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
saveBase64Image: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('createToolEndCallback', () => {
|
||||||
|
let req, res, artifactPromises, createToolEndCallback;
|
||||||
|
let logger;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Get the mocked logger
|
||||||
|
logger = require('@librechat/data-schemas').logger;
|
||||||
|
|
||||||
|
// Now require the module after all mocks are set up
|
||||||
|
const callbacks = require('../callbacks');
|
||||||
|
createToolEndCallback = callbacks.createToolEndCallback;
|
||||||
|
|
||||||
|
req = {
|
||||||
|
user: { id: 'user123' },
|
||||||
|
};
|
||||||
|
res = {
|
||||||
|
headersSent: false,
|
||||||
|
write: jest.fn(),
|
||||||
|
};
|
||||||
|
artifactPromises = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ui_resources artifact handling', () => {
|
||||||
|
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
artifact: {
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
data: {
|
||||||
|
0: { type: 'button', label: 'Click me' },
|
||||||
|
1: { type: 'input', placeholder: 'Enter text' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback({ output }, metadata);
|
||||||
|
|
||||||
|
// Wait for all promises to resolve
|
||||||
|
const results = await Promise.all(artifactPromises);
|
||||||
|
|
||||||
|
// When headers are not sent, it returns attachment without writing
|
||||||
|
expect(res.write).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const attachment = results[0];
|
||||||
|
expect(attachment).toEqual({
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'run456',
|
||||||
|
toolCallId: 'tool123',
|
||||||
|
conversationId: 'thread789',
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
0: { type: 'button', label: 'Click me' },
|
||||||
|
1: { type: 'input', placeholder: 'Enter text' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write to response when headers are already sent', async () => {
|
||||||
|
res.headersSent = true;
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
artifact: {
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
data: {
|
||||||
|
0: { type: 'carousel', items: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback({ output }, metadata);
|
||||||
|
const results = await Promise.all(artifactPromises);
|
||||||
|
|
||||||
|
expect(res.write).toHaveBeenCalled();
|
||||||
|
expect(results[0]).toEqual({
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'run456',
|
||||||
|
toolCallId: 'tool123',
|
||||||
|
conversationId: 'thread789',
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
0: { type: 'carousel', items: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors when processing ui_resources', async () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
// Mock res.write to throw an error
|
||||||
|
res.headersSent = true;
|
||||||
|
res.write.mockImplementation(() => {
|
||||||
|
throw new Error('Write failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
artifact: {
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
data: {
|
||||||
|
0: { type: 'test' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback({ output }, metadata);
|
||||||
|
const results = await Promise.all(artifactPromises);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
'Error processing artifact content:',
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(results[0]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple artifacts including ui_resources', async () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
artifact: {
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
data: {
|
||||||
|
0: { type: 'chart', data: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[Tools.web_search]: {
|
||||||
|
results: ['result1', 'result2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback({ output }, metadata);
|
||||||
|
const results = await Promise.all(artifactPromises);
|
||||||
|
|
||||||
|
// Both ui_resources and web_search should be processed
|
||||||
|
expect(artifactPromises).toHaveLength(2);
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
|
||||||
|
// Check ui_resources attachment
|
||||||
|
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
||||||
|
expect(uiResourceAttachment).toBeTruthy();
|
||||||
|
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
|
||||||
|
0: { type: 'chart', data: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check web_search attachment
|
||||||
|
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
||||||
|
expect(webSearchAttachment).toBeTruthy();
|
||||||
|
expect(webSearchAttachment[Tools.web_search]).toEqual({
|
||||||
|
results: ['result1', 'result2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not process artifacts when output has no artifacts', async () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
content: 'Some regular content',
|
||||||
|
// No artifact property
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback({ output }, metadata);
|
||||||
|
|
||||||
|
expect(artifactPromises).toHaveLength(0);
|
||||||
|
expect(res.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty ui_resources data object', async () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
artifact: {
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback({ output }, metadata);
|
||||||
|
const results = await Promise.all(artifactPromises);
|
||||||
|
|
||||||
|
expect(results[0]).toEqual({
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'run456',
|
||||||
|
toolCallId: 'tool123',
|
||||||
|
conversationId: 'thread789',
|
||||||
|
[Tools.ui_resources]: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ui_resources with complex nested data', async () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const complexData = {
|
||||||
|
0: {
|
||||||
|
type: 'form',
|
||||||
|
fields: [
|
||||||
|
{ name: 'field1', type: 'text', required: true },
|
||||||
|
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
|
||||||
|
],
|
||||||
|
nested: {
|
||||||
|
deep: {
|
||||||
|
value: 123,
|
||||||
|
array: [1, 2, 3],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
tool_call_id: 'tool123',
|
||||||
|
artifact: {
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
data: complexData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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 () => {
|
||||||
|
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
run_id: 'run456',
|
||||||
|
thread_id: 'thread789',
|
||||||
|
};
|
||||||
|
|
||||||
|
await toolEndCallback(undefined, metadata);
|
||||||
|
|
||||||
|
expect(artifactPromises).toHaveLength(0);
|
||||||
|
expect(res.write).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -265,6 +265,30 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: a lot of duplicated code in createToolEndCallback
|
||||||
|
// we should refactor this to use a helper function in a follow-up PR
|
||||||
|
if (output.artifact[Tools.ui_resources]) {
|
||||||
|
artifactPromises.push(
|
||||||
|
(async () => {
|
||||||
|
const attachment = {
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: metadata.run_id,
|
||||||
|
toolCallId: output.tool_call_id,
|
||||||
|
conversationId: metadata.thread_id,
|
||||||
|
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
|
||||||
|
};
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
|
||||||
|
return attachment;
|
||||||
|
})().catch((error) => {
|
||||||
|
logger.error('Error processing artifact content:', error);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (output.artifact[Tools.web_search]) {
|
if (output.artifact[Tools.web_search]) {
|
||||||
artifactPromises.push(
|
artifactPromises.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
|
@ -642,10 +642,3 @@ declare global {
|
||||||
google_tag_manager?: unknown;
|
google_tag_manager?: unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UIResource = {
|
|
||||||
uri: string;
|
|
||||||
mimeType: string;
|
|
||||||
text: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
|
@ -211,6 +211,7 @@ export default function ToolCall({
|
||||||
domain={authDomain || (domain ?? '')}
|
domain={authDomain || (domain ?? '')}
|
||||||
function_name={function_name}
|
function_name={function_name}
|
||||||
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
|
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
|
||||||
|
attachments={attachments}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { Tools } from 'librechat-data-provider';
|
||||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
import UIResourceCarousel from './UIResourceCarousel';
|
import UIResourceCarousel from './UIResourceCarousel';
|
||||||
import type { UIResource } from '~/common';
|
import type { TAttachment, UIResource } from 'librechat-data-provider';
|
||||||
|
|
||||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||||
return (
|
return (
|
||||||
|
@ -27,12 +28,14 @@ export default function ToolCallInfo({
|
||||||
domain,
|
domain,
|
||||||
function_name,
|
function_name,
|
||||||
pendingAuth,
|
pendingAuth,
|
||||||
|
attachments,
|
||||||
}: {
|
}: {
|
||||||
input: string;
|
input: string;
|
||||||
function_name: string;
|
function_name: string;
|
||||||
output?: string | null;
|
output?: string | null;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
pendingAuth?: boolean;
|
pendingAuth?: boolean;
|
||||||
|
attachments?: TAttachment[];
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const formatText = (text: string) => {
|
const formatText = (text: string) => {
|
||||||
|
@ -54,25 +57,12 @@ export default function ToolCallInfo({
|
||||||
: localize('com_assistants_attempt_info');
|
: localize('com_assistants_attempt_info');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract ui_resources from the output to display them in the UI
|
const uiResources: UIResource[] =
|
||||||
let uiResources: UIResource[] = [];
|
attachments
|
||||||
if (output?.includes('ui_resources')) {
|
?.filter((attachment) => attachment.type === Tools.ui_resources)
|
||||||
try {
|
.flatMap((attachment) => {
|
||||||
const parsedOutput = JSON.parse(output);
|
return attachment[Tools.ui_resources] as UIResource[];
|
||||||
const uiResourcesItem = parsedOutput.find(
|
}) ?? [];
|
||||||
(contentItem) => contentItem.metadata?.type === 'ui_resources',
|
|
||||||
);
|
|
||||||
if (uiResourcesItem?.metadata?.data) {
|
|
||||||
uiResources = uiResourcesItem.metadata.data;
|
|
||||||
output = JSON.stringify(
|
|
||||||
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// If JSON parsing fails, keep original output
|
|
||||||
console.error('Failed to parse output:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-2">
|
<div className="w-full p-2">
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
|
||||||
import type { UIResource } from '~/common';
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
|
import type { UIResource } from 'librechat-data-provider';
|
||||||
|
|
||||||
interface UIResourceCarouselProps {
|
interface UIResourceCarouselProps {
|
||||||
uiResources: UIResource[];
|
uiResources: UIResource[];
|
||||||
|
|
|
@ -0,0 +1,382 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
import { Tools } from 'librechat-data-provider';
|
||||||
|
import ToolCall from '../ToolCall';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: () => (key: string, values?: any) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
com_assistants_function_use: `Used ${values?.[0]}`,
|
||||||
|
com_assistants_completed_function: `Completed ${values?.[0]}`,
|
||||||
|
com_assistants_completed_action: `Completed action on ${values?.[0]}`,
|
||||||
|
com_assistants_running_var: `Running ${values?.[0]}`,
|
||||||
|
com_assistants_running_action: 'Running action',
|
||||||
|
com_ui_sign_in_to_domain: `Sign in to ${values?.[0]}`,
|
||||||
|
com_ui_cancelled: 'Cancelled',
|
||||||
|
com_ui_requires_auth: 'Requires authentication',
|
||||||
|
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
},
|
||||||
|
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ content }: { content: string }) => <div data-testid="message-content">{content}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../ToolCallInfo', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ attachments, ...props }: any) => (
|
||||||
|
<div data-testid="tool-call-info" data-attachments={JSON.stringify(attachments)}>
|
||||||
|
{JSON.stringify(props)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../ProgressText', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
|
||||||
|
<div onClick={onClick}>{finishedText || inProgressText}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Parts', () => ({
|
||||||
|
AttachmentGroup: ({ attachments }: any) => (
|
||||||
|
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components/ui', () => ({
|
||||||
|
Button: ({ children, onClick, ...props }: any) => (
|
||||||
|
<button onClick={onClick} {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('lucide-react', () => ({
|
||||||
|
ChevronDown: () => <span>{'ChevronDown'}</span>,
|
||||||
|
ChevronUp: () => <span>{'ChevronUp'}</span>,
|
||||||
|
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ToolCall', () => {
|
||||||
|
const mockProps = {
|
||||||
|
args: '{"test": "input"}',
|
||||||
|
name: 'testFunction',
|
||||||
|
output: 'Test output',
|
||||||
|
initialProgress: 1,
|
||||||
|
isSubmitting: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithRecoil = (component: React.ReactElement) => {
|
||||||
|
return render(<RecoilRoot>{component}</RecoilRoot>);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
expect(toolCallInfo).toBeInTheDocument();
|
||||||
|
|
||||||
|
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||||
|
expect(attachmentsData).toBe(JSON.stringify(attachments));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass empty array when no attachments', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||||
|
expect(attachmentsData).toBeNull(); // JSON.stringify(undefined) returns undefined, so attribute is not set
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass multiple attachments of different types', () => {
|
||||||
|
const attachments = [
|
||||||
|
{
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg1',
|
||||||
|
toolCallId: 'tool1',
|
||||||
|
conversationId: 'conv1',
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
'0': { type: 'form', fields: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: Tools.web_search,
|
||||||
|
messageId: 'msg2',
|
||||||
|
toolCallId: 'tool2',
|
||||||
|
conversationId: 'conv2',
|
||||||
|
[Tools.web_search]: {
|
||||||
|
results: ['result1', 'result2'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||||
|
expect(JSON.parse(attachmentsData!)).toEqual(attachments);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attachment group rendering', () => {
|
||||||
|
it('should render AttachmentGroup when attachments are provided', () => {
|
||||||
|
const attachments = [
|
||||||
|
{
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg123',
|
||||||
|
toolCallId: 'tool456',
|
||||||
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
'0': { type: 'chart', data: [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
|
||||||
|
|
||||||
|
const attachmentGroup = screen.getByTestId('attachment-group');
|
||||||
|
expect(attachmentGroup).toBeInTheDocument();
|
||||||
|
expect(attachmentGroup.textContent).toBe(JSON.stringify(attachments));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render AttachmentGroup when no attachments', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render AttachmentGroup when attachments is empty array', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} attachments={[]} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tool call info visibility', () => {
|
||||||
|
it('should toggle tool call info when clicking header', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||||
|
|
||||||
|
// Initially closed
|
||||||
|
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click to open
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click to close
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass all required props to ToolCallInfo', () => {
|
||||||
|
const attachments = [
|
||||||
|
{
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg123',
|
||||||
|
toolCallId: 'tool456',
|
||||||
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
'0': { type: 'button', label: 'Test' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use a name with domain separator (_action_) and domain separator (---)
|
||||||
|
const propsWithDomain = {
|
||||||
|
...mockProps,
|
||||||
|
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithRecoil(<ToolCall {...propsWithDomain} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const props = JSON.parse(toolCallInfo.textContent!);
|
||||||
|
|
||||||
|
expect(props.input).toBe('{"test": "input"}');
|
||||||
|
expect(props.output).toBe('Test output');
|
||||||
|
expect(props.function_name).toBe('testFunction');
|
||||||
|
// Domain is extracted from name and --- are replaced with dots
|
||||||
|
expect(props.domain).toBe('test.domain.com');
|
||||||
|
expect(props.pendingAuth).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('authentication flow', () => {
|
||||||
|
it('should show sign-in button when auth URL is provided', () => {
|
||||||
|
const originalOpen = window.open;
|
||||||
|
window.open = jest.fn();
|
||||||
|
|
||||||
|
renderWithRecoil(
|
||||||
|
<ToolCall
|
||||||
|
{...mockProps}
|
||||||
|
initialProgress={0.5} // Less than 1 so it's not complete
|
||||||
|
auth="https://auth.example.com"
|
||||||
|
isSubmitting={true} // Should be submitting for auth to show
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const signInButton = screen.getByText('Sign in to auth.example.com');
|
||||||
|
expect(signInButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(signInButton);
|
||||||
|
expect(window.open).toHaveBeenCalledWith(
|
||||||
|
'https://auth.example.com',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
|
||||||
|
window.open = originalOpen;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass pendingAuth as true when auth is pending', () => {
|
||||||
|
renderWithRecoil(
|
||||||
|
<ToolCall
|
||||||
|
{...mockProps}
|
||||||
|
auth="https://auth.example.com" // Need auth URL to extract domain
|
||||||
|
initialProgress={0.5} // Less than 1
|
||||||
|
isSubmitting={true} // Still submitting
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const props = JSON.parse(toolCallInfo.textContent!);
|
||||||
|
expect(props.pendingAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show auth section when cancelled', () => {
|
||||||
|
renderWithRecoil(
|
||||||
|
<ToolCall
|
||||||
|
{...mockProps}
|
||||||
|
auth="https://auth.example.com"
|
||||||
|
authDomain="example.com"
|
||||||
|
progress={0.5}
|
||||||
|
cancelled={true}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show auth section when progress is complete', () => {
|
||||||
|
renderWithRecoil(
|
||||||
|
<ToolCall
|
||||||
|
{...mockProps}
|
||||||
|
auth="https://auth.example.com"
|
||||||
|
authDomain="example.com"
|
||||||
|
progress={1}
|
||||||
|
cancelled={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle undefined args', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const props = JSON.parse(toolCallInfo.textContent!);
|
||||||
|
expect(props.input).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null output', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const props = JSON.parse(toolCallInfo.textContent!);
|
||||||
|
expect(props.output).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing domain', () => {
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const props = JSON.parse(toolCallInfo.textContent!);
|
||||||
|
expect(props.domain).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested attachments', () => {
|
||||||
|
const complexAttachments = [
|
||||||
|
{
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg123',
|
||||||
|
toolCallId: 'tool456',
|
||||||
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: {
|
||||||
|
'0': {
|
||||||
|
type: 'nested',
|
||||||
|
data: {
|
||||||
|
deep: {
|
||||||
|
value: 'test',
|
||||||
|
array: [1, 2, 3],
|
||||||
|
object: { key: 'value' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||||
|
|
||||||
|
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||||
|
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||||
|
expect(JSON.parse(attachmentsData!)).toEqual(complexAttachments);
|
||||||
|
|
||||||
|
const attachmentGroup = screen.getByTestId('attachment-group');
|
||||||
|
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { Tools } from 'librechat-data-provider';
|
||||||
import ToolCallInfo from '../ToolCallInfo';
|
|
||||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||||
import UIResourceCarousel from '../UIResourceCarousel';
|
import { render, screen } 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';
|
||||||
|
|
||||||
// Mock the dependencies
|
// Mock the dependencies
|
||||||
jest.mock('~/hooks', () => ({
|
jest.mock('~/hooks', () => ({
|
||||||
|
@ -46,24 +48,25 @@ describe('ToolCallInfo', () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ui_resources extraction', () => {
|
describe('ui_resources from attachments', () => {
|
||||||
it('should extract single ui_resource from output', () => {
|
it('should render single ui_resource from attachments', () => {
|
||||||
const uiResource = {
|
const uiResource = {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
data: 'Test resource',
|
data: 'Test resource',
|
||||||
};
|
};
|
||||||
|
|
||||||
const output = JSON.stringify([
|
const attachments: TAttachment[] = [
|
||||||
{ type: 'text', text: 'Regular output' },
|
|
||||||
{
|
{
|
||||||
metadata: {
|
type: Tools.ui_resources,
|
||||||
type: 'ui_resources',
|
messageId: 'msg123',
|
||||||
data: [uiResource],
|
toolCallId: 'tool456',
|
||||||
},
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: [uiResource],
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
// Need output for ui_resources to render
|
||||||
|
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||||
|
|
||||||
// Should render UIResourceRenderer for single resource
|
// Should render UIResourceRenderer for single resource
|
||||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||||
|
@ -81,29 +84,33 @@ describe('ToolCallInfo', () => {
|
||||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should extract multiple ui_resources from output', () => {
|
it('should render carousel for multiple ui_resources from attachments', () => {
|
||||||
const uiResources = [
|
// To test multiple resources, we can use a single attachment with multiple resources
|
||||||
{ type: 'text', data: 'Resource 1' },
|
const attachments: TAttachment[] = [
|
||||||
{ type: 'text', data: 'Resource 2' },
|
{
|
||||||
{ type: 'text', data: 'Resource 3' },
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg1',
|
||||||
|
toolCallId: 'tool1',
|
||||||
|
conversationId: 'conv1',
|
||||||
|
[Tools.ui_resources]: [
|
||||||
|
{ type: 'text', data: 'Resource 1' },
|
||||||
|
{ type: 'text', data: 'Resource 2' },
|
||||||
|
{ type: 'text', data: 'Resource 3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const output = JSON.stringify([
|
// Need output for ui_resources to render
|
||||||
{ type: 'text', text: 'Regular output' },
|
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||||
{
|
|
||||||
metadata: {
|
|
||||||
type: 'ui_resources',
|
|
||||||
data: uiResources,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
|
||||||
|
|
||||||
// Should render carousel for multiple resources
|
// Should render carousel for multiple resources
|
||||||
expect(UIResourceCarousel).toHaveBeenCalledWith(
|
expect(UIResourceCarousel).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
uiResources,
|
uiResources: [
|
||||||
|
{ type: 'text', data: 'Resource 1' },
|
||||||
|
{ type: 'text', data: 'Resource 2' },
|
||||||
|
{ type: 'text', data: 'Resource 3' },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
|
@ -112,34 +119,38 @@ describe('ToolCallInfo', () => {
|
||||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter out ui_resources from displayed output', () => {
|
it('should handle attachments with normal output', () => {
|
||||||
const regularContent = [
|
const attachments: TAttachment[] = [
|
||||||
{ type: 'text', text: 'Regular output 1' },
|
{
|
||||||
{ type: 'text', text: 'Regular output 2' },
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg123',
|
||||||
|
toolCallId: 'tool456',
|
||||||
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const output = JSON.stringify([
|
const output = JSON.stringify([
|
||||||
...regularContent,
|
{ type: 'text', text: 'Regular output 1' },
|
||||||
{
|
{ type: 'text', text: 'Regular output 2' },
|
||||||
metadata: {
|
|
||||||
type: 'ui_resources',
|
|
||||||
data: [{ type: 'text', data: 'UI Resource' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
|
const { container } = render(
|
||||||
|
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
|
||||||
|
);
|
||||||
|
|
||||||
// Check that the displayed output doesn't contain ui_resources
|
// Check that the output is displayed normally
|
||||||
const codeBlocks = container.querySelectorAll('code');
|
const codeBlocks = container.querySelectorAll('code');
|
||||||
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
|
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
|
||||||
|
|
||||||
expect(outputCode).toContain('Regular output 1');
|
expect(outputCode).toContain('Regular output 1');
|
||||||
expect(outputCode).toContain('Regular output 2');
|
expect(outputCode).toContain('Regular output 2');
|
||||||
expect(outputCode).not.toContain('ui_resources');
|
|
||||||
|
// UI resources should be rendered via attachments
|
||||||
|
expect(UIResourceRenderer).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle output without ui_resources', () => {
|
it('should handle no attachments', () => {
|
||||||
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||||
|
@ -148,66 +159,56 @@ describe('ToolCallInfo', () => {
|
||||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle malformed ui_resources gracefully', () => {
|
it('should handle empty attachments array', () => {
|
||||||
const output = JSON.stringify([
|
const attachments: TAttachment[] = [];
|
||||||
{
|
|
||||||
metadata: 'ui_resources', // metadata should be an object, not a string
|
|
||||||
text: 'some text content',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Component should not throw error and should render without UI resources
|
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
|
||||||
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
|
|
||||||
|
|
||||||
// Should render the component without crashing
|
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||||
expect(container).toBeTruthy();
|
|
||||||
|
|
||||||
// UIResourceCarousel should not be called since the metadata structure is invalid
|
|
||||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle ui_resources as plain text without breaking', () => {
|
it('should handle attachments with non-ui_resources type', () => {
|
||||||
const outputWithTextOnly =
|
const attachments: TAttachment[] = [
|
||||||
'This output contains ui_resources as plain text but not as a proper structure';
|
{
|
||||||
|
type: Tools.web_search as any,
|
||||||
|
messageId: 'msg123',
|
||||||
|
toolCallId: 'tool456',
|
||||||
|
conversationId: 'conv789',
|
||||||
|
[Tools.web_search]: {
|
||||||
|
organic: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
|
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
|
||||||
|
|
||||||
// Should render normally without errors
|
// Should not render UI resources components for non-ui_resources attachments
|
||||||
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Result')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// The output text should be displayed in a code block
|
|
||||||
const codeBlocks = screen.getAllByText((content, element) => {
|
|
||||||
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
|
|
||||||
});
|
|
||||||
expect(codeBlocks.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Should not render UI resources components
|
|
||||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rendering logic', () => {
|
describe('rendering logic', () => {
|
||||||
it('should render UI Resources heading when ui_resources exist', () => {
|
it('should render UI Resources heading when ui_resources exist in attachments', () => {
|
||||||
const output = JSON.stringify([
|
const attachments: TAttachment[] = [
|
||||||
{
|
{
|
||||||
metadata: {
|
type: Tools.ui_resources,
|
||||||
type: 'ui_resources',
|
messageId: 'msg123',
|
||||||
data: [{ type: 'text', data: 'Test' }],
|
toolCallId: 'tool456',
|
||||||
},
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
// Need output for ui_resources section to render
|
||||||
|
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||||
|
|
||||||
expect(screen.getByText('UI Resources')).toBeInTheDocument();
|
expect(screen.getByText('UI Resources')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render UI Resources heading when no ui_resources', () => {
|
it('should not render UI Resources heading when no ui_resources in attachments', () => {
|
||||||
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
render(<ToolCallInfo {...mockProps} />);
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
|
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -218,16 +219,18 @@ describe('ToolCallInfo', () => {
|
||||||
data: { fields: [{ name: 'test', type: 'text' }] },
|
data: { fields: [{ name: 'test', type: 'text' }] },
|
||||||
};
|
};
|
||||||
|
|
||||||
const output = JSON.stringify([
|
const attachments: TAttachment[] = [
|
||||||
{
|
{
|
||||||
metadata: {
|
type: Tools.ui_resources,
|
||||||
type: 'ui_resources',
|
messageId: 'msg123',
|
||||||
data: [uiResource],
|
toolCallId: 'tool456',
|
||||||
},
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: [uiResource],
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
// Need output for ui_resources to render
|
||||||
|
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||||
|
|
||||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
@ -244,16 +247,18 @@ describe('ToolCallInfo', () => {
|
||||||
it('should console.log when UIAction is triggered', async () => {
|
it('should console.log when UIAction is triggered', async () => {
|
||||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||||
|
|
||||||
const output = JSON.stringify([
|
const attachments: TAttachment[] = [
|
||||||
{
|
{
|
||||||
metadata: {
|
type: Tools.ui_resources,
|
||||||
type: 'ui_resources',
|
messageId: 'msg123',
|
||||||
data: [{ type: 'text', data: 'Test' }],
|
toolCallId: 'tool456',
|
||||||
},
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
// Need output for ui_resources to render
|
||||||
|
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||||
|
|
||||||
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
|
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
|
||||||
typeof UIResourceRenderer
|
typeof UIResourceRenderer
|
||||||
|
@ -270,4 +275,55 @@ describe('ToolCallInfo', () => {
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('backward compatibility', () => {
|
||||||
|
it('should handle output with ui_resources for backward compatibility', () => {
|
||||||
|
const output = JSON.stringify([
|
||||||
|
{ type: 'text', text: 'Regular output' },
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
type: 'ui_resources',
|
||||||
|
data: [{ type: 'text', data: 'UI Resource' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||||
|
|
||||||
|
// Since we now use attachments, ui_resources in output should be ignored
|
||||||
|
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||||
|
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize attachments over output ui_resources', () => {
|
||||||
|
const attachments: TAttachment[] = [
|
||||||
|
{
|
||||||
|
type: Tools.ui_resources,
|
||||||
|
messageId: 'msg123',
|
||||||
|
toolCallId: 'tool456',
|
||||||
|
conversationId: 'conv789',
|
||||||
|
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = JSON.stringify([
|
||||||
|
{
|
||||||
|
metadata: {
|
||||||
|
type: 'ui_resources',
|
||||||
|
data: [{ type: 'output', data: 'From output' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import UIResourceCarousel from '../UIResourceCarousel';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import type { UIResource } from '~/common';
|
import type { UIResource } from 'librechat-data-provider';
|
||||||
|
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
|
||||||
|
|
||||||
// Mock the UIResourceRenderer component
|
// Mock the UIResourceRenderer component
|
||||||
jest.mock('@mcp-ui/client', () => ({
|
jest.mock('@mcp-ui/client', () => ({
|
||||||
|
|
|
@ -1044,9 +1044,9 @@
|
||||||
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
|
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
|
||||||
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
|
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
|
||||||
"com_ui_oauth_error_title": "Authentication Failed",
|
"com_ui_oauth_error_title": "Authentication Failed",
|
||||||
|
"com_ui_oauth_revoke": "Revoke",
|
||||||
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
|
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
|
||||||
"com_ui_oauth_success_title": "Authentication Successful",
|
"com_ui_oauth_success_title": "Authentication Successful",
|
||||||
"com_ui_oauth_revoke": "Revoke",
|
|
||||||
"com_ui_of": "of",
|
"com_ui_of": "of",
|
||||||
"com_ui_off": "Off",
|
"com_ui_off": "Off",
|
||||||
"com_ui_offline": "Offline",
|
"com_ui_offline": "Offline",
|
||||||
|
|
|
@ -848,7 +848,7 @@
|
||||||
"com_ui_download_backup": "下载备份代码",
|
"com_ui_download_backup": "下载备份代码",
|
||||||
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
|
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
|
||||||
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
|
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
|
||||||
"com_ui_drag_drop": "这里需要放点东西,当前是空的",
|
"com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
|
||||||
"com_ui_dropdown_variables": "下拉变量:",
|
"com_ui_dropdown_variables": "下拉变量:",
|
||||||
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
|
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
|
||||||
"com_ui_duplicate": "复制",
|
"com_ui_duplicate": "复制",
|
||||||
|
@ -1044,6 +1044,7 @@
|
||||||
"com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。",
|
"com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。",
|
||||||
"com_ui_oauth_error_missing_state": "缺少状态参数,请重试。",
|
"com_ui_oauth_error_missing_state": "缺少状态参数,请重试。",
|
||||||
"com_ui_oauth_error_title": "认证失败",
|
"com_ui_oauth_error_title": "认证失败",
|
||||||
|
"com_ui_oauth_revoke": "撤销",
|
||||||
"com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:",
|
"com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:",
|
||||||
"com_ui_oauth_success_title": "认证成功",
|
"com_ui_oauth_success_title": "认证成功",
|
||||||
"com_ui_of": "/",
|
"com_ui_of": "/",
|
||||||
|
@ -1252,6 +1253,7 @@
|
||||||
"com_ui_web_search_cohere_key": "输入 Cohere API Key",
|
"com_ui_web_search_cohere_key": "输入 Cohere API Key",
|
||||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL(可选)",
|
"com_ui_web_search_firecrawl_url": "Firecrawl API URL(可选)",
|
||||||
"com_ui_web_search_jina_key": "输入 Jina API Key",
|
"com_ui_web_search_jina_key": "输入 Jina API Key",
|
||||||
|
"com_ui_web_search_jina_url": "Jina API URL(可选)",
|
||||||
"com_ui_web_search_processing": "正在处理结果",
|
"com_ui_web_search_processing": "正在处理结果",
|
||||||
"com_ui_web_search_provider": "搜索提供商",
|
"com_ui_web_search_provider": "搜索提供商",
|
||||||
"com_ui_web_search_provider_searxng": "SearXNG",
|
"com_ui_web_search_provider_searxng": "SearXNG",
|
||||||
|
@ -1263,6 +1265,7 @@
|
||||||
"com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key",
|
"com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key",
|
||||||
"com_ui_web_search_reranker_jina": "Jina AI",
|
"com_ui_web_search_reranker_jina": "Jina AI",
|
||||||
"com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key",
|
"com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key",
|
||||||
|
"com_ui_web_search_reranker_jina_url_help": "了解 Jina Rerank API",
|
||||||
"com_ui_web_search_scraper": "抓取器",
|
"com_ui_web_search_scraper": "抓取器",
|
||||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||||
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",
|
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",
|
||||||
|
|
|
@ -74,9 +74,23 @@ export class MCPConnectionFactory {
|
||||||
oauthTokens,
|
oauthTokens,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.useOAuth) this.handleOAuthEvents(connection);
|
let cleanupOAuthHandlers: (() => void) | null = null;
|
||||||
await this.attemptToConnect(connection);
|
if (this.useOAuth) {
|
||||||
return connection;
|
cleanupOAuthHandlers = this.handleOAuthEvents(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.attemptToConnect(connection);
|
||||||
|
if (cleanupOAuthHandlers) {
|
||||||
|
cleanupOAuthHandlers();
|
||||||
|
}
|
||||||
|
return connection;
|
||||||
|
} catch (error) {
|
||||||
|
if (cleanupOAuthHandlers) {
|
||||||
|
cleanupOAuthHandlers();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieves existing OAuth tokens from storage or returns null */
|
/** Retrieves existing OAuth tokens from storage or returns null */
|
||||||
|
@ -133,8 +147,8 @@ export class MCPConnectionFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets up OAuth event handlers for the connection */
|
/** Sets up OAuth event handlers for the connection */
|
||||||
protected handleOAuthEvents(connection: MCPConnection): void {
|
protected handleOAuthEvents(connection: MCPConnection): () => void {
|
||||||
connection.on('oauthRequired', async (data) => {
|
const oauthHandler = async (data: { serverUrl?: string }) => {
|
||||||
logger.info(`${this.logPrefix} oauthRequired event received`);
|
logger.info(`${this.logPrefix} oauthRequired event received`);
|
||||||
|
|
||||||
// If we just want to initiate OAuth and return, handle it differently
|
// If we just want to initiate OAuth and return, handle it differently
|
||||||
|
@ -202,7 +216,13 @@ export class MCPConnectionFactory {
|
||||||
logger.warn(`${this.logPrefix} OAuth failed, emitting oauthFailed event`);
|
logger.warn(`${this.logPrefix} OAuth failed, emitting oauthFailed event`);
|
||||||
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
|
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
connection.on('oauthRequired', oauthHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
connection.removeListener('oauthRequired', oauthHandler);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attempts to establish connection with timeout handling */
|
/** Attempts to establish connection with timeout handling */
|
||||||
|
|
|
@ -56,6 +56,9 @@ describe('MCPConnectionFactory', () => {
|
||||||
isConnected: jest.fn(),
|
isConnected: jest.fn(),
|
||||||
setOAuthTokens: jest.fn(),
|
setOAuthTokens: jest.fn(),
|
||||||
on: jest.fn().mockReturnValue(mockConnectionInstance),
|
on: jest.fn().mockReturnValue(mockConnectionInstance),
|
||||||
|
once: jest.fn().mockReturnValue(mockConnectionInstance),
|
||||||
|
off: jest.fn().mockReturnValue(mockConnectionInstance),
|
||||||
|
removeListener: jest.fn().mockReturnValue(mockConnectionInstance),
|
||||||
emit: jest.fn(),
|
emit: jest.fn(),
|
||||||
} as unknown as jest.Mocked<MCPConnection>;
|
} as unknown as jest.Mocked<MCPConnection>;
|
||||||
|
|
||||||
|
|
|
@ -161,7 +161,7 @@ describe('formatToolContent', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resource handling', () => {
|
describe('resource handling', () => {
|
||||||
it('should handle UI resources', () => {
|
it('should handle UI resources in artifacts', () => {
|
||||||
const result: t.MCPToolCallResponse = {
|
const result: t.MCPToolCallResponse = {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
@ -181,22 +181,27 @@ describe('formatToolContent', () => {
|
||||||
expect(content).toEqual([
|
expect(content).toEqual([
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: '',
|
text:
|
||||||
metadata: {
|
'Resource Text: {"items": []}\n' +
|
||||||
type: 'ui_resources',
|
'Resource URI: ui://carousel\n' +
|
||||||
data: [
|
'Resource: carousel\n' +
|
||||||
{
|
'Resource Description: A carousel component\n' +
|
||||||
uri: 'ui://carousel',
|
'Resource MIME Type: application/json',
|
||||||
mimeType: 'application/json',
|
|
||||||
text: '{"items": []}',
|
|
||||||
name: 'carousel',
|
|
||||||
description: 'A carousel component',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(artifacts).toBeUndefined();
|
expect(artifacts).toEqual({
|
||||||
|
ui_resources: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://carousel',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"items": []}',
|
||||||
|
name: 'carousel',
|
||||||
|
description: 'A carousel component',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle regular resources', () => {
|
it('should handle regular resources', () => {
|
||||||
|
@ -281,24 +286,75 @@ describe('formatToolContent', () => {
|
||||||
expect(content).toEqual([
|
expect(content).toEqual([
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Some text\n\n' + 'Resource URI: file://data.csv\n' + 'Resource: Data file',
|
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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(artifacts).toEqual({
|
||||||
|
ui_resources: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://button',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"label": "Click me"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle both images and UI resources in artifacts', () => {
|
||||||
|
const result: t.MCPToolCallResponse = {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Content with multimedia' },
|
||||||
|
{ type: 'image', data: 'base64imagedata', mimeType: 'image/png' },
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
resource: {
|
||||||
|
uri: 'ui://graph',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"type": "line"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [content, artifacts] = formatToolContent(result, 'openai');
|
||||||
|
expect(content).toEqual([
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'Content with multimedia',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: '',
|
text:
|
||||||
metadata: {
|
'Resource Text: {"type": "line"}\n' +
|
||||||
type: 'ui_resources',
|
'Resource URI: ui://graph\n' +
|
||||||
data: [
|
'Resource MIME Type: application/json',
|
||||||
{
|
|
||||||
uri: 'ui://button',
|
|
||||||
mimeType: 'application/json',
|
|
||||||
text: '{"label": "Click me"}',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(artifacts).toBeUndefined();
|
expect(artifacts).toEqual({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: 'data:image/png;base64,base64imagedata' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ui_resources: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://graph',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"type": "line"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -358,25 +414,14 @@ describe('formatToolContent', () => {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text:
|
text:
|
||||||
'Middle section\n\n' +
|
'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' +
|
'Resource URI: https://api.example.com/data\n' +
|
||||||
'Resource: API Data\n' +
|
'Resource: API Data\n' +
|
||||||
'Resource Description: External data source',
|
'Resource Description: External data source',
|
||||||
},
|
},
|
||||||
{ type: 'text', text: 'Conclusion' },
|
{ type: 'text', text: 'Conclusion' },
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
text: '',
|
|
||||||
metadata: {
|
|
||||||
type: 'ui_resources',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
uri: 'ui://chart',
|
|
||||||
mimeType: 'application/json',
|
|
||||||
text: '{"type": "bar"}',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
expect(artifacts).toEqual({
|
expect(artifacts).toEqual({
|
||||||
content: [
|
content: [
|
||||||
|
@ -389,6 +434,15 @@ describe('formatToolContent', () => {
|
||||||
image_url: { url: 'https://example.com/image2.jpg' },
|
image_url: { url: 'https://example.com/image2.jpg' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
ui_resources: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
uri: 'ui://chart',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
text: '{"type": "bar"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,7 @@ function isStreamableHTTPOptions(options: t.MCPOptions): options is t.Streamable
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIVE_MINUTES = 5 * 60 * 1000;
|
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||||
|
const DEFAULT_TIMEOUT = 60000;
|
||||||
|
|
||||||
interface MCPConnectionParams {
|
interface MCPConnectionParams {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
|
@ -145,19 +146,22 @@ export class MCPConnection extends EventEmitter {
|
||||||
* This helps prevent memory leaks by only passing necessary dependencies.
|
* This helps prevent memory leaks by only passing necessary dependencies.
|
||||||
*
|
*
|
||||||
* @param getHeaders Function to retrieve request headers
|
* @param getHeaders Function to retrieve request headers
|
||||||
|
* @param timeout Timeout value for the agent (in milliseconds)
|
||||||
* @returns A fetch function that merges headers appropriately
|
* @returns A fetch function that merges headers appropriately
|
||||||
*/
|
*/
|
||||||
private createFetchFunction(
|
private createFetchFunction(
|
||||||
getHeaders: () => Record<string, string> | null | undefined,
|
getHeaders: () => Record<string, string> | null | undefined,
|
||||||
|
timeout?: number,
|
||||||
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
|
): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise<UndiciResponse> {
|
||||||
return function customFetch(
|
return function customFetch(
|
||||||
input: UndiciRequestInfo,
|
input: UndiciRequestInfo,
|
||||||
init?: UndiciRequestInit,
|
init?: UndiciRequestInit,
|
||||||
): Promise<UndiciResponse> {
|
): Promise<UndiciResponse> {
|
||||||
const requestHeaders = getHeaders();
|
const requestHeaders = getHeaders();
|
||||||
|
const effectiveTimeout = timeout || DEFAULT_TIMEOUT;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
bodyTimeout: 0,
|
bodyTimeout: effectiveTimeout,
|
||||||
headersTimeout: 0,
|
headersTimeout: effectiveTimeout,
|
||||||
});
|
});
|
||||||
if (!requestHeaders) {
|
if (!requestHeaders) {
|
||||||
return undiciFetch(input, { ...init, dispatcher: agent });
|
return undiciFetch(input, { ...init, dispatcher: agent });
|
||||||
|
@ -243,6 +247,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`;
|
headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timeoutValue = this.timeout || DEFAULT_TIMEOUT;
|
||||||
const transport = new SSEClientTransport(url, {
|
const transport = new SSEClientTransport(url, {
|
||||||
requestInit: {
|
requestInit: {
|
||||||
headers,
|
headers,
|
||||||
|
@ -252,8 +257,8 @@ export class MCPConnection extends EventEmitter {
|
||||||
fetch: (url, init) => {
|
fetch: (url, init) => {
|
||||||
const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers));
|
const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers));
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
bodyTimeout: 0,
|
bodyTimeout: timeoutValue,
|
||||||
headersTimeout: 0,
|
headersTimeout: timeoutValue,
|
||||||
});
|
});
|
||||||
return undiciFetch(url, {
|
return undiciFetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
@ -264,6 +269,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
},
|
},
|
||||||
fetch: this.createFetchFunction(
|
fetch: this.createFetchFunction(
|
||||||
this.getRequestHeaders.bind(this),
|
this.getRequestHeaders.bind(this),
|
||||||
|
this.timeout,
|
||||||
) as unknown as FetchLike,
|
) as unknown as FetchLike,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -304,6 +310,7 @@ export class MCPConnection extends EventEmitter {
|
||||||
},
|
},
|
||||||
fetch: this.createFetchFunction(
|
fetch: this.createFetchFunction(
|
||||||
this.getRequestHeaders.bind(this),
|
this.getRequestHeaders.bind(this),
|
||||||
|
this.timeout,
|
||||||
) as unknown as FetchLike,
|
) as unknown as FetchLike,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { Tools } from 'librechat-data-provider';
|
||||||
|
import type { UIResource } from 'librechat-data-provider';
|
||||||
import type * as t from './types';
|
import type * as t from './types';
|
||||||
|
|
||||||
const RECOGNIZED_PROVIDERS = new Set([
|
const RECOGNIZED_PROVIDERS = new Set([
|
||||||
'google',
|
'google',
|
||||||
'anthropic',
|
'anthropic',
|
||||||
|
@ -111,7 +114,7 @@ export function formatToolContent(
|
||||||
const formattedContent: t.FormattedContent[] = [];
|
const formattedContent: t.FormattedContent[] = [];
|
||||||
const imageUrls: t.FormattedContent[] = [];
|
const imageUrls: t.FormattedContent[] = [];
|
||||||
let currentTextBlock = '';
|
let currentTextBlock = '';
|
||||||
const uiResources: t.UIResource[] = [];
|
const uiResources: UIResource[] = [];
|
||||||
|
|
||||||
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
|
type ContentHandler = undefined | ((item: t.ToolContentPart) => void);
|
||||||
|
|
||||||
|
@ -144,8 +147,7 @@ export function formatToolContent(
|
||||||
|
|
||||||
resource: (item) => {
|
resource: (item) => {
|
||||||
if (item.resource.uri.startsWith('ui://')) {
|
if (item.resource.uri.startsWith('ui://')) {
|
||||||
uiResources.push(item.resource as t.UIResource);
|
uiResources.push(item.resource as UIResource);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceText = [];
|
const resourceText = [];
|
||||||
|
@ -182,18 +184,14 @@ export function formatToolContent(
|
||||||
formattedContent.push({ type: 'text', text: currentTextBlock });
|
formattedContent.push({ type: 'text', text: currentTextBlock });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiResources.length) {
|
let artifacts: t.Artifacts = undefined;
|
||||||
formattedContent.push({
|
if (imageUrls.length || uiResources.length) {
|
||||||
type: 'text',
|
artifacts = {
|
||||||
metadata: {
|
...(imageUrls.length && { content: imageUrls }),
|
||||||
type: 'ui_resources',
|
...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }),
|
||||||
data: uiResources,
|
};
|
||||||
},
|
|
||||||
text: '',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifacts = imageUrls.length ? { content: imageUrls } : undefined;
|
|
||||||
if (CONTENT_ARRAY_PROVIDERS.has(provider)) {
|
if (CONTENT_ARRAY_PROVIDERS.has(provider)) {
|
||||||
return [formattedContent, artifacts];
|
return [formattedContent, artifacts];
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,9 @@ import {
|
||||||
StdioOptionsSchema,
|
StdioOptionsSchema,
|
||||||
WebSocketOptionsSchema,
|
WebSocketOptionsSchema,
|
||||||
StreamableHTTPOptionsSchema,
|
StreamableHTTPOptionsSchema,
|
||||||
|
Tools,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { TPlugin, TUser } from 'librechat-data-provider';
|
import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider';
|
||||||
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { TokenMethods } from '@librechat/data-schemas';
|
import type { TokenMethods } from '@librechat/data-schemas';
|
||||||
import type { FlowStateManager } from '~/flow/manager';
|
import type { FlowStateManager } from '~/flow/manager';
|
||||||
|
@ -86,7 +87,7 @@ export type FormattedContent =
|
||||||
metadata?: {
|
metadata?: {
|
||||||
type: string;
|
type: string;
|
||||||
data: UIResource[];
|
data: UIResource[];
|
||||||
}
|
};
|
||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
@ -111,24 +112,39 @@ export type FormattedContent =
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormattedContentResult = [
|
export type FileSearchSource = {
|
||||||
string | FormattedContent[],
|
fileId: string;
|
||||||
undefined | { content: FormattedContent[] },
|
relevance: number;
|
||||||
];
|
fileName?: string;
|
||||||
|
metadata?: {
|
||||||
export type UIResource = {
|
storageType?: string;
|
||||||
uri: string;
|
[key: string]: unknown;
|
||||||
mimeType: string;
|
};
|
||||||
text: string;
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Artifacts =
|
||||||
|
| {
|
||||||
|
content?: FormattedContent[];
|
||||||
|
[Tools.ui_resources]?: {
|
||||||
|
data: UIResource[];
|
||||||
|
};
|
||||||
|
[Tools.file_search]?: {
|
||||||
|
sources: FileSearchSource[];
|
||||||
|
fileCitations?: boolean;
|
||||||
|
};
|
||||||
|
[Tools.web_search]?: SearchResultData;
|
||||||
|
files?: Array<{ id: string; name: string }>;
|
||||||
|
session_id?: string;
|
||||||
|
file_ids?: string[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export type FormattedContentResult = [string | FormattedContent[], undefined | Artifacts];
|
||||||
|
|
||||||
export type ImageFormatter = (item: ImageContent) => FormattedContent;
|
export type ImageFormatter = (item: ImageContent) => FormattedContent;
|
||||||
|
|
||||||
export type FormattedToolResponse = [
|
export type FormattedToolResponse = FormattedContentResult;
|
||||||
string | FormattedContent[],
|
|
||||||
{ content: FormattedContent[] } | undefined,
|
|
||||||
];
|
|
||||||
|
|
||||||
export type ParsedServerConfig = MCPOptions & {
|
export type ParsedServerConfig = MCPOptions & {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
|
@ -83,70 +83,75 @@ const processQueue = (error: AxiosError | null, token: string | null = null) =>
|
||||||
failedQueue = [];
|
failedQueue = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
axios.interceptors.response.use(
|
if (typeof window !== 'undefined') {
|
||||||
(response) => response,
|
axios.interceptors.response.use(
|
||||||
async (error) => {
|
(response) => response,
|
||||||
const originalRequest = error.config;
|
async (error) => {
|
||||||
if (!error.response) {
|
const originalRequest = error.config;
|
||||||
return Promise.reject(error);
|
if (!error.response) {
|
||||||
}
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (originalRequest.url?.includes('/api/auth/2fa') === true) {
|
if (originalRequest.url?.includes('/api/auth/2fa') === true) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
if (originalRequest.url?.includes('/api/auth/logout') === true) {
|
if (originalRequest.url?.includes('/api/auth/logout') === true) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
if (originalRequest.url?.includes('/api/auth/refresh') === true) {
|
||||||
|
// Refresh token itself failed - redirect to login
|
||||||
|
console.log('Refresh token request failed, redirecting to login...');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (error.response.status === 401 && !originalRequest._retry) {
|
if (error.response.status === 401 && !originalRequest._retry) {
|
||||||
console.warn('401 error, refreshing token');
|
console.warn('401 error, refreshing token');
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
try {
|
||||||
|
const token = await new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
});
|
||||||
|
originalRequest.headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
return await axios(originalRequest);
|
||||||
|
} catch (err) {
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
if (isRefreshing) {
|
|
||||||
try {
|
try {
|
||||||
const token = await new Promise((resolve, reject) => {
|
const response = await refreshToken();
|
||||||
failedQueue.push({ resolve, reject });
|
|
||||||
});
|
const token = response?.token ?? '';
|
||||||
originalRequest.headers['Authorization'] = 'Bearer ' + token;
|
|
||||||
return await axios(originalRequest);
|
if (token) {
|
||||||
|
originalRequest.headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
dispatchTokenUpdatedEvent(token);
|
||||||
|
processQueue(null, token);
|
||||||
|
return await axios(originalRequest);
|
||||||
|
} else if (window.location.href.includes('share/')) {
|
||||||
|
console.log(
|
||||||
|
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
processQueue(err as AxiosError, null);
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isRefreshing = true;
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
try {
|
);
|
||||||
const response = await refreshToken(
|
}
|
||||||
// Handle edge case where we get a blank screen if the initial 401 error is from a refresh token request
|
|
||||||
originalRequest.url?.includes('api/auth/refresh') === true ? true : false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const token = response?.token ?? '';
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
originalRequest.headers['Authorization'] = 'Bearer ' + token;
|
|
||||||
dispatchTokenUpdatedEvent(token);
|
|
||||||
processQueue(null, token);
|
|
||||||
return await axios(originalRequest);
|
|
||||||
} else if (window.location.href.includes('share/')) {
|
|
||||||
console.log(
|
|
||||||
`Refresh token failed from shared link, attempting request to ${originalRequest.url}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
processQueue(err as AxiosError, null);
|
|
||||||
return Promise.reject(err);
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
get: _get,
|
get: _get,
|
||||||
|
|
|
@ -552,20 +552,33 @@ export type MemoryArtifact = {
|
||||||
type: 'update' | 'delete' | 'error';
|
type: 'update' | 'delete' | 'error';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UIResource = {
|
||||||
|
type?: string;
|
||||||
|
data?: unknown;
|
||||||
|
uri?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type TAttachmentMetadata = {
|
export type TAttachmentMetadata = {
|
||||||
type?: Tools;
|
type?: Tools;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
|
[Tools.memory]?: MemoryArtifact;
|
||||||
|
[Tools.ui_resources]?: UIResource[];
|
||||||
[Tools.web_search]?: SearchResultData;
|
[Tools.web_search]?: SearchResultData;
|
||||||
[Tools.file_search]?: SearchResultData;
|
[Tools.file_search]?: SearchResultData;
|
||||||
[Tools.memory]?: MemoryArtifact;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAttachment =
|
export type TAttachment =
|
||||||
| (TFile & TAttachmentMetadata)
|
| (TFile & TAttachmentMetadata)
|
||||||
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
|
| (Pick<TFile, 'filename' | 'filepath' | 'conversationId'> & {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
} & TAttachmentMetadata);
|
} & TAttachmentMetadata)
|
||||||
|
| (Partial<Pick<TFile, 'filename' | 'filepath'>> &
|
||||||
|
Pick<TFile, 'conversationId'> &
|
||||||
|
TAttachmentMetadata);
|
||||||
|
|
||||||
export type TMessage = z.input<typeof tMessageSchema> & {
|
export type TMessage = z.input<typeof tMessageSchema> & {
|
||||||
children?: TMessage[];
|
children?: TMessage[];
|
||||||
|
|
|
@ -23,6 +23,7 @@ export enum Tools {
|
||||||
retrieval = 'retrieval',
|
retrieval = 'retrieval',
|
||||||
function = 'function',
|
function = 'function',
|
||||||
memory = 'memory',
|
memory = 'memory',
|
||||||
|
ui_resources = 'ui_resources',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum EToolResources {
|
export enum EToolResources {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue