1/* 2 * Indie.box chat client. 3 * 4 * Talks to the same-origin `/api/chat` endpoint, which proxies to the 5 * LiteLLM gateway server-side (the API key never reaches the browser). 6 * The endpoint streams an OpenAI-compatible Server-Sent Events response; 7 * we parse `data:` lines and render assistant tokens as they arrive. 8 * 9 * No framework, no build step â plain DOM, matching the rest of the site. 10 */ 11(function () { 12 'use strict'; 13 14 const form = document.getElementById('chat-form'); 15 const input = document.getElementById('chat-input'); 16 const sendButton = document.getElementById('chat-send'); 17 const log = document.getElementById('chat-log'); 18 if (!form || !input || !sendButton || !log) return; 19 20 const isEnglish = (document.documentElement.lang || 'de').toLowerCase().startsWith('en'); 21 const STRINGS = isEnglish 22 ? { 23 thinking: 'Thinking â¦', 24 thought: 'Reasoning', 25 stop: 'Stop', 26 errorGeneric: 'Sorry, something went wrong. Please try again.', 27 errorUnavailable: 'The chat is currently unavailable. Please try again later.', 28 errorModel: 'The AI model is not running right now. Please try again in a moment.' 29 } 30 : { 31 thinking: 'Denkt nach â¦', 32 thought: 'Gedankengang', 33 stop: 'Stopp', 34 errorGeneric: 'Entschuldigung, da ist etwas schiefgelaufen. Bitte versuche es erneut.', 35 errorUnavailable: 'Der Chat ist gerade nicht verfügbar. Bitte versuche es später noc
35h einmal.', 36 errorModel: 'Das KI-Modell läuft gerade nicht. Bitte versuche es gleich noch einmal.' 37 }; 38 39 // The send button doubles as a stop button while a response streams. 40 const sendLabelEl = sendButton.querySelector('.chat-send-label'); 41 const SEND_TEXT = sendLabelEl ? sendLabelEl.textContent : ''; 42 let activeController = null; 43 44 // The bottom toolbar has two custom dropdowns (model picker, answer-mode 45 // picker), styled identically. Shared open/close wiring; opening one closes 46 // the other, and clicks outside / Escape close all. 47 const dropdowns = []; 48 function closeAllDropdowns() { 49 dropdowns.forEach(function (d) { d.setOpen(false); }); 50 } 51 function makeDropdown(rootId, buttonId, menuId) { 52 const root = document.getElementById(rootId); 53 const button = document.getElementById(buttonId); 54 const menu = document.getElementById(menuId); 55 if (!root || !button || !menu) return null; 56 function setOpen(open) { 57 menu.hidden = !open; 58 root.classList.toggle('is-open', open); 59 button.setAttribute('aria-expanded', open ? 'true' : 'false'); 60 } 61 button.addEventListener('click', function (event) { 62 event.stopPropagation(); 63 const willOpen = menu.hidden; 64 closeAllDropdowns(); 65 setOpen(willOpen); 66 }); 67 const api = { root: root, button: button, menu: menu, setOpen: setOpen }; 68 dropdowns.push(api); 69 return api; 70 } 71 document.addEventListener('click', function (event) { 72 dropdowns.forEach(function (d) { if (!d.root.contains(event.target)) d.setOpen(false); }); 73 }); 74 document.addEventListener('keydown', function (event) { 75 if (event.key === 'Escape') closeAllDropdowns(); 76 }); 77 78 // On narrow screens the three pickers collapse behind an "Options" toggle. 79 function closeOpts() { 80 const toolbar = document.getElementById('chat-toolbar'); 81 const toggle = document.getElementById('chat-opts-toggle'); 82 if (toolbar) toolbar.classList.remove('is-open'); 83 if (toggle) toggle.setAttribute('aria-expanded', 'false'); 84 } 85 (function initOptsToggle() { 86 const toolbar = document.getElementById('chat-toolbar'); 87 const toggle = document.getElementById('chat-opts-toggle'); 88 if (!toolbar || !toggle) return; 89 toggle.addEventListener('click', function () { 90 const open = !toolbar.classList.contains('is-open'); 91 toolbar.classList.toggle('is-open', open); 92 toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); 93 }); 94 })(); 95 96 // Answer-mode picker: "Instant Answer" (reasoning off, fast) vs 97 // "Thinking Mode" (reasoning on, with the live chain-of-thought shown). 98 let thinkMode = false; // effective flag sent to the backend 99 let userMode = false; // the user's chosen mode for toggle-capable models 100 // The Instant/Thinking toggle only applies to models whose chain-of-thought 101 // can actually be switched off via `reasoning_effort` (verified: Qwen3.6). 102 // Others show a static "Auto" instead: GLM-5.2 always answers directly and 103 // rejects the reasoning parameter, MiniMax always reasons. Keeping this a 104 // whitelist means a new model defaults to the safe static state until its 105 // capability is confirmed (mirror this list in the backend's 106 // LITELLM_REASONING_MODELS). 107 function modelSupportsModeToggle(id) { return /qwen/i.test(id || ''); } 108 // Non-toggle models fall into two camps: GLM-5.2 always answers directly and 109 // rejects reasoning_effort, so its fixed mode is the Instant answer; other 110 // non-toggle models (e.g. MiniMax) always reason and show "Auto". 111 function modelAlwaysDirect(id) { return /glm/i.test(id || ''); } 112 let setModeForModel = function () {}; // wired up by initModeMenu 113 let setMode = function () {}; // wired up by initModeMenu (used by chips) 114 115 (function initModeMenu() { 116 const dd = makeDropdown('chat-mode', 'chat-mode-button', 'chat-mode-menu'); 117 const current = document.getElementById('chat-mode-current'); 118 if (!dd || !current) return; 119 const options = Array.prototype.slice.call(dd.menu.querySelectorAll('.chat-mode-option')); 120 121 function labelFor(mode) { 122 const o = dd.menu.querySelector('.chat-mode-option[data-mode="' + mode + '"] .chat-mode-label'); 123 return o ? o.textContent : mode; 124 } 125 const instantLabel = labelFor('instant'); 126 const thinkingLabel = labelFor('thinking'); 127 128 function setActive(isThinking) { 129 options.forEach(function (o) { 130 const active = (o.getAttribute('data-mode') === 'thinking') === isThinking; 131 o.classList.toggle('is-active', active); 132 o.setAttribute('aria-selected', active ? 'true' : 'false'); 133 }); 134 } 135 136 // Apply a mode choice, respecting the current model's capability. 137 function apply(isThinking) { 138 userMode = isThinking;
139 if (modelSupportsModeToggle(selectedModel)) { 140 thinkMode = userMode; 141 current.textContent = userMode ? thinkingLabel : instantLabel; 142 setActive(userMode); 143 } 144 } 145 setMode = apply; 146 147 options.forEach(function (opt) { 148 opt.addEventListener('click', function () { 149 apply(opt.getAttribute('data-mode') === 'thinking'); 150 dd.setOpen(false); 151 closeOpts(); 152 }); 153 }); 154 155 setModeForModel = function (model) { 156 if (modelSupportsModeToggle(model)) { 157 dd.root.classList.remove('chat-mode--static'); 158 dd.button.disabled = false; 159 thinkMode = userMode; 160 current.textContent = userMode ? thinkingLabel : instantLabel; 161 setActive(userMode); 162 } else { 163 // No mode choice for this model - show a fixed, honest state. 164 dd.setOpen(false); 165 dd.button.disabled = true; 166 dd.root.classList.add('chat-mode--static'); 167 if (modelAlwaysDirect(model)) { 168 // GLM-5.2 answers directly and rejects reasoning_effort, so 169 // its fixed mode is Instant - and think:false is safe because 170 // the backend never sends reasoning_effort to this model. 171 current.textContent = instantLabel; 172 thinkMode = false; 173 } else { 174 // Always-reasoning model: the reasoning streams into the 175 // thinking box live regardless of the flag. 176 current.textContent = 'Auto'; 177 thinkMode = true; 178 } 179 } 180 }; 181 })(); 182 183 // Friendly metadata for the known models (drives the rich picker). 184 function modelMeta(id) { 185 const x = (id || '').toLowerCase(); 186 if (/glm/.test(x)) return { short: 'GLM-5.2', role: isEnglish ? 'The All-Rounder' : 'Der Allrounder', tag: isEnglish ? 'direct, fast answers for everyday tasks' : 'direkte, schnelle Antworten für den Alltag', rec: true }; 187 if (/qwen3\.8/.test(x)) return { short: 'Qwen3.8', role: isEnglish ? 'The New One' : 'Das Neue', tag: isEnglish ? 'brand new, dense, currently still rather slow' : 'brandneu, dense, aktuell noch recht langsam', rec: false }; 188 if (/qwen/.test(x)) return { short: 'Qwen3.6', role: isEnglish ? 'The Fast One' : 'Der Schnelle', tag: isEnglish ? 'fast & efficient, long docs too' : 'schnell & effizient, auch lange Docs', rec: false }; 189 if (/minimax/.test(x)) return { short: 'MiniMax M3', role: isEnglish ? 'The Deep Thinker' : 'Der Tiefdenker', tag: isEnglish ? 'multi-step tasks, always reasons' : 'mehrstufige Aufgaben, denkt immer mit', rec: false }; 190 // Unknown model id: show it as-is, no invented claims. 191 return { short: id, role: id, tag: '', rec: false }; 192 } 193 let openCompare = function () {}; // wired up by initCompareModal 194 195 // Model picker: reads the available models from the backend (key stays 196 // server-side) and lets the user switch; the choice is sent with each message. 197 let selectedModel = ''; 198 (function initModelMenu() { 199 const dd = makeDropdown('chat-model', 'chat-model-button', 'chat-model-menu'); 200 const current = document.getElementById('chat-model-current'); 201 if (!dd || !current) return; 202 const MODEL_LABEL = isEnglish ? 'Model' : 'Modell'; 203 204 function choose(id) { 205 selectedModel = id; 206 current.innerHTML = ''; 207 const pfx = document.createElement('span'); 208 pfx.className = 'chat-model-prefix'; 209 pfx.textContent = MODEL_LABEL + ': '; 210 const nm = document.createElement('span'); 211 nm.className = 'chat-model-name'; 212 nm.textContent = modelMeta(id).short || id; 213 current.appendChild(pfx); 214 current.appendChild(nm); 215 Array.prototype.slice.call(dd.menu.querySelectorAll('.chat-model-option')).forEac
215h(function (o) { 216 const active = o.getAttribute('data-model') === id; 217 o.classList.toggle('is-active', active); 218 o.setAttribute('aria-selected', active ? 'true' : 'false'); 219 }); 220 // Update the answer-mode picker for this model's capabilities. 221 setModeForModel(id); 222 } 223 224 function buildOption(id) { 225 const meta = modelMeta(id); 226 const li = document.createElement('li'); 227 li.className = 'chat-mode-option chat-model-option'; 228 li.setAttribute('role', 'option'); 229 li.setAttribute('data-model', id); 230 231 const check = document.createElement('span'); 232 check.className = 'chat-mode-check'; 233 check.setAttribute('aria-hidden', 'true'); 234 check.textContent = 'â'; 235 236 const text = document.createElement('span'); 237 text.className = 'chat-model-opt'; 238 const role = document.createElement('span'); 239 role.className = 'chat-model-opt-role'; 240 role.textContent = meta.role; 241 if (meta.rec) { 242 const badge = document.createElement('span'); 243 badge.className = 'chat-model-opt-badge';
244 badge.textContent = isEnglish ? 'Recommended' : 'Empfehlung'; 245 role.appendChild(document.createTextNode(' ')); 246 role.appendChild(badge); 247 } 248 const sub = document.createElement('span'); 249 sub.className = 'chat-model-opt-sub'; 250 sub.textContent = meta.tag ? (id + ' · ' + meta.tag) : id; 251 text.appendChild(role); 252 text.appendChild(sub); 253 254 li.appendChild(check); 255 li.appendChild(text); 256 li.addEventListener('click', function () { 257 choose(id); 258 dd.setOpen(false); 259 closeOpts(); 260 }); 261 return li; 262 } 263 264 fetch('/api/chat/models', { headers: { Accept: 'application/json' }, credentials: 'same-origin' }) 265 .then(function (r) { return r.ok ? r.json() : null; }) 266 .then(function (info) { 267 const models = (info && info.models) || []; 268 const def = (info && info.default) || models[0] || ''; 269 if (!models.length) { selectedModel = def; current.textContent = def ? MODEL_LABEL + ': ' + def : 'â'; return; } 270 dd.menu.innerHTML = ''; 271 models.forEach(function (id) { dd.menu.appendChild(buildOption(id)); }); 272 273 // "Compare all models" opens the overview overlay. 274 const compare = document.createElement('li'); 275 compare.className = 'chat-model-compare'; 276 compare.setAttribute('role', 'button'); 277 compare.tabIndex = 0; 278 compare.textContent = (isEnglish ? 'Compare all models' : 'Alle Modelle vergleichen') + ' â'; 279 compare.addEventListener('click', function () { dd.setOpen(false); openCompare(); }); 280 dd.menu.appendChild(compare); 281 282 choose(models.indexOf(def) >= 0 ? def : models[0]); 283 }) 284 .catch(function () { current.textContent = 'â'; }); 285 })(); 286 287 // Model-comparison overlay, opened from the model picker. 288 (function initCompareModal() { 289 const modal = document.getElementById('model-compare'); 290 if (!modal) return; 291 function setOpen(open) { 292 modal.hidden = !open; 293 document.body.classList.toggle('modal-open', open); 294 } 295 openCompare = function () { setOpen(true); }; 296 Array.prototype.slice.call(modal.querySelectorAll('[data-close]')).forEach(function (el) { 297 el.addEventListener('click', function () { setOpen(false); }); 298 }); 299 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') setOpen(false); }); 300 })(); 301 302 // When the user agrees to the greeting's follow-up question, the model 303 // gets this fuller prompt instead of the bare "ja"/"yes" â otherwise it 304 // has no context for the answer (the greeting lives only in the UI, not in 305 // the conversation history). The visible bubble still shows what the user 306 // typed. Edit this copy freely. 307 const INTRO_FOLLOWUP_PROMPT = isEnglish 308 ? 'Yes, please. Explain concretely and clearly the areas where the Indie.cluster can support me. Cover, among others: working and chatting with your own documents (PDFs, contracts, reports) including knowledge bases; building agents and automated workflows with tool and API connectivity; running various AI models locally for text analysis, research, coding, and content creation; and handling long contexts with autonomous validation across the cluster. Describe each area in one or two sentences with a practical example, and emphasise that everything runs fully local and private, with no cloud.' 309 : 'Ja, gerne. Bitte erkläre mir konkret und übersichtlich, in welchen Einsatzgebieten mich der Indie.cluster unterstützen kann. Gehe dabei unter anderem auf diese Bereiche ein: das Arbeiten und Chatten mit eigenen Dokumenten (PDFs, Verträge, Berichte) inklusive Wissensdatenbanken; das Erstellen von Agenten und automatisierten Workflows mit Tool- und API-Anbindung; das lokale Ausführen verschiedener KI-Modelle für Textanalyse, Recherche, Programmierung und Inhaltserstellung; sowie die Verarbeitung langer Kontexte und autonome Validierungen über den Cluster. Beschreibe jeden Bereich in ein bis zwei Sätzen mit einem praxisnahen Beispiel und betone, dass alles vollständig lokal und privat ohne Cloud läuft.'; 310 311 // A short affirmative answer to the greeting question ("ja", "yes", â¦). 312 const AFFIRMATIVE_RE = /^(ja|jo|jap|jepp|yes|yep|yeah|klar|na klar|gerne|sicher|unbedingt|sure|ok|okay)\b/; 313 function isAffirmative(text) { 314 return AFFIRMATIVE_RE.test(text.toLowerCase().trim()); 315 } 316 317 // Conversation history sent to the backend (system prompt is added server-side). 318 const messages = []; 319 let busy = false;
320 // True until the user's first message â their answer to the greeting question. 321 let awaitingIntro = true; 322 323 // Run an example prompt: set the matching mode, then send it. 324 function runExample(prompt, mode) { 325 if (!prompt) return; 326 setMode(mode === 'thinking'); 327 send(prompt); 328 } 329 330 // Starter chips under the greeting (a one-time aid; vanish after first send) 331 // AND the always-available "Examples" dropdown in the toolbar. 332 (function initExamples() { 333 const box = document.getElementById('chat-suggestions'); 334 if (box) { 335 Array.prototype.slice.call(box.querySelectorAll('.chat-chip')).forEach(function (chip) { 336 chip.addEventListener('click', function () { 337 runExample(chip.getAttribute('data-prompt') || chip.textContent, chip.getAttribute('data-mode')); 338 }); 339 }); 340 } 341 const dd = makeDropdown('chat-examples', 'chat-examples-button', 'chat-examples-menu'); 342 if (dd) { 343 Array.prototype.slice.call(dd.menu.querySelectorAll('.chat-example')).forEach(function (item) { 344 item.addEventListener('click', function () { 345 dd.setOpen(false); 346 runExample(item.getAttribute('data-prompt'), item.getAttribute('data-mode')); 347 }); 348 }); 349 } 350 })(); 351 352 function autoGrow() { 353 input.style.height = 'auto'; 354 input.style.height = Math.min(input.scrollHeight, 200) + 'px'; 355 } 356 input.addEventListener('input', autoGrow); 357 358 function scrollToBottom() { 359 log.scrollTop = log.scrollHeight; 360 } 361 362 function escapeHtml(s) { 363 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 364 } 365 366 // Inline Markdown on an already-HTML-escaped string: code, bold, italic, links. 367 function inlineMarkdown(t) { 368 return t 369 .replace(/`([^`]+)`/g, '<code>$1</code>') 370 .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') 371 .replace(/(^|[^*])\*([^*\n]+?)\*/g, '$1<em>$2</em>') 372 .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)"']+)\)/g, 373 '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); 374 } 375 376 // Minimal, dependency-free Markdown -> HTML for assistant bubbles. HTML is 377 // escaped first, so only the known-safe tags below can ever be produced â 378 // the model output cannot inject markup. 379 function renderMarkdown(src) { 380 const lines = escapeHtml(src).split('\n'); 381 const out = []; 382 let list = null; // 'ul' | 'ol' 383 let para = []; // buffered consecutive paragraph lines 384 385 function flushPara() { 386 if (para.length) { out.push('<p>' + para.join('<br>') + '</p>'); para = []; } 387 } 388 function closeList() { 389 if (list) { out.push('</' + list + '>'); list = null; } 390 } 391 392 for (const raw of lines) { 393 const line = raw.trim(); 394 if (!line) { flushPara(); closeList(); continue; } 395 396 let m; 397 if ((m = /^#{1,6}\s+(.*)$/.exec(line))) { 398 flushPara(); closeList(); 399 out.push('<p><strong>' + inlineMarkdown(m[1]) + '</strong></p>'); 400 } else if ((m = /^[-*]\s+(.*)$/.exec(line))) { 401 flushPara(); 402 if (list !== 'ul') { closeList(); out.push('<ul>'); list = 'ul'; } 403 out.push('<li>' + inlineMarkdown(m[1]) + '</li>'); 404 } else if ((m = /^\d+\.\s+(.*)$/.exec(line))) { 405 flushPara(); 406 if (list !== 'ol') { closeList(); out.push('<ol>'); list = 'ol'; } 407 out.push('<li>' + inlineMarkdown(m[1]) + '</li>'); 408 } else { 409 closeList(); 410 para.push(inlineMarkdown(line)); 411 } 412 } 413 flushPara(); closeList(); 414 return out.join(''); 415 } 416 417 function addMessage(role, text) { 418 const wrap = document.createElement('div'); 419 wrap.className = 'chat-message chat-message--' + role; 420 const bubble = document.createElement('div'); 421 bubble.className = 'chat-bubble'; 422 bubble.textContent = text || ''; 423 wrap.appendChild(bubble); 424 log.appendChild(wrap); 425 scrollToBottom(); 426 return bubble; 427 } 428 429 function setBusy(state) { 430 busy = state; 431 input.disabled = state; 432 // While streaming the send button becomes a stop button (kept enabled). 433 sendButton.classList.toggle('chat-send--stop', state); 434 if (sendLabelEl) sendLabelEl.textContent = state ? STRINGS.stop : SEND_TEXT; 435 sendButton.setAttribute('aria-label', state ? STRINGS.stop : SEND_TEXT); 436 } 437 438 async function send(text) { 439 const content = text.trim(); 440 if (!content || busy) return; 441 442 // Suggestion chips are a starting aid only â remove them on first send. 443 const suggestions = document.getElementById('chat-suggestions'); 444 if (suggestions) suggestions.remove(); 445 446 addMessage('user', content); 447 448 // First reply to the greeting: an affirmative ("ja"/"yes") becomes a 449 // fuller prompt so the model lays out the concrete use cases; anything 450 // else is sent as typed. Either way, the intro turn is now consumed. 451 const outgoing = (awaitingIntro && isAffirmative(content)) ? INTRO_FOLLOWUP_PROMPT : content; 452 awaitingIntro = false;
453 messages.push({ role: 'user', content: outgoing }); 454 455 input.value = ''; 456 autoGrow(); 457 setBusy(true); 458 459 // Placeholder assistant bubble with a typing indicator. 460 const bubble = addMessage('assistant', ''); 461 bubble.classList.add('chat-bubble--pending'); 462 bubble.innerHTML = '<span class="chat-typing"><span></span><span></span><span></span></span>'; 463 464 const think = thinkMode; 465 let answer = ''; 466 // Lazily-created disclosure that streams the model's reasoning. 467 let thinkBody = null; 468 let thinking = ''; 469 470 function appendThinking(piece) { 471 if (!thinkBody) { 472 const details = document.createElement('details'); 473 details.className = 'chat-think'; 474 details.open = true; 475 details.innerHTML = '<summary></summary><div class="chat-think-body"></div>'; 476 details.querySelector('summary').textContent = STRINGS.thinking; 477 bubble.parentElement.insertBefore(details, bubble); 478 thinkBody = details.querySelector('.chat-think-body'); 479 // The reasoning stream replaces the typing dots. 480 bubble.classList.remove('chat-bubble--pending'); 481 bubble.textContent = ''; 482 bubble.style.display = 'none'; 483 } 484 thinking += piece; 485 thinkBody.innerHTML = renderMarkdown(thinking); 486 // Keep the newest reasoning visible: scroll the thinking box to its 487 // bottom and follow along in the chat log. 488 thinkBody.scrollTop = thinkBody.scrollHeight; 489 scrollToBottom(); 490 } 491 492 const controller = new AbortController(); 493 activeController = controller; 494 495 try { 496 const response = await fetch('/api/chat', { 497 method: 'POST', 498 headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, 499 credentials: 'same-origin', 500 signal: controller.signal, 501 body: JSON.stringify({ messages: messages, think: think, model: selectedModel || undefined, lang: isEnglish ? 'en' : 'de' }) 502 }); 503 504 if (!response.ok || !response.body) { 505 bubble.classList.remove('chat-bubble--pending'); 506 if (response.status === 503) { 507 bubble.textContent = STRINGS.errorUnavailable; 508 } else if (response.status === 502) { 509 bubble.textContent = STRINGS.errorModel; 510 } else { 511 bubble.textContent = STRINGS.errorGeneric; 512 } 513 bubble.classList.add('chat-bubble--error'); 514 setBusy(false); 515 return; 516 } 517 518 const reader = response.body.getReader(); 519 const decoder = new TextDecoder(); 520 let buffer = ''; 521 let started = false; 522 523 // Read the SSE stream and accumulate token deltas. 524 // eslint-disable-next-line no-constant-condition 525 while (true) { 526 const { value, done } = await reader.read(); 527 if (done) break; 528 buffer += decoder.decode(value, { stream: true }); 529 530 let lineEnd; 531 while ((lineEnd = buffer.indexOf('\n')) !== -1) { 532 const line = buffer.slice(0, lineEnd).trim(); 533 buffer = buffer.slice(lineEnd + 1); 534 if (!line || !line.startsWith('data:')) continue; 535 536 const data = line.slice(5).trim(); 537 if (data === '[DONE]') continue; 538 539 let parsed; 540 try { 541 parsed = JSON.parse(data); 542 } catch (err) { 543 continue; 544 } 545 546 const delta = parsed.choices && parsed.choices[0] && parsed.choices[0].delta; 547 if (!delta) continue; 548 549 // Reasoning tokens arrive before the visible answer. 550 if (delta.reasoning_content) { 551 appendThinking(delta.reasoning_content); 552 } 553 554 const piece = delta.content; 555 if (piece) { 556 if (!started) { 557 started = true; 558 bubble.classList.remove('chat-bubble--pending'); 559 bubble.style.display = ''; 560 bubble.textContent = ''; 561 // Collapse the reasoning once the answer begins. 562 if (thinkBody) { 563 const details = thinkBody.parentElement; 564 details.open = false;
565 details.querySelector('summary').textContent = STRINGS.thought; 566 } 567 } 568 answer += piece; 569 bubble.classList.add('chat-bubble--rendered'); 570 bubble.innerHTML = renderMarkdown(answer); 571 scrollToBottom(); 572 } 573 } 574 } 575 576 if (!started) { 577 // Stream ended without any content (e.g. upstream hiccup). 578 bubble.classList.remove('chat-bubble--pending'); 579 bubble.style.display = ''; 580 bubble.textContent = STRINGS.errorModel; 581 bubble.classList.add('chat-bubble--error'); 582 setBusy(false); 583 return; 584 } 585 586 messages.push({ role: 'assistant', content: answer }); 587 } catch (err) { 588 if (controller.signal.aborted) { 589 // User stopped the response: keep whatever was already rendered 590 // (so it stays in the history); if nothing was shown yet, drop 591 // the empty assistant message entirely. 592 if (answer) { 593 messages.push({ role: 'assistant', content: answer }); 594 } else { 595 const wrap = bubble.parentElement; 596 if (wrap) wrap.remove(); 597 } 598 } else { 599 bubble.classList.remove('chat-bubble--pending'); 600 bubble.style.display = ''; 601 bubble.textContent = STRINGS.errorGeneric; 602 bubble.classList.add('chat-bubble--error'); 603 } 604 } finally { 605 activeController = null; 606 setBusy(false); 607 input.focus(); 608 } 609 } 610 611 // While streaming, the same button stops the response instead of sending. 612 sendButton.addEventListener('click', function (event) { 613 if (busy) { 614 event.preventDefault(); 615 if (activeController) activeController.abort(); 616 } 617 }); 618 619 form.addEventListener('submit', function (event) { 620 event.preventDefault(); 621 if (busy) return; 622 send(input.value); 623 }); 624 625 // Enter to send, Shift+Enter for newline. 626 input.addEventListener('keydown', function (event) { 627 if (event.key === 'Enter' && !event.shiftKey) { 628 event.preventDefault(); 629 send(input.value); 630 } 631 }); 632 633 input.focus(); 634})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.