Add Streaming Display Utility and New Generation Slash Commands (/genstream, /reasoning-format) (#5438)

* Add StreamingDisplay class for live LLM generation output with floating toast panel

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix lint

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

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
Wolfsblvt
2026-04-15 20:38:13 +02:00
committed by GitHub
parent 4d67a6986b
commit 64c96e895c
8 changed files with 1122 additions and 15 deletions
+236
View File
@@ -0,0 +1,236 @@
/* ─────────────────────────────────────────────────────────────────────────────
Streaming Display — floating toast panel for live LLM generation output.
Shows reasoning (thinking) and content as they stream in.
Used by extensions that leverage ConnectionManagerRequestService streaming.
───────────────────────────────────────────────────────────────────────────── */
.streaming-display {
position: fixed;
bottom: max(calc(var(--bottomFormBlockSize) + 5px), 20px);
right: 20px;
width: min(550px, calc(100vw - 40px));
max-height: 70vh;
background: var(--SmartThemeBlurTintColor);
border: 1px solid var(--SmartThemeBorderColor);
border-radius: 10px;
padding: 14px;
z-index: 9000;
display: flex;
flex-direction: column;
gap: 10px;
opacity: 0;
transform: translateY(20px);
transition: opacity var(--animation-duration, 125ms) ease, transform var(--animation-duration, 125ms) ease;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(12px);
}
.streaming-display-visible {
opacity: 1;
transform: translateY(0);
}
/* Header label with animated activity indicator */
.streaming-display-label {
font-weight: 600;
font-size: 0.95em;
color: var(--SmartThemeBodyColor);
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
/* LED status indicator - pulsing while streaming */
.streaming-display-led {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(225, 138, 36);
animation: streaming-display-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
/* Completed state: solid green LED */
.streaming-display-complete .streaming-display-led {
background: #4caf50;
animation: none;
opacity: 1;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
}
/* Stopped state: solid red LED */
.streaming-display-stopped .streaming-display-led {
background: #f44336;
animation: none;
opacity: 1;
box-shadow: 0 0 8px rgba(244, 67, 54, 0.6);
}
@keyframes streaming-display-pulse {
0%, 100% { opacity: 0.4; transform: scale(0.9); }
50% { opacity: 1; transform: scale(1.1); }
}
/* Label text takes available space */
.streaming-display-label-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Window control buttons container */
.streaming-display-controls {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
/* Window control buttons */
.streaming-display-btn {
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--SmartThemeBodyColor);
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.6;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.streaming-display-btn:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
.streaming-display-btn-close:hover {
background-color: rgba(244, 67, 54, 0.2);
}
.streaming-display-btn-stop {
font-size: 10px;
}
.streaming-display-btn-stop:hover {
background-color: rgba(244, 150, 36, 0.2);
color: rgb(225, 138, 36);
}
/* Content container - collapsible for minimize */
.streaming-display-content {
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
transition: max-height var(--animation-duration, 125ms) ease, opacity var(--animation-duration, 125ms) ease;
}
/* Minimized state - hide content sections */
.streaming-display-minimized .streaming-display-content {
max-height: 0;
opacity: 0;
}
.streaming-display-minimized {
gap: 0;
}
/* Model/API icon in the label */
.streaming-display-icon {
width: 1.1em;
height: 1.1em;
flex-shrink: 0;
}
/* Minimized state adjustments */
.streaming-display-minimized.streaming-display {
padding: 10px 14px;
}
/* Reasoning (thinking) section */
.streaming-display-reasoning {
background: color-mix(in srgb, var(--SmartThemeBodyColor) 5%, transparent);
border-radius: 6px;
padding: 8px 10px;
border-left: 3px solid color-mix(in srgb, var(--SmartThemeBodyColor) 25%, transparent);
}
.streaming-display-reasoning-label {
font-size: 0.8em;
font-weight: 600;
opacity: 0.5;
margin-bottom: 4px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.streaming-display-reasoning-content {
font-size: 0.82em;
opacity: 0.65;
max-height: 25vh;
overflow-y: auto;
word-break: break-word;
line-height: 1.45;
scrollbar-width: thin;
}
.streaming-display-reasoning-content p {
margin: 0.3em 0;
}
.streaming-display-reasoning-content p:first-child {
margin-top: 0;
}
.streaming-display-reasoning-content p:last-child {
margin-bottom: 0;
}
/* Main content section */
.streaming-display-text {
border-top: 1px solid color-mix(in srgb, var(--SmartThemeBorderColor) 50%, transparent);
padding-top: 8px;
min-height: 1.5em;
}
.streaming-display-text-content {
font-size: 0.9em;
color: var(--SmartThemeBodyColor);
max-height: 40vh;
overflow-y: auto;
word-break: break-word;
line-height: 1.5;
scrollbar-width: thin;
}
.streaming-display-text-content p {
margin: 0.4em 0;
}
.streaming-display-text-content p:first-child {
margin-top: 0;
}
.streaming-display-text-content p:last-child {
margin-bottom: 0;
}
/* Strip auto-added quotes from <q> tags, as message formatting adds them */
.streaming-display-reasoning-content q:before,
.streaming-display-reasoning-content q:after,
.streaming-display-text-content q:before,
.streaming-display-text-content q:after {
content: '';
}
+24 -11
View File
@@ -1909,6 +1909,23 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
return mes;
}
/**
* Creates an Image element for the given API/model icon.
* The image references the matching SVG file from `/img/` and includes a tooltip with API and model info.
* The caller is responsible for appending the image to the DOM and optionally calling `SVGInject` on it.
*
* @param {string} apiName - API identifier matching an SVG file in /img/ (e.g. 'openai', 'openrouter', 'claude')
* @param {string} [modelName=''] - Model name shown in the tooltip
* @returns {HTMLImageElement} The image element (not yet in the DOM)
*/
export function createModelIcon(apiName, modelName = '') {
const image = new Image();
image.classList.add('icon-svg');
image.src = `/img/${apiName}.svg`;
image.title = modelName ? `${apiName} - ${modelName}` : apiName;
return image;
}
/**
* Inserts or replaces an SVG icon adjacent to the provided message's timestamp.
*
@@ -1916,11 +1933,9 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
* @param {ChatMessageExtra} extra - Contains the API and model details.
*/
function insertSVGIcon(mes, extra) {
// Determine the SVG filename
let modelName = extra?.api || '';
const apiName = extra?.api || '';
// If there's no API information, we can't determine which SVG to use
if (!modelName) {
if (!apiName) {
return;
}
@@ -1937,16 +1952,14 @@ function insertSVGIcon(mes, extra) {
};
};
const createModelImage = (className, targetSelector, insertBefore) => {
const image = new Image();
image.classList.add('icon-svg', className);
image.src = `/img/${modelName}.svg`;
image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`;
const insertIcon = (className, targetSelector, insertBefore) => {
const image = createModelIcon(apiName, extra?.model);
image.classList.add(className);
insertOrReplaceSVG(image, className, targetSelector, insertBefore);
};
createModelImage('timestamp-icon', '.timestamp');
createModelImage('thinking-icon', '.mes_reasoning_header_title', true);
insertIcon('timestamp-icon', '.timestamp');
insertIcon('thinking-icon', '.mes_reasoning_header_title', true);
}
/**
@@ -1,7 +1,7 @@
import { DOMPurify, Fuse } from '../../../lib.js';
import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
@@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
import { t } from '../../i18n.js';
import { getSecretLabelById } from '../../secrets.js';
import { performFuzzySearch } from '/scripts/power-user.js';
import { StreamingDisplay } from '/scripts/streaming-display.js';
import { ConnectionManagerRequestService } from '../shared.js';
import { formatReasoning } from '/scripts/reasoning.js';
const MODULE_NAME = 'connection-manager';
const NONE = '<None>';
@@ -474,6 +479,222 @@ async function renderDetailsContent(detailsContent) {
}
}
/**
* Callback for the /profile-genstream command
* Generates text using Connection Manager with streaming display support.
* @param {object} args Named arguments
* @param {string} value Unnamed argument (the prompt)
* @returns {Promise<string>} The generated text, optionally with formatted reasoning
*/
async function generateStreamCallback(args, value) {
if (!value) {
console.warn('WARN: No argument provided for /profile-genstream command');
return '';
}
// Check if Connection Manager is available
const context = getContext();
if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
toastr.error(t`Connection Manager is required for /profile-genstream. Use /gen or /genraw instead.`);
return '';
}
const profileIdOrName = args?.profile;
const includeReasoning = isTrueBoolean(args?.reasoning);
const systemPrompt = typeof args?.system == 'string' ? args.system : '';
const maxTokens = Number(args?.length ?? 2048) || 2048;
const lock = isTrueBoolean(args?.lock);
const generatingLabel = typeof args?.generating === 'string' ? args.generating : 'Generating...';
const completedLabel = typeof args?.completed === 'string' ? args.completed : 'Generated';
const enableStop = !isFalseBoolean(args?.stop);
const onStopClosure = args?.onStop instanceof SlashCommandClosure ? args.onStop : null;
const onCompleteClosure = args?.onComplete instanceof SlashCommandClosure ? args.onComplete : null;
// Parse delay: 'infinite' or negative = null (stay open), number = delay in ms
let completeDelay = 3000; // Default 3 seconds
if (args?.delay !== undefined) {
if (typeof args.delay === 'string' && args.delay.toLowerCase() === 'infinite') {
completeDelay = null; // Stay until user closes
} else {
const parsed = Number(args.delay);
if (!isNaN(parsed) && parsed >= 0) {
completeDelay = parsed;
} else if (!isNaN(parsed) && parsed < 0) {
completeDelay = null; // Negative = infinite
}
}
}
// Create abort controller for stop functionality (when stop is enabled)
const abortController = enableStop ? new AbortController() : null;
// Compose the stop handler: abort the request + optionally invoke user closure
const onStopHandler = enableStop ? async () => {
abortController.abort();
if (onStopClosure) {
try {
const localClosure = onStopClosure.getCopy();
localClosure.onProgress = () => { };
await localClosure.execute();
} catch (e) {
console.error('[GenStream] Error executing onStop closure', e);
}
}
} : null;
try {
if (lock) {
deactivateSendButtons();
}
// Determine which profile to use
// Use the currently selected profile if no profile specified
let effectiveProfileId = context.extensionSettings.connectionManager.selectedProfile;
const profiles = context.extensionSettings.connectionManager.profiles;
if (profileIdOrName) {
// Use try to find profile by id first, then fuse search
const profile = profiles.find(p => p.id === profileIdOrName);
if (profile) {
effectiveProfileId = profile.id;
} else {
const keys = [
{ name: 'name', weight: 10 },
];
const fuseResults = performFuzzySearch('profile', profiles, keys, profileIdOrName);
if (fuseResults.length > 0) {
effectiveProfileId = fuseResults[0].item.id;
} else {
toastr.warning(t`Connection profile not found: ${profileIdOrName}`);
return '';
}
}
}
if (!effectiveProfileId) {
toastr.error(t`No connection profile specified or selected. Use profile= argument or select a profile in Connection Manager.`);
return '';
}
// Create streaming display
const display = new StreamingDisplay();
display.show({
label: generatingLabel,
icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
onStop: onStopHandler,
});
const messages = [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
{ role: 'user', content: value },
];
let finalText = '';
let finalReasoning = '';
/** Gets the final (if requested, formatted) text to return for this command @returns {string} */
function buildResultText() {
// Format output with reasoning if requested
if (includeReasoning && finalReasoning) {
const { formatted } = formatReasoning(finalReasoning, finalText);
return formatted;
}
return finalText;
}
try {
// Attempt streaming first
const streamResponse = await ConnectionManagerRequestService.sendRequest(
effectiveProfileId,
messages,
maxTokens,
{ extractData: true, includePreset: true, stream: true, signal: abortController?.signal ?? undefined },
);
if (typeof streamResponse === 'function') {
const generator = streamResponse();
for await (const chunk of generator) {
finalText = chunk.text;
finalReasoning = chunk.state?.reasoning || '';
display.updateReasoning(finalReasoning);
display.updateContent(finalText);
}
} else {
// Non-streaming fallback within the try block
const extracted = streamResponse;
finalText = extracted?.content || '';
finalReasoning = extracted?.reasoning || '';
if (finalReasoning) {
display.updateReasoning(finalReasoning);
}
display.updateContent(finalText);
}
} catch (error) {
// If the user clicked stop, don't retry — show stopped state and return empty
if (abortController?.signal?.aborted) {
display.markStopped({ label: `${generatingLabel} [Stopped]` });
return buildResultText();
}
console.warn('[Slash Commands] Streaming failed, falling back to non-streaming:', error);
display.hide({ instant: true });
// Retry with non-streaming
const response = await ConnectionManagerRequestService.sendRequest(
effectiveProfileId,
messages,
maxTokens,
{ extractData: true, includePreset: true, stream: false },
);
const extracted = /** @type {import('../../custom-request.js').ExtractedData} */ (response);
finalText = extracted?.content || '';
finalReasoning = extracted?.reasoning || '';
// Show quick non-streaming display
display.show({
label: generatingLabel,
icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
});
if (finalReasoning) {
display.updateReasoning(finalReasoning);
}
display.updateContent(finalText);
}
// Mark as complete with delay (null = stay open until user closes)
display.complete({ label: completedLabel, delay: completeDelay });
// Invoke onComplete closure if provided
if (onCompleteClosure) {
try {
const localClosure = onCompleteClosure.getCopy();
localClosure.onProgress = () => { };
await localClosure.execute();
} catch (e) {
console.error('[GenStream] Error executing onComplete closure', e);
}
}
if (!finalText) {
toastr.warning(t`Generation returned empty result`);
return '';
}
return buildResultText();
} catch (err) {
console.error('Error on /genstream generation', err);
toastr.error(err.message, t`API Error`, { preventDuplicates: true });
return '';
} finally {
if (lock) {
activateSendButtons();
}
}
}
export async function init() {
extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
@@ -824,4 +1045,114 @@ export async function init() {
return JSON.stringify(profile);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-genstream',
callback: generateStreamCallback,
returns: t`generated text`,
namedArgumentList: [
new SlashCommandNamedArgument(
'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(),
),
SlashCommandNamedArgument.fromProps({
name: 'profile',
description: t`connection profile ID to use for generation`,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.connectionProfiles(),
}),
SlashCommandNamedArgument.fromProps({
name: 'reasoning',
description: t`include formatted reasoning in the output`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'false',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
SlashCommandNamedArgument.fromProps({
name: 'system',
description: t`system prompt at the start`,
typeList: [ARGUMENT_TYPE.STRING],
}),
SlashCommandNamedArgument.fromProps({
name: 'length',
description: t`API response length in tokens`,
typeList: [ARGUMENT_TYPE.NUMBER],
defaultValue: '2048',
}),
SlashCommandNamedArgument.fromProps({
name: 'generating',
description: t`label/title for the generation display`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'Generating...',
}),
SlashCommandNamedArgument.fromProps({
name: 'completed',
description: t`updated label/title for when generation completes`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'Generated',
}),
SlashCommandNamedArgument.fromProps({
name: 'delay',
description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`,
typeList: [ARGUMENT_TYPE.NUMBER],
defaultValue: '3000',
enumList: [
new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'),
new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input),
],
}),
SlashCommandNamedArgument.fromProps({
name: 'stop',
description: t`show a stop button on the streaming display that aborts generation when clicked`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
SlashCommandNamedArgument.fromProps({
name: 'onStop',
description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`,
typeList: [ARGUMENT_TYPE.CLOSURE],
}),
SlashCommandNamedArgument.fromProps({
name: 'onComplete',
description: t`closure to execute after generation completes successfully`,
typeList: [ARGUMENT_TYPE.CLOSURE],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'prompt',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
}),
],
helpString: `
<div>
${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
</div>
<div>
${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
</div>
<div>
${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
</div>
<div>
${t`Use delay to control auto-hide behavior: number (ms), "infinite", or negative to keep the display open until manually closed. The display shows a green LED when complete.`}
</div>
<div>
${t`A stop button is shown by default (stop=true). Click it to abort generation and return whatever was streamed so far. Use stop=false to hide the stop button.`}
</div>
<div>
${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
</div>
<div>
${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`}
</div>
<div>
${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`}
</div>
<div>
${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`}
</div>
`,
}));
}
+24 -1
View File
@@ -1,4 +1,4 @@
import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';
import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js';
import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
import { t } from '../i18n.js';
import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
@@ -543,6 +543,29 @@ export class ConnectionManagerRequestService {
return profile;
}
/**
* Creates a model icon Image element for the given profile (or the currently selected profile).
* Returns null if the profile is not found, has no API, or Connection Manager is unavailable.
* @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile.
* @returns {HTMLImageElement | null}
*/
static getProfileIcon(profileId) {
if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) {
return null;
}
const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile;
if (!id) return null;
try {
const profile = this.getProfile(id);
if (!profile?.api) return null;
return createModelIcon(profile.api, profile.model);
} catch {
return null;
}
}
/**
* @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
* @returns {boolean}
+68
View File
@@ -1010,6 +1010,44 @@ function registerReasoningSlashCommands() {
: parsedReasoning.reasoning;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'reasoning-format',
aliases: ['format-reasoning'],
returns: 'formatted string',
helpString: t`Formats reasoning and content into a single string using Reasoning Formatting settings. Useful for preparing text that can be parsed with /reasoning-parse.`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'reasoning',
description: 'The reasoning/thinking text to format',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'The main content text',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: false,
}),
],
callback: (args, value) => {
const reasoning = String(args?.reasoning ?? '');
const content = String(value ?? '');
if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) {
toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Format`);
return '';
}
if (!reasoning) {
toastr.warning(t`Reasoning argument is required.`, t`Reasoning Format`);
return '';
}
const { formatted } = formatReasoning(reasoning, content);
return formatted;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'reasoning-template',
aliases: ['reasoning-formatting', 'reasoning-preset'],
@@ -1411,6 +1449,36 @@ export function parseReasoningFromString(str, { strict = true } = {}, template =
}
}
/**
* Formats reasoning and content into a string using the reasoning template.
* This is the inverse of parseReasoningFromString.
* @typedef {Object} FormattedReasoning
* @property {string} formatted The formatted string with reasoning wrapped in prefix/suffix
* @property {string} contentOnly The content without reasoning
* @param {string} reasoning The reasoning/thinking text
* @param {string} content The main content/response text
* @param {ReasoningTemplate} [template=null] Optional template to use. Defaults to power_user.reasoning
* @returns {FormattedReasoning} Object containing both formatted (reasoning + content) and contentOnly
*/
export function formatReasoning(reasoning, content, template = null) {
template = template ?? power_user.reasoning;
// If no reasoning provided, return content only
if (!reasoning || !template.prefix || !template.suffix) {
return { formatted: content, contentOnly: content };
}
// Substitute macros in template parts
const prefix = substituteParams(template.prefix || '');
const suffix = substituteParams(template.suffix || '');
const separator = substituteParams(template.separator || '');
// Build the formatted string: prefix + reasoning + suffix + separator + content
const formatted = `${prefix}${reasoning}${suffix}${separator}${content}`;
return { formatted, contentOnly: content };
}
/**
* Parse reasoning in an array of swipe strings if auto-parsing is enabled.
* @param {string[]} swipes Array of swipe strings
@@ -341,4 +341,9 @@ export const commonEnumProviders = {
backgrounds: () => Array.from(document.querySelectorAll('.bg_example'))
.map(it => new SlashCommandEnumValue(it.getAttribute('bgfile')))
.filter(it => it.value?.length),
connectionProfiles: ({ includeNone = false } = {}) => () => [
...includeNone ? [new SlashCommandEnumValue('<None>')] : [],
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
],
};
+430
View File
@@ -0,0 +1,430 @@
/**
* A floating toast-like display panel for showing streaming LLM generation progress.
* Shows reasoning (thinking) and content as they stream in.
* Designed to work with ConnectionManagerRequestService streaming responses.
*
* Appends itself inside the topmost open `<dialog>` element (same approach as
* fixToastrForDialogs in popup.js) so it renders above modal overlays.
*
* @example
* const display = new StreamingDisplay();
* display.show({ label: 'Generating...' });
*
* for await (const chunk of streamGenerator) {
* display.updateReasoning(chunk.state?.reasoning)
* .updateContent(chunk.text);
* }
*
* display.complete('Generated Something'); // Mark as done (green LED, auto-hide if configured)
*/
import { SVGInject } from '../lib.js';
import { t } from './i18n.js';
import { animation_duration, messageFormatting } from '/script.js';
/** CSS class prefix */
const CSS_PREFIX = 'streaming-display';
/**
* @typedef {Object} StreamingDisplayOptions
* @property {string} [label] - Header label (e.g. "Generating greeting...")
* @property {HTMLImageElement} [icon] - Optional API/model icon image (e.g. from createModelIcon). Will be SVG-injected when loaded.
* @property {(() => (void | Promise<void>)) | null} [onStop] - Optional stop handler. When provided, a stop button is shown. Clicking it invokes this handler only — the display is not automatically hidden or completed.
*/
export class StreamingDisplay {
/** @type {HTMLElement | null} */
#element = null;
/** @type {HTMLElement | null} */
#labelElement = null;
/** @type {HTMLElement | null} */
#labelText = null;
/** @type {HTMLElement | null} */
#reasoningSection = null;
/** @type {HTMLElement | null} */
#reasoningContent = null;
/** @type {HTMLElement | null} */
#textSection = null;
/** @type {HTMLElement | null} */
#textContent = null;
/** @type {HTMLButtonElement | null} */
#stopButton = null;
/** @type {HTMLButtonElement | null} */
#minimizeButton = null;
/** @type {HTMLButtonElement | null} */
#closeButton = null;
/** @type {(() => (void | Promise<void>)) | null} */
#onStop = null;
/** @type {HTMLElement | null} */
#ledIndicator = null;
/** @type {boolean} */
#hasContent = false;
/** @type {boolean} */
#isMinimized = false;
/** @type {boolean} */
#isComplete = false;
/** @type {boolean} */
#isStopped = false;
/** @type {ReturnType<typeof setTimeout> | null} */
#hideTimeoutId = null;
/**
* Shows the streaming display panel.
* @param {StreamingDisplayOptions} [options]
* @returns {StreamingDisplay} this instance for chaining
*/
show({ label = '', icon = null, onStop = null } = {}) {
if (this.#element) this.hide({ instant: true });
this.#isMinimized = false;
this.#isComplete = false;
this.#onStop = onStop;
this.#clearHideTimeout();
this.#element = document.createElement('div');
this.#element.classList.add(CSS_PREFIX);
// Header label with LED indicator
this.#labelElement = document.createElement('div');
this.#labelElement.classList.add(`${CSS_PREFIX}-label`);
// LED status indicator (pulsing while streaming, green when complete)
this.#ledIndicator = document.createElement('span');
this.#ledIndicator.classList.add(`${CSS_PREFIX}-led`);
this.#labelElement.appendChild(this.#ledIndicator);
// Insert model icon into the label (after the LED)
if (icon instanceof HTMLImageElement) {
icon.classList.add(`${CSS_PREFIX}-icon`);
this.#labelElement.appendChild(icon);
icon.onload = async function () {
await SVGInject(icon);
};
}
this.#labelText = document.createElement('span');
this.#labelText.classList.add(`${CSS_PREFIX}-label-text`);
this.#labelText.textContent = label;
this.#labelElement.appendChild(this.#labelText);
// Window control buttons container
const controls = document.createElement('div');
controls.classList.add(`${CSS_PREFIX}-controls`);
// Stop button (only shown when an onStop handler is provided)
if (onStop) {
this.#stopButton = document.createElement('button');
this.#stopButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-stop`);
this.#stopButton.setAttribute('aria-label', t`Stop`);
this.#stopButton.setAttribute('title', t`Stop generation`);
this.#stopButton.innerHTML = '&#9632;'; // Black square ■
this.#stopButton.addEventListener('click', async () => {
// Disable immediately to prevent double-clicks and give instant feedback
if (this.#stopButton) {
this.#stopButton.disabled = true;
}
try {
await this.#onStop?.();
} catch (e) {
console.error('[StreamingDisplay] Error executing stop handler', e);
}
});
controls.appendChild(this.#stopButton);
}
// Minimize button
this.#minimizeButton = document.createElement('button');
this.#minimizeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-minimize`);
this.#minimizeButton.setAttribute('aria-label', t`Minimize`);
this.#minimizeButton.setAttribute('title', t`Minimize`);
this.#minimizeButton.innerHTML = '&#8211;'; // En dash
this.#minimizeButton.addEventListener('click', () => this.toggleMinimize());
controls.appendChild(this.#minimizeButton);
// Close button
this.#closeButton = document.createElement('button');
this.#closeButton.classList.add(`${CSS_PREFIX}-btn`, `${CSS_PREFIX}-btn-close`);
this.#closeButton.setAttribute('aria-label', t`Close`);
this.#closeButton.setAttribute('title', t`Close (generation continues in background)`);
this.#closeButton.innerHTML = '&#215;'; // Multiplication sign (×)
this.#closeButton.addEventListener('click', () => this.hide());
controls.appendChild(this.#closeButton);
this.#labelElement.appendChild(controls);
this.#element.appendChild(this.#labelElement);
// Content container (for minimize functionality)
const contentContainer = document.createElement('div');
contentContainer.classList.add(`${CSS_PREFIX}-content`);
// Reasoning section (hidden until content arrives)
this.#reasoningSection = document.createElement('div');
this.#reasoningSection.classList.add(`${CSS_PREFIX}-reasoning`);
this.#reasoningSection.style.display = 'none';
const reasoningLabel = document.createElement('div');
reasoningLabel.classList.add(`${CSS_PREFIX}-reasoning-label`);
reasoningLabel.textContent = t`Thinking...`;
this.#reasoningSection.appendChild(reasoningLabel);
this.#reasoningContent = document.createElement('div');
this.#reasoningContent.classList.add(`${CSS_PREFIX}-reasoning-content`);
this.#reasoningSection.appendChild(this.#reasoningContent);
contentContainer.appendChild(this.#reasoningSection);
// Content section (hidden until content arrives)
this.#textSection = document.createElement('div');
this.#textSection.classList.add(`${CSS_PREFIX}-text`);
this.#textSection.style.display = 'none';
this.#textContent = document.createElement('div');
this.#textContent.classList.add(`${CSS_PREFIX}-text-content`, 'mes_text'); // Allow formatting based on how chat messages are formatted too
this.#textSection.appendChild(this.#textContent);
contentContainer.appendChild(this.#textSection);
this.#element.appendChild(contentContainer);
// Append inside the topmost open dialog (same pattern as fixToastrForDialogs in popup.js).
// Modal <dialog> elements live in the browser's top layer, so z-index alone won't work.
const target = Array.from(document.querySelectorAll('dialog[open]:not([closing])')).pop() ?? document.body;
target.appendChild(this.#element);
// Trigger entrance animation on next frame
requestAnimationFrame(() => {
this.#element?.classList.add(`${CSS_PREFIX}-visible`);
});
return this;
}
/**
* Toggles the minimized state of the display.
* When minimized, only the header with label and buttons is shown.
* @returns {StreamingDisplay} this instance for chaining
*/
toggleMinimize() {
if (!this.#element) return this;
this.#isMinimized = !this.#isMinimized;
this.#element.classList.toggle(`${CSS_PREFIX}-minimized`, this.#isMinimized);
// Update minimize button icon/appearance
if (this.#minimizeButton) {
this.#minimizeButton.innerHTML = this.#isMinimized ? '&#9633;' : '&#8211;'; // Square when minimized, dash when not
this.#minimizeButton.setAttribute('title', this.#isMinimized ? t`Restore` : t`Minimize`);
this.#minimizeButton.setAttribute('aria-label', this.#isMinimized ? t`Restore` : t`Minimize`);
}
return this;
}
/**
* @returns {boolean} Whether the display is currently minimized
*/
get isMinimized() {
return this.#isMinimized;
}
/**
* @returns {boolean} Whether the display is marked as complete (generation finished)
*/
get isComplete() {
return this.#isComplete;
}
/**
* @returns {boolean} Whether the display was stopped by the user
*/
get isStopped() {
return this.#isStopped;
}
/**
* Updates the header label text.
* @param {string} label
* @returns {StreamingDisplay} this instance for chaining
*/
setLabel(label) {
if (this.#labelText) {
this.#labelText.textContent = label;
}
return this;
}
/**
* Updates the reasoning (thinking) section with new text.
* Automatically shows the reasoning section when text is provided.
* @param {string} text - Accumulated reasoning text
* @returns {StreamingDisplay} this instance for chaining
*/
updateReasoning(text) {
if (!this.#reasoningContent || !this.#reasoningSection || !text) return this;
this.#reasoningSection.style.display = '';
this.#reasoningContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, true);
this.#reasoningContent.scrollTop = this.#reasoningContent.scrollHeight;
return this;
}
/**
* Updates the main content section with new text.
* Automatically shows the content section when text is provided (including empty string).
* @param {string|null|undefined} text - Accumulated content text
* @returns {StreamingDisplay} this instance for chaining
*/
updateContent(text) {
if (!this.#textContent || !this.#textSection || !text) return this;
this.#hasContent = true;
this.#textSection.style.display = '';
this.#textContent.innerHTML = messageFormatting(text, '', false, false, -1, {}, false);
this.#textContent.scrollTop = this.#textContent.scrollHeight;
return this;
}
/** @returns {boolean} Whether any content text has been displayed via streaming */
get hasContent() {
return this.#hasContent;
}
/**
* Marks the generation as stopped by the user.
*
* Changes the LED indicator to solid red, removes the stop button, and keeps the display
* visible until the user manually closes it with the close button (no auto-hide).
*
* @param {Object} [options={}]
* @param {string|null} [options.label=null] - Optional label override (e.g. `'Generating... [Stopped]'`).
* @returns {StreamingDisplay} this instance for chaining
*/
markStopped({ label = null } = {}) {
if (!this.#element || this.#isStopped || this.#isComplete) return this;
this.#isStopped = true;
this.#clearHideTimeout();
this.#element.classList.add(`${CSS_PREFIX}-stopped`);
// Remove the stop button — nothing left to stop
if (this.#stopButton) {
this.#stopButton.remove();
this.#stopButton = null;
}
if (label !== null) {
this.setLabel(label);
}
return this;
}
/**
* Marks the generation as complete and initiates cleanup. Optionally set a new label.
*
* This is the **preferred method** to call after streaming ends. It:
* - Changes the LED indicator from pulsing orange to solid green
* - Waits for the specified delay to let the user see the final result
* - Then hides the display with a fade-out animation
*
* @param {Object} [options={}]
* @param {string|null} [options.label=null] - Set the label automatically to a new one to display the completed state.
* @param {number|null} [options.delay=3000] - Delay in ms before hiding. Use `null` or negative value to keep displayed until user manually closes it.
* @returns {StreamingDisplay} this instance for chaining
*/
complete({ label = null, delay = 3000 } = {}) {
if (!this.#element || this.#isComplete) return this;
this.#isComplete = true;
this.#element.classList.add(`${CSS_PREFIX}-complete`);
// Clear any existing hide timeout
this.#clearHideTimeout();
if (this.#stopButton) {
this.#stopButton.remove();
this.#stopButton = null;
}
if (label !== null) {
this.setLabel(label);
}
// Auto-hide after delay if specified (positive number)
if (typeof delay === 'number' && delay >= 0) {
this.#hideTimeoutId = setTimeout(() => {
this.#performHide();
}, delay);
}
return this;
}
/**
* Immediately hides and removes the streaming display.
*
* **Note:** This is for immediate cleanup (e.g., when canceling generation
* or closing the app). Prefer `complete()` when generation finishes normally,
* as it shows the green LED and gives the user time to see the final result.
*
* @param {Object} [options={}]
* @param {boolean} [options.instant=false] - Skip the fade-out animation
* @returns {StreamingDisplay} this instance for chaining
*/
hide({ instant = false } = {}) {
this.#clearHideTimeout();
this.#performHide({ instant });
return this;
}
/**
* Clears any pending auto-hide timeout.
*/
#clearHideTimeout() {
if (this.#hideTimeoutId !== null) {
clearTimeout(this.#hideTimeoutId);
this.#hideTimeoutId = null;
}
}
/**
* Internal method to actually remove the DOM element.
* @param {Object} [options={}]
* @param {boolean} [options.instant=false]
*/
#performHide({ instant = false } = {}) {
if (!this.#element) return;
const el = this.#element;
// Clear all private fields
this.#element = null;
this.#labelElement = null;
this.#labelText = null;
this.#reasoningSection = null;
this.#reasoningContent = null;
this.#textSection = null;
this.#textContent = null;
this.#stopButton = null;
this.#minimizeButton = null;
this.#closeButton = null;
this.#ledIndicator = null;
this.#onStop = null;
this.#hasContent = false;
this.#isMinimized = false;
this.#isComplete = false;
this.#isStopped = false;
this.#hideTimeoutId = null;
if (instant) {
el.remove();
return;
}
el.classList.remove(`${CSS_PREFIX}-visible`);
const duration = animation_duration;
if (duration > 0) {
setTimeout(() => el.remove(), duration);
} else {
el.remove();
}
}
}
+1
View File
@@ -15,6 +15,7 @@
@import url(css/secrets.css);
@import url(css/backgrounds.css);
@import url(css/chat-backups.css);
@import url(css/streaming-display.css);
:root {
interpolate-size: allow-keywords;