831 lines
27 KiB
TypeScript
831 lines
27 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|