diff --git a/Dockerfile b/Dockerfile index 65ee9b3bf..68f0b1cf5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.0-rc3 +# v0.8.0-rc4 # Base node image FROM node:20-alpine AS node @@ -30,7 +30,7 @@ RUN \ # Allow mounting of these files, which have no default touch .env ; \ # Create directories for the volumes to inherit the correct permissions - mkdir -p /app/client/public/images /app/api/logs ; \ + mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \ npm config set fetch-retry-maxtimeout 600000 ; \ npm config set fetch-retries 5 ; \ npm config set fetch-retry-mintimeout 15000 ; \ @@ -44,8 +44,6 @@ RUN \ npm prune --production; \ npm cache clean --force -RUN mkdir -p /app/client/public/images /app/api/logs - # Node API setup EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/Dockerfile.multi b/Dockerfile.multi index 64d0858bc..8013a3c65 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.0-rc3 +# v0.8.0-rc4 # Base for all builds FROM node:20-alpine AS base-min diff --git a/api/package.json b/api/package.json index c9a0435b4..da5331ba0 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -56,7 +56,7 @@ "@modelcontextprotocol/sdk": "^1.17.1", "@node-saml/passport-saml": "^5.1.0", "@waylaidwanderer/fetch-event-source": "^3.0.1", - "axios": "^1.8.2", + "axios": "^1.12.1", "bcryptjs": "^2.4.3", "compression": "^1.8.1", "connect-redis": "^8.1.0", diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js new file mode 100644 index 000000000..25f00bab8 --- /dev/null +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -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(); + }); + }); +}); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 6138964ba..d700f0a9c 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -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]) { artifactPromises.push( (async () => { diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 63912f7e0..b9369464e 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,3 +1,4 @@ +/** v0.8.0-rc4 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', @@ -28,7 +29,8 @@ module.exports = { 'jest-file-loader', '^test/(.*)$': '/test/$1', '^~/(.*)$': '/src/$1', - '^librechat-data-provider/react-query$': '/../node_modules/librechat-data-provider/src/react-query', + '^librechat-data-provider/react-query$': + '/../node_modules/librechat-data-provider/src/react-query', }, restoreMocks: true, testResultsProcessor: 'jest-junit', diff --git a/client/package.json b/client/package.json index d49f79245..ac79ab621 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "description": "", "type": "module", "scripts": { diff --git a/client/src/common/types.ts b/client/src/common/types.ts index b03a80f07..5ea6ad6bb 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -642,10 +642,3 @@ declare global { google_tag_manager?: unknown; } } - -export type UIResource = { - uri: string; - mimeType: string; - text: string; - [key: string]: unknown; -}; diff --git a/client/src/components/Agents/AgentGrid.tsx b/client/src/components/Agents/AgentGrid.tsx index 9f6442839..ab821eb87 100644 --- a/client/src/components/Agents/AgentGrid.tsx +++ b/client/src/components/Agents/AgentGrid.tsx @@ -13,7 +13,7 @@ interface AgentGridProps { category: string; // Currently selected category searchQuery: string; // Current search query onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected - scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll + scrollElementRef?: React.RefObject; // Parent scroll container ref for infinite scroll } /** @@ -23,7 +23,7 @@ const AgentGrid: React.FC = ({ category, searchQuery, onSelectAgent, - scrollElement, + scrollElementRef, }) => { const localize = useLocalize(); @@ -87,7 +87,7 @@ const AgentGrid: React.FC = ({ // Set up infinite scroll const { setScrollElement } = useInfiniteScroll({ hasNextPage, - isFetchingNextPage, + isLoading: isFetching || isFetchingNextPage, fetchNextPage: () => { if (hasNextPage && !isFetching) { fetchNextPage(); @@ -99,10 +99,11 @@ const AgentGrid: React.FC = ({ // Connect the scroll element when it's provided useEffect(() => { + const scrollElement = scrollElementRef?.current; if (scrollElement) { setScrollElement(scrollElement); } - }, [scrollElement, setScrollElement]); + }, [scrollElementRef, setScrollElement]); /** * Get category display name from API data or use fallback diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 83aa453da..97cf1b20c 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -197,21 +197,21 @@ const AgentMarketplace: React.FC = ({ className = '' }) = */ const handleSearch = (query: string) => { const newParams = new URLSearchParams(searchParams); + const currentCategory = displayCategory; + if (query.trim()) { newParams.set('q', query.trim()); - // Switch to "all" category when starting a new search - navigate(`/agents/all?${newParams.toString()}`); } else { newParams.delete('q'); - // Preserve current category when clearing search - const currentCategory = displayCategory; - if (currentCategory === 'promoted') { - navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`); - } else { - navigate( - `/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`, - ); - } + } + + // Always preserve current category when searching or clearing search + if (currentCategory === 'promoted') { + navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`); + } else { + navigate( + `/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`, + ); } }; @@ -427,7 +427,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = category={displayCategory} searchQuery={searchQuery} onSelectAgent={handleAgentSelect} - scrollElement={scrollContainerRef.current} + scrollElementRef={scrollContainerRef} /> @@ -507,7 +507,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = category={nextCategory} searchQuery={searchQuery} onSelectAgent={handleAgentSelect} - scrollElement={scrollContainerRef.current} + scrollElementRef={scrollContainerRef} /> )} diff --git a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx index 3b1444ac4..cad18ea80 100644 --- a/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx +++ b/client/src/components/Agents/tests/AgentGrid.integration.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; + import '@testing-library/jest-dom'; import AgentGrid from '../AgentGrid'; import type t from 'librechat-data-provider'; @@ -81,6 +82,115 @@ import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery); +// Helper to create mock API response +const createMockResponse = ( + agentIds: string[], + hasMore: boolean, + afterCursor?: string, +): t.AgentListResponse => ({ + object: 'list', + data: agentIds.map( + (id) => + ({ + id, + name: `Agent ${id}`, + description: `Description for ${id}`, + created_at: Date.now(), + model: 'gpt-4', + tools: [], + instructions: '', + avatar: null, + provider: 'openai', + model_parameters: { + temperature: 0.7, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + maxContextTokens: 2000, + max_context_tokens: 2000, + max_output_tokens: 2000, + }, + }) as t.Agent, + ), + first_id: agentIds[0] || '', + last_id: agentIds[agentIds.length - 1] || '', + has_more: hasMore, + after: afterCursor, +}); + +// Helper to setup mock viewport +const setupViewport = (scrollHeight: number, clientHeight: number) => { + const listeners: { [key: string]: EventListener[] } = {}; + return { + scrollHeight, + clientHeight, + scrollTop: 0, + addEventListener: jest.fn((event: string, listener: EventListener) => { + if (!listeners[event]) { + listeners[event] = []; + } + listeners[event].push(listener); + }), + removeEventListener: jest.fn((event: string, listener: EventListener) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter((l) => l !== listener); + } + }), + dispatchEvent: jest.fn((event: Event) => { + const eventListeners = listeners[event.type]; + if (eventListeners) { + eventListeners.forEach((listener) => listener(event)); + } + return true; + }), + } as unknown as HTMLElement; +}; + +// Helper to create mock infinite query return value +const createMockInfiniteQuery = ( + pages: t.AgentListResponse[], + options?: { + isLoading?: boolean; + hasNextPage?: boolean; + fetchNextPage?: jest.Mock; + isFetchingNextPage?: boolean; + }, +) => + ({ + data: { + pages, + pageParams: pages.map((_, i) => (i === 0 ? undefined : `cursor-${i * 6}`)), + }, + isLoading: options?.isLoading ?? false, + error: null, + isFetching: false, + hasNextPage: options?.hasNextPage ?? pages[pages.length - 1]?.has_more ?? false, + isFetchingNextPage: options?.isFetchingNextPage ?? false, + fetchNextPage: options?.fetchNextPage ?? jest.fn(), + refetch: jest.fn(), + // Add missing required properties for UseInfiniteQueryResult + isError: false, + isLoadingError: false, + isRefetchError: false, + isSuccess: true, + status: 'success' as const, + dataUpdatedAt: Date.now(), + errorUpdateCount: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + fetchStatus: 'idle' as const, + isFetched: true, + isFetchedAfterMount: true, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + isPending: false, + isRefetching: false, + isStale: false, + remove: jest.fn(), + }) as any; + describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { const mockOnSelectAgent = jest.fn(); @@ -343,6 +453,15 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { }); describe('Infinite Scroll Functionality', () => { + beforeEach(() => { + // Silence console.log in tests + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should show loading indicator when fetching next page', () => { mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({ ...defaultMockQueryResult, @@ -396,5 +515,358 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => { expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument(); }); + + describe('Auto-fetch to fill viewport', () => { + it('should NOT auto-fetch when viewport is filled (5 agents, has_more=false)', async () => { + const mockResponse = createMockResponse(['1', '2', '3', '4', '5'], false); + const fetchNextPage = jest.fn(); + + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue( + createMockInfiniteQuery([mockResponse], { fetchNextPage }), + ); + + const scrollElement = setupViewport(500, 1000); // Content smaller than viewport + const scrollElementRef = { current: scrollElement }; + const Wrapper = createWrapper(); + + render( + + + , + ); + + // Wait for initial render + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(5); + }); + + // Wait to ensure no auto-fetch happens + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + // fetchNextPage should NOT be called since has_more is false + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + it('should auto-fetch when viewport not filled (7 agents, big viewport)', async () => { + const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'); + const secondPage = createMockResponse(['7'], false); + let currentPages = [firstPage]; + const fetchNextPage = jest.fn(); + + // Mock that updates pages when fetchNextPage is called + mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() => + createMockInfiniteQuery(currentPages, { + fetchNextPage: jest.fn().mockImplementation(() => { + fetchNextPage(); + currentPages = [firstPage, secondPage]; + return Promise.resolve(); + }), + hasNextPage: true, + }), + ); + + const scrollElement = setupViewport(400, 1200); // Large viewport (content < viewport) + const scrollElementRef = { current: scrollElement }; + const Wrapper = createWrapper(); + + const { rerender } = render( + + + , + ); + + // Wait for initial 6 agents + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(6); + }); + + // Wait for ResizeObserver and auto-fetch to trigger + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + // Auto-fetch should have been triggered (multiple times due to reliability checks) + expect(fetchNextPage).toHaveBeenCalled(); + expect(fetchNextPage.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Update mock data and re-render + currentPages = [firstPage, secondPage]; + rerender( + + + , + ); + + // Should now show all 7 agents + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(7); + }); + }); + + it('should NOT auto-fetch when viewport is filled (7 agents, small viewport)', async () => { + const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'); + const fetchNextPage = jest.fn(); + + mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue( + createMockInfiniteQuery([firstPage], { fetchNextPage, hasNextPage: true }), + ); + + const scrollElement = setupViewport(1200, 600); // Small viewport, content fills it + const scrollElementRef = { current: scrollElement }; + const Wrapper = createWrapper(); + + render( + + + , + ); + + // Wait for initial 6 agents + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(6); + }); + + // Wait to ensure no auto-fetch happens + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + // Should NOT auto-fetch since viewport is filled + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + it('should auto-fetch once to fill viewport then stop (20 agents)', async () => { + const allPages = [ + createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'), + createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12'), + createMockResponse(['13', '14', '15', '16', '17', '18'], true, 'cursor-18'), + createMockResponse(['19', '20'], false), + ]; + + let currentPages = [allPages[0]]; + let fetchCount = 0; + const fetchNextPage = jest.fn(); + + mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() => + createMockInfiniteQuery(currentPages, { + fetchNextPage: jest.fn().mockImplementation(() => { + fetchCount++; + fetchNextPage(); + if (currentPages.length < 2) { + currentPages = allPages.slice(0, 2); + } + return Promise.resolve(); + }), + hasNextPage: currentPages.length < 2, + }), + ); + + const scrollElement = setupViewport(600, 1000); // Viewport fits ~12 agents + const scrollElementRef = { current: scrollElement }; + const Wrapper = createWrapper(); + + const { rerender } = render( + + + , + ); + + // Wait for initial 6 agents + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(6); + }); + + // Should auto-fetch to fill viewport + await waitFor( + () => { + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }, + { timeout: 500 }, + ); + + // Simulate viewport being filled after 12 agents + Object.defineProperty(scrollElement, 'scrollHeight', { + value: 1200, + writable: true, + configurable: true, + }); + + currentPages = allPages.slice(0, 2); + rerender( + + + , + ); + + // Should show 12 agents + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(12); + }); + + // Wait to ensure no additional auto-fetch + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + // Should only have fetched once (to fill viewport) + expect(fetchCount).toBe(1); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('should auto-fetch when viewport resizes to be taller (window resize)', async () => { + const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'); + const secondPage = createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12'); + let currentPages = [firstPage]; + const fetchNextPage = jest.fn(); + let resizeObserverCallback: ResizeObserverCallback | null = null; + + // Mock that updates pages when fetchNextPage is called + mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() => + createMockInfiniteQuery(currentPages, { + fetchNextPage: jest.fn().mockImplementation(() => { + fetchNextPage(); + if (currentPages.length === 1) { + currentPages = [firstPage, secondPage]; + } + return Promise.resolve(); + }), + hasNextPage: currentPages.length === 1, + }), + ); + + // Mock ResizeObserver to capture the callback + const ResizeObserverMock = jest.fn().mockImplementation((callback) => { + resizeObserverCallback = callback; + return { + observe: jest.fn(), + disconnect: jest.fn(), + unobserve: jest.fn(), + }; + }); + global.ResizeObserver = ResizeObserverMock as any; + + // Start with a small viewport that fits the content + const scrollElement = setupViewport(800, 600); + const scrollElementRef = { current: scrollElement }; + const Wrapper = createWrapper(); + + const { rerender } = render( + + + , + ); + + // Wait for initial 6 agents + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(6); + }); + + // Verify ResizeObserver was set up + expect(ResizeObserverMock).toHaveBeenCalled(); + expect(resizeObserverCallback).not.toBeNull(); + + // Initially no fetch should happen as viewport is filled + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + expect(fetchNextPage).not.toHaveBeenCalled(); + + // Simulate window resize - make viewport taller + Object.defineProperty(scrollElement, 'clientHeight', { + value: 1200, // Now taller than content + writable: true, + configurable: true, + }); + + // Trigger ResizeObserver callback to simulate resize detection + act(() => { + if (resizeObserverCallback) { + resizeObserverCallback( + [ + { + target: scrollElement, + contentRect: { + x: 0, + y: 0, + width: 800, + height: 1200, + top: 0, + right: 800, + bottom: 1200, + left: 0, + } as DOMRectReadOnly, + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + } as ResizeObserverEntry, + ], + {} as ResizeObserver, + ); + } + }); + + // Should trigger auto-fetch due to viewport now being larger than content + await waitFor( + () => { + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }, + { timeout: 500 }, + ); + + // Update the component with new data + rerender( + + + , + ); + + // Should now show 12 agents after fetching + await waitFor(() => { + expect(screen.getAllByRole('gridcell')).toHaveLength(12); + }); + }); + }); }); }); diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 7943dd6f1..4af8dd1a2 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -211,6 +211,7 @@ export default function ToolCall({ domain={authDomain || (domain ?? '')} function_name={function_name} pendingAuth={authDomain.length > 0 && !cancelled && progress < 1} + attachments={attachments} /> )} diff --git a/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx b/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx index 1cc472f83..7d9a941d1 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallInfo.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { useLocalize } from '~/hooks'; +import { Tools } from 'librechat-data-provider'; import { UIResourceRenderer } from '@mcp-ui/client'; 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 }) { return ( @@ -27,12 +28,14 @@ export default function ToolCallInfo({ domain, function_name, pendingAuth, + attachments, }: { input: string; function_name: string; output?: string | null; domain?: string; pendingAuth?: boolean; + attachments?: TAttachment[]; }) { const localize = useLocalize(); const formatText = (text: string) => { @@ -54,25 +57,12 @@ export default function ToolCallInfo({ : localize('com_assistants_attempt_info'); } - // Extract ui_resources from the output to display them in the UI - let uiResources: UIResource[] = []; - if (output?.includes('ui_resources')) { - try { - const parsedOutput = JSON.parse(output); - 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); - } - } + const uiResources: UIResource[] = + attachments + ?.filter((attachment) => attachment.type === Tools.ui_resources) + .flatMap((attachment) => { + return attachment[Tools.ui_resources] as UIResource[]; + }) ?? []; return (
diff --git a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx index 4fdb7a38a..dfffa0893 100644 --- a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx +++ b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx @@ -1,6 +1,6 @@ -import { UIResourceRenderer } from '@mcp-ui/client'; -import type { UIResource } from '~/common'; import React, { useState } from 'react'; +import { UIResourceRenderer } from '@mcp-ui/client'; +import type { UIResource } from 'librechat-data-provider'; interface UIResourceCarouselProps { uiResources: UIResource[]; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx new file mode 100644 index 000000000..d1f4f8921 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx @@ -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 = { + 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 }) =>
{content}
, +})); + +jest.mock('../ToolCallInfo', () => ({ + __esModule: true, + default: ({ attachments, ...props }: any) => ( +
+ {JSON.stringify(props)} +
+ ), +})); + +jest.mock('../ProgressText', () => ({ + __esModule: true, + default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => ( +
{finishedText || inProgressText}
+ ), +})); + +jest.mock('../Parts', () => ({ + AttachmentGroup: ({ attachments }: any) => ( +
{JSON.stringify(attachments)}
+ ), +})); + +jest.mock('~/components/ui', () => ({ + Button: ({ children, onClick, ...props }: any) => ( + + ), +})); + +jest.mock('lucide-react', () => ({ + ChevronDown: () => {'ChevronDown'}, + ChevronUp: () => {'ChevronUp'}, + TriangleAlert: () => {'TriangleAlert'}, +})); + +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({component}); + }; + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument(); + }); + + it('should not render AttachmentGroup when attachments is empty array', () => { + renderWithRecoil(); + + expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument(); + }); + }); + + describe('tool call info visibility', () => { + it('should toggle tool call info when clicking header', () => { + renderWithRecoil(); + + // 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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument(); + }); + + it('should not show auth section when progress is complete', () => { + renderWithRecoil( + , + ); + + expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('should handle undefined args', () => { + renderWithRecoil(); + + 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(); + + 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(); + + 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(); + + 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); + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx index d612fb8e9..3e74504fc 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallInfo.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import ToolCallInfo from '../ToolCallInfo'; +import { Tools } from 'librechat-data-provider'; 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 jest.mock('~/hooks', () => ({ @@ -46,24 +48,25 @@ describe('ToolCallInfo', () => { jest.clearAllMocks(); }); - describe('ui_resources extraction', () => { - it('should extract single ui_resource from output', () => { + describe('ui_resources from attachments', () => { + it('should render single ui_resource from attachments', () => { const uiResource = { type: 'text', data: 'Test resource', }; - const output = JSON.stringify([ - { type: 'text', text: 'Regular output' }, + const attachments: TAttachment[] = [ { - metadata: { - type: 'ui_resources', - data: [uiResource], - }, + type: Tools.ui_resources, + messageId: 'msg123', + toolCallId: 'tool456', + conversationId: 'conv789', + [Tools.ui_resources]: [uiResource], }, - ]); + ]; - render(); + // Need output for ui_resources to render + render(); // Should render UIResourceRenderer for single resource expect(UIResourceRenderer).toHaveBeenCalledWith( @@ -81,29 +84,33 @@ describe('ToolCallInfo', () => { expect(UIResourceCarousel).not.toHaveBeenCalled(); }); - it('should extract multiple ui_resources from output', () => { - const uiResources = [ - { type: 'text', data: 'Resource 1' }, - { type: 'text', data: 'Resource 2' }, - { type: 'text', data: 'Resource 3' }, + it('should render carousel for multiple ui_resources from attachments', () => { + // To test multiple resources, we can use a single attachment with multiple resources + const attachments: TAttachment[] = [ + { + 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([ - { type: 'text', text: 'Regular output' }, - { - metadata: { - type: 'ui_resources', - data: uiResources, - }, - }, - ]); - - render(); + // Need output for ui_resources to render + render(); // Should render carousel for multiple resources expect(UIResourceCarousel).toHaveBeenCalledWith( expect.objectContaining({ - uiResources, + uiResources: [ + { type: 'text', data: 'Resource 1' }, + { type: 'text', data: 'Resource 2' }, + { type: 'text', data: 'Resource 3' }, + ], }), expect.any(Object), ); @@ -112,34 +119,38 @@ describe('ToolCallInfo', () => { expect(UIResourceRenderer).not.toHaveBeenCalled(); }); - it('should filter out ui_resources from displayed output', () => { - const regularContent = [ - { type: 'text', text: 'Regular output 1' }, - { type: 'text', text: 'Regular output 2' }, + it('should handle attachments with normal output', () => { + const attachments: TAttachment[] = [ + { + type: Tools.ui_resources, + messageId: 'msg123', + toolCallId: 'tool456', + conversationId: 'conv789', + [Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }], + }, ]; const output = JSON.stringify([ - ...regularContent, - { - metadata: { - type: 'ui_resources', - data: [{ type: 'text', data: 'UI Resource' }], - }, - }, + { type: 'text', text: 'Regular output 1' }, + { type: 'text', text: 'Regular output 2' }, ]); - const { container } = render(); + const { container } = render( + , + ); - // Check that the displayed output doesn't contain ui_resources + // Check that the output is displayed normally const codeBlocks = container.querySelectorAll('code'); const outputCode = codeBlocks[1]?.textContent; // Second code block is the output expect(outputCode).toContain('Regular output 1'); 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' }]); render(); @@ -148,66 +159,56 @@ describe('ToolCallInfo', () => { expect(UIResourceCarousel).not.toHaveBeenCalled(); }); - it('should handle malformed ui_resources gracefully', () => { - const output = JSON.stringify([ - { - metadata: 'ui_resources', // metadata should be an object, not a string - text: 'some text content', - }, - ]); + it('should handle empty attachments array', () => { + const attachments: TAttachment[] = []; - // Component should not throw error and should render without UI resources - const { container } = render(); + render(); - // Should render the component without crashing - expect(container).toBeTruthy(); - - // UIResourceCarousel should not be called since the metadata structure is invalid + expect(UIResourceRenderer).not.toHaveBeenCalled(); expect(UIResourceCarousel).not.toHaveBeenCalled(); }); - it('should handle ui_resources as plain text without breaking', () => { - const outputWithTextOnly = - 'This output contains ui_resources as plain text but not as a proper structure'; + it('should handle attachments with non-ui_resources type', () => { + const attachments: TAttachment[] = [ + { + type: Tools.web_search as any, + messageId: 'msg123', + toolCallId: 'tool456', + conversationId: 'conv789', + [Tools.web_search]: { + organic: [], + }, + }, + ]; - render(); + render(); - // Should render normally without errors - 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 + // Should not render UI resources components for non-ui_resources attachments expect(UIResourceRenderer).not.toHaveBeenCalled(); expect(UIResourceCarousel).not.toHaveBeenCalled(); }); }); describe('rendering logic', () => { - it('should render UI Resources heading when ui_resources exist', () => { - const output = JSON.stringify([ + it('should render UI Resources heading when ui_resources exist in attachments', () => { + const attachments: TAttachment[] = [ { - metadata: { - type: 'ui_resources', - data: [{ type: 'text', data: 'Test' }], - }, + type: Tools.ui_resources, + messageId: 'msg123', + toolCallId: 'tool456', + conversationId: 'conv789', + [Tools.ui_resources]: [{ type: 'text', data: 'Test' }], }, - ]); + ]; - render(); + // Need output for ui_resources section to render + render(); expect(screen.getByText('UI Resources')).toBeInTheDocument(); }); - it('should not render UI Resources heading when no ui_resources', () => { - const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]); - - render(); + it('should not render UI Resources heading when no ui_resources in attachments', () => { + render(); expect(screen.queryByText('UI Resources')).not.toBeInTheDocument(); }); @@ -218,16 +219,18 @@ describe('ToolCallInfo', () => { data: { fields: [{ name: 'test', type: 'text' }] }, }; - const output = JSON.stringify([ + const attachments: TAttachment[] = [ { - metadata: { - type: 'ui_resources', - data: [uiResource], - }, + type: Tools.ui_resources, + messageId: 'msg123', + toolCallId: 'tool456', + conversationId: 'conv789', + [Tools.ui_resources]: [uiResource], }, - ]); + ]; - render(); + // Need output for ui_resources to render + render(); expect(UIResourceRenderer).toHaveBeenCalledWith( expect.objectContaining({ @@ -244,16 +247,18 @@ describe('ToolCallInfo', () => { it('should console.log when UIAction is triggered', async () => { const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - const output = JSON.stringify([ + const attachments: TAttachment[] = [ { - metadata: { - type: 'ui_resources', - data: [{ type: 'text', data: 'Test' }], - }, + type: Tools.ui_resources, + messageId: 'msg123', + toolCallId: 'tool456', + conversationId: 'conv789', + [Tools.ui_resources]: [{ type: 'text', data: 'Test' }], }, - ]); + ]; - render(); + // Need output for ui_resources to render + render(); const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction< typeof UIResourceRenderer @@ -270,4 +275,55 @@ describe('ToolCallInfo', () => { 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(); + + // 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(); + + // Should use attachments, not output + expect(UIResourceRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + resource: { type: 'attachment', data: 'From attachments' }, + }), + expect.any(Object), + ); + }); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx index faeddd1e0..2878c6444 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import UIResourceCarousel from '../UIResourceCarousel'; -import type { UIResource } from '~/common'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import type { UIResource } from 'librechat-data-provider'; +import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel'; // Mock the UIResourceRenderer component jest.mock('@mcp-ui/client', () => ({ diff --git a/client/src/components/SidePanel/Agents/MCPTool.tsx b/client/src/components/SidePanel/Agents/MCPTool.tsx index c6bfd2b09..e36efc443 100644 --- a/client/src/components/SidePanel/Agents/MCPTool.tsx +++ b/client/src/components/SidePanel/Agents/MCPTool.tsx @@ -4,7 +4,6 @@ import { ChevronDown } from 'lucide-react'; import { useFormContext } from 'react-hook-form'; import { Constants } from 'librechat-data-provider'; import * as AccordionPrimitive from '@radix-ui/react-accordion'; -import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import { Label, Checkbox, @@ -14,20 +13,18 @@ import { AccordionItem, CircleHelpIcon, OGDialogTrigger, - useToastContext, AccordionContent, OGDialogTemplate, } from '@librechat/client'; import type { AgentForm, MCPServerInfo } from '~/common'; +import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; -import { useLocalize, useMCPServerManager } from '~/hooks'; import { cn } from '~/utils'; export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) { const localize = useLocalize(); - const { showToast } = useToastContext(); - const updateUserPlugins = useUpdateUserPluginsMutation(); + const { removeTool } = useRemoveMCPTool(); const { getValues, setValue } = useFormContext(); const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); @@ -56,36 +53,6 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) setValue('tools', [...otherTools, ...newSelectedTools]); }; - const removeTool = (serverName: string) => { - if (!serverName) { - return; - } - updateUserPlugins.mutate( - { - pluginKey: `${Constants.mcp_prefix}${serverName}`, - action: 'uninstall', - auth: {}, - isEntityTool: true, - }, - { - onError: (error: unknown) => { - showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' }); - }, - onSuccess: () => { - const currentTools = getValues('tools'); - const remainingToolIds = - currentTools?.filter( - (currentToolId) => - currentToolId !== serverName && - !currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`), - ) || []; - setValue('tools', remainingToolIds); - showToast({ message: 'Tool deleted successfully', status: 'success' }); - }, - }, - ); - }; - const selectedTools = getSelectedTools(); const isExpanded = accordionValue === currentServerName; diff --git a/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx b/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx index 037724317..6d1881477 100644 --- a/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx +++ b/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx @@ -1,25 +1,12 @@ import React, { useState } from 'react'; import { CircleX } from 'lucide-react'; -import { useFormContext } from 'react-hook-form'; -import { Constants } from 'librechat-data-provider'; -import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; -import { - Label, - OGDialog, - TrashIcon, - useToastContext, - OGDialogTrigger, - OGDialogTemplate, -} from '@librechat/client'; -import type { AgentForm } from '~/common'; -import { useLocalize } from '~/hooks'; +import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client'; +import { useLocalize, useRemoveMCPTool } from '~/hooks'; import { cn } from '~/utils'; export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) { const localize = useLocalize(); - const { showToast } = useToastContext(); - const updateUserPlugins = useUpdateUserPluginsMutation(); - const { getValues, setValue } = useFormContext(); + const { removeTool } = useRemoveMCPTool(); const [isFocused, setIsFocused] = useState(false); const [isHovering, setIsHovering] = useState(false); @@ -28,36 +15,6 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin return null; } - const removeTool = () => { - updateUserPlugins.mutate( - { - pluginKey: `${Constants.mcp_prefix}${serverName}`, - action: 'uninstall', - auth: {}, - isEntityTool: true, - }, - { - onError: (error: unknown) => { - showToast({ - message: localize('com_ui_delete_tool_error', { error: String(error) }), - status: 'error', - }); - }, - onSuccess: () => { - const currentTools = getValues('tools'); - const remainingToolIds = - currentTools?.filter( - (currentToolId) => - currentToolId !== serverName && - !currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`), - ) || []; - setValue('tools', remainingToolIds); - showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' }); - }, - }, - ); - }; - return (
} selection={{ - selectHandler: () => removeTool(), + selectHandler: () => removeTool(serverName || ''), selectClasses: 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white', selectText: localize('com_ui_delete'), diff --git a/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx b/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx index 336b56f3d..24d45d468 100644 --- a/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx +++ b/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx @@ -1,29 +1,18 @@ import React, { useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { Constants } from 'librechat-data-provider'; -import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; -import { - Label, - OGDialog, - TrashIcon, - OGDialogTrigger, - useToastContext, - OGDialogTemplate, -} from '@librechat/client'; -import type { AgentForm, MCPServerInfo } from '~/common'; +import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client'; +import type { MCPServerInfo } from '~/common'; +import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; -import { useLocalize, useMCPServerManager } from '~/hooks'; import { cn } from '~/utils'; export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) { + const localize = useLocalize(); + const { removeTool } = useRemoveMCPTool(); + const [isFocused, setIsFocused] = useState(false); const [isHovering, setIsHovering] = useState(false); - const localize = useLocalize(); - const { showToast } = useToastContext(); - const updateUserPlugins = useUpdateUserPluginsMutation(); - const { getValues, setValue } = useFormContext(); const { initializeServer, isInitializing, getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); @@ -31,39 +20,6 @@ export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPS return null; } - const removeTool = (serverName: string) => { - if (!serverName) { - return; - } - updateUserPlugins.mutate( - { - pluginKey: `${Constants.mcp_prefix}${serverName}`, - action: 'uninstall', - auth: {}, - isEntityTool: true, - }, - { - onError: (error: unknown) => { - showToast({ - message: localize('com_ui_delete_tool_error', { error: String(error) }), - status: 'error', - }); - }, - onSuccess: () => { - const currentTools = getValues('tools'); - const remainingToolIds = - currentTools?.filter( - (currentToolId) => - currentToolId !== serverName && - !currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`), - ) || []; - setValue('tools', remainingToolIds); - showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' }); - }, - }, - ); - }; - const serverName = serverInfo.serverName; const isServerInitializing = isInitializing(serverName); const statusIconProps = getServerStatusIconProps(serverName); diff --git a/client/src/hooks/MCP/index.ts b/client/src/hooks/MCP/index.ts index 761f61b26..fa2a7d7fb 100644 --- a/client/src/hooks/MCP/index.ts +++ b/client/src/hooks/MCP/index.ts @@ -3,3 +3,4 @@ export * from './useMCPConnectionStatus'; export * from './useMCPSelect'; export * from './useVisibleTools'; export { useMCPServerManager } from './useMCPServerManager'; +export { useRemoveMCPTool } from './useRemoveMCPTool'; diff --git a/client/src/hooks/MCP/useRemoveMCPTool.ts b/client/src/hooks/MCP/useRemoveMCPTool.ts new file mode 100644 index 000000000..af14f5c08 --- /dev/null +++ b/client/src/hooks/MCP/useRemoveMCPTool.ts @@ -0,0 +1,61 @@ +import { useCallback } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { Constants } from 'librechat-data-provider'; +import { useToastContext } from '@librechat/client'; +import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; +import type { AgentForm } from '~/common'; +import { useLocalize } from '~/hooks'; + +/** + * Hook for removing MCP tools/servers from an agent + * Provides unified logic for MCPTool, UninitializedMCPTool, and UnconfiguredMCPTool components + */ +export function useRemoveMCPTool() { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const updateUserPlugins = useUpdateUserPluginsMutation(); + const { getValues, setValue } = useFormContext(); + + const removeTool = useCallback( + (serverName: string) => { + if (!serverName) { + return; + } + + updateUserPlugins.mutate( + { + pluginKey: `${Constants.mcp_prefix}${serverName}`, + action: 'uninstall', + auth: {}, + isEntityTool: true, + }, + { + onError: (error: unknown) => { + showToast({ + message: localize('com_ui_delete_tool_error', { error: String(error) }), + status: 'error', + }); + }, + onSuccess: () => { + const currentTools = getValues('tools'); + const remainingToolIds = + currentTools?.filter( + (currentToolId) => + currentToolId !== serverName && + !currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`), + ) || []; + setValue('tools', remainingToolIds, { shouldDirty: true }); + + showToast({ + message: localize('com_ui_delete_tool_save_reminder'), + status: 'warning', + }); + }, + }, + ); + }, + [getValues, setValue, updateUserPlugins, showToast, localize], + ); + + return { removeTool }; +} diff --git a/client/src/hooks/useInfiniteScroll.ts b/client/src/hooks/useInfiniteScroll.ts index 4c1ac8dae..9c35d6d18 100644 --- a/client/src/hooks/useInfiniteScroll.ts +++ b/client/src/hooks/useInfiniteScroll.ts @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { throttle } from 'lodash'; interface UseInfiniteScrollOptions { hasNextPage?: boolean; - isFetchingNextPage?: boolean; + isLoading?: boolean; fetchNextPage: () => void; threshold?: number; // Percentage of scroll position to trigger fetch (0-1) throttleMs?: number; // Throttle delay in milliseconds @@ -15,77 +15,95 @@ interface UseInfiniteScrollOptions { */ export const useInfiniteScroll = ({ hasNextPage = false, - isFetchingNextPage = false, + isLoading = false, fetchNextPage, threshold = 0.8, // Trigger when 80% scrolled throttleMs = 200, }: UseInfiniteScrollOptions) => { - const scrollElementRef = useRef(null); + // Monitor resizing of the scroll container + const resizeObserverRef = useRef(null); + const [scrollElement, setScrollElementState] = useState(null); - // Throttled scroll handler to prevent excessive API calls - const handleScroll = useCallback( - throttle(() => { - const element = scrollElementRef.current; - if (!element) return; + // Handler to check if we need to fetch more data + const handleNeedToFetch = useCallback(() => { + if (!scrollElement) return; - const { scrollTop, scrollHeight, clientHeight } = element; + const { scrollTop, scrollHeight, clientHeight } = scrollElement; - // Calculate scroll position as percentage - const scrollPosition = (scrollTop + clientHeight) / scrollHeight; + // Calculate scroll position as percentage + const scrollPosition = (scrollTop + clientHeight) / scrollHeight; - // Check if we've scrolled past the threshold and conditions are met - const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage; + // Check if we've scrolled past the threshold and conditions are met + const shouldFetch = scrollPosition >= threshold && hasNextPage && !isLoading; - if (shouldFetch) { - fetchNextPage(); - } - }, throttleMs), - [hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs], + if (shouldFetch) { + fetchNextPage(); + } + }, [scrollElement, hasNextPage, isLoading, fetchNextPage, threshold]); + + // Create a throttled version - using useMemo to ensure it's created synchronously + const throttledHandleNeedToFetch = useMemo( + () => throttle(handleNeedToFetch, throttleMs), + [handleNeedToFetch, throttleMs], ); - // Set up scroll listener + // Clean up throttled function on unmount useEffect(() => { - const element = scrollElementRef.current; + return () => { + throttledHandleNeedToFetch.cancel?.(); + }; + }, [throttledHandleNeedToFetch]); + + // Check if we need to fetch more data when loading state changes (useful to fill content on first load) + useEffect(() => { + if (isLoading === false && scrollElement) { + // Use requestAnimationFrame to ensure DOM is ready after loading completes + const rafId = requestAnimationFrame(() => { + throttledHandleNeedToFetch(); + }); + return () => cancelAnimationFrame(rafId); + } + }, [isLoading, scrollElement, throttledHandleNeedToFetch]); + + // Set up scroll listener and ResizeObserver + useEffect(() => { + const element = scrollElement; if (!element) return; - // Remove any existing listener first - element.removeEventListener('scroll', handleScroll); + // Add the scroll listener + element.addEventListener('scroll', throttledHandleNeedToFetch, { passive: true }); - // Add the new listener - element.addEventListener('scroll', handleScroll, { passive: true }); + // Set up ResizeObserver to detect size changes + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + } + + resizeObserverRef.current = new ResizeObserver(() => { + // Check if we need to fetch more data when container resizes + throttledHandleNeedToFetch(); + }); + + resizeObserverRef.current.observe(element); + + // Check immediately when element changes + throttledHandleNeedToFetch(); return () => { - element.removeEventListener('scroll', handleScroll); - // Clean up throttled function - handleScroll.cancel?.(); + element.removeEventListener('scroll', throttledHandleNeedToFetch); + // Clean up ResizeObserver + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } }; - }, [handleScroll]); - - // Additional effect to re-setup listeners when scroll element changes - useEffect(() => { - const element = scrollElementRef.current; - if (!element) return; - // Remove any existing listener first - element.removeEventListener('scroll', handleScroll); - - // Add the new listener - element.addEventListener('scroll', handleScroll, { passive: true }); - return () => { - element.removeEventListener('scroll', handleScroll); - // Clean up throttled function - handleScroll.cancel?.(); - }; - }, [scrollElementRef.current, handleScroll]); + }, [scrollElement, throttledHandleNeedToFetch]); // Function to manually set the scroll container const setScrollElement = useCallback((element: HTMLElement | null) => { - scrollElementRef.current = element; + setScrollElementState(element); }, []); return { setScrollElement, - scrollElementRef, }; }; - -export default useInfiniteScroll; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 8b8216638..faccb8dd1 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -834,7 +834,7 @@ "com_ui_delete_tool": "Delete Tool", "com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?", "com_ui_delete_tool_error": "Error while deleting the tool: {{error}}", - "com_ui_delete_tool_success": "Tool deleted successfully", + "com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.", "com_ui_deleted": "Deleted", "com_ui_deleting_file": "Deleting file...", "com_ui_descending": "Desc", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 6e23a36af..f39b20919 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -35,7 +35,7 @@ "com_agents_created_by": "izveidojis", "com_agents_description_placeholder": "Pēc izvēles: aprakstiet savu aģentu šeit", "com_agents_empty_state_heading": "Nav atrasts neviens aģents", - "com_agents_enable_file_search": "Iespējot failu meklēšanu", + "com_agents_enable_file_search": "Iespējot faila augšupielādi informācijas iegūšanai no tā", "com_agents_error_bad_request_message": "Pieprasījumu nevarēja apstrādāt.", "com_agents_error_bad_request_suggestion": "Lūdzu, pārbaudiet ievadītos datus un mēģiniet vēlreiz.", "com_agents_error_category_title": "Kategorija Kļūda", @@ -62,7 +62,7 @@ "com_agents_file_context": "Failu konteksts (OCR)", "com_agents_file_context_disabled": "Pirms failu augšupielādes failu kontekstam ir jāizveido aģents.", "com_agents_file_context_info": "Faili, kas augšupielādēti kā “Konteksts”, tiek apstrādāti, izmantojot OCR, lai iegūtu tekstu, kas pēc tam tiek pievienots aģenta norādījumiem. Ideāli piemērots dokumentiem, attēliem ar tekstu vai PDF failiem, kuriem nepieciešams pilns faila teksta saturs.", - "com_agents_file_search_disabled": "Pirms failu augšupielādes failu meklēšanai ir jāizveido aģents.", + "com_agents_file_search_disabled": "Lai varētu iespējot faila augšupielādi informācijas iegūšanai no tā ir jāizveido aģents.", "com_agents_file_search_info": "Kad šī opcija ir iespējota, aģents tiks informēts par precīziem tālāk norādītajiem failu nosaukumiem, ļaujot tam izgūt atbilstošu kontekstu no šiem failiem.", "com_agents_grid_announcement": "Rādu {{count}} aģentus {{category}} kategorijā", "com_agents_instructions_placeholder": "Sistēmas instrukcijas, ko izmantos aģents", @@ -124,17 +124,17 @@ "com_assistants_delete_actions_success": "Darbība veiksmīgi dzēsta no asistenta", "com_assistants_description_placeholder": "Pēc izvēles: Šeit aprakstiet savu asistentu", "com_assistants_domain_info": "Asistents nosūtīja šo informāciju {{0}}", - "com_assistants_file_search": "Failu meklēšana", - "com_assistants_file_search_info": "Failu meklēšana ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.", + "com_assistants_file_search": "Faila augšupielāde informācijas iegūšanai no tā", + "com_assistants_file_search_info": "Faila augšupielāde informācijas iegūšanai no tā ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.", "com_assistants_function_use": "Izmantotais asistents {{0}}", "com_assistants_image_vision": "Attēla redzējums", "com_assistants_instructions_placeholder": "Sistēmas norādījumi, ko izmanto asistents", "com_assistants_knowledge": "Zināšanas", - "com_assistants_knowledge_disabled": "Pirms failu augšupielādes kā Zināšanas ir jāizveido asistents un jāaktivizē un jāsaglabā koda interpretētājs vai failu atgūšana.", + "com_assistants_knowledge_disabled": "Pirms failu var augšupielādēt zināšanu krātuvē ir jāizveido asistents un jāaktivizē koda interpretētājs vai failu atgūšana.", "com_assistants_knowledge_info": "Ja augšupielādējat failus sadaļā Zināšanas, sarunās ar asistentu var tikt iekļauts faila saturs.", "com_assistants_max_starters_reached": "Sasniegts maksimālais sarunu uzsākšanas iespēju skaits", "com_assistants_name_placeholder": "Pēc izvēles: Asistenta nosaukums", - "com_assistants_non_retrieval_model": "Šajā modelī failu meklēšana nav iespējota. Lūdzu, izvēlieties citu modeli.", + "com_assistants_non_retrieval_model": "Šajā modelī faila augšupielāde informācijas iegūšana no tā nav iespējota. Lūdzu, izvēlieties citu modeli.", "com_assistants_retrieval": "Atgūšana", "com_assistants_running_action": "Darbība palaista", "com_assistants_running_var": "Strādā {{0}}", @@ -143,7 +143,7 @@ "com_assistants_update_actions_success": "Veiksmīgi izveidota vai atjaunināta darbība", "com_assistants_update_error": "Jūsu asistenta atjaunināšanā notika kļūda.", "com_assistants_update_success": "Veiksmīgi atjaunināts", - "com_auth_already_have_account": "Jau ir konts?", + "com_auth_already_have_account": "Vai jums jau ir konts?", "com_auth_apple_login": "Pierakstīties, izmantojot Apple", "com_auth_back_to_login": "Atgriezties pie pieteikšanās", "com_auth_click": "Noklikšķiniet uz", @@ -650,7 +650,7 @@ "com_ui_agent_category_rd": "Pētniecība un attīstība", "com_ui_agent_category_sales": "Pārdošana", "com_ui_agent_category_selector_aria": "Aģenta kategorijas atlasītājs", - "com_ui_agent_chain": "Aģentu ķēde (aģentu maisījums)", + "com_ui_agent_chain": "Aģentu ķēde (Vairāki aģenti kopā sasaistīti)", "com_ui_agent_chain_info": "Ļauj izveidot aģentu secību ķēdes. Katrs aģents var piekļūt iepriekšējo ķēdē esošo aģentu izvades datiem. Balstīts uz \"Aģentu sajaukuma\" arhitektūru, kurā aģenti izmanto iepriekšējos izvades datus kā palīginformāciju.", "com_ui_agent_chain_max": "Jūs esat sasniedzis maksimālo skaitu {{0}} aģentu.", "com_ui_agent_delete_error": "Dzēšot aģentu, radās kļūda.", @@ -730,7 +730,7 @@ "com_ui_bearer": "Nesējs", "com_ui_bookmark_delete_confirm": "Vai tiešām vēlaties dzēst šo grāmatzīmi?", "com_ui_bookmarks": "Grāmatzīmes", - "com_ui_bookmarks_add": "Pievienot grāmatzīmes", + "com_ui_bookmarks_add": "Pievienot grāmatzīmi", "com_ui_bookmarks_add_to_conversation": "Pievienot pašreizējai sarunai", "com_ui_bookmarks_count": "Grāfs", "com_ui_bookmarks_create_error": "Veidojot grāmatzīmi, radās kļūda", @@ -807,8 +807,8 @@ "com_ui_date_may": "Maijs", "com_ui_date_november": "Novembris", "com_ui_date_october": "Oktobris", - "com_ui_date_previous_30_days": "Iepriekšējās 30 dienas", - "com_ui_date_previous_7_days": "Iepriekšējās 7 dienas", + "com_ui_date_previous_30_days": "Pēdējās 30 dienas", + "com_ui_date_previous_7_days": "Pēdējās 7 dienas", "com_ui_date_september": "Septembris", "com_ui_date_today": "Šodien", "com_ui_date_yesterday": "Vakar", @@ -848,7 +848,7 @@ "com_ui_download_backup": "Lejupielādēt rezerves kodus", "com_ui_download_backup_tooltip": "Pirms turpināt, lejupielādējiet rezerves kodus. Tie būs nepieciešami, lai atgūtu piekļuvi, ja pazaudēsiet autentifikatora ierīci.", "com_ui_download_error": "Kļūda, lejupielādējot failu. Iespējams, fails ir izdzēsts.", - "com_ui_drag_drop": "Nav rezultātu", + "com_ui_drag_drop": "Ievietojiet šeit jebkuru failu, lai pievienotu to sarunai", "com_ui_dropdown_variables": "Nolaižamās izvēlnes mainīgie:", "com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}` (mainīgā_nosakums:opcija1|opcija2|opcija3)", "com_ui_duplicate": "Dublicēt", @@ -877,9 +877,9 @@ "com_ui_export_convo_modal": "Eksportēt sarunas modālo logu", "com_ui_feedback_more": "Vairāk...", "com_ui_feedback_more_information": "Sniegt papildu atsauksmes", - "com_ui_feedback_negative": "Nepieciešami uzlabojumi", + "com_ui_feedback_negative": "Atbildei nepieciešami uzlabojumi", "com_ui_feedback_placeholder": "Lūdzu, sniedziet šeit jebkādas papildu atsauksmes.", - "com_ui_feedback_positive": "Man tas patīk", + "com_ui_feedback_positive": "Lieliska atbilde", "com_ui_feedback_tag_accurate_reliable": "Precīzs un uzticams", "com_ui_feedback_tag_attention_to_detail": "Uzmanība detaļām", "com_ui_feedback_tag_bad_style": "Slikts stils vai tonis", @@ -1046,6 +1046,7 @@ "com_ui_oauth_error_missing_code": "Trūkst autorizācijas koda. Lūdzu, mēģiniet vēlreiz.", "com_ui_oauth_error_missing_state": "Trūkst stāvokļa parametrs. Lūdzu, mēģiniet vēlreiz.", "com_ui_oauth_error_title": "Autentifikācija neizdevās", + "com_ui_oauth_revoke": "Atsaukt", "com_ui_oauth_success_description": "Jūsu autentifikācija bija veiksmīga. Šis logs aizvērsies pēc", "com_ui_oauth_success_title": "Autentifikācija veiksmīga", "com_ui_of": "no", @@ -1119,7 +1120,7 @@ "com_ui_role_viewer": "Skatītājs", "com_ui_role_viewer_desc": "Var skatīt un izmantot aģentu, bet nevar to rediģēt", "com_ui_roleplay": "Lomu spēle", - "com_ui_run_code": "Palaišanas kods", + "com_ui_run_code": "Palaist kodu", "com_ui_run_code_error": "Radās kļūda, izpildot kodu", "com_ui_save": "Saglabāt", "com_ui_save_badge_changes": "Vai saglabāt emblēmas izmaiņas?", @@ -1187,7 +1188,7 @@ "com_ui_support_contact": "Atbalsta kontaktinformācija", "com_ui_support_contact_email": "E-pasts", "com_ui_support_contact_email_invalid": "Lūdzu, ievadiet derīgu e-pasta adresi", - "com_ui_support_contact_email_placeholder": "atbalsts@piemers.com", + "com_ui_support_contact_email_placeholder": "atbalsts@piemers.lv", "com_ui_support_contact_name": "Vārds", "com_ui_support_contact_name_min_length": "Vārdam jābūt vismaz {{minLength}} rakstu zīmēm", "com_ui_support_contact_name_placeholder": "Atbalsta kontaktpersonas vārds", @@ -1220,19 +1221,19 @@ "com_ui_update_mcp_success": "Veiksmīgi izveidots vai atjaunināts MCP", "com_ui_upload": "Augšupielādēt", "com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts", - "com_ui_upload_code_files": "Augšupielādēt koda interpretētājam", + "com_ui_upload_code_files": "Augšupielādēt failu koda interpretētājam", "com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.", "com_ui_upload_error": "Augšupielādējot failu, radās kļūda.", - "com_ui_upload_file_context": "Augšupielādēt failu kontekstā", - "com_ui_upload_file_search": "Augšupielādēt failu meklēšanai", + "com_ui_upload_file_context": "Augšupielādēt failu kā kontekstu", + "com_ui_upload_file_search": "Augšupielādēt failu informācijas iegūšanai no tā", "com_ui_upload_files": "Augšupielādēt failus", - "com_ui_upload_image": "Augšupielādēt attēlu", - "com_ui_upload_image_input": "Augšupielādēt attēlu", + "com_ui_upload_image": "Augšupielādēt failu kā attēlu", + "com_ui_upload_image_input": "Augšupielādēt failu kā attēlu", "com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.", "com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB", - "com_ui_upload_ocr_text": "Augšupielādēt kā tekstu", + "com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu", "com_ui_upload_success": "Fails veiksmīgi augšupielādēts", - "com_ui_upload_type": "Atlasiet augšupielādes veidu", + "com_ui_upload_type": "Izvēlieties augšupielādes veidu", "com_ui_usage": "Izmantošana", "com_ui_use_2fa_code": "Izmantojiet 2FA kodu", "com_ui_use_backup_code": "Izmantojiet rezerves kodu", diff --git a/client/src/locales/nl/translation.json b/client/src/locales/nl/translation.json index f1c85fcc9..02c0c4e2c 100644 --- a/client/src/locales/nl/translation.json +++ b/client/src/locales/nl/translation.json @@ -4,7 +4,7 @@ "com_a11y_ai_composing": "De AI is nog bezig met het formuleren van een antwoord.", "com_a11y_end": "De AI is klaar met het antwoord.", "com_a11y_start": "De AI is begonnen met antwoorden.", - "com_agents_all": "Alle Agents\n\n", + "com_agents_all": "Alle Agents\n", "com_agents_by_librechat": "door LibreChat", "com_agents_category_empty": "Geen agents gevonden in de {{category}} categorie", "com_agents_category_tab_label": "{{category}} categorie, {{position}} of {{total}}", @@ -215,7 +215,7 @@ "com_endpoint_openai_max": "Het max. aantal tokens dat kan worden gegenereerd. De totale lengte van invoer-tokens en gegenereerde tokens is beperkt door de contextlengte van het model.", "com_endpoint_openai_pres": "Getal tussen -2,0 en 2,0. Positieve waarden straffen nieuwe tokens op basis van of ze al voorkomen in de tekst tot nu toe, waardoor de kans dat het model over nieuwe onderwerpen praat toeneemt.", "com_endpoint_openai_prompt_prefix_placeholder": "Stel aangepaste instructies in om op te nemen in Systeembericht. Standaard: geen", - "com_endpoint_openai_reasoning_effort": "Alleen voor redeneringsmodellen: beperkt de inspanning voor redeneren. Het verminderen van de redeneringsinspanning kan leiden tot snellere antwoorden en minder tokens die worden gebruikt voor redeneren in een antwoord. 'Minimaal' produceert zeer weinig redeneringstokens voor de snelste tijd tot het eerste token, vooral geschikt voor coderen en instructies volgen.", + "com_endpoint_openai_reasoning_effort": "Alleen redeneermodellen: beperkt de inspanning voor redeneren. Vermindering van redeneerinspanning kan resulteren in snellere reacties en minder tokens gebruikt voor redeneren in een reactie. Minimaal' produceert zeer weinig redeneertokens voor de snelste tijd-tot-eerste-tokens, vooral geschikt voor codering en het volgen van instructies.", "com_endpoint_openai_temp": "Hogere waarden = meer willekeurig, terwijl lagere waarden = meer gericht en deterministisch. We raden aan dit of Top P te wijzigen, maar niet beide.", "com_endpoint_openai_topp": "Een alternatief voor sampling met temperatuur, genaamd nucleus sampling, waarbij het model de resultaten van de tokens met de top_p waarschijnlijkheidsmassa in overweging neemt. Dus 0,1 betekent dat alleen de tokens die de bovenste 10% waarschijnlijkheidsmassa omvatten, in overweging worden genomen. We raden aan dit of temperatuur te wijzigen, maar niet beide.", "com_endpoint_output": "Uitvoer", diff --git a/client/src/locales/zh-Hans/translation.json b/client/src/locales/zh-Hans/translation.json index 6e885fbbe..7320a1f1b 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -1044,6 +1044,7 @@ "com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。", "com_ui_oauth_error_missing_state": "缺少状态参数,请重试。", "com_ui_oauth_error_title": "认证失败", + "com_ui_oauth_revoke": "撤销", "com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:", "com_ui_oauth_success_title": "认证成功", "com_ui_of": "/", @@ -1264,7 +1265,7 @@ "com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key", "com_ui_web_search_reranker_jina": "Jina AI", "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_reranker_jina_url_help": "了解 Jina Rerank API", "com_ui_web_search_scraper": "抓取器", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key", diff --git a/e2e/jestSetup.js b/e2e/jestSetup.js index 87b76a3ec..d7ac4b0b7 100644 --- a/e2e/jestSetup.js +++ b/e2e/jestSetup.js @@ -1,3 +1,3 @@ -// v0.8.0-rc3 +// v0.8.0-rc4 // See .env.test.example for an example of the '.env.test' file. require('dotenv').config({ path: './e2e/.env.test' }); diff --git a/helm/librechat-rag-api/templates/rag-deployment.yaml b/helm/librechat-rag-api/templates/rag-deployment.yaml index dfe10dced..7f31df251 100755 --- a/helm/librechat-rag-api/templates/rag-deployment.yaml +++ b/helm/librechat-rag-api/templates/rag-deployment.yaml @@ -57,7 +57,7 @@ spec: valueFrom: secretKeyRef: name: {{ .Values.postgresql.auth.existingSecret }} - key: {{ .Values.postgresql.auth.existingSecretKey }} + key: {{ .Values.postgresql.auth.secretKeys.userPasswordKey }} {{- end }} {{- if .Values.global.librechat.existingSecretName }} - name: OPENAI_API_KEY diff --git a/helm/librechat-rag-api/values.yaml b/helm/librechat-rag-api/values.yaml index 4aec90b1d..cd722bc09 100755 --- a/helm/librechat-rag-api/values.yaml +++ b/helm/librechat-rag-api/values.yaml @@ -14,6 +14,7 @@ image: # Overrides the image tag whose default is the chart appVersion. tag: latest +# See https://github.com/bitnami/charts/blob/main/bitnami/postgresql/values.yaml for more details. postgresql: enabled: true # nameOverride: vectordb @@ -27,7 +28,10 @@ postgresql: # define a secret with values for "postgres-password", "password" (user Password) and "replication-password" or add values directly existingSecret: librechat-vectordb # postgres-password is needed to enable pgvector extension. If you enable it manually you can use "password" and user "librechat" - existingSecretKey: postgres-password + secretKeys: + userPasswordKey: postgres-password + adminPasswordKey: postgres-password + replicationPasswordKey: postgres-password imagePullSecrets: [] nameOverride: '' diff --git a/helm/librechat/Chart.yaml b/helm/librechat/Chart.yaml index cb1231fd3..2ce37fa92 100755 --- a/helm/librechat/Chart.yaml +++ b/helm/librechat/Chart.yaml @@ -23,7 +23,7 @@ version: 1.8.10 # It is recommended to use it with quotes. # renovate: image=ghcr.io/danny-avila/librechat -appVersion: "v0.8.0-rc3" +appVersion: "v0.8.0-rc4" home: https://www.librechat.ai diff --git a/helm/librechat/templates/deployment.yaml b/helm/librechat/templates/deployment.yaml index 9f6e4f624..492edafdb 100755 --- a/helm/librechat/templates/deployment.yaml +++ b/helm/librechat/templates/deployment.yaml @@ -30,6 +30,7 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + serviceAccountName: {{ include "librechat.serviceAccountName" . }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: diff --git a/helm/librechat/templates/serviceaccount.yaml b/helm/librechat/templates/serviceaccount.yaml new file mode 100644 index 000000000..983f9069f --- /dev/null +++ b/helm/librechat/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "librechat.serviceAccountName" . }} + labels: + {{- include "librechat.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/helm/librechat/values.yaml b/helm/librechat/values.yaml index f14b5e227..f290766d1 100755 --- a/helm/librechat/values.yaml +++ b/helm/librechat/values.yaml @@ -115,6 +115,17 @@ imagePullSecrets: [] nameOverride: "" fullnameOverride: "" +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" lifecycle: {} # # base for adding a custom banner // see https://github.com/danny-avila/LibreChat/pull/3952 for an example diff --git a/package-lock.json b/package-lock.json index 561e6228f..d0e4572cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "LibreChat", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "LibreChat", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "license": "ISC", "workspaces": [ "api", @@ -47,7 +47,7 @@ }, "api": { "name": "@librechat/backend", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.52.0", @@ -72,7 +72,7 @@ "@modelcontextprotocol/sdk": "^1.17.1", "@node-saml/passport-saml": "^5.1.0", "@waylaidwanderer/fetch-event-source": "^3.0.1", - "axios": "^1.8.2", + "axios": "^1.12.1", "bcryptjs": "^2.4.3", "compression": "^1.8.1", "connect-redis": "^8.1.0", @@ -2623,7 +2623,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "license": "ISC", "dependencies": { "@ariakit/react": "^0.4.15", @@ -31025,12 +31025,13 @@ } }, "node_modules/axios": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", - "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", + "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -51607,7 +51608,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.3.5", + "version": "1.4.0", "license": "ISC", "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -51643,7 +51644,7 @@ "@librechat/agents": "^2.4.79", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", - "axios": "^1.8.2", + "axios": "^1.12.1", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2", @@ -51735,7 +51736,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.2.8", + "version": "0.3.0", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", @@ -52037,10 +52038,10 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.006", + "version": "0.8.010", "license": "ISC", "dependencies": { - "axios": "^1.8.2", + "axios": "^1.12.1", "dayjs": "^1.11.13", "js-yaml": "^4.1.0", "zod": "^3.22.4" @@ -52141,7 +52142,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.21", + "version": "0.0.22", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", diff --git a/package.json b/package.json index 913315a7c..4b7788bcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "LibreChat", - "version": "v0.8.0-rc3", + "version": "v0.8.0-rc4", "description": "", "workspaces": [ "api", @@ -125,7 +125,7 @@ }, "overrides": { "@langchain/openai": "0.5.18", - "axios": "1.8.2", + "axios": "1.12.1", "elliptic": "^6.6.1", "form-data": "^4.0.4", "mdast-util-gfm-autolink-literal": "2.0.0", diff --git a/packages/api/package.json b/packages/api/package.json index 9d3c3ad8b..ec5974594 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/api", - "version": "1.3.5", + "version": "1.4.0", "type": "commonjs", "description": "MCP services for LibreChat", "main": "dist/index.js", @@ -76,7 +76,7 @@ "@librechat/agents": "^2.4.79", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", - "axios": "^1.8.2", + "axios": "^1.12.1", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2", diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index 2c63b8129..348161363 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -74,9 +74,23 @@ export class MCPConnectionFactory { oauthTokens, }); - if (this.useOAuth) this.handleOAuthEvents(connection); - await this.attemptToConnect(connection); - return connection; + let cleanupOAuthHandlers: (() => void) | null = null; + if (this.useOAuth) { + 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 */ @@ -133,8 +147,8 @@ export class MCPConnectionFactory { } /** Sets up OAuth event handlers for the connection */ - protected handleOAuthEvents(connection: MCPConnection): void { - connection.on('oauthRequired', async (data) => { + protected handleOAuthEvents(connection: MCPConnection): () => void { + const oauthHandler = async (data: { serverUrl?: string }) => { logger.info(`${this.logPrefix} oauthRequired event received`); // 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`); connection.emit('oauthFailed', new Error('OAuth authentication failed')); } - }); + }; + + connection.on('oauthRequired', oauthHandler); + + return () => { + connection.removeListener('oauthRequired', oauthHandler); + }; } /** Attempts to establish connection with timeout handling */ diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index c739b8ffc..e96d207f2 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -56,6 +56,9 @@ describe('MCPConnectionFactory', () => { isConnected: jest.fn(), setOAuthTokens: jest.fn(), on: jest.fn().mockReturnValue(mockConnectionInstance), + once: jest.fn().mockReturnValue(mockConnectionInstance), + off: jest.fn().mockReturnValue(mockConnectionInstance), + removeListener: jest.fn().mockReturnValue(mockConnectionInstance), emit: jest.fn(), } as unknown as jest.Mocked; diff --git a/packages/api/src/mcp/__tests__/parsers.test.ts b/packages/api/src/mcp/__tests__/parsers.test.ts index de3d4cd70..a26373aef 100644 --- a/packages/api/src/mcp/__tests__/parsers.test.ts +++ b/packages/api/src/mcp/__tests__/parsers.test.ts @@ -161,7 +161,7 @@ describe('formatToolContent', () => { }); describe('resource handling', () => { - it('should handle UI resources', () => { + it('should handle UI resources in artifacts', () => { const result: t.MCPToolCallResponse = { content: [ { @@ -181,22 +181,27 @@ describe('formatToolContent', () => { expect(content).toEqual([ { type: 'text', - text: '', - metadata: { - type: 'ui_resources', - data: [ - { - uri: 'ui://carousel', - mimeType: 'application/json', - text: '{"items": []}', - name: 'carousel', - description: 'A carousel component', - }, - ], - }, + text: + 'Resource Text: {"items": []}\n' + + 'Resource URI: ui://carousel\n' + + 'Resource: carousel\n' + + 'Resource Description: A carousel component\n' + + 'Resource MIME Type: application/json', }, ]); - 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', () => { @@ -281,24 +286,75 @@ describe('formatToolContent', () => { expect(content).toEqual([ { 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', - text: '', - metadata: { - type: 'ui_resources', - data: [ - { - uri: 'ui://button', - mimeType: 'application/json', - text: '{"label": "Click me"}', - }, - ], - }, + text: + 'Resource Text: {"type": "line"}\n' + + 'Resource URI: ui://graph\n' + + 'Resource MIME Type: application/json', }, ]); - 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', 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' + 'Resource: API Data\n' + 'Resource Description: External data source', }, { type: 'text', text: 'Conclusion' }, - { - type: 'text', - text: '', - metadata: { - type: 'ui_resources', - data: [ - { - uri: 'ui://chart', - mimeType: 'application/json', - text: '{"type": "bar"}', - }, - ], - }, - }, ]); expect(artifacts).toEqual({ content: [ @@ -389,6 +434,15 @@ describe('formatToolContent', () => { image_url: { url: 'https://example.com/image2.jpg' }, }, ], + ui_resources: { + data: [ + { + uri: 'ui://chart', + mimeType: 'application/json', + text: '{"type": "bar"}', + }, + ], + }, }); }); diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index d937efa05..d4d56ceb8 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -65,6 +65,7 @@ function isStreamableHTTPOptions(options: t.MCPOptions): options is t.Streamable } const FIVE_MINUTES = 5 * 60 * 1000; +const DEFAULT_TIMEOUT = 60000; interface MCPConnectionParams { serverName: string; @@ -145,19 +146,22 @@ export class MCPConnection extends EventEmitter { * This helps prevent memory leaks by only passing necessary dependencies. * * @param getHeaders Function to retrieve request headers + * @param timeout Timeout value for the agent (in milliseconds) * @returns A fetch function that merges headers appropriately */ private createFetchFunction( getHeaders: () => Record | null | undefined, + timeout?: number, ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise { return function customFetch( input: UndiciRequestInfo, init?: UndiciRequestInit, ): Promise { const requestHeaders = getHeaders(); + const effectiveTimeout = timeout || DEFAULT_TIMEOUT; const agent = new Agent({ - bodyTimeout: 0, - headersTimeout: 0, + bodyTimeout: effectiveTimeout, + headersTimeout: effectiveTimeout, }); if (!requestHeaders) { return undiciFetch(input, { ...init, dispatcher: agent }); @@ -243,6 +247,7 @@ export class MCPConnection extends EventEmitter { headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`; } + const timeoutValue = this.timeout || DEFAULT_TIMEOUT; const transport = new SSEClientTransport(url, { requestInit: { headers, @@ -252,8 +257,8 @@ export class MCPConnection extends EventEmitter { fetch: (url, init) => { const fetchHeaders = new Headers(Object.assign({}, init?.headers, headers)); const agent = new Agent({ - bodyTimeout: 0, - headersTimeout: 0, + bodyTimeout: timeoutValue, + headersTimeout: timeoutValue, }); return undiciFetch(url, { ...init, @@ -264,6 +269,7 @@ export class MCPConnection extends EventEmitter { }, fetch: this.createFetchFunction( this.getRequestHeaders.bind(this), + this.timeout, ) as unknown as FetchLike, }); @@ -304,6 +310,7 @@ export class MCPConnection extends EventEmitter { }, fetch: this.createFetchFunction( this.getRequestHeaders.bind(this), + this.timeout, ) as unknown as FetchLike, }); diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts index 77af29bee..97b1efc4c 100644 --- a/packages/api/src/mcp/parsers.ts +++ b/packages/api/src/mcp/parsers.ts @@ -1,4 +1,7 @@ +import { Tools } from 'librechat-data-provider'; +import type { UIResource } from 'librechat-data-provider'; import type * as t from './types'; + const RECOGNIZED_PROVIDERS = new Set([ 'google', 'anthropic', @@ -111,7 +114,7 @@ export function formatToolContent( const formattedContent: t.FormattedContent[] = []; const imageUrls: t.FormattedContent[] = []; let currentTextBlock = ''; - const uiResources: t.UIResource[] = []; + const uiResources: UIResource[] = []; type ContentHandler = undefined | ((item: t.ToolContentPart) => void); @@ -144,8 +147,7 @@ export function formatToolContent( resource: (item) => { if (item.resource.uri.startsWith('ui://')) { - uiResources.push(item.resource as t.UIResource); - return; + uiResources.push(item.resource as UIResource); } const resourceText = []; @@ -182,18 +184,14 @@ export function formatToolContent( formattedContent.push({ type: 'text', text: currentTextBlock }); } - if (uiResources.length) { - formattedContent.push({ - type: 'text', - metadata: { - type: 'ui_resources', - data: uiResources, - }, - text: '', - }); + let artifacts: t.Artifacts = undefined; + if (imageUrls.length || uiResources.length) { + artifacts = { + ...(imageUrls.length && { content: imageUrls }), + ...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }), + }; } - const artifacts = imageUrls.length ? { content: imageUrls } : undefined; if (CONTENT_ARRAY_PROVIDERS.has(provider)) { return [formattedContent, artifacts]; } diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 95086a367..7d137afd0 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -6,8 +6,9 @@ import { StdioOptionsSchema, WebSocketOptionsSchema, StreamableHTTPOptionsSchema, + Tools, } 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 { TokenMethods } from '@librechat/data-schemas'; import type { FlowStateManager } from '~/flow/manager'; @@ -86,7 +87,7 @@ export type FormattedContent = metadata?: { type: string; data: UIResource[]; - } + }; text?: string; } | { @@ -111,24 +112,39 @@ export type FormattedContent = }; }; -export type FormattedContentResult = [ - string | FormattedContent[], - undefined | { content: FormattedContent[] }, -]; - -export type UIResource = { - uri: string; - mimeType: string; - text: string; +export type FileSearchSource = { + fileId: string; + relevance: number; + fileName?: string; + metadata?: { + storageType?: 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 FormattedToolResponse = [ - string | FormattedContent[], - { content: FormattedContent[] } | undefined, -]; +export type FormattedToolResponse = FormattedContentResult; export type ParsedServerConfig = MCPOptions & { url?: string; diff --git a/packages/client/package.json b/packages/client/package.json index e06e28407..6d1a83cdf 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/client", - "version": "0.2.8", + "version": "0.3.0", "description": "React components for LibreChat", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 4afbdab96..027e01483 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.006", + "version": "0.8.010", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", @@ -39,7 +39,7 @@ }, "homepage": "https://librechat.ai", "dependencies": { - "axios": "^1.8.2", + "axios": "^1.12.1", "dayjs": "^1.11.13", "js-yaml": "^4.1.0", "zod": "^3.22.4" diff --git a/packages/data-provider/react-query/package-lock.json b/packages/data-provider/react-query/package-lock.json new file mode 100644 index 000000000..b15db0d60 --- /dev/null +++ b/packages/data-provider/react-query/package-lock.json @@ -0,0 +1,292 @@ +{ + "name": "librechat-data-provider/react-query", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "librechat-data-provider/react-query", + "dependencies": { + "axios": "^1.12.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", + "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + } + } +} diff --git a/packages/data-provider/react-query/package.json b/packages/data-provider/react-query/package.json index e449e6966..8fa6b067b 100644 --- a/packages/data-provider/react-query/package.json +++ b/packages/data-provider/react-query/package.json @@ -5,6 +5,6 @@ "module": "./index.es.js", "types": "../dist/types/react-query/index.d.ts", "dependencies": { - "axios": "^1.7.7" + "axios": "^1.12.1" } } diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ea1bbdfd9..ff276ba81 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1528,9 +1528,9 @@ export enum TTSProviders { /** Enum for app-wide constants */ export enum Constants { /** Key for the app's version. */ - VERSION = 'v0.8.0-rc3', + VERSION = 'v0.8.0-rc4', /** Key for the Custom Config's version (librechat.yaml). */ - CONFIG_VERSION = '1.2.8', + CONFIG_VERSION = '1.2.9', /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ NO_PARENT = '00000000-0000-0000-0000-000000000000', /** Standard value to use whatever the submission prelim. `responseMessageId` is */ diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index e4dd53847..3aa2021ea 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -83,70 +83,75 @@ const processQueue = (error: AxiosError | null, token: string | null = null) => failedQueue = []; }; -axios.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; - if (!error.response) { - return Promise.reject(error); - } +if (typeof window !== 'undefined') { + axios.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + if (!error.response) { + return Promise.reject(error); + } - if (originalRequest.url?.includes('/api/auth/2fa') === true) { - return Promise.reject(error); - } - if (originalRequest.url?.includes('/api/auth/logout') === true) { - return Promise.reject(error); - } + if (originalRequest.url?.includes('/api/auth/2fa') === true) { + return Promise.reject(error); + } + if (originalRequest.url?.includes('/api/auth/logout') === true) { + 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) { - console.warn('401 error, refreshing token'); - originalRequest._retry = true; + if (error.response.status === 401 && !originalRequest._retry) { + console.warn('401 error, refreshing token'); + 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 { - const token = await new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }); - originalRequest.headers['Authorization'] = 'Bearer ' + token; - return await axios(originalRequest); + const response = await refreshToken(); + + 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; } } - isRefreshing = true; - - 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); - }, -); + return Promise.reject(error); + }, + ); +} export default { get: _get, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index dc4baebc0..8ae503ef2 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -552,20 +552,33 @@ export type MemoryArtifact = { type: 'update' | 'delete' | 'error'; }; +export type UIResource = { + type?: string; + data?: unknown; + uri?: string; + mimeType?: string; + text?: string; + [key: string]: unknown; +}; + export type TAttachmentMetadata = { type?: Tools; messageId: string; toolCallId: string; + [Tools.memory]?: MemoryArtifact; + [Tools.ui_resources]?: UIResource[]; [Tools.web_search]?: SearchResultData; [Tools.file_search]?: SearchResultData; - [Tools.memory]?: MemoryArtifact; }; export type TAttachment = | (TFile & TAttachmentMetadata) | (Pick & { expiresAt: number; - } & TAttachmentMetadata); + } & TAttachmentMetadata) + | (Partial> & + Pick & + TAttachmentMetadata); export type TMessage = z.input & { children?: TMessage[]; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index df9009728..03e287cbc 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -23,6 +23,7 @@ export enum Tools { retrieval = 'retrieval', function = 'function', memory = 'memory', + ui_resources = 'ui_resources', } export enum EToolResources { diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index 51496a050..f6591111e 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.21", + "version": "0.0.22", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs",