mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-23 11:50:14 +01:00
refactor: organize Sharing/Agent components, improve type safety for resource types and access role ids, rename enums to PascalCase refactor: organize Sharing/Agent components, improve type safety for resource types and access role ids chore: move sharing related components to dedicated "Sharing" directory chore: remove PublicSharingToggle component and update index exports chore: move non-sidepanel agent components to `~/components/Agents` chore: move AgentCategoryDisplay component with tests chore: remove commented out code refactor: change PERMISSION_BITS from const to enum for better type safety refactor: reorganize imports in GenericGrantAccessDialog and update index exports for hooks refactor: update type definitions to use ACCESS_ROLE_IDS for improved type safety refactor: remove unused canAccessPromptResource middleware and related code refactor: remove unused prompt access roles from createAccessRoleMethods refactor: update resourceType in AclEntry type definition to remove unused 'prompt' value refactor: introduce ResourceType enum and update resourceType usage across data provider files for improved type safety refactor: update resourceType usage to ResourceType enum across sharing and permissions components for improved type safety refactor: standardize resourceType usage to ResourceType enum across agent and prompt models, permissions controller, and middleware for enhanced type safety refactor: update resourceType references from PROMPT_GROUP to PROMPTGROUP for consistency across models, middleware, and components refactor: standardize access role IDs and resource type usage across agent, file, and prompt models for improved type safety and consistency chore: add typedefs for TUpdateResourcePermissionsRequest and TUpdateResourcePermissionsResponse to enhance type definitions chore: move SearchPicker to PeoplePicker dir refactor: implement debouncing for query changes in SearchPicker for improved performance chore: fix typing, import order for agent admin settings fix: agent admin settings, prevent agent form submission refactor: rename `ACCESS_ROLE_IDS` to `AccessRoleIds` refactor: replace PermissionBits with PERMISSION_BITS refactor: replace PERMISSION_BITS with PermissionBits
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import React, { useState, useEffect, useMemo } 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, Constants } from 'librechat-data-provider';
|
|
import type t from 'librechat-data-provider';
|
|
import type { ContextType } from '~/common';
|
|
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
|
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
|
|
import { SidePanelProvider, useChatContext } from '~/Providers';
|
|
import { MarketplaceProvider } from './MarketplaceContext';
|
|
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 { cn } from '~/utils';
|
|
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 (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;
|
|
}
|
|
queryClient.setQueryData<t.TMessage[]>(
|
|
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
|
[],
|
|
);
|
|
queryClient.invalidateQueries([QueryKeys.messages]);
|
|
newConversation();
|
|
};
|
|
|
|
// 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', []);
|
|
|
|
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}`}>
|
|
<MarketplaceProvider>
|
|
<SidePanelProvider>
|
|
<SidePanelGroup
|
|
defaultLayout={defaultLayout}
|
|
fullPanelCollapse={fullCollapse}
|
|
defaultCollapsed={defaultCollapsed}
|
|
>
|
|
<main className="flex h-full flex-col overflow-hidden" role="main">
|
|
{/* Scrollable container */}
|
|
<div className="scrollbar-gutter-stable 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 */}
|
|
<div className="container mx-auto max-w-4xl">
|
|
<div className={cn('mb-8 text-center', isSmallScreen ? 'mt-6' : '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 max-w-2xl pb-6">
|
|
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
|
</div>
|
|
|
|
{/* Category tabs */}
|
|
<CategoryTabs
|
|
categories={categoriesQuery.data || []}
|
|
activeTab={activeTab}
|
|
isLoading={categoriesQuery.isLoading}
|
|
onChange={handleTabChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scrollable content area */}
|
|
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
|
{/* Category header - only show when not searching */}
|
|
{!searchQuery && (
|
|
<div className="mb-6 mt-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-text-primary">{name}</h2>
|
|
{description && (
|
|
<p className="mt-2 text-text-secondary">{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}
|
|
/>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</SidePanelGroup>
|
|
</SidePanelProvider>
|
|
</MarketplaceProvider>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AgentMarketplace;
|