mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
✨ feat: Enhance favorites management with validation, update data structure, and improve UI interactions
This commit is contained in:
parent
bfeae91a96
commit
960e2ee527
10 changed files with 117 additions and 31 deletions
|
|
@ -9,6 +9,28 @@ const updateFavoritesController = async (req, res) => {
|
|||
return res.status(400).json({ message: 'Favorites data is required' });
|
||||
}
|
||||
|
||||
// Validate favorites structure
|
||||
if (!Array.isArray(favorites)) {
|
||||
return res.status(400).json({ message: 'Favorites must be an array' });
|
||||
}
|
||||
|
||||
for (const fav of favorites) {
|
||||
const hasAgent = !!fav.agentId;
|
||||
const hasModel = !!(fav.model && fav.endpoint);
|
||||
|
||||
if (!hasAgent && !hasModel) {
|
||||
return res.status(400).json({
|
||||
message: 'Each favorite must have either agentId or model+endpoint',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAgent && hasModel) {
|
||||
return res.status(400).json({
|
||||
message: 'Favorite cannot have both agentId and model/endpoint',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
userId,
|
||||
{ $set: { favorites } },
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
|||
aria-describedby={`agent-${agent.id}-description`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
}
|
||||
let rendering: JSX.Element;
|
||||
if (item.type === 'favorites') {
|
||||
rendering = <FavoritesList />;
|
||||
rendering = <FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />;
|
||||
} else if (item.type === 'chats-header') {
|
||||
rendering = (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
|||
return (
|
||||
<div className="mr-2 h-5 w-5">
|
||||
<EndpointIcon
|
||||
conversation={{ endpoint: model.endpoint, model: model.model } as any}
|
||||
conversation={{ endpoint: model.endpoint, model: model.model } as t.TConversation}
|
||||
endpoint={model.endpoint}
|
||||
model={model.model}
|
||||
size={20}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React, { useRef, useCallback, useMemo } from 'react';
|
||||
import React, { useRef, useCallback, useMemo, useContext } from 'react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||
import { QueryKeys, dataService, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query';
|
||||
import type { InfiniteData } from '@tanstack/react-query';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useFavorites } from '~/hooks';
|
||||
import { useFavorites, useLocalize, useHasAccess, AuthContext } from '~/hooks';
|
||||
import FavoriteItem from './FavoriteItem';
|
||||
|
||||
interface DraggableFavoriteItemProps {
|
||||
|
|
@ -45,7 +47,10 @@ const DraggableFavoriteItem = ({
|
|||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as any).y - hoverBoundingRect.top;
|
||||
if (!clientOffset) {
|
||||
return;
|
||||
}
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||
return;
|
||||
|
|
@ -84,10 +89,44 @@ const DraggableFavoriteItem = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default function FavoritesList() {
|
||||
export default function FavoritesList({
|
||||
isSmallScreen,
|
||||
toggleNav,
|
||||
}: {
|
||||
isSmallScreen?: boolean;
|
||||
toggleNav?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const authContext = useContext(AuthContext);
|
||||
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
|
||||
|
||||
const hasAccessToAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const hasAccessToMarketplace = useHasAccess({
|
||||
permissionType: PermissionTypes.MARKETPLACE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
// Check if auth is ready (avoid race conditions)
|
||||
const authReady =
|
||||
authContext?.isAuthenticated !== undefined &&
|
||||
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
|
||||
|
||||
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
|
||||
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
|
||||
|
||||
const handleAgentMarketplace = useCallback(() => {
|
||||
navigate('/agents');
|
||||
if (isSmallScreen && toggleNav) {
|
||||
toggleNav();
|
||||
}
|
||||
}, [navigate, isSmallScreen, toggleNav]);
|
||||
|
||||
const agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[];
|
||||
|
||||
const agentQueries = useQueries({
|
||||
|
|
@ -146,13 +185,29 @@ export default function FavoritesList() {
|
|||
persistFavorites(favorites);
|
||||
}, [favorites, persistFavorites]);
|
||||
|
||||
if (favorites.length === 0) {
|
||||
// If no favorites and no marketplace to show, return null
|
||||
if (favorites.length === 0 && !showAgentMarketplace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-col pb-2">
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{/* Agent Marketplace button - identical styling to favorite items */}
|
||||
{showAgentMarketplace && (
|
||||
<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-active-alt"
|
||||
onClick={handleAgentMarketplace}
|
||||
data-testid="nav-agents-marketplace-button"
|
||||
>
|
||||
<div className="flex flex-1 items-center truncate pr-6">
|
||||
<div className="mr-2 h-5 w-5">
|
||||
<LayoutGrid className="h-5 w-5 text-text-primary" />
|
||||
</div>
|
||||
<span className="truncate">{localize('com_agents_marketplace')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{favorites.map((fav, index) => {
|
||||
if (fav.agentId) {
|
||||
const agent = agentsMap[fav.agentId];
|
||||
|
|
@ -177,7 +232,7 @@ export default function FavoritesList() {
|
|||
moveItem={moveItem}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FavoriteItem item={fav as any} type="model" />
|
||||
<FavoriteItem item={{ model: fav.model, endpoint: fav.endpoint }} type="model" />
|
||||
</DraggableFavoriteItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import store from '~/store';
|
|||
|
||||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
|
||||
|
||||
const NAV_WIDTH_DESKTOP = '260px';
|
||||
const NAV_WIDTH_MOBILE = '320px';
|
||||
|
|
@ -160,9 +159,6 @@ const Nav = memo(
|
|||
const headerButtons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Suspense fallback={null}>
|
||||
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
||||
</Suspense>
|
||||
{hasAccessToBookmarks && (
|
||||
<>
|
||||
<div className="mt-1.5" />
|
||||
|
|
@ -173,7 +169,7 @@ const Nav = memo(
|
|||
)}
|
||||
</>
|
||||
),
|
||||
[hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible],
|
||||
[hasAccessToBookmarks, tags, isSmallScreen],
|
||||
);
|
||||
|
||||
const [isSearchLoading, setIsSearchLoading] = useState(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { Favorite } from '~/store/favorites';
|
||||
import store from '~/store';
|
||||
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
|
||||
|
||||
|
|
@ -11,13 +12,15 @@ export default function useFavorites() {
|
|||
useEffect(() => {
|
||||
if (getFavoritesQuery.data) {
|
||||
if (Array.isArray(getFavoritesQuery.data)) {
|
||||
const mapped = getFavoritesQuery.data.map((f: any) => {
|
||||
if (f.agentId || (f.model && f.endpoint)) return f;
|
||||
if (f.type === 'agent' && f.id) return { agentId: f.id };
|
||||
// Drop label and map legacy model format
|
||||
if (f.type === 'model') return { model: f.model, endpoint: f.endpoint };
|
||||
return f;
|
||||
});
|
||||
const mapped = getFavoritesQuery.data.map(
|
||||
(f: Favorite & { type?: string; id?: string }) => {
|
||||
if (f.agentId || (f.model && f.endpoint)) return f;
|
||||
if (f.type === 'agent' && f.id) return { agentId: f.id };
|
||||
// Drop label and map legacy model format
|
||||
if (f.type === 'model') return { model: f.model, endpoint: f.endpoint };
|
||||
return f;
|
||||
},
|
||||
);
|
||||
setFavorites(mapped);
|
||||
} else {
|
||||
// Handle legacy format or invalid data
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
"com_agents_link_copy_failed": "Failed to copy link",
|
||||
"com_agents_load_more_label": "Load more agents from {{category}} category",
|
||||
"com_agents_loading": "Loading...",
|
||||
"com_agents_marketplace": "Agent Marketplace",
|
||||
"com_agents_marketplace": "Explore Agents",
|
||||
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
||||
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
|
||||
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
|
||||
|
|
|
|||
|
|
@ -25,11 +25,17 @@ export function deleteUser(): Promise<s.TPreset> {
|
|||
return request.delete(endpoints.deleteUser());
|
||||
}
|
||||
|
||||
export function getFavorites(): Promise<unknown> {
|
||||
export type FavoriteItem = {
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
export function getFavorites(): Promise<FavoriteItem[]> {
|
||||
return request.get('/api/user/settings/favorites');
|
||||
}
|
||||
|
||||
export function updateFavorites(favorites: unknown): Promise<unknown> {
|
||||
export function updateFavorites(favorites: FavoriteItem[]): Promise<FavoriteItem[]> {
|
||||
return request.post('/api/user/settings/favorites', { favorites });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,13 +34,11 @@ export interface IUser extends Document {
|
|||
personalization?: {
|
||||
memories?: boolean;
|
||||
};
|
||||
favorites?: {
|
||||
agents: string[];
|
||||
models: Array<{
|
||||
model: string;
|
||||
endpoint: string;
|
||||
}>;
|
||||
};
|
||||
favorites?: Array<{
|
||||
agentId?: string;
|
||||
model?: string;
|
||||
endpoint?: string;
|
||||
}>;
|
||||
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