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