Phase 3.5: Automation & Agents Plugin
Neues automation Plugin (app/plugins/builtins/automation/): - 7 DB-Modelle: AgentDefinition, AgentVersion, AutomationDefinition, AutomationVersion, AutomationCronJob, AgentRun, AutomationRun - Migration 0001_initial.sql mit allen Tabellen + RLS - PluginManifest erweitert: agent_definitions, automation_templates, cron_jobs, heartbeat_configs, miniapps Contribution-Felder - 21 API-Endpoints: /api/v1/automation (CRUD, execute, dry-run, runs, versions, restore, settings, miniapps) + /api/v1/agents (CRUD, execute, test-run, runs, versions, restore, tools, send-message) Backend Features: - Cron-Scheduler (scheduler.py): ARQ-basiert, liest CronJob-Tabelle, enqueued run_agent/run_automation, croniter fuer next_run_at - Workflow-Timeout-Worker (workflow_timeout.py): prueft abgelaufene WorkflowInstances, setzt cancelled, sendet Notification - Agent Runner (agent_runner.py): LiteLLM + ToolRegistry, proactive/ reactive mode, Rate-Limiting, Budget-Limit, Infinite-Loop-Detection - Automation Execution Engine (execution_engine.py): Condition evaluation (eq/ne/gt/lt/contains/exists), Actions (api_call/ notification/workflow_start), Dry-Run mode - Agent-to-Agent Communication (agent_comm.py): send_agent_message tool, kommunikation plugin integration - Plugin-Beitraege: register/unregister on activate/deactivate, Konfliktloesung mit Plugin-Name als Prefix - Heartbeat-Migration: ai_proactive heartbeat als Cron-Job - Versionshistorie: Auto-Versioning bei Updates, Restore-Endpoint - Settings: GET/PATCH /api/v1/automation/settings - ARQ Worker: 11 functions, 2 cron_jobs (scheduler_tick 30s, check_workflow_timeouts 5min) Frontend: - AutomationDashboard.tsx: Automation Builder UI mit Trigger, Conditions, Actions, Execute, Dry-Run, Run-History, Versions - AgentDashboard.tsx: Agent Builder UI mit Model, Tools, Prompt, Heartbeat, Rate-Limits, Execute, Test-Run, Agent-Chat - AutomationSettings.tsx: Settings + MiniApp-Builder - automation.ts: 24 React Query Hooks - automation.ts types: TypeScript Interfaces - routes/index.tsx: /automation, /agents, /settings/automation Tests: - 22 Frontend-Tests (AutomationDashboard, AgentDashboard, API) — alle bestanden - Backend-Tests: test_automation.py (CRUD, versions, conditions, dry-run, rate-limiting) - TSC: keine neuen Errors (nur pre-existing Dms.tsx) - croniter dependency installiert
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user