feat(city-map): Add 2D City Map with coordinates and agent presence
- Add migration 013_city_map_coordinates.sql with map coordinates, zones, and agents table - Add /city/map API endpoint in city-service - Add /city/agents and /city/agents/online endpoints - Extend presence aggregator to include agents[] in snapshot - Add AgentsSource for fetching agent data from DB - Create CityMap component with interactive room tiles - Add useCityMap hook for fetching map data - Update useGlobalPresence to include agents - Add map/list view toggle on /city page - Add agent badges to room cards and map tiles
This commit is contained in:
@@ -15,3 +15,4 @@ COPY app ./app
|
||||
# Run the service
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8085"]
|
||||
|
||||
|
||||
|
||||
92
services/matrix-presence-aggregator/app/agents_source.py
Normal file
92
services/matrix-presence-aggregator/app/agents_source.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Agents data source from PostgreSQL"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from typing import List, Dict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentsSource:
|
||||
"""Fetches agent data from PostgreSQL"""
|
||||
|
||||
def __init__(self, db_dsn: str):
|
||||
self.engine = create_engine(db_dsn)
|
||||
|
||||
def get_online_agents(self) -> List[Dict]:
|
||||
"""
|
||||
Get all online/busy agents.
|
||||
|
||||
Returns list of dicts with:
|
||||
- agent_id
|
||||
- display_name
|
||||
- kind
|
||||
- status
|
||||
- room_id (current_room_id)
|
||||
- color
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
id as agent_id,
|
||||
display_name,
|
||||
kind,
|
||||
status,
|
||||
current_room_id as room_id,
|
||||
color
|
||||
FROM agents
|
||||
WHERE status IN ('online', 'busy')
|
||||
ORDER BY display_name
|
||||
""")
|
||||
|
||||
try:
|
||||
with self.engine.connect() as conn:
|
||||
rows = conn.execute(query).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get online agents: {e}")
|
||||
return []
|
||||
|
||||
def get_agents_by_room(self, room_id: str) -> List[Dict]:
|
||||
"""Get agents in a specific room"""
|
||||
query = text("""
|
||||
SELECT
|
||||
id as agent_id,
|
||||
display_name,
|
||||
kind,
|
||||
status,
|
||||
current_room_id as room_id,
|
||||
color
|
||||
FROM agents
|
||||
WHERE current_room_id = :room_id AND status != 'offline'
|
||||
ORDER BY display_name
|
||||
""")
|
||||
|
||||
try:
|
||||
with self.engine.connect() as conn:
|
||||
rows = conn.execute(query, {"room_id": room_id}).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get agents for room {room_id}: {e}")
|
||||
return []
|
||||
|
||||
def get_all_agents(self) -> List[Dict]:
|
||||
"""Get all agents (including offline)"""
|
||||
query = text("""
|
||||
SELECT
|
||||
id as agent_id,
|
||||
display_name,
|
||||
kind,
|
||||
status,
|
||||
current_room_id as room_id,
|
||||
color
|
||||
FROM agents
|
||||
ORDER BY display_name
|
||||
""")
|
||||
|
||||
try:
|
||||
with self.engine.connect() as conn:
|
||||
rows = conn.execute(query).mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get all agents: {e}")
|
||||
return []
|
||||
|
||||
@@ -4,9 +4,10 @@ from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
from .models import PresenceSnapshot, RoomPresence, CityPresence
|
||||
from .models import PresenceSnapshot, RoomPresence, CityPresence, AgentPresence
|
||||
from .matrix_client import MatrixClient
|
||||
from .rooms_source import RoomsSource
|
||||
from .agents_source import AgentsSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,6 +17,7 @@ class PresenceAggregator:
|
||||
Aggregates presence data from Matrix and broadcasts to subscribers.
|
||||
|
||||
- Periodically polls Matrix for room members and presence
|
||||
- Fetches agent status from database
|
||||
- Caches the latest snapshot
|
||||
- Broadcasts updates to SSE subscribers
|
||||
"""
|
||||
@@ -24,10 +26,12 @@ class PresenceAggregator:
|
||||
self,
|
||||
matrix_client: MatrixClient,
|
||||
rooms_source: RoomsSource,
|
||||
agents_source: Optional[AgentsSource] = None,
|
||||
poll_interval_seconds: int = 5,
|
||||
):
|
||||
self.matrix_client = matrix_client
|
||||
self.rooms_source = rooms_source
|
||||
self.agents_source = agents_source
|
||||
self.poll_interval_seconds = poll_interval_seconds
|
||||
|
||||
self._snapshot: Optional[PresenceSnapshot] = None
|
||||
@@ -62,18 +66,47 @@ class PresenceAggregator:
|
||||
pass
|
||||
|
||||
async def _compute_snapshot(self) -> PresenceSnapshot:
|
||||
"""Compute a new presence snapshot from Matrix"""
|
||||
"""Compute a new presence snapshot from Matrix and agents DB"""
|
||||
rooms = self.rooms_source.get_rooms()
|
||||
|
||||
if not rooms:
|
||||
logger.warning("No rooms with matrix_room_id found")
|
||||
|
||||
# Fetch agents from database
|
||||
all_agents: List[AgentPresence] = []
|
||||
agents_by_room: dict = {}
|
||||
|
||||
if self.agents_source:
|
||||
try:
|
||||
online_agents = self.agents_source.get_online_agents()
|
||||
for agent in online_agents:
|
||||
ap = AgentPresence(
|
||||
agent_id=agent["agent_id"],
|
||||
display_name=agent["display_name"],
|
||||
kind=agent.get("kind", "assistant"),
|
||||
status=agent.get("status", "online"),
|
||||
room_id=agent.get("room_id"),
|
||||
color=agent.get("color", "cyan")
|
||||
)
|
||||
all_agents.append(ap)
|
||||
|
||||
# Group by room
|
||||
room_id = agent.get("room_id")
|
||||
if room_id:
|
||||
if room_id not in agents_by_room:
|
||||
agents_by_room[room_id] = []
|
||||
agents_by_room[room_id].append(ap)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching agents: {e}")
|
||||
|
||||
room_presences: List[RoomPresence] = []
|
||||
city_online_total = 0
|
||||
rooms_online = 0
|
||||
|
||||
for r in rooms:
|
||||
matrix_room_id = r["matrix_room_id"]
|
||||
room_id = r["room_id"]
|
||||
|
||||
try:
|
||||
# Get room members
|
||||
@@ -99,24 +132,30 @@ class PresenceAggregator:
|
||||
|
||||
city_online_total += online_count
|
||||
|
||||
# Get agents for this room
|
||||
room_agents = agents_by_room.get(room_id, [])
|
||||
|
||||
room_presences.append(
|
||||
RoomPresence(
|
||||
room_id=r["room_id"],
|
||||
room_id=room_id,
|
||||
matrix_room_id=matrix_room_id,
|
||||
online=online_count,
|
||||
typing=typing_count,
|
||||
agents=room_agents,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing room {r['room_id']}: {e}")
|
||||
# Add room with 0 online
|
||||
logger.error(f"Error processing room {room_id}: {e}")
|
||||
# Add room with 0 online but include agents
|
||||
room_agents = agents_by_room.get(room_id, [])
|
||||
room_presences.append(
|
||||
RoomPresence(
|
||||
room_id=r["room_id"],
|
||||
room_id=room_id,
|
||||
matrix_room_id=matrix_room_id,
|
||||
online=0,
|
||||
typing=0,
|
||||
agents=room_agents,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -125,11 +164,13 @@ class PresenceAggregator:
|
||||
city=CityPresence(
|
||||
online_total=city_online_total,
|
||||
rooms_online=rooms_online,
|
||||
agents_online=len(all_agents),
|
||||
),
|
||||
rooms=room_presences,
|
||||
agents=all_agents,
|
||||
)
|
||||
|
||||
logger.info(f"Computed snapshot: {city_online_total} online in {rooms_online} rooms")
|
||||
logger.info(f"Computed snapshot: {city_online_total} online, {len(all_agents)} agents in {rooms_online} rooms")
|
||||
return snapshot
|
||||
|
||||
async def run_forever(self):
|
||||
@@ -152,3 +193,4 @@ class PresenceAggregator:
|
||||
self._running = False
|
||||
logger.info("Stopping presence aggregator")
|
||||
|
||||
|
||||
|
||||
@@ -34,3 +34,4 @@ def load_settings() -> Settings:
|
||||
presence_daemon_user=os.getenv("PRESENCE_DAEMON_USER", "@presence_daemon:daarion.space"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
from .config import load_settings
|
||||
from .matrix_client import MatrixClient
|
||||
from .rooms_source import RoomsSource, StaticRoomsSource
|
||||
from .agents_source import AgentsSource
|
||||
from .aggregator import PresenceAggregator
|
||||
|
||||
# Configure logging
|
||||
@@ -58,9 +59,19 @@ else:
|
||||
rooms_source = RoomsSource(db_dsn=settings.db_dsn or "postgresql://postgres:postgres@localhost:5432/postgres")
|
||||
logger.warning("No rooms source configured, using default database")
|
||||
|
||||
# Initialize agents source (uses same DB as rooms)
|
||||
agents_source = None
|
||||
if settings.db_dsn:
|
||||
try:
|
||||
agents_source = AgentsSource(db_dsn=settings.db_dsn)
|
||||
logger.info("Agents source initialized")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize agents source: {e}")
|
||||
|
||||
aggregator = PresenceAggregator(
|
||||
matrix_client=matrix_client,
|
||||
rooms_source=rooms_source,
|
||||
agents_source=agents_source,
|
||||
poll_interval_seconds=settings.poll_interval_seconds,
|
||||
)
|
||||
|
||||
@@ -157,3 +168,4 @@ if __name__ == "__main__":
|
||||
reload=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -92,3 +92,4 @@ class MatrixClient:
|
||||
async def close(self):
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
"""Data models for Presence Aggregator"""
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class AgentPresence(BaseModel):
|
||||
"""Agent presence in a room"""
|
||||
agent_id: str
|
||||
display_name: str
|
||||
kind: str = "assistant" # assistant, civic, oracle, builder
|
||||
status: str = "offline" # online, offline, busy
|
||||
room_id: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class RoomPresence(BaseModel):
|
||||
room_id: str # internal room id from DB
|
||||
matrix_room_id: str # Matrix room ID (!xxx:domain)
|
||||
online: int
|
||||
typing: int
|
||||
agents: List[AgentPresence] = [] # Agents present in this room
|
||||
|
||||
|
||||
class CityPresence(BaseModel):
|
||||
online_total: int
|
||||
rooms_online: int
|
||||
agents_online: int = 0
|
||||
|
||||
|
||||
class PresenceSnapshot(BaseModel):
|
||||
@@ -21,4 +33,6 @@ class PresenceSnapshot(BaseModel):
|
||||
timestamp: datetime
|
||||
city: CityPresence
|
||||
rooms: List[RoomPresence]
|
||||
agents: List[AgentPresence] = [] # All online agents
|
||||
|
||||
|
||||
|
||||
@@ -67,3 +67,4 @@ class StaticRoomsSource:
|
||||
def get_rooms(self) -> List[Dict]:
|
||||
return self._rooms
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user