feat: Refactor Favorites functionality to support new data structure and enhance UI interactions

This commit is contained in:
Marco Beretta 2025-11-23 02:48:19 +01:00
parent d2faf9c67d
commit 4c10fcd118
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
13 changed files with 347 additions and 175 deletions

View file

@ -35,11 +35,16 @@ const getFavoritesController = async (req, res) => {
return res.status(404).json({ message: 'User not found' }); return res.status(404).json({ message: 'User not found' });
} }
const favorites = user.favorites || {}; let favorites = user.favorites || [];
res.status(200).json({
agents: favorites.agents || [], // Ensure favorites is an array (migration/dev fix)
models: favorites.models || [], if (!Array.isArray(favorites)) {
}); favorites = [];
// Optionally update the DB to fix it permanently
await User.findByIdAndUpdate(userId, { $set: { favorites: [] } });
}
res.status(200).json(favorites);
} catch (error) { } catch (error) {
console.error('Error fetching favorites:', error); console.error('Error fetching favorites:', error);
res.status(500).json({ message: 'Internal server error' }); res.status(500).json({ message: 'Internal server error' });

View file

@ -1,8 +1,7 @@
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, useFavorites } from '~/hooks'; import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils'; import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
interface AgentCardProps { interface AgentCardProps {
@ -17,13 +16,6 @@ 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 '';
@ -57,16 +49,6 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
<div className="absolute right-2 top-2 z-10">
<button
onClick={handleFavoriteClick}
className="rounded-full p-1 hover:bg-surface-hover"
>
<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-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Left column: Avatar and Category */} {/* Left column: Avatar and Category */}
@ -93,7 +75,9 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
<p <p
id={`agent-${agent.id}-description`} id={`agent-${agent.id}-description`}
className="line-clamp-3 text-sm leading-relaxed text-text-primary" className="line-clamp-3 text-sm leading-relaxed text-text-primary"
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})} {...(agent.description
? { 'aria-label': `Description: ${agent.description}` }
: {})}
> >
{agent.description ?? ''} {agent.description ?? ''}
</p> </p>

View file

@ -1,5 +1,5 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { Link } from 'lucide-react'; import { Link, Pin, PinOff } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client'; import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client';
import { import {
@ -11,8 +11,8 @@ import {
AgentListResponse, AgentListResponse,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks';
import { renderAgentAvatar, clearMessagesCache } from '~/utils'; import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useLocalize, useDefaultConvo } from '~/hooks';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
interface SupportContact { interface SupportContact {
@ -39,6 +39,14 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const getDefaultConversation = useDefaultConvo(); const getDefaultConversation = useDefaultConvo();
const { conversation, newConversation } = useChatContext(); const { conversation, newConversation } = useChatContext();
const { isFavoriteAgent, toggleFavoriteAgent } = useFavorites();
const isFavorite = isFavoriteAgent(agent?.id);
const handleFavoriteClick = () => {
if (agent) {
toggleFavoriteAgent(agent.id);
}
};
/** /**
* Navigate to chat with the selected agent * Navigate to chat with the selected agent
@ -133,18 +141,6 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
return ( return (
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<OGDialogContent ref={dialogRef} className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto"> <OGDialogContent ref={dialogRef} className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
{/* Copy link button - positioned next to close button */}
<Button
variant="ghost"
size="icon"
className="absolute right-11 top-4 h-4 w-4 rounded-sm p-0 opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={localize('com_agents_copy_link')}
onClick={handleCopyLink}
title={localize('com_agents_copy_link')}
>
<Link />
</Button>
{/* Agent avatar - top center */} {/* Agent avatar - top center */}
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div> <div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
@ -168,7 +164,25 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
</div> </div>
{/* Action button */} {/* Action button */}
<div className="mb-4 mt-6 flex justify-center"> <div className="mb-4 mt-6 flex justify-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleFavoriteClick}
title={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
{isFavorite ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
</Button>
<Button
variant="outline"
size="icon"
onClick={handleCopyLink}
title={localize('com_agents_copy_link')}
aria-label={localize('com_agents_copy_link')}
>
<Link className="h-4 w-4" />
</Button>
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}> <Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
{localize('com_agents_start_chat')} {localize('com_agents_start_chat')}
</Button> </Button>

View file

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { EarthIcon, Star } from 'lucide-react'; import { EarthIcon, Pin, PinOff } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import type { Endpoint } from '~/common'; import type { Endpoint } from '~/common';
import { useFavorites } from '~/hooks'; import { useFavorites } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
interface EndpointModelItemProps { interface EndpointModelItemProps {
modelId: string | null; modelId: string | null;
@ -15,7 +15,8 @@ interface EndpointModelItemProps {
export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) { export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) {
const { handleSelectModel } = useModelSelectorContext(); const { handleSelectModel } = useModelSelectorContext();
const { isFavoriteModel, toggleFavoriteModel } = useFavorites(); const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } =
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;
@ -35,15 +36,45 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
modelName = endpoint.assistantNames[modelId]; modelName = endpoint.assistantNames[modelId];
} }
const isFavorite = isFavoriteModel(modelId ?? '', endpoint.value); const isAgent = isAgentsEndpoint(endpoint.value);
const isFavorite = isAgent
? isFavoriteAgent(modelId ?? '')
: isFavoriteModel(modelId ?? '', endpoint.value);
const handleFavoriteClick = (e: React.MouseEvent) => { const handleFavoriteClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (modelId) { if (modelId) {
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value, label: modelName ?? undefined }); if (isAgent) {
toggleFavoriteAgent(modelId);
} else {
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value });
}
} }
}; };
const renderAvatar = () => {
if (avatarUrl) {
return (
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
</div>
);
}
if (
(isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
endpoint.icon
) {
return (
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
);
}
return (
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full" />
);
};
return ( return (
<MenuItem <MenuItem
key={modelId} key={modelId}
@ -51,34 +82,22 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
className="group 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 ? ( {renderAvatar()}
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
</div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
endpoint.icon ? (
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon}
</div>
) : (
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full" />
)}
<span className="truncate">{modelName}</span> <span className="truncate">{modelName}</span>
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />} {isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
</div> </div>
<button <button
onClick={handleFavoriteClick} onClick={handleFavoriteClick}
className={cn( className={cn(
'rounded-full p-1 hover:bg-surface-hover', 'rounded-md p-1 hover:bg-surface-hover',
isFavorite ? 'visible' : 'invisible group-hover:visible', isFavorite ? 'visible' : 'invisible group-hover:visible',
)} )}
> >
<Star {isFavorite ? (
className={cn( <PinOff className="h-4 w-4 text-text-secondary" />
'h-4 w-4', ) : (
isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary', <Pin className="h-4 w-4 text-text-secondary" />
)} )}
/>
</button> </button>
</MenuItem> </MenuItem>
); );

View file

@ -132,8 +132,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
return ( return (
<div <div
className={cn( className={cn(
'group relative flex h-12 w-full items-center border-l-2 transition-[background-color] duration-200 md:h-9', 'group relative flex h-12 w-full items-center border-l-2 md:h-9',
isActiveConvo isActiveConvo || isPopoverActive
? 'rounded-r-lg border-l-border-xheavy bg-surface-active-alt' ? 'rounded-r-lg border-l-border-xheavy bg-surface-active-alt'
: 'rounded-lg border-l-transparent hover:bg-surface-active-alt', : 'rounded-lg border-l-transparent hover:bg-surface-active-alt',
)} )}
@ -173,6 +173,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
) : ( ) : (
<ConvoLink <ConvoLink
isActiveConvo={isActiveConvo} isActiveConvo={isActiveConvo}
isPopoverActive={isPopoverActive}
title={title} title={title}
onRename={handleRename} onRename={handleRename}
isSmallScreen={isSmallScreen} isSmallScreen={isSmallScreen}

View file

@ -3,6 +3,7 @@ import { cn } from '~/utils';
interface ConvoLinkProps { interface ConvoLinkProps {
isActiveConvo: boolean; isActiveConvo: boolean;
isPopoverActive: boolean;
title: string | null; title: string | null;
onRename: () => void; onRename: () => void;
isSmallScreen: boolean; isSmallScreen: boolean;
@ -12,6 +13,7 @@ interface ConvoLinkProps {
const ConvoLink: React.FC<ConvoLinkProps> = ({ const ConvoLink: React.FC<ConvoLinkProps> = ({
isActiveConvo, isActiveConvo,
isPopoverActive,
title, title,
onRename, onRename,
isSmallScreen, isSmallScreen,
@ -22,7 +24,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
<div <div
className={cn( className={cn(
'flex grow items-center gap-2 overflow-hidden rounded-lg px-2', 'flex grow items-center gap-2 overflow-hidden rounded-lg px-2',
isActiveConvo ? 'bg-surface-active-alt' : '', isActiveConvo || isPopoverActive ? 'bg-surface-active-alt' : '',
)} )}
title={title ?? undefined} title={title ?? undefined}
aria-current={isActiveConvo ? 'page' : undefined} aria-current={isActiveConvo ? 'page' : undefined}
@ -47,7 +49,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
<div <div
className={cn( className={cn(
'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l', 'absolute bottom-0 right-0 top-0 w-20 rounded-r-lg bg-gradient-to-l',
isActiveConvo isActiveConvo || isPopoverActive
? 'from-surface-active-alt' ? 'from-surface-active-alt'
: 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%', : 'from-surface-primary-alt from-0% to-transparent group-hover:from-surface-active-alt group-hover:from-40%',
)} )}

View file

@ -1,13 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Menu from '@ariakit/react/menu'; import * as Menu from '@ariakit/react/menu';
import { Ellipsis, Trash } from 'lucide-react'; import { useNavigate } from 'react-router-dom';
import { Ellipsis, PinOff } from 'lucide-react';
import { DropdownPopup } from '@librechat/client'; import { DropdownPopup } from '@librechat/client';
import type { FavoriteModel } from '~/store/favorites';
import type t from 'librechat-data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { useNewConvo, useFavorites, useLocalize } from '~/hooks'; import { useNewConvo, useFavorites, useLocalize } from '~/hooks';
import { renderAgentAvatar, cn } from '~/utils'; 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 = { type FavoriteItemProps = {
item: t.Agent | FavoriteModel; item: t.Agent | FavoriteModel;
@ -68,7 +68,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
const renderIcon = () => { const renderIcon = () => {
if (type === 'agent') { if (type === 'agent') {
return renderAgentAvatar(item as t.Agent, { size: 20, className: 'mr-2' }); return renderAgentAvatar(item as t.Agent, { size: 'icon', className: 'mr-2' });
} }
const model = item as FavoriteModel; const model = item as FavoriteModel;
return ( return (
@ -87,22 +87,25 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
if (type === 'agent') { if (type === 'agent') {
return (item as t.Agent).name; return (item as t.Agent).name;
} }
return (item as FavoriteModel).label || (item as FavoriteModel).model; return (item as FavoriteModel).model;
}; };
const menuId = React.useId(); const menuId = React.useId();
const dropdownItems = [ const dropdownItems = [
{ {
label: localize('com_ui_remove'), label: localize('com_ui_unpin'),
onClick: handleRemove, onClick: handleRemove,
icon: <Trash className="h-4 w-4 text-text-primary" />, icon: <PinOff className="h-4 w-4 text-text-secondary" />,
}, },
]; ];
return ( return (
<div <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" className={cn(
'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',
isPopoverActive ? 'bg-surface-active-alt' : '',
)}
onClick={handleClick} onClick={handleClick}
data-testid="favorite-item" data-testid="favorite-item"
> >
@ -110,7 +113,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
{renderIcon()} {renderIcon()}
<span className="truncate">{getName()}</span> <span className="truncate">{getName()}</span>
</div> </div>
<div <div
className={cn( className={cn(
'absolute right-2 flex items-center', 'absolute right-2 flex items-center',
@ -126,13 +129,13 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
trigger={ trigger={
<Menu.MenuButton <Menu.MenuButton
className={cn( className={cn(
'flex h-7 w-7 items-center justify-center rounded-md hover:bg-surface-hover focus:bg-surface-hover', 'flex h-7 w-7 items-center justify-center rounded-md',
isPopoverActive ? 'bg-surface-hover' : '', isPopoverActive ? 'bg-surface-active-alt' : '',
)} )}
aria-label={localize('com_ui_options')} aria-label={localize('com_ui_options')}
data-testid="favorite-options-button" data-testid="favorite-options-button"
> >
<Ellipsis className="h-4 w-4 text-text-secondary hover:text-text-primary" /> <Ellipsis className="h-4 w-4 text-text-secondary" />
</Menu.MenuButton> </Menu.MenuButton>
} }
items={dropdownItems} items={dropdownItems}

View file

@ -1,51 +1,195 @@
import React from 'react'; import React, { useRef, useCallback, useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import * as Collapsible from '@radix-ui/react-collapsible'; import * as Collapsible from '@radix-ui/react-collapsible';
import { QueryKeys, dataService } from 'librechat-data-provider'; import { QueryKeys, dataService } from 'librechat-data-provider';
import { useFavorites, useLocalStorage } from '~/hooks'; import { useQueries, useQueryClient } from '@tanstack/react-query';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { useFavorites, useLocalStorage, useLocalize } from '~/hooks';
import FavoriteItem from './FavoriteItem'; import FavoriteItem from './FavoriteItem';
interface DraggableFavoriteItemProps {
id: string;
index: number;
moveItem: (dragIndex: number, hoverIndex: number) => void;
onDrop: () => void;
children: React.ReactNode;
}
const DraggableFavoriteItem = ({
id,
index,
moveItem,
onDrop,
children,
}: DraggableFavoriteItemProps) => {
const ref = useRef<HTMLDivElement>(null);
const [{ handlerId }, drop] = useDrop({
accept: 'favorite-item',
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
hover(item: { index: number; id: string }, monitor) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) {
return;
}
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 (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
moveItem(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: 'favorite-item',
item: () => {
return { id, index };
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
end: () => {
onDrop();
},
});
const opacity = isDragging ? 0 : 1;
drag(drop(ref));
return (
<div ref={ref} style={{ opacity }} data-handler-id={handlerId}>
{children}
</div>
);
};
export default function FavoritesList() { export default function FavoritesList() {
const { favorites } = useFavorites(); const localize = useLocalize();
const queryClient = useQueryClient();
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true); const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true);
const agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[];
const agentQueries = useQueries({ const agentQueries = useQueries({
queries: (favorites.agents || []).map((agentId) => ({ queries: agentIds.map((agentId) => ({
queryKey: [QueryKeys.agent, agentId], queryKey: [QueryKeys.agent, agentId],
queryFn: () => dataService.getAgentById({ agent_id: agentId }), queryFn: () => dataService.getAgentById({ agent_id: agentId }),
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
})), })),
}); });
const favoriteAgents = agentQueries const agentsMap = useMemo(() => {
.map((query) => query.data) const map: Record<string, t.Agent> = {};
.filter((agent) => agent !== undefined);
const favoriteModels = favorites.models || []; const addToMap = (agent: t.Agent) => {
if (agent && agent.id && !map[agent.id]) {
map[agent.id] = agent;
}
};
if ((favorites.agents || []).length === 0 && (favorites.models || []).length === 0) { const marketplaceData = queryClient.getQueriesData<InfiniteData<t.AgentListResponse>>([
QueryKeys.marketplaceAgents,
]);
marketplaceData.forEach(([_, data]) => {
data?.pages.forEach((page) => {
page.data.forEach(addToMap);
});
});
const agentsListData = queryClient.getQueriesData<t.AgentListResponse>([QueryKeys.agents]);
agentsListData.forEach(([_, data]) => {
if (data && Array.isArray(data.data)) {
data.data.forEach(addToMap);
}
});
agentQueries.forEach((query) => {
if (query.data) {
map[query.data.id] = query.data;
}
});
return map;
}, [agentQueries, queryClient]);
const moveItem = useCallback(
(dragIndex: number, hoverIndex: number) => {
const newFavorites = [...favorites];
const [draggedItem] = newFavorites.splice(dragIndex, 1);
newFavorites.splice(hoverIndex, 0, draggedItem);
reorderFavorites(newFavorites);
},
[favorites, reorderFavorites],
);
const handleDrop = useCallback(() => {
persistFavorites(favorites);
}, [favorites, persistFavorites]);
if (favorites.length === 0) {
return null; return null;
} }
return ( return (
<Collapsible.Root <Collapsible.Root open={isExpanded} onOpenChange={setIsExpanded} className="flex flex-col py-2">
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"> <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" /> <ChevronRight className="h-3 w-3 transition-transform duration-200 group-data-[state=open]:rotate-90" />
<span className="select-none">Favorites</span> <span className="select-none">{localize('com_ui_pinned')}</span>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content className="collapsible-content"> <Collapsible.Content className="collapsible-content">
<div className="mt-1 flex flex-col gap-1"> <div className="mt-1 flex flex-col gap-1">
{favoriteAgents.map((agent) => ( {favorites.map((fav, index) => {
<FavoriteItem key={agent!.id} item={agent!} type="agent" /> if (fav.agentId) {
))} const agent = agentsMap[fav.agentId];
{favoriteModels.map((model) => ( if (!agent) return null;
<FavoriteItem key={`${model.endpoint}-${model.model}`} item={model} type="model" /> return (
))} <DraggableFavoriteItem
key={fav.agentId}
id={fav.agentId}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={agent} type="agent" />
</DraggableFavoriteItem>
);
} else if (fav.model && fav.endpoint) {
return (
<DraggableFavoriteItem
key={`${fav.endpoint}-${fav.model}`}
id={`${fav.endpoint}-${fav.model}`}
index={index}
moveItem={moveItem}
onDrop={handleDrop}
>
<FavoriteItem item={fav as any} type="model" />
</DraggableFavoriteItem>
);
}
return null;
})}
</div> </div>
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>

View file

@ -1,7 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import store from '~/store'; import store from '~/store';
import type { FavoriteModel } from '~/store/favorites';
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider'; import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
export default function useFavorites() { export default function useFavorites() {
@ -11,73 +10,71 @@ export default function useFavorites() {
useEffect(() => { useEffect(() => {
if (getFavoritesQuery.data) { if (getFavoritesQuery.data) {
setFavorites({ if (Array.isArray(getFavoritesQuery.data)) {
agents: getFavoritesQuery.data.agents || [], const mapped = getFavoritesQuery.data.map((f: any) => {
models: getFavoritesQuery.data.models || [], 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
setFavorites([]);
}
} }
}, [getFavoritesQuery.data, setFavorites]); }, [getFavoritesQuery.data, setFavorites]);
const saveFavorites = (newFavorites: typeof favorites) => { const saveFavorites = (newFavorites: typeof favorites) => {
setFavorites(newFavorites); const cleaned = newFavorites.map((f) => {
updateFavoritesMutation.mutate(newFavorites); if (f.agentId) return { agentId: f.agentId };
if (f.model && f.endpoint) return { model: f.model, endpoint: f.endpoint };
return f;
});
setFavorites(cleaned);
updateFavoritesMutation.mutate(cleaned);
}; };
const addFavoriteAgent = (id: string) => { const addFavoriteAgent = (agentId: string) => {
const agents = favorites?.agents || []; if (favorites.some((f) => f.agentId === agentId)) return;
if (agents.includes(id)) return; const newFavorites = [...favorites, { agentId }];
const newFavorites = {
...favorites,
agents: [...agents, id],
};
saveFavorites(newFavorites); saveFavorites(newFavorites);
}; };
const removeFavoriteAgent = (id: string) => { const removeFavoriteAgent = (agentId: string) => {
const agents = favorites?.agents || []; const newFavorites = favorites.filter((f) => f.agentId !== agentId);
const newFavorites = {
...favorites,
agents: agents.filter((item) => item !== id),
};
saveFavorites(newFavorites); saveFavorites(newFavorites);
}; };
const addFavoriteModel = (model: FavoriteModel) => { const addFavoriteModel = (model: { model: string; endpoint: string }) => {
const models = favorites?.models || []; if (favorites.some((f) => f.model === model.model && f.endpoint === model.endpoint)) return;
if (models.some((m) => m.model === model.model && m.endpoint === model.endpoint)) return; const newFavorites = [...favorites, { model: model.model, endpoint: model.endpoint }];
const newFavorites = {
...favorites,
models: [...models, model],
};
saveFavorites(newFavorites); saveFavorites(newFavorites);
}; };
const removeFavoriteModel = (model: string, endpoint: string) => { const removeFavoriteModel = (model: string, endpoint: string) => {
const models = favorites?.models || []; const newFavorites = favorites.filter((f) => !(f.model === model && f.endpoint === endpoint));
const newFavorites = {
...favorites,
models: models.filter((m) => !(m.model === model && m.endpoint === endpoint)),
};
saveFavorites(newFavorites); saveFavorites(newFavorites);
}; };
const isFavoriteAgent = (id: string) => { const isFavoriteAgent = (agentId: string) => {
return (favorites?.agents || []).includes(id); return favorites.some((f) => f.agentId === agentId);
}; };
const isFavoriteModel = (model: string, endpoint: string) => { const isFavoriteModel = (model: string, endpoint: string) => {
return (favorites?.models || []).some((m) => m.model === model && m.endpoint === endpoint); return favorites.some((f) => f.model === model && f.endpoint === endpoint);
}; };
const toggleFavoriteAgent = (id: string) => { const toggleFavoriteAgent = (agentId: string) => {
if (isFavoriteAgent(id)) { if (isFavoriteAgent(agentId)) {
removeFavoriteAgent(id); removeFavoriteAgent(agentId);
} else { } else {
addFavoriteAgent(id); addFavoriteAgent(agentId);
} }
}; };
const toggleFavoriteModel = (model: FavoriteModel) => { const toggleFavoriteModel = (model: { model: string; endpoint: string }) => {
if (isFavoriteModel(model.model, model.endpoint)) { if (isFavoriteModel(model.model, model.endpoint)) {
removeFavoriteModel(model.model, model.endpoint); removeFavoriteModel(model.model, model.endpoint);
} else { } else {
@ -85,6 +82,14 @@ export default function useFavorites() {
} }
}; };
const reorderFavorites = (newFavorites: typeof favorites) => {
setFavorites(newFavorites);
};
const persistFavorites = (newFavorites: typeof favorites) => {
updateFavoritesMutation.mutate(newFavorites);
};
return { return {
favorites, favorites,
addFavoriteAgent, addFavoriteAgent,
@ -95,5 +100,7 @@ export default function useFavorites() {
isFavoriteModel, isFavoriteModel,
toggleFavoriteAgent, toggleFavoriteAgent,
toggleFavoriteModel, toggleFavoriteModel,
reorderFavorites,
persistFavorites,
}; };
} }

View file

@ -851,7 +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_unpin": "Unpin",
"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?",
@ -1129,6 +1129,8 @@
"com_ui_prev": "Prev", "com_ui_prev": "Prev",
"com_ui_preview": "Preview", "com_ui_preview": "Preview",
"com_ui_privacy_policy": "Privacy policy", "com_ui_privacy_policy": "Privacy policy",
"com_ui_pin": "Pin",
"com_ui_pinned": "Pinned",
"com_ui_privacy_policy_url": "Privacy Policy URL", "com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_prompt": "Prompt", "com_ui_prompt": "Prompt",
"com_ui_prompt_group_button": "{{name}} prompt, {{category}} category", "com_ui_prompt_group_button": "{{name}} prompt, {{category}} category",

View file

@ -1,22 +1,21 @@
import { atom } from 'recoil'; import { atom } from 'recoil';
export type Favorite = {
agentId?: string;
model?: string;
endpoint?: string;
};
export type FavoriteModel = { export type FavoriteModel = {
model: string; model: string;
endpoint: string; endpoint: string;
label?: string;
}; };
export type FavoritesState = { export type FavoritesState = Favorite[];
agents: string[];
models: FavoriteModel[];
};
const favorites = atom<FavoritesState>({ const favorites = atom<FavoritesState>({
key: 'favorites', key: 'favorites',
default: { default: [],
agents: [],
models: [],
},
}); });
export default { export default {

View file

@ -29,7 +29,7 @@ export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | n
export const renderAgentAvatar = ( export const renderAgentAvatar = (
agent: t.Agent | null | undefined, agent: t.Agent | null | undefined,
options: { options: {
size?: 'sm' | 'md' | 'lg' | 'xl'; size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
className?: string; className?: string;
showBorder?: boolean; showBorder?: boolean;
} = {}, } = {},
@ -40,6 +40,7 @@ export const renderAgentAvatar = (
// Size mappings for responsive design // Size mappings for responsive design
const sizeClasses = { const sizeClasses = {
icon: 'h-5 w-5',
sm: 'h-12 w-12 sm:h-14 sm:w-14', sm: 'h-12 w-12 sm:h-14 sm:w-14',
md: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24', md: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',
lg: 'h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28', lg: 'h-20 w-20 sm:h-24 sm:w-24 md:h-28 md:w-28',
@ -47,6 +48,7 @@ export const renderAgentAvatar = (
}; };
const iconSizeClasses = { const iconSizeClasses = {
icon: 'h-4 w-4',
sm: 'h-6 w-6 sm:h-7 sm:w-7', sm: 'h-6 w-6 sm:h-7 sm:w-7',
md: 'h-6 w-6 sm:h-8 sm:w-8 md:h-10 md:w-10', md: 'h-6 w-6 sm:h-8 sm:w-8 md:h-10 md:w-10',
lg: 'h-8 w-8 sm:h-10 sm:w-10 md:h-12 md:w-12', lg: 'h-8 w-8 sm:h-10 sm:w-10 md:h-12 md:w-12',
@ -54,6 +56,7 @@ export const renderAgentAvatar = (
}; };
const placeholderSizeClasses = { const placeholderSizeClasses = {
icon: 'h-5 w-5',
sm: 'h-10 w-10 sm:h-12 sm:w-12', sm: 'h-10 w-10 sm:h-12 sm:w-12',
md: 'h-12 w-12 sm:h-16 sm:w-16 md:h-20 md:w-20', md: 'h-12 w-12 sm:h-16 sm:w-16 md:h-20 md:w-20',
lg: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24', lg: 'h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24',

View file

@ -142,26 +142,15 @@ const userSchema = new Schema<IUser>(
default: {}, default: {},
}, },
favorites: { favorites: {
type: { type: [
agents: { {
type: [String], _id: false,
default: [], agentId: String, // for agent
model: String, // for model
endpoint: String, // for model
}, },
models: { ],
type: [ default: [],
{
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: {