diff --git a/backend/agent/api.py b/backend/agent/api.py index 4c814cc1..85cd38d6 100644 --- a/backend/agent/api.py +++ b/backend/agent/api.py @@ -146,6 +146,27 @@ class ThreadAgentResponse(BaseModel): source: str # "thread", "default", "none", "missing" message: str +class AgentExportData(BaseModel): + """Exportable agent configuration data""" + name: str + description: Optional[str] = None + system_prompt: str + agentpress_tools: Dict[str, Any] + configured_mcps: List[Dict[str, Any]] + custom_mcps: List[Dict[str, Any]] + avatar: Optional[str] = None + avatar_color: Optional[str] = None + tags: Optional[List[str]] = [] + metadata: Optional[Dict[str, Any]] = None + export_version: str = "1.0" + exported_at: str + exported_by: Optional[str] = None + +class AgentImportRequest(BaseModel): + """Request to import an agent from JSON""" + import_data: AgentExportData + import_as_new: bool = True # Always true, only creating new agents is supported + def initialize( _db: DBConnection, _instance_id: Optional[str] = None @@ -1589,6 +1610,140 @@ async def get_agent(agent_id: str, user_id: str = Depends(get_current_user_id_fr logger.error(f"Error fetching agent {agent_id} for user {user_id}: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to fetch agent: {str(e)}") +@router.get("/agents/{agent_id}/export", response_model=AgentExportData) +async def export_agent(agent_id: str, user_id: str = Depends(get_current_user_id_from_jwt)): + """Export an agent configuration as JSON""" + logger.info(f"Exporting agent {agent_id} for user: {user_id}") + + try: + client = await db.client + + # Get agent data + agent_result = await client.table('agents').select('*').eq('agent_id', agent_id).eq('account_id', user_id).execute() + if not agent_result.data: + raise HTTPException(status_code=404, detail="Agent not found") + + agent = agent_result.data[0] + + # Get current version data if available + current_version = None + if agent.get('current_version_id'): + version_result = await client.table('agent_versions').select('*').eq('version_id', agent['current_version_id']).execute() + if version_result.data: + current_version = version_result.data[0] + + # Extract configuration using existing helper + from agent.config_helper import extract_agent_config + config = extract_agent_config(agent, current_version) + + # Clean metadata for export (remove instance-specific data) + export_metadata = {} + if agent.get('metadata'): + export_metadata = {k: v for k, v in agent['metadata'].items() + if k not in ['is_suna_default', 'centrally_managed', 'installation_date', 'last_central_update']} + + # Create export data + export_data = AgentExportData( + name=config.get('name', ''), + description=config.get('description', ''), + system_prompt=config.get('system_prompt', ''), + agentpress_tools=config.get('agentpress_tools', {}), + configured_mcps=config.get('configured_mcps', []), + custom_mcps=config.get('custom_mcps', []), + avatar=config.get('avatar'), + avatar_color=config.get('avatar_color'), + tags=agent.get('tags', []), + metadata=export_metadata, + exported_at=datetime.utcnow().isoformat(), + exported_by=user_id + ) + + logger.info(f"Successfully exported agent {agent_id}") + return export_data + + except Exception as e: + logger.error(f"Error exporting agent {agent_id}: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to export agent: {str(e)}") + +@router.post("/agents/import", response_model=AgentResponse) +async def import_agent( + import_request: AgentImportRequest, + user_id: str = Depends(get_current_user_id_from_jwt) +): + """Import an agent from JSON configuration""" + logger.info(f"Importing agent for user: {user_id}") + + if not await is_enabled("custom_agents"): + raise HTTPException( + status_code=403, + detail="Custom agents currently disabled. This feature is not available at the moment." + ) + + try: + client = await db.client + import_data = import_request.import_data + + # Validate import data + if not import_data.name or not import_data.system_prompt: + raise HTTPException(status_code=400, detail="Agent name and system prompt are required") + + # Create new agent + logger.info(f"Creating new agent from import: {import_data.name}") + + # Check if user wants to set as default, and if so, unset other defaults + is_default = False # Imported agents are not default by default + + insert_data = { + "account_id": user_id, + "name": import_data.name, + "description": import_data.description, + "avatar": import_data.avatar, + "avatar_color": import_data.avatar_color, + "is_default": is_default, + "tags": import_data.tags or [], + "version_count": 1, + "metadata": import_data.metadata or {} + } + + new_agent = await client.table('agents').insert(insert_data).execute() + + if not new_agent.data: + raise HTTPException(status_code=500, detail="Failed to create agent from import") + + agent = new_agent.data[0] + agent_id = agent['agent_id'] + + # Create initial version + try: + version_service = await _get_version_service() + + version = await version_service.create_version( + agent_id=agent_id, + user_id=user_id, + system_prompt=import_data.system_prompt, + configured_mcps=import_data.configured_mcps, + custom_mcps=import_data.custom_mcps, + agentpress_tools=import_data.agentpress_tools, + version_name="v1", + change_description="Initial version from import" + ) + + logger.info(f"Successfully imported agent {agent_id}") + return await get_agent(agent_id, user_id) + + except Exception as e: + # Clean up agent if anything goes wrong + await client.table('agents').delete().eq('agent_id', agent_id).execute() + raise e + + + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error importing agent: {str(e)}") + raise HTTPException(status_code=500, detail=f"Failed to import agent: {str(e)}") + @router.post("/agents", response_model=AgentResponse) async def create_agent( agent_data: AgentCreateRequest, diff --git a/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx b/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx index c8a39ce2..6ad38e14 100644 --- a/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx +++ b/frontend/src/app/(dashboard)/agents/config/[agentId]/page.tsx @@ -23,6 +23,7 @@ import { cn } from '@/lib/utils'; import { AgentHeader, VersionAlert, AgentBuilderTab, ConfigurationTab } from '@/components/agents/config'; import { UpcomingRunsDropdown } from '@/components/agents/upcoming-runs-dropdown'; +import { useExportAgent } from '@/hooks/react-query/agents/use-agent-export-import'; interface FormData { name: string; @@ -50,6 +51,7 @@ export default function AgentConfigurationPage() { const updateAgentMutation = useUpdateAgent(); const createVersionMutation = useCreateAgentVersion(); const activateVersionMutation = useActivateAgentVersion(); + const exportMutation = useExportAgent(); const [formData, setFormData] = useState({ name: '', @@ -389,6 +391,11 @@ export default function AgentConfigurationPage() { } }, [agentId, activateVersionMutation]); + const handleExport = useCallback(() => { + if (!agentId) return; + exportMutation.mutate(agentId); + }, [agentId, exportMutation]); + useEffect(() => { if (isViewingOldVersion && activeTab === 'agent-builder') { setActiveTab('configuration'); @@ -526,6 +533,8 @@ export default function AgentConfigurationPage() { onFieldChange={handleFieldChange} onStyleChange={handleStyleChange} onTabChange={setActiveTab} + onExport={handleExport} + isExporting={exportMutation.isPending} agentMetadata={agent?.metadata} /> @@ -655,6 +664,8 @@ export default function AgentConfigurationPage() { onFieldChange={handleFieldChange} onStyleChange={handleStyleChange} onTabChange={setActiveTab} + onExport={handleExport} + isExporting={exportMutation.isPending} agentMetadata={agent?.metadata} /> @@ -724,6 +735,8 @@ export default function AgentConfigurationPage() { + + diff --git a/frontend/src/components/agents/config/agent-header.tsx b/frontend/src/components/agents/config/agent-header.tsx index 995956c7..b7ac1f80 100644 --- a/frontend/src/components/agents/config/agent-header.tsx +++ b/frontend/src/components/agents/config/agent-header.tsx @@ -1,11 +1,18 @@ import React from 'react'; -import { Sparkles, Settings } from 'lucide-react'; +import { Sparkles, Settings, MoreHorizontal, Download } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { EditableText } from '@/components/ui/editable'; import { StylePicker } from '../style-picker'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { KortixLogo } from '@/components/sidebar/kortix-logo'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; interface AgentHeaderProps { agentId: string; @@ -22,6 +29,8 @@ interface AgentHeaderProps { onFieldChange: (field: string, value: any) => void; onStyleChange: (emoji: string, color: string) => void; onTabChange: (value: string) => void; + onExport?: () => void; + isExporting?: boolean; agentMetadata?: { is_suna_default?: boolean; restrictions?: { @@ -39,6 +48,8 @@ export function AgentHeader({ onFieldChange, onStyleChange, onTabChange, + onExport, + isExporting = false, agentMetadata, }: AgentHeaderProps) { const isSunaAgent = agentMetadata?.is_suna_default || false; @@ -60,7 +71,7 @@ export function AgentHeader({
{isSunaAgent ? ( -
+
) : ( @@ -93,30 +104,59 @@ export function AgentHeader({
-{!isSunaAgent && ( - - - - - Prompt to Build - - - - Manual Config - - - - )} +
+ {/* 3-dots menu for actions - always show if onExport is available */} + {onExport && ( + + + + + + + + Export agent + + + + )} + + {/* Only show tabs for non-Suna agents */} + {!isSunaAgent && ( + + + + + Prompt to Build + + + + Manual Config + + + + )} +
); } \ No newline at end of file diff --git a/frontend/src/components/agents/new-agent-dialog.tsx b/frontend/src/components/agents/new-agent-dialog.tsx index 047d38fe..adae6878 100644 --- a/frontend/src/components/agents/new-agent-dialog.tsx +++ b/frontend/src/components/agents/new-agent-dialog.tsx @@ -1,7 +1,7 @@ 'use client'; -import React from 'react'; -import { Loader2 } from 'lucide-react'; +import React, { useState, useRef } from 'react'; +import { Loader2, Upload, Plus, FileJson, CheckCircle2, AlertCircle } from 'lucide-react'; import { AlertDialog, AlertDialogAction, @@ -12,7 +12,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useCreateNewAgent } from '@/hooks/react-query/agents/use-agents'; +import { useImportAgent, parseAgentImportFile, type AgentExportData } from '@/hooks/react-query/agents/use-agent-export-import'; +import { toast } from 'sonner'; interface NewAgentDialogProps { open: boolean; @@ -21,7 +27,14 @@ interface NewAgentDialogProps { } export function NewAgentDialog({ open, onOpenChange, onSuccess }: NewAgentDialogProps) { + const [mode, setMode] = useState<'create' | 'import'>('create'); + const [importFile, setImportFile] = useState(null); + const [importData, setImportData] = useState(null); + const [isProcessingFile, setIsProcessingFile] = useState(false); + const fileInputRef = useRef(null); + const createNewAgentMutation = useCreateNewAgent(); + const importMutation = useImportAgent(); const handleCreateNewAgent = () => { createNewAgentMutation.mutate(undefined, { @@ -36,33 +49,188 @@ export function NewAgentDialog({ open, onOpenChange, onSuccess }: NewAgentDialog }); }; - const isLoading = createNewAgentMutation.isPending; + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (!file.name.endsWith('.json')) { + toast.error('Please select a JSON file'); + return; + } + + setIsProcessingFile(true); + try { + const data = await parseAgentImportFile(file); + setImportFile(file); + setImportData(data); + toast.success('Import file loaded successfully'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to parse import file'); + setImportFile(null); + setImportData(null); + } finally { + setIsProcessingFile(false); + } + }; + + const handleImport = () => { + if (!importData) { + toast.error('No import data available'); + return; + } + + importMutation.mutate({ + import_data: importData, + import_as_new: true + }, { + onSuccess: () => { + onOpenChange(false); + onSuccess?.(); + // Reset form + setImportFile(null); + setImportData(null); + setMode('create'); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }); + }; + + const handleDialogClose = (open: boolean) => { + if (!open) { + // Reset form when closing + setImportFile(null); + setImportData(null); + setMode('create'); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + onOpenChange(open); + }; + + const isLoading = createNewAgentMutation.isPending || importMutation.isPending; return ( - - + + Create New Agent - This will create a new agent with a default name and description. + This will create a new agent that you can customize and configure. + +
+ {/* Content based on mode */} + {mode === 'create' ? ( +
+
+ This will create a new agent with a default name and description that you can customize later. +
+ + {/* Subtle import option */} +
+ +
+
+ ) : ( +
+
+ + +
+ + + + {isProcessingFile && ( + + + + Processing import file... + + + )} + + {importData && ( + + + +
+
Agent: {importData.name}
+ {importData.description && ( +
Description: {importData.description}
+ )} +
Exported: {new Date(importData.exported_at).toLocaleString()}
+
+
+
+ )} +
+ )} +
+ Cancel - - {isLoading ? ( - <> - - Creating... - - ) : ( - 'Create' - )} - + + {mode === 'create' ? ( + + {createNewAgentMutation.isPending ? ( + <> + + Creating... + + ) : ( + <> + + Create Agent + + )} + + ) : ( + + {importMutation.isPending ? ( + <> + + Importing... + + ) : ( + <> + + Import Agent + + )} + + )}
diff --git a/frontend/src/hooks/react-query/agents/use-agent-export-import.ts b/frontend/src/hooks/react-query/agents/use-agent-export-import.ts new file mode 100644 index 00000000..60c9145a --- /dev/null +++ b/frontend/src/hooks/react-query/agents/use-agent-export-import.ts @@ -0,0 +1,207 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { createClient } from "@/lib/supabase/client"; + +const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; + +export interface AgentExportData { + name: string; + description?: string; + system_prompt: string; + agentpress_tools: Record; + configured_mcps: Array<{ + name: string; + config: Record; + }>; + custom_mcps: Array<{ + name: string; + type: 'json' | 'sse'; + config: Record; + enabledTools: string[]; + }>; + avatar?: string; + avatar_color?: string; + tags?: string[]; + metadata?: Record; + export_version: string; + exported_at: string; + exported_by?: string; +} + +export interface AgentImportRequest { + import_data: AgentExportData; + import_as_new: boolean; +} + +// Export agent hook +export const useExportAgent = () => { + return useMutation({ + mutationFn: async (agentId: string): Promise => { + console.log('Exporting agent:', agentId); + + const supabase = createClient(); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session) { + throw new Error('You must be logged in to export agents'); + } + + const response = await fetch(`${API_URL}/agents/${agentId}/export`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Export response:', data); + return data; + }, + onSuccess: (data, agentId) => { + console.log('Export successful for agent:', agentId, 'Data:', data); + + // Create and download JSON file + const jsonString = JSON.stringify(data, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `${data.name.replace(/[^a-zA-Z0-9]/g, '_')}_agent_export.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`Agent "${data.name}" exported successfully`); + }, + onError: (error: any) => { + console.error('Export error:', error); + toast.error(error?.message || "Failed to export agent"); + }, + }); +}; + +// Import agent hook +export const useImportAgent = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (importRequest: AgentImportRequest) => { + console.log('Importing agent:', importRequest); + + const supabase = createClient(); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session) { + throw new Error('You must be logged in to import agents'); + } + + const response = await fetch(`${API_URL}/agents/import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}`, + }, + body: JSON.stringify(importRequest), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Import response:', data); + return data; + }, + onSuccess: (data, variables) => { + console.log('Import successful:', data); + + // Invalidate agents list to refresh data + queryClient.invalidateQueries({ queryKey: ['agents'] }); + + const action = variables.import_as_new ? 'imported' : 'updated'; + toast.success(`Agent "${variables.import_data.name}" ${action} successfully`); + }, + onError: (error: any) => { + console.error('Import error:', error); + toast.error(error?.message || "Failed to import agent"); + }, + }); +}; + +// Helper function to validate import data +export const validateImportData = (data: any): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + + if (!data) { + errors.push("No data provided"); + return { isValid: false, errors }; + } + + if (!data.name || typeof data.name !== 'string') { + errors.push("Agent name is required"); + } + + if (!data.system_prompt || typeof data.system_prompt !== 'string') { + errors.push("System prompt is required"); + } + + if (!data.export_version) { + errors.push("Export version is missing - this may not be a valid agent export file"); + } + + if (data.agentpress_tools && typeof data.agentpress_tools !== 'object') { + errors.push("Invalid agentpress_tools format"); + } + + if (data.configured_mcps && !Array.isArray(data.configured_mcps)) { + errors.push("Invalid configured_mcps format"); + } + + if (data.custom_mcps && !Array.isArray(data.custom_mcps)) { + errors.push("Invalid custom_mcps format"); + } + + return { + isValid: errors.length === 0, + errors + }; +}; + +// Helper function to read and parse JSON file +export const parseAgentImportFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const data = JSON.parse(content); + + const validation = validateImportData(data); + if (!validation.isValid) { + reject(new Error(`Invalid import file: ${validation.errors.join(', ')}`)); + return; + } + + resolve(data as AgentExportData); + } catch (error) { + reject(new Error('Failed to parse JSON file')); + } + }; + + reader.onerror = () => { + reject(new Error('Failed to read file')); + }; + + reader.readAsText(file); + }); +}; \ No newline at end of file diff --git a/sample_agent_export.json b/sample_agent_export.json new file mode 100644 index 00000000..b3c8db3f --- /dev/null +++ b/sample_agent_export.json @@ -0,0 +1,44 @@ +{ + "name": "Research Assistant", + "description": "An AI assistant specialized in conducting research and providing comprehensive analysis", + "system_prompt": "You are a research assistant with expertise in gathering, analyzing, and synthesizing information from various sources. Your approach is thorough and methodical.\n\nKey capabilities:\n- Conduct comprehensive web research\n- Analyze and synthesize information from multiple sources\n- Provide well-structured reports and summaries\n- Fact-check information for accuracy\n- Identify trends and patterns in data\n\nAlways cite your sources and provide evidence-based conclusions.", + "agentpress_tools": { + "web_search": { + "enabled": true, + "description": "Search the web for current information and research" + }, + "sb_files": { + "enabled": true, + "description": "Read and write files for research documentation" + }, + "code_interpreter": { + "enabled": true, + "description": "Analyze data and create visualizations" + } + }, + "configured_mcps": [ + { + "name": "filesystem", + "qualifiedName": "@modelcontextprotocol/server-filesystem", + "config": { + "path": "/research-data" + }, + "enabledTools": [ + "read_file", + "write_file", + "list_directory" + ] + } + ], + "custom_mcps": [], + "avatar": "🔬", + "avatar_color": "#4F46E5", + "tags": ["research", "analysis", "academic"], + "metadata": { + "category": "productivity", + "use_cases": ["academic research", "market analysis", "fact checking"] + }, + "export_version": "1.0", + "exported_at": "2024-01-15T10:30:00.000Z", + "exported_by": "user123" +} \ No newline at end of file