Add agent huddle SSE feed with frontend live subscriptions
This commit is contained in:
@@ -49,6 +49,7 @@ import IntentResearchTest from './pages/IntentResearchTest';
|
||||
import SchedulerDashboard from './pages/SchedulerDashboard';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import ApprovalsPage from './pages/ApprovalsPage';
|
||||
import TeamActivityPage from './pages/TeamActivityPage';
|
||||
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
|
||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||
@@ -622,6 +623,7 @@ const App: React.FC = () => {
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||
<Route path="/approvals" element={<ProtectedRoute><ApprovalsPage /></ProtectedRoute>} />
|
||||
<Route path="/team-activity" element={<ProtectedRoute><TeamActivityPage /></ProtectedRoute>} />
|
||||
<Route path="/stripe-disputes" element={<ProtectedRoute><StripeDisputesDashboard /></ProtectedRoute>} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/research-test" element={<ResearchDashboard />} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Paper,
|
||||
Typography,
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
@@ -20,182 +19,70 @@ import {
|
||||
Search as SeoIcon,
|
||||
Campaign as SocialIcon,
|
||||
CompareArrows as CompetitorIcon,
|
||||
Refresh as RefreshIcon,
|
||||
MoreVert as MoreVertIcon
|
||||
Refresh as RefreshIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
|
||||
|
||||
interface AgentStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: 'active' | 'thinking' | 'idle' | 'offline';
|
||||
current_activity: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Mock data - In real implementation, this would come from a backend endpoint
|
||||
// /api/agents/status or similar
|
||||
const AGENT_TEAM: AgentStatus[] = [
|
||||
{
|
||||
id: 'strategy_architect',
|
||||
name: 'Strategy Architect',
|
||||
role: 'Team Lead',
|
||||
status: 'active',
|
||||
current_activity: 'Analyzing content pillar performance',
|
||||
icon: StrategyIcon,
|
||||
color: '#6366f1' // Indigo
|
||||
},
|
||||
{
|
||||
id: 'content_strategist',
|
||||
name: 'Content Strategist',
|
||||
role: 'Creative',
|
||||
status: 'thinking',
|
||||
current_activity: 'Identifying semantic gaps in "AI Tools"',
|
||||
icon: ContentIcon,
|
||||
color: '#10b981' // Emerald
|
||||
},
|
||||
{
|
||||
id: 'seo_specialist',
|
||||
name: 'SEO Specialist',
|
||||
role: 'Technical',
|
||||
status: 'idle',
|
||||
current_activity: 'Monitoring SERP rankings',
|
||||
icon: SeoIcon,
|
||||
color: '#f59e0b' // Amber
|
||||
},
|
||||
{
|
||||
id: 'social_manager',
|
||||
name: 'Social Manager',
|
||||
role: 'Engagement',
|
||||
status: 'idle',
|
||||
current_activity: 'Waiting for new content to schedule',
|
||||
icon: SocialIcon,
|
||||
color: '#ec4899' // Pink
|
||||
},
|
||||
{
|
||||
id: 'competitor_analyst',
|
||||
name: 'Competitor Analyst',
|
||||
role: 'Intelligence',
|
||||
status: 'active',
|
||||
current_activity: 'Scanning competitor X for new posts',
|
||||
icon: CompetitorIcon,
|
||||
color: '#ef4444' // Red
|
||||
}
|
||||
];
|
||||
const ICON_BY_AGENT: Record<string, React.ElementType> = {
|
||||
strategy: StrategyIcon,
|
||||
content: ContentIcon,
|
||||
seo: SeoIcon,
|
||||
social: SocialIcon,
|
||||
competitor: CompetitorIcon,
|
||||
};
|
||||
|
||||
const TeamHuddleWidget: React.FC = () => {
|
||||
const { runs, events, connectionMode, lastHeartbeatAt } = useAgentHuddleFeed();
|
||||
|
||||
const rows = React.useMemo(() => {
|
||||
return runs.slice(0, 5).map((run) => {
|
||||
const event = events.find((e) => e.agent_type === run.agent_type);
|
||||
const agentType = String(run.agent_type || 'strategy');
|
||||
const IconComponent = ICON_BY_AGENT[agentType] || StrategyIcon;
|
||||
const status = run.status === 'running' ? 'thinking' : run.success === false ? 'offline' : 'active';
|
||||
return {
|
||||
id: run.id,
|
||||
name: agentType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
role: run.status || 'active',
|
||||
status,
|
||||
current_activity: event?.message || run.result_summary || 'Awaiting next update',
|
||||
icon: IconComponent,
|
||||
};
|
||||
});
|
||||
}, [runs, events]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)'
|
||||
}}
|
||||
>
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="h6" fontWeight={700} color="text.primary">
|
||||
Team Huddle
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Live"
|
||||
size="small"
|
||||
color="success"
|
||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Tooltip title="Refresh Team Status">
|
||||
<IconButton size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Typography variant="h6" fontWeight={700} color="text.primary">Team Huddle</Typography>
|
||||
<Chip label={connectionMode === 'sse' ? 'Live' : connectionMode === 'polling' ? 'Polling' : 'Connecting'} size="small" color={connectionMode === 'sse' ? 'success' : 'warning'} sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
|
||||
</Box>
|
||||
<Tooltip title={lastHeartbeatAt ? `Heartbeat ${new Date(lastHeartbeatAt).toLocaleTimeString()}` : 'Waiting for heartbeat'}>
|
||||
<IconButton size="small"><RefreshIcon fontSize="small" /></IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<List disablePadding>
|
||||
{AGENT_TEAM.map((agent, index) => (
|
||||
{rows.map((agent, index) => (
|
||||
<React.Fragment key={agent.id}>
|
||||
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />}
|
||||
<ListItem
|
||||
alignItems="flex-start"
|
||||
disableGutters
|
||||
sx={{ py: 0.5 }}
|
||||
secondaryAction={
|
||||
<Tooltip title={agent.status}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor:
|
||||
agent.status === 'active' ? '#22c55e' :
|
||||
agent.status === 'thinking' ? '#3b82f6' :
|
||||
'#94a3b8',
|
||||
boxShadow: agent.status === 'active' ? '0 0 0 2px rgba(34, 197, 94, 0.2)' : 'none',
|
||||
animation: agent.status === 'thinking' ? 'pulse 1.5s infinite' : 'none',
|
||||
'@keyframes pulse': {
|
||||
'0%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.6, transform: 'scale(1.2)' },
|
||||
'100%': { opacity: 1, transform: 'scale(1)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<ListItem alignItems="flex-start" disableGutters sx={{ py: 0.5 }}>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: `${agent.color}15`,
|
||||
color: agent.color,
|
||||
width: 40,
|
||||
height: 40
|
||||
}}
|
||||
>
|
||||
<agent.icon fontSize="small" />
|
||||
</Avatar>
|
||||
<Avatar sx={{ bgcolor: '#eef2ff', color: '#6366f1', width: 40, height: 40 }}><agent.icon fontSize="small" /></Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{agent.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>
|
||||
{agent.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
fontSize: '0.75rem',
|
||||
mt: 0.25
|
||||
}}
|
||||
>
|
||||
{agent.current_activity}
|
||||
</Typography>
|
||||
}
|
||||
primary={<Box display="flex" alignItems="center" gap={1}><Typography variant="subtitle2" fontWeight={600}>{agent.name}</Typography><Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>{agent.role}</Typography></Box>}
|
||||
secondary={<Typography variant="body2" color="text.secondary" sx={{ display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden', fontSize: '0.75rem', mt: 0.25 }}>{agent.current_activity}</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
|
||||
<Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center">
|
||||
<Typography variant="caption" color="primary" sx={{ fontWeight: 600, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}>
|
||||
<Typography component={RouterLink} to="/team-activity" variant="caption" color="primary" sx={{ fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||
View Full Team Activity
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
167
frontend/src/hooks/useAgentHuddleFeed.ts
Normal file
167
frontend/src/hooks/useAgentHuddleFeed.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { apiClient, getApiUrl, getAuthTokenGetter } from '../api/client';
|
||||
|
||||
export interface AgentRunItem { id: number; agent_type?: string; status?: string; success?: boolean | null; result_summary?: string | null; finished_at?: string | null; }
|
||||
export interface AgentEventItem { id: number; agent_type?: string; event_type?: string; message?: string | null; created_at?: string | null; }
|
||||
export interface AgentAlertItem { id: number; title?: string; message?: string; severity?: string; read_at?: string | null; }
|
||||
export interface AgentApprovalItem { id: number; action_type?: string; status?: string; risk_level?: number; created_at?: string | null; }
|
||||
|
||||
interface Cursor { run_id: number; event_id: number; alert_id: number; approval_id: number; }
|
||||
interface FeedPayload {
|
||||
runs: AgentRunItem[];
|
||||
events: AgentEventItem[];
|
||||
alerts: AgentAlertItem[];
|
||||
approvals: AgentApprovalItem[];
|
||||
cursor: Cursor;
|
||||
}
|
||||
|
||||
const DEFAULT_CURSOR: Cursor = { run_id: 0, event_id: 0, alert_id: 0, approval_id: 0 };
|
||||
const BASE_BACKOFF_MS = 1500;
|
||||
const MAX_BACKOFF_MS = 20000;
|
||||
|
||||
const mergeById = <T extends { id: number }>(prev: T[], incoming: T[], limit = 100): T[] => {
|
||||
if (!incoming.length) return prev;
|
||||
const byId = new Map<number, T>();
|
||||
[...prev, ...incoming].forEach((item) => byId.set(item.id, item));
|
||||
return Array.from(byId.values()).sort((a, b) => b.id - a.id).slice(0, limit);
|
||||
};
|
||||
|
||||
const parseSseLines = (raw: string): Array<{ event: string; data: string }> => {
|
||||
return raw
|
||||
.split('\n\n')
|
||||
.map((block) => block.trim())
|
||||
.filter(Boolean)
|
||||
.map((block) => {
|
||||
const lines = block.split('\n');
|
||||
const event = (lines.find((line) => line.startsWith('event:')) || 'event: message').replace('event:', '').trim();
|
||||
const data = lines
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.replace('data:', '').trim())
|
||||
.join('');
|
||||
return { event, data };
|
||||
});
|
||||
};
|
||||
|
||||
export const useAgentHuddleFeed = () => {
|
||||
const [feed, setFeed] = useState<FeedPayload>({ runs: [], events: [], alerts: [], approvals: [], cursor: DEFAULT_CURSOR });
|
||||
const [connectionMode, setConnectionMode] = useState<'connecting' | 'sse' | 'polling'>('connecting');
|
||||
const [lastHeartbeatAt, setLastHeartbeatAt] = useState<number | null>(null);
|
||||
const stopRef = useRef(false);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const cursorRef = useRef<Cursor>(DEFAULT_CURSOR);
|
||||
|
||||
const applyPayload = useCallback((payload: Partial<FeedPayload>, replace = false) => {
|
||||
setFeed((prev) => ({
|
||||
runs: replace ? (payload.runs || []) : mergeById(prev.runs, payload.runs || []),
|
||||
events: replace ? (payload.events || []) : mergeById(prev.events, payload.events || []),
|
||||
alerts: replace ? (payload.alerts || []) : mergeById(prev.alerts, payload.alerts || []),
|
||||
approvals: replace ? (payload.approvals || []) : mergeById(prev.approvals, payload.approvals || []),
|
||||
cursor: {
|
||||
run_id: payload.cursor?.run_id ?? prev.cursor.run_id,
|
||||
event_id: payload.cursor?.event_id ?? prev.cursor.event_id,
|
||||
alert_id: payload.cursor?.alert_id ?? prev.cursor.alert_id,
|
||||
approval_id: payload.cursor?.approval_id ?? prev.cursor.approval_id,
|
||||
},
|
||||
}));
|
||||
if (payload.cursor) {
|
||||
cursorRef.current = {
|
||||
run_id: payload.cursor.run_id ?? cursorRef.current.run_id,
|
||||
event_id: payload.cursor.event_id ?? cursorRef.current.event_id,
|
||||
alert_id: payload.cursor.alert_id ?? cursorRef.current.alert_id,
|
||||
approval_id: payload.cursor.approval_id ?? cursorRef.current.approval_id,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSnapshot = useCallback(async (cursor?: Cursor) => {
|
||||
const resp = await apiClient.get('/api/agents/huddle/feed', { params: cursor || {} });
|
||||
const data = resp?.data?.data as FeedPayload;
|
||||
applyPayload(data, !cursor);
|
||||
return data;
|
||||
}, [applyPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
stopRef.current = false;
|
||||
let pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const startPolling = () => {
|
||||
setConnectionMode('polling');
|
||||
if (pollingTimer) clearInterval(pollingTimer);
|
||||
pollingTimer = setInterval(async () => {
|
||||
try {
|
||||
await loadSnapshot(cursorRef.current);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, 7000);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
setConnectionMode('connecting');
|
||||
await loadSnapshot();
|
||||
const tokenGetter = getAuthTokenGetter();
|
||||
const token = tokenGetter ? await tokenGetter() : null;
|
||||
if (!token) throw new Error('No auth token available for SSE stream');
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/agents/huddle/stream`, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE stream unavailable (${response.status})`);
|
||||
}
|
||||
|
||||
reconnectAttemptRef.current = 0;
|
||||
setConnectionMode('sse');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
|
||||
while (!stopRef.current) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
throw new Error('SSE stream ended');
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() || '';
|
||||
|
||||
for (const packet of parseSseLines(chunks.join('\n\n'))) {
|
||||
if (!packet.data) continue;
|
||||
if (packet.event === 'heartbeat') {
|
||||
setLastHeartbeatAt(Date.now());
|
||||
continue;
|
||||
}
|
||||
const payload = JSON.parse(packet.data);
|
||||
if (packet.event === 'snapshot') {
|
||||
applyPayload(payload, true);
|
||||
}
|
||||
if (packet.event === 'delta') {
|
||||
applyPayload(payload, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
reconnectAttemptRef.current += 1;
|
||||
if (reconnectAttemptRef.current >= 3) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
const sleepMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * (2 ** reconnectAttemptRef.current));
|
||||
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||
if (!stopRef.current) connect();
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
stopRef.current = true;
|
||||
if (pollingTimer) clearInterval(pollingTimer);
|
||||
};
|
||||
}, [applyPayload, loadSnapshot]);
|
||||
|
||||
return useMemo(() => ({ ...feed, connectionMode, lastHeartbeatAt }), [feed, connectionMode, lastHeartbeatAt]);
|
||||
};
|
||||
66
frontend/src/pages/TeamActivityPage.tsx
Normal file
66
frontend/src/pages/TeamActivityPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
|
||||
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
|
||||
|
||||
const TeamActivityPage: React.FC = () => {
|
||||
const { runs, events, alerts, approvals, connectionMode } = useAgentHuddleFeed();
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>Team Activity</Typography>
|
||||
<Chip label={connectionMode === 'sse' ? 'Live stream' : 'Polling fallback'} color={connectionMode === 'sse' ? 'success' : 'warning'} />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Run lifecycle updates</Typography>
|
||||
<List dense>
|
||||
{runs.slice(0, 20).map((run) => (
|
||||
<ListItem key={run.id}><ListItemText primary={`${run.agent_type || 'agent'} · ${run.status}`} secondary={run.result_summary || run.finished_at || 'In progress'} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>New events</Typography>
|
||||
<List dense>
|
||||
{events.slice(0, 20).map((event) => (
|
||||
<ListItem key={event.id}><ListItemText primary={`${event.agent_type || 'agent'} · ${event.event_type}`} secondary={event.message || event.created_at} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}><Divider /></Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Alert deltas</Typography>
|
||||
<List dense>
|
||||
{alerts.slice(0, 20).map((alert) => (
|
||||
<ListItem key={alert.id}><ListItemText primary={alert.title || 'Alert'} secondary={alert.message} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card><CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Approval deltas</Typography>
|
||||
<List dense>
|
||||
{approvals.slice(0, 20).map((approval) => (
|
||||
<ListItem key={approval.id}><ListItemText primary={`${approval.action_type || 'Action'} · ${approval.status}`} secondary={approval.created_at} /></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamActivityPage;
|
||||
Reference in New Issue
Block a user