feat: add web UI dashboard SPA with dashboard, chat, sessions, and settings pages

- Add SPA shell with hash-based router, sidebar navigation, and WebSocket RPC client
- Add dashboard page with system health cards, channel status, and auto-refresh
- Add chat page with session selector, streaming tool events, and markdown rendering
- Add sessions page with list, history viewer, and delete functionality
- Add settings page with hook pattern editor, tool list, and config viewer
- Add backend handlers: sessions.delete, sessions.switch, system.channels, system.usage
- Wire channelRegistry into gateway server for channel status reporting
- Extend static file server with .mjs, .png, .ico, .woff2 content types
This commit is contained in:
William Valentin
2026-02-07 10:07:45 -08:00
parent f7d889e35e
commit 22230a3e3f
14 changed files with 1836 additions and 207 deletions
+292
View File
@@ -0,0 +1,292 @@
/**
* Flynn Chat Page
*
* Session selector, message input, streaming tool events,
* and markdown-rendered responses.
*/
/* global marked, hljs */
let _currentSession = null;
let _sending = false;
let _elements = {};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMarkdown(text) {
try {
if (typeof marked !== 'undefined') {
return marked.parse(text);
}
} catch {
// Fall through to plain text
}
return `<p>${escapeHtml(text)}</p>`;
}
function highlightCode() {
if (typeof hljs !== 'undefined') {
document.querySelectorAll('.chat-messages pre code').forEach(block => {
hljs.highlightElement(block);
});
}
}
function createMessageEl(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant') {
div.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} else {
div.textContent = content;
}
return div;
}
function createToolEventEl(event, data) {
const group = document.createElement('div');
group.className = 'tool-event-group';
const header = document.createElement('div');
header.className = 'tool-event-header';
if (event === 'tool_start') {
header.innerHTML = `<span class="spinner"></span> <strong>${escapeHtml(data.tool)}</strong>`;
} else if (event === 'tool_end') {
const icon = data.result?.success ? '&#10003;' : '&#10007;';
const cls = data.result?.success ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
header.addEventListener('click', () => {
body.classList.toggle('open');
});
const body = document.createElement('div');
body.className = 'tool-event-body';
if (event === 'tool_start' && data.args) {
body.textContent = JSON.stringify(data.args, null, 2);
} else if (event === 'tool_end' && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
group.appendChild(header);
group.appendChild(body);
return group;
}
function scrollToBottom() {
const msgs = _elements.messages;
if (msgs) {
msgs.scrollTop = msgs.scrollHeight;
}
}
async function loadSessions(client) {
const select = _elements.sessionSelect;
if (!select) return;
try {
const result = await client.call('sessions.list');
const sessions = result.sessions ?? [];
// Preserve current selection
const current = _currentSession;
select.innerHTML = '';
if (sessions.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No sessions';
select.appendChild(opt);
} else {
for (const s of sessions) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.id} (${s.messageCount} msgs)`;
if (s.id === current) opt.selected = true;
select.appendChild(opt);
}
}
// Update current session
_currentSession = select.value || null;
} catch {
// Ignore — sessions may not be available
}
}
async function loadHistory(client) {
const msgs = _elements.messages;
if (!msgs || !_currentSession) return;
msgs.innerHTML = '';
try {
const result = await client.call('sessions.history', { sessionId: _currentSession });
const messages = result.messages ?? [];
for (const msg of messages) {
const role = msg.role ?? 'assistant';
const content = msg.content ?? msg.text ?? '';
msgs.appendChild(createMessageEl(role, content));
}
scrollToBottom();
} catch {
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
}
}
async function sendMessage(client) {
const input = _elements.input;
const text = input?.value?.trim();
if (!text || _sending) return;
_sending = true;
_elements.sendBtn.disabled = true;
input.value = '';
// Show user message
_elements.messages.appendChild(createMessageEl('user', text));
scrollToBottom();
// Create placeholder for assistant response
const placeholder = document.createElement('div');
placeholder.className = 'message assistant streaming-cursor';
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
_elements.messages.appendChild(placeholder);
scrollToBottom();
try {
const stream = client.stream('agent.send', { message: text });
stream.on('tool_start', (data) => {
const el = createToolEventEl('tool_start', data);
_elements.messages.insertBefore(el, placeholder);
scrollToBottom();
});
stream.on('tool_end', (data) => {
// Replace the last tool_start spinner with completion marker
const events = _elements.messages.querySelectorAll('.tool-event-group');
const last = events[events.length - 1];
if (last) {
const header = last.querySelector('.tool-event-header');
if (header && data.tool) {
const icon = data.result?.success !== false ? '&#10003;' : '&#10007;';
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
// Add result body
const body = last.querySelector('.tool-event-body');
if (body && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
}
scrollToBottom();
});
const done = await stream.result;
// Replace placeholder with actual response
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} catch (err) {
placeholder.classList.remove('streaming-cursor');
placeholder.className = 'message error';
placeholder.textContent = `Error: ${err.message}`;
} finally {
_sending = false;
if (_elements.sendBtn) _elements.sendBtn.disabled = false;
scrollToBottom();
}
}
export const ChatPage = {
async render(el, client) {
el.innerHTML = `
<div class="chat-layout">
<div class="chat-header">
<select id="chat-session-select"></select>
<button id="chat-new-session" class="btn btn-secondary">+ New Session</button>
<button id="chat-load-history" class="btn btn-secondary">Load History</button>
</div>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="btn btn-primary">Send</button>
</div>
</div>
`;
_elements = {
sessionSelect: el.querySelector('#chat-session-select'),
messages: el.querySelector('#chat-messages'),
input: el.querySelector('#chat-input'),
sendBtn: el.querySelector('#chat-send'),
};
// Load sessions into dropdown
await loadSessions(client);
// Event: session change
_elements.sessionSelect.addEventListener('change', () => {
_currentSession = _elements.sessionSelect.value || null;
});
// Event: new session
el.querySelector('#chat-new-session').addEventListener('click', async () => {
try {
const result = await client.call('sessions.create');
_currentSession = result.sessionId;
await loadSessions(client);
_elements.messages.innerHTML = '';
} catch (err) {
_elements.messages.innerHTML = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
}
});
// Event: load history
el.querySelector('#chat-load-history').addEventListener('click', () => {
loadHistory(client);
});
// Event: send message
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
// Event: Enter to send (Shift+Enter for newline)
_elements.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(client);
}
});
// Auto-resize textarea
_elements.input.addEventListener('input', () => {
const ta = _elements.input;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
});
// If there's a current session, show welcome
if (!_currentSession) {
_elements.messages.innerHTML = '<div class="empty-state">Select a session or create a new one to start chatting</div>';
}
},
teardown() {
_currentSession = null;
_sending = false;
_elements = {};
},
};