forked from cardosofelipe/fast-next-template
feat(frontend): implement agent configuration pages (#41)
- Add agent types list page with search and filter functionality - Add agent type detail/edit page with tabbed interface - Create AgentTypeForm component with React Hook Form + Zod validation - Implement model configuration (temperature, max tokens, top_p) - Add MCP permission management with checkboxes - Include personality prompt editor textarea - Create TanStack Query hooks for agent-types API - Add useDebounce hook for search optimization - Comprehensive unit tests for all components (68 tests) Components: - AgentTypeList: Grid view with status badges, expertise tags - AgentTypeDetail: Full detail view with model config, MCP permissions - AgentTypeForm: Create/edit with 4 tabs (Basic, Model, Permissions, Personality) Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
194
frontend/src/app/[locale]/(authenticated)/agents/[id]/page.tsx
Normal file
194
frontend/src/app/[locale]/(authenticated)/agents/[id]/page.tsx
Normal file
@@ -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<ViewMode>(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 (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<AgentTypeDetail
|
||||
agentType={null}
|
||||
onBack={handleBack}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDeactivate={handleDeactivate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render based on view mode
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{(viewMode === 'create' || viewMode === 'edit') && (
|
||||
<AgentTypeForm
|
||||
agentType={viewMode === 'edit' ? agentType ?? undefined : undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
isSubmitting={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'detail' && (
|
||||
<AgentTypeDetail
|
||||
agentType={agentType ?? null}
|
||||
isLoading={isLoading}
|
||||
onBack={handleBack}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDeactivate={handleDeactivate}
|
||||
isDeactivating={deactivateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
frontend/src/app/[locale]/(authenticated)/agents/page.tsx
Normal file
95
frontend/src/app/[locale]/(authenticated)/agents/page.tsx
Normal file
@@ -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 (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<AgentTypeList
|
||||
agentTypes={filteredAgentTypes}
|
||||
isLoading={isLoading}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={handleStatusFilterChange}
|
||||
onSelect={handleSelect}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
416
frontend/src/components/agents/AgentTypeDetail.tsx
Normal file
416
frontend/src/components/agents/AgentTypeDetail.tsx
Normal file
@@ -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 (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" variant="outline">
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" variant="outline">
|
||||
Inactive
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for agent type detail
|
||||
*/
|
||||
function AgentTypeDetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-10 w-10" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-2 h-4 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model display name
|
||||
*/
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
const modelNames: Record<string, string> = {
|
||||
'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 <AgentTypeDetailSkeleton />;
|
||||
}
|
||||
|
||||
if (!agentType) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 font-semibold">Agent type not found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The requested agent type could not be found
|
||||
</p>
|
||||
<Button onClick={onBack} variant="outline" className="mt-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modelParams = agentType.model_params as {
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Go back</span>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{agentType.name}</h1>
|
||||
<AgentTypeStatusBadge isActive={agentType.is_active} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Last modified:{' '}
|
||||
{new Date(agentType.updated_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onDuplicate}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEdit}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Description Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Description
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{agentType.description || 'No description provided'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expertise Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Expertise Areas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agentType.expertise.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agentType.expertise.map((skill) => (
|
||||
<Badge key={skill} variant="secondary">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No expertise areas defined</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Personality Prompt Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Personality Prompt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="whitespace-pre-wrap rounded-lg bg-muted p-4 text-sm">
|
||||
{agentType.personality_prompt}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MCP Permissions Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
MCP Permissions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Model Context Protocol servers this agent can access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{AVAILABLE_MCP_SERVERS.map((server) => {
|
||||
const isEnabled = agentType.mcp_servers.includes(server.id);
|
||||
return (
|
||||
<div
|
||||
key={server.id}
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isEnabled
|
||||
? 'border-primary/20 bg-primary/5'
|
||||
: 'border-muted bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${
|
||||
isEnabled ? 'bg-primary/10' : 'bg-muted'
|
||||
}`}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{server.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{server.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isEnabled ? 'default' : 'secondary'}>
|
||||
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Model Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Cpu className="h-5 w-5" />
|
||||
Model Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Primary Model</p>
|
||||
<p className="font-medium">
|
||||
{getModelDisplayName(agentType.primary_model)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Failover Model</p>
|
||||
<p className="font-medium">
|
||||
{agentType.fallback_models.length > 0
|
||||
? getModelDisplayName(agentType.fallback_models[0])
|
||||
: 'None configured'}
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Temperature</span>
|
||||
<span className="font-medium">{modelParams.temperature ?? 0.7}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Max Tokens</span>
|
||||
<span className="font-medium">
|
||||
{(modelParams.max_tokens ?? 8192).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Top P</span>
|
||||
<span className="font-medium">{modelParams.top_p ?? 0.95}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Instance Stats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Bot className="h-5 w-5" />
|
||||
Instances
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-primary">
|
||||
{agentType.instance_count}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Active instances</p>
|
||||
</div>
|
||||
<Button variant="outline" className="mt-4 w-full" size="sm" disabled>
|
||||
View Instances
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={isDeactivating}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{agentType.is_active ? 'Deactivate Type' : 'Delete Type'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onDeactivate}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{agentType.is_active ? 'Deactivate' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
554
frontend/src/components/agents/AgentTypeForm.tsx
Normal file
554
frontend/src/components/agents/AgentTypeForm.tsx
Normal file
@@ -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<AgentTypeCreateFormValues>({
|
||||
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 (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCancel}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Go back</span>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{isEditing ? 'Edit Agent Type' : 'Create Agent Type'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{isEditing
|
||||
? 'Modify agent type configuration'
|
||||
: 'Define a new agent type template'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : isEditing ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabbed Form */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="basic">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Basic Info
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="model">
|
||||
<Cpu className="mr-2 h-4 w-4" />
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="permissions">
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Permissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="personality">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Personality
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Basic Info Tab */}
|
||||
<TabsContent value="basic" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>
|
||||
Define the agent type name, description, and expertise areas
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Software Architect"
|
||||
{...register('name')}
|
||||
aria-invalid={!!errors.name}
|
||||
aria-describedby={errors.name ? 'name-error' : undefined}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p id="name-error" className="text-sm text-destructive" role="alert">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">
|
||||
Slug <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="e.g., software-architect"
|
||||
{...register('slug')}
|
||||
aria-invalid={!!errors.slug}
|
||||
aria-describedby={errors.slug ? 'slug-error' : undefined}
|
||||
/>
|
||||
{errors.slug && (
|
||||
<p id="slug-error" className="text-sm text-destructive" role="alert">
|
||||
{errors.slug.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-friendly identifier (auto-generated from name)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Controller
|
||||
name="is_active"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value ? 'active' : 'inactive'}
|
||||
onValueChange={(val) => field.onChange(val === 'active')}
|
||||
>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive / Draft</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Describe what this agent type does..."
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
aria-invalid={!!errors.description}
|
||||
aria-describedby={errors.description ? 'description-error' : undefined}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p id="description-error" className="text-sm text-destructive" role="alert">
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Expertise Areas</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add skills and areas of expertise
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g., System Design"
|
||||
value={expertiseInput}
|
||||
onChange={(e) => setExpertiseInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddExpertise();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleAddExpertise}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{watchExpertise.map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="gap-1">
|
||||
{skill}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded-full hover:bg-muted"
|
||||
onClick={() => handleRemoveExpertise(skill)}
|
||||
aria-label={`Remove ${skill}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Model Configuration Tab */}
|
||||
<TabsContent value="model" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Model Selection</CardTitle>
|
||||
<CardDescription>
|
||||
Choose the AI models that power this agent type
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primary_model">
|
||||
Primary Model <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Controller
|
||||
name="primary_model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger id="primary_model">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((model) => (
|
||||
<SelectItem key={model.value} value={model.value}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.primary_model && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{errors.primary_model.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Main model used for this agent
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fallback_model">Fallover Model</Label>
|
||||
<Controller
|
||||
name="fallback_models"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value?.[0] || ''}
|
||||
onValueChange={(val) => field.onChange([val])}
|
||||
>
|
||||
<SelectTrigger id="fallback_model">
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AVAILABLE_MODELS.map((model) => (
|
||||
<SelectItem key={model.value} value={model.value}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Backup model if primary is unavailable
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sliders className="h-5 w-5" />
|
||||
Model Parameters
|
||||
</CardTitle>
|
||||
<CardDescription>Fine-tune the model behavior</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="temperature">Temperature</Label>
|
||||
<Controller
|
||||
name="model_params.temperature"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="temperature"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={field.value ?? 0.7}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
0 = deterministic, 2 = creative
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_tokens">Max Tokens</Label>
|
||||
<Controller
|
||||
name="model_params.max_tokens"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="max_tokens"
|
||||
type="number"
|
||||
step="1024"
|
||||
min="1024"
|
||||
max="128000"
|
||||
value={field.value ?? 8192}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Maximum response length</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="top_p">Top P</Label>
|
||||
<Controller
|
||||
name="model_params.top_p"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="top_p"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min="0"
|
||||
max="1"
|
||||
value={field.value ?? 0.95}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Nucleus sampling threshold</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* MCP Permissions Tab */}
|
||||
<TabsContent value="permissions" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP Server Permissions</CardTitle>
|
||||
<CardDescription>
|
||||
Configure which MCP servers this agent can access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{AVAILABLE_MCP_SERVERS.map((server) => (
|
||||
<div key={server.id} className="rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`mcp-${server.id}`}
|
||||
checked={watchMcpServers.includes(server.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleMcpServerToggle(server.id, checked === true)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={`mcp-${server.id}`} className="font-medium">
|
||||
{server.name}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">{server.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Personality Prompt Tab */}
|
||||
<TabsContent value="personality" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personality Prompt</CardTitle>
|
||||
<CardDescription>
|
||||
Define the agent's personality, behavior, and communication style. This
|
||||
prompt shapes how the agent approaches tasks and interacts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
id="personality_prompt"
|
||||
placeholder="You are a..."
|
||||
rows={15}
|
||||
className="font-mono text-sm"
|
||||
{...register('personality_prompt')}
|
||||
aria-invalid={!!errors.personality_prompt}
|
||||
aria-describedby={
|
||||
errors.personality_prompt ? 'personality_prompt-error' : undefined
|
||||
}
|
||||
/>
|
||||
{errors.personality_prompt && (
|
||||
<p
|
||||
id="personality_prompt-error"
|
||||
className="text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{errors.personality_prompt.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Character count: {watch('personality_prompt')?.length || 0}
|
||||
</span>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-xs">
|
||||
Tip: Be specific about expertise, communication style, and decision-making
|
||||
approach
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
248
frontend/src/components/agents/AgentTypeList.tsx
Normal file
248
frontend/src/components/agents/AgentTypeList.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* AgentTypeList Component
|
||||
*
|
||||
* Displays a grid of agent type cards with search and filter functionality.
|
||||
* Used on the main agent types page for browsing and selecting agent types.
|
||||
*/
|
||||
|
||||
'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 { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Bot, Plus, Search, Cpu } from 'lucide-react';
|
||||
import type { AgentTypeResponse } from '@/lib/api/types/agentTypes';
|
||||
|
||||
interface AgentTypeListProps {
|
||||
agentTypes: AgentTypeResponse[];
|
||||
isLoading?: boolean;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
statusFilter: string;
|
||||
onStatusFilterChange: (status: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge component for agent types
|
||||
*/
|
||||
function AgentTypeStatusBadge({ isActive }: { isActive: boolean }) {
|
||||
if (isActive) {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" variant="outline">
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" variant="outline">
|
||||
Inactive
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading skeleton for agent type cards
|
||||
*/
|
||||
function AgentTypeCardSkeleton() {
|
||||
return (
|
||||
<Card className="h-[200px]">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</div>
|
||||
<Skeleton className="mt-3 h-6 w-3/4" />
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
<Skeleton className="h-5 w-14" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model display name from model ID
|
||||
*/
|
||||
function getModelDisplayName(modelId: string): string {
|
||||
const parts = modelId.split('-');
|
||||
if (parts.length >= 2) {
|
||||
return parts.slice(0, 2).join(' ').replace('claude', 'Claude');
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
export function AgentTypeList({
|
||||
agentTypes,
|
||||
isLoading = false,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
className,
|
||||
}: AgentTypeListProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Agent Types</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure templates for spawning AI agent instances
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Agent Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row">
|
||||
<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 types..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
aria-label="Search agent types"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
|
||||
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<AgentTypeCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Type Grid */}
|
||||
{!isLoading && agentTypes.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{agentTypes.map((type) => (
|
||||
<Card
|
||||
key={type.id}
|
||||
className="cursor-pointer transition-all hover:border-primary hover:shadow-md"
|
||||
onClick={() => onSelect(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(type.id);
|
||||
}
|
||||
}}
|
||||
aria-label={`View ${type.name} agent type`}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<AgentTypeStatusBadge isActive={type.is_active} />
|
||||
</div>
|
||||
<CardTitle className="mt-3">{type.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{type.description || 'No description provided'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Expertise tags */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{type.expertise.slice(0, 3).map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="text-xs">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
{type.expertise.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{type.expertise.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
{type.expertise.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">No expertise defined</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{getModelDisplayName(type.primary_model)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">{type.instance_count} instances</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && agentTypes.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<Bot className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 font-semibold">No agent types found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Create your first agent type to get started'}
|
||||
</p>
|
||||
{!searchQuery && statusFilter === 'all' && (
|
||||
<Button onClick={onCreate} className="mt-4">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Agent Type
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/components/agents/index.ts
Normal file
9
frontend/src/components/agents/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Agent Components
|
||||
*
|
||||
* Components for managing agent types and agent instances.
|
||||
*/
|
||||
|
||||
export { AgentTypeForm } from './AgentTypeForm';
|
||||
export { AgentTypeList } from './AgentTypeList';
|
||||
export { AgentTypeDetail } from './AgentTypeDetail';
|
||||
@@ -3,3 +3,6 @@
|
||||
|
||||
// Authentication hooks
|
||||
export * from './useAuth';
|
||||
|
||||
// Agent Types hooks
|
||||
export * from './useAgentTypes';
|
||||
|
||||
220
frontend/src/lib/api/hooks/useAgentTypes.ts
Normal file
220
frontend/src/lib/api/hooks/useAgentTypes.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Agent Types Hooks
|
||||
*
|
||||
* TanStack Query hooks for managing agent type operations.
|
||||
* Provides data fetching and mutations for the agent-types API.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import type {
|
||||
AgentTypeCreate,
|
||||
AgentTypeUpdate,
|
||||
AgentTypeResponse,
|
||||
AgentTypeListResponse,
|
||||
AgentTypeListParams,
|
||||
} from '@/lib/api/types/agentTypes';
|
||||
|
||||
/**
|
||||
* Query keys for agent types
|
||||
*/
|
||||
export const agentTypeKeys = {
|
||||
all: ['agent-types'] as const,
|
||||
lists: () => [...agentTypeKeys.all, 'list'] as const,
|
||||
list: (params: AgentTypeListParams) => [...agentTypeKeys.lists(), params] as const,
|
||||
details: () => [...agentTypeKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...agentTypeKeys.details(), id] as const,
|
||||
bySlug: (slug: string) => [...agentTypeKeys.all, 'slug', slug] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default page limit for listing agent types
|
||||
*/
|
||||
const DEFAULT_PAGE_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Hook to fetch paginated list of agent types
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Query result with agent types list
|
||||
*/
|
||||
export function useAgentTypes(params: AgentTypeListParams = {}) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = DEFAULT_PAGE_LIMIT,
|
||||
is_active = true,
|
||||
search,
|
||||
} = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.list({ page, limit, is_active, search }),
|
||||
queryFn: async (): Promise<AgentTypeListResponse> => {
|
||||
const response = await apiClient.instance.get('/api/v1/agent-types', {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
is_active,
|
||||
...(search ? { search } : {}),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single agent type by ID
|
||||
*
|
||||
* @param id - Agent type UUID
|
||||
* @returns Query result with agent type details
|
||||
*/
|
||||
export function useAgentType(id: string | null) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.detail(id ?? ''),
|
||||
queryFn: async (): Promise<AgentTypeResponse> => {
|
||||
if (!id) throw new Error('Agent type ID is required');
|
||||
const response = await apiClient.instance.get(`/api/v1/agent-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!user && !!id,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch an agent type by slug
|
||||
*
|
||||
* @param slug - Agent type slug
|
||||
* @returns Query result with agent type details
|
||||
*/
|
||||
export function useAgentTypeBySlug(slug: string | null) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.bySlug(slug ?? ''),
|
||||
queryFn: async (): Promise<AgentTypeResponse> => {
|
||||
if (!slug) throw new Error('Agent type slug is required');
|
||||
const response = await apiClient.instance.get(`/api/v1/agent-types/slug/${slug}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!user && !!slug,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a new agent type
|
||||
*
|
||||
* @returns Mutation for creating agent types
|
||||
*/
|
||||
export function useCreateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: AgentTypeCreate): Promise<AgentTypeResponse> => {
|
||||
const response = await apiClient.instance.post('/api/v1/agent-types', data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate all agent type lists to refetch
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update an existing agent type
|
||||
*
|
||||
* @returns Mutation for updating agent types
|
||||
*/
|
||||
export function useUpdateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: AgentTypeUpdate;
|
||||
}): Promise<AgentTypeResponse> => {
|
||||
const response = await apiClient.instance.patch(`/api/v1/agent-types/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (updatedAgentType) => {
|
||||
// Update the cache for this specific agent type
|
||||
queryClient.setQueryData(
|
||||
agentTypeKeys.detail(updatedAgentType.id),
|
||||
updatedAgentType
|
||||
);
|
||||
// Invalidate lists to reflect changes
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to deactivate (soft delete) an agent type
|
||||
*
|
||||
* @returns Mutation for deactivating agent types
|
||||
*/
|
||||
export function useDeactivateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.instance.delete(`/api/v1/agent-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
// Invalidate all agent type queries
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.all });
|
||||
// Remove specific agent type from cache
|
||||
queryClient.removeQueries({ queryKey: agentTypeKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to duplicate an agent type
|
||||
*
|
||||
* @returns Mutation for duplicating agent types
|
||||
*/
|
||||
export function useDuplicateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (agentType: AgentTypeResponse): Promise<AgentTypeResponse> => {
|
||||
// Create a new agent type based on the existing one
|
||||
const newAgentType: AgentTypeCreate = {
|
||||
name: `${agentType.name} (Copy)`,
|
||||
slug: `${agentType.slug}-copy`,
|
||||
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 },
|
||||
mcp_servers: [...agentType.mcp_servers],
|
||||
tool_permissions: { ...agentType.tool_permissions },
|
||||
is_active: false, // Start as inactive/draft
|
||||
};
|
||||
|
||||
const response = await apiClient.instance.post('/api/v1/agent-types', newAgentType);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate lists to show the new duplicate
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
137
frontend/src/lib/api/types/agentTypes.ts
Normal file
137
frontend/src/lib/api/types/agentTypes.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* AgentType API Types
|
||||
*
|
||||
* These types mirror the backend Pydantic schemas for AgentType entities.
|
||||
* Used for type-safe API communication with the agent-types endpoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base agent type fields shared across create, update, and response schemas
|
||||
*/
|
||||
export interface AgentTypeBase {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
expertise: string[];
|
||||
personality_prompt: string;
|
||||
primary_model: string;
|
||||
fallback_models: string[];
|
||||
model_params: Record<string, unknown>;
|
||||
mcp_servers: string[];
|
||||
tool_permissions: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for creating a new agent type
|
||||
*/
|
||||
export interface AgentTypeCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
expertise?: string[];
|
||||
personality_prompt: string;
|
||||
primary_model: string;
|
||||
fallback_models?: string[];
|
||||
model_params?: Record<string, unknown>;
|
||||
mcp_servers?: string[];
|
||||
tool_permissions?: Record<string, unknown>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for updating an existing agent type
|
||||
*/
|
||||
export interface AgentTypeUpdate {
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
expertise?: string[] | null;
|
||||
personality_prompt?: string | null;
|
||||
primary_model?: string | null;
|
||||
fallback_models?: string[] | null;
|
||||
model_params?: Record<string, unknown> | null;
|
||||
mcp_servers?: string[] | null;
|
||||
tool_permissions?: Record<string, unknown> | null;
|
||||
is_active?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for agent type API responses
|
||||
*/
|
||||
export interface AgentTypeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
expertise: string[];
|
||||
personality_prompt: string;
|
||||
primary_model: string;
|
||||
fallback_models: string[];
|
||||
model_params: Record<string, unknown>;
|
||||
mcp_servers: string[];
|
||||
tool_permissions: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata for list responses
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for paginated agent type list responses
|
||||
*/
|
||||
export interface AgentTypeListResponse {
|
||||
data: AgentTypeResponse[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for listing agent types
|
||||
*/
|
||||
export interface AgentTypeListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model parameter configuration with typed fields
|
||||
*/
|
||||
export interface ModelParams {
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP permission scope configuration
|
||||
*/
|
||||
export interface McpPermission {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool permission entry
|
||||
*/
|
||||
export interface ToolPermission {
|
||||
tool_id: string;
|
||||
enabled: boolean;
|
||||
scopes?: string[];
|
||||
}
|
||||
8
frontend/src/lib/api/types/index.ts
Normal file
8
frontend/src/lib/api/types/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API Types
|
||||
*
|
||||
* Custom types for API entities that may not be in the generated client.
|
||||
* These are typically used for Syndarix-specific features.
|
||||
*/
|
||||
|
||||
export * from './agentTypes';
|
||||
@@ -4,4 +4,5 @@
|
||||
* @module lib/hooks
|
||||
*/
|
||||
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';
|
||||
|
||||
46
frontend/src/lib/hooks/useDebounce.ts
Normal file
46
frontend/src/lib/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* useDebounce Hook
|
||||
*
|
||||
* Debounces a value by a specified delay.
|
||||
* Useful for search inputs and other user input that triggers API calls.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that debounces a value
|
||||
*
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds
|
||||
* @returns The debounced value
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [searchQuery, setSearchQuery] = useState('');
|
||||
* const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
*
|
||||
* // Use debouncedSearch for API calls
|
||||
* useEffect(() => {
|
||||
* fetchResults(debouncedSearch);
|
||||
* }, [debouncedSearch]);
|
||||
* ```
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up the timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Clean up on value change or unmount
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
155
frontend/src/lib/validations/agentType.ts
Normal file
155
frontend/src/lib/validations/agentType.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Agent Type Form Validation Schemas
|
||||
*
|
||||
* Zod schemas for validating agent type form data.
|
||||
* Used with react-hook-form for form validation.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Slug validation regex: lowercase letters, numbers, and hyphens only
|
||||
*/
|
||||
const slugRegex = /^[a-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* Available AI models for agent types
|
||||
*/
|
||||
export const AVAILABLE_MODELS = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Available MCP servers for agent permissions
|
||||
*/
|
||||
export const AVAILABLE_MCP_SERVERS = [
|
||||
{ id: 'gitea', name: 'Gitea', description: 'Git repository management' },
|
||||
{ id: 'knowledge', name: 'Knowledge Base', description: 'Vector database for RAG' },
|
||||
{ id: 'filesystem', name: 'Filesystem', description: 'File read/write operations' },
|
||||
{ id: 'slack', name: 'Slack', description: 'Team communication' },
|
||||
{ id: 'browser', name: 'Browser', description: 'Web browsing and scraping' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Agent type status options
|
||||
*/
|
||||
export const AGENT_TYPE_STATUS = [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Model params schema
|
||||
*/
|
||||
const modelParamsSchema = z.object({
|
||||
temperature: z.number().min(0).max(2),
|
||||
max_tokens: z.number().int().min(1024).max(128000),
|
||||
top_p: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for agent type form fields
|
||||
*/
|
||||
export const agentTypeFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(255, 'Name must be less than 255 characters'),
|
||||
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.max(255, 'Slug must be less than 255 characters')
|
||||
.regex(slugRegex, 'Slug must contain only lowercase letters, numbers, and hyphens')
|
||||
.refine((val) => !val.startsWith('-') && !val.endsWith('-'), {
|
||||
message: 'Slug cannot start or end with a hyphen',
|
||||
})
|
||||
.refine((val) => !val.includes('--'), {
|
||||
message: 'Slug cannot contain consecutive hyphens',
|
||||
}),
|
||||
|
||||
description: z
|
||||
.string()
|
||||
.max(2000, 'Description must be less than 2000 characters')
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
expertise: z.array(z.string()),
|
||||
|
||||
personality_prompt: z
|
||||
.string()
|
||||
.min(1, 'Personality prompt is required')
|
||||
.max(10000, 'Personality prompt must be less than 10000 characters'),
|
||||
|
||||
primary_model: z.string().min(1, 'Primary model is required'),
|
||||
|
||||
fallback_models: z.array(z.string()),
|
||||
|
||||
model_params: modelParamsSchema,
|
||||
|
||||
mcp_servers: z.array(z.string()),
|
||||
|
||||
tool_permissions: z.record(z.string(), z.unknown()),
|
||||
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a new agent type (alias for backward compatibility)
|
||||
*/
|
||||
export const agentTypeCreateSchema = agentTypeFormSchema;
|
||||
|
||||
/**
|
||||
* Schema for updating an existing agent type
|
||||
* All fields are optional since we support partial updates
|
||||
*/
|
||||
export const agentTypeUpdateSchema = agentTypeFormSchema.partial();
|
||||
|
||||
/**
|
||||
* Type for agent type create form values
|
||||
*/
|
||||
export type AgentTypeCreateFormValues = z.infer<typeof agentTypeCreateSchema>;
|
||||
|
||||
/**
|
||||
* Type for agent type update form values
|
||||
*/
|
||||
export type AgentTypeUpdateFormValues = z.infer<typeof agentTypeUpdateSchema>;
|
||||
|
||||
/**
|
||||
* Default values for creating a new agent type
|
||||
*/
|
||||
export const defaultAgentTypeValues: AgentTypeCreateFormValues = {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: null,
|
||||
expertise: [],
|
||||
personality_prompt: '',
|
||||
primary_model: 'claude-opus-4-5-20251101',
|
||||
fallback_models: ['claude-sonnet-4-20250514'],
|
||||
model_params: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
top_p: 0.95,
|
||||
},
|
||||
mcp_servers: [],
|
||||
tool_permissions: {},
|
||||
is_active: false, // Start as draft
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
*
|
||||
* @param name - The name to convert to a slug
|
||||
* @returns A valid slug string
|
||||
*/
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
}
|
||||
Reference in New Issue
Block a user