feat: Implement Favorites functionality with controllers, hooks, and UI components

This commit is contained in:
Marco Beretta 2025-11-23 00:27:41 +01:00
parent 28cdc06209
commit d2faf9c67d
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
19 changed files with 588 additions and 76 deletions

View 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,
};

View 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;

View file

@ -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);

View file

@ -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,15 +56,19 @@ 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
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 */} {/* Left column: Avatar and Category */}
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4"> <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> <div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
@ -104,6 +116,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
</div> </div>
</div> </div>
</div> </div>
</div>
); );
}; };

View file

@ -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"
className="block"
> >
<path <Star
fillRule="evenodd" className={cn(
clipRule="evenodd" 'h-4 w-4',
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" isFavorite ? 'fill-yellow-400 text-yellow-400' : 'text-text-secondary',
fill="currentColor"
/>
</svg>
</div>
)} )}
/>
</button>
</MenuItem> </MenuItem>
); );
} }

View 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>
);
}

View 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>
);
}

View file

@ -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],
); );

View 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);
},
},
);
};

View file

@ -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';

View file

@ -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';

View 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,
};
}

View file

@ -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?",

View 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,
};

View file

@ -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,
}; };

View file

@ -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;
}
}

View file

@ -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));
} }

View file

@ -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,

View file

@ -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) */