Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>STEMbotix AI Chat</title> | |
| <link rel="icon" type="image/png" href="assets/stem_black.png"> | |
| <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css"> | |
| <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script> | |
| <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script> | |
| <style> | |
| :root { | |
| /* Branding colors */ | |
| --brand-orange: #EB5A28; | |
| --brand-orange-hover: #cf4f22; | |
| /* Lights Out Dark Mode Palette */ | |
| --bg-main: #000000; | |
| --bg-sidebar: #070707; | |
| --bg-input: #121212; | |
| --bg-hover: #1A1A1A; | |
| --text-primary: #EDEDED; | |
| --text-secondary: #777777; | |
| --border-color: #222222; | |
| --msg-user: #141414; | |
| --msg-ai: transparent; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Montserrat', sans-serif; | |
| background-color: var(--bg-main); | |
| color: var(--text-primary); | |
| display: flex; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| /* --- Sidebar Styles --- */ | |
| .sidebar { | |
| width: 260px; | |
| flex-shrink: 0; | |
| background-color: var(--bg-sidebar); | |
| display: flex; | |
| flex-direction: column; | |
| border-right: 1px solid var(--border-color); | |
| transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| position: relative; | |
| z-index: 20; | |
| } | |
| .sidebar.collapsed { | |
| margin-left: -260px; | |
| } | |
| .sidebar-header { | |
| padding: 20px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| .brand-logo { | |
| max-width: 80%; | |
| height: auto; | |
| object-fit: contain; | |
| } | |
| .new-chat-btn { | |
| margin: 20px 15px; | |
| padding: 12px; | |
| background-color: transparent; | |
| border: 1px solid var(--border-color); | |
| color: var(--text-primary); | |
| border-radius: 8px; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| font-family: 'Montserrat', sans-serif; | |
| transition: background-color 0.2s, border-color 0.2s; | |
| } | |
| .new-chat-btn:hover { | |
| background-color: var(--bg-hover); | |
| } | |
| /* --- Chat History & Options Menu --- */ | |
| .chat-history { | |
| flex-grow: 1; | |
| overflow-y: auto; | |
| padding: 0 15px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 2px; | |
| } | |
| .history-item { | |
| position: relative; | |
| padding: 10px 12px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| color: var(--text-secondary); | |
| font-size: 14px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| transition: background-color 0.2s, color 0.2s; | |
| } | |
| .history-item:hover, .history-item.active { | |
| background-color: var(--bg-hover); | |
| color: var(--text-primary); | |
| } | |
| .chat-title { | |
| flex-grow: 1; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| padding-right: 10px; | |
| } | |
| .options-btn { | |
| background: none; | |
| border: none; | |
| color: var(--text-secondary); | |
| font-size: 18px; | |
| line-height: 1; | |
| cursor: pointer; | |
| opacity: 0; | |
| padding: 0 4px; | |
| transition: opacity 0.2s, color 0.2s; | |
| } | |
| .history-item:hover .options-btn, | |
| .options-btn.menu-open { | |
| opacity: 1; | |
| } | |
| .options-btn:hover { | |
| color: var(--text-primary); | |
| } | |
| .options-menu { | |
| position: absolute; | |
| right: 10px; | |
| top: 35px; | |
| background-color: var(--bg-input); | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| display: none; | |
| flex-direction: column; | |
| z-index: 100; | |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.8); | |
| min-width: 120px; | |
| overflow: hidden; | |
| } | |
| .options-menu.show { | |
| display: flex; | |
| } | |
| .option-item { | |
| padding: 10px 15px; | |
| font-size: 13px; | |
| color: var(--text-primary); | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| transition: background-color 0.2s; | |
| } | |
| .option-item:hover { | |
| background-color: var(--bg-hover); | |
| } | |
| .option-item.delete { | |
| color: #ff4a4a; | |
| } | |
| .rename-input { | |
| width: 100%; | |
| background: transparent; | |
| border: none; | |
| color: var(--text-primary); | |
| font-family: inherit; | |
| font-size: 14px; | |
| outline: none; | |
| border-bottom: 1px solid var(--brand-orange); | |
| padding: 2px 0; | |
| } | |
| .sidebar-footer { | |
| padding: 15px; | |
| border-top: 1px solid var(--border-color); | |
| } | |
| .settings-btn { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| color: var(--text-primary); | |
| cursor: pointer; | |
| padding: 10px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| transition: background-color 0.2s; | |
| } | |
| .settings-btn:hover { | |
| background-color: var(--bg-hover); | |
| } | |
| /* --- Main Chat Area Styles --- */ | |
| .main-chat { | |
| flex-grow: 1; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; | |
| } | |
| /* Header for Sidebar Toggle */ | |
| .main-header { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| padding: 15px 20px; | |
| display: flex; | |
| align-items: center; | |
| z-index: 10; | |
| } | |
| .toggle-sidebar-btn { | |
| background: transparent; | |
| border: none; | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| padding: 8px; | |
| border-radius: 8px; | |
| transition: background-color 0.2s, color 0.2s; | |
| } | |
| .toggle-sidebar-btn:hover { | |
| background-color: var(--bg-hover); | |
| color: var(--text-primary); | |
| } | |
| .chat-container { | |
| flex-grow: 1; | |
| overflow-y: auto; | |
| padding: 70px 20px 40px 20px; /* Top padding accommodates the header */ | |
| display: flex; | |
| flex-direction: column; | |
| gap: 24px; | |
| scroll-behavior: smooth; | |
| } | |
| .message-row { | |
| display: flex; | |
| width: 100%; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| animation: fadeIn 0.4s ease-out forwards; | |
| } | |
| .message-row.user { | |
| justify-content: flex-end; | |
| } | |
| .message-row.ai { | |
| justify-content: flex-start; | |
| } | |
| .message-content { | |
| max-width: 80%; | |
| padding: 14px 18px; | |
| border-radius: 12px; | |
| font-size: 15px; | |
| line-height: 1.6; | |
| word-wrap: break-word; | |
| } | |
| .user .message-content { | |
| background-color: var(--msg-user); | |
| border-bottom-right-radius: 4px; | |
| } | |
| .ai .message-content { | |
| background-color: var(--msg-ai); | |
| } | |
| .ai-avatar { | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 50%; | |
| background-image: url('assets/stem_black.png'); | |
| background-size: cover; | |
| background-position: center; | |
| margin-right: 15px; | |
| flex-shrink: 0; | |
| margin-top: 5px; | |
| } | |
| /* --- Input Area Styles --- */ | |
| .input-container { | |
| padding: 20px 20px 10px 20px; | |
| background: linear-gradient(180deg, transparent, var(--bg-main) 20%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| z-index: 10; | |
| } | |
| .input-box { | |
| width: 100%; | |
| max-width: 800px; | |
| position: relative; | |
| background-color: var(--bg-input); | |
| border: 1px solid var(--border-color); | |
| border-radius: 16px; | |
| transition: border-color 0.3s; | |
| } | |
| .input-box:focus-within { | |
| border-color: var(--brand-orange); | |
| } | |
| textarea { | |
| width: 100%; | |
| background: transparent; | |
| border: none; | |
| color: var(--text-primary); | |
| padding: 16px 50px 16px 20px; | |
| font-family: 'Montserrat', sans-serif; | |
| font-size: 15px; | |
| resize: none; | |
| outline: none; | |
| height: 56px; | |
| max-height: 200px; | |
| line-height: 1.5; | |
| overflow-y: hidden; | |
| } | |
| textarea::placeholder { | |
| color: var(--text-secondary); | |
| } | |
| .send-btn { | |
| position: absolute; | |
| right: 10px; | |
| bottom: 10px; | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 10px; | |
| background-color: transparent; | |
| border: none; | |
| color: var(--text-secondary); | |
| cursor: pointer; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| transition: color 0.2s, transform 0.1s; | |
| } | |
| .send-btn:hover { | |
| color: var(--text-primary); | |
| } | |
| .send-btn:active { | |
| transform: scale(0.95); | |
| } | |
| .send-btn svg { | |
| width: 20px; | |
| height: 20px; | |
| fill: currentColor; | |
| } | |
| .disclaimer-text { | |
| font-size: 12px; | |
| color: #555555; | |
| margin-top: 12px; | |
| text-align: center; | |
| } | |
| .welcome-placeholder { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-grow: 1; | |
| font-family: 'Montserrat', sans-serif; | |
| font-size: 28px; | |
| font-weight: 600; | |
| color: var(--text-secondary); | |
| user-select: none; | |
| } | |
| /* Animations */ | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* Scrollbar Styling */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #555; } | |
| .cursor::after { | |
| content: '▋'; | |
| animation: blink 1s step-start infinite; | |
| color: var(--text-secondary); | |
| margin-left: 2px; | |
| } | |
| @keyframes blink { 50% { opacity: 0; } } | |
| </style> | |
| </head> | |
| <body> | |
| <aside class="sidebar" id="sidebar"> | |
| <div class="sidebar-header"> | |
| <img src="assets/stembotix.png" alt="STEMbotix Logo" class="brand-logo"> | |
| </div> | |
| <button class="new-chat-btn"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 5v14M5 12h14"/> | |
| </svg> | |
| New Chat | |
| </button> | |
| <div class="chat-history" id="chatHistoryList"> | |
| </div> | |
| <div class="sidebar-footer"> | |
| <div class="settings-btn"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <circle cx="12" cy="12" r="3"></circle> | |
| <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> | |
| </svg> | |
| Settings | |
| </div> | |
| </div> | |
| </aside> | |
| <main class="main-chat"> | |
| <header class="main-header"> | |
| <button class="toggle-sidebar-btn" id="toggleSidebarBtn" title="Toggle Sidebar"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> | |
| <line x1="9" y1="3" x2="9" y2="21"></line> | |
| </svg> | |
| </button> | |
| </header> | |
| <div class="chat-container" id="chatContainer"> | |
| <div class="welcome-placeholder" id="welcomePlaceholder">Where should we start?</div> | |
| </div> | |
| <div class="input-container"> | |
| <div class="input-box"> | |
| <textarea id="userInput" placeholder="Ask StemGraph AI..." rows="1"></textarea> | |
| <button class="send-btn" id="sendBtn"> | |
| <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg> | |
| </button> | |
| </div> | |
| <div class="disclaimer-text"> | |
| StemGraph is an AI agent and can make mistakes. | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| /* ========================================= | |
| State | |
| ========================================= */ | |
| let currentThreadId = crypto.randomUUID(); | |
| const threads = []; // {id, title} | |
| let isSending = false; | |
| /* ========================================= | |
| Sidebar Toggle Logic | |
| ========================================= */ | |
| const sidebar = document.getElementById('sidebar'); | |
| const toggleSidebarBtn = document.getElementById('toggleSidebarBtn'); | |
| toggleSidebarBtn.addEventListener('click', () => { | |
| sidebar.classList.toggle('collapsed'); | |
| }); | |
| /* ========================================= | |
| Chat History Sidebar | |
| ========================================= */ | |
| const chatHistoryList = document.getElementById('chatHistoryList'); | |
| function addThreadToSidebar(id, title) { | |
| threads.unshift({id: id, title: title}); | |
| renderHistory(); | |
| } | |
| function renderHistory() { | |
| chatHistoryList.innerHTML = ''; | |
| threads.forEach((thread) => { | |
| const item = document.createElement('div'); | |
| item.className = `history-item ${thread.id === currentThreadId ? 'active' : ''}`; | |
| item.setAttribute('data-thread-id', thread.id); | |
| item.innerHTML = ` | |
| <span class="chat-title">${thread.title}</span> | |
| <button class="options-btn" onclick="toggleMenu(event, this)">⋮</button> | |
| <div class="options-menu"> | |
| <div class="option-item" onclick="renameChat(event, this)"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg> | |
| Rename | |
| </div> | |
| <div class="option-item delete" onclick="deleteChat(event, this)"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg> | |
| Delete | |
| </div> | |
| </div> | |
| `; | |
| item.addEventListener('click', () => loadThread(thread.id)); | |
| chatHistoryList.appendChild(item); | |
| }); | |
| } | |
| function loadThread(threadId) { | |
| currentThreadId = threadId; | |
| renderHistory(); | |
| chatContainer.innerHTML = ''; | |
| fetch('/history/' + threadId) | |
| .then(res => res.json()) | |
| .then(data => { | |
| data.messages.forEach(msg => { | |
| const sender = msg.role === 'user' ? 'user' : 'ai'; | |
| const el = appendMessage(sender, msg.content); | |
| if (sender === 'ai') renderMath(el); | |
| }); | |
| }); | |
| } | |
| /* ========================================= | |
| New Chat | |
| ========================================= */ | |
| document.querySelector('.new-chat-btn').addEventListener('click', () => { | |
| currentThreadId = crypto.randomUUID(); | |
| chatContainer.innerHTML = '<div class="welcome-placeholder" id="welcomePlaceholder">Where should we start?</div>'; | |
| renderHistory(); | |
| }); | |
| /* ========================================= | |
| Options Menu (Rename / Delete) | |
| ========================================= */ | |
| function toggleMenu(e, btn) { | |
| e.stopPropagation(); | |
| closeAllMenus(); | |
| const menu = btn.nextElementSibling; | |
| menu.classList.add('show'); | |
| btn.classList.add('menu-open'); | |
| } | |
| document.addEventListener('click', closeAllMenus); | |
| function closeAllMenus() { | |
| document.querySelectorAll('.options-menu.show').forEach(menu => { | |
| menu.classList.remove('show'); | |
| }); | |
| document.querySelectorAll('.options-btn.menu-open').forEach(btn => { | |
| btn.classList.remove('menu-open'); | |
| }); | |
| } | |
| function deleteChat(e, optionEl) { | |
| e.stopPropagation(); | |
| const item = optionEl.closest('.history-item'); | |
| const threadId = item.getAttribute('data-thread-id'); | |
| const idx = threads.findIndex(t => t.id === threadId); | |
| if (idx !== -1) threads.splice(idx, 1); | |
| item.style.opacity = '0'; | |
| setTimeout(() => { item.remove(); }, 200); | |
| } | |
| function renameChat(e, optionEl) { | |
| e.stopPropagation(); | |
| const item = optionEl.closest('.history-item'); | |
| const titleSpan = item.querySelector('.chat-title'); | |
| const threadId = item.getAttribute('data-thread-id'); | |
| closeAllMenus(); | |
| const currentTitle = titleSpan.innerText; | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.value = currentTitle; | |
| input.className = 'rename-input'; | |
| titleSpan.replaceWith(input); | |
| input.focus(); | |
| input.selectionStart = input.selectionEnd = input.value.length; | |
| function saveRename() { | |
| const newTitle = input.value.trim() || 'Untitled Chat'; | |
| titleSpan.innerText = newTitle; | |
| input.replaceWith(titleSpan); | |
| const thread = threads.find(t => t.id === threadId); | |
| if (thread) thread.title = newTitle; | |
| fetch('/rename', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({thread_id: threadId, title: newTitle}) | |
| }); | |
| } | |
| input.addEventListener('blur', saveRename); | |
| input.addEventListener('keydown', (evt) => { | |
| if (evt.key === 'Enter') saveRename(); | |
| if (evt.key === 'Escape') { | |
| titleSpan.innerText = currentTitle; | |
| input.replaceWith(titleSpan); | |
| } | |
| }); | |
| input.addEventListener('click', (evt) => evt.stopPropagation()); | |
| } | |
| /* ========================================= | |
| Chat Messaging Logic | |
| ========================================= */ | |
| const chatContainer = document.getElementById('chatContainer'); | |
| const userInput = document.getElementById('userInput'); | |
| const sendBtn = document.getElementById('sendBtn'); | |
| userInput.addEventListener('input', function() { | |
| this.style.height = '56px'; | |
| this.style.height = (this.scrollHeight) + 'px'; | |
| if (this.value.trim().length > 0) { | |
| sendBtn.style.color = 'var(--text-primary)'; | |
| } else { | |
| sendBtn.style.color = 'var(--text-secondary)'; | |
| } | |
| }); | |
| userInput.addEventListener('keydown', function(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| sendBtn.addEventListener('click', sendMessage); | |
| function sendMessage() { | |
| const text = userInput.value.trim(); | |
| if (!text || isSending) return; | |
| // Remove welcome placeholder if present | |
| const placeholder = document.getElementById('welcomePlaceholder'); | |
| if (placeholder) placeholder.remove(); | |
| isSending = true; | |
| appendMessage('user', text); | |
| userInput.value = ''; | |
| userInput.style.height = '56px'; | |
| sendBtn.style.color = 'var(--text-secondary)'; | |
| // Add thread to sidebar or move existing to top | |
| const exists = threads.find(t => t.id === currentThreadId); | |
| if (!exists) { | |
| const title = text.length > 30 ? text.substring(0, 30) + '...' : text; | |
| addThreadToSidebar(currentThreadId, title); | |
| } else { | |
| const idx = threads.indexOf(exists); | |
| threads.splice(idx, 1); | |
| threads.unshift(exists); | |
| renderHistory(); | |
| } | |
| streamResponse(text); | |
| } | |
| function streamResponse(text) { | |
| const contentElement = appendMessage('ai', ''); | |
| contentElement.classList.add('cursor'); | |
| fetch('/chat', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({message: text, thread_id: currentThreadId}) | |
| }).then(response => { | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| function read() { | |
| reader.read().then(({done, value}) => { | |
| if (done) { | |
| contentElement.classList.remove('cursor'); | |
| renderMath(contentElement); | |
| isSending = false; | |
| return; | |
| } | |
| buffer += decoder.decode(value, {stream: true}); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop(); | |
| lines.forEach(line => { | |
| if (!line.startsWith('data: ')) return; | |
| const payload = line.substring(6); | |
| if (payload === '[DONE]') { | |
| contentElement.classList.remove('cursor'); | |
| renderMath(contentElement); | |
| isSending = false; | |
| return; | |
| } | |
| const data = JSON.parse(payload); | |
| contentElement.textContent += data.token; | |
| scrollToBottom(); | |
| }); | |
| read(); | |
| }); | |
| } | |
| read(); | |
| }); | |
| } | |
| function appendMessage(sender, text) { | |
| const rowDiv = document.createElement('div'); | |
| rowDiv.classList.add('message-row', sender); | |
| let contentHTML = ''; | |
| if (sender === 'ai') contentHTML += `<div class="ai-avatar"></div>`; | |
| contentHTML += `<div class="message-content">${text}</div>`; | |
| rowDiv.innerHTML = contentHTML; | |
| chatContainer.appendChild(rowDiv); | |
| scrollToBottom(); | |
| return rowDiv.querySelector('.message-content'); | |
| } | |
| function scrollToBottom() { | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } | |
| /* ========================================= | |
| Math Rendering (KaTeX) | |
| ========================================= */ | |
| function renderMath(element) { | |
| if (typeof renderMathInElement === 'undefined') return; | |
| renderMathInElement(element, { | |
| delimiters: [ | |
| {left: '$$', right: '$$', display: true}, | |
| {left: '$', right: '$', display: false}, | |
| {left: '\\(', right: '\\)', display: false}, | |
| {left: '\\[', right: '\\]', display: true} | |
| ], | |
| throwOnError: false | |
| }); | |
| } | |
| /* ========================================= | |
| Init: Load existing threads | |
| ========================================= */ | |
| fetch('/threads') | |
| .then(res => res.json()) | |
| .then(data => { | |
| data.threads.forEach(t => { | |
| threads.push({id: t.id, title: t.title}); | |
| }); | |
| renderHistory(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |