feat: add home_node support to Citizens API and UI

- Add HomeNodeView model to city-service
- Update get_public_citizens and get_public_citizen_by_slug to JOIN node_cache
- Add HomeNode interface to frontend types
- Display node badge on citizen cards
- Show full home_node info on citizen profile page
This commit is contained in:
Apple
2025-11-28 04:34:25 -08:00
parent f52a9e6c5e
commit 35768f1180
5 changed files with 112 additions and 13 deletions

View File

@@ -151,10 +151,38 @@ export default function CitizenProfilePage() {
</p>
</div>
)}
{citizen.node_id && (
{citizen.home_node && (
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
<p className="text-xs uppercase text-white/40">Node</p>
<p className="text-white mt-1 text-lg">{citizen.node_id}</p>
<p className="text-xs uppercase text-white/40">Home Node</p>
<div className="mt-2 space-y-1">
<p className="text-white text-lg">{citizen.home_node.name || citizen.node_id}</p>
{citizen.home_node.roles && citizen.home_node.roles.length > 0 && (
<div className="flex flex-wrap gap-1">
{citizen.home_node.roles.map((role) => (
<span
key={role}
className={`px-2 py-0.5 rounded text-xs ${
role === 'gpu' ? 'bg-amber-500/20 text-amber-300' :
role === 'core' ? 'bg-emerald-500/20 text-emerald-300' :
role === 'development' ? 'bg-purple-500/20 text-purple-300' :
'bg-white/10 text-white/60'
}`}
>
{role}
</span>
))}
</div>
)}
{citizen.home_node.environment && (
<span className={`inline-block px-2 py-0.5 rounded text-xs ${
citizen.home_node.environment === 'production'
? 'bg-emerald-500/20 text-emerald-300'
: 'bg-amber-500/20 text-amber-300'
}`}>
{citizen.home_node.environment}
</span>
)}
</div>
</div>
)}
</div>

View File

@@ -190,14 +190,25 @@ function CitizenCard({ citizen }: { citizen: PublicCitizenSummary }) {
)}
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
<span className={`flex items-center gap-1.5 text-xs ${statusColor}`}>
<span
className={`w-2 h-2 rounded-full ${
status === 'online' ? 'bg-emerald-500' : 'bg-white/30'
}`}
/>
{status}
</span>
<div className="flex items-center gap-3">
<span className={`flex items-center gap-1.5 text-xs ${statusColor}`}>
<span
className={`w-2 h-2 rounded-full ${
status === 'online' ? 'bg-emerald-500' : 'bg-white/30'
}`}
/>
{status}
</span>
{citizen.home_node?.name && (
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
citizen.home_node.environment === 'production'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-amber-500/20 text-amber-400'
}`}>
{citizen.home_node.name.split(' ')[0]}
</span>
)}
</div>
<span className="text-cyan-400 text-sm group-hover:translate-x-1 transition-transform">
View Profile
</span>

View File

@@ -1,3 +1,11 @@
export interface HomeNode {
id?: string | null;
name?: string | null;
hostname?: string | null;
roles: string[];
environment?: string | null;
}
export interface PublicCitizenSummary {
slug: string;
display_name: string;
@@ -10,6 +18,7 @@ export interface PublicCitizenSummary {
public_skills: string[];
online_status?: "online" | "offline" | "unknown" | string;
status?: string | null;
home_node?: HomeNode | null;
}
export interface CityPresenceRoom {
@@ -44,6 +53,7 @@ export interface PublicCitizenProfile {
name: string;
district?: string | null;
} | null;
home_node?: HomeNode | null;
}
export interface CitizenInteractionInfo {

View File

@@ -191,6 +191,15 @@ class CityPresenceView(BaseModel):
rooms: List[CityPresenceRoomView] = []
class HomeNodeView(BaseModel):
"""Home node information for agent/citizen"""
id: Optional[str] = None
name: Optional[str] = None
hostname: Optional[str] = None
roles: List[str] = []
environment: Optional[str] = None
class PublicCitizenSummary(BaseModel):
slug: str
display_name: str
@@ -203,6 +212,8 @@ class PublicCitizenSummary(BaseModel):
public_skills: List[str] = []
online_status: Optional[str] = "unknown"
status: Optional[str] = None # backward compatibility
# Home node info
home_node: Optional[HomeNodeView] = None
class PublicCitizenProfile(BaseModel):
@@ -222,6 +233,8 @@ class PublicCitizenProfile(BaseModel):
metrics_public: Dict[str, Any]
admin_panel_url: Optional[str] = None
microdao: Optional[Dict[str, Any]] = None
# Home node info
home_node: Optional[HomeNodeView] = None
class CitizenInteractionInfo(BaseModel):

View File

@@ -606,8 +606,14 @@ async def get_public_citizens(
a.public_primary_room_slug,
COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills,
COALESCE(a.status, 'unknown') AS status,
a.node_id,
nc.node_name AS home_node_name,
nc.hostname AS home_node_hostname,
nc.roles AS home_node_roles,
nc.environment AS home_node_environment,
COUNT(*) OVER() AS total_count
FROM agents a
LEFT JOIN node_cache nc ON a.node_id = nc.node_id
WHERE {where_sql}
ORDER BY a.display_name
LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}
@@ -627,6 +633,20 @@ async def get_public_citizens(
data.pop("total_count", None)
data["public_skills"] = list(data.get("public_skills") or [])
data["online_status"] = data.get("status") or "unknown"
# Build home_node object
if data.get("node_id"):
data["home_node"] = {
"id": data.get("node_id"),
"name": data.get("home_node_name"),
"hostname": data.get("home_node_hostname"),
"roles": list(data.get("home_node_roles") or []),
"environment": data.get("home_node_environment")
}
else:
data["home_node"] = None
# Clean up intermediate fields
for key in ["home_node_name", "home_node_hostname", "home_node_roles", "home_node_environment"]:
data.pop(key, None)
items.append(data)
return items, total
@@ -700,8 +720,13 @@ async def get_public_citizen_by_slug(slug: str) -> Optional[dict]:
COALESCE(a.public_skills, ARRAY[]::text[]) AS public_skills,
a.public_district,
a.public_primary_room_slug,
a.primary_room_slug
a.primary_room_slug,
nc.node_name AS home_node_name,
nc.hostname AS home_node_hostname,
nc.roles AS home_node_roles,
nc.environment AS home_node_environment
FROM agents a
LEFT JOIN node_cache nc ON a.node_id = nc.node_id
WHERE a.public_slug = $1
AND a.is_public = true
LIMIT 1
@@ -714,6 +739,17 @@ async def get_public_citizen_by_slug(slug: str) -> Optional[dict]:
agent = dict(agent_row)
agent["public_skills"] = list(agent.get("public_skills") or [])
# Build home_node object
home_node = None
if agent.get("node_id"):
home_node = {
"id": agent.get("node_id"),
"name": agent.get("home_node_name"),
"hostname": agent.get("home_node_hostname"),
"roles": list(agent.get("home_node_roles") or []),
"environment": agent.get("home_node_environment")
}
rooms = await get_agent_rooms(agent["id"])
primary_room = agent.get("public_primary_room_slug") or agent.get("primary_room_slug")
city_presence = {
@@ -765,7 +801,8 @@ async def get_public_citizen_by_slug(slug: str) -> Optional[dict]:
"interaction": interaction,
"metrics_public": metrics_public,
"microdao": microdao,
"admin_panel_url": f"/agents/{agent['id']}"
"admin_panel_url": f"/agents/{agent['id']}",
"home_node": home_node
}