436 lines
14 KiB
HTML
436 lines
14 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">
|
|
<button id="attach" title="Attach image">Attach</button>
|
|
<input type="file" id="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple style="display:none">
|
|
<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 attachBtn = document.getElementById('attach');
|
|
const fileEl = document.getElementById('file');
|
|
const statusEl = document.getElementById('status');
|
|
|
|
let pendingAttachments = [];
|
|
|
|
function sanitizeHtml(html) {
|
|
const root = document.createElement('div');
|
|
root.innerHTML = html;
|
|
|
|
const blockedTags = new Set([
|
|
'script', 'style', 'iframe', 'object', 'embed', 'link', 'meta', 'base',
|
|
'form', 'input', 'button', 'textarea', 'select',
|
|
]);
|
|
|
|
const nodes = root.querySelectorAll('*');
|
|
for (const el of nodes) {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (blockedTags.has(tag)) {
|
|
el.remove();
|
|
continue;
|
|
}
|
|
|
|
for (const attr of Array.from(el.attributes)) {
|
|
const name = attr.name.toLowerCase();
|
|
const value = attr.value.trim();
|
|
if (name.startsWith('on') || name === 'style') {
|
|
el.removeAttribute(attr.name);
|
|
continue;
|
|
}
|
|
if (name === 'href' || name === 'src' || name === 'xlink:href') {
|
|
const normalized = value.replace(/[\u0000-\u001F\u007F\s]+/g, '').toLowerCase();
|
|
if (
|
|
normalized.startsWith('javascript:')
|
|
|| normalized.startsWith('vbscript:')
|
|
|| normalized.startsWith('data:text/html')
|
|
) {
|
|
el.removeAttribute(attr.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tag === 'a' && el.getAttribute('target') === '_blank') {
|
|
el.setAttribute('rel', 'noopener noreferrer');
|
|
}
|
|
}
|
|
|
|
return root.innerHTML;
|
|
}
|
|
|
|
function renderSafeMarkdown(text) {
|
|
const html = marked.parse(String(text ?? ''));
|
|
return sanitizeHtml(html);
|
|
}
|
|
|
|
function isSupportedImageMime(mimeType) {
|
|
return mimeType === 'image/jpeg'
|
|
|| mimeType === 'image/png'
|
|
|| mimeType === 'image/gif'
|
|
|| mimeType === 'image/webp';
|
|
}
|
|
|
|
function readFileAsBase64(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
reader.onload = () => {
|
|
const result = String(reader.result || '');
|
|
const comma = result.indexOf(',');
|
|
if (comma === -1) {
|
|
reject(new Error('Unexpected file encoding'));
|
|
return;
|
|
}
|
|
resolve(result.slice(comma + 1));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
// ── 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 && pendingAttachments.length === 0) || !ws || ws.readyState !== WebSocket.OPEN || inputDisabled) return;
|
|
|
|
requestId++;
|
|
const id = requestId;
|
|
|
|
// Display user message
|
|
appendUserMessage(text || `Sent ${pendingAttachments.length} attachment(s)`);
|
|
|
|
// 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, attachments: pendingAttachments },
|
|
}));
|
|
|
|
// Clear input and disable while waiting
|
|
inputEl.value = '';
|
|
pendingAttachments = [];
|
|
fileEl.value = '';
|
|
setInputDisabled(true);
|
|
}
|
|
|
|
attachBtn.addEventListener('click', () => {
|
|
fileEl.click();
|
|
});
|
|
|
|
fileEl.addEventListener('change', async () => {
|
|
const files = Array.from(fileEl.files ?? []);
|
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
for (const file of files) {
|
|
if (!isSupportedImageMime(file.type)) {
|
|
appendErrorMessage(`Unsupported file type: ${file.type || file.name}`);
|
|
continue;
|
|
}
|
|
if (file.size > MAX_BYTES) {
|
|
appendErrorMessage(`File too large (max 5MB): ${file.name}`);
|
|
continue;
|
|
}
|
|
try {
|
|
const data = await readFileAsBase64(file);
|
|
pendingAttachments.push({ mimeType: file.type, data, filename: file.name });
|
|
} catch (err) {
|
|
appendErrorMessage(`Failed to attach ${file.name}: ${err.message}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
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 = renderSafeMarkdown(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 = renderSafeMarkdown(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>
|