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>
306 lines
10 KiB
HTML
306 lines
10 KiB
HTML
<!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> |