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>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Streaming Display — floating toast panel for live LLM generation output.
|
||||
Shows reasoning (thinking) and content as they stream in.
|
||||
Used by extensions that leverage ConnectionManagerRequestService streaming.
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.streaming-display {
|
||||
position: fixed;
|
||||
bottom: max(calc(var(--bottomFormBlockSize) + 5px), 20px);
|
||||
right: 20px;
|
||||
width: min(550px, calc(100vw - 40px));
|
||||
max-height: 70vh;
|
||||
background: var(--SmartThemeBlurTintColor);
|
||||
border: 1px solid var(--SmartThemeBorderColor);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity var(--animation-duration, 125ms) ease, transform var(--animation-duration, 125ms) ease;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.streaming-display-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Header label with animated activity indicator */
|
||||
.streaming-display-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* LED status indicator - pulsing while streaming */
|
||||
.streaming-display-led {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgb(225, 138, 36);
|
||||
animation: streaming-display-pulse 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Completed state: solid green LED */
|
||||
.streaming-display-complete .streaming-display-led {
|
||||
background: #4caf50;
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
/* Stopped state: solid red LED */
|
||||
.streaming-display-stopped .streaming-display-led {
|
||||
background: #f44336;
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 8px rgba(244, 67, 54, 0.6);
|
||||
}
|
||||
|
||||
@keyframes streaming-display-pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(0.9); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
/* Label text takes available space */
|
||||
.streaming-display-label-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Window control buttons container */
|
||||
.streaming-display-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Window control buttons */
|
||||
.streaming-display-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.streaming-display-btn:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.streaming-display-btn-close:hover {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.streaming-display-btn-stop {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.streaming-display-btn-stop:hover {
|
||||
background-color: rgba(244, 150, 36, 0.2);
|
||||
color: rgb(225, 138, 36);
|
||||
}
|
||||
|
||||
/* Content container - collapsible for minimize */
|
||||
.streaming-display-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
transition: max-height var(--animation-duration, 125ms) ease, opacity var(--animation-duration, 125ms) ease;
|
||||
}
|
||||
|
||||
/* Minimized state - hide content sections */
|
||||
.streaming-display-minimized .streaming-display-content {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.streaming-display-minimized {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Model/API icon in the label */
|
||||
.streaming-display-icon {
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Minimized state adjustments */
|
||||
.streaming-display-minimized.streaming-display {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
/* Reasoning (thinking) section */
|
||||
.streaming-display-reasoning {
|
||||
background: color-mix(in srgb, var(--SmartThemeBodyColor) 5%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid color-mix(in srgb, var(--SmartThemeBodyColor) 25%, transparent);
|
||||
}
|
||||
|
||||
.streaming-display-reasoning-label {
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.streaming-display-reasoning-content {
|
||||
font-size: 0.82em;
|
||||
opacity: 0.65;
|
||||
max-height: 25vh;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.streaming-display-reasoning-content p {
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
.streaming-display-reasoning-content p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.streaming-display-reasoning-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Main content section */
|
||||
.streaming-display-text {
|
||||
border-top: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 50%, transparent);
|
||||
padding-top: 8px;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
.streaming-display-text-content {
|
||||
font-size: 0.9em;
|
||||
color: var(--SmartThemeBodyColor);
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.streaming-display-text-content p {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
|
||||
.streaming-display-text-content p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.streaming-display-text-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Strip auto-added quotes from <q> tags, as message formatting adds them */
|
||||
.streaming-display-reasoning-content q:before,
|
||||
.streaming-display-reasoning-content q:after,
|
||||
.streaming-display-text-content q:before,
|
||||
.streaming-display-text-content q:after {
|
||||
content: '';
|
||||
}
|
||||
+24
-11
@@ -1909,6 +1909,23 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
|
||||
return mes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Image element for the given API/model icon.
|
||||
* The image references the matching SVG file from `/img/` and includes a tooltip with API and model info.
|
||||
* The caller is responsible for appending the image to the DOM and optionally calling `SVGInject` on it.
|
||||
*
|
||||
* @param {string} apiName - API identifier matching an SVG file in /img/ (e.g. 'openai', 'openrouter', 'claude')
|
||||
* @param {string} [modelName=''] - Model name shown in the tooltip
|
||||
* @returns {HTMLImageElement} The image element (not yet in the DOM)
|
||||
*/
|
||||
export function createModelIcon(apiName, modelName = '') {
|
||||
const image = new Image();
|
||||
image.classList.add('icon-svg');
|
||||
image.src = `/img/${apiName}.svg`;
|
||||
image.title = modelName ? `${apiName} - ${modelName}` : apiName;
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts or replaces an SVG icon adjacent to the provided message's timestamp.
|
||||
*
|
||||
@@ -1916,11 +1933,9 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
|
||||
* @param {ChatMessageExtra} extra - Contains the API and model details.
|
||||
*/
|
||||
function insertSVGIcon(mes, extra) {
|
||||
// Determine the SVG filename
|
||||
let modelName = extra?.api || '';
|
||||
const apiName = extra?.api || '';
|
||||
|
||||
// If there's no API information, we can't determine which SVG to use
|
||||
if (!modelName) {
|
||||
if (!apiName) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1937,16 +1952,14 @@ function insertSVGIcon(mes, extra) {
|
||||
};
|
||||
};
|
||||
|
||||
const createModelImage = (className, targetSelector, insertBefore) => {
|
||||
const image = new Image();
|
||||
image.classList.add('icon-svg', className);
|
||||
image.src = `/img/${modelName}.svg`;
|
||||
image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
|
||||
const insertIcon = (className, targetSelector, insertBefore) => {
|
||||
const image = createModelIcon(apiName, extra?.model);
|
||||
image.classList.add(className);
|
||||
insertOrReplaceSVG(image, className, targetSelector, insertBefore);
|
||||
};
|
||||
|
||||
createModelImage('timestamp-icon', '.timestamp');
|
||||
createModelImage('thinking-icon', '.mes_reasoning_header_title', true);
|
||||
insertIcon('timestamp-icon', '.timestamp');
|
||||
insertIcon('thinking-icon', '.mes_reasoning_header_title', true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DOMPurify, Fuse } from '../../../lib.js';
|
||||
|
||||
import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
|
||||
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
|
||||
import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
|
||||
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
|
||||
import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
|
||||
@@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
|
||||
import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
|
||||
import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
|
||||
import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
|
||||
import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
|
||||
import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
|
||||
import { t } from '../../i18n.js';
|
||||
import { getSecretLabelById } from '../../secrets.js';
|
||||
import { performFuzzySearch } from '/scripts/power-user.js';
|
||||
import { StreamingDisplay } from '/scripts/streaming-display.js';
|
||||
import { ConnectionManagerRequestService } from '../shared.js';
|
||||
import { formatReasoning } from '/scripts/reasoning.js';
|
||||
|
||||
const MODULE_NAME = 'connection-manager';
|
||||
const NONE = '<None>';
|
||||
@@ -474,6 +479,222 @@ async function renderDetailsContent(detailsContent) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for the /profile-genstream command
|
||||
* Generates text using Connection Manager with streaming display support.
|
||||
* @param {object} args Named arguments
|
||||
* @param {string} value Unnamed argument (the prompt)
|
||||
* @returns {Promise<string>} The generated text, optionally with formatted reasoning
|
||||
*/
|
||||
async function generateStreamCallback(args, value) {
|
||||
if (!value) {
|
||||
console.warn('WARN: No argument provided for /profile-genstream command');
|
||||
return '';
|
||||
}
|
||||
|
||||
// Check if Connection Manager is available
|
||||
const context = getContext();
|
||||
if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
|
||||
toastr.error(t`Connection Manager is required for /profile-genstream. Use /gen or /genraw instead.`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const profileIdOrName = args?.profile;
|
||||
const includeReasoning = isTrueBoolean(args?.reasoning);
|
||||
const systemPrompt = typeof args?.system == 'string' ? args.system : '';
|
||||
const maxTokens = Number(args?.length ?? 2048) || 2048;
|
||||
const lock = isTrueBoolean(args?.lock);
|
||||
const generatingLabel = typeof args?.generating === 'string' ? args.generating : 'Generating...';
|
||||
const completedLabel = typeof args?.completed === 'string' ? args.completed : 'Generated';
|
||||
const enableStop = !isFalseBoolean(args?.stop);
|
||||
const onStopClosure = args?.onStop instanceof SlashCommandClosure ? args.onStop : null;
|
||||
const onCompleteClosure = args?.onComplete instanceof SlashCommandClosure ? args.onComplete : null;
|
||||
|
||||
// Parse delay: 'infinite' or negative = null (stay open), number = delay in ms
|
||||
let completeDelay = 3000; // Default 3 seconds
|
||||
if (args?.delay !== undefined) {
|
||||
if (typeof args.delay === 'string' && args.delay.toLowerCase() === 'infinite') {
|
||||
completeDelay = null; // Stay until user closes
|
||||
} else {
|
||||
const parsed = Number(args.delay);
|
||||
if (!isNaN(parsed) && parsed >= 0) {
|
||||
completeDelay = parsed;
|
||||
} else if (!isNaN(parsed) && parsed < 0) {
|
||||
completeDelay = null; // Negative = infinite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for stop functionality (when stop is enabled)
|
||||
const abortController = enableStop ? new AbortController() : null;
|
||||
|
||||
// Compose the stop handler: abort the request + optionally invoke user closure
|
||||
const onStopHandler = enableStop ? async () => {
|
||||
abortController.abort();
|
||||
if (onStopClosure) {
|
||||
try {
|
||||
const localClosure = onStopClosure.getCopy();
|
||||
localClosure.onProgress = () => { };
|
||||
await localClosure.execute();
|
||||
} catch (e) {
|
||||
console.error('[GenStream] Error executing onStop closure', e);
|
||||
}
|
||||
}
|
||||
} : null;
|
||||
|
||||
try {
|
||||
if (lock) {
|
||||
deactivateSendButtons();
|
||||
}
|
||||
|
||||
// Determine which profile to use
|
||||
// Use the currently selected profile if no profile specified
|
||||
let effectiveProfileId = context.extensionSettings.connectionManager.selectedProfile;
|
||||
|
||||
const profiles = context.extensionSettings.connectionManager.profiles;
|
||||
|
||||
if (profileIdOrName) {
|
||||
// Use try to find profile by id first, then fuse search
|
||||
const profile = profiles.find(p => p.id === profileIdOrName);
|
||||
if (profile) {
|
||||
effectiveProfileId = profile.id;
|
||||
} else {
|
||||
const keys = [
|
||||
{ name: 'name', weight: 10 },
|
||||
];
|
||||
const fuseResults = performFuzzySearch('profile', profiles, keys, profileIdOrName);
|
||||
if (fuseResults.length > 0) {
|
||||
effectiveProfileId = fuseResults[0].item.id;
|
||||
} else {
|
||||
toastr.warning(t`Connection profile not found: ${profileIdOrName}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!effectiveProfileId) {
|
||||
toastr.error(t`No connection profile specified or selected. Use profile= argument or select a profile in Connection Manager.`);
|
||||
return '';
|
||||
}
|
||||
|
||||
// Create streaming display
|
||||
const display = new StreamingDisplay();
|
||||
display.show({
|
||||
label: generatingLabel,
|
||||
icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
|
||||
onStop: onStopHandler,
|
||||
});
|
||||
|
||||
const messages = [
|
||||
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
|
||||
{ role: 'user', content: value },
|
||||
];
|
||||
|
||||
let finalText = '';
|
||||
let finalReasoning = '';
|
||||
|
||||
/** Gets the final (if requested, formatted) text to return for this command @returns {string} */
|
||||
function buildResultText() {
|
||||
// Format output with reasoning if requested
|
||||
if (includeReasoning && finalReasoning) {
|
||||
const { formatted } = formatReasoning(finalReasoning, finalText);
|
||||
return formatted;
|
||||
}
|
||||
|
||||
return finalText;
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt streaming first
|
||||
const streamResponse = await ConnectionManagerRequestService.sendRequest(
|
||||
effectiveProfileId,
|
||||
messages,
|
||||
maxTokens,
|
||||
{ extractData: true, includePreset: true, stream: true, signal: abortController?.signal ?? undefined },
|
||||
);
|
||||
|
||||
if (typeof streamResponse === 'function') {
|
||||
const generator = streamResponse();
|
||||
for await (const chunk of generator) {
|
||||
finalText = chunk.text;
|
||||
finalReasoning = chunk.state?.reasoning || '';
|
||||
display.updateReasoning(finalReasoning);
|
||||
display.updateContent(finalText);
|
||||
}
|
||||
} else {
|
||||
// Non-streaming fallback within the try block
|
||||
const extracted = streamResponse;
|
||||
finalText = extracted?.content || '';
|
||||
finalReasoning = extracted?.reasoning || '';
|
||||
if (finalReasoning) {
|
||||
display.updateReasoning(finalReasoning);
|
||||
}
|
||||
display.updateContent(finalText);
|
||||
}
|
||||
} catch (error) {
|
||||
// If the user clicked stop, don't retry — show stopped state and return empty
|
||||
if (abortController?.signal?.aborted) {
|
||||
display.markStopped({ label: `${generatingLabel} [Stopped]` });
|
||||
return buildResultText();
|
||||
}
|
||||
|
||||
console.warn('[Slash Commands] Streaming failed, falling back to non-streaming:', error);
|
||||
display.hide({ instant: true });
|
||||
|
||||
// Retry with non-streaming
|
||||
const response = await ConnectionManagerRequestService.sendRequest(
|
||||
effectiveProfileId,
|
||||
messages,
|
||||
maxTokens,
|
||||
{ extractData: true, includePreset: true, stream: false },
|
||||
);
|
||||
|
||||
const extracted = /** @type {import('../../custom-request.js').ExtractedData} */ (response);
|
||||
finalText = extracted?.content || '';
|
||||
finalReasoning = extracted?.reasoning || '';
|
||||
|
||||
// Show quick non-streaming display
|
||||
display.show({
|
||||
label: generatingLabel,
|
||||
icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
|
||||
});
|
||||
if (finalReasoning) {
|
||||
display.updateReasoning(finalReasoning);
|
||||
}
|
||||
display.updateContent(finalText);
|
||||
}
|
||||
|
||||
// Mark as complete with delay (null = stay open until user closes)
|
||||
display.complete({ label: completedLabel, delay: completeDelay });
|
||||
|
||||
// Invoke onComplete closure if provided
|
||||
if (onCompleteClosure) {
|
||||
try {
|
||||
const localClosure = onCompleteClosure.getCopy();
|
||||
localClosure.onProgress = () => { };
|
||||
await localClosure.execute();
|
||||
} catch (e) {
|
||||
console.error('[GenStream] Error executing onComplete closure', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalText) {
|
||||
toastr.warning(t`Generation returned empty result`);
|
||||
return '';
|
||||
}
|
||||
|
||||
return buildResultText();
|
||||
} catch (err) {
|
||||
console.error('Error on /genstream generation', err);
|
||||
toastr.error(err.message, t`API Error`, { preventDuplicates: true });
|
||||
return '';
|
||||
} finally {
|
||||
if (lock) {
|
||||
activateSendButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
|
||||
|
||||
@@ -824,4 +1045,114 @@ export async function init() {
|
||||
return JSON.stringify(profile);
|
||||
},
|
||||
}));
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'profile-genstream',
|
||||
callback: generateStreamCallback,
|
||||
returns: t`generated text`,
|
||||
namedArgumentList: [
|
||||
new SlashCommandNamedArgument(
|
||||
'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(),
|
||||
),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'profile',
|
||||
description: t`connection profile ID to use for generation`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
enumProvider: commonEnumProviders.connectionProfiles(),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'reasoning',
|
||||
description: t`include formatted reasoning in the output`,
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'false',
|
||||
enumProvider: commonEnumProviders.boolean('trueFalse'),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'system',
|
||||
description: t`system prompt at the start`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'length',
|
||||
description: t`API response length in tokens`,
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
defaultValue: '2048',
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'generating',
|
||||
description: t`label/title for the generation display`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: 'Generating...',
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'completed',
|
||||
description: t`updated label/title for when generation completes`,
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
defaultValue: 'Generated',
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'delay',
|
||||
description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`,
|
||||
typeList: [ARGUMENT_TYPE.NUMBER],
|
||||
defaultValue: '3000',
|
||||
enumList: [
|
||||
new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'),
|
||||
new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input),
|
||||
],
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'stop',
|
||||
description: t`show a stop button on the streaming display that aborts generation when clicked`,
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'true',
|
||||
enumProvider: commonEnumProviders.boolean('trueFalse'),
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'onStop',
|
||||
description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`,
|
||||
typeList: [ARGUMENT_TYPE.CLOSURE],
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'onComplete',
|
||||
description: t`closure to execute after generation completes successfully`,
|
||||
typeList: [ARGUMENT_TYPE.CLOSURE],
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'prompt',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
}),
|
||||
],
|
||||
helpString: `
|
||||
<div>
|
||||
${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Use delay to control auto-hide behavior: number (ms), "infinite", or negative to keep the display open until manually closed. The display shows a green LED when complete.`}
|
||||
</div>
|
||||
<div>
|
||||
${t`A stop button is shown by default (stop=true). Click it to abort generation and return whatever was streamed so far. Use stop=false to hide the stop button.`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`}
|
||||
</div>
|
||||
<div>
|
||||
${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`}
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';
|
||||
import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js';
|
||||
import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
|
||||
@@ -543,6 +543,29 @@ export class ConnectionManagerRequestService {
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a model icon Image element for the given profile (or the currently selected profile).
|
||||
* Returns null if the profile is not found, has no API, or Connection Manager is unavailable.
|
||||
* @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile.
|
||||
* @returns {HTMLImageElement | null}
|
||||
*/
|
||||
static getProfileIcon(profileId) {
|
||||
if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile;
|
||||
if (!id) return null;
|
||||
|
||||
try {
|
||||
const profile = this.getProfile(id);
|
||||
if (!profile?.api) return null;
|
||||
return createModelIcon(profile.api, profile.model);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
|
||||
* @returns {boolean}
|
||||
|
||||
@@ -1010,6 +1010,44 @@ function registerReasoningSlashCommands() {
|
||||
: parsedReasoning.reasoning;
|
||||
},
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'reasoning-format',
|
||||
aliases: ['format-reasoning'],
|
||||
returns: 'formatted string',
|
||||
helpString: t`Formats reasoning and content into a single string using Reasoning Formatting settings. Useful for preparing text that can be parsed with /reasoning-parse.`,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'reasoning',
|
||||
description: 'The reasoning/thinking text to format',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
}),
|
||||
],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The main content text',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: false,
|
||||
}),
|
||||
],
|
||||
callback: (args, value) => {
|
||||
const reasoning = String(args?.reasoning ?? '');
|
||||
const content = String(value ?? '');
|
||||
|
||||
if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
|
||||
toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Format`);
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!reasoning) {
|
||||
toastr.warning(t`Reasoning argument is required.`, t`Reasoning Format`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const { formatted } = formatReasoning(reasoning, content);
|
||||
return formatted;
|
||||
},
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'reasoning-template',
|
||||
aliases: ['reasoning-formatting', 'reasoning-preset'],
|
||||
@@ -1411,6 +1449,36 @@ export function parseReasoningFromString(str, { strict = true } = {}, template =
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats reasoning and content into a string using the reasoning template.
|
||||
* This is the inverse of parseReasoningFromString.
|
||||
* @typedef {Object} FormattedReasoning
|
||||
* @property {string} formatted The formatted string with reasoning wrapped in prefix/suffix
|
||||
* @property {string} contentOnly The content without reasoning
|
||||
* @param {string} reasoning The reasoning/thinking text
|
||||
* @param {string} content The main content/response text
|
||||
* @param {ReasoningTemplate} [template=null] Optional template to use. Defaults to power_user.reasoning
|
||||
* @returns {FormattedReasoning} Object containing both formatted (reasoning + content) and contentOnly
|
||||
*/
|
||||
export function formatReasoning(reasoning, content, template = null) {
|
||||
template = template ?? power_user.reasoning;
|
||||
|
||||
// If no reasoning provided, return content only
|
||||
if (!reasoning || !template.prefix || !template.suffix) {
|
||||
return { formatted: content, contentOnly: content };
|
||||
}
|
||||
|
||||
// Substitute macros in template parts
|
||||
const prefix = substituteParams(template.prefix || '');
|
||||
const suffix = substituteParams(template.suffix || '');
|
||||
const separator = substituteParams(template.separator || '');
|
||||
|
||||
// Build the formatted string: prefix + reasoning + suffix + separator + content
|
||||
const formatted = `${prefix}${reasoning}${suffix}${separator}${content}`;
|
||||
|
||||
return { formatted, contentOnly: content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse reasoning in an array of swipe strings if auto-parsing is enabled.
|
||||
* @param {string[]} swipes Array of swipe strings
|
||||
|
||||
@@ -341,4 +341,9 @@ export const commonEnumProviders = {
|
||||
backgrounds: () => Array.from(document.querySelectorAll('.bg_example'))
|
||||
.map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))
|
||||
.filter(it => it.value?.length),
|
||||
|
||||
connectionProfiles: ({ includeNone = false } = {}) => () => [
|
||||
...includeNone ? [new SlashCommandEnumValue('<None>')] : [],
|
||||
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
@import url(css/secrets.css);
|
||||
@import url(css/backgrounds.css);
|
||||
@import url(css/chat-backups.css);
|
||||
@import url(css/streaming-display.css);
|
||||
|
||||
:root {
|
||||
interpolate-size: allow-keywords;
|
||||
|
||||
Reference in New Issue
Block a user