feat: make thread agent agnostic

This commit is contained in:
Soumyadas15 2025-06-26 22:06:47 +05:30
parent 18057a4db6
commit 3ca9aed8de
16 changed files with 1173 additions and 326 deletions

View File

@ -1870,6 +1870,7 @@ class MarketplaceAgent(BaseModel):
creator_name: str
avatar: Optional[str]
avatar_color: Optional[str]
is_kortix_team: Optional[bool] = False
class MarketplaceAgentsResponse(BaseModel):
agents: List[MarketplaceAgent]
@ -1938,6 +1939,25 @@ async def get_marketplace_agents(
if has_more:
total_pages = page + 1
# Add Kortix team identification
kortix_team_creators = [
'kortix', 'kortix team', 'suna team', 'official', 'kortix official'
]
for agent in agents_data:
creator_name = agent.get('creator_name', '').lower()
agent['is_kortix_team'] = any(
kortix_creator in creator_name
for kortix_creator in kortix_team_creators
)
agents_data = sorted(agents_data, key=lambda x: (
not x.get('is_kortix_team', False),
-x.get('download_count', 0) if sort_by == "most_downloaded" else 0,
x.get('name', '').lower() if sort_by == "name" else '',
-(datetime.fromisoformat(x.get('marketplace_published_at', x.get('created_at', ''))).timestamp()) if sort_by == "newest" else 0
))
logger.info(f"Found {len(agents_data)} marketplace agents (page {page}, estimated {total_pages} pages)")
return {
"agents": agents_data,

View File

@ -114,6 +114,7 @@ class TemplateResponse(BaseModel):
creator_name: Optional[str] = None
avatar: Optional[str]
avatar_color: Optional[str]
is_kortix_team: Optional[bool] = False
class InstallationResponse(BaseModel):
"""Response model for template installation"""

View File

@ -71,24 +71,18 @@ class TemplateManager:
tags: Optional[List[str]] = None
) -> str:
"""
Create an agent template from an existing agent, stripping all credentials
Create a secure template from an existing agent
Args:
agent_id: ID of the existing agent
creator_id: ID of the user creating the template
make_public: Whether to make the template public immediately
tags: Optional tags for the template
Returns:
template_id: ID of the created template
This extracts the agent configuration and creates a template with
MCP requirements (without credentials) that can be safely shared
"""
logger.info(f"Creating template from agent {agent_id}")
logger.info(f"Creating template from agent {agent_id} for user {creator_id}")
try:
client = await db.client
# Get the existing agent with current version
agent_result = await client.table('agents').select('*, agent_versions!current_version_id(*)').eq('agent_id', agent_id).execute()
# Get the agent
agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).execute()
if not agent_result.data:
raise ValueError("Agent not found")
@ -96,61 +90,63 @@ class TemplateManager:
# Verify ownership
if agent['account_id'] != creator_id:
raise ValueError("Access denied: not agent owner")
raise ValueError("Access denied - you can only create templates from your own agents")
# Extract MCP requirements (remove credentials)
# Extract MCP requirements from agent configuration
mcp_requirements = []
# Process configured_mcps
for mcp_config in agent.get('configured_mcps', []):
requirement = {
'qualified_name': mcp_config.get('qualifiedName'),
'display_name': mcp_config.get('name'),
'enabled_tools': mcp_config.get('enabledTools', []),
'required_config': list(mcp_config.get('config', {}).keys())
}
mcp_requirements.append(requirement)
# Process configured_mcps (regular MCP servers)
for mcp in agent.get('configured_mcps', []):
if isinstance(mcp, dict) and 'qualifiedName' in mcp:
# Extract required config keys from the config
config_keys = list(mcp.get('config', {}).keys())
requirement = {
'qualified_name': mcp['qualifiedName'],
'display_name': mcp.get('name', mcp['qualifiedName']),
'enabled_tools': mcp.get('enabledTools', []),
'required_config': config_keys
}
mcp_requirements.append(requirement)
# Process custom_mcps
# Process custom_mcps (custom MCP servers)
for custom_mcp in agent.get('custom_mcps', []):
custom_type = custom_mcp.get('customType', custom_mcp.get('type', 'sse'))
requirement = {
'qualified_name': f"custom_{custom_type}_{custom_mcp['name'].replace(' ', '_').lower()}",
'display_name': custom_mcp['name'],
'enabled_tools': custom_mcp.get('enabledTools', []),
'required_config': list(custom_mcp.get('config', {}).keys()),
'custom_type': custom_type
}
logger.info(f"Created custom MCP requirement: {requirement}")
mcp_requirements.append(requirement)
if isinstance(custom_mcp, dict) and 'name' in custom_mcp:
# Extract required config keys from the config
config_keys = list(custom_mcp.get('config', {}).keys())
requirement = {
'qualified_name': custom_mcp['name'].lower().replace(' ', '_'),
'display_name': custom_mcp['name'],
'enabled_tools': custom_mcp.get('enabledTools', []),
'required_config': config_keys,
'custom_type': custom_mcp.get('type', 'http') # Default to http
}
mcp_requirements.append(requirement)
# Use version data if available, otherwise fall back to agent data
version_data = agent.get('agent_versions', {})
if version_data:
system_prompt = version_data.get('system_prompt', agent['system_prompt'])
agentpress_tools = version_data.get('agentpress_tools', agent.get('agentpress_tools', {}))
version_name = version_data.get('version_name', 'v1')
else:
system_prompt = agent['system_prompt']
agentpress_tools = agent.get('agentpress_tools', {})
version_name = 'v1'
kortix_team_account_ids = [
'xxxxxxxx',
]
# Create template
is_kortix_team = creator_id in kortix_team_account_ids
# Create the template
template_data = {
'creator_id': creator_id,
'name': agent['name'],
'description': agent.get('description'),
'system_prompt': system_prompt,
'system_prompt': agent['system_prompt'],
'mcp_requirements': mcp_requirements,
'agentpress_tools': agentpress_tools,
'agentpress_tools': agent.get('agentpress_tools', {}),
'tags': tags or [],
'is_public': make_public,
'is_kortix_team': is_kortix_team,
'avatar': agent.get('avatar'),
'avatar_color': agent.get('avatar_color'),
'metadata': {
'source_agent_id': agent_id,
'source_version_id': agent.get('current_version_id'),
'source_version_name': version_name
'source_version_name': agent.get('current_version', {}).get('version_name', 'v1.0')
}
}
@ -163,7 +159,7 @@ class TemplateManager:
raise ValueError("Failed to create template")
template_id = result.data[0]['template_id']
logger.info(f"Successfully created template {template_id} from agent {agent_id}")
logger.info(f"Successfully created template {template_id} from agent {agent_id} with is_kortix_team={is_kortix_team}")
return template_id
@ -554,10 +550,19 @@ class TemplateManager:
if not template or template.creator_id != creator_id:
raise ValueError("Template not found or access denied")
# Check if this is a Kortix team account
kortix_team_account_ids = [
'bc14b70b-2edf-473c-95be-5f5b109d6553', # Your Kortix team account ID
# Add more Kortix team account IDs here as needed
]
is_kortix_team = template.creator_id in kortix_team_account_ids
# Update template
update_data = {
'is_public': True,
'marketplace_published_at': datetime.now(timezone.utc).isoformat()
'marketplace_published_at': datetime.now(timezone.utc).isoformat(),
'is_kortix_team': is_kortix_team # Set based on account
}
if tags:
@ -568,6 +573,7 @@ class TemplateManager:
.eq('template_id', template_id)\
.execute()
logger.info(f"Published template {template_id} with is_kortix_team={is_kortix_team}")
return len(result.data) > 0
except Exception as e:
@ -615,6 +621,7 @@ class TemplateManager:
query = client.table('agent_templates')\
.select('*')\
.eq('is_public', True)\
.order('is_kortix_team', desc=True)\
.order('marketplace_published_at', desc=True)\
.range(offset, offset + limit - 1)
@ -628,6 +635,9 @@ class TemplateManager:
templates = []
for template_data in result.data:
# Use the database field for is_kortix_team
is_kortix_team = template_data.get('is_kortix_team', False)
templates.append({
'template_id': template_data['template_id'],
'name': template_data['name'],
@ -639,11 +649,13 @@ class TemplateManager:
'download_count': template_data.get('download_count', 0),
'marketplace_published_at': template_data.get('marketplace_published_at'),
'created_at': template_data['created_at'],
'creator_name': 'Anonymous',
'creator_name': 'Kortix Team' if is_kortix_team else 'Community',
'avatar': template_data.get('avatar'),
'avatar_color': template_data.get('avatar_color')
'avatar_color': template_data.get('avatar_color'),
'is_kortix_team': is_kortix_team
})
# Templates are already sorted by database query (Kortix team first, then by date)
return templates
except Exception as e:

View File

@ -0,0 +1,15 @@
-- Migration: Add is_kortix_team field to agent_templates
-- This migration adds support for marking templates as Kortix team templates
BEGIN;
-- Add is_kortix_team column to agent_templates table
ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS is_kortix_team BOOLEAN DEFAULT false;
-- Create index for better performance
CREATE INDEX IF NOT EXISTS idx_agent_templates_is_kortix_team ON agent_templates(is_kortix_team);
-- Add comment
COMMENT ON COLUMN agent_templates.is_kortix_team IS 'Indicates if this template is created by the Kortix team (official templates)';
COMMIT;

View File

@ -118,8 +118,6 @@ const AgentModal = ({ agent, isOpen, onClose, onCustomize, onChat, onPublish, on
Chat
</Button>
</div>
{/* Marketplace Actions */}
<div className="pt-2 border-t">
{agent.is_public ? (
<div className="space-y-2">
@ -265,31 +263,20 @@ export const AgentsGrid = ({
const { avatar, color } = getAgentStyling(agent);
const isPublishing = publishingId === agent.agent_id;
const isUnpublishing = unpublishingId === agent.agent_id;
return (
<div
key={agent.agent_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"
onClick={() => handleAgentClick(agent)}
>
<div className={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
<div className="text-4xl">
{avatar}
</div>
<div className="absolute top-3 right-3 flex gap-2">
{agent.is_default && (
<Star className="h-4 w-4 text-white fill-white drop-shadow-sm" />
)}
{agent.is_public && (
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm px-2 py-1 rounded-full">
<Shield className="h-3 w-3 text-white" />
<span className="text-white text-xs font-medium">{agent.download_count || 0}</span>
</div>
)}
<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">
<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">
{agent.name}

View File

@ -49,9 +49,9 @@ export const SearchAndFilters = ({
allTools
}: SearchAndFiltersProps) => {
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center flex-1">
<div className="relative flex-1 max-w-md">
<div className="flex flex-col w-full gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col w-full gap-3 sm:flex-row sm:items-center flex-1">
<div className="relative flex-1 w-full 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..."
@ -71,7 +71,7 @@ export const SearchAndFilters = ({
)}
</div>
<Select value={sortBy} onValueChange={(value: SortOption) => setSortBy(value)}>
{/* <Select value={sortBy} onValueChange={(value: SortOption) => setSortBy(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
@ -90,11 +90,11 @@ export const SearchAndFilters = ({
className="px-3"
>
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
</Button>
</Button> */}
</div>
<div className="flex items-center gap-2">
<DropdownMenu>
{/* <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="relative">
<Filter className="h-4 w-4" />
@ -137,7 +137,7 @@ export const SearchAndFilters = ({
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu> */}
{/* <div className="flex border rounded-md">
<Button

View File

@ -1,10 +1,22 @@
import { getPixelDesignFromSeed } from '@/components/ui/pixel-avatar';
export const getAgentAvatar = (agentId: string) => {
const avatars = ['🤖', '🎯', '⚡', '🚀', '🔮', '🎨', '📊', '🔧', '💡', '🌟'];
const colors = ['#06b6d4', '#22c55e', '#8b5cf6', '#3b82f6', '#ec4899', '#eab308', '#ef4444', '#6366f1'];
const avatarIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % avatars.length;
const colorIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
return {
avatar: avatars[avatarIndex],
color: colors[colorIndex]
};
};
const avatars = ['🤖', '🎯', '⚡', '🚀', '🔮', '🎨', '📊', '🔧', '💡', '🌟'];
const colors = ['#06b6d4', '#22c55e', '#8b5cf6', '#3b82f6', '#ec4899', '#eab308', '#ef4444', '#6366f1'];
const avatarIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % avatars.length;
const colorIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
return {
avatar: avatars[avatarIndex],
color: colors[colorIndex]
};
};
export const getAgentPixelAvatar = (agentId: string) => {
const colors = ['#06b6d4', '#22c55e', '#8b5cf6', '#3b82f6', '#ec4899', '#eab308', '#ef4444', '#6366f1'];
const colorIndex = agentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
const pixelDesign = getPixelDesignFromSeed(agentId);
return {
avatar: pixelDesign,
color: colors[colorIndex]
};
};

View File

@ -1,6 +1,4 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { isFlagEnabled } from '@/lib/feature-flags';
export const metadata: Metadata = {
title: 'Create Agent | Kortix Suna',
@ -17,9 +15,5 @@ export default async function NewAgentLayout({
}: {
children: React.ReactNode;
}) {
const agentPlaygroundEnabled = await isFlagEnabled('custom_agents');
if (!agentPlaygroundEnabled) {
redirect('/dashboard');
}
return <>{children}</>;
}

View File

@ -1,7 +1,7 @@
'use client';
import React, { useState, useMemo, useEffect } from 'react';
import { Plus, AlertCircle, Loader2, File } from 'lucide-react';
import { Plus, AlertCircle, Loader2, File, Bot } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { UpdateAgentDialog } from './_components/update-agent-dialog';
@ -240,60 +240,36 @@ export default function AgentsPage() {
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Your Agents
</h1>
<p className="text-md text-muted-foreground max-w-2xl">
Create and manage your AI agents with custom instructions and tools
</p>
</div>
<div className="flex gap-2 items-center">
<Button
onClick={() => router.push('/marketplace/my-templates')}
className="self-start sm:self-center"
variant="outline"
>
<File className="h-5 w-5" />
My Templates
</Button>
<Button
onClick={handleCreateNewAgent}
disabled={createAgentMutation.isPending}
className="self-start sm:self-center"
>
{createAgentMutation.isPending ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="h-5 w-5" />
New Agent
</>
)}
</Button>
<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'>
<Bot className='h-6 w-6 text-primary' />
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Agents
</h1>
</div>
<p className="text-md text-muted-foreground max-w-2xl">
Create and manage your agents with custom instructions and tools
</p>
</div>
<SearchAndFilters
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
sortBy={sortBy}
setSortBy={setSortBy}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
filters={filters}
setFilters={setFilters}
activeFiltersCount={activeFiltersCount}
clearFilters={clearFilters}
viewMode={viewMode}
setViewMode={setViewMode}
allTools={allTools}
/>
</div>
</div>
<SearchAndFilters
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
sortBy={sortBy}
setSortBy={setSortBy}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
filters={filters}
setFilters={setFilters}
activeFiltersCount={activeFiltersCount}
clearFilters={clearFilters}
viewMode={viewMode}
setViewMode={setViewMode}
allTools={allTools}
/>
<ResultsInfo
isLoading={isLoading}
totalAgents={pagination?.total || 0}

View File

@ -1,13 +1,14 @@
'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 } from 'lucide-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';
@ -37,6 +38,7 @@ interface MarketplaceTemplate {
avatar?: string;
avatar_color?: string;
template_id: string;
is_kortix_team?: boolean;
mcp_requirements?: Array<{
qualified_name: string;
display_name: string;
@ -74,6 +76,14 @@ interface MissingProfile {
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;
@ -82,6 +92,159 @@ interface InstallDialogProps {
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,
@ -301,13 +464,13 @@ const InstallDialog: React.FC<InstallDialogProps> = ({
<AlertTriangle className="h-4 w-4 text-destructive" />
<AlertTitle className="text-destructive">Missing Credential Profiles</AlertTitle>
<AlertDescription className="text-destructive/80">
This agent requires credential profiles for the following services:
This agent requires profiles for the following services:
</AlertDescription>
</Alert>
<div className="space-y-3">
{missingProfiles.map((profile) => (
<Card key={profile.qualified_name} className="border-destructive/20 shadow-none bg-transparent">
<Card key={profile.qualified_name} className="py-0 border-destructive/20 shadow-none bg-transparent">
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive/10">
@ -533,6 +696,7 @@ export default function MarketplacePage() {
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(() => ({
@ -546,13 +710,14 @@ export default function MarketplacePage() {
const installTemplateMutation = useInstallTemplate();
// Transform secure templates data
const marketplaceItems = useMemo(() => {
const items: MarketplaceTemplate[] = [];
const { kortixTeamItems, communityItems } = useMemo(() => {
const kortixItems: MarketplaceTemplate[] = [];
const communityItems: MarketplaceTemplate[] = [];
// Add secure templates (all items are now secure)
if (secureTemplates) {
secureTemplates.forEach(template => {
items.push({
const item: MarketplaceTemplate = {
id: template.template_id,
name: template.name,
description: template.description,
@ -564,35 +729,59 @@ export default function MarketplacePage() {
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 items
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;
}
});
// 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);
};
@ -658,11 +847,11 @@ export default function MarketplacePage() {
const allTags = React.useMemo(() => {
const tags = new Set<string>();
marketplaceItems.forEach(item => {
allMarketplaceItems.forEach(item => {
item.tags?.forEach(tag => tags.add(tag));
});
return Array.from(tags);
}, [marketplaceItems]);
}, [allMarketplaceItems]);
if (flagLoading) {
return (
@ -701,54 +890,57 @@ export default function MarketplacePage() {
return (
<div className="container mx-auto max-w-7xl px-4 py-8">
<div className="space-y-8">
<div className="space-y-4">
<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 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>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search agent templates..."
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>
{allTags.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Filter by tags:</p>
@ -772,7 +964,7 @@ export default function MarketplacePage() {
{isLoading ? (
"Loading marketplace..."
) : (
`${marketplaceItems.length} template${marketplaceItems.length !== 1 ? 's' : ''} found`
`${allMarketplaceItems.length} template${allMarketplaceItems.length !== 1 ? 's' : ''} found`
)}
</div>
@ -792,7 +984,7 @@ export default function MarketplacePage() {
</div>
))}
</div>
) : marketplaceItems.length === 0 ? (
) : allMarketplaceItems.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
{searchQuery || selectedTags.length > 0
@ -801,95 +993,214 @@ export default function MarketplacePage() {
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{marketplaceItems.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={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
<div className="text-4xl">
{avatar}
</div>
<div className="absolute top-3 right-3 flex gap-2">
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm px-2 py-1 rounded-full">
<Download className="h-3 w-3 text-white" />
<span className="text-white text-xs font-medium">{item.download_count}</span>
</div>
</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 className="p-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="space-y-1 mb-4">
<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>
<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" />
Install Template
</>
)}
</Button>
<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-xl font-semibold text-foreground">Agents from Community</h2>
<p className="text-sm text-muted-foreground">Templates created by the community</p>
</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={`h-50 flex items-center justify-center relative`} style={{ backgroundColor: color }}>
<div className="text-4xl">
{avatar}
</div>
<div className="absolute top-3 right-3 flex gap-2">
<div className="flex items-center gap-1 bg-white/20 backdrop-blur-sm px-2 py-1 rounded-full">
<Download className="h-3 w-3 text-white" />
<span className="text-white text-xs font-medium">{item.download_count}</span>
</div>
</div>
</div>
<div className="p-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="space-y-1 mb-4">
<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>
<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" />
Install Template
</>
)}
</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}

View File

@ -1,6 +1,4 @@
import { isFlagEnabled } from '@/lib/feature-flags';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'Credentials | Kortix Suna',
@ -17,9 +15,5 @@ export default async function CredentialsLayout({
}: {
children: React.ReactNode;
}) {
const customAgentsEnabled = await isFlagEnabled('custom_agents');
if (!customAgentsEnabled) {
redirect('/dashboard');
}
return <>{children}</>;
}

View File

@ -30,20 +30,20 @@ interface PredefinedAgent {
}
const PREDEFINED_AGENTS: PredefinedAgent[] = [
{
id: 'slides',
name: 'Slides',
description: 'Create stunning presentations and slide decks',
icon: <Presentation className="h-4 w-4" />,
category: 'productivity'
},
{
id: 'sheets',
name: 'Sheets',
description: 'Spreadsheet and data analysis expert',
icon: <FileSpreadsheet className="h-4 w-4" />,
category: 'productivity'
}
// {
// id: 'slides',
// name: 'Slides',
// description: 'Create stunning presentations and slide decks',
// icon: <Presentation className="h-4 w-4" />,
// category: 'productivity'
// },
// {
// id: 'sheets',
// name: 'Sheets',
// description: 'Spreadsheet and data analysis expert',
// icon: <FileSpreadsheet className="h-4 w-4" />,
// category: 'productivity'
// }
];
interface ChatSettingsDropdownProps {
@ -213,7 +213,7 @@ export const ChatSettingsDropdown: React.FC<ChatSettingsDropdownProps> = ({
/>
</div>
</div>
<div className="max-h-80 overflow-y-auto">
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent">
{agentsLoading ? (
<div className="p-3 text-sm text-muted-foreground text-center">
Loading agents...
@ -255,9 +255,6 @@ export const ChatSettingsDropdown: React.FC<ChatSettingsDropdownProps> = ({
</Badge>
)}
</div>
{/* <p className="text-xs text-muted-foreground line-clamp-1 mt-0.5">
{agent.description}
</p> */}
</div>
</div>
{isSelected && (

View File

@ -0,0 +1,262 @@
import React, { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Eraser, Palette, RotateCcw, Save } from 'lucide-react';
interface PixelArtEditorProps {
onSave: (pixelArt: string) => void;
onCancel: () => void;
initialPixelArt?: string;
size?: number;
}
const GRID_SIZE = 16;
const COLORS = [
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#C0C0C0', '#808080',
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#48DBFB'
];
export const PixelArtEditor: React.FC<PixelArtEditorProps> = ({
onSave,
onCancel,
initialPixelArt,
size = 400
}) => {
const [grid, setGrid] = useState<string[][]>(() => {
// Initialize grid with transparent pixels
const initialGrid = Array(GRID_SIZE).fill(null).map(() =>
Array(GRID_SIZE).fill('transparent')
);
// If there's initial pixel art, try to parse it
if (initialPixelArt) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(initialPixelArt, 'image/svg+xml');
const rects = doc.querySelectorAll('rect');
rects.forEach(rect => {
const x = parseInt(rect.getAttribute('x') || '0');
const y = parseInt(rect.getAttribute('y') || '0');
const width = parseInt(rect.getAttribute('width') || '1');
const height = parseInt(rect.getAttribute('height') || '1');
const fill = rect.getAttribute('fill') || 'currentColor';
// Fill the grid based on the rect
for (let row = y; row < y + height && row < GRID_SIZE; row++) {
for (let col = x; col < x + width && col < GRID_SIZE; col++) {
if (row >= 0 && col >= 0) {
initialGrid[row][col] = fill;
}
}
}
});
} catch (error) {
console.warn('Failed to parse initial pixel art:', error);
}
}
return initialGrid;
});
const [selectedColor, setSelectedColor] = useState('#000000');
const [isErasing, setIsErasing] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
const handlePixelClick = useCallback((row: number, col: number) => {
setGrid(prev => {
const newGrid = prev.map(r => [...r]);
newGrid[row][col] = isErasing ? 'transparent' : selectedColor;
return newGrid;
});
}, [selectedColor, isErasing]);
const handleMouseDown = useCallback((row: number, col: number) => {
setIsDrawing(true);
handlePixelClick(row, col);
}, [handlePixelClick]);
const handleMouseEnter = useCallback((row: number, col: number) => {
if (isDrawing) {
handlePixelClick(row, col);
}
}, [isDrawing, handlePixelClick]);
const handleMouseUp = useCallback(() => {
setIsDrawing(false);
}, []);
const clearGrid = useCallback(() => {
setGrid(Array(GRID_SIZE).fill(null).map(() =>
Array(GRID_SIZE).fill('transparent')
));
}, []);
const generateSVG = useCallback(() => {
const rects: string[] = [];
const visited = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(false));
for (let row = 0; row < GRID_SIZE; row++) {
for (let col = 0; col < GRID_SIZE; col++) {
if (!visited[row][col] && grid[row][col] !== 'transparent') {
const color = grid[row][col];
let width = 1;
let height = 1;
while (col + width < GRID_SIZE &&
grid[row][col + width] === color &&
!visited[row][col + width]) {
width++;
}
let canExtendHeight = true;
while (row + height < GRID_SIZE && canExtendHeight) {
for (let c = col; c < col + width; c++) {
if (grid[row + height][c] !== color || visited[row + height][c]) {
canExtendHeight = false;
break;
}
}
if (canExtendHeight) height++;
}
for (let r = row; r < row + height; r++) {
for (let c = col; c < col + width; c++) {
visited[r][c] = true;
}
}
const fill = color === 'currentColor' ? 'currentColor' : color;
rects.push(`<rect x="${col}" y="${row}" width="${width}" height="${height}" fill="${fill}"/>`);
}
}
}
return `<svg viewBox="0 0 ${GRID_SIZE} ${GRID_SIZE}" xmlns="http://www.w3.org/2000/svg">\n ${rects.join('\n ')}\n</svg>`;
}, [grid]);
const handleSave = useCallback(() => {
const svg = generateSVG();
onSave(svg);
}, [generateSVG, onSave]);
const pixelSize = size / GRID_SIZE;
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Pixel Art Editor
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Colors:</span>
<Button
variant={isErasing ? "default" : "outline"}
size="sm"
onClick={() => setIsErasing(!isErasing)}
>
<Eraser className="h-4 w-4" />
{isErasing ? "Erasing" : "Eraser"}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{COLORS.map(color => (
<button
key={color}
className={`w-8 h-8 rounded border-2 ${
selectedColor === color && !isErasing
? 'border-primary ring-2 ring-primary/20'
: 'border-border'
}`}
style={{ backgroundColor: color }}
onClick={() => {
setSelectedColor(color);
setIsErasing(false);
}}
title={color}
/>
))}
<button
className={`w-8 h-8 rounded border-2 ${
selectedColor === 'currentColor' && !isErasing
? 'border-primary ring-2 ring-primary/20'
: 'border-border'
} bg-gradient-to-br from-blue-500 to-purple-500`}
onClick={() => {
setSelectedColor('currentColor');
setIsErasing(false);
}}
title="Theme Color"
>
<span className="text-white text-xs font-bold">T</span>
</button>
</div>
</div>
<div className="flex justify-center">
<div
className="grid border-2 border-border bg-white dark:bg-gray-900 relative"
style={{
gridTemplateColumns: `repeat(${GRID_SIZE}, 1fr)`,
width: size,
height: size
}}
onMouseLeave={handleMouseUp}
>
{grid.map((row, rowIndex) =>
row.map((pixel, colIndex) => (
<div
key={`${rowIndex}-${colIndex}`}
className="border border-gray-200 dark:border-gray-700 cursor-pointer hover:opacity-80"
style={{
backgroundColor: pixel === 'transparent' ? 'transparent' :
pixel === 'currentColor' ? '#3b82f6' : pixel,
width: pixelSize,
height: pixelSize
}}
onMouseDown={() => handleMouseDown(rowIndex, colIndex)}
onMouseEnter={() => handleMouseEnter(rowIndex, colIndex)}
onMouseUp={handleMouseUp}
/>
))
)}
</div>
</div>
<div className="space-y-2">
<span className="text-sm font-medium">Preview:</span>
<div className="flex items-center gap-4">
<div
className="w-12 h-12 border rounded"
style={{ backgroundColor: '#3b82f6', color: 'white' }}
dangerouslySetInnerHTML={{ __html: generateSVG() }}
/>
<div
className="w-12 h-12 border rounded"
style={{ backgroundColor: '#ef4444', color: 'white' }}
dangerouslySetInnerHTML={{ __html: generateSVG() }}
/>
<div
className="w-12 h-12 border rounded"
style={{ backgroundColor: '#10b981', color: 'white' }}
dangerouslySetInnerHTML={{ __html: generateSVG() }}
/>
</div>
</div>
<div className="flex gap-2 justify-between">
<Button variant="outline" onClick={clearGrid}>
<RotateCcw className="h-4 w-4" />
Clear
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSave}>
<Save className="h-4 w-4" />
Save
</Button>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,266 @@
import React from 'react';
export const PIXEL_ART_DESIGNS = {
robot: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
<rect x="3" y="3" width="1" height="4" fill="currentColor"/>
<rect x="12" y="3" width="1" height="4" fill="currentColor"/>
<rect x="5" y="4" width="2" height="2" fill="white"/>
<rect x="9" y="4" width="2" height="2" fill="white"/>
<rect x="6" y="5" width="1" height="1" fill="currentColor"/>
<rect x="9" y="5" width="1" height="1" fill="currentColor"/>
<rect x="2" y="9" width="12" height="4" fill="currentColor"/>
<rect x="4" y="10" width="8" height="2" fill="white"/>
<rect x="5" y="11" width="6" height="1" fill="currentColor"/>
</svg>`,
cat: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="2" width="4" height="6" fill="currentColor"/>
<rect x="5" y="1" width="2" height="2" fill="currentColor"/>
<rect x="9" y="1" width="2" height="2" fill="currentColor"/>
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="9" y="4" width="1" height="1" fill="white"/>
<rect x="7" y="6" width="2" height="1" fill="white"/>
<rect x="6" y="7" width="4" height="1" fill="white"/>
</svg>`,
wizard: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="1" width="2" height="1" fill="currentColor"/>
<rect x="6" y="2" width="4" height="1" fill="currentColor"/>
<rect x="5" y="3" width="6" height="1" fill="currentColor"/>
<rect x="4" y="4" width="8" height="4" fill="currentColor"/>
<rect x="6" y="5" width="1" height="1" fill="white"/>
<rect x="9" y="5" width="1" height="1" fill="white"/>
<rect x="7" y="7" width="2" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
</svg>`,
knight: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="2" width="6" height="6" fill="currentColor"/>
<rect x="4" y="3" width="8" height="4" fill="currentColor"/>
<rect x="6" y="4" width="1" height="2" fill="white"/>
<rect x="9" y="4" width="1" height="2" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="7" y="1" width="2" height="2" fill="currentColor"/>
</svg>`,
ninja: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="9" y="4" width="1" height="1" fill="white"/>
<rect x="5" y="6" width="6" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="2" y="2" width="12" height="1" fill="currentColor"/>
</svg>`,
pirate: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="8" y="4" width="3" height="1" fill="white"/>
<rect x="7" y="6" width="2" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="3" y="2" width="10" height="1" fill="currentColor"/>
</svg>`,
alien: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="2" width="6" height="6" fill="currentColor"/>
<rect x="4" y="3" width="8" height="4" fill="currentColor"/>
<rect x="3" y="4" width="10" height="2" fill="currentColor"/>
<rect x="5" y="5" width="2" height="2" fill="white"/>
<rect x="9" y="5" width="2" height="2" fill="white"/>
<rect x="2" y="8" width="12" height="4" fill="currentColor"/>
<rect x="4" y="10" width="8" height="2" fill="white"/>
</svg>`,
dragon: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="2" width="4" height="6" fill="currentColor"/>
<rect x="5" y="3" width="6" height="4" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="9" y="4" width="1" height="1" fill="white"/>
<rect x="4" y="1" width="2" height="2" fill="currentColor"/>
<rect x="10" y="1" width="2" height="2" fill="currentColor"/>
<rect x="3" y="8" width="10" height="4" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
</svg>`,
ghost: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="10" fill="currentColor"/>
<rect x="5" y="2" width="6" height="1" fill="currentColor"/>
<rect x="6" y="1" width="4" height="1" fill="currentColor"/>
<rect x="6" y="5" width="1" height="2" fill="white"/>
<rect x="9" y="5" width="1" height="2" fill="white"/>
<rect x="4" y="13" width="2" height="1" fill="transparent"/>
<rect x="7" y="13" width="2" height="1" fill="transparent"/>
<rect x="10" y="13" width="2" height="1" fill="transparent"/>
</svg>`,
bear: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="2" width="2" height="2" fill="currentColor"/>
<rect x="11" y="2" width="2" height="2" fill="currentColor"/>
<rect x="4" y="3" width="8" height="6" fill="currentColor"/>
<rect x="6" y="5" width="1" height="1" fill="white"/>
<rect x="9" y="5" width="1" height="1" fill="white"/>
<rect x="7" y="7" width="2" height="1" fill="white"/>
<rect x="3" y="9" width="10" height="4" fill="currentColor"/>
<rect x="5" y="11" width="6" height="2" fill="white"/>
</svg>`,
astronaut: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
<rect x="3" y="3" width="10" height="4" fill="currentColor"/>
<rect x="6" y="4" width="1" height="2" fill="white"/>
<rect x="9" y="4" width="1" height="2" fill="white"/>
<rect x="7" y="6" width="2" height="1" fill="white"/>
<rect x="2" y="8" width="12" height="5" fill="currentColor"/>
<rect x="4" y="10" width="8" height="2" fill="white"/>
<rect x="1" y="9" width="2" height="2" fill="currentColor"/>
<rect x="13" y="9" width="2" height="2" fill="currentColor"/>
</svg>`,
viking: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="9" y="4" width="1" height="1" fill="white"/>
<rect x="7" y="6" width="2" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="2" y="1" width="3" height="3" fill="currentColor"/>
<rect x="11" y="1" width="3" height="3" fill="currentColor"/>
<rect x="7" y="0" width="2" height="2" fill="currentColor"/>
</svg>`,
demon: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="5" y="4" width="2" height="2" fill="white"/>
<rect x="9" y="4" width="2" height="2" fill="white"/>
<rect x="6" y="5" width="1" height="1" fill="currentColor"/>
<rect x="9" y="5" width="1" height="1" fill="currentColor"/>
<rect x="7" y="7" width="2" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="3" y="1" width="2" height="3" fill="currentColor"/>
<rect x="11" y="1" width="2" height="3" fill="currentColor"/>
</svg>`,
samurai: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="9" y="4" width="1" height="1" fill="white"/>
<rect x="5" y="6" width="6" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="6" y="1" width="4" height="2" fill="currentColor"/>
<rect x="5" y="2" width="6" height="1" fill="currentColor"/>
</svg>`,
witch: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="3" width="8" height="5" fill="currentColor"/>
<rect x="6" y="4" width="1" height="1" fill="white"/>
<rect x="9" y="4" width="1" height="1" fill="white"/>
<rect x="7" y="6" width="2" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="6" y="0" width="4" height="3" fill="currentColor"/>
<rect x="10" y="1" width="2" height="2" fill="currentColor"/>
</svg>`,
cyborg: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
<rect x="6" y="4" width="1" height="2" fill="white"/>
<rect x="9" y="4" width="2" height="2" fill="white"/>
<rect x="10" y="4" width="1" height="1" fill="currentColor"/>
<rect x="7" y="6" width="2" height="1" fill="white"/>
<rect x="3" y="8" width="10" height="5" fill="currentColor"/>
<rect x="5" y="10" width="6" height="2" fill="white"/>
<rect x="12" y="3" width="2" height="2" fill="currentColor"/>
</svg>`
};
export const PIXEL_ART_CATEGORIES = {
fantasy: {
name: "Fantasy",
designs: ["wizard", "knight", "dragon", "witch", "demon"]
},
scifi: {
name: "Sci-Fi",
designs: ["robot", "alien", "astronaut", "cyborg"]
},
animals: {
name: "Animals",
designs: ["cat", "bear", "ghost"]
},
warriors: {
name: "Warriors",
designs: ["ninja", "pirate", "viking", "samurai"]
}
};
interface PixelAvatarProps {
design: string;
size?: number;
className?: string;
customPixels?: string;
}
export const PixelAvatar: React.FC<PixelAvatarProps> = ({
design,
size = 32,
className = "",
customPixels
}) => {
const pixelArt = customPixels || PIXEL_ART_DESIGNS[design];
if (!pixelArt) {
return (
<div
className={`inline-block ${className}`}
style={{ width: size, height: size }}
>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="4" y="2" width="8" height="6" fill="currentColor"/>
<rect x="6" y="4" width="2" height="2" fill="white"/>
<rect x="9" y="4" width="2" height="2" fill="white"/>
<rect x="2" y="9" width="12" height="4" fill="currentColor"/>
</svg>
</div>
);
}
return (
<div
className={`inline-block ${className}`}
style={{ width: size, height: size }}
dangerouslySetInnerHTML={{ __html: pixelArt }}
/>
);
};
export const getAllPixelDesigns = (): string[] => {
return Object.keys(PIXEL_ART_DESIGNS);
};
export const getPixelDesignsByCategory = (category: string): string[] => {
return PIXEL_ART_CATEGORIES[category]?.designs || [];
};
export const getRandomPixelDesign = (): string => {
const designs = getAllPixelDesigns();
return designs[Math.floor(Math.random() * designs.length)];
};
export const getPixelDesignFromSeed = (seed: string): string => {
const designs = getAllPixelDesigns();
let hash = 0;
for (let i = 0; i < seed.length; i++) {
const char = seed.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
const designIndex = Math.abs(hash) % designs.length;
return designs[designIndex];
};

View File

@ -44,6 +44,7 @@ export interface AgentTemplate {
creator_name?: string;
avatar?: string;
avatar_color?: string;
is_kortix_team?: boolean;
metadata?: {
source_agent_id?: string;
source_version_id?: string;