/** * 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 `

${escapeHtml(text)}

`; } 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 = ` ${escapeHtml(data.tool)}`; } else if (event === 'tool_end') { const icon = data.result?.success ? '✓' : '✗'; const cls = data.result?.success ? 'status-ok' : 'status-error'; header.innerHTML = `${icon} ${escapeHtml(data.tool)}`; } 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 = '
Could not load history
'; } } 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 = 'Thinking...'; _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 = `${icon} ${escapeHtml(data.tool)}`; } // 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 = `
`; _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 = `
Failed to create session: ${err.message}
`; } }); // 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 = '
Select a session or create a new one to start chatting
'; } }, teardown() { _currentSession = null; _sending = false; _elements = {}; }, };