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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
100
apps/web/src/components/ui/AgentPresenceBadge.tsx
Normal file
100
apps/web/src/components/ui/AgentPresenceBadge.tsx
Normal 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;
|
||||
}
|
||||
107
apps/web/src/hooks/useAgentPresence.ts
Normal file
107
apps/web/src/hooks/useAgentPresence.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user