feat(F): F-UI-CHAT AgentChat, F-UI-LOG AgentRunLog, F-UI-MON AgentMonitor

- F-UI-CHAT: AgentChat.tsx (147 lines) — SSE streaming, extended trace toggle, dry run, cost display, stop button
- F-UI-LOG: AgentRunLog.tsx (82 lines) — timeline view, export JSON/CSV
- F-UI-MON: AgentMonitor.tsx (86 lines) — live stats, active runs, auto-refresh 5s
This commit is contained in:
Agent Zero
2026-08-17 18:34:29 +02:00
parent 8ed6d27885
commit 131d761936
3 changed files with 315 additions and 0 deletions
@@ -0,0 +1,147 @@
import { useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PaperAirplaneIcon, StopCircleIcon, CurrencyDollarIcon } from '@heroicons/react/24/outline';
interface AgentStep {
step_number: number;
thought: string;
action: string | null;
action_input: Record<string, unknown> | null;
observation: string | null;
cost_usd: number;
}
interface AgentChatProps {
agentId: string;
agentName: string;
}
export function AgentChat({ agentId, agentName }: AgentChatProps) {
const { t } = useTranslation();
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState('');
const [steps, setSteps] = useState<AgentStep[]>([]);
const [isRunning, setIsRunning] = useState(false);
const [totalCost, setTotalCost] = useState(0);
const [extendedTrace, setExtendedTrace] = useState(false);
const [dryRun, setDryRun] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const handleSend = useCallback(() => {
if (!input.trim() || isRunning) return;
const userMsg = input.trim();
setMessages((prev) => [...prev, `User: ${userMsg}`]);
setInput('');
setIsRunning(true);
setSteps([]);
setTotalCost(0);
const url = `/api/v1/agents/${agentId}/stream?message=${encodeURIComponent(userMsg)}&trace_mode=${extendedTrace ? 'extended' : 'standard'}&dry_run=${dryRun}`;
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.addEventListener('step', (e) => {
const step = JSON.parse(e.data) as AgentStep;
setSteps((prev) => [...prev, step]);
setTotalCost((prev) => prev + (step.cost_usd || 0));
});
eventSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
if (data.status === 'running') {
setMessages((prev) => [...prev, `Step ${data.step}: ${data.action || 'Thinking...'}`]);
}
});
eventSource.addEventListener('done', (e) => {
const data = JSON.parse(e.data);
setMessages((prev) => [...prev, `Agent: ${data.final_content}`]);
setIsRunning(false);
eventSource.close();
});
eventSource.addEventListener('error', (e) => {
try {
const data = JSON.parse(e.data);
setMessages((prev) => [...prev, `Error: ${data.error}`]);
} catch {
setMessages((prev) => [...prev, 'Error: Connection lost']);
}
setIsRunning(false);
eventSource.close();
});
}, [input, isRunning, agentId, extendedTrace, dryRun]);
const handleStop = useCallback(() => {
eventSourceRef.current?.close();
setIsRunning(false);
setMessages((prev) => [...prev, 'Agent run stopped by user']);
}, []);
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{agentName}</h2>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" checked={extendedTrace} onChange={(e) => setExtendedTrace(e.target.checked)} className="rounded" aria-label={t('agents.extendedTrace')} />
{t('agents.extendedTrace')}
</label>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} className="rounded" aria-label={t('agents.dryRun')} />
{t('agents.dryRun')}
</label>
{totalCost > 0 && (
<span className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<CurrencyDollarIcon className="w-4 h-4" />
{totalCost.toFixed(6)}
</span>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((msg, i) => (
<div key={i} className={`p-3 rounded-lg ${msg.startsWith('User:') ? 'bg-primary-50 dark:bg-primary-900/20' : msg.startsWith('Error:') ? 'bg-red-50 dark:bg-red-900/20' : 'bg-gray-50 dark:bg-gray-800'}`}>
<p className="text-sm text-gray-900 dark:text-gray-100">{msg}</p>
</div>
))}
{extendedTrace && steps.map((step) => (
<div key={step.step_number} className="p-3 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-gray-500">Step {step.step_number}</span>
<span className="text-xs text-gray-400">${step.cost_usd.toFixed(6)}</span>
</div>
{step.thought && <p className="text-sm text-gray-700 dark:text-gray-300 mb-1"><strong>Thought:</strong> {step.thought}</p>}
{step.action && <p className="text-sm text-primary-600 dark:text-primary-400 mb-1"><strong>Action:</strong> {step.action}</p>}
{step.observation && <p className="text-sm text-gray-600 dark:text-gray-400"><strong>Observation:</strong> {step.observation}</p>}
</div>
))}
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder={t('agents.typeMessage')}
disabled={isRunning}
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500"
aria-label={t('agents.messageInput')}
/>
{isRunning ? (
<button onClick={handleStop} className="p-2 rounded-lg bg-red-500 text-white hover:bg-red-600 min-h-[44px] min-w-[44px]" aria-label={t('agents.stop')}>
<StopCircleIcon className="w-5 h-5" />
</button>
) : (
<button onClick={handleSend} disabled={!input.trim()} className="p-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50 min-h-[44px] min-w-[44px]" aria-label={t('agents.send')}>
<PaperAirplaneIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ChartBarIcon, ExclamationTriangleIcon, CurrencyDollarIcon, ClockIcon } from '@heroicons/react/24/outline';
export function AgentMonitor() {
const { t } = useTranslation();
const { data: stats, isLoading } = useQuery({
queryKey: ['agent-monitor-stats'],
queryFn: async () => {
const res = await fetch('/api/v1/agents/monitor/stats');
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json() as Promise<{ active_runs: number; total_budget_usd: number; runs_per_hour: number; error_rate: number }>;
},
refetchInterval: 5000,
});
const { data: activeRuns } = useQuery({
queryKey: ['agent-active-runs'],
queryFn: async () => {
const res = await fetch('/api/v1/agents/runs/recent?status=running&limit=20');
if (!res.ok) throw new Error('Failed to fetch runs');
return res.json() as Promise<Array<{ id: string; agent_name: string; status: string; started_at: string; cost_usd: number }>>;
},
refetchInterval: 5000,
});
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900 p-6 space-y-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('agents.monitoring')}</h2>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 rounded-lg bg-primary-50 dark:bg-primary-900/20">
<div className="flex items-center gap-2 mb-2">
<ClockIcon className="w-5 h-5 text-primary-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.activeRuns')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats?.active_runs ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="flex items-center gap-2 mb-2">
<CurrencyDollarIcon className="w-5 h-5 text-green-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.totalBudget')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">${(stats?.total_budget_usd ?? 0).toFixed(4)}</p>
</div>
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2 mb-2">
<ChartBarIcon className="w-5 h-5 text-blue-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.runsPerHour')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats?.runs_per_hour ?? 0}</p>
</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="flex items-center gap-2 mb-2">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
<span className="text-sm text-gray-600 dark:text-gray-400">{t('agents.errorRate')}</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{(stats?.error_rate ?? 0).toFixed(1)}%</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{t('agents.activeRunsList')}</h3>
{isLoading && <p className="text-gray-500">{t('common.loading')}</p>}
<div className="space-y-2">
{activeRuns?.map((run) => (
<div key={run.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">{run.agent_name}</p>
<p className="text-xs text-gray-400">{new Date(run.started_at).toLocaleString()}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">${run.cost_usd.toFixed(6)}</span>
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800">{run.status}</span>
</div>
</div>
))}
{(!activeRuns || activeRuns.length === 0) && !isLoading && (
<p className="text-sm text-gray-400">{t('agents.noActiveRuns')}</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
interface RunStep {
id: string;
step_number: number;
thought: string | null;
action: string | null;
action_input: Record<string, unknown> | null;
observation: string | null;
cost_usd: number;
created_at: string;
}
interface AgentRunLogProps {
agentId: string;
runId: string;
}
export function AgentRunLog({ agentId, runId }: AgentRunLogProps) {
const { t } = useTranslation();
const [filterStatus, setFilterStatus] = useState<string>('all');
const { data: steps, isLoading } = useQuery({
queryKey: ['agent-run-steps', agentId, runId],
queryFn: async () => {
const res = await fetch(`/api/v1/agents/${agentId}/runs/${runId}/steps`);
if (!res.ok) throw new Error('Failed to fetch steps');
return res.json() as Promise<RunStep[]>;
},
});
const handleExport = (format: 'json' | 'csv') => {
if (!steps) return;
const data = format === 'json' ? JSON.stringify(steps, null, 2) :
'step,thought,action,observation,cost,timestamp\n' +
steps.map((s) => `${s.step_number},${s.thought || ''},${s.action || ''},${s.observation || ''},${s.cost_usd},${s.created_at}`).join('\n');
const blob = new Blob([data], { type: format === 'json' ? 'application/json' : 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `agent-run-${runId}.${format}`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-900">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('agents.runLog')}</h2>
<div className="flex items-center gap-2">
<button onClick={() => handleExport('json')} className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px]" aria-label={t('agents.exportJson')}>
<ArrowDownTrayIcon className="w-5 h-5" />
</button>
<button onClick={() => handleExport('csv')} className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 min-h-[44px] min-w-[44px]" aria-label={t('agents.exportCsv')}>
<ArrowDownTrayIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isLoading && <p className="text-gray-500">{t('common.loading')}</p>}
{steps?.map((step) => (
<div key={step.id} className="p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">Step {step.step_number}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400">${step.cost_usd.toFixed(6)}</span>
<span className="text-xs text-gray-400">{new Date(step.created_at).toLocaleString()}</span>
</div>
</div>
{step.thought && <p className="text-sm text-gray-700 dark:text-gray-300 mb-2"><strong>Thought:</strong> {step.thought}</p>}
{step.action && <p className="text-sm text-primary-600 dark:text-primary-400 mb-2"><strong>Action:</strong> {step.action}</p>}
{step.action_input && <pre className="text-xs text-gray-500 bg-gray-50 dark:bg-gray-800 p-2 rounded mb-2 overflow-x-auto">{JSON.stringify(step.action_input, null, 2)}</pre>}
{step.observation && <p className="text-sm text-gray-600 dark:text-gray-400"><strong>Observation:</strong> {step.observation}</p>}
</div>
))}
</div>
</div>
);
}