mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
📌 feat: Pin Agents and Models in the Sidebar (#10634)
* 🪦 refactor: Remove Legacy Code (#10533) * 🗑️ chore: Remove unused Legacy Provider clients and related helpers * Deleted OpenAIClient and GoogleClient files along with their associated tests. * Removed references to these clients in the clients index file. * Cleaned up typedefs by removing the OpenAISpecClient export. * Updated chat controllers to use the OpenAI SDK directly instead of the removed client classes. * chore/remove-openapi-specs * 🗑️ chore: Remove unused mergeSort and misc utility functions * Deleted mergeSort.js and misc.js files as they are no longer needed. * Removed references to cleanUpPrimaryKeyValue in messages.js and adjusted related logic. * Updated mongoMeili.ts to eliminate local implementations of removed functions. * chore: remove legacy endpoints * chore: remove all plugins endpoint related code * chore: remove unused prompt handling code and clean up imports * Deleted handleInputs.js and instructions.js files as they are no longer needed. * Removed references to these files in the prompts index.js. * Updated docker-compose.yml to simplify reverse proxy configuration. * chore: remove unused LightningIcon import from Icons.tsx * chore: clean up translation.json by removing deprecated and unused keys * chore: update Jest configuration and remove unused mock file * Simplified the setupFiles array in jest.config.js by removing the fetchEventSource mock. * Deleted the fetchEventSource.js mock file as it is no longer needed. * fix: simplify endpoint type check in Landing and ConversationStarters components * Updated the endpoint type check to use strict equality for better clarity and performance. * Ensured consistency in the handling of the azureOpenAI endpoint across both components. * chore: remove unused dependencies from package.json and package-lock.json * chore: remove legacy EditController, associated routes and imports * chore: update banResponse logic to refine request handling for banned users * chore: remove unused validateEndpoint middleware and its references * chore: remove unused 'res' parameter from initializeClient in multiple endpoint files * chore: remove unused 'isSmallScreen' prop from BookmarkNav and NewChat components; clean up imports in ArchivedChatsTable and useSetIndexOptions hooks; enhance localization in PromptVersions * chore: remove unused import of Constants and TMessage from MobileNav; retain only necessary QueryKeys import * chore: remove unused TResPlugin type and related references; clean up imports in types and schemas * 🪦 refactor: Remove Legacy Code (#10533) * 🗑️ chore: Remove unused Legacy Provider clients and related helpers * Deleted OpenAIClient and GoogleClient files along with their associated tests. * Removed references to these clients in the clients index file. * Cleaned up typedefs by removing the OpenAISpecClient export. * Updated chat controllers to use the OpenAI SDK directly instead of the removed client classes. * chore/remove-openapi-specs * 🗑️ chore: Remove unused mergeSort and misc utility functions * Deleted mergeSort.js and misc.js files as they are no longer needed. * Removed references to cleanUpPrimaryKeyValue in messages.js and adjusted related logic. * Updated mongoMeili.ts to eliminate local implementations of removed functions. * chore: remove legacy endpoints * chore: remove all plugins endpoint related code * chore: remove unused prompt handling code and clean up imports * Deleted handleInputs.js and instructions.js files as they are no longer needed. * Removed references to these files in the prompts index.js. * Updated docker-compose.yml to simplify reverse proxy configuration. * chore: remove unused LightningIcon import from Icons.tsx * chore: clean up translation.json by removing deprecated and unused keys * chore: update Jest configuration and remove unused mock file * Simplified the setupFiles array in jest.config.js by removing the fetchEventSource mock. * Deleted the fetchEventSource.js mock file as it is no longer needed. * fix: simplify endpoint type check in Landing and ConversationStarters components * Updated the endpoint type check to use strict equality for better clarity and performance. * Ensured consistency in the handling of the azureOpenAI endpoint across both components. * chore: remove unused dependencies from package.json and package-lock.json * chore: remove legacy EditController, associated routes and imports * chore: update banResponse logic to refine request handling for banned users * chore: remove unused validateEndpoint middleware and its references * chore: remove unused 'res' parameter from initializeClient in multiple endpoint files * chore: remove unused 'isSmallScreen' prop from BookmarkNav and NewChat components; clean up imports in ArchivedChatsTable and useSetIndexOptions hooks; enhance localization in PromptVersions * chore: remove unused import of Constants and TMessage from MobileNav; retain only necessary QueryKeys import * chore: remove unused TResPlugin type and related references; clean up imports in types and schemas * 📦 chore: Bump Express.js to v5 (#10671) * chore: update express to version 5.1.0 in package.json * chore: update express-rate-limit to version 8.2.1 in package.json and package-lock.json * fix: Enhance server startup error handling in experimental and index files * Added error handling for server startup in both experimental.js and index.js to log errors and exit the process if the server fails to start. * Updated comments in openidStrategy.js to clarify the purpose of the CustomOpenIDStrategy class and its relation to Express version changes. * chore: Implement rate limiting for all POST routes excluding /speech, required for express v5 * Added middleware to apply IP and user rate limiters to all POST requests, ensuring that the /speech route remains unaffected. * Enhanced code clarity with comments explaining the new rate limiting logic. * chore: Enable writable req.query for mongoSanitize compatibility in Express 5 * chore: Ensure req.body exists in multiple middleware and route files for Express 5 compatibility * 🗣 feat: MCP Status Accessibility Improvements (#10738) * feat: make MultiSelect highlight same opacity as other focus highlights in app * feat: add better screenreader announcements for mcp server and variable states * feat: memoize fullTitle calculation * 🪨 feat: Add PROXY support for AWS Bedrock endpoints (#8871) * feat: added PROXY support for AWS Bedrock endpoint * chore: explicit install of new packages required for bedrock proxy --------- Co-authored-by: Danny Avila <danny@librechat.ai> * ✨ feat: Implement Favorites functionality with controllers, hooks, and UI components * ✨ feat: Refactor Favorites functionality to support new data structure and enhance UI interactions * ✨ feat: Add endpoint to new conversation for agent favorites * ✨ feat: Enhance Conversations and Favorites components with expanded functionality and improved UI interactions * ✨ feat: Remove 'Pinned' label from UI translations for cleaner interface * feat: clean up comments and improve code readability in favorites and agent components; bump @librechat/data-schemas to 0.0.24 * ✨ feat: Enhance favorites management with validation, update data structure, and improve UI interactions * ✨ feat: Simplify rendering logic in EndpointModelItem and optimize useEffect dependencies in Conversations component * ✨ test: Update favorites mock implementation and improve button focus styles in AgentDetail tests * ✨ feat: Enhance favorites management by adding loading and error states, and refactor related hooks and components * ✨ feat: Add loading skeletons for favorites while agents are being fetched * ✨ feat: Improve loading experience in FavoritesList by adding skeleton placeholders for favorites and marketplace * feat: Optimize cache handling in Conversations and enhance FavoritesList to notify height changes on loading completion * ✨ feat: Add loading skeleton for SearchBar in Nav component and update agent avatar fallback icon to Feather * feat: Refactor FavoritesController validation, streamline ModelSelector component, and enhance EndpointModelItem with selection state * feat: Adjust padding in Conversations and FavoritesList components for improved layout consistency * feat: Refactor FavoritesController to use model methods for user updates and retrieval * feat: Enhance Favorites functionality with validation, cleanup, and improved error handling * tests: Update AgentCard and agent utilities to use Feather icon fallback instead of Bot icon * refactor: Remove collapsible animation styles from CSS * feat: Migrate favorites state management from Recoil to Jotai * fix: Correct type definition in useGetFavoritesQuery and ensure useFavorites is exported * refactor: Simplify AuthField component by removing TooltipAnchor and directly rendering Label * fix: Ensure favorites are always an array and update references in FavoritesList * style: Update Conversation component styles for improved UI consistency * feat: re-integrate AuthContext to manage agent marketplace visibility based on authentication state * fix: Improve optimistic updates in favorites mutation handling * feat: Implement error handling for favorites limit and consolidate marketplace access logic * fix: package-lock --------- Co-authored-by: Danny Avila <danny@librechat.ai> Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Co-authored-by: Arthur Barrett <abarrett@fas.harvard.edu>
This commit is contained in:
parent
cea4f57a73
commit
b6e5ea5d33
31 changed files with 1310 additions and 184 deletions
99
api/server/controllers/FavoritesController.js
Normal file
99
api/server/controllers/FavoritesController.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
const { updateUser, getUserById } = require('~/models');
|
||||||
|
|
||||||
|
const MAX_FAVORITES = 50;
|
||||||
|
const MAX_STRING_LENGTH = 256;
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(favorites)) {
|
||||||
|
return res.status(400).json({ message: 'Favorites must be an array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favorites.length > MAX_FAVORITES) {
|
||||||
|
return res.status(400).json({
|
||||||
|
code: 'MAX_FAVORITES_EXCEEDED',
|
||||||
|
message: `Maximum ${MAX_FAVORITES} favorites allowed`,
|
||||||
|
limit: MAX_FAVORITES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fav of favorites) {
|
||||||
|
const hasAgent = !!fav.agentId;
|
||||||
|
const hasModel = !!(fav.model && fav.endpoint);
|
||||||
|
|
||||||
|
if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||||
|
}
|
||||||
|
if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||||
|
}
|
||||||
|
if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAgent && !hasModel) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Each favorite must have either agentId or model+endpoint',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAgent && hasModel) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Favorite cannot have both agentId and model/endpoint',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await updateUser(userId, { 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 getUserById(userId, 'favorites');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let favorites = user.favorites || [];
|
||||||
|
|
||||||
|
if (!Array.isArray(favorites)) {
|
||||||
|
favorites = [];
|
||||||
|
await updateUser(userId, { favorites: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(favorites);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching favorites:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
updateFavoritesController,
|
||||||
|
getFavoritesController,
|
||||||
|
};
|
||||||
13
api/server/routes/settings.js
Normal file
13
api/server/routes/settings.js
Normal 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;
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Two column layout */}
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex h-full items-start gap-3">
|
<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>
|
||||||
|
|
@ -81,13 +81,15 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner info - moved to bottom right */}
|
{/* Owner info */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const displayName = getContactDisplayName(agent);
|
const displayName = getContactDisplayName(agent);
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
|
|
@ -104,6 +106,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,42 +141,48 @@ 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 */}
|
{/* Agent avatar */}
|
||||||
<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 aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|
||||||
{/* Agent name - center aligned below image */}
|
{/* Agent name */}
|
||||||
<div className="mt-3 text-center">
|
<div className="mt-3 text-center">
|
||||||
<h2 className="text-2xl font-bold text-text-primary">
|
<h2 className="text-2xl font-bold text-text-primary">
|
||||||
{agent?.name || localize('com_agents_loading')}
|
{agent?.name || localize('com_agents_loading')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact info - center aligned below name */}
|
{/* Contact info */}
|
||||||
{agent?.support_contact && formatContact() && (
|
{agent?.support_contact && formatContact() && (
|
||||||
<div className="mt-1 text-center text-sm text-text-secondary">
|
<div className="mt-1 text-center text-sm text-text-secondary">
|
||||||
{localize('com_agents_contact')}: {formatContact()}
|
{localize('com_agents_contact')}: {formatContact()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Agent description - below contact */}
|
{/* Agent description */}
|
||||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||||
{agent?.description}
|
{agent?.description}
|
||||||
</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" aria-hidden="true" />
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ describe('AgentCard', () => {
|
||||||
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
|
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays Bot icon fallback when no avatar is provided', () => {
|
it('displays Feather icon fallback when no avatar is provided', () => {
|
||||||
const agentWithoutAvatar = {
|
const agentWithoutAvatar = {
|
||||||
...mockAgent,
|
...mockAgent,
|
||||||
avatar: undefined,
|
avatar: undefined,
|
||||||
|
|
@ -112,9 +112,9 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
|
||||||
|
|
||||||
// Check for Bot icon presence by looking for the svg with lucide-bot class
|
// Check for Feather icon presence by looking for the svg with lucide-feather class
|
||||||
const botIcon = document.querySelector('.lucide-bot');
|
const featherIcon = document.querySelector('.lucide-feather');
|
||||||
expect(botIcon).toBeInTheDocument();
|
expect(featherIcon).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onClick when card is clicked', () => {
|
it('calls onClick when card is clicked', () => {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,23 @@ jest.mock('~/hooks', () => ({
|
||||||
useMediaQuery: jest.fn(() => false), // Mock as desktop by default
|
useMediaQuery: jest.fn(() => false), // Mock as desktop by default
|
||||||
useLocalize: jest.fn(),
|
useLocalize: jest.fn(),
|
||||||
useDefaultConvo: jest.fn(),
|
useDefaultConvo: jest.fn(),
|
||||||
|
useFavorites: jest.fn(() => ({
|
||||||
|
favorites: [],
|
||||||
|
isFavoriteAgent: jest.fn(() => false),
|
||||||
|
toggleFavoriteAgent: jest.fn(),
|
||||||
|
isFavoriteModel: jest.fn(() => false),
|
||||||
|
toggleFavoriteModel: jest.fn(),
|
||||||
|
addFavoriteAgent: jest.fn(),
|
||||||
|
removeFavoriteAgent: jest.fn(),
|
||||||
|
addFavoriteModel: jest.fn(),
|
||||||
|
removeFavoriteModel: jest.fn(),
|
||||||
|
reorderFavorites: jest.fn(),
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
isUpdating: false,
|
||||||
|
fetchError: null,
|
||||||
|
updateError: null,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@librechat/client', () => ({
|
jest.mock('@librechat/client', () => ({
|
||||||
|
|
@ -342,7 +359,7 @@ describe('AgentDetail', () => {
|
||||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||||
|
|
||||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||||
expect(copyLinkButton).toHaveClass('focus:outline-none', 'focus:ring-2');
|
expect(copyLinkButton).toHaveClass('focus-visible:outline-none', 'focus-visible:ring-2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ function ModelSelectorContent() {
|
||||||
searchValue,
|
searchValue,
|
||||||
searchResults,
|
searchResults,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
setSearchValue,
|
setSearchValue,
|
||||||
setSelectedValues,
|
setSelectedValues,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { EarthIcon } 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 type { Endpoint } from '~/common';
|
|
||||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
import { CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
|
import { useFavorites, useLocalize } from '~/hooks';
|
||||||
|
import type { Endpoint } from '~/common';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
interface EndpointModelItemProps {
|
interface EndpointModelItemProps {
|
||||||
modelId: string | null;
|
modelId: string | null;
|
||||||
|
|
@ -12,7 +14,10 @@ interface EndpointModelItemProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
|
export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointModelItemProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
const { handleSelectModel } = useModelSelectorContext();
|
const { handleSelectModel } = useModelSelectorContext();
|
||||||
|
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;
|
||||||
|
|
@ -32,31 +37,79 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
|
||||||
modelName = endpoint.assistantNames[modelId];
|
modelName = endpoint.assistantNames[modelId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAgent = isAgentsEndpoint(endpoint.value);
|
||||||
|
const isFavorite = isAgent
|
||||||
|
? isFavoriteAgent(modelId ?? '')
|
||||||
|
: isFavoriteModel(modelId ?? '', endpoint.value);
|
||||||
|
|
||||||
|
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!modelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAgent) {
|
||||||
|
toggleFavoriteAgent(modelId);
|
||||||
|
} else {
|
||||||
|
toggleFavoriteModel({ model: modelId, endpoint: endpoint.value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAvatar = () => {
|
||||||
|
const isAgentOrAssistant =
|
||||||
|
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value);
|
||||||
|
const showEndpointIcon = isAgentOrAssistant && endpoint.icon;
|
||||||
|
|
||||||
|
const getContent = () => {
|
||||||
|
if (avatarUrl) {
|
||||||
|
return <img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />;
|
||||||
|
}
|
||||||
|
if (showEndpointIcon) {
|
||||||
|
return endpoint.icon;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = getContent();
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
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 ? (
|
{renderAvatar()}
|
||||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
<span className="truncate">{modelName}</span>
|
||||||
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
|
{isGlobal && <EarthIcon className="ml-1 size-4 text-surface-submit" />}
|
||||||
</div>
|
</div>
|
||||||
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
|
<button
|
||||||
endpoint.icon ? (
|
onClick={handleFavoriteClick}
|
||||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
aria-label={isFavorite ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||||
{endpoint.icon}
|
className={cn(
|
||||||
</div>
|
'rounded-md p-1 hover:bg-surface-hover',
|
||||||
) : null}
|
isFavorite ? 'visible' : 'invisible group-hover:visible',
|
||||||
<span className="truncate text-left">{modelName}</span>
|
)}
|
||||||
{isGlobal && (
|
>
|
||||||
<EarthIcon
|
{isFavorite ? (
|
||||||
className="ml-auto size-4 flex-shrink-0 self-center text-green-400"
|
<PinOff className="h-4 w-4 text-text-secondary" />
|
||||||
|
) : (
|
||||||
|
<Pin
|
||||||
|
className="h-4 w-4 text-text-secondary"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="flex-shrink-0 self-center">
|
<div className="flex-shrink-0 self-center">
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
import { useMemo, memo, type FC, useCallback } from 'react';
|
import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Spinner, useMediaQuery } from '@librechat/client';
|
import { Spinner, useMediaQuery } from '@librechat/client';
|
||||||
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
|
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
|
||||||
import { TConversation } from 'librechat-data-provider';
|
import { TConversation } from 'librechat-data-provider';
|
||||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks';
|
||||||
import { groupConversationsByDate } from '~/utils';
|
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
|
||||||
|
import { groupConversationsByDate, cn } from '~/utils';
|
||||||
import Convo from './Convo';
|
import Convo from './Convo';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
interface ConversationsProps {
|
interface ConversationsProps {
|
||||||
conversations: Array<TConversation | null>;
|
conversations: Array<TConversation | null>;
|
||||||
moveToTop: () => void;
|
moveToTop: () => void;
|
||||||
toggleNav: () => void;
|
toggleNav: () => void;
|
||||||
containerRef: React.RefObject<HTMLDivElement | List>;
|
containerRef: React.RefObject<List>;
|
||||||
loadMoreConversations: () => void;
|
loadMoreConversations: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSearchLoading: boolean;
|
isSearchLoading: boolean;
|
||||||
|
isChatsExpanded: boolean;
|
||||||
|
setIsChatsExpanded: (expanded: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoadingSpinner = memo(() => {
|
const LoadingSpinner = memo(() => {
|
||||||
|
|
@ -30,10 +36,13 @@ const LoadingSpinner = memo(() => {
|
||||||
|
|
||||||
LoadingSpinner.displayName = 'LoadingSpinner';
|
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||||
|
|
||||||
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return (
|
return (
|
||||||
<h2 className="mt-2 pl-2 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
|
<h2
|
||||||
|
className={cn('pl-1 pt-1 text-text-secondary', isFirst === true ? 'mt-0' : 'mt-2')}
|
||||||
|
style={{ fontSize: '0.7rem' }}
|
||||||
|
>
|
||||||
{localize(groupName as TranslationKeys) || groupName}
|
{localize(groupName as TranslationKeys) || groupName}
|
||||||
</h2>
|
</h2>
|
||||||
);
|
);
|
||||||
|
|
@ -42,6 +51,8 @@ const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
||||||
DateLabel.displayName = 'DateLabel';
|
DateLabel.displayName = 'DateLabel';
|
||||||
|
|
||||||
type FlattenedItem =
|
type FlattenedItem =
|
||||||
|
| { type: 'favorites' }
|
||||||
|
| { type: 'chats-header' }
|
||||||
| { type: 'header'; groupName: string }
|
| { type: 'header'; groupName: string }
|
||||||
| { type: 'convo'; convo: TConversation }
|
| { type: 'convo'; convo: TConversation }
|
||||||
| { type: 'loading' };
|
| { type: 'loading' };
|
||||||
|
|
@ -75,10 +86,19 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
loadMoreConversations,
|
loadMoreConversations,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSearchLoading,
|
isSearchLoading,
|
||||||
|
isChatsExpanded,
|
||||||
|
setIsChatsExpanded,
|
||||||
}) => {
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const search = useRecoilValue(store.search);
|
||||||
|
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const convoHeight = isSmallScreen ? 44 : 34;
|
const convoHeight = isSmallScreen ? 44 : 34;
|
||||||
|
const showAgentMarketplace = useShowMarketplace();
|
||||||
|
|
||||||
|
// Determine if FavoritesList will render content
|
||||||
|
const shouldShowFavorites =
|
||||||
|
!search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
|
||||||
|
|
||||||
const filteredConversations = useMemo(
|
const filteredConversations = useMemo(
|
||||||
() => rawConversations.filter(Boolean) as TConversation[],
|
() => rawConversations.filter(Boolean) as TConversation[],
|
||||||
|
|
@ -92,6 +112,13 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
|
|
||||||
const flattenedItems = useMemo(() => {
|
const flattenedItems = useMemo(() => {
|
||||||
const items: FlattenedItem[] = [];
|
const items: FlattenedItem[] = [];
|
||||||
|
// Only include favorites row if FavoritesList will render content
|
||||||
|
if (shouldShowFavorites) {
|
||||||
|
items.push({ type: 'favorites' });
|
||||||
|
}
|
||||||
|
items.push({ type: 'chats-header' });
|
||||||
|
|
||||||
|
if (isChatsExpanded) {
|
||||||
groupedConversations.forEach(([groupName, convos]) => {
|
groupedConversations.forEach(([groupName, convos]) => {
|
||||||
items.push({ type: 'header', groupName });
|
items.push({ type: 'header', groupName });
|
||||||
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
|
items.push(...convos.map((convo) => ({ type: 'convo' as const, convo })));
|
||||||
|
|
@ -100,31 +127,64 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
items.push({ type: 'loading' } as any);
|
items.push({ type: 'loading' } as any);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [groupedConversations, isLoading]);
|
}, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]);
|
||||||
|
|
||||||
|
// Store flattenedItems in a ref for keyMapper to access without recreating cache
|
||||||
|
const flattenedItemsRef = useRef(flattenedItems);
|
||||||
|
flattenedItemsRef.current = flattenedItems;
|
||||||
|
|
||||||
|
// Create a stable cache that doesn't depend on flattenedItems
|
||||||
const cache = useMemo(
|
const cache = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new CellMeasurerCache({
|
new CellMeasurerCache({
|
||||||
fixedWidth: true,
|
fixedWidth: true,
|
||||||
defaultHeight: convoHeight,
|
defaultHeight: convoHeight,
|
||||||
keyMapper: (index) => {
|
keyMapper: (index) => {
|
||||||
const item = flattenedItems[index];
|
const item = flattenedItemsRef.current[index];
|
||||||
|
if (!item) {
|
||||||
|
return `unknown-${index}`;
|
||||||
|
}
|
||||||
|
if (item.type === 'favorites') {
|
||||||
|
return 'favorites';
|
||||||
|
}
|
||||||
|
if (item.type === 'chats-header') {
|
||||||
|
return 'chats-header';
|
||||||
|
}
|
||||||
if (item.type === 'header') {
|
if (item.type === 'header') {
|
||||||
return `header-${index}`;
|
return `header-${item.groupName}`;
|
||||||
}
|
}
|
||||||
if (item.type === 'convo') {
|
if (item.type === 'convo') {
|
||||||
return `convo-${item.convo.conversationId}`;
|
return `convo-${item.convo.conversationId}`;
|
||||||
}
|
}
|
||||||
if (item.type === 'loading') {
|
if (item.type === 'loading') {
|
||||||
return `loading-${index}`;
|
return 'loading';
|
||||||
}
|
}
|
||||||
return `unknown-${index}`;
|
return `unknown-${index}`;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[flattenedItems, convoHeight],
|
[convoHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Debounced function to clear cache and recompute heights
|
||||||
|
const clearFavoritesCache = useCallback(() => {
|
||||||
|
if (cache) {
|
||||||
|
cache.clear(0, 0);
|
||||||
|
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
|
||||||
|
containerRef.current.recomputeRowHeights(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cache, containerRef]);
|
||||||
|
|
||||||
|
// Clear cache when favorites change
|
||||||
|
useEffect(() => {
|
||||||
|
const frameId = requestAnimationFrame(() => {
|
||||||
|
clearFavoritesCache();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frameId);
|
||||||
|
}, [favorites.length, isFavoritesLoading, clearFavoritesCache]);
|
||||||
|
|
||||||
const rowRenderer = useCallback(
|
const rowRenderer = useCallback(
|
||||||
({ index, key, parent, style }) => {
|
({ index, key, parent, style }) => {
|
||||||
const item = flattenedItems[index];
|
const item = flattenedItems[index];
|
||||||
|
|
@ -140,8 +200,36 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let rendering: JSX.Element;
|
let rendering: JSX.Element;
|
||||||
if (item.type === 'header') {
|
if (item.type === 'favorites') {
|
||||||
rendering = <DateLabel groupName={item.groupName} />;
|
rendering = (
|
||||||
|
<FavoritesList
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
toggleNav={toggleNav}
|
||||||
|
onHeightChange={clearFavoritesCache}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (item.type === 'chats-header') {
|
||||||
|
rendering = (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsChatsExpanded(!isChatsExpanded)}
|
||||||
|
className="group flex w-full items-center justify-between px-1 py-2 text-xs font-bold text-text-secondary"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="select-none">{localize('com_ui_chats')}</span>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3 transition-transform duration-200',
|
||||||
|
isChatsExpanded ? 'rotate-90' : '',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else if (item.type === 'header') {
|
||||||
|
// First date header index depends on whether favorites row is included
|
||||||
|
// With favorites: [favorites, chats-header, first-header] → index 2
|
||||||
|
// Without favorites: [chats-header, first-header] → index 1
|
||||||
|
const firstHeaderIndex = shouldShowFavorites ? 2 : 1;
|
||||||
|
rendering = <DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />;
|
||||||
} else if (item.type === 'convo') {
|
} else if (item.type === 'convo') {
|
||||||
rendering = (
|
rendering = (
|
||||||
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
|
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
|
||||||
|
|
@ -150,14 +238,25 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
return (
|
return (
|
||||||
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
||||||
{({ registerChild }) => (
|
{({ registerChild }) => (
|
||||||
<div ref={registerChild} style={style} className="px-2">
|
<div ref={registerChild} style={style} className="">
|
||||||
{rendering}
|
{rendering}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CellMeasurer>
|
</CellMeasurer>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[cache, flattenedItems, moveToTop, toggleNav],
|
[
|
||||||
|
cache,
|
||||||
|
flattenedItems,
|
||||||
|
moveToTop,
|
||||||
|
toggleNav,
|
||||||
|
clearFavoritesCache,
|
||||||
|
isSmallScreen,
|
||||||
|
isChatsExpanded,
|
||||||
|
localize,
|
||||||
|
setIsChatsExpanded,
|
||||||
|
shouldShowFavorites,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRowHeight = useCallback(
|
const getRowHeight = useCallback(
|
||||||
|
|
@ -180,7 +279,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col pb-2 text-sm text-text-primary">
|
<div className="relative flex h-full min-h-0 flex-col pb-2 text-sm text-text-primary">
|
||||||
{isSearchLoading ? (
|
{isSearchLoading ? (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<Spinner className="text-text-primary" />
|
<Spinner className="text-text-primary" />
|
||||||
|
|
@ -191,7 +290,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ width, height }) => (
|
{({ width, height }) => (
|
||||||
<List
|
<List
|
||||||
ref={containerRef as React.RefObject<List>}
|
ref={containerRef}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
deferredMeasurementCache={cache}
|
deferredMeasurementCache={cache}
|
||||||
|
|
|
||||||
|
|
@ -132,10 +132,10 @@ 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 rounded-lg md:h-9',
|
||||||
isActiveConvo
|
isActiveConvo || isPopoverActive
|
||||||
? 'rounded-r-lg border-l-border-xheavy bg-surface-active-alt'
|
? 'bg-surface-active-alt before:absolute before:bottom-1 before:left-0 before:top-1 before:w-0.5 before:rounded-full before:bg-black dark:before:bg-white'
|
||||||
: 'rounded-lg border-l-transparent hover:bg-surface-active-alt',
|
: 'hover:bg-surface-active-alt',
|
||||||
)}
|
)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={renaming ? -1 : 0}
|
tabIndex={renaming ? -1 : 0}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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%',
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useCallback, useContext } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { LayoutGrid } from 'lucide-react';
|
import { LayoutGrid } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
|
||||||
import { TooltipAnchor, Button } from '@librechat/client';
|
import { TooltipAnchor, Button } from '@librechat/client';
|
||||||
import { useLocalize, useHasAccess, AuthContext } from '~/hooks';
|
import { useLocalize, useShowMarketplace } from '~/hooks';
|
||||||
|
|
||||||
interface AgentMarketplaceButtonProps {
|
interface AgentMarketplaceButtonProps {
|
||||||
isSmallScreen?: boolean;
|
isSmallScreen?: boolean;
|
||||||
|
|
@ -16,17 +15,7 @@ export default function AgentMarketplaceButton({
|
||||||
}: AgentMarketplaceButtonProps) {
|
}: AgentMarketplaceButtonProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const authContext = useContext(AuthContext);
|
const showAgentMarketplace = useShowMarketplace();
|
||||||
|
|
||||||
const hasAccessToAgents = useHasAccess({
|
|
||||||
permissionType: PermissionTypes.AGENTS,
|
|
||||||
permission: Permissions.USE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasAccessToMarketplace = useHasAccess({
|
|
||||||
permissionType: PermissionTypes.MARKETPLACE,
|
|
||||||
permission: Permissions.USE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAgentMarketplace = useCallback(() => {
|
const handleAgentMarketplace = useCallback(() => {
|
||||||
navigate('/agents');
|
navigate('/agents');
|
||||||
|
|
@ -35,14 +24,6 @@ export default function AgentMarketplaceButton({
|
||||||
}
|
}
|
||||||
}, [navigate, isSmallScreen, toggleNav]);
|
}, [navigate, isSmallScreen, toggleNav]);
|
||||||
|
|
||||||
// Check if auth is ready (avoid race conditions)
|
|
||||||
const authReady =
|
|
||||||
authContext?.isAuthenticated !== undefined &&
|
|
||||||
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
|
|
||||||
|
|
||||||
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
|
|
||||||
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
|
|
||||||
|
|
||||||
if (!showAgentMarketplace) {
|
if (!showAgentMarketplace) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
150
client/src/components/Nav/Favorites/FavoriteItem.tsx
Normal file
150
client/src/components/Nav/Favorites/FavoriteItem.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import * as Menu from '@ariakit/react/menu';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Ellipsis, PinOff } from 'lucide-react';
|
||||||
|
import { DropdownPopup } from '@librechat/client';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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,
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
agent_id: agent.id,
|
||||||
|
},
|
||||||
|
preset: {
|
||||||
|
...agent,
|
||||||
|
endpoint: EModelEndpoint.agents,
|
||||||
|
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: 'icon', 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 t.TConversation}
|
||||||
|
endpoint={model.endpoint}
|
||||||
|
model={model.model}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getName = () => {
|
||||||
|
if (type === 'agent') {
|
||||||
|
return (item as t.Agent).name;
|
||||||
|
}
|
||||||
|
return (item as FavoriteModel).model;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuId = React.useId();
|
||||||
|
|
||||||
|
const dropdownItems = [
|
||||||
|
{
|
||||||
|
label: localize('com_ui_unpin'),
|
||||||
|
onClick: handleRemove,
|
||||||
|
icon: <PinOff className="h-4 w-4 text-text-secondary" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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',
|
||||||
|
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" />
|
||||||
|
</Menu.MenuButton>
|
||||||
|
}
|
||||||
|
items={dropdownItems}
|
||||||
|
menuId={menuId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
client/src/components/Nav/Favorites/FavoritesList.tsx
Normal file
299
client/src/components/Nav/Favorites/FavoritesList.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
import React, { useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { LayoutGrid } from 'lucide-react';
|
||||||
|
import { useDrag, useDrop } from 'react-dnd';
|
||||||
|
import { Skeleton } from '@librechat/client';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { QueryKeys, dataService } from 'librechat-data-provider';
|
||||||
|
import { useQueries, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { InfiniteData } from '@tanstack/react-query';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
import { useFavorites, useLocalize, useShowMarketplace } from '~/hooks';
|
||||||
|
import FavoriteItem from './FavoriteItem';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
const FavoriteItemSkeleton = () => (
|
||||||
|
<div className="flex w-full items-center rounded-lg px-3 py-2">
|
||||||
|
<Skeleton className="mr-2 h-5 w-5 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MarketplaceSkeleton = () => (
|
||||||
|
<div className="flex w-full items-center rounded-lg px-3 py-2">
|
||||||
|
<Skeleton className="mr-2 h-5 w-5" />
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<{ index: number; id: string }, unknown, { handlerId: any }>(
|
||||||
|
{
|
||||||
|
accept: 'favorite-item',
|
||||||
|
collect(monitor) {
|
||||||
|
return {
|
||||||
|
handlerId: monitor.getHandlerId(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hover(item, 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();
|
||||||
|
if (!clientOffset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hoverClientY = clientOffset.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({
|
||||||
|
isSmallScreen,
|
||||||
|
toggleNav,
|
||||||
|
onHeightChange,
|
||||||
|
}: {
|
||||||
|
isSmallScreen?: boolean;
|
||||||
|
toggleNav?: () => void;
|
||||||
|
/** Callback when the list height might have changed (e.g., agents finished loading) */
|
||||||
|
onHeightChange?: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const localize = useLocalize();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const search = useRecoilValue(store.search);
|
||||||
|
const { favorites, reorderFavorites, isLoading: isFavoritesLoading } = useFavorites();
|
||||||
|
const showAgentMarketplace = useShowMarketplace();
|
||||||
|
|
||||||
|
const handleAgentMarketplace = useCallback(() => {
|
||||||
|
navigate('/agents');
|
||||||
|
if (isSmallScreen && toggleNav) {
|
||||||
|
toggleNav();
|
||||||
|
}
|
||||||
|
}, [navigate, isSmallScreen, toggleNav]);
|
||||||
|
|
||||||
|
// Ensure favorites is always an array (could be corrupted in localStorage)
|
||||||
|
const safeFavorites = useMemo(() => (Array.isArray(favorites) ? favorites : []), [favorites]);
|
||||||
|
|
||||||
|
const agentIds = safeFavorites.map((f) => f.agentId).filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const agentQueries = useQueries({
|
||||||
|
queries: agentIds.map((agentId) => ({
|
||||||
|
queryKey: [QueryKeys.agent, agentId],
|
||||||
|
queryFn: () => dataService.getAgentById({ agent_id: agentId }),
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAgentsLoading = agentIds.length > 0 && agentQueries.some((q) => q.isLoading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAgentsLoading && onHeightChange) {
|
||||||
|
onHeightChange();
|
||||||
|
}
|
||||||
|
}, [isAgentsLoading, onHeightChange]);
|
||||||
|
const agentsMap = useMemo(() => {
|
||||||
|
const map: Record<string, t.Agent> = {};
|
||||||
|
|
||||||
|
const addToMap = (agent: t.Agent) => {
|
||||||
|
if (agent && agent.id && !map[agent.id]) {
|
||||||
|
map[agent.id] = agent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 draggedFavoritesRef = useRef(safeFavorites);
|
||||||
|
|
||||||
|
const moveItem = useCallback(
|
||||||
|
(dragIndex: number, hoverIndex: number) => {
|
||||||
|
const newFavorites = [...draggedFavoritesRef.current];
|
||||||
|
const [draggedItem] = newFavorites.splice(dragIndex, 1);
|
||||||
|
newFavorites.splice(hoverIndex, 0, draggedItem);
|
||||||
|
draggedFavoritesRef.current = newFavorites;
|
||||||
|
reorderFavorites(newFavorites, false);
|
||||||
|
},
|
||||||
|
[reorderFavorites],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(() => {
|
||||||
|
// Persist the final order using the ref which has the latest state
|
||||||
|
reorderFavorites(draggedFavoritesRef.current, true);
|
||||||
|
}, [reorderFavorites]);
|
||||||
|
|
||||||
|
// Keep ref in sync when favorites change from external sources
|
||||||
|
useEffect(() => {
|
||||||
|
draggedFavoritesRef.current = safeFavorites;
|
||||||
|
}, [safeFavorites]);
|
||||||
|
|
||||||
|
if (search.query) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFavoritesLoading && safeFavorites.length === 0 && !showAgentMarketplace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFavoritesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-col pb-2">
|
||||||
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
|
{showAgentMarketplace && <MarketplaceSkeleton />}
|
||||||
|
<FavoriteItemSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-col">
|
||||||
|
<div className="mt-1 flex flex-col gap-1">
|
||||||
|
{/* Show skeletons for ALL items while agents are still loading */}
|
||||||
|
{isAgentsLoading ? (
|
||||||
|
<>
|
||||||
|
{/* Marketplace skeleton */}
|
||||||
|
{showAgentMarketplace && <MarketplaceSkeleton />}
|
||||||
|
{/* Favorite items skeletons */}
|
||||||
|
{safeFavorites.map((_, index) => (
|
||||||
|
<FavoriteItemSkeleton key={`skeleton-${index}`} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Agent Marketplace button */}
|
||||||
|
{showAgentMarketplace && (
|
||||||
|
<div
|
||||||
|
className="group relative flex w-full cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
|
||||||
|
onClick={handleAgentMarketplace}
|
||||||
|
data-testid="nav-agents-marketplace-button"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center truncate pr-6">
|
||||||
|
<div className="mr-2 h-5 w-5">
|
||||||
|
<LayoutGrid className="h-5 w-5 text-text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{localize('com_agents_marketplace')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{safeFavorites.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={{ model: fav.model, endpoint: fav.endpoint }}
|
||||||
|
type="model"
|
||||||
|
/>
|
||||||
|
</DraggableFavoriteItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { List } from 'react-virtualized';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useMediaQuery } from '@librechat/client';
|
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||||
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
|
||||||
|
|
@ -21,11 +22,18 @@ import store from '~/store';
|
||||||
|
|
||||||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||||
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
|
|
||||||
|
|
||||||
const NAV_WIDTH_DESKTOP = '260px';
|
const NAV_WIDTH_DESKTOP = '260px';
|
||||||
const NAV_WIDTH_MOBILE = '320px';
|
const NAV_WIDTH_MOBILE = '320px';
|
||||||
|
|
||||||
|
const SearchBarSkeleton = memo(() => (
|
||||||
|
<div className={cn('flex h-10 items-center py-2')}>
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
SearchBarSkeleton.displayName = 'SearchBarSkeleton';
|
||||||
|
|
||||||
const NavMask = memo(
|
const NavMask = memo(
|
||||||
({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
|
({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -60,6 +68,7 @@ const Nav = memo(
|
||||||
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
|
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||||
|
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
|
||||||
const [showLoading, setShowLoading] = useState(false);
|
const [showLoading, setShowLoading] = useState(false);
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -92,7 +101,7 @@ const Nav = memo(
|
||||||
}, [data?.pages]);
|
}, [data?.pages]);
|
||||||
|
|
||||||
const outerContainerRef = useRef<HTMLDivElement>(null);
|
const outerContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const listRef = useRef<any>(null);
|
const conversationsRef = useRef<List | null>(null);
|
||||||
|
|
||||||
const { moveToTop } = useNavScrolling<ConversationListResponse>({
|
const { moveToTop } = useNavScrolling<ConversationListResponse>({
|
||||||
setShowLoading,
|
setShowLoading,
|
||||||
|
|
@ -152,16 +161,18 @@ const Nav = memo(
|
||||||
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
|
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
|
||||||
|
|
||||||
const subHeaders = useMemo(
|
const subHeaders = useMemo(
|
||||||
() => search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />,
|
() => (
|
||||||
|
<>
|
||||||
|
{search.enabled === null && <SearchBarSkeleton />}
|
||||||
|
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
|
||||||
|
</>
|
||||||
|
),
|
||||||
[search.enabled, isSmallScreen],
|
[search.enabled, isSmallScreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const headerButtons = useMemo(
|
const headerButtons = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
|
||||||
</Suspense>
|
|
||||||
{hasAccessToBookmarks && (
|
{hasAccessToBookmarks && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1.5" />
|
<div className="mt-1.5" />
|
||||||
|
|
@ -172,7 +183,7 @@ const Nav = memo(
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible],
|
[hasAccessToBookmarks, tags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isSearchLoading, setIsSearchLoading] = useState(
|
const [isSearchLoading, setIsSearchLoading] = useState(
|
||||||
|
|
@ -210,25 +221,29 @@ const Nav = memo(
|
||||||
<nav
|
<nav
|
||||||
id="chat-history-nav"
|
id="chat-history-nav"
|
||||||
aria-label={localize('com_ui_chat_history')}
|
aria-label={localize('com_ui_chat_history')}
|
||||||
className="flex h-full flex-col px-2 pb-3.5 md:px-3"
|
className="flex h-full flex-col px-2 pb-3.5"
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col" ref={outerContainerRef}>
|
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
|
||||||
<MemoNewChat
|
<MemoNewChat
|
||||||
subHeaders={subHeaders}
|
subHeaders={subHeaders}
|
||||||
toggleNav={toggleNavVisible}
|
toggleNav={toggleNavVisible}
|
||||||
headerButtons={headerButtons}
|
headerButtons={headerButtons}
|
||||||
isSmallScreen={isSmallScreen}
|
isSmallScreen={isSmallScreen}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
|
||||||
<Conversations
|
<Conversations
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
moveToTop={moveToTop}
|
moveToTop={moveToTop}
|
||||||
toggleNav={itemToggleNav}
|
toggleNav={itemToggleNav}
|
||||||
containerRef={listRef}
|
containerRef={conversationsRef}
|
||||||
loadMoreConversations={loadMoreConversations}
|
loadMoreConversations={loadMoreConversations}
|
||||||
isLoading={isFetchingNextPage || showLoading || isLoading}
|
isLoading={isFetchingNextPage || showLoading || isLoading}
|
||||||
isSearchLoading={isSearchLoading}
|
isSearchLoading={isSearchLoading}
|
||||||
|
isChatsExpanded={isChatsExpanded}
|
||||||
|
setIsChatsExpanded={setIsChatsExpanded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover"
|
||||||
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary transition-all duration-200 focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover',
|
|
||||||
isSmallScreen === true ? 'mb-2 h-14 rounded-xl' : '',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Search
|
<Search
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
|
||||||
44
client/src/data-provider/Favorites.ts
Normal file
44
client/src/data-provider/Favorites.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { dataService } from 'librechat-data-provider';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||||
|
import type { FavoritesState } from '~/store/favorites';
|
||||||
|
|
||||||
|
export const useGetFavoritesQuery = (
|
||||||
|
config?: Omit<UseQueryOptions<FavoritesState, Error>, 'queryKey' | 'queryFn'>,
|
||||||
|
) => {
|
||||||
|
return useQuery<FavoritesState, Error>(
|
||||||
|
['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>,
|
||||||
|
{
|
||||||
|
// Optimistic update to prevent UI flickering when toggling favorites
|
||||||
|
// Sets query cache immediately before the request completes
|
||||||
|
onMutate: async (newFavorites) => {
|
||||||
|
await queryClient.cancelQueries(['favorites']);
|
||||||
|
|
||||||
|
const previousFavorites = queryClient.getQueryData<FavoritesState>(['favorites']);
|
||||||
|
queryClient.setQueryData(['favorites'], newFavorites);
|
||||||
|
|
||||||
|
return { previousFavorites };
|
||||||
|
},
|
||||||
|
onError: (_err, _newFavorites, context) => {
|
||||||
|
if (context?.previousFavorites) {
|
||||||
|
queryClient.setQueryData(['favorites'], context.previousFavorites);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { default as useNavScrolling } from './useNavScrolling';
|
export { default as useNavScrolling } from './useNavScrolling';
|
||||||
|
export { default as useShowMarketplace } from './useShowMarketplace';
|
||||||
export * from './useNavHelpers';
|
export * from './useNavHelpers';
|
||||||
|
|
|
||||||
37
client/src/hooks/Nav/useShowMarketplace.ts
Normal file
37
client/src/hooks/Nav/useShowMarketplace.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useContext, useMemo } from 'react';
|
||||||
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
|
import { useHasAccess, AuthContext } from '~/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to determine if the Agent Marketplace should be shown.
|
||||||
|
* Consolidates the logic for checking:
|
||||||
|
* - Auth readiness (avoid race conditions)
|
||||||
|
* - Access to Agents permission
|
||||||
|
* - Access to Marketplace permission
|
||||||
|
*
|
||||||
|
* @returns Whether the Agent Marketplace should be displayed
|
||||||
|
*/
|
||||||
|
export default function useShowMarketplace(): boolean {
|
||||||
|
const authContext = useContext(AuthContext);
|
||||||
|
|
||||||
|
const hasAccessToAgents = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.AGENTS,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAccessToMarketplace = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.MARKETPLACE,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if auth is ready (avoid race conditions)
|
||||||
|
const authReady = useMemo(
|
||||||
|
() =>
|
||||||
|
authContext?.isAuthenticated !== undefined &&
|
||||||
|
(authContext?.isAuthenticated === false || authContext?.user !== undefined),
|
||||||
|
[authContext?.isAuthenticated, authContext?.user],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
|
||||||
|
return authReady && hasAccessToAgents && hasAccessToMarketplace;
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export type { TranslationKeys } from './useLocalize';
|
||||||
export { default as useTimeout } from './useTimeout';
|
export { default as useTimeout } from './useTimeout';
|
||||||
export { default as useNewConvo } from './useNewConvo';
|
export { default as useNewConvo } from './useNewConvo';
|
||||||
export { default as useLocalize } from './useLocalize';
|
export { default as useLocalize } from './useLocalize';
|
||||||
|
export { default as useFavorites } from './useFavorites';
|
||||||
export { default as useChatBadges } from './useChatBadges';
|
export { default as useChatBadges } from './useChatBadges';
|
||||||
export { default as useScrollToRef } from './useScrollToRef';
|
export { default as useScrollToRef } from './useScrollToRef';
|
||||||
export { default as useLocalStorage } from './useLocalStorage';
|
export { default as useLocalStorage } from './useLocalStorage';
|
||||||
|
|
|
||||||
206
client/src/hooks/useFavorites.ts
Normal file
206
client/src/hooks/useFavorites.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { useToastContext } from '@librechat/client';
|
||||||
|
import type { Favorite } from '~/store/favorites';
|
||||||
|
import { useGetFavoritesQuery, useUpdateFavoritesMutation } from '~/data-provider';
|
||||||
|
import { favoritesAtom } from '~/store';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
|
||||||
|
/** Maximum number of favorites allowed (must match backend MAX_FAVORITES) */
|
||||||
|
const MAX_FAVORITES = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing user favorites (pinned agents and models).
|
||||||
|
*
|
||||||
|
* Favorites are synchronized with the server via `/api/user/settings/favorites`.
|
||||||
|
* Each favorite is either:
|
||||||
|
* - An agent: `{ agentId: string }`
|
||||||
|
* - A model: `{ model: string, endpoint: string }`
|
||||||
|
*
|
||||||
|
* @returns Object containing favorites state and helper methods for
|
||||||
|
* adding, removing, toggling, reordering, and checking favorites.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans favorites array to only include canonical shapes (agentId or model+endpoint).
|
||||||
|
*/
|
||||||
|
const cleanFavorites = (favorites: Favorite[]): Favorite[] => {
|
||||||
|
if (!Array.isArray(favorites)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return favorites.map((f) => {
|
||||||
|
if (f.agentId) {
|
||||||
|
return { agentId: f.agentId };
|
||||||
|
}
|
||||||
|
if (f.model && f.endpoint) {
|
||||||
|
return { model: f.model, endpoint: f.endpoint };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useFavorites() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const [favorites, setFavorites] = useAtom(favoritesAtom);
|
||||||
|
const getFavoritesQuery = useGetFavoritesQuery();
|
||||||
|
const updateFavoritesMutation = useUpdateFavoritesMutation();
|
||||||
|
|
||||||
|
const isMutatingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip updating local state if a mutation is in progress or just completed
|
||||||
|
// The local state is already optimistically updated by saveFavorites
|
||||||
|
if (isMutatingRef.current || updateFavoritesMutation.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (getFavoritesQuery.data) {
|
||||||
|
if (Array.isArray(getFavoritesQuery.data)) {
|
||||||
|
setFavorites(getFavoritesQuery.data);
|
||||||
|
} else {
|
||||||
|
setFavorites([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [getFavoritesQuery.data, setFavorites, updateFavoritesMutation.isLoading]);
|
||||||
|
|
||||||
|
const getErrorMessage = useCallback(
|
||||||
|
(error: unknown): string => {
|
||||||
|
if (error && typeof error === 'object' && 'response' in error) {
|
||||||
|
const axiosError = error as {
|
||||||
|
response?: { data?: { code?: string; limit?: number } };
|
||||||
|
};
|
||||||
|
const { code, limit } = axiosError.response?.data ?? {};
|
||||||
|
|
||||||
|
if (code === 'MAX_FAVORITES_EXCEEDED') {
|
||||||
|
return localize('com_ui_max_favorites_reached', { 0: String(limit ?? MAX_FAVORITES) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localize('com_ui_error');
|
||||||
|
},
|
||||||
|
[localize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveFavorites = useCallback(
|
||||||
|
async (newFavorites: typeof favorites) => {
|
||||||
|
const cleaned = cleanFavorites(newFavorites);
|
||||||
|
setFavorites(cleaned);
|
||||||
|
isMutatingRef.current = true;
|
||||||
|
try {
|
||||||
|
await updateFavoritesMutation.mutateAsync(cleaned);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating favorites:', error);
|
||||||
|
showToast({ message: getErrorMessage(error), status: 'error' });
|
||||||
|
// Refetch to resync state with server
|
||||||
|
getFavoritesQuery.refetch();
|
||||||
|
} finally {
|
||||||
|
// Use a small delay to prevent the useEffect from triggering immediately
|
||||||
|
// after the mutation completes but before React has finished processing
|
||||||
|
setTimeout(() => {
|
||||||
|
isMutatingRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setFavorites, updateFavoritesMutation, showToast, getErrorMessage, getFavoritesQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addFavoriteAgent = (agentId: string) => {
|
||||||
|
if (favorites.some((f) => f.agentId === agentId)) return;
|
||||||
|
const newFavorites = [...favorites, { agentId }];
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFavoriteAgent = (agentId: string) => {
|
||||||
|
const newFavorites = favorites.filter((f) => f.agentId !== agentId);
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 newFavorites = favorites.filter((f) => !(f.model === model && f.endpoint === endpoint));
|
||||||
|
saveFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFavoriteAgent = (agentId: string | undefined | null) => {
|
||||||
|
if (!agentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return favorites.some((f) => f.agentId === agentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFavoriteModel = (model: string, endpoint: string) => {
|
||||||
|
return favorites.some((f) => f.model === model && f.endpoint === endpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavoriteAgent = (agentId: string) => {
|
||||||
|
if (isFavoriteAgent(agentId)) {
|
||||||
|
removeFavoriteAgent(agentId);
|
||||||
|
} else {
|
||||||
|
addFavoriteAgent(agentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFavoriteModel = (model: { model: string; endpoint: string }) => {
|
||||||
|
if (isFavoriteModel(model.model, model.endpoint)) {
|
||||||
|
removeFavoriteModel(model.model, model.endpoint);
|
||||||
|
} else {
|
||||||
|
addFavoriteModel(model);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder favorites and optionally persist the new order to the server.
|
||||||
|
* This combines state update and persistence to avoid race conditions
|
||||||
|
* where the closure captures stale state.
|
||||||
|
*/
|
||||||
|
const reorderFavorites = useCallback(
|
||||||
|
async (newFavorites: typeof favorites, persist = false) => {
|
||||||
|
const cleaned = cleanFavorites(newFavorites);
|
||||||
|
setFavorites(cleaned);
|
||||||
|
if (persist) {
|
||||||
|
isMutatingRef.current = true;
|
||||||
|
try {
|
||||||
|
await updateFavoritesMutation.mutateAsync(cleaned);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error reordering favorites:', error);
|
||||||
|
showToast({ message: getErrorMessage(error), status: 'error' });
|
||||||
|
// Refetch to resync state with server
|
||||||
|
getFavoritesQuery.refetch();
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
isMutatingRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setFavorites, updateFavoritesMutation, showToast, getErrorMessage, getFavoritesQuery],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
favorites,
|
||||||
|
addFavoriteAgent,
|
||||||
|
removeFavoriteAgent,
|
||||||
|
addFavoriteModel,
|
||||||
|
removeFavoriteModel,
|
||||||
|
isFavoriteAgent,
|
||||||
|
isFavoriteModel,
|
||||||
|
toggleFavoriteAgent,
|
||||||
|
toggleFavoriteModel,
|
||||||
|
reorderFavorites,
|
||||||
|
/** Whether the favorites query is currently loading */
|
||||||
|
isLoading: getFavoritesQuery.isLoading,
|
||||||
|
/** Whether there was an error fetching favorites */
|
||||||
|
isError: getFavoritesQuery.isError,
|
||||||
|
/** Whether the update mutation is in progress */
|
||||||
|
isUpdating: updateFavoritesMutation.isLoading,
|
||||||
|
/** Error from fetching favorites, if any */
|
||||||
|
fetchError: getFavoritesQuery.error,
|
||||||
|
/** Error from updating favorites, if any */
|
||||||
|
updateError: updateFavoritesMutation.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
"com_agents_link_copy_failed": "Failed to copy link",
|
"com_agents_link_copy_failed": "Failed to copy link",
|
||||||
"com_agents_load_more_label": "Load more agents from {{category}} category",
|
"com_agents_load_more_label": "Load more agents from {{category}} category",
|
||||||
"com_agents_loading": "Loading...",
|
"com_agents_loading": "Loading...",
|
||||||
"com_agents_marketplace": "Agent Marketplace",
|
"com_agents_marketplace": "Explore Agents",
|
||||||
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
||||||
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
|
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
|
||||||
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
|
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
|
||||||
|
|
@ -772,6 +772,7 @@
|
||||||
"com_ui_chat": "Chat",
|
"com_ui_chat": "Chat",
|
||||||
"com_ui_chat_history": "Chat History",
|
"com_ui_chat_history": "Chat History",
|
||||||
"com_ui_check_internet": "Check your internet connection",
|
"com_ui_check_internet": "Check your internet connection",
|
||||||
|
"com_ui_chats": "Chats",
|
||||||
"com_ui_clear": "Clear",
|
"com_ui_clear": "Clear",
|
||||||
"com_ui_clear_all": "Clear all",
|
"com_ui_clear_all": "Clear all",
|
||||||
"com_ui_clear_browser_cache": "Clear your browser cache",
|
"com_ui_clear_browser_cache": "Clear your browser cache",
|
||||||
|
|
@ -851,6 +852,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_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?",
|
||||||
|
|
@ -1032,6 +1034,7 @@
|
||||||
"com_ui_manage": "Manage",
|
"com_ui_manage": "Manage",
|
||||||
"com_ui_marketplace": "Marketplace",
|
"com_ui_marketplace": "Marketplace",
|
||||||
"com_ui_marketplace_allow_use": "Allow using Marketplace",
|
"com_ui_marketplace_allow_use": "Allow using Marketplace",
|
||||||
|
"com_ui_max_favorites_reached": "Maximum pinned items reached ({{0}}). Unpin an item to add more.",
|
||||||
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
|
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
|
||||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||||
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
|
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
|
||||||
|
|
@ -1128,6 +1131,7 @@
|
||||||
"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_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",
|
||||||
|
|
|
||||||
19
client/src/store/favorites.ts
Normal file
19
client/src/store/favorites.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { createStorageAtom } from './jotai-utils';
|
||||||
|
|
||||||
|
export type Favorite = {
|
||||||
|
agentId?: string;
|
||||||
|
model?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FavoriteModel = {
|
||||||
|
model: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FavoritesState = Favorite[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This atom stores the user's favorite models/agents
|
||||||
|
*/
|
||||||
|
export const favoritesAtom = createStorageAtom<FavoritesState>('favorites', []);
|
||||||
|
|
@ -14,6 +14,7 @@ import misc from './misc';
|
||||||
import isTemporary from './temporary';
|
import isTemporary from './temporary';
|
||||||
export * from './agents';
|
export * from './agents';
|
||||||
export * from './mcp';
|
export * from './mcp';
|
||||||
|
export * from './favorites';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...artifacts,
|
...artifacts,
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@ import '@testing-library/jest-dom';
|
||||||
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
|
import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
// Mock the Bot icon from lucide-react
|
// Mock the Feather icon from lucide-react
|
||||||
jest.mock('lucide-react', () => ({
|
jest.mock('lucide-react', () => ({
|
||||||
Bot: ({ className, strokeWidth, ...props }: any) => (
|
Feather: ({ className, strokeWidth, ...props }: any) => (
|
||||||
<svg data-testid="bot-icon" className={className} data-stroke-width={strokeWidth} {...props}>
|
<svg
|
||||||
<title>{/* eslint-disable-line i18next/no-literal-string */}Bot Icon</title>
|
data-testid="feather-icon"
|
||||||
|
className={className}
|
||||||
|
data-stroke-width={strokeWidth}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>{/* eslint-disable-line i18next/no-literal-string */}Feather Icon</title>
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
@ -72,7 +77,7 @@ describe('Agent Utilities', () => {
|
||||||
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
|
expect(img).toHaveClass('rounded-full', 'object-cover', 'shadow-lg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render Bot icon fallback when no avatar', () => {
|
it('should render Feather icon fallback when no avatar', () => {
|
||||||
const agent = {
|
const agent = {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Test Agent',
|
name: 'Test Agent',
|
||||||
|
|
@ -80,9 +85,9 @@ describe('Agent Utilities', () => {
|
||||||
|
|
||||||
render(<div>{renderAgentAvatar(agent)}</div>);
|
render(<div>{renderAgentAvatar(agent)}</div>);
|
||||||
|
|
||||||
const botIcon = screen.getByTestId('bot-icon');
|
const featherIcon = screen.getByTestId('feather-icon');
|
||||||
expect(botIcon).toBeInTheDocument();
|
expect(featherIcon).toBeInTheDocument();
|
||||||
expect(botIcon).toHaveAttribute('data-stroke-width', '1.5');
|
expect(featherIcon).toHaveAttribute('data-stroke-width', '1.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should apply different size classes', () => {
|
it('should apply different size classes', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Bot } from 'lucide-react';
|
import { Feather } from 'lucide-react';
|
||||||
|
import { Skeleton } from '@librechat/client';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22,6 +23,40 @@ export const getAgentAvatarUrl = (agent: t.Agent | null | undefined): string | n
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LazyAgentAvatar = ({
|
||||||
|
url,
|
||||||
|
alt,
|
||||||
|
imgClass,
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
alt: string;
|
||||||
|
imgClass: string;
|
||||||
|
}) => {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoaded(false);
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={alt}
|
||||||
|
className={imgClass}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={() => setIsLoaded(true)}
|
||||||
|
onError={() => setIsLoaded(false)}
|
||||||
|
style={{
|
||||||
|
opacity: isLoaded ? 1 : 0,
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!isLoaded && <Skeleton className="absolute inset-0 rounded-full" aria-hidden="true" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders an agent avatar with fallback to Bot icon
|
* Renders an agent avatar with fallback to Bot icon
|
||||||
* Consistent across all agent displays
|
* Consistent across all agent displays
|
||||||
|
|
@ -29,7 +64,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 +75,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 +83,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 +91,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',
|
||||||
|
|
@ -64,27 +102,21 @@ export const renderAgentAvatar = (
|
||||||
|
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
<div
|
||||||
<img
|
className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}
|
||||||
src={avatarUrl}
|
>
|
||||||
|
<LazyAgentAvatar
|
||||||
|
url={avatarUrl}
|
||||||
alt={`${agent?.name || 'Agent'} avatar`}
|
alt={`${agent?.name || 'Agent'} avatar`}
|
||||||
className={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
|
imgClass={`${sizeClasses[size]} rounded-full object-cover shadow-lg ${borderClasses}`}
|
||||||
loading="lazy"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback placeholder with Bot icon
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
||||||
{/* Subtle minimalistic placeholder */}
|
<Feather className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} />
|
||||||
<div className="absolute inset-0 rounded-full border border-border-medium bg-surface-secondary"></div>
|
|
||||||
<div
|
|
||||||
className={`relative flex items-center justify-center rounded-full ${placeholderSizeClasses[size]}`}
|
|
||||||
>
|
|
||||||
<Bot className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,20 @@ export function deleteUser(): Promise<s.TPreset> {
|
||||||
return request.delete(endpoints.deleteUser());
|
return request.delete(endpoints.deleteUser());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FavoriteItem = {
|
||||||
|
agentId?: string;
|
||||||
|
model?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFavorites(): Promise<FavoriteItem[]> {
|
||||||
|
return request.get('/api/user/settings/favorites');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateFavorites(favorites: FavoriteItem[]): Promise<FavoriteItem[]> {
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,17 @@ const userSchema = new Schema<IUser>(
|
||||||
},
|
},
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
|
favorites: {
|
||||||
|
type: [
|
||||||
|
{
|
||||||
|
_id: false,
|
||||||
|
agentId: String, // for agent
|
||||||
|
model: String, // for model
|
||||||
|
endpoint: String, // for model
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: [],
|
||||||
|
},
|
||||||
/** 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,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ export interface IUser extends Document {
|
||||||
personalization?: {
|
personalization?: {
|
||||||
memories?: boolean;
|
memories?: boolean;
|
||||||
};
|
};
|
||||||
|
favorites?: Array<{
|
||||||
|
agentId?: string;
|
||||||
|
model?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
}>;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
/** Field for external source identification (for consistency with TPrincipal schema) */
|
/** Field for external source identification (for consistency with TPrincipal schema) */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue