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' });
}
const favorites = user.favorites || {};
res.status(200).json({
agents: favorites.agents || [],
models: favorites.models || [],
});
let favorites = user.favorites || [];
// Ensure favorites is an array (migration/dev fix)
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) {
console.error('Error fetching favorites:', error);
res.status(500).json({ message: 'Internal server error' });

View file

@ -1,8 +1,7 @@
import React, { useMemo } from 'react';
import { Star } from 'lucide-react';
import { Label } from '@librechat/client';
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';
interface AgentCardProps {
@ -17,13 +16,6 @@ interface AgentCardProps {
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
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(() => {
if (!agent.category) return '';
@ -57,16 +49,6 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
tabIndex={0}
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-center gap-3">
{/* Left column: Avatar and Category */}
@ -93,7 +75,9 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
<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
? { 'aria-label': `Description: ${agent.description}` }
: {})}
>
{agent.description ?? ''}
</p>

View file

@ -1,5 +1,5 @@
import React, { useRef } from 'react';
import { Link } from 'lucide-react';
import { Link, Pin, PinOff } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client';
import {
@ -11,8 +11,8 @@ import {
AgentListResponse,
} from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks';
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useLocalize, useDefaultConvo } from '~/hooks';
import { useChatContext } from '~/Providers';
interface SupportContact {
@ -39,6 +39,14 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
const dialogRef = useRef<HTMLDivElement>(null);
const getDefaultConversation = useDefaultConvo();
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
@ -133,18 +141,6 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
return (
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<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 */}
<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>
{/* 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}>
{localize('com_agents_start_chat')}
</Button>

View file

@ -1,11 +1,11 @@
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 { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import type { Endpoint } from '~/common';
import { useFavorites } from '~/hooks';
import { cn } from '~/utils';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
interface EndpointModelItemProps {
modelId: string | null;
@ -15,7 +15,8 @@ interface EndpointModelItemProps {
export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) {
const { handleSelectModel } = useModelSelectorContext();
const { isFavoriteModel, toggleFavoriteModel } = useFavorites();
const { isFavoriteModel, toggleFavoriteModel, isFavoriteAgent, toggleFavoriteAgent } =
useFavorites();
let isGlobal = false;
let modelName = modelId;
const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null;
@ -35,15 +36,45 @@ export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps)
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) => {
e.stopPropagation();
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 (
<MenuItem
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"
>
<div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
{avatarUrl ? (
<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" />
)}
{renderAvatar()}
<span className="truncate">{modelName}</span>
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
</div>
<button
onClick={handleFavoriteClick}
className={cn(
'rounded-full p-1 hover:bg-surface-hover',
'rounded-md p-1 hover:bg-surface-hover',
isFavorite ? 'visible' : 'invisible group-hover:visible',
)}
>
<Star
className={cn(
'h-4 w-4',
isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary',
)}
/>
{isFavorite ? (
<PinOff className="h-4 w-4 text-text-secondary" />
) : (
<Pin className="h-4 w-4 text-text-secondary" />
)}
</button>
</MenuItem>
);

View file

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

View file

@ -3,6 +3,7 @@ import { cn } from '~/utils';
interface ConvoLinkProps {
isActiveConvo: boolean;
isPopoverActive: boolean;
title: string | null;
onRename: () => void;
isSmallScreen: boolean;
@ -12,6 +13,7 @@ interface ConvoLinkProps {
const ConvoLink: React.FC<ConvoLinkProps> = ({
isActiveConvo,
isPopoverActive,
title,
onRename,
isSmallScreen,
@ -22,7 +24,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
<div
className={cn(
'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}
aria-current={isActiveConvo ? 'page' : undefined}
@ -47,7 +49,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
<div
className={cn(
'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-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 { useNavigate } from 'react-router-dom';
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 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 { 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;
@ -68,7 +68,7 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
const renderIcon = () => {
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;
return (
@ -87,22 +87,25 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
if (type === 'agent') {
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 dropdownItems = [
{
label: localize('com_ui_remove'),
label: localize('com_ui_unpin'),
onClick: handleRemove,
icon: <Trash className="h-4 w-4 text-text-primary" />,
icon: <PinOff className="h-4 w-4 text-text-secondary" />,
},
];
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"
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}
data-testid="favorite-item"
>
@ -126,13 +129,13 @@ export default function FavoriteItem({ item, type }: FavoriteItemProps) {
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' : '',
'flex h-7 w-7 items-center justify-center rounded-md',
isPopoverActive ? 'bg-surface-active-alt' : '',
)}
aria-label={localize('com_ui_options')}
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>
}
items={dropdownItems}

View file

@ -1,51 +1,195 @@
import React from 'react';
import { useQueries } from '@tanstack/react-query';
import React, { useRef, useCallback, useMemo } from 'react';
import { ChevronRight } from 'lucide-react';
import { useDrag, useDrop } from 'react-dnd';
import * as Collapsible from '@radix-ui/react-collapsible';
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';
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() {
const { favorites } = useFavorites();
const localize = useLocalize();
const queryClient = useQueryClient();
const { favorites, reorderFavorites, persistFavorites } = useFavorites();
const [isExpanded, setIsExpanded] = useLocalStorage('favoritesExpanded', true);
const agentIds = favorites.map((f) => f.agentId).filter(Boolean) as string[];
const agentQueries = useQueries({
queries: (favorites.agents || []).map((agentId) => ({
queries: agentIds.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 agentsMap = useMemo(() => {
const map: Record<string, t.Agent> = {};
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 (
<Collapsible.Root
open={isExpanded}
onOpenChange={setIsExpanded}
className="flex flex-col py-2"
>
<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>
<span className="select-none">{localize('com_ui_pinned')}</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" />
))}
{favorites.map((fav, index) => {
if (fav.agentId) {
const agent = agentsMap[fav.agentId];
if (!agent) return null;
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>
</Collapsible.Content>
</Collapsible.Root>

View file

@ -1,7 +1,6 @@
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() {
@ -11,73 +10,71 @@ export default function useFavorites() {
useEffect(() => {
if (getFavoritesQuery.data) {
setFavorites({
agents: getFavoritesQuery.data.agents || [],
models: getFavoritesQuery.data.models || [],
});
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;
});
setFavorites(mapped);
} else {
// Handle legacy format or invalid data
setFavorites([]);
}
}
}, [getFavoritesQuery.data, setFavorites]);
const saveFavorites = (newFavorites: typeof favorites) => {
setFavorites(newFavorites);
updateFavoritesMutation.mutate(newFavorites);
const cleaned = newFavorites.map((f) => {
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 agents = favorites?.agents || [];
if (agents.includes(id)) return;
const newFavorites = {
...favorites,
agents: [...agents, id],
};
const addFavoriteAgent = (agentId: string) => {
if (favorites.some((f) => f.agentId === agentId)) return;
const newFavorites = [...favorites, { agentId }];
saveFavorites(newFavorites);
};
const removeFavoriteAgent = (id: string) => {
const agents = favorites?.agents || [];
const newFavorites = {
...favorites,
agents: agents.filter((item) => item !== id),
};
const removeFavoriteAgent = (agentId: string) => {
const newFavorites = favorites.filter((f) => f.agentId !== agentId);
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],
};
const addFavoriteModel = (model: { model: string; endpoint: string }) => {
if (favorites.some((f) => f.model === model.model && f.endpoint === model.endpoint)) return;
const newFavorites = [...favorites, { model: model.model, endpoint: model.endpoint }];
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)),
};
const newFavorites = favorites.filter((f) => !(f.model === model && f.endpoint === endpoint));
saveFavorites(newFavorites);
};
const isFavoriteAgent = (id: string) => {
return (favorites?.agents || []).includes(id);
const isFavoriteAgent = (agentId: string) => {
return favorites.some((f) => f.agentId === agentId);
};
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) => {
if (isFavoriteAgent(id)) {
removeFavoriteAgent(id);
const toggleFavoriteAgent = (agentId: string) => {
if (isFavoriteAgent(agentId)) {
removeFavoriteAgent(agentId);
} else {
addFavoriteAgent(id);
addFavoriteAgent(agentId);
}
};
const toggleFavoriteModel = (model: FavoriteModel) => {
const toggleFavoriteModel = (model: { model: string; endpoint: string }) => {
if (isFavoriteModel(model.model, model.endpoint)) {
removeFavoriteModel(model.model, model.endpoint);
} 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 {
favorites,
addFavoriteAgent,
@ -95,5 +100,7 @@ export default function useFavorites() {
isFavoriteModel,
toggleFavoriteAgent,
toggleFavoriteModel,
reorderFavorites,
persistFavorites,
};
}

View file

@ -851,7 +851,7 @@
"com_ui_date_yesterday": "Yesterday",
"com_ui_decline": "I do not accept",
"com_ui_default_post_request": "Default (POST request)",
"com_ui_remove": "Remove",
"com_ui_unpin": "Unpin",
"com_ui_delete": "Delete",
"com_ui_delete_action": "Delete 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_preview": "Preview",
"com_ui_privacy_policy": "Privacy policy",
"com_ui_pin": "Pin",
"com_ui_pinned": "Pinned",
"com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_prompt": "Prompt",
"com_ui_prompt_group_button": "{{name}} prompt, {{category}} category",

View file

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

View file

@ -29,7 +29,7 @@ export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | n
export const renderAgentAvatar = (
agent: t.Agent | null | undefined,
options: {
size?: 'sm' | 'md' | 'lg' | 'xl';
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
className?: string;
showBorder?: boolean;
} = {},
@ -40,6 +40,7 @@ export const renderAgentAvatar = (
// Size mappings for responsive design
const sizeClasses = {
icon: 'h-5 w-5',
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',
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 = {
icon: 'h-4 w-4',
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',
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 = {
icon: 'h-5 w-5',
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',
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: {},
},
favorites: {
type: {
agents: {
type: [String],
default: [],
type: [
{
_id: false,
agentId: String, // for agent
model: String, // for model
endpoint: String, // for model
},
models: {
type: [
{
model: String,
endpoint: String,
label: String,
},
],
default: [],
},
},
default: {
agents: [],
models: [],
},
],
default: [],
},
/** Field for external source identification (for consistency with TPrincipal schema) */
idOnTheSource: {