/** * Flynn Chat Page * * Session selector, message input, streaming tool events, * markdown-rendered responses, slash commands, and web search. */ /* global hljs */ import { renderSafeMarkdown } from '../lib/markdown.js'; let _currentSession = null; let _sending = false; let _searchMode = false; let _slashPopupIndex = -1; let _elements = {}; let _pendingAttachments = []; // ── Slash Command Definitions ─────────────────────────────── const SLASH_COMMANDS = [ { name: '/help', desc: 'Show available commands' }, { name: '/reset', desc: 'Reset session' }, { name: '/compact', desc: 'Compact context' }, { name: '/usage', desc: 'Show token usage' }, { name: '/status', desc: 'Show system health' }, { name: '/model', desc: 'Show current model' }, ]; // ── Helpers ───────────────────────────────────────────────── function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function highlightCode() { if (typeof hljs !== 'undefined') { document.querySelectorAll('.chat-messages pre code').forEach(block => { hljs.highlightElement(block); }); } } function createMessageEl(role, content) { const wrapper = document.createElement('div'); wrapper.className = 'message-wrapper'; const div = document.createElement('div'); div.className = `message ${role}`; if (role === 'assistant' || role === 'system') { div.innerHTML = renderSafeMarkdown(content); setTimeout(highlightCode, 0); } else { div.textContent = content; } wrapper.appendChild(div); // Add action buttons (copy for all, edit for user) outside the message box if (role !== 'system') { wrapper.appendChild(createMessageActions(role)); } return wrapper; } 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; } } 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 || ''); // result is like: data:;base64, const comma = result.indexOf(','); if (comma === -1) { reject(new Error('Unexpected file encoding')); return; } resolve(result.slice(comma + 1)); }; reader.readAsDataURL(file); }); } function renderPendingAttachments() { const el = _elements.attachments; if (!el) {return;} el.innerHTML = ''; if (!_pendingAttachments.length) { el.classList.add('hidden'); return; } el.classList.remove('hidden'); for (let i = 0; i < _pendingAttachments.length; i++) { const att = _pendingAttachments[i]; const chip = document.createElement('div'); chip.className = 'attachment-chip'; const name = document.createElement('span'); name.className = 'attachment-name'; name.textContent = att.filename || 'attachment'; const rm = document.createElement('button'); rm.className = 'attachment-remove'; rm.type = 'button'; rm.title = 'Remove attachment'; rm.textContent = '×'; rm.addEventListener('click', () => { _pendingAttachments.splice(i, 1); renderPendingAttachments(); }); chip.appendChild(name); chip.appendChild(rm); el.appendChild(chip); } } function clearPendingAttachments() { _pendingAttachments = []; if (_elements.fileInput) { _elements.fileInput.value = ''; } renderPendingAttachments(); } // ── Message Action Buttons ────────────────────────────────── function getMessageText(el) { // For user messages, textContent is the raw text. // For assistant/system messages rendered as markdown, extract plain text. return (el.textContent || '').trim(); } function createMessageActions(role) { const bar = document.createElement('div'); bar.className = 'message-actions'; // Copy button — all messages const copyBtn = document.createElement('button'); copyBtn.className = 'msg-action-btn'; copyBtn.title = 'Copy'; copyBtn.innerHTML = COPY_ICON; copyBtn.addEventListener('click', () => { const msg = bar.closest('.message'); if (!msg) {return;} const text = getMessageText(msg); navigator.clipboard.writeText(text).then(() => { copyBtn.innerHTML = CHECK_ICON; copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.innerHTML = COPY_ICON; copyBtn.classList.remove('copied'); }, 1500); }); }); bar.appendChild(copyBtn); // Edit button — user messages only if (role === 'user') { const editBtn = document.createElement('button'); editBtn.className = 'msg-action-btn'; editBtn.title = 'Edit'; editBtn.innerHTML = EDIT_ICON; editBtn.addEventListener('click', () => { const msg = bar.closest('.message'); if (!msg) {return;} const text = getMessageText(msg); const input = _elements.input; if (input) { input.value = text; input.focus(); // Trigger auto-resize input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 150) + 'px'; } }); bar.appendChild(editBtn); } return bar; } // ── Search Mode ───────────────────────────────────────────── function setSearchMode(active) { _searchMode = active; const btn = _elements.searchBtn; const input = _elements.input; if (!btn || !input) {return;} if (active) { btn.classList.add('active'); input.placeholder = 'What do you want to search for?'; } else { btn.classList.remove('active'); input.placeholder = 'Type a message...'; } input.focus(); } // ── Slash Popup ───────────────────────────────────────────── function getFilteredCommands(text) { const prefix = text.toLowerCase(); return SLASH_COMMANDS.filter(c => c.name.startsWith(prefix)); } function showSlashPopup(filtered) { const popup = _elements.slashPopup; if (!popup) {return;} popup.innerHTML = ''; if (filtered.length === 0) { hideSlashPopup(); return; } for (let i = 0; i < filtered.length; i++) { const item = document.createElement('div'); item.className = 'slash-popup-item' + (i === _slashPopupIndex ? ' selected' : ''); item.innerHTML = `${escapeHtml(filtered[i].name)}${escapeHtml(filtered[i].desc)}`; item.addEventListener('click', () => { selectSlashCommand(filtered[i].name); }); // Touch-friendly: handle pointerenter for hover on mobile item.addEventListener('pointerenter', () => { _slashPopupIndex = i; updatePopupSelection(filtered); }); popup.appendChild(item); } popup.classList.remove('hidden'); } function hideSlashPopup() { const popup = _elements.slashPopup; if (popup) {popup.classList.add('hidden');} _slashPopupIndex = -1; } function updatePopupSelection(_filtered) { const popup = _elements.slashPopup; if (!popup) {return;} const items = popup.querySelectorAll('.slash-popup-item'); items.forEach((el, i) => { el.classList.toggle('selected', i === _slashPopupIndex); }); } function selectSlashCommand(name) { const input = _elements.input; if (!input) {return;} input.value = name; hideSlashPopup(); input.focus(); } function handleSlashPopupInput() { const input = _elements.input; if (!input) {return;} const text = input.value; // Show popup only when text starts with / and is at most a single word (the command itself) if (text.startsWith('/') && !text.includes(' ')) { const filtered = getFilteredCommands(text); // Clamp selection index if (_slashPopupIndex >= filtered.length) {_slashPopupIndex = filtered.length - 1;} showSlashPopup(filtered); } else { hideSlashPopup(); } } // ── Slash Command Handlers ────────────────────────────────── function parseSlashCommand(text) { const trimmed = text.trim(); if (!trimmed.startsWith('/')) {return null;} const parts = trimmed.split(/\s+/); const cmd = parts[0].toLowerCase(); const args = parts.slice(1).join(' '); switch (cmd) { case '/help': return { type: 'help' }; case '/reset': return { type: 'reset' }; case '/compact': return { type: 'compact' }; case '/usage': return { type: 'usage' }; case '/status': return { type: 'status' }; case '/model': return { type: 'model', args }; default: return null; } } function showSystemMessage(content) { _elements.messages.appendChild(createMessageEl('system', content)); scrollToBottom(); } async function handleSlashCommand(cmd, client) { switch (cmd.type) { case 'help': { const lines = [ '**Available Commands**', '', '| Command | Description |', '|---------|-------------|', '| `/help` | Show this help |', '| `/reset` | Reset the current session |', '| `/compact` | Ask the agent to compact context |', '| `/usage` | Show token usage stats |', '| `/status` | Show system health |', '| `/model` | Show current model info |', '', 'Type `/` to see autocomplete suggestions.', ]; showSystemMessage(lines.join('\n')); return true; } case 'reset': { try { // Send reset command via metadata const stream = client.stream('agent.send', { message: '/reset', metadata: { isCommand: true, command: 'reset' }, }); await stream.result; _elements.messages.innerHTML = ''; showSystemMessage('Session reset.'); } catch (err) { showSystemMessage(`Failed to reset: ${err.message}`); } return true; } case 'compact': { // Send as a regular message — the agent will interpret the request showSystemMessage('Requesting context compaction...'); return false; // Let it pass through as a normal message } case 'usage': { try { const result = await client.call('system.tokenUsage'); const sessions = result.sessions ?? []; if (sessions.length === 0) { showSystemMessage('No usage data available.'); } else { const lines = ['**Token Usage**', '']; let totalIn = 0, totalOut = 0, totalCalls = 0; for (const s of sessions) { totalIn += s.total?.inputTokens ?? 0; totalOut += s.total?.outputTokens ?? 0; totalCalls += s.total?.calls ?? 0; } lines.push(`**Input:** ${totalIn.toLocaleString()} tokens`); lines.push(`**Output:** ${totalOut.toLocaleString()} tokens`); lines.push(`**API Calls:** ${totalCalls}`); if (sessions.length > 1) { lines.push(`**Sessions:** ${sessions.length}`); } showSystemMessage(lines.join('\n')); } } catch (err) { showSystemMessage(`Failed to fetch usage: ${err.message}`); } return true; } case 'status': { try { const result = await client.call('system.health'); const lines = [ '**System Status**', '', `**Uptime:** ${result.uptime ?? 'unknown'}`, `**Status:** ${result.status ?? 'unknown'}`, ]; if (result.channels) { lines.push('', '**Channels:**'); for (const ch of result.channels) { const dot = ch.status === 'connected' ? '\\*' : '-'; lines.push(` ${dot} ${ch.name}: ${ch.status}`); } } if (result.model) { lines.push('', `**Model:** ${result.model}`); } showSystemMessage(lines.join('\n')); } catch (err) { showSystemMessage(`Failed to fetch status: ${err.message}`); } return true; } case 'model': { try { const result = await client.call('system.health'); const model = result.model ?? result.config?.model ?? 'unknown'; showSystemMessage(`**Current Model:** ${model}`); } catch (err) { showSystemMessage(`Failed to fetch model info: ${err.message}`); } return true; } default: return false; } } // ── Session Management ────────────────────────────────────── 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
'; } } // ── Send Message ──────────────────────────────────────────── async function sendMessage(client, overrideText) { const input = _elements.input; const rawText = (overrideText ?? input?.value ?? '').trim(); const hasText = Boolean(rawText); const hasAttachments = Boolean(_pendingAttachments.length > 0); if ((!hasText && !hasAttachments) || _sending) {return;} // Check for slash commands first (text only) if (hasText) { const cmd = parseSlashCommand(rawText); if (cmd) { if (!overrideText) {input.value = '';} hideSlashPopup(); const handled = await handleSlashCommand(cmd, client); if (handled) {return;} // If not fully handled (e.g. /compact), fall through to send as message } } _sending = true; _elements.sendBtn.disabled = true; if (!overrideText && input) {input.value = '';} // Apply search mode prefix let messageText = rawText; if (_searchMode && hasText && !rawText.startsWith('/')) { messageText = `Search the web for: ${rawText}`; setSearchMode(false); } const userDisplay = hasText ? rawText : `Sent ${_pendingAttachments.length} attachment(s)`; _elements.messages.appendChild(createMessageEl('user', userDisplay)); 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: messageText, attachments: _pendingAttachments }); 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 = renderSafeMarkdown(content); placeholder.appendChild(createMessageActions('assistant')); 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;} clearPendingAttachments(); scrollToBottom(); } } // ── Search SVG Icon ───────────────────────────────────────── const SEARCH_ICON = ''; const ATTACH_ICON = ''; const COPY_ICON = ''; const CHECK_ICON = ''; const EDIT_ICON = ''; // ── Page Export ────────────────────────────────────────────── 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'), searchBtn: el.querySelector('#chat-search'), attachBtn: el.querySelector('#chat-attach'), fileInput: el.querySelector('#chat-file'), attachments: el.querySelector('#chat-attachments'), slashPopup: el.querySelector('#slash-popup'), }; // 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: search button toggle _elements.searchBtn.addEventListener('click', () => { setSearchMode(!_searchMode); }); // Event: attach button _elements.attachBtn.addEventListener('click', () => { if (_elements.fileInput) {_elements.fileInput.click();} }); // Event: file input change _elements.fileInput.addEventListener('change', async () => { const files = Array.from(_elements.fileInput.files ?? []); if (files.length === 0) {return;} const MAX_BYTES = 5 * 1024 * 1024; for (const file of files) { if (!isSupportedImageMime(file.type)) { showSystemMessage(`Unsupported file type: ${file.type || file.name}`); continue; } if (file.size > MAX_BYTES) { showSystemMessage(`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) { showSystemMessage(`Failed to attach ${file.name}: ${err.message}`); } } renderPendingAttachments(); }); // Event: send message _elements.sendBtn.addEventListener('click', () => sendMessage(client)); // Event: keyboard in textarea _elements.input.addEventListener('keydown', (e) => { const popup = _elements.slashPopup; const isPopupVisible = popup && !popup.classList.contains('hidden'); // Handle slash popup navigation if (isPopupVisible) { const text = _elements.input.value; const filtered = getFilteredCommands(text); if (e.key === 'ArrowDown') { e.preventDefault(); _slashPopupIndex = Math.min(_slashPopupIndex + 1, filtered.length - 1); updatePopupSelection(filtered); return; } if (e.key === 'ArrowUp') { e.preventDefault(); _slashPopupIndex = Math.max(_slashPopupIndex - 1, 0); updatePopupSelection(filtered); return; } if ((e.key === 'Enter' || e.key === 'Tab') && _slashPopupIndex >= 0 && _slashPopupIndex < filtered.length) { e.preventDefault(); selectSlashCommand(filtered[_slashPopupIndex].name); return; } if (e.key === 'Escape') { e.preventDefault(); hideSlashPopup(); return; } } // Enter to send (Shift+Enter for newline) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); hideSlashPopup(); sendMessage(client); } }); // Event: input changes for slash popup + auto-resize _elements.input.addEventListener('input', () => { // Auto-resize textarea const ta = _elements.input; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'; // Slash command popup handleSlashPopupInput(); }); // Dismiss slash popup on outside click el.addEventListener('click', (e) => { if (!e.target.closest('.chat-input-wrapper')) { hideSlashPopup(); } }); // 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; _searchMode = false; _slashPopupIndex = -1; _elements = {}; _pendingAttachments = []; }, };