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
19101910}
19111911
19121912/**
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+ */
1921+export 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+/**
19131930 * Inserts or replaces an SVG icon adjacent to the provided message's timestamp.
19141931 *
19151932 * @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced.
19161933 * @param {ChatMessageExtra} extra - Contains the API and model details.
19171934 */
19181935function insertSVGIcon(mes, extra) {
1919- // Determine the SVG filename
1936+ const apiName = extra?.api || '';
1920- let modelName = extra?.api || '';
19211937
1922- // If there's no API information, we can't determine which SVG to use
1938+ if (!apiName) {
1923- if (!modelName) {
19241939 return;
19251940 }
19261941
@@ -1937,16 +1952,14 @@ function insertSVGIcon(mes, extra) {
19371952 };
19381953 };
19391954
19401955 const createModelImageinsertIcon = (className, targetSelector, insertBefore) => {
19411956 const image = new ImagecreateModelIcon(apiName, extra?.model);
19421957 image.classList.add('icon-svg', className);
1943- image.src = `/img/${modelName}.svg`;
1944- image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
19451958 insertOrReplaceSVG(image, className, targetSelector, insertBefore);
19461959 };
19471960
19481961 createModelImageinsertIcon('timestamp-icon', '.timestamp');
19491962 createModelImageinsertIcon('thinking-icon', '.mes_reasoning_header_title', true);
19501963}
19511964
19521965/**
public/scripts/extensions/connection-manager/index.js+334 -3
@@ -1,7 +1,7 @@
11import { DOMPurify, Fuse } from '../../../lib.js';
22
33import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
44import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
55import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
66import { SlashCommand } from '../../slash-commands/SlashCommand.js';
77import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
@@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
99import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1010import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
1111import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
12+import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
1213import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1314import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
1415import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
1516import { t } from '../../i18n.js';
1617import { getSecretLabelById } from '../../secrets.js';
18+import { performFuzzySearch } from '/scripts/power-user.js';
19+import { StreamingDisplay } from '/scripts/streaming-display.js';
20+import { ConnectionManagerRequestService } from '../shared.js';
21+import { formatReasoning } from '/scripts/reasoning.js';
1722
1823const MODULE_NAME = 'connection-manager';
1924const NONE = '<None>';
@@ -474,6 +479,222 @@ async function renderDetailsContent(detailsContent) {
474479 }
475480}
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+ */
489+async 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+
477698export async function init() {
478699 extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
479700
@@ -824,4 +1045,114 @@ export async function init() {
8241045 return JSON.stringify(profile);
8251046 },
8261047 }));
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+ }));
8271158}
public/scripts/extensions/shared.js+24 -1
@@ -1,4 +1,4 @@
11import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js';
22import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
33import { t } from '../i18n.js';
44import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
@@ -544,6 +544,29 @@ export class ConnectionManagerRequestService {
544544 }
545545
546546 /**
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+ /**
547570 * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
548571 * @returns {boolean}
549572 */
public/scripts/reasoning.js+68 -0
@@ -1011,6 +1011,44 @@ function registerReasoningSlashCommands() {
10111011 },
10121012 }));
10131013 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({
10141052 name: 'reasoning-template',
10151053 aliases: ['reasoning-formatting', 'reasoning-preset'],
10161054 callback: selectReasoningTemplateCallback,
@@ -1412,6 +1450,36 @@ export function parseReasoningFromString(str, { strict = true } = {}, template =
14121450}
14131451
14141452/**
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+ */
1463+export 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+/**
14151483 * Parse reasoning in an array of swipe strings if auto-parsing is enabled.
14161484 * @param {string[]} swipes Array of swipe strings
14171485 * @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 = {
341341 backgrounds: () => Array.from(document.querySelectorAll('.bg_example'))
342342 .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))
343343 .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+ ],
344349};
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+
21+import { SVGInject } from '../lib.js';
22+import { t } from './i18n.js';
23+import { animation_duration, messageFormatting } from '/script.js';
24+
25+/** CSS class prefix */
26+const CSS_PREFIX = 'streaming-display';
27+
28+/**
29+ * @typedef {Object} StreamingDisplayOptions
30+ * @property {string} [label] - Header label (e.g. "Generating greeting...")
31+ * @property {HTMLImageElement} [icon] - Optional API/model icon image (e.g. from createModelIcon). Will be SVG-injected when loaded.
32+ * @property {(() => (void | Promise<void>)) | null} [onStop] - Optional stop handler. When provided, a stop button is shown. Clicking it invokes this handler only — the display is not automatically hidden or completed.
33+ */
34+
35+export class StreamingDisplay {
36+ /** @type {HTMLElement | null} */
37+ #element = null;
38+ /** @type {HTMLElement | null} */
39+ #labelElement = null;
40+ /** @type {HTMLElement | null} */
41+ #labelText = null;
42+ /** @type {HTMLElement | null} */
43+ #reasoningSection = null;
44+ /** @type {HTMLElement | null} */
45+ #reasoningContent = null;
46+ /** @type {HTMLElement | null} */
47+ #textSection = null;
48+ /** @type {HTMLElement | null} */
49+ #textContent = null;
50+ /** @type {HTMLButtonElement | null} */
51+ #stopButton = null;
52+ /** @type {HTMLButtonElement | null} */
53+ #minimizeButton = null;
54+ /** @type {HTMLButtonElement | null} */
55+ #closeButton = null;
56+ /** @type {(() => (void | Promise<void>)) | null} */
57+ #onStop = null;
58+ /** @type {HTMLElement | null} */
59+ #ledIndicator = null;
60+ /** @type {boolean} */
61+ #hasContent = false;
62+ /** @type {boolean} */
63+ #isMinimized = false;
64+ /** @type {boolean} */
65+ #isComplete = false;
66+ /** @type {boolean} */
67+ #isStopped = false;
68+ /** @type {ReturnType<typeof setTimeout> | null} */
69+ #hideTimeoutId = null;
70+
71+ /**
72+ * Shows the streaming display panel.
73+ * @param {StreamingDisplayOptions} [options]
74+ * @returns {StreamingDisplay} this instance for chaining
75+ */
76+ show({ label = '', icon = null, onStop = null } = {}) {
77+ if (this.#element) this.hide({ instant: true });
78+
79+ this.#isMinimized = false;
80+ this.#isComplete = false;
81+ this.#onStop = onStop;
82+ this.#clearHideTimeout();
83+
84+ this.#element = document.createElement('div');
85+ this.#element.classList.add(CSS_PREFIX);
86+
87+ // Header label with LED indicator
88+ this.#labelElement = document.createElement('div');
89+ this.#labelElement.classList.add(`${CSS_PREFIX}-label`);
90+
91+ // LED status indicator (pulsing while streaming, green when complete)
92+ this.#ledIndicator = document.createElement('span');
93+ this.#ledIndicator.classList.add(`${CSS_PREFIX}-led`);
94+ this.#labelElement.appendChild(this.#ledIndicator);
95+
96+ // Insert model icon into the label (after the LED)
97+ if (icon instanceof HTMLImageElement) {
98+ icon.classList.add(`${CSS_PREFIX}-icon`);
99+ this.#labelElement.appendChild(icon);
100+ icon.onload = async function () {
101+ await SVGInject(icon);
102+ };
103+ }
104+
105+ this.#labelText = document.createElement('span');
106+ this.#labelText.classList.add(`${CSS_PREFIX}-label-text`);
107+ this.#labelText.textContent = label;
108+ this.#labelElement.appendChild(this.#labelText);
109+
110+ // Window control buttons container
111+ const controls = document.createElement('div');
112+ controls.classList.add(`${CSS_PREFIX}-controls`);
113+
114+ // Stop button (only shown when an onStop handler is provided)
115+ if (onStop) {
116+ this.#stopButton = document.createElement('button');
117+ this.#stopButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-stop`);
118+ this.#stopButton.setAttribute('aria-label', t`Stop`);
119+ this.#stopButton.setAttribute('title', t`Stop generation`);
120+ this.#stopButton.innerHTML = '&#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 @@
1515@import url(css/secrets.css);
1616@import url(css/backgrounds.css);
1717@import url(css/chat-backups.css);
18+@import url(css/streaming-display.css);
1819
1920:root {
2021 interpolate-size: allow-keywords;