feat: implement Agent Presence Indicators (MVP)

Backend:
- Add /api/v1/agents/presence endpoint
- Integrate with matrix-presence-aggregator
- Add DAGI router health checks

Frontend:
- Create useAgentPresence hook
- Create AgentPresenceBadge component
- Integrate into /agents list page
- Integrate into /agents/:agentId cabinet

Shows real-time online/offline status for all agents.
This commit is contained in:
Apple
2025-11-30 09:41:57 -08:00
parent 111d62a62a
commit fcdac0f33c
5 changed files with 341 additions and 14 deletions

View File

@@ -22,6 +22,7 @@ import { ensureOrchestratorRoom } from '@/lib/api/microdao';
import { Bot, Settings, FileText, Building2, Cpu, MessageSquare, BarChart3, Users, Globe, Lock, Eye, EyeOff, ChevronLeft, Loader2, MessageCircle, PlusCircle } from 'lucide-react';
import { CityChatWidget } from '@/components/city/CityChatWidget';
import { AgentChatWidget } from '@/components/chat/AgentChatWidget';
import { AgentPresenceBadge } from '@/components/ui/AgentPresenceBadge';
import { Button } from '@/components/ui/button';
// Tab types
@@ -237,9 +238,12 @@ export default function AgentConsolePage() {
)}
</div>
<div>
<h1 className="text-xl font-bold text-white">
{profile?.display_name || agentId}
</h1>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-white">
{profile?.display_name || agentId}
</h1>
<AgentPresenceBadge agentId={agentId} size="md" showLabel={true} />
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-white/50">{profile?.kind || 'agent'}</span>
<span className="text-white/30"></span>

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { Bot, Users, Building2, Server, ExternalLink, Shield } from 'lucide-react';
import { useAgentList } from '@/hooks/useAgents';
import { AgentSummary, getGovLevelBadge } from '@/lib/types/agents';
import { AgentPresenceBadge } from '@/components/ui/AgentPresenceBadge';
// Kind emoji mapping
const kindEmoji: Record<string, string> = {
@@ -42,17 +43,23 @@ function AgentCard({ agent }: { agent: AgentSummary }) {
>
{/* Header */}
<div className="flex items-start gap-4 mb-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center flex-shrink-0 overflow-hidden">
{agent.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={agent.avatar_url}
alt={agent.display_name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-2xl">{emoji}</span>
)}
<div className="relative">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-violet-500/30 to-purple-600/30 flex items-center justify-center flex-shrink-0 overflow-hidden">
{agent.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={agent.avatar_url}
alt={agent.display_name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-2xl">{emoji}</span>
)}
</div>
{/* Presence Badge */}
<div className="absolute -top-1 -right-1">
<AgentPresenceBadge agentId={agent.id} size="sm" />
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white truncate group-hover:text-violet-400 transition-colors">

View File

@@ -0,0 +1,100 @@
'use client';
import { useAgentPresence } from '@/hooks/useAgentPresence';
import { cn } from '@/lib/utils';
interface AgentPresenceBadgeProps {
agentId: string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
showTooltip?: boolean;
className?: string;
}
/**
* Presence Badge для агентів.
*
* Показує статус: online (зелений), away/unavailable (жовтий), offline (сірий).
* Підтримує tooltip з деталями.
*/
export function AgentPresenceBadge({
agentId,
size = 'sm',
showLabel = false,
showTooltip = true,
className
}: AgentPresenceBadgeProps) {
const { getPresence, getPresenceStatus, loading } = useAgentPresence();
if (loading) {
return (
<div className={cn('animate-pulse', className)}>
<div className={cn(
'rounded-full bg-gray-400',
size === 'sm' && 'w-2 h-2',
size === 'md' && 'w-3 h-3',
size === 'lg' && 'w-4 h-4'
)} />
</div>
);
}
const presence = getPresence(agentId);
const status = getPresenceStatus(agentId);
const getStatusInfo = () => {
switch (status) {
case 'online':
return {
color: 'bg-emerald-500',
label: 'Online',
description: 'Агент активний'
};
case 'away':
return {
color: 'bg-amber-500',
label: 'Away',
description: 'Агент неактивний'
};
default:
return {
color: 'bg-gray-500',
label: 'Offline',
description: 'Агент офлайн'
};
}
};
const statusInfo = getStatusInfo();
const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4'
};
const badge = (
<div
className={cn('flex items-center gap-1.5', className)}
title={presence ? `${presence.display_name}: ${statusInfo.description}` : undefined}
>
<div className={cn(
'rounded-full border border-white/20',
sizeClasses[size],
statusInfo.color
)} />
{showLabel && (
<span className={cn(
'text-white/70',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
size === 'lg' && 'text-base'
)}>
{statusInfo.label}
</span>
)}
</div>
);
return badge;
}

View File

@@ -0,0 +1,107 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
export interface AgentPresence {
agent_id: string;
display_name: string;
matrix_presence: 'online' | 'unavailable' | 'offline';
dagi_router_presence: 'healthy' | 'degraded' | 'offline' | 'unknown';
last_seen?: string;
node_id?: string;
}
interface UseAgentPresenceOptions {
agentIds?: string[];
autoRefresh?: boolean;
refreshInterval?: number;
}
/**
* Hook для отримання presence статусу агентів.
*
* Отримує Matrix presence + DAGI router health.
* Автоматично оновлюється кожні 30 секунд.
*/
export function useAgentPresence(options: UseAgentPresenceOptions = {}) {
const { agentIds, autoRefresh = true, refreshInterval = 30000 } = options;
const [presenceData, setPresenceData] = useState<Record<string, AgentPresence>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPresence = useCallback(async () => {
try {
setError(null);
const params = agentIds ? `?agent_ids=${encodeURIComponent(agentIds.join(','))}` : '';
const response = await fetch(`/api/v1/agents/presence${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch presence: ${response.status}`);
}
const data = await response.json();
const presenceMap: Record<string, AgentPresence> = {};
for (const presence of data.presence || []) {
presenceMap[presence.agent_id] = presence;
}
setPresenceData(presenceMap);
setLoading(false);
} catch (err) {
console.error('[useAgentPresence] Failed to fetch presence:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch presence');
setLoading(false);
}
}, [agentIds]);
// Initial fetch
useEffect(() => {
fetchPresence();
}, [fetchPresence]);
// Auto-refresh
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchPresence, refreshInterval);
return () => clearInterval(interval);
}, [autoRefresh, refreshInterval, fetchPresence]);
const getPresence = useCallback((agentId: string): AgentPresence | null => {
return presenceData[agentId] || null;
}, [presenceData]);
const getPresenceStatus = useCallback((agentId: string): 'online' | 'away' | 'offline' => {
const presence = presenceData[agentId];
if (!presence) return 'offline';
// Priority: Matrix status first, then DAGI
if (presence.matrix_presence === 'online') return 'online';
if (presence.matrix_presence === 'unavailable') return 'away';
if (presence.dagi_router_presence === 'healthy') return 'online';
if (presence.dagi_router_presence === 'degraded') return 'away';
return 'offline';
}, [presenceData]);
const isAgentOnline = useCallback((agentId: string): boolean => {
return getPresenceStatus(agentId) === 'online';
}, [getPresenceStatus]);
const isAgentAway = useCallback((agentId: string): boolean => {
return getPresenceStatus(agentId) === 'away';
}, [getPresenceStatus]);
return {
presenceData,
loading,
error,
refresh: fetchPresence,
getPresence,
getPresenceStatus,
isAgentOnline,
isAgentAway
};
}

View File

@@ -1407,6 +1407,115 @@ async def get_microdao_chat_room(slug: str):
raise HTTPException(status_code=500, detail="Failed to get microdao chat room")
# =============================================================================
# Presence API (TASK_PHASE_AGENT_PRESENCE_INDICATORS_MVP)
# =============================================================================
@api_router.get("/agents/presence")
async def get_agents_presence():
"""
Отримати presence статус всіх активних агентів.
Повертає Matrix presence + DAGI router health.
"""
try:
# Get all agents from DB
agents = await repo_city.list_agents_summaries(limit=1000)
# Get Matrix presence from matrix-presence-aggregator
matrix_presence = await get_matrix_presence_status()
# Get DAGI router health (simplified for MVP)
dagi_health = await get_dagi_router_health()
# Combine presence data
presence_data = []
for agent in agents:
agent_id = agent["id"]
node_id = agent.get("node_id")
# Matrix presence
matrix_status = matrix_presence.get(agent_id, {}).get("status", "offline")
last_seen = matrix_presence.get(agent_id, {}).get("last_seen")
# DAGI router presence (node-level)
dagi_status = "unknown"
if node_id and node_id in dagi_health:
dagi_status = dagi_health[node_id].get("router_status", "unknown")
presence_data.append({
"agent_id": agent_id,
"display_name": agent.get("display_name", agent_id),
"matrix_presence": matrix_status,
"dagi_router_presence": dagi_status,
"last_seen": last_seen,
"node_id": node_id
})
return {"presence": presence_data}
except Exception as e:
logger.error(f"Failed to get agents presence: {e}")
raise HTTPException(status_code=500, detail="Failed to get agents presence")
async def get_matrix_presence_status():
"""
Get Matrix presence from matrix-presence-aggregator.
Returns dict: agent_id -> {status: 'online'|'unavailable'|'offline', last_seen: timestamp}
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get("http://matrix-presence-aggregator:8080/api/presence/agents")
if response.status_code == 200:
data = response.json()
return data.get("agents", {})
else:
logger.warning(f"Matrix presence aggregator returned {response.status_code}")
return {}
except Exception as e:
logger.warning(f"Failed to get Matrix presence: {e}")
return {}
async def get_dagi_router_health():
"""
Get DAGI router health from node-registry or direct ping.
Returns dict: node_id -> {router_status: 'healthy'|'degraded'|'offline'}
"""
try:
# Try to get from node-registry first
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get("http://dagi-node-registry:9205/api/v1/health")
if response.status_code == 200:
data = response.json()
return data.get("nodes", {})
else:
logger.warning(f"Node registry returned {response.status_code}")
except Exception as e:
logger.warning(f"Failed to get DAGI router health from node-registry: {e}")
# Fallback: try direct ping to known nodes
try:
known_nodes = ["node-1-hetzner-gex44", "node-2-macbook-m4max"]
health_data = {}
for node_id in known_nodes:
try:
async with httpx.AsyncClient(timeout=2.0) as client:
response = await client.get(f"http://dagi-router-{node_id}:8080/health")
if response.status_code == 200:
health_data[node_id] = {"router_status": "healthy"}
else:
health_data[node_id] = {"router_status": "degraded"}
except Exception:
health_data[node_id] = {"router_status": "offline"}
return health_data
except Exception as e:
logger.warning(f"Failed to get DAGI router health: {e}")
return {}
# =============================================================================
# City Feed API
# =============================================================================