mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20: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' });
|
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(
|
const user = await User.findByIdAndUpdate(
|
||||||
userId,
|
userId,
|
||||||
{ $set: { favorites } },
|
{ $set: { favorites } },
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ 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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
}
|
}
|
||||||
let rendering: JSX.Element;
|
let rendering: JSX.Element;
|
||||||
if (item.type === 'favorites') {
|
if (item.type === 'favorites') {
|
||||||
rendering = <FavoritesList />;
|
rendering = <FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />;
|
||||||
} else if (item.type === 'chats-header') {
|
} else if (item.type === 'chats-header') {
|
||||||
rendering = (
|
rendering = (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="mr-2 h-5 w-5">
|
<div className="mr-2 h-5 w-5">
|
||||||
<EndpointIcon
|
<EndpointIcon
|
||||||
conversation={{ endpoint: model.endpoint, model: model.model } as any}
|
conversation={{ endpoint: model.endpoint, model: model.model } as t.TConversation}
|
||||||
endpoint={model.endpoint}
|
endpoint={model.endpoint}
|
||||||
model={model.model}
|
model={model.model}
|
||||||
size={20}
|
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 { 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 { useQueries, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { InfiniteData } from '@tanstack/react-query';
|
import type { InfiniteData } from '@tanstack/react-query';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import { useFavorites } from '~/hooks';
|
import { useFavorites, useLocalize, useHasAccess, AuthContext } from '~/hooks';
|
||||||
import FavoriteItem from './FavoriteItem';
|
import FavoriteItem from './FavoriteItem';
|
||||||
|
|
||||||
interface DraggableFavoriteItemProps {
|
interface DraggableFavoriteItemProps {
|
||||||
|
|
@ -45,7 +47,10 @@ const DraggableFavoriteItem = ({
|
||||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||||
const clientOffset = monitor.getClientOffset();
|
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) {
|
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
|
||||||
return;
|
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 queryClient = useQueryClient();
|
||||||
|
const authContext = useContext(AuthContext);
|
||||||
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
|
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 agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[];
|
||||||
|
|
||||||
const agentQueries = useQueries({
|
const agentQueries = useQueries({
|
||||||
|
|
@ -146,13 +185,29 @@ export default function FavoritesList() {
|
||||||
persistFavorites(favorites);
|
persistFavorites(favorites);
|
||||||
}, [favorites, persistFavorites]);
|
}, [favorites, persistFavorites]);
|
||||||
|
|
||||||
if (favorites.length === 0) {
|
// If no favorites and no marketplace to show, return null
|
||||||
|
if (favorites.length === 0 && !showAgentMarketplace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 flex flex-col pb-2">
|
<div className="mb-2 flex flex-col pb-2">
|
||||||
<div className="mt-1 flex flex-col gap-1">
|
<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) => {
|
{favorites.map((fav, index) => {
|
||||||
if (fav.agentId) {
|
if (fav.agentId) {
|
||||||
const agent = agentsMap[fav.agentId];
|
const agent = agentsMap[fav.agentId];
|
||||||
|
|
@ -177,7 +232,7 @@ export default function FavoritesList() {
|
||||||
moveItem={moveItem}
|
moveItem={moveItem}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<FavoriteItem item={fav as any} type="model" />
|
<FavoriteItem item={{ model: fav.model, endpoint: fav.endpoint }} type="model" />
|
||||||
</DraggableFavoriteItem>
|
</DraggableFavoriteItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ 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 NAV_WIDTH_DESKTOP = '260px';
|
const NAV_WIDTH_DESKTOP = '260px';
|
||||||
const NAV_WIDTH_MOBILE = '320px';
|
const NAV_WIDTH_MOBILE = '320px';
|
||||||
|
|
@ -160,9 +159,6 @@ const Nav = memo(
|
||||||
const headerButtons = useMemo(
|
const headerButtons = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
|
||||||
</Suspense>
|
|
||||||
{hasAccessToBookmarks && (
|
{hasAccessToBookmarks && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1.5" />
|
<div className="mt-1.5" />
|
||||||
|
|
@ -173,7 +169,7 @@ const Nav = memo(
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible],
|
[hasAccessToBookmarks, tags, isSmallScreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isSearchLoading, setIsSearchLoading] = useState(
|
const [isSearchLoading, setIsSearchLoading] = useState(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
|
import type { Favorite } from '~/store/favorites';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
|
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
|
||||||
|
|
||||||
|
|
@ -11,13 +12,15 @@ export default function useFavorites() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getFavoritesQuery.data) {
|
if (getFavoritesQuery.data) {
|
||||||
if (Array.isArray(getFavoritesQuery.data)) {
|
if (Array.isArray(getFavoritesQuery.data)) {
|
||||||
const mapped = getFavoritesQuery.data.map((f: any) => {
|
const mapped = getFavoritesQuery.data.map(
|
||||||
|
(f: Favorite & { type?: string; id?: string }) => {
|
||||||
if (f.agentId || (f.model && f.endpoint)) return f;
|
if (f.agentId || (f.model && f.endpoint)) return f;
|
||||||
if (f.type === 'agent' && f.id) return { agentId: f.id };
|
if (f.type === 'agent' && f.id) return { agentId: f.id };
|
||||||
// Drop label and map legacy model format
|
// Drop label and map legacy model format
|
||||||
if (f.type === 'model') return { model: f.model, endpoint: f.endpoint };
|
if (f.type === 'model') return { model: f.model, endpoint: f.endpoint };
|
||||||
return f;
|
return f;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
setFavorites(mapped);
|
setFavorites(mapped);
|
||||||
} else {
|
} else {
|
||||||
// Handle legacy format or invalid data
|
// Handle legacy format or invalid data
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
"com_agents_link_copy_failed": "Failed to copy link",
|
"com_agents_link_copy_failed": "Failed to copy link",
|
||||||
"com_agents_load_more_label": "Load more agents from {{category}} category",
|
"com_agents_load_more_label": "Load more agents from {{category}} category",
|
||||||
"com_agents_loading": "Loading...",
|
"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_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_description_placeholder": "Explain what it does in a few words",
|
||||||
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
|
"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());
|
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');
|
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 });
|
return request.post('/api/user/settings/favorites', { favorites });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,11 @@ export interface IUser extends Document {
|
||||||
personalization?: {
|
personalization?: {
|
||||||
memories?: boolean;
|
memories?: boolean;
|
||||||
};
|
};
|
||||||
favorites?: {
|
favorites?: Array<{
|
||||||
agents: string[];
|
agentId?: string;
|
||||||
models: Array<{
|
model?: string;
|
||||||
model: string;
|
endpoint?: string;
|
||||||
endpoint: 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