mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 10:20:15 +01:00
* style: AgentCard and AgentGrid UI with improved layout and accessibility - Updated AgentCard component to improve layout, including flexbox adjustments for better responsiveness and spacing. - Added aria-label for agent description to enhance accessibility. - Introduced a new translation key for agent description in the localization file. - Modified AgentGrid to include horizontal margins for better alignment on various screen sizes. * style: Update AgentCard description line clamp for improved readability - Increased the line clamp for agent descriptions in the AgentCard component from 3 to 5 lines, enhancing the display of longer descriptions while maintaining a clean layout. * feat: Integrate Agent Detail Dialog in AgentCard Component - Enhanced the AgentCard component to include an OGDialog for displaying detailed agent information. - Introduced AgentDetailContent to manage the content of the dialog, allowing users to view agent details and initiate chats directly from the card. - Updated AgentGrid to utilize the new onSelect prop for agent selection, improving the interaction flow. - Removed deprecated code related to agent detail handling in the Marketplace component for cleaner implementation. * ci: Enhance AgentCard and Accessibility Tests with Improved Mocks and Keyboard Interaction - Updated AgentCard tests to utilize the new onSelect prop for better interaction handling. - Introduced comprehensive mocks for hooks and components to streamline testing and avoid testing internal implementations. - Improved accessibility tests by ensuring keyboard interactions are properly handled and do not throw errors. - Enhanced the overall structure of tests to support better readability and maintainability.
496 lines
20 KiB
TypeScript
496 lines
20 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { useRecoilState } from 'recoil';
|
|
import { useOutletContext } from 'react-router-dom';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
|
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
|
|
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
|
|
import type t from 'librechat-data-provider';
|
|
import type { ContextType } from '~/common';
|
|
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
|
|
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
|
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
|
|
import { SidePanelProvider, useChatContext } from '~/Providers';
|
|
import { SidePanelGroup } from '~/components/SidePanel';
|
|
import { OpenSidebar } from '~/components/Chat/Menus';
|
|
import { cn, clearMessagesCache } from '~/utils';
|
|
import CategoryTabs from './CategoryTabs';
|
|
import SearchBar from './SearchBar';
|
|
import AgentGrid from './AgentGrid';
|
|
import store from '~/store';
|
|
|
|
interface AgentMarketplaceProps {
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* AgentMarketplace - Main component for browsing and discovering agents
|
|
*
|
|
* Provides tabbed navigation for different agent categories,
|
|
* search functionality, and detailed agent view through a modal dialog.
|
|
* Uses URL parameters for state persistence and deep linking.
|
|
*/
|
|
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
|
|
const localize = useLocalize();
|
|
const navigate = useNavigate();
|
|
const { category } = useParams();
|
|
const queryClient = useQueryClient();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { conversation, newConversation } = useChatContext();
|
|
|
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
|
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
|
|
|
|
// Get URL parameters
|
|
const searchQuery = searchParams.get('q') || '';
|
|
|
|
// Animation state
|
|
type Direction = 'left' | 'right';
|
|
// Initialize with a default value to prevent rendering issues
|
|
const [displayCategory, setDisplayCategory] = useState<string>(category || 'all');
|
|
const [nextCategory, setNextCategory] = useState<string | null>(null);
|
|
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
|
const [animationDirection, setAnimationDirection] = useState<Direction>('right');
|
|
|
|
// Ref for the scrollable container to enable infinite scroll
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Set page title
|
|
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
|
|
|
// Ensure right sidebar is always visible in marketplace
|
|
useEffect(() => {
|
|
setHideSidePanel(false);
|
|
|
|
// Also try to force expand via localStorage
|
|
localStorage.setItem('hideSidePanel', 'false');
|
|
localStorage.setItem('fullPanelCollapse', 'false');
|
|
}, [setHideSidePanel, hideSidePanel]);
|
|
|
|
// Ensure endpoints config is loaded first (required for agent queries)
|
|
useGetEndpointsQuery();
|
|
|
|
// Fetch categories using existing query pattern
|
|
const categoriesQuery = useGetAgentCategoriesQuery({
|
|
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
|
|
refetchOnWindowFocus: false,
|
|
refetchOnReconnect: false,
|
|
refetchOnMount: false,
|
|
});
|
|
|
|
// Handle initial category when on /agents without a category
|
|
useEffect(() => {
|
|
if (
|
|
!category &&
|
|
window.location.pathname === '/agents' &&
|
|
categoriesQuery.data &&
|
|
displayCategory === 'all'
|
|
) {
|
|
const hasPromoted = categoriesQuery.data.some((cat) => cat.value === 'promoted');
|
|
if (hasPromoted) {
|
|
// If promoted exists, update display to show it
|
|
setDisplayCategory('promoted');
|
|
}
|
|
}
|
|
}, [category, categoriesQuery.data, displayCategory]);
|
|
|
|
/**
|
|
* Handle agent card selection - updates URL for deep linking
|
|
*/
|
|
const handleAgentSelect = (agent: t.Agent) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
newParams.set('agent_id', agent.id);
|
|
setSearchParams(newParams);
|
|
};
|
|
|
|
/**
|
|
* Determine ordered tabs to compute indices for direction
|
|
*/
|
|
const orderedTabs = useMemo<string[]>(() => {
|
|
const dynamic = (categoriesQuery.data || []).map((c) => c.value);
|
|
// Only include values that actually exist in the categories
|
|
const set = new Set<string>(dynamic);
|
|
return Array.from(set);
|
|
}, [categoriesQuery.data]);
|
|
|
|
const getTabIndex = useCallback(
|
|
(tab: string): number => {
|
|
const idx = orderedTabs.indexOf(tab);
|
|
return idx >= 0 ? idx : 0;
|
|
},
|
|
[orderedTabs],
|
|
);
|
|
|
|
/**
|
|
* Handle category tab selection changes with directional animation
|
|
*/
|
|
const handleTabChange = (tabValue: string) => {
|
|
if (tabValue === displayCategory || isTransitioning) {
|
|
// Ignore redundant or rapid clicks during transition
|
|
return;
|
|
}
|
|
|
|
const currentIndex = getTabIndex(displayCategory);
|
|
const newIndex = getTabIndex(tabValue);
|
|
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
|
|
|
setAnimationDirection(direction);
|
|
setNextCategory(tabValue);
|
|
setIsTransitioning(true);
|
|
|
|
// Update URL immediately, preserving current search params
|
|
const currentSearchParams = searchParams.toString();
|
|
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
|
if (tabValue === 'promoted') {
|
|
navigate(`/agents${searchParamsStr}`);
|
|
} else {
|
|
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
|
}
|
|
|
|
// Complete transition after 300ms
|
|
window.setTimeout(() => {
|
|
setDisplayCategory(tabValue);
|
|
setNextCategory(null);
|
|
setIsTransitioning(false);
|
|
}, 300);
|
|
};
|
|
|
|
/**
|
|
* Sync display when URL changes externally (back/forward)
|
|
*/
|
|
useEffect(() => {
|
|
if (category && category !== displayCategory && !isTransitioning) {
|
|
// URL changed externally, update display without animation
|
|
setDisplayCategory(category);
|
|
}
|
|
}, [category, displayCategory, isTransitioning]);
|
|
|
|
// No longer needed with keyframes
|
|
|
|
/**
|
|
* Handle search query changes
|
|
*
|
|
* @param query - The search query string
|
|
*/
|
|
const handleSearch = (query: string) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
const currentCategory = displayCategory;
|
|
|
|
if (query.trim()) {
|
|
newParams.set('q', query.trim());
|
|
} else {
|
|
newParams.delete('q');
|
|
}
|
|
|
|
// Always preserve current category when searching or clearing search
|
|
if (currentCategory === 'promoted') {
|
|
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
|
} else {
|
|
navigate(
|
|
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handle new chat button click
|
|
*/
|
|
|
|
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
|
window.open('/c/new', '_blank');
|
|
return;
|
|
}
|
|
clearMessagesCache(queryClient, conversation?.conversationId);
|
|
queryClient.invalidateQueries([QueryKeys.messages]);
|
|
newConversation();
|
|
};
|
|
|
|
// Layout configuration for SidePanelGroup
|
|
const defaultLayout = useMemo(() => {
|
|
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
|
|
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
|
|
}, []);
|
|
|
|
const defaultCollapsed = useMemo(() => {
|
|
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
|
|
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
|
|
}, []);
|
|
|
|
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
|
|
|
const hasAccessToMarketplace = useHasAccess({
|
|
permissionType: PermissionTypes.MARKETPLACE,
|
|
permission: Permissions.USE,
|
|
});
|
|
useEffect(() => {
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
if (!hasAccessToMarketplace) {
|
|
timeoutId = setTimeout(() => {
|
|
navigate('/c/new');
|
|
}, 1000);
|
|
}
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}, [hasAccessToMarketplace, navigate]);
|
|
|
|
if (!hasAccessToMarketplace) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
|
<SidePanelProvider>
|
|
<SidePanelGroup
|
|
defaultLayout={defaultLayout}
|
|
fullPanelCollapse={fullCollapse}
|
|
defaultCollapsed={defaultCollapsed}
|
|
>
|
|
<main className="flex h-full flex-col overflow-hidden" role="main">
|
|
{/* Scrollable container */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
|
>
|
|
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
|
{!isSmallScreen && (
|
|
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
|
<div className="mx-1 flex items-center gap-2">
|
|
{!navVisible ? (
|
|
<>
|
|
<OpenSidebar setNavVisible={setNavVisible} />
|
|
<TooltipAnchor
|
|
description={localize('com_ui_new_chat')}
|
|
render={
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
data-testid="agents-new-chat-button"
|
|
aria-label={localize('com_ui_new_chat')}
|
|
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
|
onClick={handleNewChat}
|
|
>
|
|
<NewChatIcon />
|
|
</Button>
|
|
}
|
|
/>
|
|
</>
|
|
) : (
|
|
// Invisible placeholder to maintain height
|
|
<div className="h-10 w-10" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Hero Section - scrolls away */}
|
|
{!isSmallScreen && (
|
|
<div className="container mx-auto max-w-4xl">
|
|
<div className={cn('mb-8 text-center', 'mt-12')}>
|
|
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
|
{localize('com_agents_marketplace')}
|
|
</h1>
|
|
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
|
{localize('com_agents_marketplace_subtitle')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Sticky wrapper for search bar and categories */}
|
|
<div
|
|
className={cn(
|
|
'sticky z-10 bg-presentation pb-4',
|
|
isSmallScreen ? 'top-0' : 'top-14',
|
|
)}
|
|
>
|
|
<div className="container mx-auto max-w-4xl px-4">
|
|
{/* Search bar */}
|
|
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
|
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
|
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
|
{/* Admin Settings */}
|
|
<MarketplaceAdminSettings />
|
|
</div>
|
|
|
|
{/* Category tabs */}
|
|
<CategoryTabs
|
|
categories={categoriesQuery.data || []}
|
|
activeTab={displayCategory}
|
|
isLoading={categoriesQuery.isLoading}
|
|
onChange={handleTabChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* Scrollable content area */}
|
|
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
|
{/* Two-pane animated container wrapping category header + grid */}
|
|
<div className="relative overflow-hidden">
|
|
{/* Current content pane */}
|
|
<div
|
|
className={cn(
|
|
isTransitioning &&
|
|
(animationDirection === 'right'
|
|
? 'motion-safe:animate-slide-out-left'
|
|
: 'motion-safe:animate-slide-out-right'),
|
|
)}
|
|
key={`pane-current-${displayCategory}`}
|
|
>
|
|
{/* Category header - only show when not searching */}
|
|
{!searchQuery && (
|
|
<div className="mb-6 mt-6">
|
|
{(() => {
|
|
// Get category data for display
|
|
const getCategoryData = () => {
|
|
if (displayCategory === 'promoted') {
|
|
return {
|
|
name: localize('com_agents_top_picks'),
|
|
description: localize('com_agents_recommended'),
|
|
};
|
|
}
|
|
if (displayCategory === 'all') {
|
|
return {
|
|
name: localize('com_agents_all'),
|
|
description: localize('com_agents_all_description'),
|
|
};
|
|
}
|
|
|
|
// Find the category in the API data
|
|
const categoryData = categoriesQuery.data?.find(
|
|
(cat) => cat.value === displayCategory,
|
|
);
|
|
if (categoryData) {
|
|
return {
|
|
name: categoryData.label?.startsWith('com_')
|
|
? localize(categoryData.label as TranslationKeys)
|
|
: categoryData.label,
|
|
description: categoryData.description?.startsWith('com_')
|
|
? localize(categoryData.description as TranslationKeys)
|
|
: categoryData.description || '',
|
|
};
|
|
}
|
|
|
|
// Fallback for unknown categories
|
|
return {
|
|
name:
|
|
displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1),
|
|
description: '',
|
|
};
|
|
};
|
|
|
|
const { name, description } = getCategoryData();
|
|
|
|
return (
|
|
<div className="text-left">
|
|
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
|
{description && (
|
|
<p className="mt-2 text-text-secondary">{description}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Agent grid */}
|
|
<AgentGrid
|
|
key={`grid-${displayCategory}`}
|
|
category={displayCategory}
|
|
searchQuery={searchQuery}
|
|
onSelectAgent={handleAgentSelect}
|
|
scrollElementRef={scrollContainerRef}
|
|
/>
|
|
</div>
|
|
|
|
{/* Next content pane, only during transition */}
|
|
{isTransitioning && nextCategory && (
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0',
|
|
animationDirection === 'right'
|
|
? 'motion-safe:animate-slide-in-right'
|
|
: 'motion-safe:animate-slide-in-left',
|
|
)}
|
|
key={`pane-next-${nextCategory}-${animationDirection}`}
|
|
>
|
|
{/* Category header - only show when not searching */}
|
|
{!searchQuery && (
|
|
<div className="mb-6 mt-6">
|
|
{(() => {
|
|
// Get category data for display
|
|
const getCategoryData = () => {
|
|
if (nextCategory === 'promoted') {
|
|
return {
|
|
name: localize('com_agents_top_picks'),
|
|
description: localize('com_agents_recommended'),
|
|
};
|
|
}
|
|
if (nextCategory === 'all') {
|
|
return {
|
|
name: localize('com_agents_all'),
|
|
description: localize('com_agents_all_description'),
|
|
};
|
|
}
|
|
|
|
// Find the category in the API data
|
|
const categoryData = categoriesQuery.data?.find(
|
|
(cat) => cat.value === nextCategory,
|
|
);
|
|
if (categoryData) {
|
|
return {
|
|
name: categoryData.label?.startsWith('com_')
|
|
? localize(categoryData.label as TranslationKeys)
|
|
: categoryData.label,
|
|
description: categoryData.description?.startsWith('com_')
|
|
? localize(
|
|
categoryData.description as Parameters<typeof localize>[0],
|
|
)
|
|
: categoryData.description || '',
|
|
};
|
|
}
|
|
|
|
// Fallback for unknown categories
|
|
return {
|
|
name:
|
|
(nextCategory || '').charAt(0).toUpperCase() +
|
|
(nextCategory || '').slice(1),
|
|
description: '',
|
|
};
|
|
};
|
|
|
|
const { name, description } = getCategoryData();
|
|
|
|
return (
|
|
<div className="text-left">
|
|
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
|
{description && (
|
|
<p className="mt-2 text-text-secondary">{description}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Agent grid */}
|
|
<AgentGrid
|
|
key={`grid-${nextCategory}`}
|
|
category={nextCategory}
|
|
searchQuery={searchQuery}
|
|
onSelectAgent={handleAgentSelect}
|
|
scrollElementRef={scrollContainerRef}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</SidePanelGroup>
|
|
</SidePanelProvider>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AgentMarketplace;
|