22230a3e3f
- 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
293 lines
8.6 KiB
JavaScript
293 lines
8.6 KiB
JavaScript
/**
|
|
* 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 ? '✓' : '✗';
|
|
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 ? '✓' : '✗';
|
|
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 = {};
|
|
},
|
|
};
|