mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-20 18:30:15 +01:00
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
||
|
|
import { useOutletContext } from 'react-router-dom';
|
||
|
|
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||
|
|
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
|
||
|
|
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||
|
|
import type t from 'librechat-data-provider';
|
||
|
|
import type { ContextType } from '~/common';
|
||
|
|
import { useGetAgentCategoriesQuery, useGetEndpointsQuery } from '~/data-provider';
|
||
|
|
import { MarketplaceProvider } from './MarketplaceContext';
|
||
|
|
import { useDocumentTitle, useLocalize } from '~/hooks';
|
||
|
|
import { SidePanelGroup } from '~/components/SidePanel';
|
||
|
|
import { OpenSidebar } from '~/components/Chat/Menus';
|
||
|
|
import CategoryTabs from './CategoryTabs';
|
||
|
|
import AgentDetail from './AgentDetail';
|
||
|
|
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 [searchParams, setSearchParams] = useSearchParams();
|
||
|
|
const { category } = useParams();
|
||
|
|
const setHideSidePanel = useSetRecoilState(store.hideSidePanel);
|
||
|
|
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||
|
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||
|
|
|
||
|
|
// Get URL parameters (default to 'promoted' instead of 'all')
|
||
|
|
const activeTab = category || 'promoted';
|
||
|
|
const searchQuery = searchParams.get('q') || '';
|
||
|
|
const selectedAgentId = searchParams.get('agent_id') || '';
|
||
|
|
|
||
|
|
// Local state
|
||
|
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||
|
|
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(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 agent card selection
|
||
|
|
*
|
||
|
|
* @param agent - The selected agent object
|
||
|
|
*/
|
||
|
|
const handleAgentSelect = (agent: t.Agent) => {
|
||
|
|
// Update URL with selected agent
|
||
|
|
const newParams = new URLSearchParams(searchParams);
|
||
|
|
newParams.set('agent_id', agent.id);
|
||
|
|
setSearchParams(newParams);
|
||
|
|
setSelectedAgent(agent);
|
||
|
|
setIsDetailOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle closing the agent detail dialog
|
||
|
|
*/
|
||
|
|
const handleDetailClose = () => {
|
||
|
|
const newParams = new URLSearchParams(searchParams);
|
||
|
|
newParams.delete('agent_id');
|
||
|
|
setSearchParams(newParams);
|
||
|
|
setSelectedAgent(null);
|
||
|
|
setIsDetailOpen(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle category tab selection changes
|
||
|
|
*
|
||
|
|
* @param tabValue - The selected category value
|
||
|
|
*/
|
||
|
|
const handleTabChange = (tabValue: string) => {
|
||
|
|
const currentSearchParams = searchParams.toString();
|
||
|
|
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||
|
|
|
||
|
|
// Navigate to the selected category
|
||
|
|
if (tabValue === 'promoted') {
|
||
|
|
navigate(`/agents${searchParamsStr}`);
|
||
|
|
} else {
|
||
|
|
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle search query changes
|
||
|
|
*
|
||
|
|
* @param query - The search query string
|
||
|
|
*/
|
||
|
|
const handleSearch = (query: string) => {
|
||
|
|
const newParams = new URLSearchParams(searchParams);
|
||
|
|
if (query.trim()) {
|
||
|
|
newParams.set('q', query.trim());
|
||
|
|
// Switch to "all" category when starting a new search
|
||
|
|
navigate(`/agents/all?${newParams.toString()}`);
|
||
|
|
} else {
|
||
|
|
newParams.delete('q');
|
||
|
|
// Preserve current category when clearing search
|
||
|
|
const currentCategory = activeTab;
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
navigate('/c/new');
|
||
|
|
};
|
||
|
|
|
||
|
|
// Check if a detail view should be open based on URL
|
||
|
|
useEffect(() => {
|
||
|
|
setIsDetailOpen(!!selectedAgentId);
|
||
|
|
}, [selectedAgentId]);
|
||
|
|
|
||
|
|
// 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', []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
||
|
|
<MarketplaceProvider>
|
||
|
|
<SidePanelGroup
|
||
|
|
defaultLayout={defaultLayout}
|
||
|
|
fullPanelCollapse={fullCollapse}
|
||
|
|
defaultCollapsed={defaultCollapsed}
|
||
|
|
>
|
||
|
|
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||
|
|
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||
|
|
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
|
||
|
|
<div className="mx-1 flex items-center gap-2">
|
||
|
|
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
|
||
|
|
{!navVisible && (
|
||
|
|
<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>
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="container mx-auto max-w-4xl px-4 py-8">
|
||
|
|
{/* Hero Section - ChatGPT Style */}
|
||
|
|
<div className="mb-8 mt-12 text-center">
|
||
|
|
<h1 className="mb-3 text-5xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||
|
|
{localize('com_agents_marketplace')}
|
||
|
|
</h1>
|
||
|
|
<p className="mx-auto mb-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300">
|
||
|
|
{localize('com_agents_marketplace_subtitle')}
|
||
|
|
</p>
|
||
|
|
|
||
|
|
{/* Search bar */}
|
||
|
|
<div className="mx-auto max-w-2xl">
|
||
|
|
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Category tabs */}
|
||
|
|
<CategoryTabs
|
||
|
|
categories={categoriesQuery.data || []}
|
||
|
|
activeTab={activeTab}
|
||
|
|
isLoading={categoriesQuery.isLoading}
|
||
|
|
onChange={handleTabChange}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Category header - only show when not searching */}
|
||
|
|
{!searchQuery && (
|
||
|
|
<div className="mb-6">
|
||
|
|
{(() => {
|
||
|
|
// Get category data for display
|
||
|
|
const getCategoryData = () => {
|
||
|
|
if (activeTab === 'promoted') {
|
||
|
|
return {
|
||
|
|
name: localize('com_agents_top_picks'),
|
||
|
|
description: localize('com_agents_recommended'),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (activeTab === 'all') {
|
||
|
|
return {
|
||
|
|
name: 'All Agents',
|
||
|
|
description: 'Browse all shared agents across all categories',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Find the category in the API data
|
||
|
|
const categoryData = categoriesQuery.data?.find(
|
||
|
|
(cat) => cat.value === activeTab,
|
||
|
|
);
|
||
|
|
if (categoryData) {
|
||
|
|
return {
|
||
|
|
name: categoryData.label,
|
||
|
|
description: categoryData.description || '',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback for unknown categories
|
||
|
|
return {
|
||
|
|
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
||
|
|
description: '',
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const { name, description } = getCategoryData();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="text-left">
|
||
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{name}</h2>
|
||
|
|
{description && (
|
||
|
|
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Agent grid */}
|
||
|
|
<AgentGrid
|
||
|
|
category={activeTab}
|
||
|
|
searchQuery={searchQuery}
|
||
|
|
onSelectAgent={handleAgentSelect}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Agent detail dialog */}
|
||
|
|
{isDetailOpen && selectedAgent && (
|
||
|
|
<AgentDetail
|
||
|
|
agent={selectedAgent}
|
||
|
|
isOpen={isDetailOpen}
|
||
|
|
onClose={handleDetailClose}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</main>
|
||
|
|
</SidePanelGroup>
|
||
|
|
</MarketplaceProvider>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default AgentMarketplace;
|