TASK 034: MicroDAO Multi-Room Backend
- Added migration 031_microdao_multi_room.sql
- Extended city_rooms with microdao_id, room_role, is_public, sort_order
- Added CityRoomSummary, MicrodaoRoomsList, MicrodaoRoomUpdate models
- Added get_microdao_rooms, get_microdao_rooms_by_slug functions
- Added attach_room_to_microdao, update_microdao_room functions
- Added API endpoints: GET/POST/PATCH /city/microdao/{slug}/rooms
TASK 035: MicroDAO Multi-Room UI
- Added proxy routes for rooms API
- Extended CityRoomSummary type with multi-room fields
- Added useMicrodaoRooms hook
- Created MicrodaoRoomsSection component with role labels/icons
TASK 036: MicroDAO Room Orchestrator Panel
- Created MicrodaoRoomsAdminPanel component
- Role selector, visibility toggle, set primary button
- Attach existing room form
- Integrated into /microdao/[slug] page
1806 lines
67 KiB
Python
1806 lines
67 KiB
Python
"""
|
||
City Backend API Routes
|
||
"""
|
||
|
||
from fastapi import APIRouter, HTTPException, Depends, Body, Header, Query, Request
|
||
from pydantic import BaseModel
|
||
from typing import List, Optional
|
||
import logging
|
||
import httpx
|
||
import os
|
||
|
||
from models_city import (
|
||
CityRoomRead,
|
||
CityRoomCreate,
|
||
CityRoomDetail,
|
||
CityRoomMessageRead,
|
||
CityRoomMessageCreate,
|
||
CityFeedEventRead,
|
||
CityMapRoom,
|
||
CityMapConfig,
|
||
CityMapResponse,
|
||
AgentRead,
|
||
AgentPresence,
|
||
AgentSummary,
|
||
MicrodaoBadge,
|
||
HomeNodeView,
|
||
NodeProfile,
|
||
PublicCitizenSummary,
|
||
PublicCitizenProfile,
|
||
CitizenInteractionInfo,
|
||
CitizenAskRequest,
|
||
CitizenAskResponse,
|
||
AgentMicrodaoMembership,
|
||
MicrodaoSummary,
|
||
MicrodaoDetail,
|
||
MicrodaoAgentView,
|
||
MicrodaoChannelView,
|
||
MicrodaoCitizenView,
|
||
MicrodaoOption
|
||
)
|
||
import repo_city
|
||
from common.redis_client import PresenceRedis, get_redis
|
||
from matrix_client import create_matrix_room, find_matrix_room_by_alias
|
||
from dagi_router_client import get_dagi_router_client, DagiRouterClient
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# JWT validation (simplified for MVP)
|
||
AUTH_SERVICE_URL = os.getenv("AUTH_SERVICE_URL", "http://daarion-auth:7020")
|
||
MATRIX_GATEWAY_URL = os.getenv("MATRIX_GATEWAY_URL", "http://daarion-matrix-gateway:7025")
|
||
|
||
router = APIRouter(prefix="/city", tags=["city"])
|
||
public_router = APIRouter(prefix="/public", tags=["public"])
|
||
api_router = APIRouter(prefix="/api/v1", tags=["api_v1"])
|
||
|
||
|
||
class MicrodaoMembershipPayload(BaseModel):
|
||
microdao_id: str
|
||
role: Optional[str] = None
|
||
is_core: bool = False
|
||
|
||
|
||
# =============================================================================
|
||
# Agents API (for Agent Console)
|
||
# =============================================================================
|
||
|
||
@public_router.get("/agents")
|
||
async def list_agents(
|
||
kind: Optional[str] = Query(None, description="Filter by agent kind"),
|
||
node_id: Optional[str] = Query(None, description="Filter by node_id"),
|
||
microdao_id: Optional[str] = Query(None, description="Filter by microDAO id"),
|
||
is_public: Optional[bool] = Query(None, description="Filter by public status"),
|
||
visibility_scope: Optional[str] = Query(None, description="Filter by visibility: global, microdao, private"),
|
||
include_system: bool = Query(True, description="Include system agents"),
|
||
limit: int = Query(100, le=200),
|
||
offset: int = Query(0, ge=0)
|
||
):
|
||
"""Список всіх агентів для Agent Console (unified API)"""
|
||
try:
|
||
kinds_list = [kind] if kind else None
|
||
agents, total = await repo_city.list_agent_summaries(
|
||
node_id=node_id,
|
||
microdao_id=microdao_id,
|
||
is_public=is_public,
|
||
visibility_scope=visibility_scope,
|
||
kinds=kinds_list,
|
||
include_system=include_system,
|
||
limit=limit,
|
||
offset=offset
|
||
)
|
||
|
||
items: List[AgentSummary] = []
|
||
for agent in agents:
|
||
# Build home_node if available
|
||
home_node_data = agent.get("home_node")
|
||
home_node = None
|
||
if home_node_data:
|
||
home_node = HomeNodeView(
|
||
id=home_node_data.get("id"),
|
||
name=home_node_data.get("name"),
|
||
hostname=home_node_data.get("hostname"),
|
||
roles=home_node_data.get("roles", []),
|
||
environment=home_node_data.get("environment")
|
||
)
|
||
|
||
# Build microdao badges
|
||
microdaos = [
|
||
MicrodaoBadge(
|
||
id=m.get("id", ""),
|
||
name=m.get("name", ""),
|
||
slug=m.get("slug"),
|
||
role=m.get("role")
|
||
)
|
||
for m in agent.get("microdaos", [])
|
||
]
|
||
|
||
items.append(AgentSummary(
|
||
id=agent["id"],
|
||
slug=agent.get("slug"),
|
||
display_name=agent["display_name"],
|
||
title=agent.get("title"),
|
||
tagline=agent.get("tagline"),
|
||
kind=agent.get("kind", "assistant"),
|
||
avatar_url=agent.get("avatar_url"),
|
||
status=agent.get("status", "offline"),
|
||
node_id=agent.get("node_id"),
|
||
node_label=agent.get("node_label"),
|
||
home_node=home_node,
|
||
visibility_scope=agent.get("visibility_scope", "city"),
|
||
is_listed_in_directory=agent.get("is_listed_in_directory", True),
|
||
is_system=agent.get("is_system", False),
|
||
is_public=agent.get("is_public", False),
|
||
is_orchestrator=agent.get("is_orchestrator", False),
|
||
primary_microdao_id=agent.get("primary_microdao_id"),
|
||
primary_microdao_name=agent.get("primary_microdao_name"),
|
||
primary_microdao_slug=agent.get("primary_microdao_slug"),
|
||
district=agent.get("district"),
|
||
microdaos=microdaos,
|
||
microdao_memberships=agent.get("microdao_memberships", []),
|
||
public_skills=agent.get("public_skills", [])
|
||
))
|
||
|
||
return {"items": items, "total": total}
|
||
except Exception as e:
|
||
logger.error(f"Failed to list agents: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to list agents")
|
||
|
||
|
||
class AgentVisibilityPayload(BaseModel):
|
||
"""Agent visibility update payload (Task 029)"""
|
||
is_public: bool
|
||
visibility_scope: Optional[str] = None # 'global' | 'microdao' | 'private'
|
||
|
||
|
||
@router.put("/agents/{agent_id}/visibility")
|
||
async def update_agent_visibility_endpoint(
|
||
agent_id: str,
|
||
payload: AgentVisibilityPayload
|
||
):
|
||
"""Оновити налаштування видимості агента (Task 029)"""
|
||
try:
|
||
# Validate visibility_scope if provided
|
||
valid_scopes = ("global", "microdao", "private", "city", "owner_only") # support legacy too
|
||
if payload.visibility_scope and payload.visibility_scope not in valid_scopes:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"visibility_scope must be one of: {', '.join(valid_scopes)}"
|
||
)
|
||
|
||
# Normalize legacy values
|
||
scope = payload.visibility_scope
|
||
if scope == "city":
|
||
scope = "global"
|
||
elif scope == "owner_only":
|
||
scope = "private"
|
||
|
||
# Update in database
|
||
result = await repo_city.update_agent_visibility(
|
||
agent_id=agent_id,
|
||
is_public=payload.is_public,
|
||
visibility_scope=scope,
|
||
)
|
||
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Agent not found")
|
||
|
||
return {
|
||
"status": "ok",
|
||
"agent_id": agent_id,
|
||
"is_public": result.get("is_public"),
|
||
"visibility_scope": result.get("visibility_scope"),
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to update agent visibility: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to update visibility")
|
||
|
||
|
||
# =============================================================================
|
||
# Nodes API (for Node Directory)
|
||
# =============================================================================
|
||
|
||
@public_router.get("/nodes")
|
||
async def list_nodes():
|
||
"""Список всіх нод мережі"""
|
||
try:
|
||
nodes = await repo_city.get_all_nodes()
|
||
|
||
items: List[NodeProfile] = []
|
||
for node in nodes:
|
||
items.append(NodeProfile(
|
||
node_id=node["node_id"],
|
||
name=node["name"],
|
||
hostname=node.get("hostname"),
|
||
roles=list(node.get("roles") or []),
|
||
environment=node.get("environment", "unknown"),
|
||
status=node.get("status", "offline"),
|
||
gpu_info=node.get("gpu"),
|
||
agents_total=node.get("agents_total", 0),
|
||
agents_online=node.get("agents_online", 0),
|
||
last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None
|
||
))
|
||
|
||
return {"items": items, "total": len(items)}
|
||
except Exception as e:
|
||
logger.error(f"Failed to list nodes: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to list nodes")
|
||
|
||
|
||
@public_router.get("/nodes/{node_id}")
|
||
async def get_node_profile(node_id: str):
|
||
"""Отримати профіль ноди з Guardian та Steward агентами"""
|
||
try:
|
||
node = await repo_city.get_node_by_id(node_id)
|
||
if not node:
|
||
raise HTTPException(status_code=404, detail="Node not found")
|
||
|
||
# Build guardian agent summary
|
||
guardian_agent = None
|
||
if node.get("guardian_agent"):
|
||
from models_city import NodeAgentSummary
|
||
guardian_agent = NodeAgentSummary(
|
||
id=node["guardian_agent"]["id"],
|
||
name=node["guardian_agent"]["name"],
|
||
kind=node["guardian_agent"].get("kind"),
|
||
slug=node["guardian_agent"].get("slug")
|
||
)
|
||
|
||
# Build steward agent summary
|
||
steward_agent = None
|
||
if node.get("steward_agent"):
|
||
from models_city import NodeAgentSummary
|
||
steward_agent = NodeAgentSummary(
|
||
id=node["steward_agent"]["id"],
|
||
name=node["steward_agent"]["name"],
|
||
kind=node["steward_agent"].get("kind"),
|
||
slug=node["steward_agent"].get("slug")
|
||
)
|
||
|
||
return NodeProfile(
|
||
node_id=node["node_id"],
|
||
name=node["name"],
|
||
hostname=node.get("hostname"),
|
||
roles=list(node.get("roles") or []),
|
||
environment=node.get("environment", "unknown"),
|
||
status=node.get("status", "offline"),
|
||
gpu_info=node.get("gpu"),
|
||
agents_total=node.get("agents_total", 0),
|
||
agents_online=node.get("agents_online", 0),
|
||
last_heartbeat=str(node["last_heartbeat"]) if node.get("last_heartbeat") else None,
|
||
guardian_agent_id=node.get("guardian_agent_id"),
|
||
steward_agent_id=node.get("steward_agent_id"),
|
||
guardian_agent=guardian_agent,
|
||
steward_agent=steward_agent
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get node {node_id}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get node")
|
||
|
||
|
||
# =============================================================================
|
||
# Public Citizens API
|
||
# =============================================================================
|
||
|
||
@public_router.get("/citizens")
|
||
async def list_public_citizens(
|
||
district: Optional[str] = Query(None, description="Filter by district"),
|
||
kind: Optional[str] = Query(None, description="Filter by agent kind"),
|
||
q: Optional[str] = Query(None, description="Search by display name or title"),
|
||
limit: int = Query(50, le=100),
|
||
offset: int = Query(0, ge=0)
|
||
):
|
||
"""Публічний список громадян з фільтрами"""
|
||
try:
|
||
citizens, total = await repo_city.get_public_citizens(
|
||
district=district,
|
||
kind=kind,
|
||
q=q,
|
||
limit=limit,
|
||
offset=offset
|
||
)
|
||
|
||
items: List[PublicCitizenSummary] = []
|
||
for citizen in citizens:
|
||
# Build home_node if available
|
||
home_node_data = citizen.get("home_node")
|
||
home_node = None
|
||
if home_node_data:
|
||
home_node = HomeNodeView(
|
||
id=home_node_data.get("id"),
|
||
name=home_node_data.get("name"),
|
||
hostname=home_node_data.get("hostname"),
|
||
roles=home_node_data.get("roles", []),
|
||
environment=home_node_data.get("environment")
|
||
)
|
||
|
||
items.append(PublicCitizenSummary(
|
||
slug=citizen["public_slug"],
|
||
display_name=citizen["display_name"],
|
||
public_title=citizen.get("public_title"),
|
||
public_tagline=citizen.get("public_tagline"),
|
||
avatar_url=citizen.get("avatar_url"),
|
||
kind=citizen.get("kind"),
|
||
district=citizen.get("public_district"),
|
||
primary_room_slug=citizen.get("public_primary_room_slug"),
|
||
public_skills=citizen.get("public_skills", []),
|
||
online_status=citizen.get("online_status"),
|
||
status=citizen.get("status"),
|
||
home_node=home_node
|
||
))
|
||
|
||
return {"items": items, "total": total}
|
||
except Exception as e:
|
||
logger.error(f"Failed to list public citizens: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to list public citizens")
|
||
|
||
|
||
@public_router.get("/citizens/{slug}")
|
||
async def get_public_citizen(slug: str, request: Request):
|
||
"""Отримати публічний профіль громадянина"""
|
||
try:
|
||
include_admin_url = False
|
||
authorization = request.headers.get("Authorization")
|
||
if authorization:
|
||
user_info = await validate_jwt_token(authorization)
|
||
if user_info:
|
||
roles = user_info.get("roles", [])
|
||
if any(role in ["admin", "architect"] for role in roles):
|
||
include_admin_url = True
|
||
|
||
citizen = await repo_city.get_public_citizen_by_slug(slug)
|
||
if not citizen:
|
||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||
|
||
if not include_admin_url:
|
||
citizen["admin_panel_url"] = None
|
||
|
||
return PublicCitizenProfile(**citizen)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get public citizen {slug}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get citizen")
|
||
|
||
|
||
@public_router.get("/citizens/{slug}/interaction", response_model=CitizenInteractionInfo)
|
||
async def get_citizen_interaction_info(slug: str):
|
||
"""Отримати інформацію для взаємодії з громадянином"""
|
||
try:
|
||
agent = await repo_city.get_public_agent_by_slug(slug)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||
|
||
matrix_config = await repo_city.get_agent_matrix_config(agent["id"])
|
||
matrix_user_id = matrix_config.get("matrix_user_id") if matrix_config else None
|
||
|
||
primary_room_slug = agent.get("public_primary_room_slug") or agent.get("primary_room_slug")
|
||
primary_room_id = matrix_config.get("primary_room_id") if matrix_config else None
|
||
primary_room_name = None
|
||
room_record = None
|
||
|
||
if primary_room_id:
|
||
room_record = await repo_city.get_room_by_id(primary_room_id)
|
||
elif primary_room_slug:
|
||
room_record = await repo_city.get_room_by_slug(primary_room_slug)
|
||
|
||
if room_record:
|
||
primary_room_id = room_record.get("id")
|
||
primary_room_name = room_record.get("name")
|
||
primary_room_slug = room_record.get("slug") or primary_room_slug
|
||
|
||
microdao = await repo_city.get_microdao_for_agent(agent["id"])
|
||
|
||
return CitizenInteractionInfo(
|
||
slug=slug,
|
||
display_name=agent["display_name"],
|
||
primary_room_slug=primary_room_slug,
|
||
primary_room_id=primary_room_id,
|
||
primary_room_name=primary_room_name,
|
||
matrix_user_id=matrix_user_id,
|
||
district=agent.get("public_district"),
|
||
microdao_slug=microdao.get("slug") if microdao else None,
|
||
microdao_name=microdao.get("name") if microdao else None,
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get interaction info for citizen {slug}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to load interaction info")
|
||
|
||
|
||
@public_router.post("/citizens/{slug}/ask", response_model=CitizenAskResponse)
|
||
async def ask_citizen(
|
||
slug: str,
|
||
payload: CitizenAskRequest,
|
||
router_client: DagiRouterClient = Depends(get_dagi_router_client),
|
||
):
|
||
"""Надіслати запитання громадянину через DAGI Router"""
|
||
question = (payload.question or "").strip()
|
||
if not question:
|
||
raise HTTPException(status_code=400, detail="Question is required")
|
||
|
||
try:
|
||
agent = await repo_city.get_public_agent_by_slug(slug)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||
|
||
router_response = await router_client.ask_agent(
|
||
agent_id=agent["id"],
|
||
prompt=question,
|
||
system_prompt=payload.context,
|
||
)
|
||
|
||
answer = (
|
||
router_response.get("response")
|
||
or router_response.get("answer")
|
||
or router_response.get("result")
|
||
)
|
||
if answer:
|
||
answer = answer.strip()
|
||
if not answer:
|
||
answer = "Вибач, агент наразі не може відповісти."
|
||
|
||
return CitizenAskResponse(
|
||
answer=answer,
|
||
agent_display_name=agent["display_name"],
|
||
agent_id=agent["id"],
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except httpx.HTTPError as e:
|
||
logger.error(f"DAGI Router request failed for citizen {slug}: {e}")
|
||
raise HTTPException(status_code=502, detail="Citizen is temporarily unavailable")
|
||
except Exception as e:
|
||
logger.error(f"Failed to ask citizen {slug}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to ask citizen")
|
||
|
||
|
||
# =============================================================================
|
||
# API v1 — MicroDAO Membership
|
||
# =============================================================================
|
||
|
||
@api_router.get("/microdao/options")
|
||
async def get_microdao_options():
|
||
"""Отримати список MicroDAO для селектора"""
|
||
try:
|
||
options = await repo_city.get_microdao_options()
|
||
items = [MicrodaoOption(**option) for option in options]
|
||
return {"items": items}
|
||
except Exception as e:
|
||
logger.error(f"Failed to get microdao options: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get microdao options")
|
||
|
||
|
||
@api_router.put("/agents/{agent_id}/microdao-membership")
|
||
async def assign_agent_microdao_membership(
|
||
agent_id: str,
|
||
payload: MicrodaoMembershipPayload,
|
||
authorization: Optional[str] = Header(None)
|
||
):
|
||
"""Призначити/оновити членство агента в MicroDAO"""
|
||
await ensure_architect_or_admin(authorization)
|
||
|
||
try:
|
||
membership = await repo_city.upsert_agent_microdao_membership(
|
||
agent_id=agent_id,
|
||
microdao_id=payload.microdao_id,
|
||
role=payload.role,
|
||
is_core=payload.is_core
|
||
)
|
||
if not membership:
|
||
raise HTTPException(status_code=404, detail="MicroDAO not found")
|
||
return membership
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to assign microdao membership: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to assign microdao membership")
|
||
|
||
|
||
@api_router.delete("/agents/{agent_id}/microdao-membership/{microdao_id}")
|
||
async def delete_agent_microdao_membership(
|
||
agent_id: str,
|
||
microdao_id: str,
|
||
authorization: Optional[str] = Header(None)
|
||
):
|
||
"""Видалити членство агента в MicroDAO"""
|
||
await ensure_architect_or_admin(authorization)
|
||
|
||
try:
|
||
deleted = await repo_city.remove_agent_microdao_membership(agent_id, microdao_id)
|
||
if not deleted:
|
||
raise HTTPException(status_code=404, detail="Membership not found")
|
||
return {"status": "deleted"}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to delete microdao membership: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to delete microdao membership")
|
||
|
||
|
||
# =============================================================================
|
||
# City Rooms API
|
||
# =============================================================================
|
||
|
||
@router.get("/rooms", response_model=List[CityRoomRead])
|
||
async def get_city_rooms(limit: int = 100, offset: int = 0):
|
||
"""
|
||
Отримати список всіх City Rooms
|
||
"""
|
||
try:
|
||
rooms = await repo_city.get_all_rooms(limit=limit, offset=offset)
|
||
|
||
# Додати online count (приблизно)
|
||
online_count = await PresenceRedis.get_online_count()
|
||
|
||
result = []
|
||
for room in rooms:
|
||
result.append({
|
||
**room,
|
||
"members_online": online_count if room.get("is_default") else max(1, online_count // 2),
|
||
"last_event": None # TODO: з останнього повідомлення
|
||
})
|
||
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city rooms: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city rooms")
|
||
|
||
|
||
@router.post("/rooms", response_model=CityRoomRead)
|
||
async def create_city_room(payload: CityRoomCreate):
|
||
"""
|
||
Створити нову City Room (автоматично створює Matrix room)
|
||
"""
|
||
try:
|
||
# TODO: витягнути user_id з JWT
|
||
created_by = "u_system" # Mock для MVP
|
||
|
||
# Перевірити чи не існує вже
|
||
existing = await repo_city.get_room_by_slug(payload.slug)
|
||
if existing:
|
||
raise HTTPException(status_code=409, detail="Room with this slug already exists")
|
||
|
||
# Створити Matrix room
|
||
matrix_room_id, matrix_room_alias = await create_matrix_room(
|
||
slug=payload.slug,
|
||
name=payload.name,
|
||
visibility="public"
|
||
)
|
||
|
||
if not matrix_room_id:
|
||
logger.warning(f"Failed to create Matrix room for {payload.slug}, proceeding without Matrix")
|
||
|
||
room = await repo_city.create_room(
|
||
slug=payload.slug,
|
||
name=payload.name,
|
||
description=payload.description,
|
||
created_by=created_by,
|
||
matrix_room_id=matrix_room_id,
|
||
matrix_room_alias=matrix_room_alias
|
||
)
|
||
|
||
# Додати початкове повідомлення
|
||
await repo_city.create_room_message(
|
||
room_id=room["id"],
|
||
body=f"Кімната '{payload.name}' створена! Ласкаво просимо! 🎉",
|
||
author_agent_id="ag_system"
|
||
)
|
||
|
||
# Додати в feed
|
||
await repo_city.create_feed_event(
|
||
kind="system",
|
||
room_id=room["id"],
|
||
payload={"action": "room_created", "room_name": payload.name, "matrix_room_id": matrix_room_id}
|
||
)
|
||
|
||
return {**room, "members_online": 1, "last_event": None}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to create city room: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to create city room")
|
||
|
||
|
||
@router.get("/rooms/{room_id}", response_model=CityRoomDetail)
|
||
async def get_city_room(room_id: str):
|
||
"""
|
||
Отримати деталі City Room з повідомленнями
|
||
"""
|
||
try:
|
||
room = await repo_city.get_room_by_id(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
|
||
messages = await repo_city.get_room_messages(room_id, limit=50)
|
||
|
||
# Додати username до повідомлень
|
||
for msg in messages:
|
||
if msg.get("author_user_id"):
|
||
msg["username"] = f"User-{msg['author_user_id'][-4:]}" # Mock
|
||
elif msg.get("author_agent_id"):
|
||
msg["username"] = "System Agent"
|
||
else:
|
||
msg["username"] = "Anonymous"
|
||
|
||
online_users = await PresenceRedis.get_all_online()
|
||
|
||
return {
|
||
**room,
|
||
"members_online": len(online_users),
|
||
"last_event": None,
|
||
"messages": messages,
|
||
"online_members": online_users[:20] # Перші 20
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city room: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city room")
|
||
|
||
|
||
@router.post("/rooms/{room_id}/messages", response_model=CityRoomMessageRead)
|
||
async def send_city_room_message(room_id: str, payload: CityRoomMessageCreate):
|
||
"""
|
||
Надіслати повідомлення в City Room
|
||
"""
|
||
try:
|
||
# Перевірити чи кімната існує
|
||
room = await repo_city.get_room_by_id(room_id)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
|
||
# TODO: витягнути user_id з JWT
|
||
author_user_id = "u_mock_user" # Mock для MVP
|
||
|
||
# Створити повідомлення
|
||
message = await repo_city.create_room_message(
|
||
room_id=room_id,
|
||
body=payload.body,
|
||
author_user_id=author_user_id
|
||
)
|
||
|
||
# Додати в feed
|
||
await repo_city.create_feed_event(
|
||
kind="room_message",
|
||
room_id=room_id,
|
||
user_id=author_user_id,
|
||
payload={"body": payload.body[:100], "message_id": message["id"]}
|
||
)
|
||
|
||
# TODO: Broadcast WS event
|
||
# await ws_manager.broadcast_to_room(room_id, {
|
||
# "event": "room.message",
|
||
# "message": message
|
||
# })
|
||
|
||
# Додати username
|
||
message["username"] = f"User-{author_user_id[-4:]}"
|
||
|
||
return message
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to send room message: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to send message")
|
||
|
||
|
||
@router.post("/rooms/{room_id}/join")
|
||
async def join_city_room(room_id: str):
|
||
"""
|
||
Приєднатися до City Room (для tracking)
|
||
"""
|
||
# TODO: витягнути user_id з JWT
|
||
user_id = "u_mock_user"
|
||
|
||
# Для MVP просто повертаємо success
|
||
# У production можна зберігати active memberships в Redis
|
||
|
||
logger.info(f"User {user_id} joined room {room_id}")
|
||
return {"status": "joined", "room_id": room_id}
|
||
|
||
|
||
@router.post("/rooms/{room_id}/leave")
|
||
async def leave_city_room(room_id: str):
|
||
"""
|
||
Покинути City Room
|
||
"""
|
||
# TODO: витягнути user_id з JWT
|
||
user_id = "u_mock_user"
|
||
|
||
logger.info(f"User {user_id} left room {room_id}")
|
||
return {"status": "left", "room_id": room_id}
|
||
|
||
|
||
# =============================================================================
|
||
# Matrix Backfill API (Internal)
|
||
# =============================================================================
|
||
|
||
@router.post("/matrix/backfill")
|
||
async def backfill_matrix_rooms():
|
||
"""
|
||
Backfill Matrix rooms for existing City Rooms that don't have Matrix integration.
|
||
This is an internal endpoint for admin use.
|
||
"""
|
||
try:
|
||
rooms_without_matrix = await repo_city.get_rooms_without_matrix()
|
||
|
||
results = {
|
||
"processed": 0,
|
||
"created": 0,
|
||
"found": 0,
|
||
"failed": 0,
|
||
"details": []
|
||
}
|
||
|
||
for room in rooms_without_matrix:
|
||
results["processed"] += 1
|
||
slug = room["slug"]
|
||
name = room["name"]
|
||
room_id = room["id"]
|
||
|
||
# Спочатку спробувати знайти існуючу Matrix room
|
||
alias = f"#city_{slug}:daarion.space"
|
||
matrix_room_id, matrix_room_alias = await find_matrix_room_by_alias(alias)
|
||
|
||
if matrix_room_id:
|
||
# Знайдено існуючу
|
||
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
|
||
results["found"] += 1
|
||
results["details"].append({
|
||
"room_id": room_id,
|
||
"slug": slug,
|
||
"status": "found",
|
||
"matrix_room_id": matrix_room_id
|
||
})
|
||
else:
|
||
# Створити нову
|
||
matrix_room_id, matrix_room_alias = await create_matrix_room(slug, name, "public")
|
||
|
||
if matrix_room_id:
|
||
await repo_city.update_room_matrix(room_id, matrix_room_id, matrix_room_alias)
|
||
results["created"] += 1
|
||
results["details"].append({
|
||
"room_id": room_id,
|
||
"slug": slug,
|
||
"status": "created",
|
||
"matrix_room_id": matrix_room_id
|
||
})
|
||
else:
|
||
results["failed"] += 1
|
||
results["details"].append({
|
||
"room_id": room_id,
|
||
"slug": slug,
|
||
"status": "failed",
|
||
"error": "Could not create Matrix room"
|
||
})
|
||
|
||
logger.info(f"Matrix backfill completed: {results['processed']} processed, "
|
||
f"{results['created']} created, {results['found']} found, {results['failed']} failed")
|
||
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"Matrix backfill failed: {e}")
|
||
raise HTTPException(status_code=500, detail=f"Backfill failed: {str(e)}")
|
||
|
||
|
||
# =============================================================================
|
||
# Chat Bootstrap API (Matrix Integration)
|
||
# =============================================================================
|
||
|
||
async def validate_jwt_token(authorization: str) -> Optional[dict]:
|
||
"""Validate JWT token via auth-service introspect endpoint."""
|
||
if not authorization or not authorization.startswith("Bearer "):
|
||
return None
|
||
|
||
token = authorization.replace("Bearer ", "")
|
||
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
try:
|
||
resp = await client.post(
|
||
f"{AUTH_SERVICE_URL}/api/auth/introspect",
|
||
json={"token": token}
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
if data.get("active"):
|
||
return {"user_id": data.get("sub"), "email": data.get("email"), "roles": data.get("roles", [])}
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"JWT validation error: {e}")
|
||
return None
|
||
|
||
|
||
async def ensure_architect_or_admin(authorization: Optional[str]) -> dict:
|
||
"""Переконатися, що користувач має роль architect/admin"""
|
||
if not authorization:
|
||
raise HTTPException(status_code=403, detail="Missing authorization token")
|
||
|
||
user_info = await validate_jwt_token(authorization)
|
||
if not user_info:
|
||
raise HTTPException(status_code=403, detail="Invalid authorization token")
|
||
|
||
roles = user_info.get("roles", [])
|
||
if not any(role in ["admin", "architect"] for role in roles):
|
||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||
|
||
return user_info
|
||
|
||
|
||
@router.get("/chat/bootstrap")
|
||
async def chat_bootstrap(
|
||
room_slug: str = Query(..., description="City room slug"),
|
||
authorization: Optional[str] = Header(None)
|
||
):
|
||
"""
|
||
Bootstrap Matrix chat for a city room.
|
||
|
||
Returns Matrix credentials and room info for the authenticated user.
|
||
"""
|
||
# Validate JWT
|
||
user_info = await validate_jwt_token(authorization)
|
||
if not user_info:
|
||
raise HTTPException(status_code=401, detail="Invalid or missing authorization token")
|
||
|
||
user_id = user_info.get("user_id")
|
||
if not user_id:
|
||
raise HTTPException(status_code=401, detail="Invalid token: missing user_id")
|
||
|
||
# Get room by slug
|
||
room = await repo_city.get_room_by_slug(room_slug)
|
||
if not room:
|
||
raise HTTPException(status_code=404, detail=f"Room '{room_slug}' not found")
|
||
|
||
# Check if room has Matrix integration
|
||
matrix_room_id = room.get("matrix_room_id")
|
||
matrix_room_alias = room.get("matrix_room_alias")
|
||
|
||
if not matrix_room_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Room does not have Matrix integration. Run /city/matrix/backfill first."
|
||
)
|
||
|
||
# Get Matrix user token from matrix-gateway
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
try:
|
||
token_resp = await client.post(
|
||
f"{MATRIX_GATEWAY_URL}/internal/matrix/users/token",
|
||
json={"user_id": user_id}
|
||
)
|
||
|
||
if token_resp.status_code != 200:
|
||
error = token_resp.json()
|
||
logger.error(f"Failed to get Matrix token: {error}")
|
||
raise HTTPException(status_code=500, detail="Failed to get Matrix credentials")
|
||
|
||
matrix_creds = token_resp.json()
|
||
|
||
except httpx.RequestError as e:
|
||
logger.error(f"Matrix gateway request error: {e}")
|
||
raise HTTPException(status_code=503, detail="Matrix service unavailable")
|
||
|
||
# Return bootstrap data
|
||
return {
|
||
"matrix_hs_url": f"https://app.daarion.space", # Through nginx proxy
|
||
"matrix_user_id": matrix_creds["matrix_user_id"],
|
||
"matrix_access_token": matrix_creds["access_token"],
|
||
"matrix_device_id": matrix_creds["device_id"],
|
||
"matrix_room_id": matrix_room_id,
|
||
"matrix_room_alias": matrix_room_alias,
|
||
"room": {
|
||
"id": room["id"],
|
||
"slug": room["slug"],
|
||
"name": room["name"],
|
||
"description": room.get("description")
|
||
}
|
||
}
|
||
|
||
|
||
# =============================================================================
|
||
# City Feed API
|
||
# =============================================================================
|
||
|
||
@router.get("/feed", response_model=List[CityFeedEventRead])
|
||
async def get_city_feed(limit: int = 20, offset: int = 0):
|
||
"""
|
||
Отримати City Feed (останні події)
|
||
"""
|
||
try:
|
||
events = await repo_city.get_feed_events(limit=limit, offset=offset)
|
||
return events
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city feed: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city feed")
|
||
|
||
|
||
# =============================================================================
|
||
# City Map API (2D Map)
|
||
# =============================================================================
|
||
|
||
@router.get("/map", response_model=CityMapResponse)
|
||
async def get_city_map():
|
||
"""
|
||
Отримати дані для 2D мапи міста.
|
||
|
||
Повертає:
|
||
- config: розміри сітки та налаштування
|
||
- rooms: список кімнат з координатами
|
||
"""
|
||
try:
|
||
# Отримати конфігурацію
|
||
config_data = await repo_city.get_map_config()
|
||
config = CityMapConfig(
|
||
grid_width=config_data.get("grid_width", 6),
|
||
grid_height=config_data.get("grid_height", 3),
|
||
cell_size=config_data.get("cell_size", 100),
|
||
background_url=config_data.get("background_url")
|
||
)
|
||
|
||
# Отримати кімнати з координатами
|
||
rooms_data = await repo_city.get_rooms_for_map()
|
||
rooms = []
|
||
|
||
for room in rooms_data:
|
||
rooms.append(CityMapRoom(
|
||
id=room["id"],
|
||
slug=room["slug"],
|
||
name=room["name"],
|
||
description=room.get("description"),
|
||
room_type=room.get("room_type", "public"),
|
||
zone=room.get("zone", "central"),
|
||
icon=room.get("icon"),
|
||
color=room.get("color"),
|
||
x=room.get("map_x", 0),
|
||
y=room.get("map_y", 0),
|
||
w=room.get("map_w", 1),
|
||
h=room.get("map_h", 1),
|
||
matrix_room_id=room.get("matrix_room_id")
|
||
))
|
||
|
||
return CityMapResponse(config=config, rooms=rooms)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get city map: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get city map")
|
||
|
||
|
||
# =============================================================================
|
||
# Agents API
|
||
# =============================================================================
|
||
|
||
@router.put("/agents/{agent_id}/public-profile")
|
||
async def update_agent_public_profile(agent_id: str, request: Request):
|
||
"""
|
||
Оновити публічний профіль агента.
|
||
Тільки для Architect/Admin.
|
||
"""
|
||
try:
|
||
# Check agent exists
|
||
agent = await repo_city.get_agent_by_id(agent_id)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
|
||
|
||
# Parse body
|
||
body = await request.json()
|
||
|
||
is_public = body.get("is_public", False)
|
||
public_slug = body.get("public_slug")
|
||
public_title = body.get("public_title")
|
||
public_tagline = body.get("public_tagline")
|
||
public_skills = body.get("public_skills", [])
|
||
public_district = body.get("public_district")
|
||
public_primary_room_slug = body.get("public_primary_room_slug")
|
||
|
||
# Validate: if is_public, slug is required
|
||
if is_public and not public_slug:
|
||
raise HTTPException(status_code=400, detail="public_slug is required when is_public is true")
|
||
|
||
# Validate slug format
|
||
if public_slug:
|
||
import re
|
||
if not re.match(r'^[a-z0-9_-]+$', public_slug.lower()):
|
||
raise HTTPException(status_code=400, detail="public_slug must contain only lowercase letters, numbers, underscores, and hyphens")
|
||
|
||
# Validate skills (max 10, max 64 chars each)
|
||
if public_skills:
|
||
public_skills = [s[:64] for s in public_skills[:10]]
|
||
|
||
# Update
|
||
result = await repo_city.update_agent_public_profile(
|
||
agent_id=agent_id,
|
||
is_public=is_public,
|
||
public_slug=public_slug,
|
||
public_title=public_title,
|
||
public_tagline=public_tagline,
|
||
public_skills=public_skills,
|
||
public_district=public_district,
|
||
public_primary_room_slug=public_primary_room_slug
|
||
)
|
||
|
||
logger.info(f"Updated public profile for agent {agent_id}: is_public={is_public}, slug={public_slug}")
|
||
|
||
return result
|
||
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to update agent public profile: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail="Failed to update agent public profile")
|
||
|
||
|
||
@router.get("/citizens")
|
||
async def get_public_citizens_legacy(limit: int = 50, offset: int = 0):
|
||
"""
|
||
Отримати список публічних громадян DAARION City.
|
||
"""
|
||
try:
|
||
citizens, total = await repo_city.get_public_citizens(limit=limit, offset=offset)
|
||
return {"citizens": citizens, "total": total}
|
||
except Exception as e:
|
||
logger.error(f"Failed to get public citizens: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get public citizens")
|
||
|
||
|
||
@router.get("/citizens/{slug}")
|
||
async def get_citizen_by_slug(slug: str, request: Request):
|
||
"""
|
||
Отримати публічного громадянина за slug.
|
||
Для адмінів/архітекторів додається admin_panel_url.
|
||
"""
|
||
try:
|
||
include_admin_url = True # legacy endpoint доступний тільки з адмінської панелі
|
||
|
||
citizen = await repo_city.get_public_citizen_by_slug(slug)
|
||
if not citizen:
|
||
raise HTTPException(status_code=404, detail=f"Citizen not found: {slug}")
|
||
|
||
if not include_admin_url:
|
||
citizen["admin_panel_url"] = None
|
||
|
||
return citizen
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get citizen: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get citizen")
|
||
|
||
|
||
@router.put("/agents/{agent_id}/prompts/{kind}")
|
||
async def update_agent_prompt(agent_id: str, kind: str, request: Request):
|
||
"""
|
||
Оновити системний промт агента.
|
||
Тільки для Architect/Admin.
|
||
kind: core | safety | governance | tools
|
||
"""
|
||
try:
|
||
# Validate kind
|
||
valid_kinds = ["core", "safety", "governance", "tools"]
|
||
if kind not in valid_kinds:
|
||
raise HTTPException(status_code=400, detail=f"Invalid kind. Must be one of: {valid_kinds}")
|
||
|
||
# Check agent exists
|
||
agent = await repo_city.get_agent_by_id(agent_id)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
|
||
|
||
# Parse body
|
||
body = await request.json()
|
||
content = body.get("content")
|
||
note = body.get("note")
|
||
|
||
if not content or not content.strip():
|
||
raise HTTPException(status_code=400, detail="Content is required")
|
||
|
||
# TODO: Get user from JWT and check permissions
|
||
# For now, use a placeholder
|
||
created_by = "ARCHITECT" # Will be replaced with actual user from auth
|
||
|
||
# Update prompt
|
||
result = await repo_city.update_agent_prompt(
|
||
agent_id=agent_id,
|
||
kind=kind,
|
||
content=content.strip(),
|
||
created_by=created_by,
|
||
note=note
|
||
)
|
||
|
||
logger.info(f"Updated {kind} prompt for agent {agent_id} to version {result['version']}")
|
||
|
||
return result
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to update agent prompt: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail="Failed to update agent prompt")
|
||
|
||
|
||
@router.get("/agents/{agent_id}/prompts/{kind}/history")
|
||
async def get_agent_prompt_history(agent_id: str, kind: str, limit: int = 10):
|
||
"""
|
||
Отримати історію версій промту агента.
|
||
"""
|
||
try:
|
||
valid_kinds = ["core", "safety", "governance", "tools"]
|
||
if kind not in valid_kinds:
|
||
raise HTTPException(status_code=400, detail=f"Invalid kind. Must be one of: {valid_kinds}")
|
||
|
||
history = await repo_city.get_agent_prompt_history(agent_id, kind, limit)
|
||
return {"agent_id": agent_id, "kind": kind, "history": history}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get prompt history: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get prompt history")
|
||
|
||
|
||
@router.get("/agents/{agent_id}/dashboard")
|
||
async def get_agent_dashboard(agent_id: str):
|
||
"""
|
||
Отримати повний dashboard агента (DAIS Profile + Node + Metrics)
|
||
"""
|
||
try:
|
||
# Get agent profile
|
||
agent = await repo_city.get_agent_by_id(agent_id)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}")
|
||
|
||
# Get agent's rooms
|
||
rooms = await repo_city.get_agent_rooms(agent_id)
|
||
|
||
# Build DAIS profile
|
||
profile = {
|
||
"agent_id": agent["id"],
|
||
"display_name": agent["display_name"],
|
||
"kind": agent.get("kind", "assistant"),
|
||
"model": agent.get("model"),
|
||
"avatar_url": agent.get("avatar_url"),
|
||
"status": agent.get("status", "offline"),
|
||
"node_id": agent.get("node_id"),
|
||
"is_public": agent.get("is_public", False),
|
||
"public_slug": agent.get("public_slug"),
|
||
"is_orchestrator": agent.get("is_orchestrator", False),
|
||
"primary_microdao_id": agent.get("primary_microdao_id"),
|
||
"primary_microdao_name": agent.get("primary_microdao_name"),
|
||
"primary_microdao_slug": agent.get("primary_microdao_slug"),
|
||
"roles": [agent.get("role")] if agent.get("role") else [],
|
||
"tags": [],
|
||
"dais": {
|
||
"core": {
|
||
"title": agent.get("display_name"),
|
||
"bio": f"{agent.get('kind', 'assistant').title()} agent in DAARION",
|
||
"version": "1.0.0"
|
||
},
|
||
"vis": {
|
||
"avatar_url": agent.get("avatar_url"),
|
||
"color_primary": agent.get("color", "#22D3EE")
|
||
},
|
||
"cog": {
|
||
"base_model": agent.get("model", "default"),
|
||
"provider": "ollama",
|
||
"node_id": agent.get("node_id")
|
||
},
|
||
"act": {
|
||
"tools": agent.get("capabilities", [])
|
||
}
|
||
},
|
||
"city_presence": {
|
||
"primary_room_slug": agent.get("primary_room_slug"),
|
||
"district": agent.get("home_district"),
|
||
"rooms": rooms
|
||
}
|
||
}
|
||
|
||
# Get node info (simplified)
|
||
node_info = None
|
||
if agent.get("node_id"):
|
||
node_info = {
|
||
"node_id": agent["node_id"],
|
||
"status": "online" # Would fetch from Node Registry in production
|
||
}
|
||
|
||
# Get system prompts
|
||
system_prompts = await repo_city.get_agent_prompts(agent_id)
|
||
|
||
# Get public profile
|
||
public_profile = await repo_city.get_agent_public_profile(agent_id)
|
||
|
||
# MicroDAO memberships
|
||
memberships_raw = await repo_city.get_agent_microdao_memberships(agent_id)
|
||
memberships = [
|
||
AgentMicrodaoMembership(
|
||
microdao_id=item["microdao_id"],
|
||
microdao_slug=item.get("microdao_slug"),
|
||
microdao_name=item.get("microdao_name"),
|
||
role=item.get("role"),
|
||
is_core=item.get("is_core", False)
|
||
)
|
||
for item in memberships_raw
|
||
]
|
||
|
||
# Get primary city room for agent
|
||
primary_city_room = None
|
||
# Priority 1: agent's primary room from city_rooms
|
||
if rooms and len(rooms) > 0:
|
||
primary_room = rooms[0] # First room as primary
|
||
primary_city_room = {
|
||
"id": primary_room.get("id"),
|
||
"slug": primary_room.get("slug"),
|
||
"name": primary_room.get("name"),
|
||
"matrix_room_id": primary_room.get("matrix_room_id")
|
||
}
|
||
# Priority 2: Get from primary MicroDAO's main room
|
||
elif agent.get("primary_microdao_id"):
|
||
microdao_room = await repo_city.get_microdao_primary_room(agent["primary_microdao_id"])
|
||
if microdao_room:
|
||
primary_city_room = microdao_room
|
||
|
||
# Build dashboard response
|
||
dashboard = {
|
||
"profile": profile,
|
||
"node": node_info,
|
||
"primary_city_room": primary_city_room,
|
||
"runtime": {
|
||
"health": "healthy" if agent.get("status") == "online" else "unknown",
|
||
"last_success_at": None,
|
||
"last_error_at": None
|
||
},
|
||
"metrics": {
|
||
"tasks_1h": 0,
|
||
"tasks_24h": 0,
|
||
"errors_24h": 0,
|
||
"avg_latency_ms_1h": 0,
|
||
"success_rate_24h": 1.0
|
||
},
|
||
"recent_activity": [],
|
||
"system_prompts": system_prompts,
|
||
"public_profile": public_profile,
|
||
"microdao_memberships": memberships
|
||
}
|
||
|
||
return dashboard
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get agent dashboard: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail="Failed to get agent dashboard")
|
||
|
||
|
||
@router.get("/agents", response_model=List[AgentRead])
|
||
async def get_agents():
|
||
"""
|
||
Отримати список всіх агентів
|
||
"""
|
||
try:
|
||
agents = await repo_city.get_all_agents()
|
||
result = []
|
||
|
||
for agent in agents:
|
||
capabilities = agent.get("capabilities", [])
|
||
if isinstance(capabilities, str):
|
||
import json
|
||
capabilities = json.loads(capabilities)
|
||
|
||
result.append(AgentRead(
|
||
id=agent["id"],
|
||
display_name=agent["display_name"],
|
||
kind=agent.get("kind", "assistant"),
|
||
avatar_url=agent.get("avatar_url"),
|
||
color=agent.get("color", "cyan"),
|
||
status=agent.get("status", "offline"),
|
||
current_room_id=agent.get("current_room_id"),
|
||
capabilities=capabilities
|
||
))
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get agents: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get agents")
|
||
|
||
|
||
@router.get("/agents/online", response_model=List[AgentPresence])
|
||
async def get_online_agents():
|
||
"""
|
||
Отримати список онлайн агентів (для presence)
|
||
"""
|
||
try:
|
||
agents = await repo_city.get_online_agents()
|
||
result = []
|
||
|
||
for agent in agents:
|
||
result.append(AgentPresence(
|
||
agent_id=agent["id"],
|
||
display_name=agent["display_name"],
|
||
kind=agent.get("kind", "assistant"),
|
||
status=agent.get("status", "offline"),
|
||
room_id=agent.get("current_room_id"),
|
||
color=agent.get("color", "cyan")
|
||
))
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get online agents: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get online agents")
|
||
|
||
|
||
@router.get("/rooms/{room_id}/agents", response_model=List[AgentPresence])
|
||
async def get_room_agents(room_id: str):
|
||
"""
|
||
Отримати агентів у конкретній кімнаті
|
||
"""
|
||
try:
|
||
agents = await repo_city.get_agents_by_room(room_id)
|
||
result = []
|
||
|
||
for agent in agents:
|
||
result.append(AgentPresence(
|
||
agent_id=agent["id"],
|
||
display_name=agent["display_name"],
|
||
kind=agent.get("kind", "assistant"),
|
||
status=agent.get("status", "offline"),
|
||
room_id=room_id,
|
||
color=agent.get("color", "cyan"),
|
||
node_id=agent.get("node_id"),
|
||
model=agent.get("model"),
|
||
role=agent.get("role")
|
||
))
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get room agents: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get room agents")
|
||
|
||
|
||
@router.get("/agents/presence-snapshot")
|
||
async def get_agents_presence_snapshot():
|
||
"""
|
||
Отримати snapshot всіх агентів для presence (50 агентів по 10 districts)
|
||
"""
|
||
try:
|
||
snapshot = await repo_city.get_agents_presence_snapshot()
|
||
return snapshot
|
||
except Exception as e:
|
||
logger.error(f"Failed to get agents presence snapshot: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get agents presence snapshot")
|
||
|
||
|
||
# =============================================================================
|
||
# MicroDAO API
|
||
# =============================================================================
|
||
|
||
@router.get("/microdao", response_model=List[MicrodaoSummary])
|
||
async def get_microdaos(
|
||
district: Optional[str] = Query(None, description="Filter by district"),
|
||
is_public: Optional[bool] = Query(True, description="Filter by public status (default: True)"),
|
||
is_platform: Optional[bool] = Query(None, description="Filter by platform status"),
|
||
q: Optional[str] = Query(None, description="Search by name/description"),
|
||
include_all: bool = Query(False, description="Include non-public (admin only)"),
|
||
limit: int = Query(50, le=100),
|
||
offset: int = Query(0, ge=0)
|
||
):
|
||
"""
|
||
Отримати список MicroDAOs.
|
||
|
||
- **district**: фільтр по дістрікту (Core, Energy, Green, Labs, etc.)
|
||
- **is_public**: фільтр по публічності (за замовчуванням True)
|
||
- **is_platform**: фільтр по типу (платформа/дістрікт)
|
||
- **q**: пошук по назві або опису
|
||
- **include_all**: включити всі (для адмінів)
|
||
"""
|
||
try:
|
||
# If include_all is True (admin mode), don't filter by is_public
|
||
public_filter = None if include_all else is_public
|
||
|
||
daos = await repo_city.list_microdao_summaries(
|
||
district=district,
|
||
is_public=public_filter,
|
||
is_platform=is_platform,
|
||
q=q,
|
||
limit=limit,
|
||
offset=offset
|
||
)
|
||
|
||
result = []
|
||
for dao in daos:
|
||
result.append(MicrodaoSummary(
|
||
id=dao["id"],
|
||
slug=dao["slug"],
|
||
name=dao["name"],
|
||
description=dao.get("description"),
|
||
district=dao.get("district"),
|
||
is_public=dao.get("is_public", True),
|
||
is_platform=dao.get("is_platform", False),
|
||
is_active=dao.get("is_active", True),
|
||
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
|
||
orchestrator_agent_name=dao.get("orchestrator_agent_name"),
|
||
parent_microdao_id=dao.get("parent_microdao_id"),
|
||
parent_microdao_slug=dao.get("parent_microdao_slug"),
|
||
logo_url=dao.get("logo_url"),
|
||
member_count=dao.get("member_count", 0),
|
||
agents_count=dao.get("agents_count", 0),
|
||
room_count=dao.get("room_count", 0),
|
||
rooms_count=dao.get("rooms_count", 0),
|
||
channels_count=dao.get("channels_count", 0)
|
||
))
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to get microdaos: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail="Failed to get microdaos")
|
||
|
||
|
||
@router.get("/microdao/{slug}", response_model=MicrodaoDetail)
|
||
async def get_microdao_by_slug(slug: str):
|
||
"""
|
||
Отримати детальну інформацію про MicroDAO.
|
||
|
||
Включає:
|
||
- Базову інформацію про DAO
|
||
- Список агентів (з ролями)
|
||
- Список каналів (Telegram, Matrix, City rooms, CrewAI)
|
||
"""
|
||
try:
|
||
dao = await repo_city.get_microdao_by_slug(slug)
|
||
if not dao:
|
||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||
|
||
# Build agents list
|
||
agents = []
|
||
for agent in dao.get("agents", []):
|
||
agents.append(MicrodaoAgentView(
|
||
agent_id=agent["agent_id"],
|
||
display_name=agent.get("display_name", agent["agent_id"]),
|
||
role=agent.get("role"),
|
||
is_core=agent.get("is_core", False)
|
||
))
|
||
|
||
# Build channels list
|
||
channels = []
|
||
for channel in dao.get("channels", []):
|
||
channels.append(MicrodaoChannelView(
|
||
kind=channel["kind"],
|
||
ref_id=channel["ref_id"],
|
||
display_name=channel.get("display_name"),
|
||
is_primary=channel.get("is_primary", False)
|
||
))
|
||
|
||
public_citizens = []
|
||
for citizen in dao.get("public_citizens", []):
|
||
public_citizens.append(MicrodaoCitizenView(
|
||
slug=citizen["slug"],
|
||
display_name=citizen["display_name"],
|
||
public_title=citizen.get("public_title"),
|
||
public_tagline=citizen.get("public_tagline"),
|
||
avatar_url=citizen.get("avatar_url"),
|
||
district=citizen.get("public_district"),
|
||
primary_room_slug=citizen.get("public_primary_room_slug")
|
||
))
|
||
|
||
# Build child microDAOs list
|
||
child_microdaos = []
|
||
for child in dao.get("child_microdaos", []):
|
||
child_microdaos.append(MicrodaoSummary(
|
||
id=child["id"],
|
||
slug=child["slug"],
|
||
name=child["name"],
|
||
is_public=child.get("is_public", True),
|
||
is_platform=child.get("is_platform", False)
|
||
))
|
||
|
||
# Get all rooms for MicroDAO (multi-room support)
|
||
all_rooms = await repo_city.get_microdao_rooms(dao["id"])
|
||
rooms_list = [
|
||
CityRoomSummary(
|
||
id=room["id"],
|
||
slug=room["slug"],
|
||
name=room["name"],
|
||
matrix_room_id=room.get("matrix_room_id"),
|
||
microdao_id=room.get("microdao_id"),
|
||
microdao_slug=room.get("microdao_slug"),
|
||
room_role=room.get("room_role"),
|
||
is_public=room.get("is_public", True),
|
||
sort_order=room.get("sort_order", 100)
|
||
)
|
||
for room in all_rooms
|
||
]
|
||
|
||
# Get primary city room (first room with role='primary' or first by sort_order)
|
||
primary_room_summary = None
|
||
if rooms_list:
|
||
primary = next((r for r in rooms_list if r.room_role == 'primary'), rooms_list[0])
|
||
primary_room_summary = primary
|
||
|
||
return MicrodaoDetail(
|
||
id=dao["id"],
|
||
slug=dao["slug"],
|
||
name=dao["name"],
|
||
description=dao.get("description"),
|
||
district=dao.get("district"),
|
||
is_public=dao.get("is_public", True),
|
||
is_platform=dao.get("is_platform", False),
|
||
is_active=dao.get("is_active", True),
|
||
orchestrator_agent_id=dao.get("orchestrator_agent_id"),
|
||
orchestrator_display_name=dao.get("orchestrator_display_name"),
|
||
parent_microdao_id=dao.get("parent_microdao_id"),
|
||
parent_microdao_slug=dao.get("parent_microdao_slug"),
|
||
child_microdaos=child_microdaos,
|
||
logo_url=dao.get("logo_url"),
|
||
agents=agents,
|
||
channels=channels,
|
||
public_citizens=public_citizens,
|
||
primary_city_room=primary_room_summary,
|
||
rooms=rooms_list
|
||
)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get microdao {slug}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail="Failed to get microdao")
|
||
|
||
|
||
# =============================================================================
|
||
# MicroDAO Multi-Room API (Task 034)
|
||
# =============================================================================
|
||
|
||
from models_city import MicrodaoRoomsList, MicrodaoRoomUpdate, AttachExistingRoomRequest
|
||
|
||
|
||
@router.get("/microdao/{slug}/rooms", response_model=MicrodaoRoomsList)
|
||
async def get_microdao_rooms_endpoint(slug: str):
|
||
"""
|
||
Отримати всі кімнати MicroDAO (Task 034).
|
||
Повертає список кімнат, впорядкованих за sort_order.
|
||
"""
|
||
try:
|
||
result = await repo_city.get_microdao_rooms_by_slug(slug)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||
|
||
rooms = [
|
||
CityRoomSummary(
|
||
id=room["id"],
|
||
slug=room["slug"],
|
||
name=room["name"],
|
||
matrix_room_id=room.get("matrix_room_id"),
|
||
microdao_id=room.get("microdao_id"),
|
||
microdao_slug=room.get("microdao_slug"),
|
||
room_role=room.get("room_role"),
|
||
is_public=room.get("is_public", True),
|
||
sort_order=room.get("sort_order", 100)
|
||
)
|
||
for room in result["rooms"]
|
||
]
|
||
|
||
return MicrodaoRoomsList(
|
||
microdao_id=result["microdao_id"],
|
||
microdao_slug=result["microdao_slug"],
|
||
rooms=rooms
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get microdao rooms for {slug}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to get microdao rooms")
|
||
|
||
|
||
@router.post("/microdao/{slug}/rooms/attach-existing", response_model=CityRoomSummary)
|
||
async def attach_existing_room_endpoint(
|
||
slug: str,
|
||
payload: AttachExistingRoomRequest
|
||
):
|
||
"""
|
||
Прив'язати існуючу кімнату до MicroDAO (Task 036).
|
||
Потребує прав адміністратора або оркестратора MicroDAO.
|
||
"""
|
||
try:
|
||
# Get microdao by slug
|
||
dao = await repo_city.get_microdao_by_slug(slug)
|
||
if not dao:
|
||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||
|
||
# TODO: Add authorization check (assert_can_manage_microdao)
|
||
|
||
result = await repo_city.attach_room_to_microdao(
|
||
microdao_id=dao["id"],
|
||
room_id=payload.room_id,
|
||
room_role=payload.room_role,
|
||
is_public=payload.is_public,
|
||
sort_order=payload.sort_order
|
||
)
|
||
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Room not found")
|
||
|
||
return CityRoomSummary(
|
||
id=result["id"],
|
||
slug=result["slug"],
|
||
name=result["name"],
|
||
matrix_room_id=result.get("matrix_room_id"),
|
||
microdao_id=result.get("microdao_id"),
|
||
room_role=result.get("room_role"),
|
||
is_public=result.get("is_public", True),
|
||
sort_order=result.get("sort_order", 100)
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to attach room to microdao {slug}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to attach room")
|
||
|
||
|
||
@router.patch("/microdao/{slug}/rooms/{room_id}", response_model=CityRoomSummary)
|
||
async def update_microdao_room_endpoint(
|
||
slug: str,
|
||
room_id: str,
|
||
payload: MicrodaoRoomUpdate
|
||
):
|
||
"""
|
||
Оновити налаштування кімнати MicroDAO (Task 036).
|
||
Потребує прав адміністратора або оркестратора MicroDAO.
|
||
"""
|
||
try:
|
||
# Get microdao by slug
|
||
dao = await repo_city.get_microdao_by_slug(slug)
|
||
if not dao:
|
||
raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
|
||
|
||
# TODO: Add authorization check (assert_can_manage_microdao)
|
||
|
||
result = await repo_city.update_microdao_room(
|
||
microdao_id=dao["id"],
|
||
room_id=room_id,
|
||
room_role=payload.room_role,
|
||
is_public=payload.is_public,
|
||
sort_order=payload.sort_order,
|
||
set_primary=payload.set_primary or False
|
||
)
|
||
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Room not found or not attached to this MicroDAO")
|
||
|
||
return CityRoomSummary(
|
||
id=result["id"],
|
||
slug=result["slug"],
|
||
name=result["name"],
|
||
matrix_room_id=result.get("matrix_room_id"),
|
||
microdao_id=result.get("microdao_id"),
|
||
room_role=result.get("room_role"),
|
||
is_public=result.get("is_public", True),
|
||
sort_order=result.get("sort_order", 100)
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to update room {room_id} for microdao {slug}: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to update room")
|
||
|
||
|
||
# =============================================================================
|
||
# MicroDAO Visibility & Creation (Task 029)
|
||
# =============================================================================
|
||
|
||
class MicrodaoVisibilityPayload(BaseModel):
|
||
"""MicroDAO visibility update payload"""
|
||
is_public: bool
|
||
is_platform: Optional[bool] = None
|
||
|
||
|
||
@router.put("/microdao/{microdao_id}/visibility")
|
||
async def update_microdao_visibility_endpoint(
|
||
microdao_id: str,
|
||
payload: MicrodaoVisibilityPayload
|
||
):
|
||
"""Оновити налаштування видимості MicroDAO (Task 029)"""
|
||
try:
|
||
result = await repo_city.update_microdao_visibility(
|
||
microdao_id=microdao_id,
|
||
is_public=payload.is_public,
|
||
is_platform=payload.is_platform,
|
||
)
|
||
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="MicroDAO not found")
|
||
|
||
return {
|
||
"status": "ok",
|
||
"microdao_id": result.get("id"),
|
||
"slug": result.get("slug"),
|
||
"is_public": result.get("is_public"),
|
||
"is_platform": result.get("is_platform"),
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to update microdao visibility: {e}")
|
||
raise HTTPException(status_code=500, detail="Failed to update visibility")
|
||
|
||
|
||
class MicrodaoCreatePayload(BaseModel):
|
||
"""Create MicroDAO from agent (orchestrator flow)"""
|
||
name: str
|
||
slug: str
|
||
description: Optional[str] = None
|
||
make_platform: bool = False
|
||
is_public: bool = True
|
||
parent_microdao_id: Optional[str] = None
|
||
|
||
|
||
@router.post("/agents/{agent_id}/microdao", response_model=dict)
|
||
async def create_microdao_for_agent_endpoint(
|
||
agent_id: str,
|
||
payload: MicrodaoCreatePayload
|
||
):
|
||
"""
|
||
Створити MicroDAO для агента (зробити його оркестратором).
|
||
|
||
Цей endpoint:
|
||
1. Створює новий MicroDAO
|
||
2. Призначає агента оркестратором
|
||
3. Додає агента як члена DAO
|
||
4. Встановлює primary_microdao_id якщо порожній
|
||
"""
|
||
try:
|
||
# Check if agent exists and is not archived
|
||
agent = await repo_city.get_agent_by_id(agent_id)
|
||
if not agent:
|
||
raise HTTPException(status_code=404, detail="Agent not found")
|
||
|
||
# Check if slug is unique
|
||
existing = await repo_city.get_microdao_by_slug(payload.slug)
|
||
if existing:
|
||
raise HTTPException(status_code=400, detail=f"MicroDAO with slug '{payload.slug}' already exists")
|
||
|
||
# Create MicroDAO
|
||
result = await repo_city.create_microdao_for_agent(
|
||
orchestrator_agent_id=agent_id,
|
||
name=payload.name,
|
||
slug=payload.slug,
|
||
description=payload.description,
|
||
make_platform=payload.make_platform,
|
||
is_public=payload.is_public,
|
||
parent_microdao_id=payload.parent_microdao_id,
|
||
)
|
||
|
||
if not result:
|
||
raise HTTPException(status_code=500, detail="Failed to create MicroDAO")
|
||
|
||
return {
|
||
"status": "ok",
|
||
"microdao": result,
|
||
"agent_id": agent_id,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to create microdao for agent {agent_id}: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail="Failed to create MicroDAO")
|
||
|