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:
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>
|
||||
Reference in New Issue
Block a user