diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml index 4fb425b4c2df..f6fab7958744 100644 --- a/.github/workflows/gitnexus-index.yml +++ b/.github/workflows/gitnexus-index.yml @@ -88,4 +88,5 @@ jobs: || github.ref_name }} path: .gitnexus/ + include-hidden-files: true retention-days: 30 diff --git a/Dockerfile b/Dockerfile index 19d275eb3103..0b9d0bb5bbcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.4 +# v0.8.5-rc1 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index bf5570f3867a..265b41ff5dc2 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.4 +# v0.8.5-rc1 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 diff --git a/api/package.json b/api/package.json index fe65c599fa43..de58e79af4bd 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.4", + "version": "v0.8.5-rc1", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.63", + "@librechat/agents": "^3.1.64", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -52,7 +52,7 @@ "@node-saml/passport-saml": "^5.1.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", - "axios": "1.13.6", + "axios": "^1.15.0", "bcryptjs": "^2.4.3", "compression": "^1.8.1", "connect-redis": "^8.1.0", diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js index 186dd810bf5b..1dfe8e56cdc9 100644 --- a/api/server/controllers/FavoritesController.js +++ b/api/server/controllers/FavoritesController.js @@ -27,6 +27,7 @@ const updateFavoritesController = async (req, res) => { for (const fav of favorites) { const hasAgent = !!fav.agentId; const hasModel = !!(fav.model && fav.endpoint); + const hasSpec = !!fav.spec; if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) { return res @@ -43,18 +44,46 @@ const updateFavoritesController = async (req, res) => { .status(400) .json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` }); } + if (fav.spec !== undefined && fav.spec !== null) { + if (typeof fav.spec !== 'string' || fav.spec.length === 0) { + return res.status(400).json({ message: 'spec must be a non-empty string' }); + } + if (fav.spec.length > MAX_STRING_LENGTH) { + return res + .status(400) + .json({ message: `spec exceeds maximum length of ${MAX_STRING_LENGTH}` }); + } + } + + const hasPartialModel = !hasModel && !!(fav.model || fav.endpoint); + + if (hasPartialModel && !hasAgent && !hasSpec) { + return res.status(400).json({ message: 'model and endpoint must be provided together' }); + } - if (!hasAgent && !hasModel) { + const typeCount = [hasAgent, hasModel, hasSpec].filter(Boolean).length; + if (typeCount === 0) { return res.status(400).json({ - message: 'Each favorite must have either agentId or model+endpoint', + message: 'Each favorite must have either agentId, model+endpoint, or spec', }); } - if (hasAgent && hasModel) { + if (typeCount > 1) { return res.status(400).json({ - message: 'Favorite cannot have both agentId and model/endpoint', + message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)', }); } + + if (hasSpec && (fav.agentId || fav.model || fav.endpoint)) { + return res + .status(400) + .json({ message: 'spec cannot be combined with agentId, model, or endpoint' }); + } + if (hasAgent && (fav.model || fav.endpoint)) { + return res + .status(400) + .json({ message: 'agentId cannot be combined with model or endpoint' }); + } } const user = await updateUser(userId, { favorites }); @@ -63,10 +92,10 @@ const updateFavoritesController = async (req, res) => { return res.status(404).json({ message: 'User not found' }); } - res.status(200).json(user.favorites); + return res.status(200).json(user.favorites); } catch (error) { console.error('Error updating favorites:', error); - res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'Internal server error' }); } }; @@ -86,10 +115,10 @@ const getFavoritesController = async (req, res) => { await updateUser(userId, { favorites: [] }); } - res.status(200).json(favorites); + return res.status(200).json(favorites); } catch (error) { console.error('Error fetching favorites:', error); - res.status(500).json({ message: 'Internal server error' }); + return res.status(500).json({ message: 'Internal server error' }); } }; diff --git a/api/server/controllers/FavoritesController.spec.js b/api/server/controllers/FavoritesController.spec.js new file mode 100644 index 000000000000..c3aea3081d55 --- /dev/null +++ b/api/server/controllers/FavoritesController.spec.js @@ -0,0 +1,308 @@ +jest.mock('~/models', () => ({ + updateUser: jest.fn(), + getUserById: jest.fn(), +})); + +const { updateUser, getUserById } = require('~/models'); +const { updateFavoritesController, getFavoritesController } = require('./FavoritesController'); + +const makeRes = () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +}; + +const makeReq = (body = {}) => ({ + body, + user: { id: 'user-123' }, +}); + +describe('FavoritesController', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('updateFavoritesController - payload envelope', () => { + it('rejects missing favorites key with 400', async () => { + const req = makeReq({}); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Favorites data is required' }); + expect(updateUser).not.toHaveBeenCalled(); + }); + + it('rejects non-array favorites with 400', async () => { + const req = makeReq({ favorites: 'not-an-array' }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'Favorites must be an array' }); + }); + + it('rejects favorites over MAX_FAVORITES with 400 + code', async () => { + const favorites = Array.from({ length: 51 }, (_, i) => ({ agentId: `agent-${i}` })); + const req = makeReq({ favorites }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + code: 'MAX_FAVORITES_EXCEEDED', + message: 'Maximum 50 favorites allowed', + limit: 50, + }); + }); + }); + + describe('updateFavoritesController - agent/model length validation', () => { + it('rejects oversized agentId', async () => { + const req = makeReq({ favorites: [{ agentId: 'a'.repeat(257) }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'agentId exceeds maximum length of 256' }); + }); + + it('rejects oversized model', async () => { + const req = makeReq({ favorites: [{ model: 'm'.repeat(257), endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'model exceeds maximum length of 256' }); + }); + }); + + describe('updateFavoritesController - spec validation', () => { + it('accepts a valid spec favorite', async () => { + updateUser.mockResolvedValue({ favorites: [{ spec: 'my-spec' }] }); + const req = makeReq({ favorites: [{ spec: 'my-spec' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([{ spec: 'my-spec' }]); + expect(updateUser).toHaveBeenCalledWith('user-123', { + favorites: [{ spec: 'my-spec' }], + }); + }); + + it('rejects non-string spec with 400', async () => { + const req = makeReq({ favorites: [{ spec: 42 }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' }); + }); + + it('rejects empty string spec with 400', async () => { + const req = makeReq({ favorites: [{ spec: '' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' }); + }); + + it('rejects oversized spec with 400', async () => { + const req = makeReq({ favorites: [{ spec: 's'.repeat(257) }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ message: 'spec exceeds maximum length of 256' }); + }); + + it('allows undefined/null spec (treated as absent)', async () => { + updateUser.mockResolvedValue({ favorites: [{ agentId: 'a1' }] }); + const req = makeReq({ favorites: [{ agentId: 'a1', spec: null }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + }); + }); + + describe('updateFavoritesController - exclusivity (typeCount)', () => { + it('rejects empty favorite entry with 400', async () => { + const req = makeReq({ favorites: [{}] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Each favorite must have either agentId, model+endpoint, or spec', + }); + }); + + it('rejects agentId + model combination', async () => { + const req = makeReq({ + favorites: [{ agentId: 'a1', model: 'gpt-5', endpoint: 'openai' }], + }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)', + }); + }); + + it('rejects agentId + spec combination', async () => { + const req = makeReq({ favorites: [{ agentId: 'a1', spec: 's1' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)', + }); + }); + + it('rejects model + spec combination', async () => { + const req = makeReq({ + favorites: [{ model: 'gpt-5', endpoint: 'openai', spec: 's1' }], + }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects spec with stray endpoint field', async () => { + const req = makeReq({ favorites: [{ spec: 's1', endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'spec cannot be combined with agentId, model, or endpoint', + }); + }); + + it('rejects spec with stray model field', async () => { + const req = makeReq({ favorites: [{ spec: 's1', model: 'gpt-5' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'spec cannot be combined with agentId, model, or endpoint', + }); + }); + + it('rejects agentId with stray model field (no endpoint)', async () => { + const req = makeReq({ favorites: [{ agentId: 'a1', model: 'gpt-5' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'agentId cannot be combined with model or endpoint', + }); + }); + + it('rejects agentId with stray endpoint field (no model)', async () => { + const req = makeReq({ favorites: [{ agentId: 'a1', endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'agentId cannot be combined with model or endpoint', + }); + }); + + it('rejects model without endpoint (partial model pair)', async () => { + const req = makeReq({ favorites: [{ model: 'gpt-5' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'model and endpoint must be provided together', + }); + }); + + it('rejects endpoint without model (partial model pair)', async () => { + const req = makeReq({ favorites: [{ endpoint: 'openai' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + message: 'model and endpoint must be provided together', + }); + }); + + it('accepts a mixed array of valid single-type favorites', async () => { + const favorites = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' }]; + updateUser.mockResolvedValue({ favorites }); + const req = makeReq({ favorites }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(favorites); + }); + }); + + describe('updateFavoritesController - persistence', () => { + it('returns 404 when user is not found', async () => { + updateUser.mockResolvedValue(null); + const req = makeReq({ favorites: [{ spec: 's1' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'User not found' }); + }); + + it('returns 500 when updateUser throws', async () => { + updateUser.mockRejectedValue(new Error('db down')); + const req = makeReq({ favorites: [{ spec: 's1' }] }); + const res = makeRes(); + await updateFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' }); + }); + }); + + describe('getFavoritesController', () => { + it('returns the user favorites array', async () => { + const favorites = [{ agentId: 'a1' }, { spec: 's1' }]; + getUserById.mockResolvedValue({ favorites }); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(favorites); + }); + + it('returns [] when user.favorites is null (falsy)', async () => { + getUserById.mockResolvedValue({ favorites: null }); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(updateUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it('repairs corrupt favorites field (non-array truthy)', async () => { + getUserById.mockResolvedValue({ favorites: 'corrupt' }); + updateUser.mockResolvedValue({ favorites: [] }); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(updateUser).toHaveBeenCalledWith('user-123', { favorites: [] }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([]); + }); + + it('returns 404 when user not found', async () => { + getUserById.mockResolvedValue(null); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 500 when getUserById throws', async () => { + getUserById.mockRejectedValue(new Error('db down')); + const req = makeReq(); + const res = makeRes(); + await getFavoritesController(req, res); + expect(res.status).toHaveBeenCalledWith(500); + }); + }); +}); diff --git a/bun.lock b/bun.lock index fb1ec008409f..82c05c6430eb 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "api": { "name": "@librechat/backend", - "version": "0.8.4", + "version": "0.8.5-rc1", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", "@aws-sdk/client-bedrock-runtime": "^3.980.0", @@ -130,7 +130,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "0.8.4", + "version": "0.8.5-rc1", "dependencies": { "@ariakit/react": "^0.4.15", "@ariakit/react-core": "^0.4.17", @@ -264,7 +264,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.27", + "version": "1.7.28", "devDependencies": { "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", @@ -345,7 +345,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.56", + "version": "0.4.57", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -433,7 +433,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.401", + "version": "0.8.500", "dependencies": { "axios": "^1.13.5", "dayjs": "^1.11.13", @@ -470,7 +470,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.40", + "version": "0.0.49", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 375e4418a78a..a46d7d4079e9 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.4 */ +/** v0.8.5-rc1 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index f041629cbb94..fb4acca60ecc 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.4", + "version": "v0.8.5-rc1", "description": "", "type": "module", "scripts": { diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index 1820eed8b991..4f71bb114f03 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -183,7 +183,12 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => > - diff --git a/client/src/components/Agents/AgentDetailContent.tsx b/client/src/components/Agents/AgentDetailContent.tsx index 1e06d8230fd1..7a2a95401d97 100644 --- a/client/src/components/Agents/AgentDetailContent.tsx +++ b/client/src/components/Agents/AgentDetailContent.tsx @@ -181,7 +181,12 @@ const AgentDetailContent: React.FC = ({ agent }) => { > - diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 181d219c08d7..0b10e6c7126e 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useMemo } from 'react'; +import React, { useRef, useState, useMemo, useCallback } from 'react'; import { useRecoilState } from 'recoil'; import * as Ariakit from '@ariakit/react'; import { @@ -19,6 +19,7 @@ import { Providers, EToolResources, EModelEndpoint, + isPermissiveMimeConfig, defaultAgentCapabilities, bedrockDocumentExtensions, isDocumentSupportedProvider, @@ -110,27 +111,35 @@ const AttachFileMenu = ({ ephemeralAgent, ); - const handleUploadClick = (fileType?: FileUploadType) => { - if (!inputRef.current) { - return; - } - inputRef.current.value = ''; - if (fileType === 'image') { - inputRef.current.accept = 'image/*,.heif,.heic'; - } else if (fileType === 'document') { - inputRef.current.accept = '.pdf,application/pdf'; - } else if (fileType === 'image_document') { - inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf'; - } else if (fileType === 'image_document_extended') { - inputRef.current.accept = `image/*,.heif,.heic,${bedrockDocumentExtensions}`; - } else if (fileType === 'image_document_video_audio') { - inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf,video/*,audio/*'; - } else { + const handleUploadClick = useCallback( + (fileType?: FileUploadType) => { + if (!inputRef.current) { + return; + } + inputRef.current.value = ''; + if ( + fileType !== undefined && + isPermissiveMimeConfig(endpointFileConfig?.supportedMimeTypes) + ) { + inputRef.current.accept = ''; + } else if (fileType === 'image') { + inputRef.current.accept = 'image/*,.heif,.heic'; + } else if (fileType === 'document') { + inputRef.current.accept = '.pdf,application/pdf'; + } else if (fileType === 'image_document') { + inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf'; + } else if (fileType === 'image_document_extended') { + inputRef.current.accept = `image/*,.heif,.heic,${bedrockDocumentExtensions}`; + } else if (fileType === 'image_document_video_audio') { + inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf,video/*,audio/*'; + } else { + inputRef.current.accept = ''; + } + inputRef.current.click(); inputRef.current.accept = ''; - } - inputRef.current.click(); - inputRef.current.accept = ''; - }; + }, + [endpointFileConfig?.supportedMimeTypes], + ); const dropdownItems = useMemo(() => { const createMenuItems = (onAction: (fileType?: FileUploadType) => void) => { @@ -247,6 +256,7 @@ const AttachFileMenu = ({ endpointType, capabilities, useResponsesApi, + handleUploadClick, setToolResource, setEphemeralAgent, sharePointEnabled, diff --git a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx index 14ce5bb2095c..d72fedd21de8 100644 --- a/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx +++ b/client/src/components/Chat/Menus/Endpoints/CustomMenu.tsx @@ -67,7 +67,8 @@ export const CustomMenu = React.forwardRef(func parent ? 'animate-popover-left ml-3' : 'animate-popover', 'outline-none! z-40 flex max-h-[min(450px,var(--popover-available-height))] w-full', 'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light', - 'bg-presentation px-3 py-2 text-sm text-text-primary shadow-lg', + 'bg-presentation text-sm text-text-primary shadow-lg', + parent ? 'px-0.5 py-0.5' : 'px-3 py-2', 'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]', searchable && 'p-0', )} diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 7cec4744d549..3c3e58fc9e84 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -1,11 +1,11 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React from 'react'; import { VisuallyHidden } from '@ariakit/react'; import { CheckCircle2, EarthIcon, Pin, PinOff } from 'lucide-react'; import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import type { Endpoint } from '~/common'; +import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; -import { useFavorites, useLocalize } from '~/hooks'; -import type { Endpoint } from '~/common'; import { cn } from '~/utils'; interface EndpointModelItemProps { @@ -26,24 +26,7 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } = useFavorites(); - const itemRef = useRef(null); - const [isActive, setIsActive] = useState(false); - - useEffect(() => { - const element = itemRef.current; - if (!element) { - return; - } - - const observer = new MutationObserver(() => { - setIsActive(element.hasAttribute('data-active-item')); - }); - - observer.observe(element, { attributes: true, attributeFilter: ['data-active-item'] }); - setIsActive(element.hasAttribute('data-active-item')); - - return () => observer.disconnect(); - }, []); + const { ref: itemRef, isActive } = useIsActiveItem(); let isGlobal = false; let modelName = modelId; @@ -126,16 +109,19 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) {isGlobal && } {isSelected && ( <> ({ isFavoriteAgent: () => false, toggleFavoriteAgent: jest.fn(), }), + useIsActiveItem: () => ({ ref: { current: null }, isActive: false }), })); const baseEndpoint: Endpoint = { diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx new file mode 100644 index 000000000000..fe863e9a3bdd --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/ModelSpecItem.test.tsx @@ -0,0 +1,125 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import type { TModelSpec } from 'librechat-data-provider'; +import { ModelSpecItem } from '../ModelSpecItem'; + +const mockHandleSelectSpec = jest.fn(); +const mockToggleFavoriteSpec = jest.fn(); +let mockIsFavoriteSpec = false; +let mockIsActive = false; + +jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({ + useModelSelectorContext: () => ({ + handleSelectSpec: mockHandleSelectSpec, + endpointsConfig: {}, + }), +})); + +jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => { + const React = jest.requireActual('react'); + return { + CustomMenuItem: React.forwardRef(function MockMenuItem( + { children, ...rest }: { children?: React.ReactNode }, + ref: React.Ref, + ) { + return React.createElement('div', { ref, role: 'menuitem', ...rest }, children); + }), + }; +}); + +jest.mock('../SpecIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useFavorites: () => ({ + isFavoriteSpec: () => mockIsFavoriteSpec, + toggleFavoriteSpec: mockToggleFavoriteSpec, + }), + useIsActiveItem: () => ({ ref: { current: null }, isActive: mockIsActive }), +})); + +const baseSpec: TModelSpec = { + name: 'my-spec', + label: 'My Spec', + preset: { + endpoint: 'openai', + model: 'gpt-5', + }, +}; + +describe('ModelSpecItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsFavoriteSpec = false; + mockIsActive = false; + }); + + it('renders the spec label and icon', () => { + render(); + expect(screen.getByText('My Spec')).toBeInTheDocument(); + expect(screen.getByTestId('spec-icon')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render( + , + ); + expect(screen.getByText('Fast and cheap')).toBeInTheDocument(); + }); + + it('renders aria-selected=true when isSelected', () => { + render(); + expect(screen.getByRole('menuitem')).toHaveAttribute('aria-selected', 'true'); + }); + + it('does NOT set aria-selected when not selected', () => { + render(); + expect(screen.getByRole('menuitem')).not.toHaveAttribute('aria-selected'); + }); + + it('calls handleSelectSpec on row click', () => { + render(); + fireEvent.click(screen.getByRole('menuitem')); + expect(mockHandleSelectSpec).toHaveBeenCalledWith(baseSpec); + }); + + describe('pin button', () => { + it('renders Pin icon with "com_ui_pin" label when not favorited', () => { + mockIsFavoriteSpec = false; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pin' })).toBeInTheDocument(); + }); + + it('renders PinOff icon with "com_ui_unpin" label when favorited', () => { + mockIsFavoriteSpec = true; + render(); + expect(screen.getByRole('button', { name: 'com_ui_unpin' })).toBeInTheDocument(); + }); + + it('calls toggleFavoriteSpec with spec.name on click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_pin' })); + expect(mockToggleFavoriteSpec).toHaveBeenCalledWith('my-spec'); + }); + + it('stops propagation so handleSelectSpec is not fired', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_pin' })); + expect(mockHandleSelectSpec).not.toHaveBeenCalled(); + }); + + it('has tabIndex=-1 when item is not active', () => { + mockIsActive = false; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '-1'); + }); + + it('has tabIndex=0 when item is active', () => { + mockIsActive = true; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '0'); + }); + }); +}); diff --git a/client/src/components/Nav/Favorites/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx index 248008869d3f..f373d26e9390 100644 --- a/client/src/components/Nav/Favorites/FavoriteItem.tsx +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -3,8 +3,9 @@ import * as Menu from '@ariakit/react/menu'; import { Ellipsis, PinOff } from 'lucide-react'; import { DropdownPopup } from '@librechat/client'; import { EModelEndpoint } from 'librechat-data-provider'; +import type { Agent, TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; import type { FavoriteModel } from '~/store/favorites'; -import type t from 'librechat-data-provider'; +import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; import { useFavorites, useLocalize } from '~/hooks'; import { renderAgentAvatar, cn } from '~/utils'; @@ -16,30 +17,44 @@ type Kwargs = { spec?: string | null; }; -type FavoriteItemProps = { - item: t.Agent | FavoriteModel; - type: 'agent' | 'model'; - onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void; +type FavoriteItemBaseProps = { onRemoveFocus?: () => void; }; -export default function FavoriteItem({ - item, - type, - onSelectEndpoint, - onRemoveFocus, -}: FavoriteItemProps) { +type AgentFavoriteProps = FavoriteItemBaseProps & { + type: 'agent'; + item: Agent; + onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void; +}; + +type ModelFavoriteProps = FavoriteItemBaseProps & { + type: 'model'; + item: FavoriteModel; + onSelectEndpoint?: (endpoint?: EModelEndpoint | string | null, kwargs?: Kwargs) => void; +}; + +type SpecFavoriteProps = FavoriteItemBaseProps & { + type: 'spec'; + item: TModelSpec; + onSelectSpec?: (spec: TModelSpec) => void; + endpointsConfig?: TEndpointsConfig; +}; + +type FavoriteItemProps = AgentFavoriteProps | ModelFavoriteProps | SpecFavoriteProps; + +export default function FavoriteItem(props: FavoriteItemProps) { + const { onRemoveFocus } = props; const localize = useLocalize(); - const { removeFavoriteAgent, removeFavoriteModel } = useFavorites(); + const { removeFavoriteAgent, removeFavoriteModel, removeFavoriteSpec } = useFavorites(); const [isPopoverActive, setIsPopoverActive] = useState(false); const handleSelect = () => { - if (type === 'agent') { - const agent = item as t.Agent; - onSelectEndpoint?.(EModelEndpoint.agents, { agent_id: agent.id }); + if (props.type === 'agent') { + props.onSelectEndpoint?.(EModelEndpoint.agents, { agent_id: props.item.id }); + } else if (props.type === 'spec') { + props.onSelectSpec?.(props.item); } else { - const model = item as FavoriteModel; - onSelectEndpoint?.(model.endpoint, { model: model.model }); + props.onSelectEndpoint?.(props.item.endpoint, { model: props.item.model }); } }; @@ -59,11 +74,12 @@ export default function FavoriteItem({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); - if (type === 'agent') { - removeFavoriteAgent((item as t.Agent).id); + if (props.type === 'agent') { + removeFavoriteAgent(props.item.id); + } else if (props.type === 'spec') { + removeFavoriteSpec(props.item.name); } else { - const model = item as FavoriteModel; - removeFavoriteModel(model.model, model.endpoint); + removeFavoriteModel(props.item.model, props.item.endpoint); } setIsPopoverActive(false); requestAnimationFrame(() => { @@ -72,26 +88,35 @@ export default function FavoriteItem({ }; const renderIcon = () => { - if (type === 'agent') { - return renderAgentAvatar(item as t.Agent, { size: 'icon', className: 'mr-2' }); + if (props.type === 'agent') { + return renderAgentAvatar(props.item, { size: 'icon', className: 'mr-2' }); + } + if (props.type === 'spec') { + return ( +
+ +
+ ); } - const model = item as FavoriteModel; return (
- +
); }; - const getName = (): string => { - if (type === 'agent') { - return (item as t.Agent).name ?? ''; - } - return (item as FavoriteModel).model; - }; - - const name = getName(); - const typeLabel = type === 'agent' ? localize('com_ui_agent') : localize('com_ui_model'); + let name: string; + let typeLabel: string; + if (props.type === 'agent') { + name = props.item.name ?? ''; + typeLabel = localize('com_ui_agent'); + } else if (props.type === 'spec') { + name = props.item.label; + typeLabel = localize('com_ui_model_spec'); + } else { + name = props.item.model; + typeLabel = localize('com_ui_model'); + } const ariaLabel = `${name} (${typeLabel})`; const menuId = React.useId(); diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index 934652349fa8..d36fb2de7142 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -1,23 +1,23 @@ import React, { useRef, useCallback, useMemo, useEffect } from 'react'; import { LayoutGrid } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { useDrag, useDrop } from 'react-dnd'; import { Skeleton } from '@librechat/client'; import { useNavigate } from 'react-router-dom'; import { useQueries } from '@tanstack/react-query'; -import { useRecoilValue } from 'recoil'; import { QueryKeys, dataService } from 'librechat-data-provider'; -import type t from 'librechat-data-provider'; +import type { Agent, TEndpointsConfig, TModelSpec } from 'librechat-data-provider'; import type { AgentQueryResult } from '~/common'; import { useGetConversation, - useShowMarketplace, useFavorites, useLocalize, + useShowMarketplace, useNewConvo, } from '~/hooks'; +import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import useSelectMention from '~/hooks/Input/useSelectMention'; -import { useGetEndpointsQuery } from '~/data-provider'; import FavoriteItem from './FavoriteItem'; import store from '~/store'; @@ -133,10 +133,24 @@ export default function FavoritesList({ const { newConversation } = useNewConvo(); const assistantsMap = useAssistantsMapContext(); const agentsMap = useAgentsMapContext(); - const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery(); + const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); + const { data: startupConfig } = useGetStartupConfig(); + + const modelSpecs = useMemo( + () => startupConfig?.modelSpecs?.list ?? [], + [startupConfig?.modelSpecs?.list], + ); + + const specsMap = useMemo(() => { + const map: Record = {}; + for (const spec of modelSpecs) { + map[spec.name] = spec; + } + return map; + }, [modelSpecs]); - const { onSelectEndpoint: _onSelectEndpoint } = useSelectMention({ - modelSpecs: [], + const { onSelectEndpoint: _onSelectEndpoint, onSelectSpec: _onSelectSpec } = useSelectMention({ + modelSpecs, assistantsMap, endpointsConfig, getConversation, @@ -154,6 +168,16 @@ export default function FavoritesList({ [_onSelectEndpoint, isSmallScreen, toggleNav], ); + const onSelectSpec = useCallback( + (...args: Parameters>) => { + _onSelectSpec?.(...args); + if (isSmallScreen && toggleNav) { + toggleNav(); + } + }, + [_onSelectSpec, isSmallScreen, toggleNav], + ); + const marketplaceRef = useRef(null); const listContainerRef = useRef(null); @@ -243,11 +267,36 @@ export default function FavoritesList({ } }, [staleAgentIdsKey, safeFavorites, reorderFavorites]); + const staleSpecNamesKey = useMemo(() => { + if (startupConfig === undefined) { + return ''; + } + return safeFavorites + .filter((f) => f.spec && !specsMap[f.spec]) + .map((f) => f.spec as string) + .sort() + .join(','); + }, [safeFavorites, specsMap, startupConfig]); + + const specCleanupAttemptedRef = useRef(''); + + useEffect(() => { + if (!staleSpecNamesKey || specCleanupAttemptedRef.current === staleSpecNamesKey) { + return; + } + const staleSet = new Set(staleSpecNamesKey.split(',')); + const cleaned = safeFavorites.filter((f) => !f.spec || !staleSet.has(f.spec)); + if (cleaned.length < safeFavorites.length) { + specCleanupAttemptedRef.current = staleSpecNamesKey; + reorderFavorites(cleaned, true); + } + }, [staleSpecNamesKey, safeFavorites, reorderFavorites]); + const combinedAgentsMap = useMemo(() => { if (agentsMap === undefined) { return undefined; } - const combined: Record = {}; + const combined: Record = {}; for (const [key, value] of Object.entries(agentsMap)) { if (value) { combined[key] = value; @@ -369,6 +418,28 @@ export default function FavoritesList({ /> ); + } else if (fav.spec) { + const spec = specsMap[fav.spec]; + if (!spec) { + return null; + } + return ( + + + + ); } else if (fav.model && fav.endpoint) { return ( ({ + useLocalize: () => (key: string) => key, + useFavorites: () => ({ + removeFavoriteAgent: mockRemoveFavoriteAgent, + removeFavoriteModel: mockRemoveFavoriteModel, + removeFavoriteSpec: mockRemoveFavoriteSpec, + }), +})); + +jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('~/components/Endpoints/MinimalIcon', () => ({ + __esModule: true, + default: () => , +})); + +jest.mock('~/utils', () => ({ + ...jest.requireActual('~/utils'), + renderAgentAvatar: () => , +})); + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + DropdownPopup: () =>
, +})); + +jest.mock('@ariakit/react/menu', () => ({ + MenuButton: ({ children }: { children?: React.ReactNode }) => ( + + ), +})); + +const baseAgent: Agent = { + id: 'agent-123', + name: 'Research Agent', + avatar: null, + provider: 'openai', + model: 'gpt-5', + instructions: '', + description: '', + tools: [], + created_at: 0, + updated_at: 0, + author: 'u1', +} as unknown as Agent; + +const baseModel: FavoriteModel = { model: 'gpt-5', endpoint: 'openai' }; + +const baseSpec: TModelSpec = { + name: 'my-spec', + label: 'My Model Spec', + preset: { endpoint: 'openai', model: 'gpt-5' }, +}; + +describe('FavoriteItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('type="agent"', () => { + it('renders the agent name and avatar', () => { + const onSelectEndpoint = jest.fn(); + render(); + expect(screen.getByText('Research Agent')).toBeInTheDocument(); + expect(screen.getByTestId('agent-avatar')).toBeInTheDocument(); + }); + + it('has aria-label formatted as " (com_ui_agent)"', () => { + render(); + expect( + screen.getByRole('button', { name: 'Research Agent (com_ui_agent)' }), + ).toBeInTheDocument(); + }); + + it('calls onSelectEndpoint with agents endpoint + agent_id on click', () => { + const onSelectEndpoint = jest.fn(); + render(); + fireEvent.click(screen.getByTestId('favorite-item')); + expect(onSelectEndpoint).toHaveBeenCalledWith('agents', { agent_id: 'agent-123' }); + }); + }); + + describe('type="model"', () => { + it('renders model name and minimal icon', () => { + render(); + expect(screen.getByText('gpt-5')).toBeInTheDocument(); + expect(screen.getByTestId('minimal-icon')).toBeInTheDocument(); + }); + + it('has aria-label formatted as " (com_ui_model)"', () => { + render(); + expect(screen.getByRole('button', { name: 'gpt-5 (com_ui_model)' })).toBeInTheDocument(); + }); + + it('calls onSelectEndpoint with endpoint + model on click', () => { + const onSelectEndpoint = jest.fn(); + render(); + fireEvent.click(screen.getByTestId('favorite-item')); + expect(onSelectEndpoint).toHaveBeenCalledWith('openai', { model: 'gpt-5' }); + }); + }); + + describe('type="spec"', () => { + it('renders the spec label and SpecIcon', () => { + render(); + expect(screen.getByText('My Model Spec')).toBeInTheDocument(); + expect(screen.getByTestId('spec-icon')).toBeInTheDocument(); + }); + + it('has aria-label formatted as "