diff --git a/frontend/src/app/[locale]/(authenticated)/agents/[id]/page.tsx b/frontend/src/app/[locale]/(authenticated)/agents/[id]/page.tsx new file mode 100644 index 0000000..2674757 --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/agents/[id]/page.tsx @@ -0,0 +1,194 @@ +/** + * Agent Type Detail/Edit Page + * + * Displays agent type details with options to edit, duplicate, or deactivate. + * Handles 'new' as a special ID to show the create form. + */ + +'use client'; + +import { useCallback, useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { AgentTypeDetail, AgentTypeForm } from '@/components/agents'; +import { + useAgentType, + useCreateAgentType, + useUpdateAgentType, + useDeactivateAgentType, + useDuplicateAgentType, +} from '@/lib/api/hooks/useAgentTypes'; +import type { AgentTypeCreateFormValues } from '@/lib/validations/agentType'; + +type ViewMode = 'detail' | 'edit' | 'create'; + +export default function AgentTypeDetailPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + // Determine initial view mode + const isNew = id === 'new'; + const [viewMode, setViewMode] = useState(isNew ? 'create' : 'detail'); + + // Fetch agent type data (skip if creating new) + const { + data: agentType, + isLoading, + error, + } = useAgentType(isNew ? null : id); + + // Mutations + const createMutation = useCreateAgentType(); + const updateMutation = useUpdateAgentType(); + const deactivateMutation = useDeactivateAgentType(); + const duplicateMutation = useDuplicateAgentType(); + + // Handle navigation back to list + const handleBack = useCallback(() => { + if (viewMode === 'edit') { + setViewMode('detail'); + } else { + router.push('/agents'); + } + }, [router, viewMode]); + + // Handle edit button click + const handleEdit = useCallback(() => { + setViewMode('edit'); + }, []); + + // Handle form submission for create/update + const handleSubmit = useCallback( + async (data: AgentTypeCreateFormValues) => { + try { + if (isNew || viewMode === 'create') { + // Create new agent type + const result = await createMutation.mutateAsync({ + name: data.name, + slug: data.slug, + description: data.description, + expertise: data.expertise, + personality_prompt: data.personality_prompt, + primary_model: data.primary_model, + fallback_models: data.fallback_models, + model_params: data.model_params, + mcp_servers: data.mcp_servers, + tool_permissions: data.tool_permissions, + is_active: data.is_active, + }); + toast.success('Agent type created', { + description: `${result.name} has been created successfully`, + }); + router.push(`/agents/${result.id}`); + } else { + // Update existing agent type + const result = await updateMutation.mutateAsync({ + id, + data: { + name: data.name, + slug: data.slug, + description: data.description, + expertise: data.expertise, + personality_prompt: data.personality_prompt, + primary_model: data.primary_model, + fallback_models: data.fallback_models, + model_params: data.model_params, + mcp_servers: data.mcp_servers, + tool_permissions: data.tool_permissions, + is_active: data.is_active, + }, + }); + toast.success('Agent type updated', { + description: `${result.name} has been updated successfully`, + }); + setViewMode('detail'); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'An error occurred'; + toast.error('Failed to save agent type', { description: message }); + } + }, + [id, isNew, viewMode, createMutation, updateMutation, router] + ); + + // Handle duplicate + const handleDuplicate = useCallback(async () => { + if (!agentType) return; + + try { + const result = await duplicateMutation.mutateAsync(agentType); + toast.success('Agent type duplicated', { + description: `${result.name} has been created`, + }); + router.push(`/agents/${result.id}`); + } catch (err) { + const message = err instanceof Error ? err.message : 'An error occurred'; + toast.error('Failed to duplicate agent type', { description: message }); + } + }, [agentType, duplicateMutation, router]); + + // Handle deactivate + const handleDeactivate = useCallback(async () => { + try { + await deactivateMutation.mutateAsync(id); + toast.success('Agent type deactivated', { + description: 'The agent type has been deactivated', + }); + router.push('/agents'); + } catch (err) { + const message = err instanceof Error ? err.message : 'An error occurred'; + toast.error('Failed to deactivate agent type', { description: message }); + } + }, [id, deactivateMutation, router]); + + // Handle cancel from form + const handleCancel = useCallback(() => { + if (isNew) { + router.push('/agents'); + } else { + setViewMode('detail'); + } + }, [isNew, router]); + + // Show error state + if (error && !isNew) { + return ( +
+ +
+ ); + } + + // Render based on view mode + return ( +
+ {(viewMode === 'create' || viewMode === 'edit') && ( + + )} + + {viewMode === 'detail' && ( + + )} +
+ ); +} diff --git a/frontend/src/app/[locale]/(authenticated)/agents/page.tsx b/frontend/src/app/[locale]/(authenticated)/agents/page.tsx new file mode 100644 index 0000000..f05efe2 --- /dev/null +++ b/frontend/src/app/[locale]/(authenticated)/agents/page.tsx @@ -0,0 +1,95 @@ +/** + * Agent Types List Page + * + * Displays a list of agent types with search and filter functionality. + * Allows navigation to agent type detail and creation pages. + */ + +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { AgentTypeList } from '@/components/agents'; +import { useAgentTypes } from '@/lib/api/hooks/useAgentTypes'; +import { useDebounce } from '@/lib/hooks/useDebounce'; + +export default function AgentTypesPage() { + const router = useRouter(); + + // Filter state + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + + // Debounce search for API calls + const debouncedSearch = useDebounce(searchQuery, 300); + + // Determine is_active filter value + const isActiveFilter = useMemo(() => { + if (statusFilter === 'active') return true; + if (statusFilter === 'inactive') return false; + return undefined; // 'all' returns undefined to not filter + }, [statusFilter]); + + // Fetch agent types + const { data, isLoading, error } = useAgentTypes({ + search: debouncedSearch || undefined, + is_active: isActiveFilter, + page: 1, + limit: 50, + }); + + // Filter results client-side for 'all' status + const filteredAgentTypes = useMemo(() => { + if (!data?.data) return []; + + // When status is 'all', we need to fetch both and combine + // For now, the API returns based on is_active filter + return data.data; + }, [data?.data]); + + // Handle navigation to agent type detail + const handleSelect = useCallback( + (id: string) => { + router.push(`/agents/${id}`); + }, + [router] + ); + + // Handle navigation to create page + const handleCreate = useCallback(() => { + router.push('/agents/new'); + }, [router]); + + // Handle search change + const handleSearchChange = useCallback((query: string) => { + setSearchQuery(query); + }, []); + + // Handle status filter change + const handleStatusFilterChange = useCallback((status: string) => { + setStatusFilter(status); + }, []); + + // Show error toast if fetch fails + if (error) { + toast.error('Failed to load agent types', { + description: 'Please try again later', + }); + } + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/agents/AgentTypeDetail.tsx b/frontend/src/components/agents/AgentTypeDetail.tsx new file mode 100644 index 0000000..04a6aea --- /dev/null +++ b/frontend/src/components/agents/AgentTypeDetail.tsx @@ -0,0 +1,416 @@ +/** + * AgentTypeDetail Component + * + * Displays detailed information about a single agent type. + * Shows model configuration, permissions, personality, and instance stats. + */ + +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { + Bot, + ArrowLeft, + Copy, + Edit, + Trash2, + FileText, + Zap, + MessageSquare, + Shield, + Cpu, + CheckCircle2, + AlertTriangle, +} from 'lucide-react'; +import type { AgentTypeResponse } from '@/lib/api/types/agentTypes'; +import { AVAILABLE_MCP_SERVERS } from '@/lib/validations/agentType'; + +interface AgentTypeDetailProps { + agentType: AgentTypeResponse | null; + isLoading?: boolean; + onBack: () => void; + onEdit: () => void; + onDuplicate: () => void; + onDeactivate: () => void; + isDeactivating?: boolean; + className?: string; +} + +/** + * Status badge component for agent types + */ +function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) { + if (isActive) { + return ( + + Active + + ); + } + return ( + + Inactive + + ); +} + +/** + * Loading skeleton for agent type detail + */ +function AgentTypeDetailSkeleton() { + return ( +
+
+ +
+ + +
+
+
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+
+ ); +} + +/** + * Get model display name + */ +function getModelDisplayName(modelId: string): string { + const modelNames: Record = { + 'claude-opus-4-5-20251101': 'Claude Opus 4.5', + 'claude-sonnet-4-20250514': 'Claude Sonnet 4', + 'claude-3-5-sonnet-20241022': 'Claude 3.5 Sonnet', + }; + return modelNames[modelId] || modelId; +} + +export function AgentTypeDetail({ + agentType, + isLoading = false, + onBack, + onEdit, + onDuplicate, + onDeactivate, + isDeactivating = false, + className, +}: AgentTypeDetailProps) { + if (isLoading) { + return ; + } + + if (!agentType) { + return ( +
+ +

Agent type not found

+

+ The requested agent type could not be found +

+ +
+ ); + } + + const modelParams = agentType.model_params as { + temperature?: number; + max_tokens?: number; + top_p?: number; + }; + + return ( +
+ {/* Header */} +
+ +
+
+

{agentType.name}

+ +
+

+ Last modified:{' '} + {new Date(agentType.updated_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} +

+
+
+ + +
+
+ +
+ {/* Main Content */} +
+ {/* Description Card */} + + + + + Description + + + +

+ {agentType.description || 'No description provided'} +

+
+
+ + {/* Expertise Card */} + + + + + Expertise Areas + + + + {agentType.expertise.length > 0 ? ( +
+ {agentType.expertise.map((skill) => ( + + {skill} + + ))} +
+ ) : ( +

No expertise areas defined

+ )} +
+
+ + {/* Personality Prompt Card */} + + + + + Personality Prompt + + + +
+                {agentType.personality_prompt}
+              
+
+
+ + {/* MCP Permissions Card */} + + + + + MCP Permissions + + + Model Context Protocol servers this agent can access + + + +
+ {AVAILABLE_MCP_SERVERS.map((server) => { + const isEnabled = agentType.mcp_servers.includes(server.id); + return ( +
+
+
+ {isEnabled ? ( + + ) : ( + + )} +
+
+

{server.name}

+

+ {server.description} +

+
+
+ + {isEnabled ? 'Enabled' : 'Disabled'} + +
+ ); + })} +
+
+
+
+ + {/* Sidebar */} +
+ {/* Model Configuration */} + + + + + Model Configuration + + + +
+

Primary Model

+

+ {getModelDisplayName(agentType.primary_model)} +

+
+
+

Failover Model

+

+ {agentType.fallback_models.length > 0 + ? getModelDisplayName(agentType.fallback_models[0]) + : 'None configured'} +

+
+ +
+
+ Temperature + {modelParams.temperature ?? 0.7} +
+
+ Max Tokens + + {(modelParams.max_tokens ?? 8192).toLocaleString()} + +
+
+ Top P + {modelParams.top_p ?? 0.95} +
+
+
+
+ + {/* Instance Stats */} + + + + + Instances + + + +
+

+ {agentType.instance_count} +

+

Active instances

+
+ +
+
+ + {/* Danger Zone */} + + + + + Danger Zone + + + + + + + + + + Are you sure? + + {agentType.is_active + ? `This will deactivate the "${agentType.name}" agent type. Existing instances will continue to work, but no new instances can be created from this type.` + : `This will permanently delete the "${agentType.name}" agent type. This action cannot be undone.`} + + + + Cancel + + {agentType.is_active ? 'Deactivate' : 'Delete'} + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/agents/AgentTypeForm.tsx b/frontend/src/components/agents/AgentTypeForm.tsx new file mode 100644 index 0000000..bbb0ec9 --- /dev/null +++ b/frontend/src/components/agents/AgentTypeForm.tsx @@ -0,0 +1,554 @@ +/** + * AgentTypeForm Component + * + * React Hook Form-based form for creating and editing agent types. + * Features tabbed interface for organizing form sections. + */ + +'use client'; + +import { useEffect, useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + FileText, + Cpu, + Shield, + MessageSquare, + Sliders, + Save, + ArrowLeft, + X, +} from 'lucide-react'; +import { + agentTypeCreateSchema, + type AgentTypeCreateFormValues, + AVAILABLE_MODELS, + AVAILABLE_MCP_SERVERS, + defaultAgentTypeValues, + generateSlug, +} from '@/lib/validations/agentType'; +import type { AgentTypeResponse } from '@/lib/api/types/agentTypes'; + +interface AgentTypeFormProps { + agentType?: AgentTypeResponse; + onSubmit: (data: AgentTypeCreateFormValues) => void; + onCancel: () => void; + isSubmitting?: boolean; + className?: string; +} + +export function AgentTypeForm({ + agentType, + onSubmit, + onCancel, + isSubmitting = false, + className, +}: AgentTypeFormProps) { + const isEditing = !!agentType; + const [activeTab, setActiveTab] = useState('basic'); + const [expertiseInput, setExpertiseInput] = useState(''); + + // Always use create schema for validation - editing requires all fields too + const form = useForm({ + resolver: zodResolver(agentTypeCreateSchema), + defaultValues: agentType + ? { + name: agentType.name, + slug: agentType.slug, + description: agentType.description, + expertise: agentType.expertise, + personality_prompt: agentType.personality_prompt, + primary_model: agentType.primary_model, + fallback_models: agentType.fallback_models, + model_params: (agentType.model_params ?? { + temperature: 0.7, + max_tokens: 8192, + top_p: 0.95, + }) as AgentTypeCreateFormValues['model_params'], + mcp_servers: agentType.mcp_servers, + tool_permissions: agentType.tool_permissions, + is_active: agentType.is_active, + } + : defaultAgentTypeValues, + }); + + const { + register, + control, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = form; + + const watchName = watch('name'); + const watchExpertise = watch('expertise') || []; + const watchMcpServers = watch('mcp_servers') || []; + + // Auto-generate slug from name for new agent types + useEffect(() => { + if (!isEditing && watchName) { + const slug = generateSlug(watchName); + setValue('slug', slug, { shouldValidate: true }); + } + }, [watchName, isEditing, setValue]); + + const handleAddExpertise = () => { + if (expertiseInput.trim()) { + const newExpertise = expertiseInput.trim().toLowerCase(); + if (!watchExpertise.includes(newExpertise)) { + setValue('expertise', [...watchExpertise, newExpertise]); + } + setExpertiseInput(''); + } + }; + + const handleRemoveExpertise = (skill: string) => { + setValue( + 'expertise', + watchExpertise.filter((e) => e !== skill) + ); + }; + + const handleMcpServerToggle = (serverId: string, checked: boolean) => { + if (checked) { + setValue('mcp_servers', [...watchMcpServers, serverId]); + } else { + setValue( + 'mcp_servers', + watchMcpServers.filter((s) => s !== serverId) + ); + } + }; + + return ( +
+ {/* Header */} +
+ +
+

+ {isEditing ? 'Edit Agent Type' : 'Create Agent Type'} +

+

+ {isEditing + ? 'Modify agent type configuration' + : 'Define a new agent type template'} +

+
+
+ + +
+
+ + {/* Tabbed Form */} + + + + + Basic Info + + + + Model + + + + Permissions + + + + Personality + + + + {/* Basic Info Tab */} + + + + Basic Information + + Define the agent type name, description, and expertise areas + + + +
+
+ + + {errors.name && ( + + )} +
+
+ + + {errors.slug && ( + + )} +

+ URL-friendly identifier (auto-generated from name) +

+
+
+ +
+
+ + ( + + )} + /> +
+
+ +
+ +