| 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 | |
| 21 | import { SVGInject } from '../lib.js'; |
| 22 | import { t } from './i18n.js'; |
| 23 | import { animation_duration, messageFormatting } from '/script.js'; |
| 24 | |
| 25 | /** CSS class prefix */ |
| 26 | const 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 | |
| 35 | export 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 = '■'; // 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 = '–'; // 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 = '×'; // 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 ? '□' : '–'; // 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 | } |