suna/frontend/src/app/(dashboard)/marketplace/page.tsx

1170 lines
47 KiB
TypeScript

'use client';
import React, { useState, useMemo, useEffect } from 'react';
import { Search, Download, Star, Calendar, User, Tags, TrendingUp, Shield, CheckCircle, Loader2, Settings, Wrench, AlertTriangle, GitBranch, Plus, ShoppingBag } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Card, CardContent } from '@/components/ui/card';
import { toast } from 'sonner';
import { getAgentAvatar } from '../agents/_utils/get-agent-style';
import { Skeleton } from '@/components/ui/skeleton';
import { CredentialProfileSelector } from '@/components/workflows/CredentialProfileSelector';
import {
useMarketplaceTemplates,
useInstallTemplate
} from '@/hooks/react-query/secure-mcp/use-secure-mcp';
import { useCredentialProfilesForMcp } from '@/hooks/react-query/mcp/use-credential-profiles';
import { createClient } from '@/lib/supabase/client';
import { useFeatureFlag } from '@/lib/feature-flags';
import { useRouter } from 'next/navigation';
type SortOption = 'newest' | 'popular' | 'most_downloaded' | 'name';
interface MarketplaceTemplate {
id: string;
name: string;
description: string;
tags: string[];
download_count: number;
creator_name: string;
created_at: string;
marketplace_published_at?: string;
avatar?: string;
avatar_color?: string;
template_id: string;
is_kortix_team?: boolean;
mcp_requirements?: Array<{
qualified_name: string;
display_name: string;
enabled_tools?: string[];
required_config: string[];
custom_type?: 'sse' | 'http';
}>;
metadata?: {
source_agent_id?: string;
source_version_id?: string;
source_version_name?: string;
};
}
interface SetupStep {
id: string;
title: string;
description: string;
type: 'credential_profile' | 'custom_server';
service_name: string;
qualified_name: string;
required_fields?: Array<{
key: string;
label: string;
type: 'text' | 'url' | 'password';
placeholder: string;
description?: string;
}>;
custom_type?: 'sse' | 'http';
}
interface MissingProfile {
qualified_name: string;
display_name: string;
required_config: string[];
}
interface AgentPreviewSheetProps {
item: MarketplaceTemplate | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: (item: MarketplaceTemplate) => void;
isInstalling: boolean;
}
interface InstallDialogProps {
item: MarketplaceTemplate | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onInstall: (item: MarketplaceTemplate, instanceName?: string, profileMappings?: Record<string, string>, customMcpConfigs?: Record<string, Record<string, any>>) => Promise<void>;
isInstalling: boolean;
}
const AgentPreviewSheet: React.FC<AgentPreviewSheetProps> = ({
item,
open,
onOpenChange,
onInstall,
isInstalling
}) => {
if (!item) return null;
const { avatar, color } = item.avatar && item.avatar_color
? { avatar: item.avatar, color: item.avatar_color }
: getAgentAvatar(item.id);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent>
<SheetHeader className="space-y-4">
<div className="flex items-start gap-4">
<div
className="h-16 w-16 flex items-center justify-center rounded-xl shrink-0"
style={{ backgroundColor: color }}
>
<div className="text-3xl">{avatar}</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<SheetTitle className="text-xl font-semibold line-clamp-2">
{item.name}
</SheetTitle>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
<span>{item.creator_name}</span>
</div>
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
<span>{item.download_count} downloads</span>
</div>
</div>
</div>
</div>
<Button
onClick={() => onInstall(item)}
disabled={isInstalling}
size='sm'
className='w-48'
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Add to Library
</>
)}
</Button>
</SheetHeader>
<div className="px-4 space-y-6 py-6">
<div className="space-y-2">
<h3 className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
Description
</h3>
<p className="text-sm leading-relaxed">
{item.description || 'No description available for this agent.'}
</p>
</div>
{item.tags && item.tags.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Tags
</h3>
<div className="flex flex-wrap gap-2">
{item.tags.map(tag => (
<Badge key={tag} variant="outline" className="text-xs">
<Tags className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
)}
{item.mcp_requirements && item.mcp_requirements.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
Required Tools & MCPs
</h3>
<div className="space-y-2">
{item.mcp_requirements.map((mcp, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-muted-foreground/10 border rounded-lg">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<Wrench className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium text-sm">{mcp.display_name}</div>
{mcp.enabled_tools && mcp.enabled_tools.length > 0 && (
<div className="text-xs text-muted-foreground">
{mcp.enabled_tools.length} tool{mcp.enabled_tools.length !== 1 ? 's' : ''}
</div>
)}
</div>
</div>
{mcp.custom_type && (
<Badge variant="outline" className="text-xs">
{mcp.custom_type.toUpperCase()}
</Badge>
)}
</div>
))}
</div>
</div>
)}
{item.metadata?.source_version_name && (
<div className="space-y-2">
<h3 className="font-medium text-sm text-muted-foreground uppercase tracking-wide">
Version
</h3>
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{item.metadata.source_version_name}</span>
</div>
</div>
)}
{item.marketplace_published_at && (
<div className="space-y-2">
<h3 className="font-medium text-xs text-muted-foreground uppercase tracking-wide">
Published
</h3>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{formatDate(item.marketplace_published_at)}</span>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
};
const InstallDialog: React.FC<InstallDialogProps> = ({
item,
open,
onOpenChange,
onInstall,
isInstalling
}) => {
const [currentStep, setCurrentStep] = useState(0);
const [instanceName, setInstanceName] = useState('');
const [setupData, setSetupData] = useState<Record<string, Record<string, string>>>({});
const [profileMappings, setProfileMappings] = useState<Record<string, string>>({});
const [isCheckingRequirements, setIsCheckingRequirements] = useState(false);
const [setupSteps, setSetupSteps] = useState<SetupStep[]>([]);
const [missingProfiles, setMissingProfiles] = useState<MissingProfile[]>([]);
React.useEffect(() => {
if (item && open) {
setInstanceName(`${item.name}`);
setSetupData({});
setProfileMappings({});
setIsCheckingRequirements(true);
setMissingProfiles([]);
checkRequirementsAndSetupSteps();
}
}, [item, open]);
// Add a function to refresh requirements when profiles are created
const refreshRequirements = React.useCallback(() => {
if (item && open) {
setIsCheckingRequirements(true);
checkRequirementsAndSetupSteps();
}
}, [item, open]);
const checkRequirementsAndSetupSteps = async () => {
if (!item?.mcp_requirements) return;
const steps: SetupStep[] = [];
const customServers = item.mcp_requirements.filter(req => req.custom_type);
const regularServices = item.mcp_requirements.filter(req => !req.custom_type);
for (const req of regularServices) {
steps.push({
id: req.qualified_name,
title: `Select Credential Profile for ${req.display_name}`,
description: `Choose or create a credential profile for ${req.display_name}.`,
type: 'credential_profile',
service_name: req.display_name,
qualified_name: req.qualified_name,
});
}
// Add custom server configuration steps (just ask for URL, no credentials needed)
for (const req of customServers) {
steps.push({
id: req.qualified_name,
title: `Configure ${req.display_name}`,
description: `Enter your ${req.display_name} server URL to connect this agent.`,
type: 'custom_server',
service_name: req.display_name,
qualified_name: req.qualified_name,
custom_type: req.custom_type,
required_fields: req.required_config.map(key => ({
key,
label: key === 'url' ? `${req.display_name} Server URL` : key,
type: key === 'url' ? 'url' : 'text',
placeholder: key === 'url' ? `https://your-${req.display_name.toLowerCase()}-server.com` : `Enter your ${key}`,
description: key === 'url' ? `Your personal ${req.display_name} server endpoint` : undefined
}))
});
}
setSetupSteps(steps);
setMissingProfiles([]); // Clear missing profiles since we handle them in setup steps
setIsCheckingRequirements(false);
};
const handleFieldChange = (stepId: string, fieldKey: string, value: string) => {
setSetupData(prev => ({
...prev,
[stepId]: {
...prev[stepId],
[fieldKey]: value
}
}));
};
const handleProfileSelect = (qualifiedName: string, profileId: string | null) => {
setProfileMappings(prev => ({
...prev,
[qualifiedName]: profileId || ''
}));
};
const isCurrentStepComplete = (): boolean => {
if (setupSteps.length === 0) return true;
if (currentStep >= setupSteps.length) return true;
const step = setupSteps[currentStep];
if (step.type === 'credential_profile') {
return !!profileMappings[step.qualified_name];
} else if (step.type === 'custom_server') {
const stepData = setupData[step.id] || {};
return step.required_fields?.every(field => {
const value = stepData[field.key];
return value && value.trim().length > 0;
}) || false;
}
return false;
};
const handleNext = () => {
if (currentStep < setupSteps.length - 1) {
setCurrentStep(currentStep + 1);
} else {
handleInstall();
}
};
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleInstall = async () => {
if (!item) return;
const customMcpConfigs: Record<string, Record<string, any>> = {};
setupSteps.forEach(step => {
if (step.type === 'custom_server') {
customMcpConfigs[step.qualified_name] = setupData[step.id] || {};
}
});
await onInstall(item, instanceName, profileMappings, customMcpConfigs);
};
const canInstall = () => {
if (!item) return false;
if (!instanceName.trim()) return false;
// Check if all required profile mappings are selected
const regularRequirements = item.mcp_requirements?.filter(req => !req.custom_type) || [];
const missingProfileMappings = regularRequirements.filter(req => !profileMappings[req.qualified_name]);
if (missingProfileMappings.length > 0) return false;
if (setupSteps.length > 0 && currentStep < setupSteps.length) return false;
return true;
};
if (!item) return null;
const currentStepData = setupSteps[currentStep];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader className="space-y-3">
<DialogTitle className="flex items-center gap-3 text-xl">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
Install {item.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{isCheckingRequirements ? (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Checking requirements...</p>
</div>
) : setupSteps.length === 0 ? (
<div className="space-y-6">
<div className="space-y-3">
<Input
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="Enter a name for this agent"
className="h-11"
/>
</div>
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/50">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-green-800 dark:text-green-200">
All requirements are configured. Ready to install!
</AlertDescription>
</Alert>
</div>
) : currentStep < setupSteps.length ? (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-semibold">
{currentStep + 1}
</div>
<h3 className="font-semibold text-base">{currentStepData.title}</h3>
{currentStepData.custom_type && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800">
{currentStepData.custom_type?.toUpperCase()}
</Badge>
)}
</div>
{/* <p className="text-sm text-muted-foreground">
{currentStepData.description}
</p> */}
</div>
</div>
<div className="space-y-4">
{currentStepData.type === 'credential_profile' ? (
<CredentialProfileSelector
mcpQualifiedName={currentStepData.qualified_name}
mcpDisplayName={currentStepData.service_name}
selectedProfileId={profileMappings[currentStepData.qualified_name]}
onProfileSelect={(profileId, profile) => {
handleProfileSelect(currentStepData.qualified_name, profileId);
if (profile && !profileMappings[currentStepData.qualified_name]) {
refreshRequirements();
}
}}
/>
) : (
currentStepData.required_fields?.map((field) => (
<div key={field.key} className="space-y-2">
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{field.label}
</label>
<Input
type={field.type}
placeholder={field.placeholder}
value={setupData[currentStepData.id]?.[field.key] || ''}
onChange={(e) => handleFieldChange(currentStepData.id, field.key, e.target.value)}
className="h-11"
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))
)}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
{setupSteps.map((_, index) => (
<div
key={index}
className={`h-1 flex-1 rounded-full transition-colors ${
index <= currentStep ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
<p className="text-xs text-muted-foreground">
Step {currentStep + 1} of {setupSteps.length}
</p>
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-1 flex-1">
<h3 className="font-semibold text-base">Almost done!</h3>
<p className="text-sm text-muted-foreground">
Give your agent a name and we'll set everything up.
</p>
</div>
</div>
<div className="ml-12 space-y-3">
<Input
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="Enter a name for this agent"
className="h-11"
/>
</div>
</div>
)}
</div>
<DialogFooter className="flex-col gap-3">
<div className="flex gap-3 w-full justify-end">
{currentStep > 0 && (
<Button variant="outline" onClick={handleBack} className="flex-1">
Back
</Button>
)}
{setupSteps.length === 0 ? (
<Button
onClick={handleInstall}
disabled={isInstalling || !canInstall()}
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Install Agent
</>
)}
</Button>
) : currentStep < setupSteps.length ? (
<Button
onClick={handleNext}
disabled={!isCurrentStepComplete()}
>
Continue
</Button>
) : (
<Button
onClick={handleInstall}
disabled={isInstalling || !canInstall()}
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Install Agent
</>
)}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default function MarketplacePage() {
const { enabled: agentMarketplaceEnabled, loading: flagLoading } = useFeatureFlag("agent_marketplace");
const router = useRouter();
useEffect(() => {
if (!flagLoading && !agentMarketplaceEnabled) {
router.replace("/dashboard");
}
}, [flagLoading, agentMarketplaceEnabled, router]);
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [installingItemId, setInstallingItemId] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<MarketplaceTemplate | null>(null);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const [showPreviewSheet, setShowPreviewSheet] = useState(false);
// Secure marketplace data (all templates are now secure)
const secureQueryParams = useMemo(() => ({
limit: 20,
offset: (page - 1) * 20,
search: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined,
}), [page, searchQuery, selectedTags]);
const { data: secureTemplates, isLoading } = useMarketplaceTemplates(secureQueryParams);
const installTemplateMutation = useInstallTemplate();
// Transform secure templates data
const { kortixTeamItems, communityItems } = useMemo(() => {
const kortixItems: MarketplaceTemplate[] = [];
const communityItems: MarketplaceTemplate[] = [];
// Add secure templates (all items are now secure)
if (secureTemplates) {
secureTemplates.forEach(template => {
const item: MarketplaceTemplate = {
id: template.template_id,
name: template.name,
description: template.description,
tags: template.tags || [],
download_count: template.download_count || 0,
creator_name: template.creator_name || 'Anonymous',
created_at: template.created_at,
marketplace_published_at: template.marketplace_published_at,
avatar: template.avatar,
avatar_color: template.avatar_color,
template_id: template.template_id,
is_kortix_team: template.is_kortix_team,
mcp_requirements: template.mcp_requirements,
metadata: template.metadata,
};
if (template.is_kortix_team) {
kortixItems.push(item);
} else {
communityItems.push(item);
}
});
}
// Sort function
const sortItems = (items: MarketplaceTemplate[]) => {
return items.sort((a, b) => {
switch (sortBy) {
case 'newest':
return new Date(b.marketplace_published_at || b.created_at).getTime() -
new Date(a.marketplace_published_at || a.created_at).getTime();
case 'popular':
case 'most_downloaded':
return b.download_count - a.download_count;
case 'name':
return a.name.localeCompare(b.name);
default:
return 0;
}
});
};
return {
kortixTeamItems: sortItems(kortixItems),
communityItems: sortItems(communityItems)
};
}, [secureTemplates, sortBy]);
// Combined items for tag filtering and search stats
const allMarketplaceItems = useMemo(() => {
return [...kortixTeamItems, ...communityItems];
}, [kortixTeamItems, communityItems]);
React.useEffect(() => {
setPage(1);
}, [searchQuery, selectedTags, sortBy]);
const handleItemClick = (item: MarketplaceTemplate) => {
setSelectedItem(item);
setShowPreviewSheet(true);
};
const handlePreviewInstall = (item: MarketplaceTemplate) => {
setShowPreviewSheet(false);
setShowInstallDialog(true);
};
const handleInstallClick = (item: MarketplaceTemplate, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
setSelectedItem(item);
setShowInstallDialog(true);
};
const handleInstall = async (
item: MarketplaceTemplate,
instanceName?: string,
profileMappings?: Record<string, string>,
customMcpConfigs?: Record<string, Record<string, any>>
) => {
setInstallingItemId(item.id);
try {
if (!instanceName || instanceName.trim() === '') {
toast.error('Please provide a name for the agent');
return;
}
const regularRequirements = item.mcp_requirements?.filter(req => !req.custom_type) || [];
const missingProfiles = regularRequirements.filter(req =>
!profileMappings || !profileMappings[req.qualified_name] || profileMappings[req.qualified_name].trim() === ''
);
if (missingProfiles.length > 0) {
const missingNames = missingProfiles.map(req => req.display_name).join(', ');
toast.error(`Please select credential profiles for: ${missingNames}`);
return;
}
const customRequirements = item.mcp_requirements?.filter(req => req.custom_type) || [];
const missingCustomConfigs = customRequirements.filter(req =>
!customMcpConfigs || !customMcpConfigs[req.qualified_name] ||
req.required_config.some(field => !customMcpConfigs[req.qualified_name][field]?.trim())
);
if (missingCustomConfigs.length > 0) {
const missingNames = missingCustomConfigs.map(req => req.display_name).join(', ');
toast.error(`Please provide all required configuration for: ${missingNames}`);
return;
}
const result = await installTemplateMutation.mutateAsync({
template_id: item.template_id,
instance_name: instanceName,
profile_mappings: profileMappings,
custom_mcp_configs: customMcpConfigs
});
if (result.status === 'installed') {
toast.success(`Agent "${instanceName}" installed successfully!`);
setShowInstallDialog(false);
} else if (result.status === 'configs_required') {
toast.error('Please provide all required configurations');
return;
} else {
toast.error('Unexpected response from server. Please try again.');
return;
}
} catch (error: any) {
console.error('Installation error:', error);
// Handle specific error types
if (error.message?.includes('already in your library')) {
toast.error('This agent is already in your library');
} else if (error.message?.includes('Credential profile not found')) {
toast.error('One or more selected credential profiles could not be found. Please refresh and try again.');
} else if (error.message?.includes('Missing credential profile')) {
toast.error('Please select credential profiles for all required services');
} else if (error.message?.includes('Invalid credential profile')) {
toast.error('One or more selected credential profiles are invalid. Please select valid profiles.');
} else if (error.message?.includes('inactive')) {
toast.error('One or more selected credential profiles are inactive. Please select active profiles.');
} else if (error.message?.includes('Template not found')) {
toast.error('This agent template is no longer available');
} else if (error.message?.includes('Access denied')) {
toast.error('You do not have permission to install this agent');
} else {
toast.error(error.message || 'Failed to install agent. Please try again.');
}
} finally {
setInstallingItemId(null);
}
};
const handleTagFilter = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag]
);
};
const getItemStyling = (item: MarketplaceTemplate) => {
if (item.avatar && item.avatar_color) {
return {
avatar: item.avatar,
color: item.avatar_color,
};
}
return getAgentAvatar(item.id);
};
const allTags = React.useMemo(() => {
const tags = new Set<string>();
allMarketplaceItems.forEach(item => {
item.tags?.forEach(tag => tags.add(tag));
});
return Array.from(tags);
}, [allMarketplaceItems]);
if (flagLoading) {
return (
<div className="container max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Agent Marketplace
</h1>
<p className="text-md text-muted-foreground max-w-2xl">
Discover and install secure AI agent templates created by the community
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="p-2 bg-neutral-100 dark:bg-sidebar rounded-2xl overflow-hidden group">
<div className="h-24 flex items-center justify-center relative bg-gradient-to-br from-opacity-90 to-opacity-100">
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<div className="space-y-2 mt-4 mb-4">
<Skeleton className="h-6 w-32 rounded" />
<Skeleton className="h-4 w-24 rounded" />
</div>
</div>
))}
</div>
</div>
);
}
if (!agentMarketplaceEnabled) {
return null;
}
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="space-y-8">
<div className='w-full space-y-4 bg-gradient-to-b from-primary/10 to-primary/5 border rounded-xl h-60 flex items-center justify-center'>
<div className="space-y-4">
<div className="space-y-2 text-center">
<div className='flex items-center justify-center gap-2'>
<ShoppingBag className='h-6 w-6 text-primary' />
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Marketplace
</h1>
</div>
<p className="text-md text-muted-foreground max-w-2xl">
Discover and install powerful agents created by the community
</p>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1 border rounded-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search agents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* <Select value={sortBy} onValueChange={(value) => setSortBy(value as SortOption)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Newest First
</div>
</SelectItem>
<SelectItem value="popular">
<div className="flex items-center gap-2">
<Star className="h-4 w-4" />
Most Popular
</div>
</SelectItem>
<SelectItem value="most_downloaded">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Most Downloaded
</div>
</SelectItem>
</SelectContent>
</Select> */}
</div>
</div>
</div>
{allTags.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Filter by tags:</p>
<div className="flex flex-wrap gap-2">
{allTags.map(tag => (
<Badge
key={tag}
variant={selectedTags.includes(tag) ? "default" : "outline"}
className="cursor-pointer hover:bg-primary/80"
onClick={() => handleTagFilter(tag)}
>
<Tags className="h-3 w-3 mr-1" />
{tag}
</Badge>
))}
</div>
</div>
)}
<div className="text-sm text-muted-foreground">
{isLoading ? (
"Loading marketplace..."
) : (
`${allMarketplaceItems.length} template${allMarketplaceItems.length !== 1 ? 's' : ''} found`
)}
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden">
<Skeleton className="h-50" />
<div className="p-4 space-y-3">
<Skeleton className="h-5 rounded" />
<div className="space-y-2">
<Skeleton className="h-4 rounded" />
<Skeleton className="h-4 rounded w-3/4" />
</div>
<Skeleton className="h-8" />
</div>
</div>
))}
</div>
) : allMarketplaceItems.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
{searchQuery || selectedTags.length > 0
? "No templates found matching your criteria. Try adjusting your search or filters."
: "No agent templates are currently available in the marketplace."}
</p>
</div>
) : (
<div className="space-y-12">
{kortixTeamItems.length > 0 && (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20">
<Shield className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Agents from Kortix Team</h2>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{kortixTeamItems.map((item) => {
const { avatar, color } = getItemStyling(item);
return (
<div
key={item.id}
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group flex flex-col h-full"
onClick={() => handleItemClick(item)}
>
<div className='p-4'>
<div className={`h-12 w-12 flex items-center justify-center rounded-lg`} style={{ backgroundColor: color }}>
<div className="text-2xl">
{avatar}
</div>
</div>
</div>
<div className="p-4 -mt-4 flex flex-col flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
{item.name}
</h3>
{item.metadata?.source_version_name && (
<Badge variant="secondary" className="text-xs shrink-0">
<GitBranch className="h-3 w-3" />
{item.metadata.source_version_name}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm mb-3 line-clamp-2">
{item.description || 'No description available'}
</p>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{item.tags.slice(0, 2).map(tag => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{item.tags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{item.tags.length - 2}
</Badge>
)}
</div>
)}
<div className="mb-4 w-full flex justify-between">
<div className='space-y-1'>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3" />
<span>By {item.creator_name}</span>
</div>
{item.marketplace_published_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>{new Date(item.marketplace_published_at).toLocaleDateString()}</span>
</div>
)}
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground text-xs font-medium">{item.download_count}</span>
</div>
</div>
<Button
onClick={(e) => handleInstallClick(item, e)}
disabled={installingItemId === item.id}
className="w-full transition-opacity mt-auto"
size="sm"
>
{installingItemId === item.id ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Add to Library
</>
)}
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{communityItems.length > 0 && (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/20">
<User className="h-4 w-4 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Agents from Community</h2>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{communityItems.map((item) => {
const { avatar, color } = getItemStyling(item);
return (
<div
key={item.id}
className="bg-neutral-100 dark:bg-sidebar border border-border rounded-2xl overflow-hidden hover:bg-muted/50 transition-all duration-200 cursor-pointer group flex flex-col h-full"
onClick={() => handleItemClick(item)}
>
<div className='p-4'>
<div className={`h-12 w-12 flex items-center justify-center rounded-lg`} style={{ backgroundColor: color }}>
<div className="text-2xl">
{avatar}
</div>
</div>
</div>
<div className="p-4 -mt-4 flex flex-col flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-foreground font-medium text-lg line-clamp-1 flex-1">
{item.name}
</h3>
{item.metadata?.source_version_name && (
<Badge variant="secondary" className="text-xs shrink-0">
<GitBranch className="h-3 w-3" />
{item.metadata.source_version_name}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm mb-3 line-clamp-2">
{item.description || 'No description available'}
</p>
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{item.tags.slice(0, 2).map(tag => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{item.tags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{item.tags.length - 2}
</Badge>
)}
</div>
)}
<div className="mb-4 w-full flex justify-between">
<div className='space-y-1'>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<User className="h-3 w-3" />
<span>By {item.creator_name}</span>
</div>
{item.marketplace_published_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>{new Date(item.marketplace_published_at).toLocaleDateString()}</span>
</div>
)}
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground text-xs font-medium">{item.download_count}</span>
</div>
</div>
<Button
onClick={(e) => handleInstallClick(item, e)}
disabled={installingItemId === item.id}
className="w-full transition-opacity mt-auto"
size="sm"
>
{installingItemId === item.id ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Installing...
</>
) : (
<>
<Download className="h-4 w-4" />
Add to Library
</>
)}
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
<AgentPreviewSheet
item={selectedItem}
open={showPreviewSheet}
onOpenChange={setShowPreviewSheet}
onInstall={handlePreviewInstall}
isInstalling={installingItemId === selectedItem?.id}
/>
<InstallDialog
item={selectedItem}
open={showInstallDialog}
onOpenChange={setShowInstallDialog}
onInstall={handleInstall}
isInstalling={installingItemId === selectedItem?.id}
/>
</div>
);
}