PageSourceSearch

https://indiebox.ai/chat.js

js indiebox.ai collected 2026-10-05 14:13:49 UTC 29,699 bytes, 634 lines download raw bytes

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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.