mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
✨ feat: Implement Favorites functionality with controllers, hooks, and UI components
This commit is contained in:
parent
28cdc06209
commit
d2faf9c67d
19 changed files with 588 additions and 76 deletions
52
api/server/controllers/FavoritesController.js
Normal file
52
api/server/controllers/FavoritesController.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
const { User } = require('~/db/models');
|
||||||
|
|
||||||
|
const updateFavoritesController = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { favorites } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
if (!favorites) {
|
||||||
|
return res.status(400).json({ message: 'Favorites data is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findByIdAndUpdate(
|
||||||
|
userId,
|
||||||
|
{ $set: { favorites } },
|
||||||
|
{ new: true, select: 'favorites' },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(user.favorites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating favorites:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFavoritesController = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
const user = await User.findById(userId).select('favorites').lean();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const favorites = user.favorites || {};
|
||||||
|
res.status(200).json({
|
||||||
|
agents: favorites.agents || [],
|
||||||
|
models: favorites.models || [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching favorites:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
updateFavoritesController,
|
||||||
|
getFavoritesController,
|
||||||
|
};
|
||||||
13
api/server/routes/settings.js
Normal file
13
api/server/routes/settings.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
const express = require('express');
|
||||||
|
const {
|
||||||
|
updateFavoritesController,
|
||||||
|
getFavoritesController,
|
||||||
|
} = require('~/server/controllers/FavoritesController');
|
||||||
|
const { requireJwtAuth } = require('~/server/middleware');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/favorites', requireJwtAuth, getFavoritesController);
|
||||||
|
router.post('/favorites', requireJwtAuth, updateFavoritesController);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -15,8 +15,11 @@ const {
|
||||||
requireJwtAuth,
|
requireJwtAuth,
|
||||||
} = require('~/server/middleware');
|
} = require('~/server/middleware');
|
||||||
|
|
||||||
|
const settings = require('./settings');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use('/settings', settings);
|
||||||
router.get('/', requireJwtAuth, getUserController);
|
router.get('/', requireJwtAuth, getUserController);
|
||||||
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
router.get('/terms', requireJwtAuth, getTermsStatusController);
|
||||||
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
router.post('/terms/accept', requireJwtAuth, acceptTermsController);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
import { Label } from '@librechat/client';
|
import { Label } from '@librechat/client';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
|
import { useLocalize, TranslationKeys, useAgentCategories, useFavorites } from '~/hooks';
|
||||||
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
|
|
@ -16,6 +17,13 @@ interface AgentCardProps {
|
||||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { categories } = useAgentCategories();
|
const { categories } = useAgentCategories();
|
||||||
|
const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites();
|
||||||
|
const isFavorite = isFavoriteAgent(agent.id);
|
||||||
|
|
||||||
|
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFavoriteAgent(agent.id);
|
||||||
|
};
|
||||||
|
|
||||||
const categoryLabel = useMemo(() => {
|
const categoryLabel = useMemo(() => {
|
||||||
if (!agent.category) return '';
|
if (!agent.category) return '';
|
||||||
|
|
@ -48,59 +56,64 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
aria-describedby={`agent-${agent.id}-description`}
|
aria-describedby={`agent-${agent.id}-description`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Two column layout */}
|
<div className="absolute right-2 top-2 z-10">
|
||||||
<div className="flex h-full items-start gap-3">
|
<button
|
||||||
{/* Left column: Avatar and Category */}
|
onClick={handleFavoriteClick}
|
||||||
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
|
className="rounded-full p-1 hover:bg-surface-hover"
|
||||||
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
>
|
||||||
|
<Star
|
||||||
|
className={cn('h-5 w-5', isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Left column: Avatar and Category */}
|
||||||
|
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
|
||||||
|
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||||
|
|
||||||
{/* Category tag */}
|
{/* Category tag */}
|
||||||
{agent.category && (
|
{agent.category && (
|
||||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||||
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
|
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: Name, description, and other content */}
|
|
||||||
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{/* Agent name */}
|
|
||||||
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
|
||||||
{agent.name}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{/* Agent description */}
|
|
||||||
<p
|
|
||||||
id={`agent-${agent.id}-description`}
|
|
||||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
|
||||||
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
|
|
||||||
>
|
|
||||||
{agent.description ?? ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner info - moved to bottom right */}
|
{/* Right column: Name, description, and other content */}
|
||||||
{(() => {
|
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
|
||||||
const displayName = getContactDisplayName(agent);
|
<div className="space-y-1">
|
||||||
if (displayName) {
|
{/* Agent name */}
|
||||||
return (
|
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||||
<div className="flex justify-end">
|
{agent.name}
|
||||||
<div className="flex items-center text-sm text-text-secondary">
|
</Label>
|
||||||
<Label>{displayName}</Label>
|
|
||||||
|
{/* Agent description */}
|
||||||
|
<p
|
||||||
|
id={`agent-${agent.id}-description`}
|
||||||
|
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||||
|
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
|
||||||
|
>
|
||||||
|
{agent.description ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner info - moved to bottom right */}
|
||||||
|
{(() => {
|
||||||
|
const displayName = getContactDisplayName(agent);
|
||||||
|
if (displayName) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<div className="flex items-center text-sm text-text-secondary">
|
||||||
|
<Label>{displayName}</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
return null;
|
||||||
return null;
|
})()}
|
||||||
})()}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EarthIcon } from 'lucide-react';
|
import { EarthIcon, Star } from 'lucide-react';
|
||||||
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
import type { Endpoint } from '~/common';
|
import type { Endpoint } from '~/common';
|
||||||
|
import { useFavorites } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
|
|
||||||
|
|
@ -11,8 +13,9 @@ interface EndpointModelItemProps {
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
|
export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) {
|
||||||
const { handleSelectModel } = useModelSelectorContext();
|
const { handleSelectModel } = useModelSelectorContext();
|
||||||
|
const { isFavoriteModel, toggleFavoriteModel } = useFavorites();
|
||||||
let isGlobal = false;
|
let isGlobal = false;
|
||||||
let modelName = modelId;
|
let modelName = modelId;
|
||||||
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
|
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
|
||||||
|
|
@ -32,11 +35,20 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
|
||||||
modelName = endpoint.assistantNames[modelId];
|
modelName = endpoint.assistantNames[modelId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFavorite = isFavoriteModel(modelId ?? '', endpoint.value);
|
||||||
|
|
||||||
|
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (modelId) {
|
||||||
|
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value, label: modelName ?? undefined });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={modelId}
|
key={modelId}
|
||||||
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
||||||
className="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="flex w-full min-w-0 items-center gap-2 px-1 py-1">
|
<div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
|
|
@ -48,31 +60,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
|
||||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||||
{endpoint.icon}
|
{endpoint.icon}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
<span className="truncate text-left">{modelName}</span>
|
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full" />
|
||||||
{isGlobal && (
|
|
||||||
<EarthIcon className="ml-auto size-4 flex-shrink-0 self-center text-green-400" />
|
|
||||||
)}
|
)}
|
||||||
|
<span className="truncate">{modelName}</span>
|
||||||
|
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
<button
|
||||||
<div className="flex-shrink-0 self-center">
|
onClick={handleFavoriteClick}
|
||||||
<svg
|
className={cn(
|
||||||
width="16"
|
'rounded-full p-1 hover:bg-surface-hover',
|
||||||
height="16"
|
isFavorite ? 'visible' : 'invisible group-hover:visible',
|
||||||
viewBox="0 0 24 24"
|
)}
|
||||||
fill="none"
|
>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<Star
|
||||||
className="block"
|
className={cn(
|
||||||
>
|
'h-4 w-4',
|
||||||
<path
|
isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary',
|
||||||
fillRule="evenodd"
|
)}
|
||||||
clipRule="evenodd"
|
/>
|
||||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
</button>
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
client/src/components/Nav/Favorites/FavoriteItem.tsx
Normal file
144
client/src/components/Nav/Favorites/FavoriteItem.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import * as Menu from '@ariakit/react/menu';
|
||||||
|
import { Ellipsis, Trash } from 'lucide-react';
|
||||||
|
import { DropdownPopup } from '@librechat/client';
|
||||||
|
import { useNewConvo, useFavorites, useLocalize } from '~/hooks';
|
||||||
|
import { renderAgentAvatar, cn } from '~/utils';
|
||||||
|
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
import type { FavoriteModel } from '~/store/favorites';
|
||||||
|
|
||||||
|
type FavoriteItemProps = {
|
||||||
|
item: t.Agent | FavoriteModel;
|
||||||
|
type: 'agent' | 'model';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { newConversation } = useNewConvo();
|
||||||
|
const { removeFavoriteAgent, removeFavoriteModel } = useFavorites();
|
||||||
|
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('[data-testid="favorite-options-button"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'agent') {
|
||||||
|
const agent = item as t.Agent;
|
||||||
|
newConversation({
|
||||||
|
template: {
|
||||||
|
...agent,
|
||||||
|
agent_id: agent.id,
|
||||||
|
},
|
||||||
|
preset: {
|
||||||
|
...agent,
|
||||||
|
agent_id: agent.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
navigate(`/c/new`);
|
||||||
|
} else {
|
||||||
|
const model = item as FavoriteModel;
|
||||||
|
newConversation({
|
||||||
|
template: {
|
||||||
|
endpoint: model.endpoint,
|
||||||
|
model: model.model,
|
||||||
|
},
|
||||||
|
preset: {
|
||||||
|
endpoint: model.endpoint,
|
||||||
|
model: model.model,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
navigate(`/c/new`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (type === 'agent') {
|
||||||
|
removeFavoriteAgent((item as t.Agent).id);
|
||||||
|
} else {
|
||||||
|
const model = item as FavoriteModel;
|
||||||
|
removeFavoriteModel(model.model, model.endpoint);
|
||||||
|
}
|
||||||
|
setIsPopoverActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (type === 'agent') {
|
||||||
|
return renderAgentAvatar(item as t.Agent, { size: 20, className: 'mr-2' });
|
||||||
|
}
|
||||||
|
const model = item as FavoriteModel;
|
||||||
|
return (
|
||||||
|
<div className="mr-2 h-5 w-5">
|
||||||
|
<EndpointIcon
|
||||||
|
conversation={{ endpoint: model.endpoint, model: model.model } as any}
|
||||||
|
endpoint={model.endpoint}
|
||||||
|
model={model.model}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getName = () => {
|
||||||
|
if (type === 'agent') {
|
||||||
|
return (item as t.Agent).name;
|
||||||
|
}
|
||||||
|
return (item as FavoriteModel).label || (item as FavoriteModel).model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuId = React.useId();
|
||||||
|
|
||||||
|
const dropdownItems = [
|
||||||
|
{
|
||||||
|
label: localize('com_ui_remove'),
|
||||||
|
onClick: handleRemove,
|
||||||
|
icon: <Trash className="h-4 w-4 text-text-primary" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary hover:bg-surface-hover"
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="favorite-item"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center truncate pr-6">
|
||||||
|
{renderIcon()}
|
||||||
|
<span className="truncate">{getName()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute right-2 flex items-center',
|
||||||
|
isPopoverActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownPopup
|
||||||
|
portal={true}
|
||||||
|
mountByState={true}
|
||||||
|
isOpen={isPopoverActive}
|
||||||
|
setIsOpen={setIsPopoverActive}
|
||||||
|
trigger={
|
||||||
|
<Menu.MenuButton
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded-md hover:bg-surface-hover focus:bg-surface-hover',
|
||||||
|
isPopoverActive ? 'bg-surface-hover' : '',
|
||||||
|
)}
|
||||||
|
aria-label={localize('com_ui_options')}
|
||||||
|
data-testid="favorite-options-button"
|
||||||
|
>
|
||||||
|
<Ellipsis className="h-4 w-4 text-text-secondary hover:text-text-primary" />
|
||||||
|
</Menu.MenuButton>
|
||||||
|
}
|
||||||
|
items={dropdownItems}
|
||||||
|
menuId={menuId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
client/src/components/Nav/Favorites/FavoritesList.tsx
Normal file
53
client/src/components/Nav/Favorites/FavoritesList.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
|
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||||
|
import { useFavorites, useLocalStorage } from '~/hooks';
|
||||||
|
import FavoriteItem from './FavoriteItem';
|
||||||
|
|
||||||
|
export default function FavoritesList() {
|
||||||
|
const { favorites } = useFavorites();
|
||||||
|
const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true);
|
||||||
|
|
||||||
|
const agentQueries = useQueries({
|
||||||
|
queries: (favorites.agents || []).map((agentId) => ({
|
||||||
|
queryKey: [QueryKeys.agent, agentId],
|
||||||
|
queryFn: () => dataService.getAgentById({ agent_id: agentId }),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoriteAgents = agentQueries
|
||||||
|
.map((query) => query.data)
|
||||||
|
.filter((agent) => agent !== undefined);
|
||||||
|
|
||||||
|
const favoriteModels = favorites.models || [];
|
||||||
|
|
||||||
|
if ((favorites.agents || []).length === 0 && (favorites.models || []).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible.Root
|
||||||
|
open={isExpanded}
|
||||||
|
onOpenChange={setIsExpanded}
|
||||||
|
className="flex flex-col py-2"
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger className="group flex w-full items-center gap-2 px-3 py-1 text-xs font-bold text-text-secondary hover:text-text-primary">
|
||||||
|
<ChevronRight className="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||||
|
<span className="select-none">Favorites</span>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content className="collapsible-content">
|
||||||
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
|
{favoriteAgents.map((agent) => (
|
||||||
|
<FavoriteItem key={agent!.id} item={agent!} type="agent" />
|
||||||
|
))}
|
||||||
|
{favoriteModels.map((model) => (
|
||||||
|
<FavoriteItem key={`${model.endpoint}-${model.model}`} item={model} type="model" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import store from '~/store';
|
||||||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||||
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
|
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
|
||||||
|
const FavoritesList = lazy(() => import('./Favorites/FavoritesList'));
|
||||||
|
|
||||||
const NAV_WIDTH_DESKTOP = '260px';
|
const NAV_WIDTH_DESKTOP = '260px';
|
||||||
const NAV_WIDTH_MOBILE = '320px';
|
const NAV_WIDTH_MOBILE = '320px';
|
||||||
|
|
@ -152,7 +153,14 @@ const Nav = memo(
|
||||||
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
|
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
|
||||||
|
|
||||||
const subHeaders = useMemo(
|
const subHeaders = useMemo(
|
||||||
() => search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />,
|
() => (
|
||||||
|
<>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<FavoritesList />
|
||||||
|
</Suspense>
|
||||||
|
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
|
||||||
|
</>
|
||||||
|
),
|
||||||
[search.enabled, isSmallScreen],
|
[search.enabled, isSmallScreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
24
client/src/data-provider/Favorites.ts
Normal file
24
client/src/data-provider/Favorites.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { dataService } from 'librechat-data-provider';
|
||||||
|
import type { FavoritesState } from '~/store/favorites';
|
||||||
|
|
||||||
|
export const useGetFavoritesQuery = (config?: any) => {
|
||||||
|
return useQuery<FavoritesState>(['favorites'], () => dataService.getFavorites() as Promise<FavoritesState>, {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdateFavoritesMutation = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(
|
||||||
|
(favorites: FavoritesState) => dataService.updateFavorites(favorites) as Promise<FavoritesState>,
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['favorites'], data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -8,6 +8,7 @@ export * from './Messages';
|
||||||
export * from './Misc';
|
export * from './Misc';
|
||||||
export * from './Tools';
|
export * from './Tools';
|
||||||
export * from './connection';
|
export * from './connection';
|
||||||
|
export * from './Favorites';
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
export * from './prompts';
|
export * from './prompts';
|
||||||
export * from './queries';
|
export * from './queries';
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,5 @@ export { default as useDocumentTitle } from './useDocumentTitle';
|
||||||
export { default as useSpeechToText } from './Input/useSpeechToText';
|
export { default as useSpeechToText } from './Input/useSpeechToText';
|
||||||
export { default as useTextToSpeech } from './Input/useTextToSpeech';
|
export { default as useTextToSpeech } from './Input/useTextToSpeech';
|
||||||
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
|
||||||
|
export { default as useFavorites } from './useFavorites';
|
||||||
export { useResourcePermissions } from './useResourcePermissions';
|
export { useResourcePermissions } from './useResourcePermissions';
|
||||||
|
|
|
||||||
99
client/src/hooks/useFavorites.ts
Normal file
99
client/src/hooks/useFavorites.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
import store from '~/store';
|
||||||
|
import type { FavoriteModel } from '~/store/favorites';
|
||||||
|
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
|
||||||
|
|
||||||
|
export default function useFavorites() {
|
||||||
|
const [favorites, setFavorites] = useRecoilState(store.favorites);
|
||||||
|
const getFavoritesQuery = useGetFavoritesQuery();
|
||||||
|
const updateFavoritesMutation = useUpdateFavoritesMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (getFavoritesQuery.data) {
|
||||||
|
setFavorites({
|
||||||
|
agents: getFavoritesQuery.data.agents || [],
|
||||||
|
models: getFavoritesQuery.data.models || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [getFavoritesQuery.data, setFavorites]);
|
||||||
|
|
||||||
|
const saveFavorites = (newFavorites: typeof favorites) => {
|
||||||
|
setFavorites(newFavorites);
|
||||||
|
updateFavoritesMutation.mutate(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFavoriteAgent = (id: string) => {
|
||||||
|
const agents = favorites?.agents || [];
|
||||||
|
if (agents.includes(id)) return;
|
||||||
|
const newFavorites = {
|
||||||
|
...favorites,
|
||||||
|
agents: [...agents, id],
|
||||||
|
};
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFavoriteAgent = (id: string) => {
|
||||||
|
const agents = favorites?.agents || [];
|
||||||
|
const newFavorites = {
|
||||||
|
...favorites,
|
||||||
|
agents: agents.filter((item) => item !== id),
|
||||||
|
};
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFavoriteModel = (model: FavoriteModel) => {
|
||||||
|
const models = favorites?.models || [];
|
||||||
|
if (models.some((m) => m.model === model.model && m.endpoint === model.endpoint)) return;
|
||||||
|
const newFavorites = {
|
||||||
|
...favorites,
|
||||||
|
models: [...models, model],
|
||||||
|
};
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFavoriteModel = (model: string, endpoint: string) => {
|
||||||
|
const models = favorites?.models || [];
|
||||||
|
const newFavorites = {
|
||||||
|
...favorites,
|
||||||
|
models: models.filter((m) => !(m.model === model && m.endpoint === endpoint)),
|
||||||
|
};
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFavoriteAgent = (id: string) => {
|
||||||
|
return (favorites?.agents || []).includes(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFavoriteModel = (model: string, endpoint: string) => {
|
||||||
|
return (favorites?.models || []).some((m) => m.model === model && m.endpoint === endpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavoriteAgent = (id: string) => {
|
||||||
|
if (isFavoriteAgent(id)) {
|
||||||
|
removeFavoriteAgent(id);
|
||||||
|
} else {
|
||||||
|
addFavoriteAgent(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavoriteModel = (model: FavoriteModel) => {
|
||||||
|
if (isFavoriteModel(model.model, model.endpoint)) {
|
||||||
|
removeFavoriteModel(model.model, model.endpoint);
|
||||||
|
} else {
|
||||||
|
addFavoriteModel(model);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
favorites,
|
||||||
|
addFavoriteAgent,
|
||||||
|
removeFavoriteAgent,
|
||||||
|
addFavoriteModel,
|
||||||
|
removeFavoriteModel,
|
||||||
|
isFavoriteAgent,
|
||||||
|
isFavoriteModel,
|
||||||
|
toggleFavoriteAgent,
|
||||||
|
toggleFavoriteModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -851,6 +851,7 @@
|
||||||
"com_ui_date_yesterday": "Yesterday",
|
"com_ui_date_yesterday": "Yesterday",
|
||||||
"com_ui_decline": "I do not accept",
|
"com_ui_decline": "I do not accept",
|
||||||
"com_ui_default_post_request": "Default (POST request)",
|
"com_ui_default_post_request": "Default (POST request)",
|
||||||
|
"com_ui_remove": "Remove",
|
||||||
"com_ui_delete": "Delete",
|
"com_ui_delete": "Delete",
|
||||||
"com_ui_delete_action": "Delete Action",
|
"com_ui_delete_action": "Delete Action",
|
||||||
"com_ui_delete_action_confirm": "Are you sure you want to delete this action?",
|
"com_ui_delete_action_confirm": "Are you sure you want to delete this action?",
|
||||||
|
|
|
||||||
24
client/src/store/favorites.ts
Normal file
24
client/src/store/favorites.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { atom } from 'recoil';
|
||||||
|
|
||||||
|
export type FavoriteModel = {
|
||||||
|
model: string;
|
||||||
|
endpoint: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FavoritesState = {
|
||||||
|
agents: string[];
|
||||||
|
models: FavoriteModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const favorites = atom<FavoritesState>({
|
||||||
|
key: 'favorites',
|
||||||
|
default: {
|
||||||
|
agents: [],
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
favorites,
|
||||||
|
};
|
||||||
|
|
@ -12,6 +12,7 @@ import lang from './language';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
import misc from './misc';
|
import misc from './misc';
|
||||||
import isTemporary from './temporary';
|
import isTemporary from './temporary';
|
||||||
|
import favorites from './favorites';
|
||||||
export * from './agents';
|
export * from './agents';
|
||||||
export * from './mcp';
|
export * from './mcp';
|
||||||
|
|
||||||
|
|
@ -30,4 +31,5 @@ export default {
|
||||||
...settings,
|
...settings,
|
||||||
...misc,
|
...misc,
|
||||||
...isTemporary,
|
...isTemporary,
|
||||||
|
...favorites,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2836,3 +2836,32 @@ html {
|
||||||
.sharepoint-picker-bg{
|
.sharepoint-picker-bg{
|
||||||
background-color: #F5F5F5;
|
background-color: #F5F5F5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible Animation */
|
||||||
|
.collapsible-content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.collapsible-content[data-state='open'] {
|
||||||
|
animation: slideDown 300ms ease-out;
|
||||||
|
}
|
||||||
|
.collapsible-content[data-state='closed'] {
|
||||||
|
animation: slideUp 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ export function deleteUser(): Promise<s.TPreset> {
|
||||||
return request.delete(endpoints.deleteUser());
|
return request.delete(endpoints.deleteUser());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFavorites(): Promise<unknown> {
|
||||||
|
return request.get('/api/user/settings/favorites');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFavorites(favorites: unknown): Promise<unknown> {
|
||||||
|
return request.post('/api/user/settings/favorites', { favorites });
|
||||||
|
}
|
||||||
|
|
||||||
export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesResponse> {
|
export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesResponse> {
|
||||||
return request.get(endpoints.shareMessages(shareId));
|
return request.get(endpoints.shareMessages(shareId));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,28 @@ const userSchema = new Schema<IUser>(
|
||||||
},
|
},
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
|
favorites: {
|
||||||
|
type: {
|
||||||
|
agents: {
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
type: [
|
||||||
|
{
|
||||||
|
model: String,
|
||||||
|
endpoint: String,
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
agents: [],
|
||||||
|
models: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
/** Field for external source identification (for consistency with TPrincipal schema) */
|
/** Field for external source identification (for consistency with TPrincipal schema) */
|
||||||
idOnTheSource: {
|
idOnTheSource: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ export interface IUser extends Document {
|
||||||
personalization?: {
|
personalization?: {
|
||||||
memories?: boolean;
|
memories?: boolean;
|
||||||
};
|
};
|
||||||
|
favorites?: {
|
||||||
|
agents: string[];
|
||||||
|
models: Array<{
|
||||||
|
model: string;
|
||||||
|
endpoint: string;
|
||||||
|
label?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
/** Field for external source identification (for consistency with TPrincipal schema) */
|
/** Field for external source identification (for consistency with TPrincipal schema) */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue