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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user