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

@@ -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>