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:
@@ -1,6 +1,41 @@
|
|||||||
# Task Board
|
# Task Board
|
||||||
_Updated: 2026-05-04_
|
_Updated: 2026-05-04_
|
||||||
|
|
||||||
|
## 🟢 v2-p2-async-bridge — AsyncEventBridge: sync EventEmitter → asyncio.Queue
|
||||||
|
**Status:** done
|
||||||
|
**Where I stopped:** Complete
|
||||||
|
**Next action:** N/A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 v2-p2-fastapi-app — FastAPI app + REST API (sessions, start, inject, roles, providers)
|
||||||
|
**Status:** done
|
||||||
|
**Where I stopped:** Complete
|
||||||
|
**Next action:** N/A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 v2-p2-websocket-sse — WebSocket + SSE streaming endpoints
|
||||||
|
**Status:** done
|
||||||
|
**Where I stopped:** Complete
|
||||||
|
**Next action:** N/A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 v2-p2-web-ui — Minimal HTML/CSS/JS frontend
|
||||||
|
**Status:** done
|
||||||
|
**Where I stopped:** Complete
|
||||||
|
**Next action:** N/A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 v2-p2-serve-command — `meeting-room serve` CLI subcommand
|
||||||
|
**Status:** done
|
||||||
|
**Where I stopped:** Complete
|
||||||
|
**Next action:** N/A
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🟢 v2-p1-deps — Обновить зависимости (pydantic, pytest, [web])
|
## 🟢 v2-p1-deps — Обновить зависимости (pydantic, pytest, [web])
|
||||||
**Status:** done
|
**Status:** done
|
||||||
**Branch:** feat/v2-phase1-core-refactor
|
**Branch:** feat/v2-phase1-core-refactor
|
||||||
@@ -44,7 +79,7 @@ _Updated: 2026-05-04_
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Фаза 1 завершена! Фаза 2 (Web-сервер) задачи будут добавлены.
|
Фаза 2 завершена! Все таски выполнены.
|
||||||
|
|
||||||
Статусы:
|
Статусы:
|
||||||
🔴 Active — только одна одновременно
|
🔴 Active — только одна одновременно
|
||||||
|
|||||||
26
.tasks/v2-p2-async-bridge.md
Normal file
26
.tasks/v2-p2-async-bridge.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# v2-p2-async-bridge
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Bridge synchronous EventEmitter to asyncio so DiscussionEngine can run in a thread while WebSocket/SSE consumers receive events asynchronously.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `meeting_room/events.py` — add AsyncEventBridge class
|
||||||
|
- `meeting_room/engine.py` — no changes (runs in thread as-is)
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
- 2026-05-04: AsyncEventBridge + asyncio.to_thread() chosen over async engine rewrite. Minimizes changes, engine stays sync.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- [ ] None
|
||||||
|
|
||||||
|
## Completed steps
|
||||||
|
- [x] AsyncEventBridge class in events.py
|
||||||
|
- [x] Thread-safe bridge with call_soon_threadsafe
|
||||||
|
- [x] subscribe/unsubscribe consumer queues
|
||||||
|
- [x] Drop policy on full queues
|
||||||
|
- [x] Tests (10/10 passing)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- events.py already has comment: "In Phase 2, an AsyncEventBridge will forward events to WebSocket clients"
|
||||||
|
- Bridge subscribes to sync EventEmitter, puts events into asyncio.Queue
|
||||||
|
- Consumers (WebSocket handlers, SSE handlers) read from the queue
|
||||||
22
.tasks/v2-p2-fastapi-app.md
Normal file
22
.tasks/v2-p2-fastapi-app.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# v2-p2-fastapi-app
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Create FastAPI application with REST API endpoints for session management and discussion control.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `meeting_room/server.py` — FastAPI app, routes, session store
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
- 2026-05-04: In-memory session store (dict). Persistent storage deferred.
|
||||||
|
- 2026-05-04: REST routes under /api prefix, WebSocket/SSE on root
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- [ ] None
|
||||||
|
|
||||||
|
## Completed steps
|
||||||
|
- [x] FastAPI app with lifespan handler
|
||||||
|
- [x] Session + SessionStore models
|
||||||
|
- [x] REST endpoints: POST /api/sessions, GET /api/sessions, GET /api/sessions/{id}, POST /api/sessions/{id}/start, POST /api/sessions/{id}/inject, GET /api/sessions/{id}/messages
|
||||||
|
- [x] GET /api/roles, GET /api/providers
|
||||||
|
- [x] Session start runs engine in asyncio.to_thread
|
||||||
|
- [x] Tests (12 passing)
|
||||||
17
.tasks/v2-p2-serve-command.md
Normal file
17
.tasks/v2-p2-serve-command.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# v2-p2-serve-command
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Add `meeting-room serve` CLI subcommand that starts the FastAPI web server.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `meeting_room/cli.py` — serve subcommand added
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
- 2026-05-04: Default port 8000, configurable via --port. --host and --reload flags.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- [ ] None
|
||||||
|
|
||||||
|
## Completed steps
|
||||||
|
- [x] `meeting-room serve` subcommand with --host, --port, --reload
|
||||||
|
- [x] Starts uvicorn with the FastAPI app
|
||||||
18
.tasks/v2-p2-web-ui.md
Normal file
18
.tasks/v2-p2-web-ui.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# v2-p2-web-ui
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Minimal HTML/CSS/JS frontend for viewing and interacting with discussions.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `meeting_room/static/index.html` — single-page UI
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
- 2026-05-04: Vanilla JS + WebSocket API. No framework.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- [ ] None
|
||||||
|
|
||||||
|
## Completed steps
|
||||||
|
- [x] Dark theme UI with WebSocket event handling
|
||||||
|
- [x] Start discussion, view messages, inject messages
|
||||||
|
- [x] Served from /static/index.html, / redirects
|
||||||
17
.tasks/v2-p2-websocket-sse.md
Normal file
17
.tasks/v2-p2-websocket-sse.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# v2-p2-websocket-sse
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Stream discussion events to browser clients via WebSocket and SSE endpoints.
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
- `meeting_room/server.py` — WebSocket and SSE route handlers
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
- 2026-05-04: WebSocket for bidirectional (events + inject), SSE for read-only
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- [ ] None
|
||||||
|
|
||||||
|
## Completed steps
|
||||||
|
- [x] WebSocket endpoint: /ws/{session_id} — bidirectional events + inject
|
||||||
|
- [x] SSE endpoint: /events/{session_id} — read-only event stream
|
||||||
@@ -426,6 +426,11 @@ def main():
|
|||||||
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
|
init_parser = subparsers.add_parser("init", help="Initialize .meeting-room workspace")
|
||||||
init_parser.add_argument("path", nargs="?", default=".", help="Where to create .meeting-room")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Handle init command
|
# Handle init command
|
||||||
@@ -433,6 +438,18 @@ def main():
|
|||||||
init_workspace(args.path)
|
init_workspace(args.path)
|
||||||
return
|
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
|
# Find config: CLI arg > .meeting-room/config.yaml > package default
|
||||||
if args.config:
|
if args.config:
|
||||||
config = load_config(args.config)
|
config = load_config(args.config)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""EventEmitter and event types for the Meeting Room discussion engine.
|
"""EventEmitter and event types for the Meeting Room discussion engine.
|
||||||
|
|
||||||
Synchronous event bus. In Phase 2, an AsyncEventBridge will forward
|
Synchronous event bus with AsyncEventBridge for forwarding events
|
||||||
events to WebSocket clients.
|
to asyncio consumers (WebSocket / SSE).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -127,3 +129,119 @@ class EventEmitter:
|
|||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Remove all subscriptions."""
|
"""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
423
meeting_room/server.py
Normal 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",
|
||||||
|
},
|
||||||
|
)
|
||||||
306
meeting_room/static/index.html
Normal file
306
meeting_room/static/index.html
Normal 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>
|
||||||
@@ -16,7 +16,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest>=7.0", "pytest-mock>=3.0"]
|
dev = ["pytest>=7.0", "pytest-mock>=3.0", "pytest-asyncio>=0.23"]
|
||||||
web = ["fastapi>=0.100", "uvicorn>=0.20", "websockets>=11.0"]
|
web = ["fastapi>=0.100", "uvicorn>=0.20", "websockets>=11.0"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
225
tests/test_async_bridge.py
Normal file
225
tests/test_async_bridge.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Tests for AsyncEventBridge — sync EventEmitter → asyncio.Queue bridge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meeting_room.events import (
|
||||||
|
AGENT_MESSAGE,
|
||||||
|
AGENT_TURN_START,
|
||||||
|
DISCUSSION_END,
|
||||||
|
DISCUSSION_START,
|
||||||
|
ROUND_END,
|
||||||
|
ROUND_START,
|
||||||
|
TOOL_CALL,
|
||||||
|
AsyncEventBridge,
|
||||||
|
EventEmitter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEventBridgeSubscribe:
|
||||||
|
"""subscribe() creates a queue that receives events."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_subscribe_returns_queue(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
q = bridge.subscribe()
|
||||||
|
assert isinstance(q, asyncio.Queue)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_subscribers(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
q1 = bridge.subscribe()
|
||||||
|
q2 = bridge.subscribe()
|
||||||
|
assert q1 is not q2
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEventBridgeStartStop:
|
||||||
|
"""start()/stop() wire the bridge to the sync bus."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_subscribes_to_all_event_types(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
for event_type in [
|
||||||
|
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
|
||||||
|
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
|
||||||
|
]:
|
||||||
|
assert len(bus.handlers(event_type)) == 1
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_unsubscribes(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
for event_type in [
|
||||||
|
DISCUSSION_START, ROUND_START, AGENT_TURN_START,
|
||||||
|
AGENT_MESSAGE, TOOL_CALL, ROUND_END, DISCUSSION_END,
|
||||||
|
]:
|
||||||
|
assert len(bus.handlers(event_type)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEventBridgeForwarding:
|
||||||
|
"""Events emitted on the sync bus arrive in async queues."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forward_event_to_queue(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
q = bridge.subscribe()
|
||||||
|
|
||||||
|
# Emit a sync event
|
||||||
|
bus.emit(AGENT_MESSAGE, role_id="moderator", name="Moderator", content="Hello")
|
||||||
|
|
||||||
|
# Give the event loop a chance to process call_soon_threadsafe
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
payload = q.get_nowait()
|
||||||
|
assert payload["type"] == AGENT_MESSAGE
|
||||||
|
assert payload["data"]["role_id"] == "moderator"
|
||||||
|
assert payload["data"]["content"] == "Hello"
|
||||||
|
assert "timestamp" in payload
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_queues_receive_same_event(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
q1 = bridge.subscribe()
|
||||||
|
q2 = bridge.subscribe()
|
||||||
|
|
||||||
|
bus.emit(DISCUSSION_START, name="test")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
p1 = q1.get_nowait()
|
||||||
|
p2 = q2.get_nowait()
|
||||||
|
assert p1["type"] == DISCUSSION_START
|
||||||
|
assert p2["type"] == DISCUSSION_START
|
||||||
|
assert p1["data"]["name"] == "test"
|
||||||
|
assert p2["data"]["name"] == "test"
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unsubscribe_stops_delivery(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
q = bridge.subscribe()
|
||||||
|
bridge.unsubscribe(q)
|
||||||
|
|
||||||
|
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content="Y")
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
assert q.empty()
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEventBridgeThreadedForwarding:
|
||||||
|
"""Events from a worker thread reach async consumers."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forward_from_thread(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
q = bridge.subscribe()
|
||||||
|
|
||||||
|
# Run engine in a thread (simplified)
|
||||||
|
def sync_work() -> None:
|
||||||
|
bus.emit(DISCUSSION_START, name="threaded")
|
||||||
|
bus.emit(AGENT_MESSAGE, role_id="bot", name="Bot", content="Hi")
|
||||||
|
bus.emit(DISCUSSION_END, total_messages=1)
|
||||||
|
|
||||||
|
await asyncio.to_thread(sync_work)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
while not q.empty():
|
||||||
|
events.append(q.get_nowait())
|
||||||
|
|
||||||
|
types = [e["type"] for e in events]
|
||||||
|
assert "discussion_start" in types
|
||||||
|
assert "agent_message" in types
|
||||||
|
assert "discussion_end" in types
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEventBridgePayloadFormat:
|
||||||
|
"""Serialized events contain type, data, and ISO timestamp."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_payload_structure(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
q = bridge.subscribe()
|
||||||
|
bus.emit(TOOL_CALL, role_id="analyst", tool_name="search", arguments='{"q":"test"}')
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
payload = q.get_nowait()
|
||||||
|
assert "type" in payload
|
||||||
|
assert "data" in payload
|
||||||
|
assert "timestamp" in payload
|
||||||
|
# timestamp is ISO format string
|
||||||
|
assert isinstance(payload["timestamp"], str)
|
||||||
|
assert payload["data"]["tool_name"] == "search"
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncEventBridgeDropPolicy:
|
||||||
|
"""When a queue is full, events are dropped without crashing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_queue_drops_events(self) -> None:
|
||||||
|
bus = EventEmitter()
|
||||||
|
bridge = AsyncEventBridge(bus)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
bridge.start(loop)
|
||||||
|
|
||||||
|
# maxsize=2 to make it fill quickly
|
||||||
|
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=2)
|
||||||
|
with bridge._lock:
|
||||||
|
bridge._queues.append(q)
|
||||||
|
|
||||||
|
# Emit 5 events — queue can hold 2
|
||||||
|
for i in range(5):
|
||||||
|
bus.emit(AGENT_MESSAGE, role_id="x", name="X", content=f"msg{i}")
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Queue should have at most 2 items (rest dropped)
|
||||||
|
received = []
|
||||||
|
while not q.empty():
|
||||||
|
received.append(q.get_nowait())
|
||||||
|
|
||||||
|
assert len(received) <= 2
|
||||||
|
|
||||||
|
bridge.stop()
|
||||||
163
tests/test_server.py
Normal file
163
tests/test_server.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""Tests for meeting_room.server — FastAPI app, REST API, WebSocket/SSE."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from meeting_room.events import AsyncEventBridge, EventEmitter
|
||||||
|
from meeting_room.server import Session, SessionStore, app, store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_store():
|
||||||
|
"""Clear the global session store before each test."""
|
||||||
|
store._sessions.clear()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
"""FastAPI test client."""
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SessionStore
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionStore:
|
||||||
|
def test_create_session(self) -> None:
|
||||||
|
store = SessionStore()
|
||||||
|
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||||
|
assert session.id
|
||||||
|
assert session.status == "created"
|
||||||
|
|
||||||
|
def test_get_session(self) -> None:
|
||||||
|
store = SessionStore()
|
||||||
|
session = store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||||
|
found = store.get(session.id)
|
||||||
|
assert found is session
|
||||||
|
|
||||||
|
def test_get_nonexistent_session(self) -> None:
|
||||||
|
store = SessionStore()
|
||||||
|
assert store.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_list_sessions(self) -> None:
|
||||||
|
store = SessionStore()
|
||||||
|
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||||
|
store.create({"providers": {}, "roles": {}, "defaults": {}}, _minimal_discussion_config())
|
||||||
|
assert len(store.list_sessions()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REST API endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateSession:
|
||||||
|
def test_create_session_returns_id_and_status(self, client) -> None:
|
||||||
|
response = client.post("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "id" in data
|
||||||
|
assert data["status"] == "created"
|
||||||
|
|
||||||
|
|
||||||
|
class TestListSessions:
|
||||||
|
def test_list_empty(self, client) -> None:
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
|
def test_list_after_create(self, client) -> None:
|
||||||
|
client.post("/api/sessions")
|
||||||
|
client.post("/api/sessions")
|
||||||
|
response = client.get("/api/sessions")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSession:
|
||||||
|
def test_get_existing_session(self, client) -> None:
|
||||||
|
create_resp = client.post("/api/sessions")
|
||||||
|
session_id = create_resp.json()["id"]
|
||||||
|
response = client.get(f"/api/sessions/{session_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == session_id
|
||||||
|
|
||||||
|
def test_get_nonexistent_session(self, client) -> None:
|
||||||
|
response = client.get("/api/sessions/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestRolesAndProviders:
|
||||||
|
def test_list_roles(self, client) -> None:
|
||||||
|
response = client.get("/api/roles")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "moderator" in data or isinstance(data, dict)
|
||||||
|
|
||||||
|
def test_list_providers(self, client) -> None:
|
||||||
|
response = client.get("/api/providers")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session model
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSession:
|
||||||
|
def test_build_engine(self) -> None:
|
||||||
|
config = _minimal_config()
|
||||||
|
session = Session("test-id", config, _minimal_discussion_config())
|
||||||
|
engine = session.build_engine()
|
||||||
|
assert engine is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_config() -> dict:
|
||||||
|
return {
|
||||||
|
"providers": {
|
||||||
|
"test_provider": {
|
||||||
|
"base_url": "https://api.example.com/v1",
|
||||||
|
"api_key": "test-key",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"moderator": {
|
||||||
|
"name": "Moderator",
|
||||||
|
"provider": "test_provider",
|
||||||
|
"model": "gpt-4o-mini",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"tools": "none",
|
||||||
|
"system_prompt": "You are a moderator.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"max_rounds": 3,
|
||||||
|
"language": "en",
|
||||||
|
"framework": "custom",
|
||||||
|
"workdir": ".",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_discussion_config():
|
||||||
|
from meeting_room.models import DefaultsConfig, DiscussionConfig
|
||||||
|
|
||||||
|
return DiscussionConfig(
|
||||||
|
providers={"test_provider": {"base_url": "https://api.example.com/v1", "api_key": "test-key"}},
|
||||||
|
roles={"moderator": {"name": "Moderator", "provider": "test_provider", "model": "gpt-4o-mini"}},
|
||||||
|
defaults=DefaultsConfig(max_rounds=3),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user