From d2faf9c67dd9d8d6f2c031c307ebdb708bd8a144 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 23 Nov 2025 00:27:41 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Implement=20Favorites=20fun?= =?UTF-8?q?ctionality=20with=20controllers,=20hooks,=20and=20UI=20componen?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/FavoritesController.js | 52 +++++++ api/server/routes/settings.js | 13 ++ api/server/routes/user.js | 3 + client/src/components/Agents/AgentCard.tsx | 111 ++++++++------ .../components/EndpointModelItem.tsx | 59 +++---- .../components/Nav/Favorites/FavoriteItem.tsx | 144 ++++++++++++++++++ .../Nav/Favorites/FavoritesList.tsx | 53 +++++++ client/src/components/Nav/Nav.tsx | 10 +- client/src/data-provider/Favorites.ts | 24 +++ client/src/data-provider/index.ts | 1 + client/src/hooks/index.ts | 1 + client/src/hooks/useFavorites.ts | 99 ++++++++++++ client/src/locales/en/translation.json | 1 + client/src/store/favorites.ts | 24 +++ client/src/store/index.ts | 2 + client/src/style.css | 29 ++++ packages/data-provider/src/data-service.ts | 8 + packages/data-schemas/src/schema/user.ts | 22 +++ packages/data-schemas/src/types/user.ts | 8 + 19 files changed, 588 insertions(+), 76 deletions(-) create mode 100644 api/server/controllers/FavoritesController.js create mode 100644 api/server/routes/settings.js create mode 100644 client/src/components/Nav/Favorites/FavoriteItem.tsx create mode 100644 client/src/components/Nav/Favorites/FavoritesList.tsx create mode 100644 client/src/data-provider/Favorites.ts create mode 100644 client/src/hooks/useFavorites.ts create mode 100644 client/src/store/favorites.ts diff --git a/api/server/controllers/FavoritesController.js b/api/server/controllers/FavoritesController.js new file mode 100644 index 0000000000..a3ef3a8a8f --- /dev/null +++ b/api/server/controllers/FavoritesController.js @@ -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, +}; diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js new file mode 100644 index 0000000000..22162fed4e --- /dev/null +++ b/api/server/routes/settings.js @@ -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; diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 7efab9d026..1858be22be 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -15,8 +15,11 @@ const { requireJwtAuth, } = require('~/server/middleware'); +const settings = require('./settings'); + const router = express.Router(); +router.use('/settings', settings); router.get('/', requireJwtAuth, getUserController); router.get('/terms', requireJwtAuth, getTermsStatusController); router.post('/terms/accept', requireJwtAuth, acceptTermsController); diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx index 29b85c5dea..aefc00f42f 100644 --- a/client/src/components/Agents/AgentCard.tsx +++ b/client/src/components/Agents/AgentCard.tsx @@ -1,7 +1,8 @@ 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 } from '~/hooks'; +import { useLocalize, TranslationKeys, useAgentCategories, useFavorites } from '~/hooks'; import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils'; interface AgentCardProps { @@ -16,6 +17,13 @@ interface AgentCardProps { const AgentCard: React.FC = ({ 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 ''; @@ -48,59 +56,64 @@ const AgentCard: React.FC = ({ agent, onClick, className = '' }) aria-describedby={`agent-${agent.id}-description`} tabIndex={0} role="button" - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} > - {/* Two column layout */} -
- {/* Left column: Avatar and Category */} -
-
{renderAgentAvatar(agent, { size: 'sm' })}
+
+ +
+
+
+ {/* Left column: Avatar and Category */} +
+
{renderAgentAvatar(agent, { size: 'sm' })}
- {/* Category tag */} - {agent.category && ( -
- -
- )} -
- - {/* Right column: Name, description, and other content */} -
-
- {/* Agent name */} - - - {/* Agent description */} -

- {agent.description ?? ''} -

+ {/* Category tag */} + {agent.category && ( +
+ +
+ )}
- {/* Owner info - moved to bottom right */} - {(() => { - const displayName = getContactDisplayName(agent); - if (displayName) { - return ( -
-
- + {/* Right column: Name, description, and other content */} +
+
+ {/* Agent name */} + + + {/* Agent description */} +

+ {agent.description ?? ''} +

+
+ + {/* Owner info - moved to bottom right */} + {(() => { + const displayName = getContactDisplayName(agent); + if (displayName) { + return ( +
+
+ +
-
- ); - } - return null; - })()} + ); + } + return null; + })()} +
diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index eeefdba598..344b4a7b53 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -1,7 +1,9 @@ import React from 'react'; -import { EarthIcon } from 'lucide-react'; +import { EarthIcon, Star } from 'lucide-react'; import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; +import { useFavorites } from '~/hooks'; +import { cn } from '~/utils'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; @@ -11,8 +13,9 @@ interface EndpointModelItemProps { isSelected: boolean; } -export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) { +export function EndpointModelItem({ modelId, endpoint }: EndpointModelItemProps) { const { handleSelectModel } = useModelSelectorContext(); + const { isFavoriteModel, toggleFavoriteModel } = useFavorites(); let isGlobal = false; let modelName = modelId; const avatarUrl = endpoint?.modelIcons?.[modelId ?? ''] || null; @@ -32,11 +35,20 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod 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 ( 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" >
{avatarUrl ? ( @@ -48,31 +60,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
{endpoint.icon}
- ) : null} - {modelName} - {isGlobal && ( - + ) : ( +
)} + {modelName} + {isGlobal && }
- {isSelected && ( -
- - - -
- )} + ); } diff --git a/client/src/components/Nav/Favorites/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx new file mode 100644 index 0000000000..758ee17a66 --- /dev/null +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -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 ( +
+ +
+ ); + }; + + 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: , + }, + ]; + + return ( +
+
+ {renderIcon()} + {getName()} +
+ +
e.stopPropagation()} + > + + + + } + items={dropdownItems} + menuId={menuId} + /> +
+
+ ); +} diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx new file mode 100644 index 0000000000..a868f1f961 --- /dev/null +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -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 ( + + + + Favorites + + +
+ {favoriteAgents.map((agent) => ( + + ))} + {favoriteModels.map((model) => ( + + ))} +
+
+
+ ); +} diff --git a/client/src/components/Nav/Nav.tsx b/client/src/components/Nav/Nav.tsx index 68dfbe2481..d66f142ea7 100644 --- a/client/src/components/Nav/Nav.tsx +++ b/client/src/components/Nav/Nav.tsx @@ -22,6 +22,7 @@ import store from '~/store'; const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav')); const AccountSettings = lazy(() => import('./AccountSettings')); const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton')); +const FavoritesList = lazy(() => import('./Favorites/FavoritesList')); const NAV_WIDTH_DESKTOP = '260px'; const NAV_WIDTH_MOBILE = '320px'; @@ -152,7 +153,14 @@ const Nav = memo( }, [isFetchingNextPage, computedHasNextPage, fetchNextPage]); const subHeaders = useMemo( - () => search.enabled === true && , + () => ( + <> + + + + {search.enabled === true && } + + ), [search.enabled, isSmallScreen], ); diff --git a/client/src/data-provider/Favorites.ts b/client/src/data-provider/Favorites.ts new file mode 100644 index 0000000000..539b354602 --- /dev/null +++ b/client/src/data-provider/Favorites.ts @@ -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(['favorites'], () => dataService.getFavorites() as Promise, { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }); +}; + +export const useUpdateFavoritesMutation = () => { + const queryClient = useQueryClient(); + return useMutation( + (favorites: FavoritesState) => dataService.updateFavorites(favorites) as Promise, + { + onSuccess: (data) => { + queryClient.setQueryData(['favorites'], data); + }, + }, + ); +}; diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index 8c798aa57a..d32fb46d0b 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -8,6 +8,7 @@ export * from './Messages'; export * from './Misc'; export * from './Tools'; export * from './connection'; +export * from './Favorites'; export * from './mutations'; export * from './prompts'; export * from './queries'; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 6443f903d0..49b185649f 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -32,4 +32,5 @@ export { default as useDocumentTitle } from './useDocumentTitle'; export { default as useSpeechToText } from './Input/useSpeechToText'; export { default as useTextToSpeech } from './Input/useTextToSpeech'; export { default as useGenerationsByLatest } from './useGenerationsByLatest'; +export { default as useFavorites } from './useFavorites'; export { useResourcePermissions } from './useResourcePermissions'; diff --git a/client/src/hooks/useFavorites.ts b/client/src/hooks/useFavorites.ts new file mode 100644 index 0000000000..3a453228b9 --- /dev/null +++ b/client/src/hooks/useFavorites.ts @@ -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, + }; +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 6f1890154f..6c3410b0c2 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -851,6 +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_delete": "Delete", "com_ui_delete_action": "Delete Action", "com_ui_delete_action_confirm": "Are you sure you want to delete this action?", diff --git a/client/src/store/favorites.ts b/client/src/store/favorites.ts new file mode 100644 index 0000000000..ace76c355e --- /dev/null +++ b/client/src/store/favorites.ts @@ -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({ + key: 'favorites', + default: { + agents: [], + models: [], + }, +}); + +export default { + favorites, +}; diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 054fac25a9..31f22e70a4 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -12,6 +12,7 @@ import lang from './language'; import settings from './settings'; import misc from './misc'; import isTemporary from './temporary'; +import favorites from './favorites'; export * from './agents'; export * from './mcp'; @@ -30,4 +31,5 @@ export default { ...settings, ...misc, ...isTemporary, + ...favorites, }; diff --git a/client/src/style.css b/client/src/style.css index 2a4550f430..13c8e35308 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -2836,3 +2836,32 @@ html { .sharepoint-picker-bg{ 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; + } +} diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 1f75e20762..01ce1d77b7 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -25,6 +25,14 @@ export function deleteUser(): Promise { return request.delete(endpoints.deleteUser()); } +export function getFavorites(): Promise { + return request.get('/api/user/settings/favorites'); +} + +export function updateFavorites(favorites: unknown): Promise { + return request.post('/api/user/settings/favorites', { favorites }); +} + export function getSharedMessages(shareId: string): Promise { return request.get(endpoints.shareMessages(shareId)); } diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 9fc8f6449c..0bdcd0912e 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -141,6 +141,28 @@ const userSchema = new Schema( }, 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) */ idOnTheSource: { type: String, diff --git a/packages/data-schemas/src/types/user.ts b/packages/data-schemas/src/types/user.ts index a4d2812b0d..88ab38ffbc 100644 --- a/packages/data-schemas/src/types/user.ts +++ b/packages/data-schemas/src/types/user.ts @@ -34,6 +34,14 @@ export interface IUser extends Document { personalization?: { memories?: boolean; }; + favorites?: { + agents: string[]; + models: Array<{ + model: string; + endpoint: string; + label?: string; + }>; + }; createdAt?: Date; updatedAt?: Date; /** Field for external source identification (for consistency with TPrincipal schema) */