Files
leocrm/frontend/src/pages/AgentDashboard.tsx
T

831 lines
27 KiB
TypeScript
Raw Normal View History

2026-07-23 20:00:37 +02:00
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useAgents,
useCreateAgent,
useUpdateAgent,
useDeleteAgent,
useExecuteAgent,
useTestRunAgent,
useAgentRuns,
useAgentVersions,
useRestoreAgentVersion,
useAgentTools,
useSendAgentMessage,
} from '@/api/automation';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
import type { AgentDefinition } from '@/types/automation';
import {
Plus,
Play,
TestTube,
History,
GitBranch,
Trash2,
Settings2,
Bot,
AlertCircle,
Loader2,
Brain,
Activity,
Clock,
DollarSign,
MessageCircle,
Send,
} from 'lucide-react';
const modeOptions = [
{ value: 'reactive', label: 'Reactive' },
{ value: 'proactive', label: 'Proactive' },
];
const commonModels = [
'gpt-4',
'gpt-4-turbo',
'gpt-3.5-turbo',
'claude-3-opus',
'claude-3-sonnet',
'claude-3-haiku',
'llama-3-70b',
'llama-3-8b',
'mistral-large',
'mixtral-8x7b',
];
function statusBadgeVariant(status: string): 'success' | 'warning' | 'secondary' {
switch (status) {
case 'active': return 'success';
case 'inactive': return 'warning';
default: return 'secondary';
}
}
function runStatusBadgeVariant(status: string): 'success' | 'warning' | 'danger' | 'info' {
switch (status) {
case 'completed': return 'success';
case 'running': return 'info';
case 'failed': return 'danger';
case 'cancelled': return 'warning';
default: return 'warning';
}
}
interface AgentFormData {
name: string;
description: string;
model: string;
system_prompt: string;
tools: string[];
heartbeat_interval: number;
mode: 'proactive' | 'reactive';
max_executions_per_hour: number;
max_duration: number;
budget_limit: number;
active: boolean;
}
const emptyForm: AgentFormData = {
name: '',
description: '',
model: 'gpt-4',
system_prompt: '',
tools: [],
heartbeat_interval: 0,
mode: 'reactive',
max_executions_per_hour: 100,
max_duration: 300,
budget_limit: 0,
active: true,
};
function AgentForm({
initial,
onSave,
onCancel,
isSaving,
}: {
initial?: AgentDefinition;
onSave: (data: Partial<AgentDefinition>) => void;
onCancel: () => void;
isSaving: boolean;
}) {
const { t } = useTranslation();
const { data: availableTools } = useAgentTools();
const [form, setForm] = useState<AgentFormData>(() => {
if (initial) {
return {
name: initial.name,
description: initial.description || '',
model: initial.model,
system_prompt: initial.system_prompt || '',
tools: initial.tools || [],
heartbeat_interval: initial.heartbeat_interval,
mode: initial.mode,
max_executions_per_hour: initial.max_executions_per_hour,
max_duration: initial.max_duration,
budget_limit: initial.budget_limit,
active: initial.active,
};
}
return { ...emptyForm };
});
const updateField = <K extends keyof AgentFormData>(key: K, value: AgentFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const toggleTool = (toolName: string) => {
setForm((prev) => {
const tools = prev.tools.includes(toolName)
? prev.tools.filter((t) => t !== toolName)
: [...prev.tools, toolName];
return { ...prev, tools };
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
name: form.name,
description: form.description || undefined,
model: form.model,
system_prompt: form.system_prompt || undefined,
tools: form.tools,
heartbeat_interval: form.heartbeat_interval,
mode: form.mode,
max_executions_per_hour: form.max_executions_per_hour,
max_duration: form.max_duration,
budget_limit: form.budget_limit,
active: form.active,
});
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name & Description */}
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.name')} *
</label>
<input
type="text"
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
required
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="My Agent"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.description')}
</label>
<textarea
value={form.description}
onChange={(e) => updateField('description', e.target.value)}
rows={2}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Optional description"
/>
</div>
</div>
{/* Model */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.model')}
</label>
<div className="flex gap-2">
<input
type="text"
value={form.model}
onChange={(e) => updateField('model', e.target.value)}
list="model-suggestions"
className="flex-1 rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="gpt-4"
/>
<datalist id="model-suggestions">
{commonModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
{/* System Prompt */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.systemPrompt')}
</label>
<textarea
value={form.system_prompt}
onChange={(e) => updateField('system_prompt', e.target.value)}
rows={4}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="You are a helpful assistant..."
/>
</div>
{/* Mode */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.mode')}
</label>
<Select
options={modeOptions}
value={form.mode}
onChange={(e) => updateField('mode', e.target.value as 'proactive' | 'reactive')}
/>
</div>
{/* Tools */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('agent.tools')}
</label>
{!availableTools || availableTools.length === 0 ? (
<p className="text-sm text-secondary-400 italic">{t('agent.noToolsAvailable')}</p>
) : (
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto">
{availableTools.map((tool) => (
<label
key={tool.name}
className="flex items-center gap-2 p-2 rounded-md hover:bg-secondary-50 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={form.tools.includes(tool.name)}
onChange={() => toggleTool(tool.name)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="font-medium text-secondary-700">{tool.name}</span>
{tool.description && (
<p className="text-xs text-secondary-400">{tool.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Heartbeat */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.heartbeatInterval')}
</label>
<input
type="number"
value={form.heartbeat_interval}
onChange={(e) => updateField('heartbeat_interval', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-secondary-400 mt-1">{t('agent.heartbeatHint')}</p>
</div>
{/* Rate Limits */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.maxExecutions')}
</label>
<input
type="number"
value={form.max_executions_per_hour}
onChange={(e) => updateField('max_executions_per_hour', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.maxDuration')} (s)
</label>
<input
type="number"
value={form.max_duration}
onChange={(e) => updateField('max_duration', parseInt(e.target.value) || 0)}
min={0}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.budgetLimit')}
</label>
<input
type="number"
value={form.budget_limit}
onChange={(e) => updateField('budget_limit', parseFloat(e.target.value) || 0)}
min={0}
step={0.01}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
{/* Active Toggle */}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.active}
onChange={(e) => updateField('active', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
{t('agent.active')}
</label>
{/* Buttons */}
<div className="flex justify-end gap-3 pt-4 border-t border-secondary-200">
<Button variant="secondary" onClick={onCancel} type="button">
{t('common.cancel')}
</Button>
<Button type="submit" isLoading={isSaving}>
{initial ? t('common.save') : t('common.create')}
</Button>
</div>
</form>
);
}
function RunHistoryModal({
agentId,
open,
onClose,
}: {
agentId: string;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const { data: runs, isLoading } = useAgentRuns(agentId);
return (
<Modal open={open} onClose={onClose} title={t('agent.runHistory')} size="lg">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !runs || runs.length === 0 ? (
<EmptyState
title={t('agent.noRuns')}
description={t('agent.noRunsDesc')}
icon={<History className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{runs.map((run) => (
<div
key={run.id}
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant={runStatusBadgeVariant(run.status)}>
{run.status}
</Badge>
<span className="text-sm text-secondary-600">
{new Date(run.started_at).toLocaleString()}
</span>
</div>
{run.error && (
<span className="text-xs text-danger-600 max-w-xs truncate" title={run.error}>
{run.error}
</span>
)}
</div>
))}
</div>
)}
</Modal>
);
}
function VersionHistoryModal({
agentId,
open,
onClose,
}: {
agentId: string;
open: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
const toast = useToast();
const { data: versions, isLoading } = useAgentVersions(agentId);
const restoreMutation = useRestoreAgentVersion();
const handleRestore = async (versionId: string) => {
try {
await restoreMutation.mutateAsync({ id: agentId, versionId });
toast.success(t('agent.versionRestored'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
return (
<Modal open={open} onClose={onClose} title={t('agent.versionHistory')} size="lg">
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : !versions || versions.length === 0 ? (
<EmptyState
title={t('agent.noVersions')}
icon={<GitBranch className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{versions.map((ver) => (
<div
key={ver.id}
className="flex items-center justify-between p-3 bg-secondary-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant="primary">v{ver.version}</Badge>
<span className="text-sm text-secondary-600">
{new Date(ver.created_at).toLocaleString()}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRestore(ver.id)}
isLoading={restoreMutation.isPending}
>
{t('common.restore')}
</Button>
</div>
))}
</div>
)}
</Modal>
);
}
export function AgentDashboardPage() {
const { t } = useTranslation();
const toast = useToast();
const { data: agents, isLoading, isError, refetch } = useAgents();
const createMutation = useCreateAgent();
const updateMutation = useUpdateAgent();
const deleteMutation = useDeleteAgent();
const executeMutation = useExecuteAgent();
const testRunMutation = useTestRunAgent();
const sendMessageMutation = useSendAgentMessage();
const [showForm, setShowForm] = useState(false);
const [editingAgent, setEditingAgent] = useState<AgentDefinition | undefined>(undefined);
const [confirmDelete, setConfirmDelete] = useState<AgentDefinition | null>(null);
const [runHistoryId, setRunHistoryId] = useState<string | null>(null);
const [versionHistoryId, setVersionHistoryId] = useState<string | null>(null);
const [showAgentChat, setShowAgentChat] = useState(false);
const [chatTargetAgent, setChatTargetAgent] = useState('');
const [chatMessage, setChatMessage] = useState('');
const handleSave = async (data: Partial<AgentDefinition>) => {
try {
if (editingAgent) {
await updateMutation.mutateAsync({ id: editingAgent.id, data });
toast.success(t('agent.updated'));
} else {
await createMutation.mutateAsync(data);
toast.success(t('agent.created'));
}
setShowForm(false);
setEditingAgent(undefined);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleDelete = async () => {
if (!confirmDelete) return;
try {
await deleteMutation.mutateAsync(confirmDelete.id);
toast.success(t('agent.deleted'));
setConfirmDelete(null);
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleExecute = async (id: string) => {
try {
await executeMutation.mutateAsync(id);
toast.success(t('agent.executed'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleTestRun = async (id: string) => {
try {
await testRunMutation.mutateAsync(id);
toast.success(t('agent.testRunSuccess'));
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const handleSendMessage = async () => {
if (!chatTargetAgent || !chatMessage.trim()) return;
try {
await sendMessageMutation.mutateAsync({
id: chatTargetAgent,
toAgentName: chatTargetAgent,
message: chatMessage,
});
toast.success(t('agent.messageSent'));
setChatMessage('');
} catch (err: any) {
toast.error(err.message || t('common.error'));
}
};
const openEdit = (agent: AgentDefinition) => {
setEditingAgent(agent);
setShowForm(true);
};
const openCreate = () => {
setEditingAgent(undefined);
setShowForm(true);
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<div className="max-w-7xl mx-auto p-6" data-testid="agent-dashboard">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-secondary-900">{t('agent.title')}</h1>
<p className="text-sm text-secondary-500 mt-1">{t('agent.subtitle')}</p>
</div>
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('agent.create')}
</Button>
</div>
{/* Loading */}
{isLoading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{/* Error */}
{isError && (
<Card className="p-6">
<div className="flex items-center gap-3 text-danger-600">
<AlertCircle className="h-5 w-5" />
<span>{t('common.errorLoading')}</span>
<Button size="sm" variant="secondary" onClick={() => refetch()}>
{t('common.retry')}
</Button>
</div>
</Card>
)}
{/* Empty State */}
{!isLoading && !isError && (!agents || agents.length === 0) && (
<EmptyState
title={t('agent.noAgents')}
description={t('agent.noAgentsDesc')}
icon={<Bot className="h-8 w-8" />}
action={
<Button onClick={openCreate} icon={<Plus className="h-4 w-4" />}>
{t('agent.createFirst')}
</Button>
}
/>
)}
{/* Agent List */}
{!isLoading && !isError && agents && agents.length > 0 && (
<div className="space-y-4">
{agents.map((agent) => (
<Card key={agent.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-semibold text-secondary-900">{agent.name}</h3>
<Badge variant={statusBadgeVariant(agent.active ? 'active' : 'inactive')}>
{agent.active ? t('agent.active') : t('agent.inactive')}
</Badge>
<Badge variant={agent.mode === 'proactive' ? 'info' : 'secondary'}>
{agent.mode}
</Badge>
</div>
{agent.description && (
<p className="text-sm text-secondary-500 mb-2">{agent.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-secondary-400">
<span className="flex items-center gap-1">
<Brain className="h-3 w-3" />
{agent.model}
</span>
<span className="flex items-center gap-1">
<Activity className="h-3 w-3" />
{agent.tools?.length || 0} tools
</span>
{agent.heartbeat_interval > 0 && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{agent.heartbeat_interval}s
</span>
)}
{agent.budget_limit > 0 && (
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{agent.budget_limit}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
size="sm"
variant="ghost"
onClick={() => handleExecute(agent.id)}
isLoading={executeMutation.isPending}
title={t('agent.execute')}
>
<Play className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleTestRun(agent.id)}
isLoading={testRunMutation.isPending}
title={t('agent.testRun')}
>
<TestTube className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setRunHistoryId(agent.id)}
title={t('agent.runHistory')}
>
<History className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setVersionHistoryId(agent.id)}
title={t('agent.versionHistory')}
>
<GitBranch className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => openEdit(agent)}
title={t('common.edit')}
>
<Settings2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDelete(agent)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4 text-danger-500" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
{/* Create/Edit Modal */}
<Modal
open={showForm}
onClose={() => { setShowForm(false); setEditingAgent(undefined); }}
title={editingAgent ? t('agent.edit') : t('agent.create')}
size="xl"
>
<AgentForm
initial={editingAgent}
onSave={handleSave}
onCancel={() => { setShowForm(false); setEditingAgent(undefined); }}
isSaving={isSaving}
/>
</Modal>
{/* Run History Modal */}
{runHistoryId && (
<RunHistoryModal
agentId={runHistoryId}
open={!!runHistoryId}
onClose={() => setRunHistoryId(null)}
/>
)}
{/* Version History Modal */}
{versionHistoryId && (
<VersionHistoryModal
agentId={versionHistoryId}
open={!!versionHistoryId}
onClose={() => setVersionHistoryId(null)}
/>
)}
{/* Delete Confirmation */}
<ConfirmDialog
open={!!confirmDelete}
onCancel={() => setConfirmDelete(null)}
onConfirm={handleDelete}
title={t('agent.deleteConfirmTitle')}
message={t('agent.deleteConfirmMessage', { name: confirmDelete?.name })}
confirmLabel={t('common.delete')}
variant="danger"
/>
{/* Agent Chat Section */}
<div className="mt-8 border-t border-secondary-200 pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-bold text-secondary-900">{t('agent.agentChat')}</h2>
<p className="text-sm text-secondary-500 mt-1">{t('agent.agentChatSubtitle')}</p>
</div>
<Button
variant={showAgentChat ? 'secondary' : 'primary'}
onClick={() => setShowAgentChat(!showAgentChat)}
icon={<MessageCircle className="h-4 w-4" />}
>
{showAgentChat ? t('common.hide') : t('agent.openChat')}
</Button>
</div>
{showAgentChat && (
<Card className="p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.targetAgent')}
</label>
<select
value={chatTargetAgent}
onChange={(e) => setChatTargetAgent(e.target.value)}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('agent.selectTargetAgent')}</option>
{agents?.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('agent.message')}
</label>
<textarea
value={chatMessage}
onChange={(e) => setChatMessage(e.target.value)}
rows={3}
className="w-full rounded-lg border border-secondary-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder={t('agent.messagePlaceholder')}
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSendMessage}
isLoading={sendMessageMutation.isPending}
disabled={!chatTargetAgent || !chatMessage.trim()}
icon={<Send className="h-4 w-4" />}
>
{t('agent.sendMessage')}
</Button>
</div>
</div>
</Card>
)}
</div>
</div>
);
}