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>
Signed| @@ -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 | +} | |
| @@ -1910,17 +1910,32 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san | ||
| 1910 | 1910 | } |
| 1911 | 1911 | |
| 1912 | 1912 | /** |
| 1913 | + * Creates an Image element for the given API/model icon. | |
| 1914 | + * The image references the matching SVG file from `/img/` and includes a tooltip with API and model info. | |
| 1915 | + * The caller is responsible for appending the image to the DOM and optionally calling `SVGInject` on it. | |
| 1916 | + * | |
| 1917 | + * @param {string} apiName - API identifier matching an SVG file in /img/ (e.g. 'openai', 'openrouter', 'claude') | |
| 1918 | + * @param {string} [modelName=''] - Model name shown in the tooltip | |
| 1919 | + * @returns {HTMLImageElement} The image element (not yet in the DOM) | |
| 1920 | + */ | |
| 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 | +/** | |
| 1913 | 1930 | * Inserts or replaces an SVG icon adjacent to the provided message's timestamp. |
| 1914 | 1931 | * |
| 1915 | 1932 | * @param {JQuery<HTMLElement>} mes - The message element containing the timestamp where the icon should be inserted or replaced. |
| 1916 | 1933 | * @param {ChatMessageExtra} extra - Contains the API and model details. |
| 1917 | 1934 | */ |
| 1918 | 1935 | function insertSVGIcon(mes, extra) { |
| 1919 | - // Determine the SVG filename | |
| 1936 | + const apiName = extra?.api || ''; | |
| 1920 | - let modelName = extra?.api || ''; | |
| 1921 | 1937 | |
| 1922 | - // If there's no API information, we can't determine which SVG to use | |
| 1938 | + if (!apiName) { | |
| 1923 | - if (!modelName) { | |
| 1924 | 1939 | return; |
| 1925 | 1940 | } |
| 1926 | 1941 | |
| @@ -1937,16 +1952,14 @@ function insertSVGIcon(mes, extra) { | ||
| 1937 | 1952 | }; |
| 1938 | 1953 | }; |
| 1939 | 1954 | |
| 1940 | 1955 | const createModelImageinsertIcon = (className, targetSelector, insertBefore) => { |
| 1941 | 1956 | const image = new ImagecreateModelIcon(apiName, extra?.model); |
| 1942 | 1957 | image.classList.add('icon-svg', className); |
| 1943 | - image.src = `/img/${modelName}.svg`; | |
| 1944 | - image.title = `${extra?.api ? extra.api + ' - ' : ''}${extra?.model ?? ''}`; | |
| 1945 | 1958 | insertOrReplaceSVG(image, className, targetSelector, insertBefore); |
| 1946 | 1959 | }; |
| 1947 | 1960 | |
| 1948 | 1961 | createModelImageinsertIcon('timestamp-icon', '.timestamp'); |
| 1949 | 1962 | createModelImageinsertIcon('thinking-icon', '.mes_reasoning_header_title', true); |
| 1950 | 1963 | } |
| 1951 | 1964 | |
| 1952 | 1965 | /** |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { DOMPurify, Fuse } from '../../../lib.js'; |
| 2 | 2 | |
| 3 | 3 | import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js'; |
| 4 | 4 | import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 5 | 5 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js'; |
| 6 | 6 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| 7 | 7 | import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js'; |
| @@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from ' | ||
| 9 | 9 | import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 10 | 10 | import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js'; |
| 11 | 11 | import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 12 | +import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js'; | |
| 12 | 13 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| 13 | 14 | import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js'; |
| 14 | 15 | import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js'; |
| 15 | 16 | import { t } from '../../i18n.js'; |
| 16 | 17 | import { 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'; | |
| 17 | 22 | |
| 18 | 23 | const MODULE_NAME = 'connection-manager'; |
| 19 | 24 | const NONE = '<None>'; |
| @@ -474,6 +479,222 @@ async function renderDetailsContent(detailsContent) { | ||
| 474 | 479 | } |
| 475 | 480 | } |
| 476 | 481 | |
| 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 | + | |
| 477 | 698 | export async function init() { |
| 478 | 699 | extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS); |
| 479 | 700 | |
| @@ -824,4 +1045,114 @@ export async function init() { | ||
| 824 | 1045 | return JSON.stringify(profile); |
| 825 | 1046 | }, |
| 826 | 1047 | })); |
| 1048 | + | |
| 1049 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 1050 | + name: 'profile-genstream', | |
| 1051 | + callback: generateStreamCallback, | |
| 1052 | + returns: t`generated text`, | |
| 1053 | + namedArgumentList: [ | |
| 1054 | + new SlashCommandNamedArgument( | |
| 1055 | + 'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(), | |
| 1056 | + ), | |
| 1057 | + SlashCommandNamedArgument.fromProps({ | |
| 1058 | + name: 'profile', | |
| 1059 | + description: t`connection profile ID to use for generation`, | |
| 1060 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1061 | + enumProvider: commonEnumProviders.connectionProfiles(), | |
| 1062 | + }), | |
| 1063 | + SlashCommandNamedArgument.fromProps({ | |
| 1064 | + name: 'reasoning', | |
| 1065 | + description: t`include formatted reasoning in the output`, | |
| 1066 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 1067 | + defaultValue: 'false', | |
| 1068 | + enumProvider: commonEnumProviders.boolean('trueFalse'), | |
| 1069 | + }), | |
| 1070 | + SlashCommandNamedArgument.fromProps({ | |
| 1071 | + name: 'system', | |
| 1072 | + description: t`system prompt at the start`, | |
| 1073 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1074 | + }), | |
| 1075 | + SlashCommandNamedArgument.fromProps({ | |
| 1076 | + name: 'length', | |
| 1077 | + description: t`API response length in tokens`, | |
| 1078 | + typeList: [ARGUMENT_TYPE.NUMBER], | |
| 1079 | + defaultValue: '2048', | |
| 1080 | + }), | |
| 1081 | + SlashCommandNamedArgument.fromProps({ | |
| 1082 | + name: 'generating', | |
| 1083 | + description: t`label/title for the generation display`, | |
| 1084 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1085 | + defaultValue: 'Generating...', | |
| 1086 | + }), | |
| 1087 | + SlashCommandNamedArgument.fromProps({ | |
| 1088 | + name: 'completed', | |
| 1089 | + description: t`updated label/title for when generation completes`, | |
| 1090 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1091 | + defaultValue: 'Generated', | |
| 1092 | + }), | |
| 1093 | + SlashCommandNamedArgument.fromProps({ | |
| 1094 | + name: 'delay', | |
| 1095 | + description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`, | |
| 1096 | + typeList: [ARGUMENT_TYPE.NUMBER], | |
| 1097 | + defaultValue: '3000', | |
| 1098 | + enumList: [ | |
| 1099 | + new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'), | |
| 1100 | + new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input), | |
| 1101 | + ], | |
| 1102 | + }), | |
| 1103 | + SlashCommandNamedArgument.fromProps({ | |
| 1104 | + name: 'stop', | |
| 1105 | + description: t`show a stop button on the streaming display that aborts generation when clicked`, | |
| 1106 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 1107 | + defaultValue: 'true', | |
| 1108 | + enumProvider: commonEnumProviders.boolean('trueFalse'), | |
| 1109 | + }), | |
| 1110 | + SlashCommandNamedArgument.fromProps({ | |
| 1111 | + name: 'onStop', | |
| 1112 | + description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`, | |
| 1113 | + typeList: [ARGUMENT_TYPE.CLOSURE], | |
| 1114 | + }), | |
| 1115 | + SlashCommandNamedArgument.fromProps({ | |
| 1116 | + name: 'onComplete', | |
| 1117 | + description: t`closure to execute after generation completes successfully`, | |
| 1118 | + typeList: [ARGUMENT_TYPE.CLOSURE], | |
| 1119 | + }), | |
| 1120 | + ], | |
| 1121 | + unnamedArgumentList: [ | |
| 1122 | + SlashCommandArgument.fromProps({ | |
| 1123 | + description: 'prompt', | |
| 1124 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1125 | + isRequired: true, | |
| 1126 | + }), | |
| 1127 | + ], | |
| 1128 | + helpString: ` | |
| 1129 | + <div> | |
| 1130 | + ${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`} | |
| 1131 | + </div> | |
| 1132 | + <div> | |
| 1133 | + ${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`} | |
| 1134 | + </div> | |
| 1135 | + <div> | |
| 1136 | + ${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`} | |
| 1137 | + </div> | |
| 1138 | + <div> | |
| 1139 | + ${t`Use delay to control auto-hide behavior: number (ms), "infinite", or negative to keep the display open until manually closed. The display shows a green LED when complete.`} | |
| 1140 | + </div> | |
| 1141 | + <div> | |
| 1142 | + ${t`A stop button is shown by default (stop=true). Click it to abort generation and return whatever was streamed so far. Use stop=false to hide the stop button.`} | |
| 1143 | + </div> | |
| 1144 | + <div> | |
| 1145 | + ${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`} | |
| 1146 | + </div> | |
| 1147 | + <div> | |
| 1148 | + ${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`} | |
| 1149 | + </div> | |
| 1150 | + <div> | |
| 1151 | + ${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`} | |
| 1152 | + </div> | |
| 1153 | + <div> | |
| 1154 | + ${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`} | |
| 1155 | + </div> | |
| 1156 | + `, | |
| 1157 | + })); | |
| 827 | 1158 | } |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js'; |
| 2 | 2 | import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js'; |
| 3 | 3 | import { t } from '../i18n.js'; |
| 4 | 4 | import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js'; |
| @@ -544,6 +544,29 @@ export class ConnectionManagerRequestService { | ||
| 544 | 544 | } |
| 545 | 545 | |
| 546 | 546 | /** |
| 547 | + * Creates a model icon Image element for the given profile (or the currently selected profile). | |
| 548 | + * Returns null if the profile is not found, has no API, or Connection Manager is unavailable. | |
| 549 | + * @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile. | |
| 550 | + * @returns {HTMLImageElement | null} | |
| 551 | + */ | |
| 552 | + static getProfileIcon(profileId) { | |
| 553 | + if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) { | |
| 554 | + return null; | |
| 555 | + } | |
| 556 | + | |
| 557 | + const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile; | |
| 558 | + if (!id) return null; | |
| 559 | + | |
| 560 | + try { | |
| 561 | + const profile = this.getProfile(id); | |
| 562 | + if (!profile?.api) return null; | |
| 563 | + return createModelIcon(profile.api, profile.model); | |
| 564 | + } catch { | |
| 565 | + return null; | |
| 566 | + } | |
| 567 | + } | |
| 568 | + | |
| 569 | + /** | |
| 547 | 570 | * @param {import('./connection-manager/index.js').ConnectionProfile?} [profile] |
| 548 | 571 | * @returns {boolean} |
| 549 | 572 | */ |
| @@ -1011,6 +1011,44 @@ function registerReasoningSlashCommands() { | ||
| 1011 | 1011 | }, |
| 1012 | 1012 | })); |
| 1013 | 1013 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1014 | + name: 'reasoning-format', | |
| 1015 | + aliases: ['format-reasoning'], | |
| 1016 | + returns: 'formatted string', | |
| 1017 | + helpString: t`Formats reasoning and content into a single string using Reasoning Formatting settings. Useful for preparing text that can be parsed with /reasoning-parse.`, | |
| 1018 | + namedArgumentList: [ | |
| 1019 | + SlashCommandNamedArgument.fromProps({ | |
| 1020 | + name: 'reasoning', | |
| 1021 | + description: 'The reasoning/thinking text to format', | |
| 1022 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1023 | + isRequired: true, | |
| 1024 | + }), | |
| 1025 | + ], | |
| 1026 | + unnamedArgumentList: [ | |
| 1027 | + SlashCommandArgument.fromProps({ | |
| 1028 | + description: 'The main content text', | |
| 1029 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 1030 | + isRequired: false, | |
| 1031 | + }), | |
| 1032 | + ], | |
| 1033 | + callback: (args, value) => { | |
| 1034 | + const reasoning = String(args?.reasoning ?? ''); | |
| 1035 | + const content = String(value ?? ''); | |
| 1036 | + | |
| 1037 | + if (!power_user.reasoning.prefix || !power_user.reasoning.suffix) { | |
| 1038 | + toastr.warning(t`Both prefix and suffix must be set in the Reasoning Formatting settings.`, t`Reasoning Format`); | |
| 1039 | + return ''; | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + if (!reasoning) { | |
| 1043 | + toastr.warning(t`Reasoning argument is required.`, t`Reasoning Format`); | |
| 1044 | + return ''; | |
| 1045 | + } | |
| 1046 | + | |
| 1047 | + const { formatted } = formatReasoning(reasoning, content); | |
| 1048 | + return formatted; | |
| 1049 | + }, | |
| 1050 | + })); | |
| 1051 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 1014 | 1052 | name: 'reasoning-template', |
| 1015 | 1053 | aliases: ['reasoning-formatting', 'reasoning-preset'], |
| 1016 | 1054 | callback: selectReasoningTemplateCallback, |
| @@ -1412,6 +1450,36 @@ export function parseReasoningFromString(str, { strict = true } = {}, template = | ||
| 1412 | 1450 | } |
| 1413 | 1451 | |
| 1414 | 1452 | /** |
| 1453 | + * Formats reasoning and content into a string using the reasoning template. | |
| 1454 | + * This is the inverse of parseReasoningFromString. | |
| 1455 | + * @typedef {Object} FormattedReasoning | |
| 1456 | + * @property {string} formatted The formatted string with reasoning wrapped in prefix/suffix | |
| 1457 | + * @property {string} contentOnly The content without reasoning | |
| 1458 | + * @param {string} reasoning The reasoning/thinking text | |
| 1459 | + * @param {string} content The main content/response text | |
| 1460 | + * @param {ReasoningTemplate} [template=null] Optional template to use. Defaults to power_user.reasoning | |
| 1461 | + * @returns {FormattedReasoning} Object containing both formatted (reasoning + content) and contentOnly | |
| 1462 | + */ | |
| 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 | +/** | |
| 1415 | 1483 | * Parse reasoning in an array of swipe strings if auto-parsing is enabled. |
| 1416 | 1484 | * @param {string[]} swipes Array of swipe strings |
| 1417 | 1485 | * @param {{extra: Partial<ReasoningMessageExtra>}[]} swipeInfoArray Array of swipe info objects |
| @@ -341,4 +341,9 @@ export const commonEnumProviders = { | ||
| 341 | 341 | backgrounds: () => Array.from(document.querySelectorAll('.bg_example')) |
| 342 | 342 | .map(it => new SlashCommandEnumValue(it.getAttribute('bgfile'))) |
| 343 | 343 | .filter(it => it.value?.length), |
| 344 | + | |
| 345 | + connectionProfiles: ({ includeNone = false } = {}) => () => [ | |
| 346 | + ...includeNone ? [new SlashCommandEnumValue('<None>')] : [], | |
| 347 | + ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)), | |
| 348 | + ], | |
| 344 | 349 | }; |
| @@ -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 = '■'; // 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 = '–'; // 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 = '×'; // 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 ? '□' : '–'; // 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 | +} | |
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | @import url(css/secrets.css); |
| 16 | 16 | @import url(css/backgrounds.css); |
| 17 | 17 | @import url(css/chat-backups.css); |
| 18 | +@import url(css/streaming-display.css); | |
| 18 | 19 | |
| 19 | 20 | :root { |
| 20 | 21 | interpolate-size: allow-keywords; |