Blame Raw
Cohee · 51ad27fb · · 430 lines (15.9 KB)
2 contributors
1/**
2 * A floating toast-like display panel for showing streaming LLM generation progress.
3 * Shows reasoning (thinking) and content as they stream in.
4 * Designed to work with ConnectionManagerRequestService streaming responses.
5 *
6 * Appends itself inside the topmost open `<dialog>` element (same approach as
7 * fixToastrForDialogs in popup.js) so it renders above modal overlays.
8 *
9 * @example
10 * const display = new StreamingDisplay();
11 * display.show({ label: 'Generating...' });
12 *
13 * for await (const chunk of streamGenerator) {
14 * display.updateReasoning(chunk.state?.reasoning)
15 * .updateContent(chunk.text);
16 * }
17 *
18 * display.complete('Generated Something'); // Mark as done (green LED, auto-hide if configured)
19 */
20
21import { SVGInject } from '../lib.js';
22import { t } from './i18n.js';
23import { animation_duration, messageFormatting } from '/script.js';
24
25/** CSS class prefix */
26const CSS_PREFIX = 'streaming-display';
27
28/**
29 * @typedef {Object} StreamingDisplayOptions
30 * @property {string} [label] - Header label (e.g. "Generating greeting...")
31 * @property {HTMLImageElement} [icon] - Optional API/model icon image (e.g. from createModelIcon). Will be SVG-injected when loaded.
32 * @property {(() => (void | Promise<void>)) | null} [onStop] - Optional stop handler. When provided, a stop button is shown. Clicking it invokes this handler only — the display is not automatically hidden or completed.
33 */
34
35export class StreamingDisplay {
36 /** @type {HTMLElement | null} */
37 #element = null;
38 /** @type {HTMLElement | null} */
39 #labelElement = null;
40 /** @type {HTMLElement | null} */
41 #labelText = null;
42 /** @type {HTMLElement | null} */
43 #reasoningSection = null;
44 /** @type {HTMLElement | null} */
45 #reasoningContent = null;
46 /** @type {HTMLElement | null} */
47 #textSection = null;
48 /** @type {HTMLElement | null} */
49 #textContent = null;
50 /** @type {HTMLButtonElement | null} */
51 #stopButton = null;
52 /** @type {HTMLButtonElement | null} */
53 #minimizeButton = null;
54 /** @type {HTMLButtonElement | null} */
55 #closeButton = null;
56 /** @type {(() => (void | Promise<void>)) | null} */
57 #onStop = null;
58 /** @type {HTMLElement | null} */
59 #ledIndicator = null;
60 /** @type {boolean} */
61 #hasContent = false;
62 /** @type {boolean} */
63 #isMinimized = false;
64 /** @type {boolean} */
65 #isComplete = false;
66 /** @type {boolean} */
67 #isStopped = false;
68 /** @type {ReturnType<typeof setTimeout> | null} */
69 #hideTimeoutId = null;
70
71 /**
72 * Shows the streaming display panel.
73 * @param {StreamingDisplayOptions} [options]
74 * @returns {StreamingDisplay} this instance for chaining
75 */
76 show({ label = '', icon = null, onStop = null } = {}) {
77 if (this.#element) this.hide({ instant: true });
78
79 this.#isMinimized = false;
80 this.#isComplete = false;
81 this.#onStop = onStop;
82 this.#clearHideTimeout();
83
84 this.#element = document.createElement('div');
85 this.#element.classList.add(CSS_PREFIX);
86
87 // Header label with LED indicator
88 this.#labelElement = document.createElement('div');
89 this.#labelElement.classList.add(`${CSS_PREFIX}-label`);
90
91 // LED status indicator (pulsing while streaming, green when complete)
92 this.#ledIndicator = document.createElement('span');
93 this.#ledIndicator.classList.add(`${CSS_PREFIX}-led`);
94 this.#labelElement.appendChild(this.#ledIndicator);
95
96 // Insert model icon into the label (after the LED)
97 if (icon instanceof HTMLImageElement) {
98 icon.classList.add(`${CSS_PREFIX}-icon`);
99 this.#labelElement.appendChild(icon);
100 icon.onload = async function () {
101 await SVGInject(icon);
102 };
103 }
104
105 this.#labelText = document.createElement('span');
106 this.#labelText.classList.add(`${CSS_PREFIX}-label-text`);
107 this.#labelText.textContent = label;
108 this.#labelElement.appendChild(this.#labelText);
109
110 // Window control buttons container
111 const controls = document.createElement('div');
112 controls.classList.add(`${CSS_PREFIX}-controls`);
113
114 // Stop button (only shown when an onStop handler is provided)
115 if (onStop) {
116 this.#stopButton = document.createElement('button');
117 this.#stopButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-stop`);
118 this.#stopButton.setAttribute('aria-label', t`Stop`);
119 this.#stopButton.setAttribute('title', t`Stop generation`);
120 this.#stopButton.innerHTML = '&#9632;'; // Black square ■
121 this.#stopButton.addEventListener('click', async () => {
122 // Disable immediately to prevent double-clicks and give instant feedback
123 if (this.#stopButton) {
124 this.#stopButton.disabled = true;
125 }
126 try {
127 await this.#onStop?.();
128 } catch (e) {
129 console.error('[StreamingDisplay] Error executing stop handler', e);
130 }
131 });
132 controls.appendChild(this.#stopButton);
133 }
134
135 // Minimize button
136 this.#minimizeButton = document.createElement('button');
137 this.#minimizeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-minimize`);
138 this.#minimizeButton.setAttribute('aria-label', t`Minimize`);
139 this.#minimizeButton.setAttribute('title', t`Minimize`);
140 this.#minimizeButton.innerHTML = '&#8211;'; // En dash
141 this.#minimizeButton.addEventListener('click', () => this.toggleMinimize());
142 controls.appendChild(this.#minimizeButton);
143
144 // Close button
145 this.#closeButton = document.createElement('button');
146 this.#closeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-close`);
147 this.#closeButton.setAttribute('aria-label', t`Close`);
148 this.#closeButton.setAttribute('title', t`Close (generation continues in background)`);
149 this.#closeButton.innerHTML = '&#215;'; // Multiplication sign (×)
150 this.#closeButton.addEventListener('click', () => this.hide());
151 controls.appendChild(this.#closeButton);
152
153 this.#labelElement.appendChild(controls);
154 this.#element.appendChild(this.#labelElement);
155
156 // Content container (for minimize functionality)
157 const contentContainer = document.createElement('div');
158 contentContainer.classList.add(`${CSS_PREFIX}-content`);
159
160 // Reasoning section (hidden until content arrives)
161 this.#reasoningSection = document.createElement('div');
162 this.#reasoningSection.classList.add(`${CSS_PREFIX}-reasoning`);
163 this.#reasoningSection.style.display = 'none';
164
165 const reasoningLabel = document.createElement('div');
166 reasoningLabel.classList.add(`${CSS_PREFIX}-reasoning-label`);
167 reasoningLabel.textContent = t`Thinking...`;
168 this.#reasoningSection.appendChild(reasoningLabel);
169
170 this.#reasoningContent = document.createElement('div');
171 this.#reasoningContent.classList.add(`${CSS_PREFIX}-reasoning-content`);
172 this.#reasoningSection.appendChild(this.#reasoningContent);
173
174 contentContainer.appendChild(this.#reasoningSection);
175
176 // Content section (hidden until content arrives)
177 this.#textSection = document.createElement('div');
178 this.#textSection.classList.add(`${CSS_PREFIX}-text`);
179 this.#textSection.style.display = 'none';
180
181 this.#textContent = document.createElement('div');
182 this.#textContent.classList.add(`${CSS_PREFIX}-text-content`, 'mes_text'); // Allow formatting based on how chat messages are formatted too
183 this.#textSection.appendChild(this.#textContent);
184
185 contentContainer.appendChild(this.#textSection);
186 this.#element.appendChild(contentContainer);
187
188 // Append inside the topmost open dialog (same pattern as fixToastrForDialogs in popup.js).
189 // Modal <dialog> elements live in the browser's top layer, so z-index alone won't work.
190 const target = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop() ?? document.body;
191 target.appendChild(this.#element);
192
193 // Trigger entrance animation on next frame
194 requestAnimationFrame(() => {
195 this.#element?.classList.add(`${CSS_PREFIX}-visible`);
196 });
197
198 return this;
199 }
200
201 /**
202 * Toggles the minimized state of the display.
203 * When minimized, only the header with label and buttons is shown.
204 * @returns {StreamingDisplay} this instance for chaining
205 */
206 toggleMinimize() {
207 if (!this.#element) return this;
208
209 this.#isMinimized = !this.#isMinimized;
210 this.#element.classList.toggle(`${CSS_PREFIX}-minimized`, this.#isMinimized);
211
212 // Update minimize button icon/appearance
213 if (this.#minimizeButton) {
214 this.#minimizeButton.innerHTML = this.#isMinimized ? '&#9633;' : '&#8211;'; // Square when minimized, dash when not
215 this.#minimizeButton.setAttribute('title', this.#isMinimized ? t`Restore` : t`Minimize`);
216 this.#minimizeButton.setAttribute('aria-label', this.#isMinimized ? t`Restore` : t`Minimize`);
217 }
218
219 return this;
220 }
221
222 /**
223 * @returns {boolean} Whether the display is currently minimized
224 */
225 get isMinimized() {
226 return this.#isMinimized;
227 }
228
229 /**
230 * @returns {boolean} Whether the display is marked as complete (generation finished)
231 */
232 get isComplete() {
233 return this.#isComplete;
234 }
235
236 /**
237 * @returns {boolean} Whether the display was stopped by the user
238 */
239 get isStopped() {
240 return this.#isStopped;
241 }
242
243 /**
244 * Updates the header label text.
245 * @param {string} label
246 * @returns {StreamingDisplay} this instance for chaining
247 */
248 setLabel(label) {
249 if (this.#labelText) {
250 this.#labelText.textContent = label;
251 }
252 return this;
253 }
254
255 /**
256 * Updates the reasoning (thinking) section with new text.
257 * Automatically shows the reasoning section when text is provided.
258 * @param {string} text - Accumulated reasoning text
259 * @returns {StreamingDisplay} this instance for chaining
260 */
261 updateReasoning(text) {
262 if (!this.#reasoningContent || !this.#reasoningSection || !text) return this;
263
264 this.#reasoningSection.style.display = '';
265 this.#reasoningContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, true);
266 this.#reasoningContent.scrollTop = this.#reasoningContent.scrollHeight;
267 return this;
268 }
269
270 /**
271 * Updates the main content section with new text.
272 * Automatically shows the content section when text is provided (including empty string).
273 * @param {string|null|undefined} text - Accumulated content text
274 * @returns {StreamingDisplay} this instance for chaining
275 */
276 updateContent(text) {
277 if (!this.#textContent || !this.#textSection || !text) return this;
278
279 this.#hasContent = true;
280 this.#textSection.style.display = '';
281 this.#textContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, false);
282 this.#textContent.scrollTop = this.#textContent.scrollHeight;
283 return this;
284 }
285
286 /** @returns {boolean} Whether any content text has been displayed via streaming */
287 get hasContent() {
288 return this.#hasContent;
289 }
290
291 /**
292 * Marks the generation as stopped by the user.
293 *
294 * Changes the LED indicator to solid red, removes the stop button, and keeps the display
295 * visible until the user manually closes it with the close button (no auto-hide).
296 *
297 * @param {Object} [options={}]
298 * @param {string|null} [options.label=null] - Optional label override (e.g. `'Generating... [Stopped]'`).
299 * @returns {StreamingDisplay} this instance for chaining
300 */
301 markStopped({ label = null } = {}) {
302 if (!this.#element || this.#isStopped || this.#isComplete) return this;
303
304 this.#isStopped = true;
305 this.#clearHideTimeout();
306 this.#element.classList.add(`${CSS_PREFIX}-stopped`);
307
308 // Remove the stop button — nothing left to stop
309 if (this.#stopButton) {
310 this.#stopButton.remove();
311 this.#stopButton = null;
312 }
313
314 if (label !== null) {
315 this.setLabel(label);
316 }
317
318 return this;
319 }
320
321 /**
322 * Marks the generation as complete and initiates cleanup. Optionally set a new label.
323 *
324 * This is the **preferred method** to call after streaming ends. It:
325 * - Changes the LED indicator from pulsing orange to solid green
326 * - Waits for the specified delay to let the user see the final result
327 * - Then hides the display with a fade-out animation
328 *
329 * @param {Object} [options={}]
330 * @param {string|null} [options.label=null] - Set the label automatically to a new one to display the completed state.
331 * @param {number|null} [options.delay=3000] - Delay in ms before hiding. Use `null` or negative value to keep displayed until user manually closes it.
332 * @returns {StreamingDisplay} this instance for chaining
333 */
334 complete({ label = null, delay = 3000 } = {}) {
335 if (!this.#element || this.#isComplete) return this;
336
337 this.#isComplete = true;
338 this.#element.classList.add(`${CSS_PREFIX}-complete`);
339
340 // Clear any existing hide timeout
341 this.#clearHideTimeout();
342
343 if (this.#stopButton) {
344 this.#stopButton.remove();
345 this.#stopButton = null;
346 }
347 if (label !== null) {
348 this.setLabel(label);
349 }
350
351 // Auto-hide after delay if specified (positive number)
352 if (typeof delay === 'number' && delay >= 0) {
353 this.#hideTimeoutId = setTimeout(() => {
354 this.#performHide();
355 }, delay);
356 }
357
358 return this;
359 }
360
361 /**
362 * Immediately hides and removes the streaming display.
363 *
364 * **Note:** This is for immediate cleanup (e.g., when canceling generation
365 * or closing the app). Prefer `complete()` when generation finishes normally,
366 * as it shows the green LED and gives the user time to see the final result.
367 *
368 * @param {Object} [options={}]
369 * @param {boolean} [options.instant=false] - Skip the fade-out animation
370 * @returns {StreamingDisplay} this instance for chaining
371 */
372 hide({ instant = false } = {}) {
373 this.#clearHideTimeout();
374 this.#performHide({ instant });
375 return this;
376 }
377
378 /**
379 * Clears any pending auto-hide timeout.
380 */
381 #clearHideTimeout() {
382 if (this.#hideTimeoutId !== null) {
383 clearTimeout(this.#hideTimeoutId);
384 this.#hideTimeoutId = null;
385 }
386 }
387
388 /**
389 * Internal method to actually remove the DOM element.
390 * @param {Object} [options={}]
391 * @param {boolean} [options.instant=false]
392 */
393 #performHide({ instant = false } = {}) {
394 if (!this.#element) return;
395
396 const el = this.#element;
397
398 // Clear all private fields
399 this.#element = null;
400 this.#labelElement = null;
401 this.#labelText = null;
402 this.#reasoningSection = null;
403 this.#reasoningContent = null;
404 this.#textSection = null;
405 this.#textContent = null;
406 this.#stopButton = null;
407 this.#minimizeButton = null;
408 this.#closeButton = null;
409 this.#ledIndicator = null;
410 this.#onStop = null;
411 this.#hasContent = false;
412 this.#isMinimized = false;
413 this.#isComplete = false;
414 this.#isStopped = false;
415 this.#hideTimeoutId = null;
416
417 if (instant) {
418 el.remove();
419 return;
420 }
421
422 el.classList.remove(`${CSS_PREFIX}-visible`);
423 const duration = animation_duration;
424 if (duration > 0) {
425 setTimeout(() => el.remove(), duration);
426 } else {
427 el.remove();
428 }
429 }
430}