Files
SillyTavern/public/scripts/streaming-display.js
Wolfsblvt 64c96e895c Add Streaming Display Utility and New Generation Slash Commands (/genstream, /reasoning-format) (#5438)
* Add StreamingDisplay class for live LLM generation output with floating toast panel

- Add StreamingDisplay class to show streaming reasoning and content in a floating toast panel
- Extract createModelIcon() helper from insertSVGIcon() for reusable API/model icon creation
- StreamingDisplay automatically appends inside topmost open dialog (same pattern as fixToastrForDialogs)
- Add CSS with fade-in animation, pulsating activity indicator, and separate reasoning/content sections
- Support optional model icon in header

* Add ConnectionManagerRequestService.getProfileIcon() method for retrieving profile API icons

- Add static getProfileIcon() method to ConnectionManagerRequestService
- Returns HTMLImageElement created via createModelIcon() for a given profile's API/model
- Accepts optional profileId parameter, defaults to currently selected profile
- Returns null if Connection Manager is disabled, profile not found, or profile has no API
- Import createModelIcon from script.js

* Use animation_duration directly in hide() and CSS transition instead of constant

- Remove ANIMATION_DURATION_MS constant and use animation_duration directly in hide() method
- Replace hardcoded 0.3s CSS transitions with CSS variable var(--animation-duration, 125ms)
- Read animation_duration value inline in hide() for accurate timing

* Add /genstream slash command with live streaming display and reasoning support

- Add /genstream slash command that generates text via Connection Manager with live streaming UI
- Add formatReasoning() helper function (inverse of parseReasoningFromString) to format reasoning/content into template-wrapped strings
- Add connectionProfiles enum provider for profile selection in slash commands
- StreamingDisplay: add delay parameter to hide() method (default 1000ms) to show final result before dismiss

* Add /reasoning-format slash command to format reasoning and content into template-wrapped strings

- Add /reasoning-format (alias: /format-reasoning) slash command that wraps reasoning/content using Reasoning Formatting settings
- Accept required 'reasoning' named argument and optional unnamed 'content' argument
- Validate that prefix/suffix are configured before formatting
- Return formatted string via formatReasoning() helper for use with /reasoning-parse
- Show warning toasts if prefix/suffix missing

* Rename /genstream command to /profile-genstream and move to appropriate module

* Apply messageFormatting to StreamingDisplay reasoning and content text for proper rendering

- Import messageFormatting from script.js
- Replace textContent with innerHTML using messageFormatting() in updateReasoning() and updateText()
- Pass isSystem=true for reasoning, isSystem=false for content to match formatting expectations
- Update css to utilize pre-formatted paragraphs correctly

* Strip auto-added quotes from <q> tags in StreamingDisplay and add 'mes_text' class for consistent chat message formatting

- Add CSS rules to remove browser-default quotes from <q> tags in reasoning and content sections
- Add 'mes_text' class to textContent div to match chat message formatting behavior
- Prevents double quotes when messageFormatting already adds them via <q> tags

* Add minimize/close buttons and complete state to StreamingDisplay with configurable auto-hide

- Add minimize button to collapse/restore content sections while keeping header visible
- Add close button to manually dismiss display (generation continues in background)
- Replace CSS pseudo-element with explicit LED indicator element for better state control
- Add complete() method to mark generation done: changes LED from pulsing orange to solid green
- Add configurable auto-hide delay after completion

* Add stop button to StreamingDisplay with abort support and onStop/onComplete closures for /profile-genstream

- Add stop button to StreamingDisplay when onStop handler is provided
- Add markStopped() method with solid red LED state indicator
- Add AbortController integration to /profile-genstream for request cancellation
- Add onStop and onComplete closure arguments to /profile-genstream command
- Update complete() method signature to use options object with label and delay
- Disable stop button immediately

* Position StreamingDisplay above bottom form block using CSS variable with fallback

- Change bottom positioning from fixed 20px to dynamic calculation
- Use max() to position above --bottomFormBlockSize + 5px or minimum 20px
- Ensures StreamingDisplay doesn't overlap with bottom UI elements

* Rename /profile-genstream arguments for clarity: label→generating, completedLabel→completed, hideDelay→delay

- Rename `label` argument to `generating` to better reflect its purpose as the in-progress state label
- Rename `completedLabel` to `completed` for consistency and brevity
- Rename `hideDelay` to `delay` for simpler naming
- Update all internal references and variable names to match new argument names
- Update argument descriptions and default values accordingly

* Remove variable resolution from /profile-genstream arguments: system, length, and delay

- Remove ARGUMENT_TYPE.VARIABLE_NAME from typeList for system, length, and delay arguments
- Replace resolveVariable() calls with direct argument access for system, length, and delay
- Simplify type checking to use typeof directly on args properties
- Maintain existing default values and validation logic

* Add warning toast and early return when connection profile not found in /profile-genstream

- Display toastr warning when fuzzy search fails to find matching profile
- Return empty string to prevent execution with invalid profile
- Improves user feedback for incorrect profile names or IDs

* Extract buildResultText() helper in /profile-genstream to return partial results when stopped

- Add buildResultText() helper function to centralize result formatting logic
- Return partial generated text when user stops generation instead of empty string
- Reuse buildResultText() for both stopped and completed states
- Maintains consistent reasoning formatting in both cases

* fix lint

* Update documentation to reflect argument name change from hideDelay to delay

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
2026-04-15 21:38:13 +03:00

431 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A floating toast-like display panel for showing streaming LLM generation progress.
* Shows reasoning (thinking) and content as they stream in.
* Designed to work with ConnectionManagerRequestService streaming responses.
*
* Appends itself inside the topmost open `<dialog>` element (same approach as
* fixToastrForDialogs in popup.js) so it renders above modal overlays.
*
* @example
* const display = new StreamingDisplay();
* display.show({ label: 'Generating...' });
*
* for await (const chunk of streamGenerator) {
* display.updateReasoning(chunk.state?.reasoning)
* .updateContent(chunk.text);
* }
*
* display.complete('Generated Something'); // Mark as done (green LED, auto-hide if configured)
*/
import { SVGInject } from '../lib.js';
import { t } from './i18n.js';
import { animation_duration, messageFormatting } from '/script.js';
/** CSS class prefix */
const CSS_PREFIX = 'streaming-display';
/**
* @typedef {Object} StreamingDisplayOptions
* @property {string} [label] - Header label (e.g. "Generating greeting...")
* @property {HTMLImageElement} [icon] - Optional API/model icon image (e.g. from createModelIcon). Will be SVG-injected when loaded.
* @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.
*/
export class StreamingDisplay {
/** @type {HTMLElement | null} */
#element = null;
/** @type {HTMLElement | null} */
#labelElement = null;
/** @type {HTMLElement | null} */
#labelText = null;
/** @type {HTMLElement | null} */
#reasoningSection = null;
/** @type {HTMLElement | null} */
#reasoningContent = null;
/** @type {HTMLElement | null} */
#textSection = null;
/** @type {HTMLElement | null} */
#textContent = null;
/** @type {HTMLButtonElement | null} */
#stopButton = null;
/** @type {HTMLButtonElement | null} */
#minimizeButton = null;
/** @type {HTMLButtonElement | null} */
#closeButton = null;
/** @type {(() => (void | Promise<void>)) | null} */
#onStop = null;
/** @type {HTMLElement | null} */
#ledIndicator = null;
/** @type {boolean} */
#hasContent = false;
/** @type {boolean} */
#isMinimized = false;
/** @type {boolean} */
#isComplete = false;
/** @type {boolean} */
#isStopped = false;
/** @type {ReturnType<typeof setTimeout> | null} */
#hideTimeoutId = null;
/**
* Shows the streaming display panel.
* @param {StreamingDisplayOptions} [options]
* @returns {StreamingDisplay} this instance for chaining
*/
show({ label = '', icon = null, onStop = null } = {}) {
if (this.#element) this.hide({ instant: true });
this.#isMinimized = false;
this.#isComplete = false;
this.#onStop = onStop;
this.#clearHideTimeout();
this.#element = document.createElement('div');
this.#element.classList.add(CSS_PREFIX);
// Header label with LED indicator
this.#labelElement = document.createElement('div');
this.#labelElement.classList.add(`${CSS_PREFIX}-label`);
// LED status indicator (pulsing while streaming, green when complete)
this.#ledIndicator = document.createElement('span');
this.#ledIndicator.classList.add(`${CSS_PREFIX}-led`);
this.#labelElement.appendChild(this.#ledIndicator);
// Insert model icon into the label (after the LED)
if (icon instanceof HTMLImageElement) {
icon.classList.add(`${CSS_PREFIX}-icon`);
this.#labelElement.appendChild(icon);
icon.onload = async function () {
await SVGInject(icon);
};
}
this.#labelText = document.createElement('span');
this.#labelText.classList.add(`${CSS_PREFIX}-label-text`);
this.#labelText.textContent = label;
this.#labelElement.appendChild(this.#labelText);
// Window control buttons container
const controls = document.createElement('div');
controls.classList.add(`${CSS_PREFIX}-controls`);
// Stop button (only shown when an onStop handler is provided)
if (onStop) {
this.#stopButton = document.createElement('button');
this.#stopButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-stop`);
this.#stopButton.setAttribute('aria-label', t`Stop`);
this.#stopButton.setAttribute('title', t`Stop generation`);
this.#stopButton.innerHTML = '&#9632;'; // Black square ■
this.#stopButton.addEventListener('click', async () => {
// Disable immediately to prevent double-clicks and give instant feedback
if (this.#stopButton) {
this.#stopButton.disabled = true;
}
try {
await this.#onStop?.();
} catch (e) {
console.error('[StreamingDisplay] Error executing stop handler', e);
}
});
controls.appendChild(this.#stopButton);
}
// Minimize button
this.#minimizeButton = document.createElement('button');
this.#minimizeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-minimize`);
this.#minimizeButton.setAttribute('aria-label', t`Minimize`);
this.#minimizeButton.setAttribute('title', t`Minimize`);
this.#minimizeButton.innerHTML = '&#8211;'; // En dash
this.#minimizeButton.addEventListener('click', () => this.toggleMinimize());
controls.appendChild(this.#minimizeButton);
// Close button
this.#closeButton = document.createElement('button');
this.#closeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-close`);
this.#closeButton.setAttribute('aria-label', t`Close`);
this.#closeButton.setAttribute('title', t`Close (generation continues in background)`);
this.#closeButton.innerHTML = '&#215;'; // Multiplication sign (×)
this.#closeButton.addEventListener('click', () => this.hide());
controls.appendChild(this.#closeButton);
this.#labelElement.appendChild(controls);
this.#element.appendChild(this.#labelElement);
// Content container (for minimize functionality)
const contentContainer = document.createElement('div');
contentContainer.classList.add(`${CSS_PREFIX}-content`);
// Reasoning section (hidden until content arrives)
this.#reasoningSection = document.createElement('div');
this.#reasoningSection.classList.add(`${CSS_PREFIX}-reasoning`);
this.#reasoningSection.style.display = 'none';
const reasoningLabel = document.createElement('div');
reasoningLabel.classList.add(`${CSS_PREFIX}-reasoning-label`);
reasoningLabel.textContent = t`Thinking...`;
this.#reasoningSection.appendChild(reasoningLabel);
this.#reasoningContent = document.createElement('div');
this.#reasoningContent.classList.add(`${CSS_PREFIX}-reasoning-content`);
this.#reasoningSection.appendChild(this.#reasoningContent);
contentContainer.appendChild(this.#reasoningSection);
// Content section (hidden until content arrives)
this.#textSection = document.createElement('div');
this.#textSection.classList.add(`${CSS_PREFIX}-text`);
this.#textSection.style.display = 'none';
this.#textContent = document.createElement('div');
this.#textContent.classList.add(`${CSS_PREFIX}-text-content`, 'mes_text'); // Allow formatting based on how chat messages are formatted too
this.#textSection.appendChild(this.#textContent);
contentContainer.appendChild(this.#textSection);
this.#element.appendChild(contentContainer);
// Append inside the topmost open dialog (same pattern as fixToastrForDialogs in popup.js).
// Modal <dialog> elements live in the browser's top layer, so z-index alone won't work.
const target = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop() ?? document.body;
target.appendChild(this.#element);
// Trigger entrance animation on next frame
requestAnimationFrame(() => {
this.#element?.classList.add(`${CSS_PREFIX}-visible`);
});
return this;
}
/**
* Toggles the minimized state of the display.
* When minimized, only the header with label and buttons is shown.
* @returns {StreamingDisplay} this instance for chaining
*/
toggleMinimize() {
if (!this.#element) return this;
this.#isMinimized = !this.#isMinimized;
this.#element.classList.toggle(`${CSS_PREFIX}-minimized`, this.#isMinimized);
// Update minimize button icon/appearance
if (this.#minimizeButton) {
this.#minimizeButton.innerHTML = this.#isMinimized ? '&#9633;' : '&#8211;'; // Square when minimized, dash when not
this.#minimizeButton.setAttribute('title', this.#isMinimized ? t`Restore` : t`Minimize`);
this.#minimizeButton.setAttribute('aria-label', this.#isMinimized ? t`Restore` : t`Minimize`);
}
return this;
}
/**
* @returns {boolean} Whether the display is currently minimized
*/
get isMinimized() {
return this.#isMinimized;
}
/**
* @returns {boolean} Whether the display is marked as complete (generation finished)
*/
get isComplete() {
return this.#isComplete;
}
/**
* @returns {boolean} Whether the display was stopped by the user
*/
get isStopped() {
return this.#isStopped;
}
/**
* Updates the header label text.
* @param {string} label
* @returns {StreamingDisplay} this instance for chaining
*/
setLabel(label) {
if (this.#labelText) {
this.#labelText.textContent = label;
}
return this;
}
/**
* Updates the reasoning (thinking) section with new text.
* Automatically shows the reasoning section when text is provided.
* @param {string} text - Accumulated reasoning text
* @returns {StreamingDisplay} this instance for chaining
*/
updateReasoning(text) {
if (!this.#reasoningContent || !this.#reasoningSection || !text) return this;
this.#reasoningSection.style.display = '';
this.#reasoningContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, true);
this.#reasoningContent.scrollTop = this.#reasoningContent.scrollHeight;
return this;
}
/**
* Updates the main content section with new text.
* Automatically shows the content section when text is provided (including empty string).
* @param {string|null|undefined} text - Accumulated content text
* @returns {StreamingDisplay} this instance for chaining
*/
updateContent(text) {
if (!this.#textContent || !this.#textSection || !text) return this;
this.#hasContent = true;
this.#textSection.style.display = '';
this.#textContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, false);
this.#textContent.scrollTop = this.#textContent.scrollHeight;
return this;
}
/** @returns {boolean} Whether any content text has been displayed via streaming */
get hasContent() {
return this.#hasContent;
}
/**
* Marks the generation as stopped by the user.
*
* Changes the LED indicator to solid red, removes the stop button, and keeps the display
* visible until the user manually closes it with the close button (no auto-hide).
*
* @param {Object} [options={}]
* @param {string|null} [options.label=null] - Optional label override (e.g. `'Generating... [Stopped]'`).
* @returns {StreamingDisplay} this instance for chaining
*/
markStopped({ label = null } = {}) {
if (!this.#element || this.#isStopped || this.#isComplete) return this;
this.#isStopped = true;
this.#clearHideTimeout();
this.#element.classList.add(`${CSS_PREFIX}-stopped`);
// Remove the stop button — nothing left to stop
if (this.#stopButton) {
this.#stopButton.remove();
this.#stopButton = null;
}
if (label !== null) {
this.setLabel(label);
}
return this;
}
/**
* Marks the generation as complete and initiates cleanup. Optionally set a new label.
*
* This is the **preferred method** to call after streaming ends. It:
* - Changes the LED indicator from pulsing orange to solid green
* - Waits for the specified delay to let the user see the final result
* - Then hides the display with a fade-out animation
*
* @param {Object} [options={}]
* @param {string|null} [options.label=null] - Set the label automatically to a new one to display the completed state.
* @param {number|null} [options.delay=3000] - Delay in ms before hiding. Use `null` or negative value to keep displayed until user manually closes it.
* @returns {StreamingDisplay} this instance for chaining
*/
complete({ label = null, delay = 3000 } = {}) {
if (!this.#element || this.#isComplete) return this;
this.#isComplete = true;
this.#element.classList.add(`${CSS_PREFIX}-complete`);
// Clear any existing hide timeout
this.#clearHideTimeout();
if (this.#stopButton) {
this.#stopButton.remove();
this.#stopButton = null;
}
if (label !== null) {
this.setLabel(label);
}
// Auto-hide after delay if specified (positive number)
if (typeof delay === 'number' && delay >= 0) {
this.#hideTimeoutId = setTimeout(() => {
this.#performHide();
}, delay);
}
return this;
}
/**
* Immediately hides and removes the streaming display.
*
* **Note:** This is for immediate cleanup (e.g., when canceling generation
* or closing the app). Prefer `complete()` when generation finishes normally,
* as it shows the green LED and gives the user time to see the final result.
*
* @param {Object} [options={}]
* @param {boolean} [options.instant=false] - Skip the fade-out animation
* @returns {StreamingDisplay} this instance for chaining
*/
hide({ instant = false } = {}) {
this.#clearHideTimeout();
this.#performHide({ instant });
return this;
}
/**
* Clears any pending auto-hide timeout.
*/
#clearHideTimeout() {
if (this.#hideTimeoutId !== null) {
clearTimeout(this.#hideTimeoutId);
this.#hideTimeoutId = null;
}
}
/**
* Internal method to actually remove the DOM element.
* @param {Object} [options={}]
* @param {boolean} [options.instant=false]
*/
#performHide({ instant = false } = {}) {
if (!this.#element) return;
const el = this.#element;
// Clear all private fields
this.#element = null;
this.#labelElement = null;
this.#labelText = null;
this.#reasoningSection = null;
this.#reasoningContent = null;
this.#textSection = null;
this.#textContent = null;
this.#stopButton = null;
this.#minimizeButton = null;
this.#closeButton = null;
this.#ledIndicator = null;
this.#onStop = null;
this.#hasContent = false;
this.#isMinimized = false;
this.#isComplete = false;
this.#isStopped = false;
this.#hideTimeoutId = null;
if (instant) {
el.remove();
return;
}
el.classList.remove(`${CSS_PREFIX}-visible`);
const duration = animation_duration;
if (duration > 0) {
setTimeout(() => el.remove(), duration);
} else {
el.remove();
}
}
}