/** * ChatUI — DOM renderer for @geepers/chat * * Mounts a full chat interface into any container element. * Drives ChatCore for all state; pure DOM updates on events. * * Usage: * const chat = new ChatUI('#chat-container', { provider: 'xai' }); * // or: new ChatUI(document.getElementById('chat'), config); */ import { ChatCore } from '../core/ChatCore.js'; import type { ChatConfig, ErrorEvent, Message, MessageEvent as ChatMessageEvent, Provider, StreamChunkEvent, StreamEndEvent, StreamStartEvent, WelcomeHint, } from '../types/index.js'; import { MODELS, PROVIDER_KEYS } from '../core/providers.js'; import { CHAT_CSS } from './styles.js'; // ========== Default hints ========== const DEFAULT_HINTS: WelcomeHint[] = [ { label: 'How do LLMs work?', prompt: 'Explain how large language models work in plain language.' }, { label: 'Write a function', prompt: 'Write a Python function that finds all prime numbers up to N using the Sieve of Eratosthenes.' }, { label: 'REST vs GraphQL', prompt: 'Compare REST and GraphQL APIs — when should I use each?' }, ]; // ========== Helpers ========== function escHtml(s: string): string { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function resolveTarget(target: string | HTMLElement): HTMLElement { if (typeof target === 'string') { const el = document.querySelector(target); if (!el) throw new Error(`[@geepers/chat] Target not found: "${target}"`); return el; } return target; } function renderMarkdown(text: string): string { // Use window.marked if available (loaded by host page) const w = globalThis as Record; if (w['marked'] && typeof (w['marked'] as Record)['parse'] === 'function') { return ((w['marked'] as Record)['parse'] as (s: string) => string)(text); } // Minimal fallback return escHtml(text).replace(/\n/g, '
'); } // ========== ChatUI ========== export class ChatUI { private core: ChatCore; private container: HTMLElement; private config: ChatConfig & { welcomeHints: WelcomeHint[] }; // DOM refs private root!: HTMLElement; private sidebar!: HTMLElement; private historyEl!: HTMLElement; private messagesEl!: HTMLElement; private textarea!: HTMLTextAreaElement; private sendBtn!: HTMLButtonElement; private stopBtn!: HTMLButtonElement; private providerSelect!: HTMLSelectElement; private providerBadge!: HTMLElement; private syspromptPanel!: HTMLElement; private syspromptInput!: HTMLTextAreaElement; private overlayEl!: HTMLElement; private toastEl!: HTMLElement; private welcomeEl: HTMLElement | null = null; private toastTimer: ReturnType | null = null; constructor(target: string | HTMLElement, config: ChatConfig = {}) { this.container = resolveTarget(target); this.config = { ...config, welcomeHints: config.welcomeHints ?? DEFAULT_HINTS, }; ChatUI.injectStyles(); this.core = new ChatCore(config); this.buildDOM(); this.wireEvents(); this.renderHistory(); this.showWelcome(); if (this.core.messages.length > 0) { this.reRenderMessages(); } } // ========== Public API ========== get chatCore(): ChatCore { return this.core; } send(text: string): Promise { return this.core.send(text); } stop(): void { this.core.stop(); } focus(): void { this.textarea?.focus(); } setProvider(provider: Provider): void { this.core.setProvider(provider); this.providerSelect.value = provider; this.providerBadge.textContent = MODELS[provider]?.label ?? provider; } setSystemPrompt(prompt: string): void { this.core.setSystemPrompt(prompt); this.syspromptInput.value = prompt; } newConversation(): void { this.core.clearHistory(); this.messagesEl.innerHTML = ''; this.welcomeEl = null; this.showWelcome(); this.renderHistory(); } destroy(): void { this.container.innerHTML = ''; } // ========== Style injection ========== private static stylesInjected = false; private static injectStyles(): void { if (ChatUI.stylesInjected || typeof document === 'undefined') return; const style = document.createElement('style'); style.setAttribute('data-gchat', ''); // CSS_RAW may be undefined if bundler doesn't support ?inline — that's OK style.textContent = CHAT_CSS; document.head.appendChild(style); ChatUI.stylesInjected = true; } // ========== DOM Construction ========== private buildDOM(): void { const theme = this.resolveTheme(); this.root = document.createElement('div'); this.root.className = 'gchat-root'; this.root.dataset['theme'] = theme; this.root.style.cssText = 'width:100%;height:100%;'; // ---- Sidebar ---- this.sidebar = document.createElement('aside'); this.sidebar.className = 'gchat-sidebar'; const sidebarHeader = document.createElement('div'); sidebarHeader.className = 'gchat-sidebar-header'; const sidebarTitle = document.createElement('span'); sidebarTitle.className = 'gchat-sidebar-title'; sidebarTitle.textContent = 'History'; const newBtn = document.createElement('button'); newBtn.className = 'gchat-new-btn'; newBtn.type = 'button'; newBtn.textContent = 'New'; newBtn.addEventListener('click', () => this.newConversation()); sidebarHeader.appendChild(sidebarTitle); sidebarHeader.appendChild(newBtn); this.sidebar.appendChild(sidebarHeader); this.historyEl = document.createElement('div'); this.historyEl.className = 'gchat-history'; this.sidebar.appendChild(this.historyEl); // ---- Main ---- const main = document.createElement('div'); main.className = 'gchat-main'; // Top bar const topbar = document.createElement('div'); topbar.className = 'gchat-topbar'; const mobileToggle = document.createElement('button'); mobileToggle.className = 'gchat-mobile-toggle'; mobileToggle.type = 'button'; mobileToggle.textContent = '☰'; mobileToggle.setAttribute('aria-label', 'Toggle history'); mobileToggle.addEventListener('click', () => this.toggleSidebar()); topbar.appendChild(mobileToggle); this.providerSelect = document.createElement('select'); this.providerSelect.className = 'gchat-provider-select'; this.providerSelect.setAttribute('aria-label', 'Select model provider'); PROVIDER_KEYS.forEach(key => { const opt = document.createElement('option'); opt.value = key; opt.textContent = MODELS[key].label; if (key === this.core.provider) opt.selected = true; this.providerSelect.appendChild(opt); }); this.providerSelect.addEventListener('change', () => { this.core.setProvider(this.providerSelect.value as Provider); this.providerBadge.textContent = MODELS[this.core.provider].label; }); topbar.appendChild(this.providerSelect); const spacer = document.createElement('span'); spacer.className = 'gchat-topbar-spacer'; topbar.appendChild(spacer); this.providerBadge = document.createElement('span'); this.providerBadge.className = 'gchat-provider-badge'; this.providerBadge.textContent = MODELS[this.core.provider].label; topbar.appendChild(this.providerBadge); const sysToggle = document.createElement('button'); sysToggle.className = 'gchat-sys-toggle'; sysToggle.type = 'button'; sysToggle.textContent = 'System'; sysToggle.setAttribute('aria-label', 'Toggle system prompt'); sysToggle.addEventListener('click', () => { this.syspromptPanel.classList.toggle('hidden'); }); topbar.appendChild(sysToggle); main.appendChild(topbar); // System prompt panel this.syspromptPanel = document.createElement('div'); this.syspromptPanel.className = 'gchat-sysprompt-panel hidden'; this.syspromptInput = document.createElement('textarea'); this.syspromptInput.className = 'gchat-sysprompt-input'; this.syspromptInput.placeholder = 'System prompt (optional)…'; this.syspromptInput.value = this.core.systemPrompt; this.syspromptInput.rows = 3; this.syspromptInput.setAttribute('aria-label', 'System prompt'); this.syspromptInput.addEventListener('input', () => { this.core.setSystemPrompt(this.syspromptInput.value); }); this.syspromptPanel.appendChild(this.syspromptInput); main.appendChild(this.syspromptPanel); // Messages area this.messagesEl = document.createElement('div'); this.messagesEl.className = 'gchat-messages'; this.messagesEl.setAttribute('role', 'log'); this.messagesEl.setAttribute('aria-live', 'polite'); this.messagesEl.setAttribute('aria-label', 'Chat messages'); main.appendChild(this.messagesEl); // Input bar const inputBar = document.createElement('div'); inputBar.className = 'gchat-input-bar'; const inputRow = document.createElement('div'); inputRow.className = 'gchat-input-row'; this.textarea = document.createElement('textarea'); this.textarea.className = 'gchat-textarea'; this.textarea.placeholder = 'Message… (Enter to send, Shift+Enter for newline)'; this.textarea.rows = 1; this.textarea.setAttribute('aria-label', 'Chat message'); this.textarea.addEventListener('input', () => this.autoResize()); this.textarea.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void this.submitMessage(); } }); inputRow.appendChild(this.textarea); this.sendBtn = document.createElement('button'); this.sendBtn.className = 'gchat-send-btn'; this.sendBtn.type = 'button'; this.sendBtn.textContent = 'Send'; this.sendBtn.addEventListener('click', () => void this.submitMessage()); inputRow.appendChild(this.sendBtn); this.stopBtn = document.createElement('button'); this.stopBtn.className = 'gchat-stop-btn hidden'; this.stopBtn.type = 'button'; this.stopBtn.textContent = 'Stop'; this.stopBtn.addEventListener('click', () => this.core.stop()); inputRow.appendChild(this.stopBtn); inputBar.appendChild(inputRow); main.appendChild(inputBar); // Overlay this.overlayEl = document.createElement('div'); this.overlayEl.className = 'gchat-overlay'; this.overlayEl.setAttribute('aria-hidden', 'true'); this.overlayEl.addEventListener('click', () => this.toggleSidebar()); // Toast this.toastEl = document.createElement('div'); this.toastEl.className = 'gchat-toast'; this.toastEl.setAttribute('role', 'status'); this.toastEl.setAttribute('aria-live', 'polite'); this.root.appendChild(this.sidebar); this.root.appendChild(main); this.root.appendChild(this.overlayEl); this.root.appendChild(this.toastEl); this.container.appendChild(this.root); } // ========== Event wiring ========== private wireEvents(): void { this.core.on('message', e => { if (e.message.role === 'user') { this.removeWelcome(); this.appendMessage(e.message); } }); this.core.on('stream-start', e => { this.sendBtn.disabled = true; this.stopBtn.classList.remove('hidden'); this.appendLoadingMessage(e.messageId); }); this.core.on('stream-chunk', e => { this.updateStreamingMessage(e.messageId, e.accumulated); }); this.core.on('stream-end', e => { this.sendBtn.disabled = false; this.stopBtn.classList.add('hidden'); this.finalizeMessage(e.message); this.renderHistory(); }); this.core.on('error', e => { this.sendBtn.disabled = false; this.stopBtn.classList.add('hidden'); this.showErrorMessage(e.error.message); }); this.core.on('conversation-change', () => { this.messagesEl.innerHTML = ''; this.welcomeEl = null; this.showWelcome(); this.renderHistory(); }); } // ========== Message rendering ========== private appendMessage(msg: Message): HTMLElement { const el = document.createElement('div'); el.className = `gchat-msg ${msg.role}`; el.dataset['msgId'] = msg.id; const header = document.createElement('div'); header.className = 'gchat-msg-header'; const roleSpan = document.createElement('span'); roleSpan.className = 'gchat-msg-role'; roleSpan.textContent = msg.role === 'user' ? 'You' : (MODELS[msg.provider ?? this.core.provider]?.label ?? 'Assistant'); header.appendChild(roleSpan); const actions = document.createElement('div'); actions.className = 'gchat-msg-actions'; const copyBtn = this.makeCopyBtn(() => msg.content); actions.appendChild(copyBtn); header.appendChild(actions); const body = document.createElement('div'); body.className = 'gchat-msg-body'; if (msg.role === 'user') { body.textContent = msg.content; } else { body.innerHTML = renderMarkdown(msg.content); this.addCodeCopyButtons(body); } el.appendChild(header); el.appendChild(body); this.messagesEl.appendChild(el); this.scrollToBottom(); return el; } private appendLoadingMessage(messageId: string): void { const el = document.createElement('div'); el.className = 'gchat-msg assistant loading'; el.dataset['msgId'] = messageId; const header = document.createElement('div'); header.className = 'gchat-msg-header'; const roleSpan = document.createElement('span'); roleSpan.className = 'gchat-msg-role'; roleSpan.textContent = MODELS[this.core.provider]?.label ?? 'Assistant'; header.appendChild(roleSpan); el.appendChild(header); const body = document.createElement('div'); body.className = 'gchat-msg-body'; el.appendChild(body); this.messagesEl.appendChild(el); this.scrollToBottom(); } private updateStreamingMessage(messageId: string, content: string): void { const el = this.messagesEl.querySelector(`[data-msg-id="${messageId}"]`); if (!el) return; el.classList.remove('loading'); const body = el.querySelector('.gchat-msg-body'); if (!body) return; body.innerHTML = renderMarkdown(content); this.addCodeCopyButtons(body); this.scrollToBottom(); } private finalizeMessage(msg: Message): void { const el = this.messagesEl.querySelector(`[data-msg-id="${msg.id}"]`); if (!el) return; el.classList.remove('loading'); // Ensure copy button exists in header let actions = el.querySelector('.gchat-msg-actions'); if (!actions) { actions = document.createElement('div'); actions.className = 'gchat-msg-actions'; el.querySelector('.gchat-msg-header')?.appendChild(actions); } if (!actions.querySelector('.gchat-msg-action-btn')) { actions.appendChild(this.makeCopyBtn(() => msg.content)); } const body = el.querySelector('.gchat-msg-body'); if (body) { body.innerHTML = renderMarkdown(msg.content); this.addCodeCopyButtons(body); } this.scrollToBottom(); } private showErrorMessage(message: string): void { // Update loading bubble if present, otherwise append const loading = this.messagesEl.querySelector('.gchat-msg.loading'); const el = loading ?? document.createElement('div'); el.classList.remove('loading'); el.classList.add('gchat-msg', 'assistant', 'error'); const body = el.querySelector('.gchat-msg-body') ?? (() => { const b = document.createElement('div'); b.className = 'gchat-msg-body'; el.appendChild(b); return b; })(); body.textContent = `Error: ${message}`; if (!loading) this.messagesEl.appendChild(el); this.scrollToBottom(); this.toast('Error sending message'); } private reRenderMessages(): void { this.messagesEl.innerHTML = ''; this.welcomeEl = null; for (const msg of this.core.messages) { this.appendMessage(msg); } } // ========== Welcome ========== private showWelcome(): void { if (this.welcomeEl || this.core.messages.length > 0) return; const el = document.createElement('div'); el.className = 'gchat-welcome'; const h2 = document.createElement('h2'); h2.textContent = 'Chat'; el.appendChild(h2); const p = document.createElement('p'); p.textContent = 'Pick a model and start a conversation.'; el.appendChild(p); const hints = this.config.welcomeHints ?? []; if (hints.length > 0) { const hintRow = document.createElement('div'); hintRow.className = 'gchat-hints'; hints.forEach(h => { const chip = document.createElement('button'); chip.className = 'gchat-hint-chip'; chip.type = 'button'; chip.textContent = h.label; chip.addEventListener('click', () => { this.textarea.value = h.prompt; void this.submitMessage(); }); hintRow.appendChild(chip); }); el.appendChild(hintRow); } this.messagesEl.appendChild(el); this.welcomeEl = el; } private removeWelcome(): void { if (this.welcomeEl) { this.welcomeEl.remove(); this.welcomeEl = null; } } // ========== History sidebar ========== private renderHistory(): void { const convos = this.core.getConversations(); this.historyEl.innerHTML = ''; if (convos.length === 0) { const empty = document.createElement('p'); empty.className = 'gchat-history-empty'; empty.textContent = 'No saved conversations'; this.historyEl.appendChild(empty); return; } convos.forEach(convo => { const item = document.createElement('div'); item.className = 'gchat-history-item' + (convo.id === this.core.conversationId ? ' active' : ''); const title = document.createElement('span'); title.className = 'gchat-history-item-title'; title.textContent = convo.title; title.title = convo.title; title.addEventListener('click', () => { if (this.core.loadConversation(convo.id)) { this.reRenderMessages(); this.renderHistory(); this.closeSidebar(); this.providerSelect.value = this.core.provider; this.providerBadge.textContent = MODELS[this.core.provider].label; this.syspromptInput.value = this.core.systemPrompt; } }); const del = document.createElement('button'); del.className = 'gchat-history-item-del'; del.textContent = '×'; del.setAttribute('aria-label', `Delete "${convo.title}"`); del.addEventListener('click', e => { e.stopPropagation(); this.core.deleteConversation(convo.id); this.renderHistory(); }); item.appendChild(title); item.appendChild(del); this.historyEl.appendChild(item); }); } // ========== Code copy buttons ========== private addCodeCopyButtons(container: HTMLElement): void { container.querySelectorAll('pre').forEach(pre => { if (pre.querySelector('.gchat-code-copy')) return; const btn = document.createElement('button'); btn.className = 'gchat-code-copy'; btn.type = 'button'; btn.textContent = 'Copy'; btn.addEventListener('click', () => { const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? ''; navigator.clipboard.writeText(code).then(() => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); }); }); pre.style.position = 'relative'; pre.appendChild(btn); }); } // ========== Submit ========== private async submitMessage(): Promise { const text = this.textarea.value.trim(); if (!text || this.core.isStreaming) return; this.textarea.value = ''; this.textarea.style.height = 'auto'; await this.core.send(text); } // ========== Helpers ========== private makeCopyBtn(getContent: () => string): HTMLButtonElement { const btn = document.createElement('button'); btn.className = 'gchat-msg-action-btn'; btn.type = 'button'; btn.textContent = 'Copy'; btn.addEventListener('click', () => { navigator.clipboard.writeText(getContent()).then(() => this.toast('Copied')); }); return btn; } private autoResize(): void { this.textarea.style.height = 'auto'; this.textarea.style.height = Math.min(this.textarea.scrollHeight, 200) + 'px'; } private scrollToBottom(): void { this.messagesEl.scrollTop = this.messagesEl.scrollHeight; } private toggleSidebar(): void { const open = this.sidebar.classList.toggle('open'); this.overlayEl.classList.toggle('visible', open); this.overlayEl.setAttribute('aria-hidden', open ? 'false' : 'true'); } private closeSidebar(): void { this.sidebar.classList.remove('open'); this.overlayEl.classList.remove('visible'); this.overlayEl.setAttribute('aria-hidden', 'true'); } private toast(message: string): void { this.toastEl.textContent = message; this.toastEl.classList.add('show'); if (this.toastTimer) clearTimeout(this.toastTimer); this.toastTimer = setTimeout(() => this.toastEl.classList.remove('show'), 2500); } private resolveTheme(): string { const t = this.config.theme ?? 'auto'; if (t === 'auto') { try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch { return 'light'; } } return t; } }