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>

64c96e895c5c8d22241ce69972a331e6fe222a3a

Wolfsblvt <wolfsblvt@gmail.com>

Signed
8 files changed, +1122 -15Ignore whitespace
public/css/streaming-display.css+236 -0
@@ -0,0 +1,236 @@
1/* ─────────────────────────────────────────────────────────────────────────────
2 Streaming Display — floating toast panel for live LLM generation output.
3 Shows reasoning (thinking) and content as they stream in.
4 Used by extensions that leverage ConnectionManagerRequestService streaming.
5 ───────────────────────────────────────────────────────────────────────────── */
6
7.streaming-display {
8 position: fixed;
9 bottom: max(calc(var(--bottomFormBlockSize) + 5px), 20px);
10 right: 20px;
11 width: min(550px, calc(100vw - 40px));
12 max-height: 70vh;
13 background: var(--SmartThemeBlurTintColor);
14 border: 1px solid var(--SmartThemeBorderColor);
15 border-radius: 10px;
16 padding: 14px;
17 z-index: 9000;
18 display: flex;
19 flex-direction: column;
20 gap: 10px;
21 opacity: 0;
22 transform: translateY(20px);
23 transition: opacity var(--animation-duration, 125ms) ease, transform var(--animation-duration, 125ms) ease;
24 overflow: hidden;
25 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
26 backdrop-filter: blur(12px);
27}
28
29.streaming-display-visible {
30 opacity: 1;
31 transform: translateY(0);
32}
33
34/* Header label with animated activity indicator */
35.streaming-display-label {
36 font-weight: 600;
37 font-size: 0.95em;
38 color: var(--SmartThemeBodyColor);
39 display: flex;
40 align-items: center;
41 gap: 8px;
42 user-select: none;
43}
44
45/* LED status indicator - pulsing while streaming */
46.streaming-display-led {
47 display: inline-block;
48 width: 8px;
49 height: 8px;
50 border-radius: 50%;
51 background: rgb(225, 138, 36);
52 animation: streaming-display-pulse 1.5s ease-in-out infinite;
53 flex-shrink: 0;
54}
55
56/* Completed state: solid green LED */
57.streaming-display-complete .streaming-display-led {
58 background: #4caf50;
59 animation: none;
60 opacity: 1;
61 box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
62}
63
64/* Stopped state: solid red LED */
65.streaming-display-stopped .streaming-display-led {
66 background: #f44336;
67 animation: none;
68 opacity: 1;
69 box-shadow: 0 0 8px rgba(244, 67, 54, 0.6);
70}
71
72@keyframes streaming-display-pulse {
73 0%, 100% { opacity: 0.4; transform: scale(0.9); }
74 50% { opacity: 1; transform: scale(1.1); }
75}
76
77/* Label text takes available space */
78.streaming-display-label-text {
79 flex: 1;
80 min-width: 0;
81 overflow: hidden;
82 text-overflow: ellipsis;
83 white-space: nowrap;
84}
85
86/* Window control buttons container */
87.streaming-display-controls {
88 display: flex;
89 align-items: center;
90 gap: 4px;
91 margin-left: auto;
92 flex-shrink: 0;
93}
94
95/* Window control buttons */
96.streaming-display-btn {
97 width: 22px;
98 height: 22px;
99 border: none;
100 border-radius: 4px;
101 background: transparent;
102 color: var(--SmartThemeBodyColor);
103 font-size: 14px;
104 line-height: 1;
105 cursor: pointer;
106 display: flex;
107 align-items: center;
108 justify-content: center;
109 opacity: 0.6;
110 transition: opacity 0.15s ease, background-color 0.15s ease;
111}
112
113.streaming-display-btn:hover {
114 opacity: 1;
115 background-color: rgba(255, 255, 255, 0.1);
116}
117
118.streaming-display-btn-close:hover {
119 background-color: rgba(244, 67, 54, 0.2);
120}
121
122.streaming-display-btn-stop {
123 font-size: 10px;
124}
125
126.streaming-display-btn-stop:hover {
127 background-color: rgba(244, 150, 36, 0.2);
128 color: rgb(225, 138, 36);
129}
130
131/* Content container - collapsible for minimize */
132.streaming-display-content {
133 display: flex;
134 flex-direction: column;
135 gap: 10px;
136 overflow: hidden;
137 transition: max-height var(--animation-duration, 125ms) ease, opacity var(--animation-duration, 125ms) ease;
138}
139
140/* Minimized state - hide content sections */
141.streaming-display-minimized .streaming-display-content {
142 max-height: 0;
143 opacity: 0;
144}
145
146.streaming-display-minimized {
147 gap: 0;
148}
149
150/* Model/API icon in the label */
151.streaming-display-icon {
152 width: 1.1em;
153 height: 1.1em;
154 flex-shrink: 0;
155}
156
157/* Minimized state adjustments */
158.streaming-display-minimized.streaming-display {
159 padding: 10px 14px;
160}
161
162/* Reasoning (thinking) section */
163.streaming-display-reasoning {
164 background: color-mix(in srgb, var(--SmartThemeBodyColor) 5%, transparent);
165 border-radius: 6px;
166 padding: 8px 10px;
167 border-left: 3px solid color-mix(in srgb, var(--SmartThemeBodyColor) 25%, transparent);
168}
169
170.streaming-display-reasoning-label {
171 font-size: 0.8em;
172 font-weight: 600;
173 opacity: 0.5;
174 margin-bottom: 4px;
175 letter-spacing: 0.03em;
176 text-transform: uppercase;
177}
178
179.streaming-display-reasoning-content {
180 font-size: 0.82em;
181 opacity: 0.65;
182 max-height: 25vh;
183 overflow-y: auto;
184 word-break: break-word;
185 line-height: 1.45;
186 scrollbar-width: thin;
187}
188
189.streaming-display-reasoning-content p {
190 margin: 0.3em 0;
191}
192
193.streaming-display-reasoning-content p:first-child {
194 margin-top: 0;
195}
196
197.streaming-display-reasoning-content p:last-child {
198 margin-bottom: 0;
199}
200
201/* Main content section */
202.streaming-display-text {
203 border-top: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 50%, transparent);
204 padding-top: 8px;
205 min-height: 1.5em;
206}
207
208.streaming-display-text-content {
209 font-size: 0.9em;
210 color: var(--SmartThemeBodyColor);
211 max-height: 40vh;
212 overflow-y: auto;
213 word-break: break-word;
214 line-height: 1.5;
215 scrollbar-width: thin;
216}
217
218.streaming-display-text-content p {
219 margin: 0.4em 0;
220}
221
222.streaming-display-text-content p:first-child {
223 margin-top: 0;
224}
225
226.streaming-display-text-content p:last-child {
227 margin-bottom: 0;
228}
229
230/* Strip auto-added quotes from <q> tags, as message formatting adds them */
231.streaming-display-reasoning-content q:before,
232.streaming-display-reasoning-content q:after,
233.streaming-display-text-content q:before,
234.streaming-display-text-content q:after {
235 content: '';
236}
public/script.js+24 -11
@@ -1910,17 +1910,32 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
1910}1910}
19111911
1912/**1912/**
1913 * Creates an Image element for the given API/model icon.
1914 * The image references the matching SVG file from `/img/` and includes a tooltip with API and model info.
1915 * The caller is responsible for appending the image to the DOM and optionally calling `SVGInject` on it.
1916 *
1917 * @param {string} apiName - API identifier matching an SVG file in /img/ (e.g. 'openai', 'openrouter', 'claude')
1918 * @param {string} [modelName=''] - Model name shown in the tooltip
1919 * @returns {HTMLImageElement} The image element (not yet in the DOM)
1920 */
1921export function createModelIcon(apiName, modelName = '') {
1922 const image = new Image();
1923 image.classList.add('icon-svg');
1924 image.src = `/img/${apiName}.svg`;
1925 image.title = modelName ? `${apiName} - ${modelName}` : apiName;
1926 return image;
1927}
1928
1929/**
1913 * Inserts or replaces an SVG icon adjacent to the provided message's timestamp.1930 * Inserts or replaces an SVG icon adjacent to the provided message's timestamp.
1914 *1931 *
1915 * @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced.1932 * @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced.
1916 * @param {ChatMessageExtra} extra - Contains the API and model details.1933 * @param {ChatMessageExtra} extra - Contains the API and model details.
1917 */1934 */
1918function insertSVGIcon(mes, extra) {1935function insertSVGIcon(mes, extra) {
1919 // Determine the SVG filename1936 const apiName = extra?.api || '';
1920 let modelName = extra?.api || '';
19211937
1922 // If there's no API information, we can't determine which SVG to use1938 if (!apiName) {
1923 if (!modelName) {
1924 return;1939 return;
1925 }1940 }
19261941
@@ -1937,16 +1952,14 @@ function insertSVGIcon(mes, extra) {
1937 };1952 };
1938 };1953 };
19391954
1940 const createModelImage = (className, targetSelector, insertBefore) => {1955 const insertIcon = (className, targetSelector, insertBefore) => {
1941 const image = new Image();1956 const image = createModelIcon(apiName, extra?.model);
1942 image.classList.add('icon-svg', className);1957 image.classList.add(className);
1943 image.src = `/img/${modelName}.svg`;
1944 image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
1945 insertOrReplaceSVG(image, className, targetSelector, insertBefore);1958 insertOrReplaceSVG(image, className, targetSelector, insertBefore);
1946 };1959 };
19471960
1948 createModelImage('timestamp-icon', '.timestamp');1961 insertIcon('timestamp-icon', '.timestamp');
1949 createModelImage('thinking-icon', '.mes_reasoning_header_title', true);1962 insertIcon('thinking-icon', '.mes_reasoning_header_title', true);
1950}1963}
19511964
1952/**1965/**
public/scripts/extensions/connection-manager/index.js+334 -3
@@ -1,7 +1,7 @@
1import { DOMPurify, Fuse } from '../../../lib.js';1import { DOMPurify, Fuse } from '../../../lib.js';
22
3import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';3import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
4import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';4import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
6import { SlashCommand } from '../../slash-commands/SlashCommand.js';6import { SlashCommand } from '../../slash-commands/SlashCommand.js';
7import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';7import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
@@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
9import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';9import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
10import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';10import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
11import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';11import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
12import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
12import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';13import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
13import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';14import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
14import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';15import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
15import { t } from '../../i18n.js';16import { t } from '../../i18n.js';
16import { getSecretLabelById } from '../../secrets.js';17import { getSecretLabelById } from '../../secrets.js';
18import { performFuzzySearch } from '/scripts/power-user.js';
19import { StreamingDisplay } from '/scripts/streaming-display.js';
20import { ConnectionManagerRequestService } from '../shared.js';
21import { formatReasoning } from '/scripts/reasoning.js';
1722
18const MODULE_NAME = 'connection-manager';23const MODULE_NAME = 'connection-manager';
19const NONE = '<None>';24const NONE = '<None>';
@@ -474,6 +479,222 @@ async function renderDetailsContent(detailsContent) {
474 }479 }
475}480}
476481
482/**
483 * Callback for the /profile-genstream command
484 * Generates text using Connection Manager with streaming display support.
485 * @param {object} args Named arguments
486 * @param {string} value Unnamed argument (the prompt)
487 * @returns {Promise<string>} The generated text, optionally with formatted reasoning
488 */
489async function generateStreamCallback(args, value) {
490 if (!value) {
491 console.warn('WARN: No argument provided for /profile-genstream command');
492 return '';
493 }
494
495 // Check if Connection Manager is available
496 const context = getContext();
497 if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
498 toastr.error(t`Connection Manager is required for /profile-genstream. Use /gen or /genraw instead.`);
499 return '';
500 }
501
502 const profileIdOrName = args?.profile;
503 const includeReasoning = isTrueBoolean(args?.reasoning);
504 const systemPrompt = typeof args?.system == 'string' ? args.system : '';
505 const maxTokens = Number(args?.length ?? 2048) || 2048;
506 const lock = isTrueBoolean(args?.lock);
507 const generatingLabel = typeof args?.generating === 'string' ? args.generating : 'Generating...';
508 const completedLabel = typeof args?.completed === 'string' ? args.completed : 'Generated';
509 const enableStop = !isFalseBoolean(args?.stop);
510 const onStopClosure = args?.onStop instanceof SlashCommandClosure ? args.onStop : null;
511 const onCompleteClosure = args?.onComplete instanceof SlashCommandClosure ? args.onComplete : null;
512
513 // Parse delay: 'infinite' or negative = null (stay open), number = delay in ms
514 let completeDelay = 3000; // Default 3 seconds
515 if (args?.delay !== undefined) {
516 if (typeof args.delay === 'string' && args.delay.toLowerCase() === 'infinite') {
517 completeDelay = null; // Stay until user closes
518 } else {
519 const parsed = Number(args.delay);
520 if (!isNaN(parsed) && parsed >= 0) {
521 completeDelay = parsed;
522 } else if (!isNaN(parsed) && parsed < 0) {
523 completeDelay = null; // Negative = infinite
524 }
525 }
526 }
527
528 // Create abort controller for stop functionality (when stop is enabled)
529 const abortController = enableStop ? new AbortController() : null;
530
531 // Compose the stop handler: abort the request + optionally invoke user closure
532 const onStopHandler = enableStop ? async () => {
533 abortController.abort();
534 if (onStopClosure) {
535 try {
536 const localClosure = onStopClosure.getCopy();
537 localClosure.onProgress = () => { };
538 await localClosure.execute();
539 } catch (e) {
540 console.error('[GenStream] Error executing onStop closure', e);
541 }
542 }
543 } : null;
544
545 try {
546 if (lock) {
547 deactivateSendButtons();
548 }
549
550 // Determine which profile to use
551 // Use the currently selected profile if no profile specified
552 let effectiveProfileId = context.extensionSettings.connectionManager.selectedProfile;
553
554 const profiles = context.extensionSettings.connectionManager.profiles;
555
556 if (profileIdOrName) {
557 // Use try to find profile by id first, then fuse search
558 const profile = profiles.find(p => p.id === profileIdOrName);
559 if (profile) {
560 effectiveProfileId = profile.id;
561 } else {
562 const keys = [
563 { name: 'name', weight: 10 },
564 ];
565 const fuseResults = performFuzzySearch('profile', profiles, keys, profileIdOrName);
566 if (fuseResults.length > 0) {
567 effectiveProfileId = fuseResults[0].item.id;
568 } else {
569 toastr.warning(t`Connection profile not found: ${profileIdOrName}`);
570 return '';
571 }
572 }
573 }
574
575 if (!effectiveProfileId) {
576 toastr.error(t`No connection profile specified or selected. Use profile= argument or select a profile in Connection Manager.`);
577 return '';
578 }
579
580 // Create streaming display
581 const display = new StreamingDisplay();
582 display.show({
583 label: generatingLabel,
584 icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
585 onStop: onStopHandler,
586 });
587
588 const messages = [
589 ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
590 { role: 'user', content: value },
591 ];
592
593 let finalText = '';
594 let finalReasoning = '';
595
596 /** Gets the final (if requested, formatted) text to return for this command @returns {string} */
597 function buildResultText() {
598 // Format output with reasoning if requested
599 if (includeReasoning && finalReasoning) {
600 const { formatted } = formatReasoning(finalReasoning, finalText);
601 return formatted;
602 }
603
604 return finalText;
605 }
606
607 try {
608 // Attempt streaming first
609 const streamResponse = await ConnectionManagerRequestService.sendRequest(
610 effectiveProfileId,
611 messages,
612 maxTokens,
613 { extractData: true, includePreset: true, stream: true, signal: abortController?.signal ?? undefined },
614 );
615
616 if (typeof streamResponse === 'function') {
617 const generator = streamResponse();
618 for await (const chunk of generator) {
619 finalText = chunk.text;
620 finalReasoning = chunk.state?.reasoning || '';
621 display.updateReasoning(finalReasoning);
622 display.updateContent(finalText);
623 }
624 } else {
625 // Non-streaming fallback within the try block
626 const extracted = streamResponse;
627 finalText = extracted?.content || '';
628 finalReasoning = extracted?.reasoning || '';
629 if (finalReasoning) {
630 display.updateReasoning(finalReasoning);
631 }
632 display.updateContent(finalText);
633 }
634 } catch (error) {
635 // If the user clicked stop, don't retry — show stopped state and return empty
636 if (abortController?.signal?.aborted) {
637 display.markStopped({ label: `${generatingLabel} [Stopped]` });
638 return buildResultText();
639 }
640
641 console.warn('[Slash Commands] Streaming failed, falling back to non-streaming:', error);
642 display.hide({ instant: true });
643
644 // Retry with non-streaming
645 const response = await ConnectionManagerRequestService.sendRequest(
646 effectiveProfileId,
647 messages,
648 maxTokens,
649 { extractData: true, includePreset: true, stream: false },
650 );
651
652 const extracted = /** @type {import('../../custom-request.js').ExtractedData} */ (response);
653 finalText = extracted?.content || '';
654 finalReasoning = extracted?.reasoning || '';
655
656 // Show quick non-streaming display
657 display.show({
658 label: generatingLabel,
659 icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
660 });
661 if (finalReasoning) {
662 display.updateReasoning(finalReasoning);
663 }
664 display.updateContent(finalText);
665 }
666
667 // Mark as complete with delay (null = stay open until user closes)
668 display.complete({ label: completedLabel, delay: completeDelay });
669
670 // Invoke onComplete closure if provided
671 if (onCompleteClosure) {
672 try {
673 const localClosure = onCompleteClosure.getCopy();
674 localClosure.onProgress = () => { };
675 await localClosure.execute();
676 } catch (e) {
677 console.error('[GenStream] Error executing onComplete closure', e);
678 }
679 }
680
681 if (!finalText) {
682 toastr.warning(t`Generation returned empty result`);
683 return '';
684 }
685
686 return buildResultText();
687 } catch (err) {
688 console.error('Error on /genstream generation', err);
689 toastr.error(err.message, t`API Error`, { preventDuplicates: true });
690 return '';
691 } finally {
692 if (lock) {
693 activateSendButtons();
694 }
695 }
696}
697
477export async function init() {698export async function init() {
478 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);699 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
479700
@@ -824,4 +1045,114 @@ export async function init() {
824 return JSON.stringify(profile);1045 return JSON.stringify(profile);
825 },1046 },
826 }));1047 }));
1048
1049 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1050 name: 'profile-genstream',
1051 callback: generateStreamCallback,
1052 returns: t`generated text`,
1053 namedArgumentList: [
1054 new SlashCommandNamedArgument(
1055 'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(),
1056 ),
1057 SlashCommandNamedArgument.fromProps({
1058 name: 'profile',
1059 description: t`connection profile ID to use for generation`,
1060 typeList: [ARGUMENT_TYPE.STRING],
1061 enumProvider: commonEnumProviders.connectionProfiles(),
1062 }),
1063 SlashCommandNamedArgument.fromProps({
1064 name: 'reasoning',
1065 description: t`include formatted reasoning in the output`,
1066 typeList: [ARGUMENT_TYPE.BOOLEAN],
1067 defaultValue: 'false',
1068 enumProvider: commonEnumProviders.boolean('trueFalse'),
1069 }),
1070 SlashCommandNamedArgument.fromProps({
1071 name: 'system',
1072 description: t`system prompt at the start`,
1073 typeList: [ARGUMENT_TYPE.STRING],
1074 }),
1075 SlashCommandNamedArgument.fromProps({
1076 name: 'length',
1077 description: t`API response length in tokens`,
1078 typeList: [ARGUMENT_TYPE.NUMBER],
1079 defaultValue: '2048',
1080 }),
1081 SlashCommandNamedArgument.fromProps({
1082 name: 'generating',
1083 description: t`label/title for the generation display`,
1084 typeList: [ARGUMENT_TYPE.STRING],
1085 defaultValue: 'Generating...',
1086 }),
1087 SlashCommandNamedArgument.fromProps({
1088 name: 'completed',
1089 description: t`updated label/title for when generation completes`,
1090 typeList: [ARGUMENT_TYPE.STRING],
1091 defaultValue: 'Generated',
1092 }),
1093 SlashCommandNamedArgument.fromProps({
1094 name: 'delay',
1095 description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`,
1096 typeList: [ARGUMENT_TYPE.NUMBER],
1097 defaultValue: '3000',
1098 enumList: [
1099 new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'),
1100 new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input),
1101 ],
1102 }),
1103 SlashCommandNamedArgument.fromProps({
1104 name: 'stop',
1105 description: t`show a stop button on the streaming display that aborts generation when clicked`,
1106 typeList: [ARGUMENT_TYPE.BOOLEAN],
1107 defaultValue: 'true',
1108 enumProvider: commonEnumProviders.boolean('trueFalse'),
1109 }),
1110 SlashCommandNamedArgument.fromProps({
1111 name: 'onStop',
1112 description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`,
1113 typeList: [ARGUMENT_TYPE.CLOSURE],
1114 }),
1115 SlashCommandNamedArgument.fromProps({
1116 name: 'onComplete',
1117 description: t`closure to execute after generation completes successfully`,
1118 typeList: [ARGUMENT_TYPE.CLOSURE],
1119 }),
1120 ],
1121 unnamedArgumentList: [
1122 SlashCommandArgument.fromProps({
1123 description: 'prompt',
1124 typeList: [ARGUMENT_TYPE.STRING],
1125 isRequired: true,
1126 }),
1127 ],
1128 helpString: `
1129 <div>
1130 ${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
1131 </div>
1132 <div>
1133 ${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
1134 </div>
1135 <div>
1136 ${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
1137 </div>
1138 <div>
1139 ${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.`}
1140 </div>
1141 <div>
1142 ${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.`}
1143 </div>
1144 <div>
1145 ${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
1146 </div>
1147 <div>
1148 ${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`}
1149 </div>
1150 <div>
1151 ${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`}
1152 </div>
1153 <div>
1154 ${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`}
1155 </div>
1156 `,
1157 }));
827}1158}
public/scripts/extensions/shared.js+24 -1
@@ -1,4 +1,4 @@
1import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';1import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js';
2import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';2import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
3import { t } from '../i18n.js';3import { t } from '../i18n.js';
4import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';4import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
@@ -544,6 +544,29 @@ export class ConnectionManagerRequestService {
544 }544 }
545545
546 /**546 /**
547 * Creates a model icon Image element for the given profile (or the currently selected profile).
548 * Returns null if the profile is not found, has no API, or Connection Manager is unavailable.
549 * @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile.
550 * @returns {HTMLImageElement | null}
551 */
552 static getProfileIcon(profileId) {
553 if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) {
554 return null;
555 }
556
557 const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile;
558 if (!id) return null;
559
560 try {
561 const profile = this.getProfile(id);
562 if (!profile?.api) return null;
563 return createModelIcon(profile.api, profile.model);
564 } catch {
565 return null;
566 }
567 }
568
569 /**
547 * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]570 * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
548 * @returns {boolean}571 * @returns {boolean}
549 */572 */
public/scripts/reasoning.js+68 -0
@@ -1011,6 +1011,44 @@ function registerReasoningSlashCommands() {
1011 },1011 },
1012 }));1012 }));
1013 SlashCommandParser.addCommandObject(SlashCommand.fromProps({1013 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1014 name: 'reasoning-format',
1015 aliases: ['format-reasoning'],
1016 returns: 'formatted string',
1017 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.`,
1018 namedArgumentList: [
1019 SlashCommandNamedArgument.fromProps({
1020 name: 'reasoning',
1021 description: 'The reasoning/thinking text to format',
1022 typeList: [ARGUMENT_TYPE.STRING],
1023 isRequired: true,
1024 }),
1025 ],
1026 unnamedArgumentList: [
1027 SlashCommandArgument.fromProps({
1028 description: 'The main content text',
1029 typeList: [ARGUMENT_TYPE.STRING],
1030 isRequired: false,
1031 }),
1032 ],
1033 callback: (args, value) => {
1034 const reasoning = String(args?.reasoning ?? '');
1035 const content = String(value ?? '');
1036
1037 if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
1038 toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Format`);
1039 return '';
1040 }
1041
1042 if (!reasoning) {
1043 toastr.warning(t`Reasoning argument is required.`, t`Reasoning Format`);
1044 return '';
1045 }
1046
1047 const { formatted } = formatReasoning(reasoning, content);
1048 return formatted;
1049 },
1050 }));
1051 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1014 name: 'reasoning-template',1052 name: 'reasoning-template',
1015 aliases: ['reasoning-formatting', 'reasoning-preset'],1053 aliases: ['reasoning-formatting', 'reasoning-preset'],
1016 callback: selectReasoningTemplateCallback,1054 callback: selectReasoningTemplateCallback,
@@ -1412,6 +1450,36 @@ export function parseReasoningFromString(str, { strict = true } = {}, template =
1412}1450}
14131451
1414/**1452/**
1453 * Formats reasoning and content into a string using the reasoning template.
1454 * This is the inverse of parseReasoningFromString.
1455 * @typedef {Object} FormattedReasoning
1456 * @property {string} formatted The formatted string with reasoning wrapped in prefix/suffix
1457 * @property {string} contentOnly The content without reasoning
1458 * @param {string} reasoning The reasoning/thinking text
1459 * @param {string} content The main content/response text
1460 * @param {ReasoningTemplate} [template=null] Optional template to use. Defaults to power_user.reasoning
1461 * @returns {FormattedReasoning} Object containing both formatted (reasoning + content) and contentOnly
1462 */
1463export function formatReasoning(reasoning, content, template = null) {
1464 template = template ?? power_user.reasoning;
1465
1466 // If no reasoning provided, return content only
1467 if (!reasoning || !template.prefix || !template.suffix) {
1468 return { formatted: content, contentOnly: content };
1469 }
1470
1471 // Substitute macros in template parts
1472 const prefix = substituteParams(template.prefix || '');
1473 const suffix = substituteParams(template.suffix || '');
1474 const separator = substituteParams(template.separator || '');
1475
1476 // Build the formatted string: prefix + reasoning + suffix + separator + content
1477 const formatted = `${prefix}${reasoning}${suffix}${separator}${content}`;
1478
1479 return { formatted, contentOnly: content };
1480}
1481
1482/**
1415 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.1483 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.
1416 * @param {string[]} swipes Array of swipe strings1484 * @param {string[]} swipes Array of swipe strings
1417 * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects1485 * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+5 -0
@@ -341,4 +341,9 @@ export const commonEnumProviders = {
341 backgrounds: () => Array.from(document.querySelectorAll('.bg_example'))341 backgrounds: () => Array.from(document.querySelectorAll('.bg_example'))
342 .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))342 .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))
343 .filter(it => it.value?.length),343 .filter(it => it.value?.length),
344
345 connectionProfiles: ({ includeNone = false } = {}) => () => [
346 ...includeNone ? [new SlashCommandEnumValue('<None>')] : [],
347 ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
348 ],
344};349};
public/scripts/streaming-display.js+430 -0
@@ -0,0 +1,430 @@
1/**
2 * A floating toast-like display panel for showing streaming LLM generation progress.
3 * Shows reasoning (thinking) and content as they stream in.
4 * Designed to work with ConnectionManagerRequestService streaming responses.
5 *
6 * Appends itself inside the topmost open `<dialog>` element (same approach as
7 * fixToastrForDialogs in popup.js) so it renders above modal overlays.
8 *
9 * @example
10 * const display = new StreamingDisplay();
11 * display.show({ label: 'Generating...' });
12 *
13 * for await (const chunk of streamGenerator) {
14 * display.updateReasoning(chunk.state?.reasoning)
15 * .updateContent(chunk.text);
16 * }
17 *
18 * display.complete('Generated Something'); // Mark as done (green LED, auto-hide if configured)
19 */
20
21import { SVGInject } from '../lib.js';
22import { t } from './i18n.js';
23import { animation_duration, messageFormatting } from '/script.js';
24
25/** CSS class prefix */
26const CSS_PREFIX = 'streaming-display';
27
28/**
29 * @typedef {Object} StreamingDisplayOptions
30 * @property {string} [label] - Header label (e.g. "Generating greeting...")
31 * @property {HTMLImageElement} [icon] - Optional API/model icon image (e.g. from createModelIcon). Will be SVG-injected when loaded.
32 * @property {(() => (void | Promise<void>)) | null} [onStop] - Optional stop handler. When provided, a stop button is shown. Clicking it invokes this handler only — the display is not automatically hidden or completed.
33 */
34
35export class StreamingDisplay {
36 /** @type {HTMLElement | null} */
37 #element = null;
38 /** @type {HTMLElement | null} */
39 #labelElement = null;
40 /** @type {HTMLElement | null} */
41 #labelText = null;
42 /** @type {HTMLElement | null} */
43 #reasoningSection = null;
44 /** @type {HTMLElement | null} */
45 #reasoningContent = null;
46 /** @type {HTMLElement | null} */
47 #textSection = null;
48 /** @type {HTMLElement | null} */
49 #textContent = null;
50 /** @type {HTMLButtonElement | null} */
51 #stopButton = null;
52 /** @type {HTMLButtonElement | null} */
53 #minimizeButton = null;
54 /** @type {HTMLButtonElement | null} */
55 #closeButton = null;
56 /** @type {(() => (void | Promise<void>)) | null} */
57 #onStop = null;
58 /** @type {HTMLElement | null} */
59 #ledIndicator = null;
60 /** @type {boolean} */
61 #hasContent = false;
62 /** @type {boolean} */
63 #isMinimized = false;
64 /** @type {boolean} */
65 #isComplete = false;
66 /** @type {boolean} */
67 #isStopped = false;
68 /** @type {ReturnType<typeof setTimeout> | null} */
69 #hideTimeoutId = null;
70
71 /**
72 * Shows the streaming display panel.
73 * @param {StreamingDisplayOptions} [options]
74 * @returns {StreamingDisplay} this instance for chaining
75 */
76 show({ label = '', icon = null, onStop = null } = {}) {
77 if (this.#element) this.hide({ instant: true });
78
79 this.#isMinimized = false;
80 this.#isComplete = false;
81 this.#onStop = onStop;
82 this.#clearHideTimeout();
83
84 this.#element = document.createElement('div');
85 this.#element.classList.add(CSS_PREFIX);
86
87 // Header label with LED indicator
88 this.#labelElement = document.createElement('div');
89 this.#labelElement.classList.add(`${CSS_PREFIX}-label`);
90
91 // LED status indicator (pulsing while streaming, green when complete)
92 this.#ledIndicator = document.createElement('span');
93 this.#ledIndicator.classList.add(`${CSS_PREFIX}-led`);
94 this.#labelElement.appendChild(this.#ledIndicator);
95
96 // Insert model icon into the label (after the LED)
97 if (icon instanceof HTMLImageElement) {
98 icon.classList.add(`${CSS_PREFIX}-icon`);
99 this.#labelElement.appendChild(icon);
100 icon.onload = async function () {
101 await SVGInject(icon);
102 };
103 }
104
105 this.#labelText = document.createElement('span');
106 this.#labelText.classList.add(`${CSS_PREFIX}-label-text`);
107 this.#labelText.textContent = label;
108 this.#labelElement.appendChild(this.#labelText);
109
110 // Window control buttons container
111 const controls = document.createElement('div');
112 controls.classList.add(`${CSS_PREFIX}-controls`);
113
114 // Stop button (only shown when an onStop handler is provided)
115 if (onStop) {
116 this.#stopButton = document.createElement('button');
117 this.#stopButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-stop`);
118 this.#stopButton.setAttribute('aria-label', t`Stop`);
119 this.#stopButton.setAttribute('title', t`Stop generation`);
120 this.#stopButton.innerHTML = '&#9632;'; // Black square ■
121 this.#stopButton.addEventListener('click', async () => {
122 // Disable immediately to prevent double-clicks and give instant feedback
123 if (this.#stopButton) {
124 this.#stopButton.disabled = true;
125 }
126 try {
127 await this.#onStop?.();
128 } catch (e) {
129 console.error('[StreamingDisplay] Error executing stop handler', e);
130 }
131 });
132 controls.appendChild(this.#stopButton);
133 }
134
135 // Minimize button
136 this.#minimizeButton = document.createElement('button');
137 this.#minimizeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-minimize`);
138 this.#minimizeButton.setAttribute('aria-label', t`Minimize`);
139 this.#minimizeButton.setAttribute('title', t`Minimize`);
140 this.#minimizeButton.innerHTML = '&#8211;'; // En dash
141 this.#minimizeButton.addEventListener('click', () => this.toggleMinimize());
142 controls.appendChild(this.#minimizeButton);
143
144 // Close button
145 this.#closeButton = document.createElement('button');
146 this.#closeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-close`);
147 this.#closeButton.setAttribute('aria-label', t`Close`);
148 this.#closeButton.setAttribute('title', t`Close (generation continues in background)`);
149 this.#closeButton.innerHTML = '&#215;'; // Multiplication sign (×)
150 this.#closeButton.addEventListener('click', () => this.hide());
151 controls.appendChild(this.#closeButton);
152
153 this.#labelElement.appendChild(controls);
154 this.#element.appendChild(this.#labelElement);
155
156 // Content container (for minimize functionality)
157 const contentContainer = document.createElement('div');
158 contentContainer.classList.add(`${CSS_PREFIX}-content`);
159
160 // Reasoning section (hidden until content arrives)
161 this.#reasoningSection = document.createElement('div');
162 this.#reasoningSection.classList.add(`${CSS_PREFIX}-reasoning`);
163 this.#reasoningSection.style.display = 'none';
164
165 const reasoningLabel = document.createElement('div');
166 reasoningLabel.classList.add(`${CSS_PREFIX}-reasoning-label`);
167 reasoningLabel.textContent = t`Thinking...`;
168 this.#reasoningSection.appendChild(reasoningLabel);
169
170 this.#reasoningContent = document.createElement('div');
171 this.#reasoningContent.classList.add(`${CSS_PREFIX}-reasoning-content`);
172 this.#reasoningSection.appendChild(this.#reasoningContent);
173
174 contentContainer.appendChild(this.#reasoningSection);
175
176 // Content section (hidden until content arrives)
177 this.#textSection = document.createElement('div');
178 this.#textSection.classList.add(`${CSS_PREFIX}-text`);
179 this.#textSection.style.display = 'none';
180
181 this.#textContent = document.createElement('div');
182 this.#textContent.classList.add(`${CSS_PREFIX}-text-content`, 'mes_text'); // Allow formatting based on how chat messages are formatted too
183 this.#textSection.appendChild(this.#textContent);
184
185 contentContainer.appendChild(this.#textSection);
186 this.#element.appendChild(contentContainer);
187
188 // Append inside the topmost open dialog (same pattern as fixToastrForDialogs in popup.js).
189 // Modal <dialog> elements live in the browser's top layer, so z-index alone won't work.
190 const target = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop() ?? document.body;
191 target.appendChild(this.#element);
192
193 // Trigger entrance animation on next frame
194 requestAnimationFrame(() => {
195 this.#element?.classList.add(`${CSS_PREFIX}-visible`);
196 });
197
198 return this;
199 }
200
201 /**
202 * Toggles the minimized state of the display.
203 * When minimized, only the header with label and buttons is shown.
204 * @returns {StreamingDisplay} this instance for chaining
205 */
206 toggleMinimize() {
207 if (!this.#element) return this;
208
209 this.#isMinimized = !this.#isMinimized;
210 this.#element.classList.toggle(`${CSS_PREFIX}-minimized`, this.#isMinimized);
211
212 // Update minimize button icon/appearance
213 if (this.#minimizeButton) {
214 this.#minimizeButton.innerHTML = this.#isMinimized ? '&#9633;' : '&#8211;'; // Square when minimized, dash when not
215 this.#minimizeButton.setAttribute('title', this.#isMinimized ? t`Restore` : t`Minimize`);
216 this.#minimizeButton.setAttribute('aria-label', this.#isMinimized ? t`Restore` : t`Minimize`);
217 }
218
219 return this;
220 }
221
222 /**
223 * @returns {boolean} Whether the display is currently minimized
224 */
225 get isMinimized() {
226 return this.#isMinimized;
227 }
228
229 /**
230 * @returns {boolean} Whether the display is marked as complete (generation finished)
231 */
232 get isComplete() {
233 return this.#isComplete;
234 }
235
236 /**
237 * @returns {boolean} Whether the display was stopped by the user
238 */
239 get isStopped() {
240 return this.#isStopped;
241 }
242
243 /**
244 * Updates the header label text.
245 * @param {string} label
246 * @returns {StreamingDisplay} this instance for chaining
247 */
248 setLabel(label) {
249 if (this.#labelText) {
250 this.#labelText.textContent = label;
251 }
252 return this;
253 }
254
255 /**
256 * Updates the reasoning (thinking) section with new text.
257 * Automatically shows the reasoning section when text is provided.
258 * @param {string} text - Accumulated reasoning text
259 * @returns {StreamingDisplay} this instance for chaining
260 */
261 updateReasoning(text) {
262 if (!this.#reasoningContent || !this.#reasoningSection || !text) return this;
263
264 this.#reasoningSection.style.display = '';
265 this.#reasoningContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, true);
266 this.#reasoningContent.scrollTop = this.#reasoningContent.scrollHeight;
267 return this;
268 }
269
270 /**
271 * Updates the main content section with new text.
272 * Automatically shows the content section when text is provided (including empty string).
273 * @param {string|null|undefined} text - Accumulated content text
274 * @returns {StreamingDisplay} this instance for chaining
275 */
276 updateContent(text) {
277 if (!this.#textContent || !this.#textSection || !text) return this;
278
279 this.#hasContent = true;
280 this.#textSection.style.display = '';
281 this.#textContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, false);
282 this.#textContent.scrollTop = this.#textContent.scrollHeight;
283 return this;
284 }
285
286 /** @returns {boolean} Whether any content text has been displayed via streaming */
287 get hasContent() {
288 return this.#hasContent;
289 }
290
291 /**
292 * Marks the generation as stopped by the user.
293 *
294 * Changes the LED indicator to solid red, removes the stop button, and keeps the display
295 * visible until the user manually closes it with the close button (no auto-hide).
296 *
297 * @param {Object} [options={}]
298 * @param {string|null} [options.label=null] - Optional label override (e.g. `'Generating... [Stopped]'`).
299 * @returns {StreamingDisplay} this instance for chaining
300 */
301 markStopped({ label = null } = {}) {
302 if (!this.#element || this.#isStopped || this.#isComplete) return this;
303
304 this.#isStopped = true;
305 this.#clearHideTimeout();
306 this.#element.classList.add(`${CSS_PREFIX}-stopped`);
307
308 // Remove the stop button — nothing left to stop
309 if (this.#stopButton) {
310 this.#stopButton.remove();
311 this.#stopButton = null;
312 }
313
314 if (label !== null) {
315 this.setLabel(label);
316 }
317
318 return this;
319 }
320
321 /**
322 * Marks the generation as complete and initiates cleanup. Optionally set a new label.
323 *
324 * This is the **preferred method** to call after streaming ends. It:
325 * - Changes the LED indicator from pulsing orange to solid green
326 * - Waits for the specified delay to let the user see the final result
327 * - Then hides the display with a fade-out animation
328 *
329 * @param {Object} [options={}]
330 * @param {string|null} [options.label=null] - Set the label automatically to a new one to display the completed state.
331 * @param {number|null} [options.delay=3000] - Delay in ms before hiding. Use `null` or negative value to keep displayed until user manually closes it.
332 * @returns {StreamingDisplay} this instance for chaining
333 */
334 complete({ label = null, delay = 3000 } = {}) {
335 if (!this.#element || this.#isComplete) return this;
336
337 this.#isComplete = true;
338 this.#element.classList.add(`${CSS_PREFIX}-complete`);
339
340 // Clear any existing hide timeout
341 this.#clearHideTimeout();
342
343 if (this.#stopButton) {
344 this.#stopButton.remove();
345 this.#stopButton = null;
346 }
347 if (label !== null) {
348 this.setLabel(label);
349 }
350
351 // Auto-hide after delay if specified (positive number)
352 if (typeof delay === 'number' && delay >= 0) {
353 this.#hideTimeoutId = setTimeout(() => {
354 this.#performHide();
355 }, delay);
356 }
357
358 return this;
359 }
360
361 /**
362 * Immediately hides and removes the streaming display.
363 *
364 * **Note:** This is for immediate cleanup (e.g., when canceling generation
365 * or closing the app). Prefer `complete()` when generation finishes normally,
366 * as it shows the green LED and gives the user time to see the final result.
367 *
368 * @param {Object} [options={}]
369 * @param {boolean} [options.instant=false] - Skip the fade-out animation
370 * @returns {StreamingDisplay} this instance for chaining
371 */
372 hide({ instant = false } = {}) {
373 this.#clearHideTimeout();
374 this.#performHide({ instant });
375 return this;
376 }
377
378 /**
379 * Clears any pending auto-hide timeout.
380 */
381 #clearHideTimeout() {
382 if (this.#hideTimeoutId !== null) {
383 clearTimeout(this.#hideTimeoutId);
384 this.#hideTimeoutId = null;
385 }
386 }
387
388 /**
389 * Internal method to actually remove the DOM element.
390 * @param {Object} [options={}]
391 * @param {boolean} [options.instant=false]
392 */
393 #performHide({ instant = false } = {}) {
394 if (!this.#element) return;
395
396 const el = this.#element;
397
398 // Clear all private fields
399 this.#element = null;
400 this.#labelElement = null;
401 this.#labelText = null;
402 this.#reasoningSection = null;
403 this.#reasoningContent = null;
404 this.#textSection = null;
405 this.#textContent = null;
406 this.#stopButton = null;
407 this.#minimizeButton = null;
408 this.#closeButton = null;
409 this.#ledIndicator = null;
410 this.#onStop = null;
411 this.#hasContent = false;
412 this.#isMinimized = false;
413 this.#isComplete = false;
414 this.#isStopped = false;
415 this.#hideTimeoutId = null;
416
417 if (instant) {
418 el.remove();
419 return;
420 }
421
422 el.classList.remove(`${CSS_PREFIX}-visible`);
423 const duration = animation_duration;
424 if (duration > 0) {
425 setTimeout(() => el.remove(), duration);
426 } else {
427 el.remove();
428 }
429 }
430}
public/style.css+1 -0
@@ -15,6 +15,7 @@
15@import url(css/secrets.css);15@import url(css/secrets.css);
16@import url(css/backgrounds.css);16@import url(css/backgrounds.css);
17@import url(css/chat-backups.css);17@import url(css/chat-backups.css);
18@import url(css/streaming-display.css);
1819
19:root {20:root {
20 interpolate-size: allow-keywords;21 interpolate-size: allow-keywords;