mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
📌 feat: Add Pin Support for Model Specs (#11219)
* feat: Enhance favorites functionality to support model specs * refactor(FavoritesList): reorder imports based on lenght * feat: improve favorite modelSpec controller; refactor: useIsActiveItem hook * refactor: consolidate Favorite type, harden controller, add tests - Add canonical TUserFavorite type in data-provider, replace three duplicate definitions (data-service, data-schemas, store/favorites) - Consolidate FavoritesController spec validation into single block, add return on 500 paths, add maxlength to mongoose sub-schema - Fix import order in FavoritesList.tsx, merge namespace type imports in FavoriteItem.tsx and FavoritesList.tsx - Add focus-visible:ring-inset on pin buttons to prevent ring clipping - Add explicit return type and JSDoc on useIsActiveItem hook - Use props.type for narrowing consistency in FavoriteItem getTypeLabel - Add 22 backend tests for FavoritesController (spec validation, typeCount exclusivity, persistence, GET path) - Add 40 frontend tests: useFavorites spec methods, useIsActiveItem observer lifecycle, ModelSpecItem pin button, FavoriteItem all three type branches, FavoritesList spec rendering * fix: address PR review findings for pin model specs - Harden backend validation to reject partial cross-type fields (e.g. spec+endpoint, agentId+model without endpoint) - Add stale-spec auto-cleanup in FavoritesList mirroring agent cleanup - Add type="button" to pin buttons in ModelSpecItem/EndpointModelItem - Fix import order violations in EndpointModelItem and ModelSpecItem - Remove hollow test, dead key prop, inline trivial helpers - Fix misleading test description, add onSelectSpec to test mock - Add return to controller success responses for consistency - Add 6 backend tests for partial cross-type field validation * fix: guard stale-spec cleanup against unloaded startupConfig Prevents race condition where spec favorites are incorrectly deleted on cold start before startupConfig has loaded. Mirrors the existing agentsMap === undefined guard pattern used for stale agent cleanup. Also adds tests for stale-spec cleanup persistence and fixes namespace import pattern in FavoritesList.spec.tsx. * fix: replace nested ternaries with if/else in FavoriteItem Resolves ESLint no-nested-ternary warnings for name and typeLabel derivations. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
daa8f0ea6b
commit
1a83f36cda
22 changed files with 1313 additions and 124 deletions
|
|
@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAgent && !hasModel) {
|
||||
const hasPartialModel = !hasModel && !!(fav.model || fav.endpoint);
|
||||
|
||||
if (hasPartialModel && !hasAgent && !hasSpec) {
|
||||
return res.status(400).json({ message: 'model and endpoint must be provided together' });
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
308
api/server/controllers/FavoritesController.spec.js
Normal file
308
api/server/controllers/FavoritesController.spec.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -67,7 +67,8 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(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',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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<HTMLDivElement>();
|
||||
|
||||
let isGlobal = false;
|
||||
let modelName = modelId;
|
||||
|
|
@ -126,16 +109,19 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
|
|||
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={handleFavoriteClick}
|
||||
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
className={cn(
|
||||
'rounded-md p-1 hover:bg-surface-hover',
|
||||
isFavorite ? 'visible' : 'invisible group-hover:visible group-data-[active-item]:visible',
|
||||
'rounded-md p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary',
|
||||
isFavorite
|
||||
? 'visible'
|
||||
: 'invisible group-focus-within:visible group-hover:visible group-data-[active-item]:visible',
|
||||
)}
|
||||
>
|
||||
{isFavorite ? (
|
||||
<PinOff className="h-4 w-4 text-text-secondary" />
|
||||
<PinOff className="h-4 w-4 text-text-secondary" aria-hidden="true" />
|
||||
) : (
|
||||
<Pin className="h-4 w-4 text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { VisuallyHidden } from '@ariakit/react';
|
||||
import { CheckCircle2, Pin, PinOff } from 'lucide-react';
|
||||
import type { TModelSpec } from 'librechat-data-provider';
|
||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||
import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks';
|
||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||
import SpecIcon from './SpecIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
|
@ -16,15 +16,24 @@ interface ModelSpecItemProps {
|
|||
export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
|
||||
const localize = useLocalize();
|
||||
const { handleSelectSpec, endpointsConfig } = useModelSelectorContext();
|
||||
const { isFavoriteSpec, toggleFavoriteSpec } = useFavorites();
|
||||
const { showIconInMenu = true } = spec;
|
||||
|
||||
const { ref: itemRef, isActive } = useIsActiveItem<HTMLDivElement>();
|
||||
|
||||
const isFavorite = isFavoriteSpec(spec.name);
|
||||
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
toggleFavoriteSpec(spec.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={spec.name}
|
||||
ref={itemRef}
|
||||
onClick={() => handleSelectSpec(spec)}
|
||||
aria-selected={isSelected || undefined}
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm',
|
||||
)}
|
||||
className="group flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -44,6 +53,24 @@ export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={handleFavoriteClick}
|
||||
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
className={cn(
|
||||
'rounded-md p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary',
|
||||
isFavorite
|
||||
? 'visible'
|
||||
: 'invisible group-focus-within:visible group-hover:visible group-data-[active-item]:visible',
|
||||
)}
|
||||
>
|
||||
{isFavorite ? (
|
||||
<PinOff className="h-4 w-4 text-text-secondary" aria-hidden="true" />
|
||||
) : (
|
||||
<Pin className="h-4 w-4 text-text-secondary" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
{isSelected && (
|
||||
<>
|
||||
<CheckCircle2
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ jest.mock('~/hooks', () => ({
|
|||
isFavoriteAgent: () => false,
|
||||
toggleFavoriteAgent: jest.fn(),
|
||||
}),
|
||||
useIsActiveItem: () => ({ ref: { current: null }, isActive: false }),
|
||||
}));
|
||||
|
||||
const baseEndpoint: Endpoint = {
|
||||
|
|
|
|||
|
|
@ -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<typeof import('react')>('react');
|
||||
return {
|
||||
CustomMenuItem: React.forwardRef(function MockMenuItem(
|
||||
{ children, ...rest }: { children?: React.ReactNode },
|
||||
ref: React.Ref<HTMLDivElement>,
|
||||
) {
|
||||
return React.createElement('div', { ref, role: 'menuitem', ...rest }, children);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../SpecIcon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <span data-testid="spec-icon" />,
|
||||
}));
|
||||
|
||||
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(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
expect(screen.getByText('My Spec')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('spec-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(
|
||||
<ModelSpecItem spec={{ ...baseSpec, description: 'Fast and cheap' }} isSelected={false} />,
|
||||
);
|
||||
expect(screen.getByText('Fast and cheap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders aria-selected=true when isSelected', () => {
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={true} />);
|
||||
expect(screen.getByRole('menuitem')).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('does NOT set aria-selected when not selected', () => {
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
expect(screen.getByRole('menuitem')).not.toHaveAttribute('aria-selected');
|
||||
});
|
||||
|
||||
it('calls handleSelectSpec on row click', () => {
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
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(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_pin' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PinOff icon with "com_ui_unpin" label when favorited', () => {
|
||||
mockIsFavoriteSpec = true;
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_unpin' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls toggleFavoriteSpec with spec.name on click', () => {
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_pin' }));
|
||||
expect(mockToggleFavoriteSpec).toHaveBeenCalledWith('my-spec');
|
||||
});
|
||||
|
||||
it('stops propagation so handleSelectSpec is not fired', () => {
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
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(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
|
||||
it('has tabIndex=0 when item is active', () => {
|
||||
mockIsActive = true;
|
||||
render(<ModelSpecItem spec={baseSpec} isSelected={false} />);
|
||||
expect(screen.getByRole('button', { name: 'com_ui_pin' })).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<div className="mr-2 h-5 w-5">
|
||||
<SpecIcon currentSpec={props.item} endpointsConfig={props.endpointsConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const model = item as FavoriteModel;
|
||||
return (
|
||||
<div className="mr-2 h-5 w-5">
|
||||
<MinimalIcon endpoint={model.endpoint} size={20} isCreatedByUser={false} />
|
||||
<MinimalIcon endpoint={props.item.endpoint} size={20} isCreatedByUser={false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 { onSelectEndpoint: _onSelectEndpoint } = useSelectMention({
|
||||
modelSpecs: [],
|
||||
const modelSpecs = useMemo(
|
||||
() => startupConfig?.modelSpecs?.list ?? [],
|
||||
[startupConfig?.modelSpecs?.list],
|
||||
);
|
||||
|
||||
const specsMap = useMemo(() => {
|
||||
const map: Record<string, TModelSpec> = {};
|
||||
for (const spec of modelSpecs) {
|
||||
map[spec.name] = spec;
|
||||
}
|
||||
return map;
|
||||
}, [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<NonNullable<typeof _onSelectSpec>>) => {
|
||||
_onSelectSpec?.(...args);
|
||||
if (isSmallScreen && toggleNav) {
|
||||
toggleNav();
|
||||
}
|
||||
},
|
||||
[_onSelectSpec, isSmallScreen, toggleNav],
|
||||
);
|
||||
|
||||
const marketplaceRef = useRef<HTMLDivElement>(null);
|
||||
const listContainerRef = useRef<HTMLDivElement>(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<string, t.Agent> = {};
|
||||
const combined: Record<string, Agent> = {};
|
||||
for (const [key, value] of Object.entries(agentsMap)) {
|
||||
if (value) {
|
||||
combined[key] = value;
|
||||
|
|
@ -369,6 +418,28 @@ export default function FavoritesList({
|
|||
/>
|
||||
</DraggableFavoriteItem>
|
||||
);
|
||||
} else if (fav.spec) {
|
||||
const spec = specsMap[fav.spec];
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DraggableFavoriteItem
|
||||
key={`spec-${fav.spec}`}
|
||||
id={`spec-${fav.spec}`}
|
||||
index={index}
|
||||
moveItem={moveItem}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FavoriteItem
|
||||
item={spec}
|
||||
type="spec"
|
||||
onSelectSpec={onSelectSpec}
|
||||
onRemoveFocus={handleRemoveFocus}
|
||||
endpointsConfig={endpointsConfig}
|
||||
/>
|
||||
</DraggableFavoriteItem>
|
||||
);
|
||||
} else if (fav.model && fav.endpoint) {
|
||||
return (
|
||||
<DraggableFavoriteItem
|
||||
|
|
|
|||
150
client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx
Normal file
150
client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { Agent, TModelSpec } from 'librechat-data-provider';
|
||||
import type { FavoriteModel } from '~/store/favorites';
|
||||
import FavoriteItem from '../FavoriteItem';
|
||||
|
||||
const mockRemoveFavoriteAgent = jest.fn();
|
||||
const mockRemoveFavoriteModel = jest.fn();
|
||||
const mockRemoveFavoriteSpec = jest.fn();
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useFavorites: () => ({
|
||||
removeFavoriteAgent: mockRemoveFavoriteAgent,
|
||||
removeFavoriteModel: mockRemoveFavoriteModel,
|
||||
removeFavoriteSpec: mockRemoveFavoriteSpec,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <span data-testid="spec-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Endpoints/MinimalIcon', () => ({
|
||||
__esModule: true,
|
||||
default: () => <span data-testid="minimal-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
...jest.requireActual('~/utils'),
|
||||
renderAgentAvatar: () => <span data-testid="agent-avatar" />,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
DropdownPopup: () => <div data-testid="dropdown-popup" />,
|
||||
}));
|
||||
|
||||
jest.mock('@ariakit/react/menu', () => ({
|
||||
MenuButton: ({ children }: { children?: React.ReactNode }) => (
|
||||
<button type="button">{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
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(<FavoriteItem type="agent" item={baseAgent} onSelectEndpoint={onSelectEndpoint} />);
|
||||
expect(screen.getByText('Research Agent')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has aria-label formatted as "<name> (com_ui_agent)"', () => {
|
||||
render(<FavoriteItem type="agent" item={baseAgent} />);
|
||||
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(<FavoriteItem type="agent" item={baseAgent} onSelectEndpoint={onSelectEndpoint} />);
|
||||
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(<FavoriteItem type="model" item={baseModel} />);
|
||||
expect(screen.getByText('gpt-5')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('minimal-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has aria-label formatted as "<model> (com_ui_model)"', () => {
|
||||
render(<FavoriteItem type="model" item={baseModel} />);
|
||||
expect(screen.getByRole('button', { name: 'gpt-5 (com_ui_model)' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelectEndpoint with endpoint + model on click', () => {
|
||||
const onSelectEndpoint = jest.fn();
|
||||
render(<FavoriteItem type="model" item={baseModel} onSelectEndpoint={onSelectEndpoint} />);
|
||||
fireEvent.click(screen.getByTestId('favorite-item'));
|
||||
expect(onSelectEndpoint).toHaveBeenCalledWith('openai', { model: 'gpt-5' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('type="spec"', () => {
|
||||
it('renders the spec label and SpecIcon', () => {
|
||||
render(<FavoriteItem type="spec" item={baseSpec} />);
|
||||
expect(screen.getByText('My Model Spec')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('spec-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has aria-label formatted as "<label> (com_ui_model_spec)"', () => {
|
||||
render(<FavoriteItem type="spec" item={baseSpec} />);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'My Model Spec (com_ui_model_spec)' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelectSpec with the full spec on click', () => {
|
||||
const onSelectSpec = jest.fn();
|
||||
render(<FavoriteItem type="spec" item={baseSpec} onSelectSpec={onSelectSpec} />);
|
||||
fireEvent.click(screen.getByTestId('favorite-item'));
|
||||
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
|
||||
});
|
||||
|
||||
it('activates on Enter key', () => {
|
||||
const onSelectSpec = jest.fn();
|
||||
render(<FavoriteItem type="spec" item={baseSpec} onSelectSpec={onSelectSpec} />);
|
||||
fireEvent.keyDown(screen.getByTestId('favorite-item'), { key: 'Enter' });
|
||||
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
|
||||
});
|
||||
|
||||
it('activates on Space key', () => {
|
||||
const onSelectSpec = jest.fn();
|
||||
render(<FavoriteItem type="spec" item={baseSpec} onSelectSpec={onSelectSpec} />);
|
||||
fireEvent.keyDown(screen.getByTestId('favorite-item'), { key: ' ' });
|
||||
expect(onSelectSpec).toHaveBeenCalledWith(baseSpec);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import { DndProvider } from 'react-dnd';
|
|||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { dataService } from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
|
||||
// Mock store before importing FavoritesList
|
||||
jest.mock('~/store', () => {
|
||||
|
|
@ -33,6 +33,7 @@ type FavoriteItem = {
|
|||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
spec?: string;
|
||||
};
|
||||
|
||||
// Mock dataService
|
||||
|
|
@ -64,21 +65,36 @@ jest.mock('~/Providers', () => ({
|
|||
useAgentsMapContext: () => ({}),
|
||||
}));
|
||||
|
||||
const mockOnSelectSpec = jest.fn();
|
||||
jest.mock('~/hooks/Input/useSelectMention', () => () => ({
|
||||
onSelectEndpoint: jest.fn(),
|
||||
onSelectSpec: mockOnSelectSpec,
|
||||
}));
|
||||
|
||||
const mockUseGetStartupConfig = jest.fn(() => ({
|
||||
data: { modelSpecs: { list: [] as unknown[] } },
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetEndpointsQuery: () => ({ data: {} }),
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
jest.mock('../FavoriteItem', () => ({
|
||||
__esModule: true,
|
||||
default: ({ item, type }: { item: any; type: string }) => (
|
||||
<div data-testid="favorite-item" data-type={type}>
|
||||
{type === 'agent' ? item.name : item.model}
|
||||
</div>
|
||||
),
|
||||
default: ({ item, type }: { item: any; type: string }) => {
|
||||
let label = item.model;
|
||||
if (type === 'agent') {
|
||||
label = item.name;
|
||||
} else if (type === 'spec') {
|
||||
label = item.label;
|
||||
}
|
||||
return (
|
||||
<div data-testid="favorite-item" data-type={type}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const createTestQueryClient = () =>
|
||||
|
|
@ -107,6 +123,14 @@ describe('FavoritesList', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFavorites.length = 0;
|
||||
mockUseFavorites.mockImplementation(() => ({
|
||||
favorites: mockFavorites,
|
||||
reorderFavorites: jest.fn(),
|
||||
isLoading: false,
|
||||
}));
|
||||
mockUseGetStartupConfig.mockImplementation(() => ({
|
||||
data: { modelSpecs: { list: [] } },
|
||||
}));
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
|
|
@ -132,11 +156,11 @@ describe('FavoritesList', () => {
|
|||
|
||||
describe('missing agent handling', () => {
|
||||
it('should exclude missing agents (404) from rendered favorites and render valid agents', async () => {
|
||||
const validAgent: t.Agent = {
|
||||
const validAgent: Agent = {
|
||||
id: 'valid-agent',
|
||||
name: 'Valid Agent',
|
||||
author: 'test-author',
|
||||
} as t.Agent;
|
||||
} as Agent;
|
||||
|
||||
// Set up favorites with both valid and missing agent
|
||||
mockFavorites.push({ agentId: 'valid-agent' }, { agentId: 'deleted-agent' });
|
||||
|
|
@ -190,11 +214,11 @@ describe('FavoritesList', () => {
|
|||
});
|
||||
|
||||
it('should treat 403 the same as 404 — agent not rendered', async () => {
|
||||
const validAgent: t.Agent = {
|
||||
const validAgent: Agent = {
|
||||
id: 'valid-agent',
|
||||
name: 'Valid Agent',
|
||||
author: 'test-author',
|
||||
} as t.Agent;
|
||||
} as Agent;
|
||||
|
||||
mockFavorites.push({ agentId: 'valid-agent' }, { agentId: 'revoked-agent' });
|
||||
|
||||
|
|
@ -270,4 +294,103 @@ describe('FavoritesList', () => {
|
|||
expect(mockReorderFavorites).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model spec rendering', () => {
|
||||
it('renders a spec favorite when startupConfig has a matching spec', async () => {
|
||||
mockUseGetStartupConfig.mockImplementation(() => ({
|
||||
data: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'fast-spec',
|
||||
label: 'Fast Spec',
|
||||
preset: { endpoint: 'openai', model: 'gpt-5' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
mockFavorites.push({ spec: 'fast-spec' });
|
||||
|
||||
const { findAllByTestId } = renderWithProviders(<FavoritesList />);
|
||||
const items = await findAllByTestId('favorite-item');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toHaveAttribute('data-type', 'spec');
|
||||
expect(items[0]).toHaveTextContent('Fast Spec');
|
||||
});
|
||||
|
||||
it('skips a spec favorite when the spec is no longer in startupConfig', () => {
|
||||
mockUseGetStartupConfig.mockImplementation(() => ({
|
||||
data: { modelSpecs: { list: [] } },
|
||||
}));
|
||||
mockFavorites.push({ spec: 'stale-spec' });
|
||||
|
||||
const { queryAllByTestId } = renderWithProviders(<FavoritesList />);
|
||||
expect(queryAllByTestId('favorite-item')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calls reorderFavorites to auto-remove stale spec favorites', async () => {
|
||||
const mockReorderFavorites = jest.fn().mockResolvedValue(undefined);
|
||||
mockUseFavorites.mockReturnValue({
|
||||
favorites: [{ spec: 'stale-spec' }],
|
||||
reorderFavorites: mockReorderFavorites,
|
||||
isLoading: false,
|
||||
});
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: { modelSpecs: { list: [] } },
|
||||
});
|
||||
|
||||
renderWithProviders(<FavoritesList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReorderFavorites).toHaveBeenCalledWith([], true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clean up specs when startupConfig is still loading', async () => {
|
||||
const mockReorderFavorites = jest.fn().mockResolvedValue(undefined);
|
||||
mockUseFavorites.mockReturnValue({
|
||||
favorites: [{ spec: 'valid-spec' }],
|
||||
reorderFavorites: mockReorderFavorites,
|
||||
isLoading: false,
|
||||
});
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
|
||||
|
||||
renderWithProviders(<FavoritesList />);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(mockReorderFavorites).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a mix of agents, models, and specs', async () => {
|
||||
const validAgent: Agent = {
|
||||
id: 'a1',
|
||||
name: 'Agent One',
|
||||
author: 'me',
|
||||
} as Agent;
|
||||
mockUseGetStartupConfig.mockImplementation(() => ({
|
||||
data: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 's1',
|
||||
label: 'Spec One',
|
||||
preset: { endpoint: 'openai', model: 'gpt-5' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
mockFavorites.push({ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' });
|
||||
(dataService.getAgentById as jest.Mock).mockResolvedValue(validAgent);
|
||||
|
||||
const { findAllByTestId } = renderWithProviders(<FavoritesList />);
|
||||
const items = await findAllByTestId('favorite-item');
|
||||
|
||||
expect(items).toHaveLength(3);
|
||||
const types = items.map((el) => el.getAttribute('data-type'));
|
||||
expect(types).toEqual(['agent', 'model', 'spec']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
186
client/src/hooks/__tests__/useFavorites.spec.tsx
Normal file
186
client/src/hooks/__tests__/useFavorites.spec.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* @jest-environment @happy-dom/jest-environment
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Provider as JotaiProvider, createStore } from 'jotai';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { favoritesAtom } from '~/store';
|
||||
import useFavorites from '../useFavorites';
|
||||
import type { Favorite } from '~/store/favorites';
|
||||
|
||||
const mockMutateAsync = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockRefetch = jest.fn();
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: () => ({ showToast: mockShowToast }),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetFavoritesQuery: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUpdateFavoritesMutation: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const renderUseFavorites = (initialFavorites: Favorite[] = []) => {
|
||||
const store = createStore();
|
||||
store.set(favoritesAtom, initialFavorites);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>{children}</JotaiProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return { ...renderHook(() => useFavorites(), { wrapper }), store };
|
||||
};
|
||||
|
||||
describe('useFavorites — spec methods', () => {
|
||||
beforeEach(() => {
|
||||
mockMutateAsync.mockReset();
|
||||
mockShowToast.mockReset();
|
||||
mockRefetch.mockReset();
|
||||
mockMutateAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('addFavoriteSpec', () => {
|
||||
it('adds a new spec favorite and persists', async () => {
|
||||
const { result } = renderUseFavorites([]);
|
||||
await act(async () => {
|
||||
result.current.addFavoriteSpec('gpt-5-spec');
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'gpt-5-spec' }]);
|
||||
expect(result.current.favorites).toEqual([{ spec: 'gpt-5-spec' }]);
|
||||
});
|
||||
|
||||
it('is a no-op when spec is already favorited', async () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'gpt-5-spec' }]);
|
||||
await act(async () => {
|
||||
result.current.addFavoriteSpec('gpt-5-spec');
|
||||
});
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves existing agent/model favorites when adding a spec', async () => {
|
||||
const existing: Favorite[] = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }];
|
||||
const { result } = renderUseFavorites(existing);
|
||||
await act(async () => {
|
||||
result.current.addFavoriteSpec('my-spec');
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([
|
||||
{ agentId: 'a1' },
|
||||
{ model: 'gpt-5', endpoint: 'openai' },
|
||||
{ spec: 'my-spec' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFavoriteSpec', () => {
|
||||
it('removes an existing spec favorite', async () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'keep' }, { spec: 'drop' }]);
|
||||
await act(async () => {
|
||||
result.current.removeFavoriteSpec('drop');
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'keep' }]);
|
||||
});
|
||||
|
||||
it('still persists when the target spec is absent', async () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'keep' }]);
|
||||
await act(async () => {
|
||||
result.current.removeFavoriteSpec('missing');
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'keep' }]);
|
||||
expect(result.current.favorites).toEqual([{ spec: 'keep' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFavoriteSpec', () => {
|
||||
it('returns false for undefined / null / empty string', () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'x' }]);
|
||||
expect(result.current.isFavoriteSpec(undefined)).toBe(false);
|
||||
expect(result.current.isFavoriteSpec(null)).toBe(false);
|
||||
expect(result.current.isFavoriteSpec('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when spec is present', () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'x' }]);
|
||||
expect(result.current.isFavoriteSpec('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when spec is not present', () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'x' }]);
|
||||
expect(result.current.isFavoriteSpec('y')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleFavoriteSpec', () => {
|
||||
it('adds when absent', async () => {
|
||||
const { result } = renderUseFavorites([]);
|
||||
await act(async () => {
|
||||
result.current.toggleFavoriteSpec('new');
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([{ spec: 'new' }]);
|
||||
});
|
||||
|
||||
it('removes when present', async () => {
|
||||
const { result } = renderUseFavorites([{ spec: 'new' }]);
|
||||
await act(async () => {
|
||||
result.current.toggleFavoriteSpec('new');
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanFavorites (via saveFavorites)', () => {
|
||||
it('filters out entries with no canonical shape', async () => {
|
||||
const { result } = renderUseFavorites([]);
|
||||
await act(async () => {
|
||||
result.current.reorderFavorites(
|
||||
[
|
||||
{ agentId: 'a1' },
|
||||
{} as Favorite, // stripped
|
||||
{ spec: 's1' },
|
||||
{ model: 'm', endpoint: 'e' },
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([
|
||||
{ agentId: 'a1' },
|
||||
{ spec: 's1' },
|
||||
{ model: 'm', endpoint: 'e' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collapses mixed-shape entry to the first-matching canonical variant', async () => {
|
||||
const { result } = renderUseFavorites([]);
|
||||
await act(async () => {
|
||||
result.current.reorderFavorites(
|
||||
[
|
||||
// agentId takes priority in cleanFavorites
|
||||
{ agentId: 'a1', spec: 'ignored' } as Favorite,
|
||||
],
|
||||
true,
|
||||
);
|
||||
});
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith([{ agentId: 'a1' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
client/src/hooks/__tests__/useIsActiveItem.spec.tsx
Normal file
83
client/src/hooks/__tests__/useIsActiveItem.spec.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @jest-environment @happy-dom/jest-environment
|
||||
*/
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
|
||||
import useIsActiveItem from '../useIsActiveItem';
|
||||
|
||||
function Probe() {
|
||||
const { ref, isActive } = useIsActiveItem<HTMLDivElement>();
|
||||
return <div ref={ref} data-testid="probe" data-active={isActive ? 'true' : 'false'} />;
|
||||
}
|
||||
|
||||
const getProbe = (container: HTMLElement) =>
|
||||
container.querySelector('[data-testid="probe"]') as HTMLDivElement;
|
||||
|
||||
describe('useIsActiveItem', () => {
|
||||
it('starts with isActive=false when data-active-item is absent', () => {
|
||||
const { container } = render(<Probe />);
|
||||
expect(getProbe(container).getAttribute('data-active')).toBe('false');
|
||||
});
|
||||
|
||||
it('flips isActive to true when data-active-item is added after mount', async () => {
|
||||
const { container } = render(<Probe />);
|
||||
const probe = getProbe(container);
|
||||
|
||||
await act(async () => {
|
||||
probe.setAttribute('data-active-item', '');
|
||||
// Allow the MutationObserver microtask to run
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(probe.getAttribute('data-active')).toBe('true');
|
||||
});
|
||||
|
||||
it('flips isActive back to false when data-active-item is removed', async () => {
|
||||
const { container } = render(<Probe />);
|
||||
const probe = getProbe(container);
|
||||
|
||||
await act(async () => {
|
||||
probe.setAttribute('data-active-item', '');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(probe.getAttribute('data-active')).toBe('true');
|
||||
|
||||
await act(async () => {
|
||||
probe.removeAttribute('data-active-item');
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(probe.getAttribute('data-active')).toBe('false');
|
||||
});
|
||||
|
||||
it('ignores unrelated attribute mutations', async () => {
|
||||
const { container } = render(<Probe />);
|
||||
const probe = getProbe(container);
|
||||
|
||||
await act(async () => {
|
||||
probe.setAttribute('data-something-else', 'x');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(probe.getAttribute('data-active')).toBe('false');
|
||||
});
|
||||
|
||||
it('disconnects the MutationObserver on unmount', async () => {
|
||||
const disconnectSpy = jest.fn();
|
||||
const realObserver = globalThis.MutationObserver;
|
||||
class SpyObserver extends realObserver {
|
||||
disconnect(): void {
|
||||
disconnectSpy();
|
||||
super.disconnect();
|
||||
}
|
||||
}
|
||||
globalThis.MutationObserver = SpyObserver;
|
||||
|
||||
const { unmount } = render(<Probe />);
|
||||
unmount();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
|
||||
globalThis.MutationObserver = realObserver;
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,7 @@ export { default as useFocusTrap } from './useFocusTrap';
|
|||
export { default as useFavorites } from './useFavorites';
|
||||
export { default as useChatBadges } from './useChatBadges';
|
||||
export { default as useScrollToRef } from './useScrollToRef';
|
||||
export { default as useIsActiveItem } from './useIsActiveItem';
|
||||
export { default as useLocalStorage } from './useLocalStorage';
|
||||
export { default as useDocumentTitle } from './useDocumentTitle';
|
||||
export { default as useSpeechToText } from './Input/useSpeechToText';
|
||||
|
|
|
|||
|
|
@ -11,33 +11,39 @@ import { logger } from '~/utils';
|
|||
const MAX_FAVORITES = 50;
|
||||
|
||||
/**
|
||||
* Hook for managing user favorites (pinned agents and models).
|
||||
* Hook for managing user favorites (pinned agents, models, and model specs).
|
||||
*
|
||||
* Favorites are synchronized with the server via `/api/user/settings/favorites`.
|
||||
* Each favorite is either:
|
||||
* - An agent: `{ agentId: string }`
|
||||
* - A model: `{ model: string, endpoint: string }`
|
||||
* - A model spec: `{ spec: string }`
|
||||
*
|
||||
* @returns Object containing favorites state and helper methods for
|
||||
* adding, removing, toggling, reordering, and checking favorites.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cleans favorites array to only include canonical shapes (agentId or model+endpoint).
|
||||
* Cleans favorites array to only include canonical shapes (agentId, model+endpoint, or spec).
|
||||
*/
|
||||
const cleanFavorites = (favorites: Favorite[]): Favorite[] => {
|
||||
if (!Array.isArray(favorites)) {
|
||||
return [];
|
||||
}
|
||||
return favorites.map((f) => {
|
||||
if (f.agentId) {
|
||||
return { agentId: f.agentId };
|
||||
}
|
||||
if (f.model && f.endpoint) {
|
||||
return { model: f.model, endpoint: f.endpoint };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
return favorites
|
||||
.map((f) => {
|
||||
if (f.agentId) {
|
||||
return { agentId: f.agentId };
|
||||
}
|
||||
if (f.model && f.endpoint) {
|
||||
return { model: f.model, endpoint: f.endpoint };
|
||||
}
|
||||
if (f.spec) {
|
||||
return { spec: f.spec };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((f): f is NonNullable<typeof f> => f !== null);
|
||||
};
|
||||
|
||||
export default function useFavorites() {
|
||||
|
|
@ -137,6 +143,34 @@ export default function useFavorites() {
|
|||
return favorites.some((f) => f.model === model && f.endpoint === endpoint);
|
||||
};
|
||||
|
||||
const addFavoriteSpec = (spec: string) => {
|
||||
if (favorites.some((f) => f.spec === spec)) {
|
||||
return;
|
||||
}
|
||||
const newFavorites = [...favorites, { spec }];
|
||||
saveFavorites(newFavorites);
|
||||
};
|
||||
|
||||
const removeFavoriteSpec = (spec: string) => {
|
||||
const newFavorites = favorites.filter((f) => f.spec !== spec);
|
||||
saveFavorites(newFavorites);
|
||||
};
|
||||
|
||||
const isFavoriteSpec = (spec: string | undefined | null) => {
|
||||
if (!spec) {
|
||||
return false;
|
||||
}
|
||||
return favorites.some((f) => f.spec === spec);
|
||||
};
|
||||
|
||||
const toggleFavoriteSpec = (spec: string) => {
|
||||
if (isFavoriteSpec(spec)) {
|
||||
removeFavoriteSpec(spec);
|
||||
} else {
|
||||
addFavoriteSpec(spec);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavoriteAgent = (agentId: string) => {
|
||||
if (isFavoriteAgent(agentId)) {
|
||||
removeFavoriteAgent(agentId);
|
||||
|
|
@ -187,10 +221,14 @@ export default function useFavorites() {
|
|||
removeFavoriteAgent,
|
||||
addFavoriteModel,
|
||||
removeFavoriteModel,
|
||||
addFavoriteSpec,
|
||||
removeFavoriteSpec,
|
||||
isFavoriteAgent,
|
||||
isFavoriteModel,
|
||||
isFavoriteSpec,
|
||||
toggleFavoriteAgent,
|
||||
toggleFavoriteModel,
|
||||
toggleFavoriteSpec,
|
||||
reorderFavorites,
|
||||
/** Whether the favorites query is currently loading */
|
||||
isLoading: getFavoritesQuery.isLoading,
|
||||
|
|
|
|||
33
client/src/hooks/useIsActiveItem.ts
Normal file
33
client/src/hooks/useIsActiveItem.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useRef, useState, useEffect } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Mirrors Ariakit's composite `data-active-item` attribute into a React state value.
|
||||
* The ref must be attached to an element that mounts synchronously on first render;
|
||||
* late-mounting refs will not be observed.
|
||||
*/
|
||||
export default function useIsActiveItem<T extends HTMLElement = HTMLElement>(): {
|
||||
ref: RefObject<T>;
|
||||
isActive: boolean;
|
||||
} {
|
||||
const ref = useRef<T>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.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();
|
||||
}, []);
|
||||
|
||||
return { ref, isActive };
|
||||
}
|
||||
|
|
@ -1196,6 +1196,7 @@
|
|||
"com_ui_model_parameters": "Model Parameters",
|
||||
"com_ui_model_parameters_reset": "Model Parameters have been reset.",
|
||||
"com_ui_model_selected": "{{0}} selected",
|
||||
"com_ui_model_spec": "Model Spec",
|
||||
"com_ui_more_info": "More info",
|
||||
"com_ui_my_prompts": "My Prompts",
|
||||
"com_ui_name": "Name",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import type { TUserFavorite } from 'librechat-data-provider';
|
||||
import { createTabIsolatedAtom } from './jotai-utils';
|
||||
|
||||
export type Favorite = {
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
export type Favorite = TUserFavorite;
|
||||
|
||||
export type FavoriteModel = {
|
||||
model: string;
|
||||
|
|
|
|||
|
|
@ -25,17 +25,11 @@ export function deleteUser(payload?: t.TDeleteUserRequest): Promise<unknown> {
|
|||
return request.deleteWithOptions(endpoints.deleteUser(), { data: payload });
|
||||
}
|
||||
|
||||
export type FavoriteItem = {
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
export function getFavorites(): Promise<FavoriteItem[]> {
|
||||
export function getFavorites(): Promise<q.TUserFavorite[]> {
|
||||
return request.get(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`);
|
||||
}
|
||||
|
||||
export function updateFavorites(favorites: FavoriteItem[]): Promise<FavoriteItem[]> {
|
||||
export function updateFavorites(favorites: q.TUserFavorite[]): Promise<q.TUserFavorite[]> {
|
||||
return request.post(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`, { favorites });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -202,6 +202,18 @@ export interface MCPAuthValuesResponse {
|
|||
authValueFlags: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* User Favorites — pinned agents, models, and model specs.
|
||||
* Exactly one variant should be set per entry; exclusivity is enforced
|
||||
* server-side in FavoritesController. Shape is loose for state-update ergonomics.
|
||||
*/
|
||||
export type TUserFavorite = {
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
spec?: string;
|
||||
};
|
||||
|
||||
/* SharePoint Graph API Token */
|
||||
export type GraphTokenParams = {
|
||||
scopes: string;
|
||||
|
|
|
|||
|
|
@ -137,9 +137,10 @@ const userSchema = new Schema<IUser>(
|
|||
type: [
|
||||
{
|
||||
_id: false,
|
||||
agentId: String, // for agent
|
||||
model: String, // for model
|
||||
endpoint: String, // for model
|
||||
agentId: { type: String, maxlength: 256 },
|
||||
model: { type: String, maxlength: 256 },
|
||||
endpoint: { type: String, maxlength: 256 },
|
||||
spec: { type: String, maxlength: 256 },
|
||||
},
|
||||
],
|
||||
default: [],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Document, Types } from 'mongoose';
|
||||
import type { TUserFavorite } from 'librechat-data-provider';
|
||||
import { CursorPaginationParams } from '~/common';
|
||||
|
||||
export interface IUser extends Document {
|
||||
|
|
@ -41,11 +42,7 @@ export interface IUser extends Document {
|
|||
personalization?: {
|
||||
memories?: boolean;
|
||||
};
|
||||
favorites?: Array<{
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
}>;
|
||||
favorites?: TUserFavorite[];
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
/** Field for external source identification (for consistency with TPrincipal schema) */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue