mirror of https://github.com/kortix-ai/suna.git
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Bot, Download, Wrench, Plug, Tag, User, Calendar, Loader2, Share, Cpu, Eye, Zap } from 'lucide-react';
|
|
import { DynamicIcon } from 'lucide-react/dynamic';
|
|
import { toast } from 'sonner';
|
|
import type { MarketplaceTemplate } from '@/components/agents/installation/types';
|
|
import { useComposioToolkitIcon } from '@/hooks/react-query/composio/use-composio';
|
|
import { useRouter } from 'next/navigation';
|
|
import { backendApi } from '@/lib/api-client';
|
|
|
|
interface MarketplaceAgentPreviewDialogProps {
|
|
agent: MarketplaceTemplate | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onInstall: (agent: MarketplaceTemplate) => void;
|
|
isInstalling?: boolean;
|
|
}
|
|
|
|
const extractAppInfo = (qualifiedName: string, customType?: string) => {
|
|
if (qualifiedName?.startsWith('composio.')) {
|
|
const extractedSlug = qualifiedName.substring(9);
|
|
if (extractedSlug) {
|
|
return { type: 'composio', slug: extractedSlug };
|
|
}
|
|
}
|
|
|
|
if (customType === 'composio') {
|
|
if (qualifiedName?.startsWith('composio.')) {
|
|
const extractedSlug = qualifiedName.substring(9);
|
|
if (extractedSlug) {
|
|
return { type: 'composio', slug: extractedSlug };
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const IntegrationLogo: React.FC<{
|
|
qualifiedName: string;
|
|
displayName: string;
|
|
customType?: string;
|
|
toolkitSlug?: string;
|
|
}> = ({ qualifiedName, displayName, customType, toolkitSlug }) => {
|
|
let appInfo = extractAppInfo(qualifiedName, customType);
|
|
|
|
if (!appInfo && toolkitSlug) {
|
|
appInfo = { type: 'composio', slug: toolkitSlug };
|
|
}
|
|
|
|
const { data: composioIconData } = useComposioToolkitIcon(
|
|
appInfo?.type === 'composio' ? appInfo.slug : '',
|
|
{ enabled: appInfo?.type === 'composio' }
|
|
);
|
|
|
|
let logoUrl: string | undefined;
|
|
if (appInfo?.type === 'composio') {
|
|
logoUrl = composioIconData?.icon_url;
|
|
}
|
|
|
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
|
|
return (
|
|
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0 overflow-hidden rounded-sm">
|
|
{logoUrl ? (
|
|
<img
|
|
src={logoUrl}
|
|
alt={displayName}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = 'none';
|
|
target.nextElementSibling?.classList.remove('hidden');
|
|
}}
|
|
/>
|
|
) : null}
|
|
<div className={logoUrl ? "hidden" : "flex w-full h-full items-center justify-center bg-muted rounded-sm text-xs font-medium text-muted-foreground"}>
|
|
{firstLetter}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const MarketplaceAgentPreviewDialog: React.FC<MarketplaceAgentPreviewDialogProps> = ({
|
|
agent,
|
|
isOpen,
|
|
onClose,
|
|
onInstall,
|
|
isInstalling = false
|
|
}) => {
|
|
const router = useRouter();
|
|
const [isGeneratingShareLink, setIsGeneratingShareLink] = React.useState(false);
|
|
|
|
if (!agent) return null;
|
|
|
|
const avatar = '🤖';
|
|
const avatar_color = '#6366f1';
|
|
const isSunaAgent = agent.is_kortix_team || false;
|
|
|
|
const tools = agent.mcp_requirements || [];
|
|
|
|
const toolRequirements = tools.filter(req => req.source === 'tool');
|
|
const triggerRequirements = tools.filter(req => req.source === 'trigger');
|
|
|
|
const integrations = toolRequirements.filter(tool => !tool.custom_type || tool.custom_type !== 'sse');
|
|
const customTools = toolRequirements.filter(tool => tool.custom_type === 'sse');
|
|
|
|
const agentpressTools = Object.entries(agent.agentpress_tools || {})
|
|
.filter(([_, enabled]) => enabled)
|
|
.map(([toolName]) => toolName);
|
|
|
|
const handleInstall = () => {
|
|
onInstall(agent);
|
|
};
|
|
|
|
const handleShare = async () => {
|
|
setIsGeneratingShareLink(true);
|
|
try {
|
|
const response = await backendApi.post(`/templates/${agent.template_id}/share`);
|
|
const data = response.data;
|
|
const shareUrl = `${window.location.origin}/templates/${data.share_id}`;
|
|
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
toast.success('Share link copied to clipboard!');
|
|
} catch (error) {
|
|
console.error('Failed to generate share link:', error);
|
|
toast.error('Failed to generate share link');
|
|
} finally {
|
|
setIsGeneratingShareLink(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
};
|
|
|
|
const getAppDisplayName = (qualifiedName: string) => {
|
|
if (qualifiedName.includes('_')) {
|
|
const parts = qualifiedName.split('_');
|
|
return parts[parts.length - 1].replace(/\b\w/g, l => l.toUpperCase());
|
|
}
|
|
return qualifiedName.replace(/\b\w/g, l => l.toUpperCase());
|
|
};
|
|
|
|
console.log('agent', agent);
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] p-0 overflow-hidden">
|
|
<DialogHeader className='p-6'>
|
|
<DialogTitle className='sr-only'>Agent Preview</DialogTitle>
|
|
{agent.icon_name ? (
|
|
<div
|
|
className='relative h-20 w-20 aspect-square rounded-2xl flex items-center justify-center shadow-lg'
|
|
style={{ backgroundColor: agent.icon_background || '#e5e5e5' }}
|
|
>
|
|
<DynamicIcon
|
|
name={agent.icon_name as any}
|
|
size={40}
|
|
color={agent.icon_color || '#000000'}
|
|
/>
|
|
<div
|
|
className="absolute inset-0 rounded-2xl pointer-events-none opacity-0 dark:opacity-100 transition-opacity"
|
|
style={{
|
|
boxShadow: `0 16px 48px -8px ${agent.icon_background || '#e5e5e5'}70, 0 8px 24px -4px ${agent.icon_background || '#e5e5e5'}50`
|
|
}}
|
|
/>
|
|
</div>
|
|
) : agent.profile_image_url ? (
|
|
<img
|
|
src={agent.profile_image_url}
|
|
alt={agent.name}
|
|
className='h-20 w-20 aspect-square rounded-2xl object-cover shadow-lg'
|
|
/>
|
|
) : (
|
|
<div className='relative h-20 w-20 aspect-square bg-muted rounded-2xl flex items-center justify-center' style={{ backgroundColor: avatar_color }}>
|
|
<div className="text-4xl drop-shadow-lg">
|
|
{avatar || '🤖'}
|
|
</div>
|
|
<div
|
|
className="absolute inset-0 rounded-2xl pointer-events-none opacity-0 dark:opacity-100 transition-opacity"
|
|
style={{
|
|
boxShadow: `0 16px 48px -8px ${avatar_color}70, 0 8px 24px -4px ${avatar_color}50`
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</DialogHeader>
|
|
<div className="-mt-4 flex flex-col max-h-[calc(90vh-8rem)] overflow-hidden">
|
|
<div className="p-6 py-0 pb-4">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div className="flex-1">
|
|
<h2 className="text-2xl font-bold text-foreground mb-2">
|
|
{agent.name}
|
|
</h2>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-3">
|
|
<div className="flex items-center gap-1">
|
|
<User className="h-4 w-4" />
|
|
{agent.creator_name || 'Unknown'}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Download className="h-4 w-4" />
|
|
{agent.download_count} downloads
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Calendar className="h-4 w-4" />
|
|
{formatDate(agent.created_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p className="text-muted-foreground leading-relaxed mb-4">
|
|
{agent.description || 'No description available'}
|
|
</p>
|
|
{agent.tags && agent.tags.length > 0 && (
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Tag className="h-4 w-4 text-muted-foreground" />
|
|
<div className="flex flex-wrap gap-1">
|
|
{agent.tags.map((tag, index) => (
|
|
<Badge key={index} variant="outline" className="text-xs">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 gap-4 overflow-y-auto p-6 pt-4 space-y-4">
|
|
{agent.model && (
|
|
<Card className='p-0 border-none bg-transparent shadow-none'>
|
|
<CardContent className="p-0">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Cpu className="h-4 w-4 text-primary" />
|
|
<h3 className="font-semibold">Model Configuration</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge
|
|
variant="secondary"
|
|
className="flex items-center px-3 py-1.5 bg-muted/50 hover:bg-muted border"
|
|
>
|
|
<span className="text-sm font-medium">
|
|
{agent.model.replace('openrouter/', '').replace('anthropic/', '')}
|
|
</span>
|
|
</Badge>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{integrations.length > 0 && (
|
|
<Card className='p-0 border-none bg-transparent shadow-none'>
|
|
<CardContent className="p-0">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Plug className="h-4 w-4 text-primary" />
|
|
<h3 className="font-semibold">Integrations</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{integrations.map((integration, index) => (
|
|
<Badge
|
|
key={index}
|
|
variant="secondary"
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-muted/50 hover:bg-muted border"
|
|
>
|
|
<IntegrationLogo
|
|
qualifiedName={integration.qualified_name}
|
|
displayName={integration.display_name || getAppDisplayName(integration.qualified_name)}
|
|
customType={integration.custom_type}
|
|
toolkitSlug={integration.toolkit_slug}
|
|
/>
|
|
<span className="text-sm font-medium ml-1">
|
|
{integration.display_name || getAppDisplayName(integration.qualified_name)}
|
|
</span>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{triggerRequirements.length > 0 && (
|
|
<Card className='p-0 border-none bg-transparent shadow-none'>
|
|
<CardContent className="p-0">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Zap className="h-4 w-4 text-primary" />
|
|
<h3 className="font-semibold">Event Triggers</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{triggerRequirements.map((trigger, index) => {
|
|
const appName = trigger.display_name?.split(' (')[0] || trigger.display_name;
|
|
const triggerName = trigger.display_name?.match(/\(([^)]+)\)/)?.[1] || trigger.display_name;
|
|
|
|
return (
|
|
<Badge
|
|
key={index}
|
|
variant="secondary"
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-muted/50 hover:bg-muted border"
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
<IntegrationLogo
|
|
qualifiedName={trigger.qualified_name}
|
|
displayName={appName || getAppDisplayName(trigger.qualified_name)}
|
|
customType={trigger.custom_type || (trigger.qualified_name?.startsWith('composio.') ? 'composio' : undefined)}
|
|
toolkitSlug={trigger.toolkit_slug}
|
|
/>
|
|
<span className="text-sm font-medium ml-1">
|
|
{triggerName || trigger.display_name}
|
|
</span>
|
|
</div>
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{customTools.length > 0 && (
|
|
<Card className='p-0 border-none bg-transparent shadow-none'>
|
|
<CardContent className="p-0">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Wrench className="h-4 w-4 text-primary" />
|
|
<h3 className="font-semibold">Custom Tools</h3>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{customTools.map((tool, index) => (
|
|
<Badge
|
|
key={index}
|
|
variant="secondary"
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-muted/50 hover:bg-muted border"
|
|
>
|
|
<IntegrationLogo
|
|
qualifiedName={tool.qualified_name}
|
|
displayName={tool.display_name || getAppDisplayName(tool.qualified_name)}
|
|
customType={tool.custom_type}
|
|
toolkitSlug={tool.toolkit_slug}
|
|
/>
|
|
<span className="text-sm font-medium ml-1">
|
|
{tool.display_name || getAppDisplayName(tool.qualified_name)}
|
|
</span>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
{agentpressTools.length === 0 && toolRequirements.length === 0 && triggerRequirements.length === 0 && (
|
|
<Card>
|
|
<CardContent className="p-4 text-center">
|
|
<Bot className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
|
<p className="text-muted-foreground">
|
|
This agent uses basic functionality without external integrations or specialized tools.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
<div className="p-6 pt-4">
|
|
<div className="flex gap-3">
|
|
<Button
|
|
onClick={handleInstall}
|
|
disabled={isInstalling}
|
|
className="flex-1"
|
|
>
|
|
{isInstalling ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Installing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Download className="h-4 w-4" />
|
|
Install Agent
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button variant="outline" onClick={handleShare} disabled={isGeneratingShareLink}>
|
|
{isGeneratingShareLink ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Share className="h-4 w-4" />
|
|
)}
|
|
Share
|
|
</Button>
|
|
<Button variant="outline" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|