Files
flynn/src/gateway/ui/chat.html
T
William Valentin 282a15d2b9 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.
2026-02-05 19:39:53 -08:00

330 lines
9.9 KiB
HTML

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