feat: Phase 2 — web server, REST API, WebSocket/SSE, UI [v0.2.0]

AsyncEventBridge bridges sync EventEmitter to asyncio consumers.
FastAPI app with session management, discussion control, roles/providers.
WebSocket for real-time events + inject, SSE for read-only streaming.
Minimal dark-theme HTML/JS UI. `meeting-room serve` CLI subcommand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 08:50:04 +03:00
parent 069462953b
commit 1dffaae822
13 changed files with 1393 additions and 6 deletions

View File

@@ -426,6 +426,11 @@ def main():
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room")
serve_parser = subparsers.add_parser("serve", help="Start web server")
serve_parser.add_argument("--host", default="0.0.0.0", help="Host to bind (default: 0.0.0.0)")
serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind (default: 8000)")
serve_parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
args = parser.parse_args()
# Handle init command
@@ -433,6 +438,18 @@ def main():
init_workspace(args.path)
return
# Handle serve command
if args.command == "serve":
import uvicorn
from meeting_room.server import app
uvicorn.run(
"meeting_room.server:app" if args.reload else app,
host=args.host,
port=args.port,
reload=args.reload,
)
return
# Find config: CLI arg > .meeting-room/config.yaml > package default
if args.config:
config = load_config(args.config)

View File

@@ -1,12 +1,14 @@
"""EventEmitter and event types for the Meeting Room discussion engine.
Synchronous event bus. In Phase 2, an AsyncEventBridge will forward
events to WebSocket clients.
Synchronous event bus with AsyncEventBridge for forwarding events
to asyncio consumers (WebSocket / SSE).
"""
from __future__ import annotations
import asyncio
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
@@ -126,4 +128,120 @@ class EventEmitter:
def clear(self) -> None:
"""Remove all subscriptions."""
self._handlers.clear()
self._handlers.clear()
# ---------------------------------------------------------------------------
# AsyncEventBridge
# ---------------------------------------------------------------------------
# All event types the bridge forwards.
_ALL_EVENT_TYPES = [
DISCUSSION_START,
ROUND_START,
AGENT_TURN_START,
AGENT_MESSAGE,
TOOL_CALL,
TOOL_RESULT,
AGENT_ERROR,
ROUND_END,
DISCUSSION_END,
BOSS_TURN,
]
class AsyncEventBridge:
"""Bridges synchronous EventEmitter events to asyncio consumers.
Subscribes to all event types on a sync ``EventEmitter`` and forwards
each event into ``asyncio.Queue`` objects — one per consumer. Designed
for running ``DiscussionEngine`` in a worker thread via
``asyncio.to_thread()`` while WebSocket / SSE handlers await events
on the async side.
Usage::
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
bridge.start(asyncio.get_event_loop())
# In an async handler:
queue = bridge.subscribe()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
finally:
bridge.unsubscribe(queue)
# Run engine in a thread:
await asyncio.to_thread(engine.run, scenario)
# Cleanup:
bridge.stop()
"""
def __init__(self, event_bus: EventEmitter) -> None:
self._bus = event_bus
self._loop: asyncio.AbstractEventLoop | None = None
self._queues: list[asyncio.Queue[dict]] = []
self._lock = threading.Lock()
self._handler = self._on_sync_event
# -- lifecycle -----------------------------------------------------------
def start(self, loop: asyncio.AbstractEventLoop) -> None:
"""Subscribe to all event types on the bus."""
self._loop = loop
for event_type in _ALL_EVENT_TYPES:
self._bus.on(event_type, self._handler)
def stop(self) -> None:
"""Unsubscribe from all event types."""
for event_type in _ALL_EVENT_TYPES:
self._bus.off(event_type, self._handler)
# -- consumer management ------------------------------------------------
def subscribe(self) -> asyncio.Queue[dict]:
"""Create a consumer queue that receives all events.
Call from async context. The caller owns the queue and must
call ``unsubscribe()`` when done.
"""
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=1000)
with self._lock:
self._queues.append(q)
return q
def unsubscribe(self, q: asyncio.Queue[dict]) -> None:
"""Remove a consumer queue. Safe to call multiple times."""
with self._lock:
try:
self._queues.remove(q)
except ValueError:
pass
# -- internal ------------------------------------------------------------
def _on_sync_event(self, event: Event) -> None:
"""Sync handler called by EventEmitter on any thread."""
payload = {
"type": event.type,
"data": event.data,
"timestamp": event.timestamp.isoformat(),
}
if self._loop is None or not self._loop.is_running():
return
with self._lock:
queues = list(self._queues)
for q in queues:
self._loop.call_soon_threadsafe(self._safe_put, q, payload)
@staticmethod
def _safe_put(q: asyncio.Queue[dict], payload: dict) -> None:
"""Put payload into queue, dropping if full."""
try:
q.put_nowait(payload)
except asyncio.QueueFull:
logger.warning("AsyncEventBridge: dropping event, queue full")

423
meeting_room/server.py Normal file
View File

@@ -0,0 +1,423 @@
"""FastAPI web server for Meeting Room — REST API, WebSocket, and SSE endpoints."""
from __future__ import annotations
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from typing import Any
from fastapi import APIRouter, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from meeting_room.api_client import APIClient
from meeting_room.config import load_config, load_discussion_config, find_workspace_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import AsyncEventBridge, EventEmitter
from meeting_room.models import DiscussionConfig, ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import ToolRegistry
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pydantic models for API request/response
# ---------------------------------------------------------------------------
class StartDiscussionRequest(BaseModel):
"""Request body for starting a discussion."""
scenario: str # scenario file path or name
config: str | None = None # optional config path override
save: bool = False
class InjectMessageRequest(BaseModel):
"""Request body for injecting a message."""
role_id: str
content: str
class SessionResponse(BaseModel):
"""Response for session creation."""
id: str
status: str
class SessionDetailResponse(BaseModel):
"""Response for session detail."""
id: str
status: str
scenario_name: str | None = None
message_count: int = 0
# ---------------------------------------------------------------------------
# Session store
# ---------------------------------------------------------------------------
class Session:
"""Holds state for a single discussion session."""
def __init__(
self,
session_id: str,
config: dict,
discussion_config: DiscussionConfig,
) -> None:
self.id = session_id
self.config = config
self.discussion_config = discussion_config
self.status: str = "created" # created | running | completed | error
self.scenario_name: str | None = None
self.messages: list[dict] = []
self.event_bus = EventEmitter()
self.bridge = AsyncEventBridge(self.event_bus)
self._engine_task: asyncio.Task | None = None
def build_engine(self) -> DiscussionEngine:
providers = {
name: ProviderConfig(**vals)
for name, vals in self.config.get("providers", {}).items()
}
api_client = APIClient(providers=providers)
tool_registry = ToolRegistry(workdir=self.discussion_config.defaults.workdir)
return DiscussionEngine(
config=self.discussion_config,
api_client=api_client,
tool_registry=tool_registry,
events=self.event_bus,
)
class SessionStore:
"""In-memory session store."""
def __init__(self) -> None:
self._sessions: dict[str, Session] = {}
def create(self, config: dict, discussion_config: DiscussionConfig) -> Session:
session_id = uuid.uuid4().hex[:12]
session = Session(session_id, config, discussion_config)
self._sessions[session_id] = session
return session
def get(self, session_id: str) -> Session | None:
return self._sessions.get(session_id)
def list_sessions(self) -> list[Session]:
return list(self._sessions.values())
# ---------------------------------------------------------------------------
# Application factory
# ---------------------------------------------------------------------------
store = SessionStore()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup/shutdown lifecycle."""
logger.info("Meeting Room server started")
yield
# Cleanup: cancel any running engine tasks
for session in store.list_sessions():
if session._engine_task and not session._engine_task.done():
session._engine_task.cancel()
logger.info("Meeting Room server stopped")
app = FastAPI(
title="Meeting Room",
version="0.2.0",
description="Multi-agent discussion framework — REST API and WebSocket",
lifespan=lifespan,
)
# Serve static files (web UI)
import os
_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
@app.get("/")
async def index():
"""Redirect to the web UI."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/index.html")
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _load_config_or_default(config_path: str | None) -> dict:
"""Load config from path, workspace, or package default."""
if config_path:
return load_config(config_path)
ws_config = find_workspace_config()
if ws_config:
return load_config(ws_config)
return load_config()
# ---------------------------------------------------------------------------
# REST API (mounted at /api)
# ---------------------------------------------------------------------------
api = APIRouter(prefix="/api")
@api.post("/sessions", response_model=SessionResponse)
async def create_session(config: str | None = None):
"""Create a new discussion session."""
raw_config = _load_config_or_default(config)
discussion_config = load_discussion_config()
session = store.create(raw_config, discussion_config)
return SessionResponse(id=session.id, status=session.status)
@api.get("/sessions", response_model=list[SessionDetailResponse])
async def list_sessions():
"""List all sessions."""
sessions = store.list_sessions()
return [
SessionDetailResponse(
id=s.id,
status=s.status,
scenario_name=s.scenario_name,
message_count=len(s.messages),
)
for s in sessions
]
@api.get("/sessions/{session_id}", response_model=SessionDetailResponse)
async def get_session(session_id: str):
"""Get session details."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return SessionDetailResponse(
id=session.id,
status=session.status,
scenario_name=session.scenario_name,
message_count=len(session.messages),
)
@api.post("/sessions/{session_id}/start", response_model=SessionDetailResponse)
async def start_discussion(session_id: str, body: StartDiscussionRequest):
"""Start a discussion in a session."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status == "running":
raise HTTPException(status_code=409, detail="Discussion already running")
# Load scenario
import os
scenario_path = body.scenario
if not os.path.isabs(scenario_path):
scenario_path = os.path.join(os.getcwd(), scenario_path)
if not os.path.exists(scenario_path):
scenario_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scenarios", body.scenario
)
if not os.path.exists(scenario_path):
raise HTTPException(status_code=404, detail=f"Scenario not found: {body.scenario}")
scenario_data = load_scenario(scenario_path)
session.scenario_name = scenario_data.get("name", "Untitled")
# Rebuild config if overridden
if body.config:
raw_config = _load_config_or_default(body.config)
discussion_config = load_discussion_config()
session.config = raw_config
session.discussion_config = discussion_config
# Start the bridge
loop = asyncio.get_event_loop()
session.bridge.start(loop)
# Subscribe a handler to collect messages
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
def collect_message(event):
session.messages.append({
"role_id": event.data.get("role_id", ""),
"name": event.data.get("name", ""),
"content": event.data.get("content", event.data.get("error", "")),
})
session.event_bus.on(AGENT_MESSAGE, collect_message)
session.event_bus.on(BOSS_TURN, collect_message)
session.event_bus.on(AGENT_ERROR, collect_message)
# Run engine in a thread
engine = session.build_engine()
session.status = "running"
async def run_engine():
try:
await asyncio.to_thread(engine.run, scenario_data)
session.status = "completed"
except Exception as exc:
logger.exception("Engine error in session %s", session.id)
session.status = "error"
finally:
session.bridge.stop()
session._engine_task = asyncio.create_task(run_engine())
return SessionDetailResponse(
id=session.id,
status=session.status,
scenario_name=session.scenario_name,
message_count=0,
)
@api.post("/sessions/{session_id}/inject", response_model=dict)
async def inject_message(session_id: str, body: InjectMessageRequest):
"""Inject a message from a Boss/CSO role."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status != "running":
raise HTTPException(status_code=409, detail="Session is not running")
engine = session.build_engine()
msg = engine.inject_message(body.role_id, body.content)
session.messages.append({
"role_id": msg.role_id,
"name": msg.name,
"content": msg.content,
})
return {"role_id": msg.role_id, "name": msg.name, "content": msg.content}
@api.get("/sessions/{session_id}/messages")
async def get_messages(session_id: str):
"""Get all messages in a session."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return session.messages
@api.get("/roles")
async def list_roles(config: str | None = None):
"""List available roles and model bindings."""
raw_config = _load_config_or_default(config)
return raw_config.get("roles", {})
@api.get("/providers")
async def list_providers(config: str | None = None):
"""List configured providers."""
raw_config = _load_config_or_default(config)
return raw_config.get("providers", {})
# Mount API router
app.include_router(api)
# ---------------------------------------------------------------------------
# WebSocket
# ---------------------------------------------------------------------------
@app.websocket("/ws/{session_id}")
async def websocket_endpoint(websocket: WebSocket, session_id: str):
"""WebSocket endpoint for real-time event streaming and message injection."""
session = store.get(session_id)
if not session:
await websocket.close(code=4004, reason="Session not found")
return
await websocket.accept()
queue = session.bridge.subscribe()
try:
# Task: forward events from queue to WebSocket
async def send_events():
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except asyncio.CancelledError:
pass
send_task = asyncio.create_task(send_events())
# Listen for incoming messages (inject)
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "inject":
role_id = data.get("role_id", "boss")
content = data.get("content", "")
engine = session.build_engine()
msg = engine.inject_message(role_id, content)
session.messages.append({
"role_id": msg.role_id,
"name": msg.name,
"content": msg.content,
})
except WebSocketDisconnect:
pass
finally:
send_task.cancel()
session.bridge.unsubscribe(queue)
except Exception:
session.bridge.unsubscribe(queue)
# ---------------------------------------------------------------------------
# SSE
# ---------------------------------------------------------------------------
@app.get("/events/{session_id}")
async def sse_endpoint(session_id: str):
"""SSE endpoint for read-only event streaming."""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
async def event_generator():
queue = session.bridge.subscribe()
try:
while True:
event = await queue.get()
# SSE format
import json
yield f"data: {json.dumps(event)}\n\n"
except asyncio.CancelledError:
pass
finally:
session.bridge.unsubscribe(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Room</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--border: #533483;
--accent: #e94560;
--text: #eee;
--muted: #999;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
background: var(--surface);
padding: 1rem 2rem;
border-bottom: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
header h1 { font-size: 1.4rem; }
header .status {
font-size: 0.85rem;
color: var(--muted);
}
.status .dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.dot.connected { background: #4caf50; }
.dot.disconnected { background: var(--accent); }
main {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--accent);
}
.controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
select, input, button {
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--card);
color: var(--text);
}
button {
cursor: pointer;
border-color: var(--accent);
transition: background 0.2s;
}
button:hover { background: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
#messages {
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem 0;
}
.msg {
padding: 0.75rem 1rem;
margin: 0.5rem 0;
border-radius: 6px;
background: var(--card);
border-left: 3px solid var(--border);
animation: fadeIn 0.3s;
}
.msg .name {
font-weight: 600;
color: var(--accent);
font-size: 0.85rem;
}
.msg .content {
margin-top: 0.3rem;
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.5;
}
.msg.tool-call {
border-left-color: #ff9800;
font-size: 0.8rem;
color: var(--muted);
}
.msg.error {
border-left-color: #f44336;
}
.inject-bar {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.inject-bar input { flex: 1; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.round-marker {
text-align: center;
color: var(--muted);
font-size: 0.8rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
</style>
</head>
<body>
<header>
<h1>Meeting Room</h1>
<div class="status">
<span class="dot disconnected" id="statusDot"></span>
<span id="statusText">Disconnected</span>
</div>
</header>
<main>
<div class="panel">
<h2>Start Discussion</h2>
<div class="controls">
<select id="scenarioSelect">
<option value="">Loading scenarios...</option>
</select>
<button id="startBtn" onclick="startSession()">Start</button>
</div>
</div>
<div class="panel" id="chatPanel" style="display:none">
<h2 id="sessionTitle">Discussion</h2>
<div id="messages"></div>
<div class="inject-bar">
<input id="injectRole" placeholder="Role (boss)" value="boss" style="width:120px">
<input id="injectText" placeholder="Inject message..." onkeydown="if(event.key==='Enter')injectMessage()">
<button onclick="injectMessage()">Send</button>
</div>
</div>
</main>
<script>
let ws = null;
let sessionId = null;
// --- API helpers ---
async function api(path, opts = {}) {
const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
// --- Load scenarios (placeholder) ---
// In a full implementation, we'd load from /api/scenarios
// For now, scenarios are provided by path
// --- Create and start session ---
async function startSession() {
const scenario = document.getElementById('scenarioSelect').value;
if (!scenario) return alert('Select a scenario first');
try {
// Create session
const sess = await api('/sessions', { method: 'POST' });
sessionId = sess.id;
// Start discussion
await api(`/sessions/${sessionId}/start`, {
method: 'POST',
body: JSON.stringify({ scenario }),
});
// Connect WebSocket
connectWS(sessionId);
document.getElementById('chatPanel').style.display = 'block';
document.getElementById('startBtn').disabled = true;
} catch (e) {
alert('Error: ' + e.message);
}
}
// --- WebSocket ---
function connectWS(sid) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws/${sid}`);
ws.onopen = () => {
document.getElementById('statusDot').className = 'dot connected';
document.getElementById('statusText').textContent = 'Connected';
};
ws.onclose = () => {
document.getElementById('statusDot').className = 'dot disconnected';
document.getElementById('statusText').textContent = 'Disconnected';
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleEvent(data);
};
}
// --- Handle events ---
const ROLE_COLORS = {
moderator: '#4fc3f7',
skeptic: '#ef5350',
idea_generator: '#66bb6a',
analyst: '#ffa726',
boss: '#ab47bc',
defender: '#42a5f5',
attacker: '#ff7043',
security: '#78909c',
};
function handleEvent(data) {
const container = document.getElementById('messages');
const type = data.type;
const d = data.data || {};
if (type === 'discussion_start') {
document.getElementById('sessionTitle').textContent = d.name || 'Discussion';
container.innerHTML = '';
} else if (type === 'round_start') {
const el = document.createElement('div');
el.className = 'round-marker';
el.textContent = `— Round ${d.round_num}/${d.total_rounds}`;
container.appendChild(el);
} else if (type === 'agent_message' || type === 'boss_turn') {
const el = document.createElement('div');
el.className = 'msg';
const color = ROLE_COLORS[d.role_id] || '#999';
el.innerHTML = `<div class="name" style="color:${color}">${d.name}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
container.appendChild(el);
} else if (type === 'agent_error') {
const el = document.createElement('div');
el.className = 'msg error';
el.innerHTML = `<div class="name">${d.name}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
container.appendChild(el);
} else if (type === 'tool_call') {
const el = document.createElement('div');
el.className = 'msg tool-call';
el.textContent = `🔧 ${d.tool_name}(${d.arguments || ''})`;
container.appendChild(el);
} else if (type === 'discussion_end') {
const el = document.createElement('div');
el.className = 'round-marker';
el.textContent = `— Discussion complete (${d.total_messages} messages) —`;
container.appendChild(el);
}
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Inject message ---
function injectMessage() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
alert('Not connected');
return;
}
const role = document.getElementById('injectRole').value || 'boss';
const content = document.getElementById('injectText').value;
if (!content) return;
ws.send(JSON.stringify({ type: 'inject', role_id: role, content }));
document.getElementById('injectText').value = '';
}
// --- Init: fetch scenarios (todo: add API endpoint) ---
// For now, just show a text input
document.getElementById('scenarioSelect').outerHTML =
'<input id="scenarioSelect" placeholder="Scenario path (e.g. scenarios/example.md)" style="flex:1">';
</script>
</body>
</html>