feat(gateway): add web UI with dashboard and chat interface

Refactor GatewayServer to serve HTTP and WebSocket on a shared
http.Server. Add static file serving with path traversal protection,
a dark-themed dashboard (system health, sessions, tools) and a
WebSocket chat interface with streaming tool events and markdown
rendering.
This commit is contained in:
William Valentin
2026-02-05 19:39:53 -08:00
parent f30a8bc318
commit 282a15d2b9
7 changed files with 1244 additions and 18 deletions
+329
View File
@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flynn Chat</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="chat-container">
<header>
<h1>Flynn</h1>
<span id="status">Connecting...</span>
<a href="/">Dashboard</a>
</header>
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="input" placeholder="Type a message..." autofocus>
<button id="send">Send</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
// ── State ──────────────────────────────────────────────────────
let requestId = 0;
let ws = null;
let reconnectDelay = 1000; // Start at 1s, increase with backoff
const MAX_RECONNECT_DELAY = 30000;
const pendingTools = new Map(); // requestId -> Map(toolName -> element)
let inputDisabled = false;
// ── DOM references ─────────────────────────────────────────────
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
const statusEl = document.getElementById('status');
// ── WebSocket connection ───────────────────────────────────────
function connect() {
setStatus('connecting');
ws = new WebSocket(`ws://${location.host}`);
ws.addEventListener('open', () => {
setStatus('connected');
reconnectDelay = 1000; // Reset backoff on successful connection
});
ws.addEventListener('message', (event) => {
handleMessage(event.data);
});
ws.addEventListener('close', () => {
setStatus('disconnected');
ws = null;
scheduleReconnect();
});
ws.addEventListener('error', () => {
// Error event is always followed by close, so reconnect happens there
});
}
function scheduleReconnect() {
setTimeout(() => {
connect();
}, reconnectDelay);
// Exponential backoff with cap
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
}
function setStatus(state) {
statusEl.textContent =
state === 'connecting' ? 'Connecting...' :
state === 'connected' ? 'Connected' :
'Disconnected';
statusEl.className = state;
}
// ── Sending messages ───────────────────────────────────────────
function sendMessage() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return;
requestId++;
const id = requestId;
// Display user message
appendUserMessage(text);
// Create an assistant message area for tool events + final response
createAssistantArea(id);
// Send JSON-RPC request
ws.send(JSON.stringify({
id: id,
method: 'agent.send',
params: { message: text },
}));
// Clear input and disable while waiting
inputEl.value = '';
setInputDisabled(true);
}
function setInputDisabled(disabled) {
inputDisabled = disabled;
inputEl.disabled = disabled;
sendBtn.disabled = disabled;
if (!disabled) {
inputEl.focus();
}
}
// ── Handling incoming messages ─────────────────────────────────
function handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch {
console.error('Failed to parse message:', raw);
return;
}
// Direct responses (e.g., from system.health) — ignore for chat
if (msg.result !== undefined && !msg.event) {
return;
}
// Error responses without an event field (protocol-level errors)
if (msg.error && !msg.event) {
appendErrorMessage(msg.error.message || 'Unknown error');
setInputDisabled(false);
return;
}
// Streamed events from agent.send
if (msg.event) {
const id = msg.id;
const data = msg.data || {};
switch (msg.event) {
case 'tool_start':
handleToolStart(id, data);
break;
case 'tool_end':
handleToolEnd(id, data);
break;
case 'done':
handleDone(id, data);
break;
case 'error':
handleError(id, data);
break;
default:
console.warn('Unknown event type:', msg.event);
}
}
}
// ── Event handlers ─────────────────────────────────────────────
function handleToolStart(id, data) {
const area = getAssistantArea(id);
if (!area) return;
const toolDiv = document.createElement('div');
toolDiv.className = 'tool-event';
toolDiv.dataset.tool = data.tool;
const spinner = document.createElement('span');
spinner.className = 'spinner';
const label = document.createElement('span');
label.textContent = `Running ${data.tool}...`;
toolDiv.appendChild(spinner);
toolDiv.appendChild(label);
area.appendChild(toolDiv);
// Track this tool element so we can update it on tool_end
if (!pendingTools.has(id)) {
pendingTools.set(id, new Map());
}
pendingTools.get(id).set(data.tool, toolDiv);
scrollToBottom();
}
function handleToolEnd(id, data) {
const toolMap = pendingTools.get(id);
if (!toolMap) return;
const toolDiv = toolMap.get(data.tool);
if (!toolDiv) return;
// Remove spinner, add checkmark
const spinner = toolDiv.querySelector('.spinner');
if (spinner) spinner.remove();
const label = toolDiv.querySelector('span');
if (label) {
label.textContent = `${data.tool} \u2714`;
}
toolMap.delete(data.tool);
if (toolMap.size === 0) {
pendingTools.delete(id);
}
scrollToBottom();
}
function handleDone(id, data) {
const area = getAssistantArea(id);
if (!area) {
// No area exists (edge case) — create a standalone message
appendAssistantMessage(data.content || '');
} else {
// Render final response as markdown inside the assistant area
const responseDiv = document.createElement('div');
responseDiv.className = 'message assistant';
responseDiv.innerHTML = marked.parse(data.content || '');
area.appendChild(responseDiv);
}
setInputDisabled(false);
scrollToBottom();
}
function handleError(id, data) {
const area = getAssistantArea(id);
const errorDiv = document.createElement('div');
errorDiv.className = 'message error';
errorDiv.textContent = data.message || 'Unknown error';
if (area) {
area.appendChild(errorDiv);
} else {
messagesEl.appendChild(errorDiv);
}
// Clean up any pending tools for this request
pendingTools.delete(id);
setInputDisabled(false);
scrollToBottom();
}
// ── DOM helpers ────────────────────────────────────────────────
/**
* Append a user message bubble to the messages area.
*/
function appendUserMessage(text) {
const div = document.createElement('div');
div.className = 'message user';
div.textContent = text;
messagesEl.appendChild(div);
scrollToBottom();
}
/**
* Append a standalone assistant message (used as fallback).
*/
function appendAssistantMessage(content) {
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = marked.parse(content);
messagesEl.appendChild(div);
scrollToBottom();
}
/**
* Append an error message to the messages area.
*/
function appendErrorMessage(text) {
const div = document.createElement('div');
div.className = 'message error';
div.textContent = text;
messagesEl.appendChild(div);
scrollToBottom();
}
/**
* Create a container div for an assistant response (tool events + final message).
* Tagged with a data-request-id so we can find it later.
*/
function createAssistantArea(id) {
const area = document.createElement('div');
area.className = 'assistant-area';
area.dataset.requestId = id;
messagesEl.appendChild(area);
return area;
}
/**
* Find the assistant area for a given request ID.
*/
function getAssistantArea(id) {
return messagesEl.querySelector(`.assistant-area[data-request-id="${id}"]`);
}
/**
* Scroll the messages container to the bottom.
*/
function scrollToBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ── Event listeners ────────────────────────────────────────────
sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendMessage();
}
});
// ── Initialize ─────────────────────────────────────────────────
connect();
</script>
</body>
</html>
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flynn Dashboard</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container" style="height:100vh;overflow-y:auto">
<header>
<h1>Flynn Dashboard</h1>
<span id="status">Connecting...</span>
<a href="/chat.html">Chat</a>
</header>
<section class="dashboard" id="health">
<!-- Health cards rendered by JS -->
</section>
<section>
<h2>Sessions</h2>
<div id="sessions" class="session-list">Loading...</div>
</section>
<section>
<h2>Tools</h2>
<div id="tools" class="tool-list">Loading...</div>
</section>
</div>
<script>
// --- Request ID counter and pending callback map ---
let requestId = 0;
const pending = new Map();
// --- WebSocket connection ---
const ws = new WebSocket(`ws://${location.host}`);
const statusEl = document.getElementById('status');
const healthEl = document.getElementById('health');
const sessionsEl = document.getElementById('sessions');
const toolsEl = document.getElementById('tools');
ws.addEventListener('open', () => {
statusEl.textContent = 'Connected';
statusEl.className = 'status-ok';
// Fetch all data on connect
fetchHealth();
fetchSessions();
fetchTools();
});
ws.addEventListener('close', () => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'status-error';
});
ws.addEventListener('error', () => {
statusEl.textContent = 'Connection error';
statusEl.className = 'status-error';
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
// Match response to pending request by ID
if (msg.id != null && pending.has(msg.id)) {
const callback = pending.get(msg.id);
pending.delete(msg.id);
if (msg.result) {
callback(null, msg.result);
} else if (msg.error) {
callback(msg.error, null);
}
}
} catch {
// Ignore malformed messages
}
});
// --- Auto-refresh health every 10 seconds ---
setInterval(fetchHealth, 10000);
// --- RPC helper: send a JSON-RPC request and register a callback ---
function rpcCall(method, params, callback) {
requestId++;
const id = requestId;
pending.set(id, callback);
const message = { id, method };
if (params) {
message.params = params;
}
ws.send(JSON.stringify(message));
}
// --- Uptime formatting: converts seconds to human-readable string ---
function formatUptime(totalSeconds) {
const s = Math.floor(totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h}h ${m}m ${sec}s`;
}
// --- Fetch and render system health ---
function fetchHealth() {
rpcCall('system.health', null, (err, result) => {
if (err) {
healthEl.innerHTML = '<div class="card"><h2>Error</h2><div class="value">' +
escapeHtml(err.message || 'Failed to fetch health') + '</div></div>';
return;
}
renderHealth(result);
});
}
function renderHealth(data) {
const isOk = data.status === 'ok';
const statusClass = isOk ? 'status-ok' : 'status-error';
const statusLabel = isOk ? '● Healthy' : '● Unhealthy';
healthEl.innerHTML = `
<div class="card">
<h2>Status</h2>
<div class="value ${statusClass}">${statusLabel}</div>
</div>
<div class="card">
<h2>Version</h2>
<div class="value">${escapeHtml(data.version || 'unknown')}</div>
</div>
<div class="card">
<h2>Uptime</h2>
<div class="value">${formatUptime(data.uptime || 0)}</div>
</div>
<div class="card">
<h2>Connections</h2>
<div class="value">${data.connections ?? 0}</div>
</div>
<div class="card">
<h2>Sessions</h2>
<div class="value">${data.sessions ?? 0}</div>
</div>
<div class="card">
<h2>Tools</h2>
<div class="value">${data.tools ?? 0}</div>
</div>
`;
}
// --- Fetch and render sessions list ---
function fetchSessions() {
rpcCall('sessions.list', null, (err, result) => {
if (err) {
sessionsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch sessions');
return;
}
renderSessions(result.sessions || []);
});
}
function renderSessions(sessions) {
if (sessions.length === 0) {
sessionsEl.textContent = 'No active sessions';
return;
}
const rows = sessions.map(s => `
<tr>
<td>${escapeHtml(s.id)}</td>
<td>${s.messageCount ?? 0}</td>
<td>${s.lastActivity ? new Date(s.lastActivity).toLocaleString() : '—'}</td>
</tr>
`).join('');
sessionsEl.innerHTML = `
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Messages</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
// --- Fetch and render tools list ---
function fetchTools() {
rpcCall('tools.list', null, (err, result) => {
if (err) {
toolsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch tools');
return;
}
renderTools(result.tools || []);
});
}
function renderTools(tools) {
if (tools.length === 0) {
toolsEl.textContent = 'No tools registered';
return;
}
const items = tools.map(t => `
<div class="tool-item">
<strong>${escapeHtml(t.name)}</strong>
${t.description ? '<span> — ' + escapeHtml(t.description) + '</span>' : ''}
</div>
`).join('');
toolsEl.innerHTML = items;
}
// --- XSS protection: escape HTML entities ---
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
</script>
</body>
</html>
+532
View File
@@ -0,0 +1,532 @@
/* ==========================================================================
Flynn Gateway — Shared Dark Theme
Terminal-aesthetic dark theme used by chat.html and index.html (dashboard).
========================================================================== */
/* ---------- CSS Custom Properties (Design Tokens) ---------- */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #1c2128;
--bg-input: #0d1117;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-muted: rgba(88, 166, 255, 0.15);
--error: #f85149;
--error-muted: rgba(248, 81, 73, 0.15);
--success: #3fb950;
--success-muted: rgba(63, 185, 80, 0.15);
--warning: #d29922;
--border: #30363d;
--border-light: #21262d;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
--font-size-base: 14px;
--font-size-sm: 12px;
--font-size-lg: 18px;
--font-size-xl: 24px;
--line-height: 1.55;
--radius: 6px;
--radius-lg: 10px;
--container-max: 900px;
--transition: 150ms ease;
}
/* ---------- Reset ---------- */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ---------- Base ---------- */
html,
body {
height: 100%;
width: 100%;
overflow: hidden; /* Pages handle their own scrolling */
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
line-height: var(--line-height);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ---------- Links ---------- */
a {
color: var(--accent);
text-decoration: none;
transition: opacity var(--transition);
}
a:hover {
opacity: 0.8;
text-decoration: underline;
}
/* ---------- Container ---------- */
.container {
max-width: var(--container-max);
margin: 0 auto;
padding: 0 16px;
}
/* ---------- Header ---------- */
header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--text-primary);
margin-right: auto;
}
header #status {
font-size: var(--font-size-sm);
color: var(--text-muted);
}
header #status.connected {
color: var(--success);
}
header #status.disconnected,
header #status.status-error {
color: var(--error);
}
header #status.status-ok {
color: var(--success);
}
/* ---------- Scrollbar (Webkit) ---------- */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ==========================================================================
Chat-Specific Styles
========================================================================== */
/* Chat layout — full viewport flex column */
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
max-width: var(--container-max);
margin: 0 auto;
}
/* Scrollable message area */
.messages {
flex: 1 1 0;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Individual message bubble */
.message {
padding: 10px 14px;
border-radius: var(--radius);
max-width: 85%;
word-wrap: break-word;
white-space: pre-wrap;
font-size: var(--font-size-base);
line-height: var(--line-height);
}
/* User messages — right-aligned with accent tint */
.message.user {
align-self: flex-end;
background-color: var(--accent-muted);
color: var(--text-primary);
border: 1px solid rgba(88, 166, 255, 0.25);
}
/* Assistant messages — left-aligned with subtle bg */
.message.assistant {
align-self: flex-start;
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-light);
}
/* Error messages */
.message.error {
align-self: flex-start;
background-color: var(--error-muted);
color: var(--text-primary);
border: 1px solid rgba(248, 81, 73, 0.35);
}
/* Input area — fixed at the bottom of chat */
.input-area {
display: flex;
flex-direction: row;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
background-color: var(--bg-secondary);
}
.input-area input {
flex: 1 1 0;
padding: 10px 12px;
background-color: var(--bg-input);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
transition: border-color var(--transition);
}
.input-area input::placeholder {
color: var(--text-muted);
}
.input-area input:focus {
border-color: var(--accent);
}
.input-area button {
padding: 10px 18px;
background-color: var(--accent);
color: var(--bg-primary);
font-family: var(--font-mono);
font-size: var(--font-size-base);
font-weight: 600;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: opacity var(--transition);
}
.input-area button:hover {
opacity: 0.85;
}
.input-area button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ==========================================================================
Dashboard-Specific Styles
========================================================================== */
/* Dashboard layout — grid of cards */
.dashboard {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
padding: 24px 0;
}
/* Section headings in dashboard page */
.container > section {
padding: 16px 0;
}
.container > section > h2 {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
/* Dashboard header — spans full width */
.dashboard-header {
grid-column: 1 / -1;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.dashboard-header h1 {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--text-primary);
}
/* Card component */
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
transition: border-color var(--transition);
}
.card:hover {
border-color: var(--text-muted);
}
.card h2 {
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.card .value {
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.card .label {
font-size: var(--font-size-sm);
color: var(--text-muted);
margin-top: 4px;
}
/* Status indicators */
.status-ok {
color: var(--success);
}
.status-error {
color: var(--error);
}
.status-warning {
color: var(--warning);
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.ok {
background-color: var(--success);
}
.status-dot.error {
background-color: var(--error);
}
.status-dot.warning {
background-color: var(--warning);
}
/* Session list */
.session-list {
list-style: none;
}
.session-list li {
padding: 8px 0;
border-bottom: 1px solid var(--border-light);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.session-list li:last-child {
border-bottom: none;
}
.session-list li a {
color: var(--accent);
}
/* Tool list */
.tool-list {
list-style: none;
}
.tool-list li {
padding: 6px 0;
border-bottom: 1px solid var(--border-light);
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.tool-list li:last-child {
border-bottom: none;
}
/* ==========================================================================
Utility Classes
========================================================================== */
/* Tool event display — monospace block for tool_start / tool_end */
.tool-event {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
background-color: var(--bg-tertiary);
color: var(--text-muted);
border: 1px solid var(--border-light);
border-radius: var(--radius);
padding: 8px 10px;
margin: 4px 0;
white-space: pre-wrap;
word-break: break-all;
}
/* CSS spinner animation */
.spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Hidden utility */
.hidden {
display: none !important;
}
/* Text utilities */
.text-muted {
color: var(--text-muted);
}
.text-secondary {
color: var(--text-secondary);
}
.text-accent {
color: var(--accent);
}
.text-error {
color: var(--error);
}
.text-success {
color: var(--success);
}
.text-sm {
font-size: var(--font-size-sm);
}
.text-lg {
font-size: var(--font-size-lg);
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
font-size: var(--font-size-sm);
border-radius: 12px;
font-weight: 600;
}
.badge.ok {
background-color: var(--success-muted);
color: var(--success);
}
.badge.error {
background-color: var(--error-muted);
color: var(--error);
}
/* ==========================================================================
Responsive — Small Screens (<600px)
========================================================================== */
@media (max-width: 600px) {
.container {
padding: 0 8px;
}
.chat-container {
max-width: 100%;
}
.messages {
padding: 10px 8px;
}
.message {
max-width: 95%;
padding: 8px 10px;
}
.input-area {
padding: 8px;
}
.input-area input {
padding: 8px 10px;
}
.input-area button {
padding: 8px 14px;
}
.dashboard {
grid-template-columns: 1fr;
padding: 16px 8px;
gap: 12px;
}
.card {
padding: 12px;
}
.card .value {
font-size: var(--font-size-lg);
}
}