64c96e895c
* 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>
431 lines
16 KiB
JavaScript
431 lines
16 KiB
JavaScript
/**
|
||
* 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 = '■'; // 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 = '–'; // 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 = '×'; // 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 ? '□' : '–'; // 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();
|
||
}
|
||
}
|
||
}
|