feat: AI Agents management page with per-agent performance tracking
New Agents tab in the sidebar (Ops group) for viewing, editing, and
creating AI agent configurations:
Database (migration 026):
- ai_agents table: editable configs for each LLM agent (model, prompts,
temperature, tokens, retries). source='system' for built-in,
source='user' for custom. Seeds 3 system agents (Document Extractor,
Event Classifier, Thesis Rewriter) using WHERE NOT EXISTS to never
overwrite user edits across reinstalls.
- agent_performance_log table: per-invocation metrics (duration,
confidence, retries, tokens, errors) linked to agent config.
API endpoints:
- GET/POST /api/agents — list and create agents
- GET/PUT/DELETE /api/agents/{id} — view, edit, delete (system agents
can be edited but not deleted)
- GET /api/agents/{id}/performance — aggregated metrics (success rate,
avg/p95 latency, confidence, token usage)
- GET /api/agents/{id}/performance/history — hourly time series
Frontend:
- AgentsPage with sidebar list + detail panel
- Agent detail: config display, system prompt viewer, performance
dashboard with metrics cards and time-series chart
- Edit form: all config fields editable including system prompt,
model, temperature, tokens, retries
- Create form: new user-defined agents with auto-slug generation
- System agents show blue badge, user agents show green badge
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
List,
|
||||
Globe,
|
||||
BarChart3,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
@@ -42,6 +43,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={18} />, group: 'Ops' },
|
||||
{ to: '/agents', label: 'Agents', icon: <Bot size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={18} />, group: 'Ops' },
|
||||
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={18} />, group: 'Analytics' },
|
||||
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={18} />, group: 'Analytics' },
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api/client';
|
||||
import { Card, LoadingSpinner, StatusBadge } from '../components/ui';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
purpose: string;
|
||||
model_provider: string;
|
||||
model_name: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
active: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentPerformance {
|
||||
total_invocations: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
avg_duration_ms: number | null;
|
||||
p95_duration_ms: number | null;
|
||||
avg_confidence: number | null;
|
||||
avg_retries: number | null;
|
||||
total_input_tokens: number | null;
|
||||
total_output_tokens: number | null;
|
||||
success_rate: number | null;
|
||||
}
|
||||
|
||||
interface PerfHistoryPoint {
|
||||
hour: string;
|
||||
invocations: number;
|
||||
successes: number;
|
||||
avg_duration_ms: number;
|
||||
avg_confidence: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useAgents() {
|
||||
return useQuery<Agent[]>({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiGet<Agent[]>('query', '/api/agents'),
|
||||
});
|
||||
}
|
||||
|
||||
function useAgentPerformance(agentId: string | undefined, hours = 24) {
|
||||
return useQuery<AgentPerformance>({
|
||||
queryKey: ['agent-performance', agentId, hours],
|
||||
queryFn: () => apiGet<AgentPerformance>('query', `/api/agents/${agentId}/performance?hours=${hours}`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
function useAgentPerfHistory(agentId: string | undefined, hours = 24) {
|
||||
return useQuery<PerfHistoryPoint[]>({
|
||||
queryKey: ['agent-perf-history', agentId, hours],
|
||||
queryFn: () => apiGet<PerfHistoryPoint[]>('query', `/api/agents/${agentId}/performance/history?hours=${hours}`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AgentsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: agents, isLoading } = useAgents();
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selected = agents?.find((a) => a.id === selectedId);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-[calc(100vh-3rem)]">
|
||||
{/* Agent List Sidebar */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-500">Agents</h2>
|
||||
<button
|
||||
onClick={() => { setCreating(true); setSelectedId(undefined); setEditing(false); }}
|
||||
className="rounded bg-brand-600 px-2 py-0.5 text-[10px] font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
{(agents ?? []).map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => { setSelectedId(agent.id); setEditing(false); setCreating(false); }}
|
||||
className={`w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
selectedId === agent.id
|
||||
? 'bg-brand-600/20 border border-brand-500/50 text-brand-300'
|
||||
: 'text-gray-300 hover:bg-surface-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium truncate">{agent.name}</span>
|
||||
{!agent.active && <span className="text-[9px] text-gray-600">OFF</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-gray-500">{agent.model_name}</span>
|
||||
<span className={`rounded px-1 py-0 text-[9px] ${agent.source === 'system' ? 'bg-blue-900/30 text-blue-400' : 'bg-emerald-900/30 text-emerald-400'}`}>
|
||||
{agent.source}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{creating ? (
|
||||
<CreateAgentForm
|
||||
onCreated={(id) => { qc.invalidateQueries({ queryKey: ['agents'] }); setSelectedId(id); setCreating(false); }}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
) : selected ? (
|
||||
editing ? (
|
||||
<EditAgentForm agent={selected} onSaved={() => { qc.invalidateQueries({ queryKey: ['agents'] }); setEditing(false); }} onCancel={() => setEditing(false)} />
|
||||
) : (
|
||||
<AgentDetail agent={selected} onEdit={() => setEditing(true)} onDeleted={() => { qc.invalidateQueries({ queryKey: ['agents'] }); setSelectedId(undefined); }} />
|
||||
)
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">Select an agent to view its configuration and performance, or create a new one.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Detail View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () => void; onDeleted: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const { data: perf } = useAgentPerformance(agent.id);
|
||||
const { data: history } = useAgentPerfHistory(agent.id);
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agent.id}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agents'] }); onDeleted(); },
|
||||
});
|
||||
|
||||
const chartData = (history ?? []).map((pt) => ({
|
||||
hour: new Date(pt.hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
invocations: pt.invocations,
|
||||
successRate: pt.invocations > 0 ? Math.round((pt.successes / pt.invocations) * 100) : 0,
|
||||
avgLatency: pt.avg_duration_ms,
|
||||
avgConfidence: Math.round((pt.avg_confidence ?? 0) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{agent.name}</h1>
|
||||
<StatusBadge status={agent.active ? 'active' : 'disabled'} />
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${agent.source === 'system' ? 'bg-blue-900/30 text-blue-400 border border-blue-700/50' : 'bg-emerald-900/30 text-emerald-400 border border-emerald-700/50'}`}>
|
||||
{agent.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onEdit} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">Edit</button>
|
||||
{agent.source === 'user' && (
|
||||
<button onClick={() => deleteMut.mutate()} className="rounded-md border border-red-700/50 px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/20">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Model</dt><dd className="font-mono text-gray-200">{agent.model_provider}/{agent.model_name}</dd></div>
|
||||
<div><dt className="text-gray-500">Prompt Version</dt><dd className="text-gray-200">{agent.prompt_version || '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Schema Version</dt><dd className="text-gray-200">{agent.schema_version}</dd></div>
|
||||
<div><dt className="text-gray-500">Temperature</dt><dd className="text-gray-200">{agent.temperature}</dd></div>
|
||||
<div><dt className="text-gray-500">Max Tokens</dt><dd className="text-gray-200">{agent.max_tokens.toLocaleString()}</dd></div>
|
||||
<div><dt className="text-gray-500">Timeout</dt><dd className="text-gray-200">{agent.timeout_seconds}s</dd></div>
|
||||
<div><dt className="text-gray-500">Max Retries</dt><dd className="text-gray-200">{agent.max_retries}</dd></div>
|
||||
<div><dt className="text-gray-500">Updated</dt><dd className="text-gray-300">{new Date(agent.updated_at).toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{agent.purpose && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Purpose</h2>
|
||||
<p className="text-sm text-gray-300">{agent.purpose}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">System Prompt</h2>
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-surface-950 p-3 text-xs text-gray-300 max-h-40 overflow-y-auto">{agent.system_prompt || '(none)'}</pre>
|
||||
</Card>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{perf && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Performance (24h)</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<MetricCard label="Invocations" value={String(perf.total_invocations ?? 0)} />
|
||||
<MetricCard label="Success Rate" value={perf.success_rate != null ? `${(perf.success_rate * 100).toFixed(1)}%` : '—'} color={perf.success_rate != null && perf.success_rate >= 0.95 ? 'text-green-400' : 'text-yellow-400'} />
|
||||
<MetricCard label="Avg Latency" value={perf.avg_duration_ms != null ? `${Math.round(perf.avg_duration_ms)}ms` : '—'} />
|
||||
<MetricCard label="P95 Latency" value={perf.p95_duration_ms != null ? `${Math.round(perf.p95_duration_ms)}ms` : '—'} />
|
||||
<MetricCard label="Avg Confidence" value={perf.avg_confidence != null ? `${(perf.avg_confidence * 100).toFixed(0)}%` : '—'} />
|
||||
</div>
|
||||
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
||||
<span>Retries avg: {perf.avg_retries?.toFixed(1) ?? '—'}</span>
|
||||
<span>Input tokens: {perf.total_input_tokens?.toLocaleString() ?? '—'}</span>
|
||||
<span>Output tokens: {perf.total_output_tokens?.toLocaleString() ?? '—'}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance Chart */}
|
||||
{chartData.length > 1 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Performance Over Time</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="invocations" name="Invocations" stroke="#3b82f6" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="successRate" name="Success %" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="avgConfidence" name="Confidence %" stroke="#f59e0b" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditAgentForm({ agent, onSaved, onCancel }: { agent: Agent; onSaved: () => void; onCancel: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: agent.name,
|
||||
purpose: agent.purpose,
|
||||
model_provider: agent.model_provider,
|
||||
model_name: agent.model_name,
|
||||
system_prompt: agent.system_prompt,
|
||||
user_prompt_template: agent.user_prompt_template,
|
||||
prompt_version: agent.prompt_version,
|
||||
schema_version: agent.schema_version,
|
||||
temperature: agent.temperature,
|
||||
max_tokens: agent.max_tokens,
|
||||
timeout_seconds: agent.timeout_seconds,
|
||||
max_retries: agent.max_retries,
|
||||
active: agent.active,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPut<Agent>('query', `/api/agents/${agent.id}`, body),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">Edit Agent: {agent.name}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<FormRow label="Name">
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Purpose">
|
||||
<textarea value={form.purpose} onChange={(e) => setForm({ ...form, purpose: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-16" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Provider">
|
||||
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Model">
|
||||
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="System Prompt">
|
||||
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-32 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<FormRow label="User Prompt Template">
|
||||
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FormRow label="Temperature">
|
||||
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Tokens">
|
||||
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Timeout (s)">
|
||||
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Retries">
|
||||
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
{mutation.isError && <p className="text-xs text-red-400">Failed to save</p>}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (id: string) => void; onCancel: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
purpose: '',
|
||||
model_provider: 'ollama',
|
||||
model_name: 'llama3.1:8b',
|
||||
system_prompt: '',
|
||||
user_prompt_template: '',
|
||||
temperature: 0,
|
||||
max_tokens: 32768,
|
||||
timeout_seconds: 120,
|
||||
max_retries: 2,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPost<{ id: string }>('query', '/api/agents', body),
|
||||
onSuccess: (data) => onCreated(data.id),
|
||||
});
|
||||
|
||||
// Auto-generate slug from name
|
||||
function handleNameChange(name: string) {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
setForm({ ...form, name, slug });
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">Create New Agent</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Name">
|
||||
<input value={form.name} onChange={(e) => handleNameChange(e.target.value)} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
|
||||
</FormRow>
|
||||
<FormRow label="Slug">
|
||||
<input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none font-mono" required />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="Purpose">
|
||||
<textarea value={form.purpose} onChange={(e) => setForm({ ...form, purpose: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-16" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Provider">
|
||||
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Model">
|
||||
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="System Prompt">
|
||||
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-32 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FormRow label="Temperature">
|
||||
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Tokens">
|
||||
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Timeout (s)">
|
||||
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Retries">
|
||||
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
{mutation.isError && <p className="text-xs text-red-400">Failed to create agent</p>}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, color = 'text-gray-100' }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { DashboardsPage } from './pages/Dashboards';
|
||||
import { HomePage } from './pages/Home';
|
||||
import { GlobalEventsPage } from './pages/GlobalEvents';
|
||||
import { GlobalEventDetailPage } from './pages/GlobalEventDetail';
|
||||
import { AgentsPage } from './pages/Agents';
|
||||
|
||||
// Root route wraps everything in the app shell layout
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -157,6 +158,12 @@ const globalEventDetailRoute = createRoute({
|
||||
component: GlobalEventDetailPage,
|
||||
});
|
||||
|
||||
const agentsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/agents',
|
||||
component: AgentsPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
companiesRoute,
|
||||
@@ -181,6 +188,7 @@ const routeTree = rootRoute.addChildren([
|
||||
analyticsDashboardsRoute,
|
||||
globalEventsRoute,
|
||||
globalEventDetailRoute,
|
||||
agentsRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
Reference in New Issue
Block a user