Files
SillyTavern/public/scripts/extensions/connection-manager/index.js
T
Wolfsblvt 64c96e895c 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>
2026-04-15 21:38:13 +03:00

1159 lines
44 KiB
JavaScript

import { DOMPurify, Fuse } from '../../../lib.js';
import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
import { t } from '../../i18n.js';
import { getSecretLabelById } from '../../secrets.js';
import { performFuzzySearch } from '/scripts/power-user.js';
import { StreamingDisplay } from '/scripts/streaming-display.js';
import { ConnectionManagerRequestService } from '../shared.js';
import { formatReasoning } from '/scripts/reasoning.js';
const MODULE_NAME = 'connection-manager';
const NONE = '<None>';
const EMPTY = '<Empty>';
const DEFAULT_SETTINGS = {
profiles: [],
selectedProfile: null,
};
// Commands that can record an empty value into the profile
const ALLOW_EMPTY = [
'stop-strings',
'start-reply-with',
];
const CC_COMMANDS = [
'api',
'preset',
// Do not fix; CC needs to set the API twice because it could be overridden by the preset
'api',
'api-url',
'model',
'proxy',
'stop-strings',
'start-reply-with',
'reasoning-template',
'prompt-post-processing',
'secret-id',
'regex-preset',
];
const TC_COMMANDS = [
'api',
'preset',
'api-url',
'model',
'sysprompt',
'sysprompt-state',
'instruct',
'context',
'instruct-state',
'tokenizer',
'stop-strings',
'start-reply-with',
'reasoning-template',
'secret-id',
'regex-preset',
];
const FANCY_NAMES = {
'api': 'API',
'api-url': 'Server URL',
'preset': 'Settings Preset',
'model': 'Model',
'proxy': 'Proxy Preset',
'sysprompt-state': 'Use System Prompt',
'sysprompt': 'System Prompt Name',
'instruct-state': 'Instruct Mode',
'instruct': 'Instruct Template',
'context': 'Context Template',
'tokenizer': 'Tokenizer',
'stop-strings': 'Custom Stopping Strings',
'start-reply-with': 'Start Reply With',
'reasoning-template': 'Reasoning Template',
'prompt-post-processing': 'Prompt Post-Processing',
'secret-id': 'Secret',
'regex-preset': 'Regex Preset',
};
/**
* A wrapper for the connection manager spinner.
*/
class ConnectionManagerSpinner {
/**
* @type {AbortController[]}
*/
static abortControllers = [];
/** @type {HTMLElement} */
spinnerElement;
/** @type {AbortController} */
abortController = new AbortController();
constructor() {
// @ts-ignore
this.spinnerElement = document.getElementById('connection_profile_spinner');
this.abortController = new AbortController();
}
start() {
ConnectionManagerSpinner.abortControllers.push(this.abortController);
this.spinnerElement.classList.remove('hidden');
}
stop() {
this.spinnerElement.classList.add('hidden');
}
isAborted() {
return this.abortController.signal.aborted;
}
static abort() {
for (const controller of ConnectionManagerSpinner.abortControllers) {
controller.abort();
}
ConnectionManagerSpinner.abortControllers = [];
}
}
/**
* Get named arguments for the command callback.
* @param {object} [args] Additional named arguments
* @param {string} [args.force] Whether to force setting the value
* @returns {object} Named arguments
*/
function getNamedArguments(args = {}) {
// None of the commands here use underscored args, but better safe than sorry
return {
_scope: new SlashCommandScope(),
_abortController: new SlashCommandAbortController(),
_debugController: new SlashCommandDebugController(),
_parserFlags: {},
_hasUnnamedArgument: false,
quiet: 'true',
...args,
};
}
/** @type {() => SlashCommandEnumValue[]} */
const profilesProvider = () => [
new SlashCommandEnumValue(NONE),
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
];
/**
* @typedef {Object} ConnectionProfile
* @property {string} id Unique identifier
* @property {string} mode Mode of the connection profile
* @property {string} [name] Name of the connection profile
* @property {string} [api] API
* @property {string} [preset] Settings Preset
* @property {string} [model] Model
* @property {string} [proxy] Proxy Preset
* @property {string} [instruct] Instruct Template
* @property {string} [context] Context Template
* @property {string} [instruct-state] Instruct Mode
* @property {string} [tokenizer] Tokenizer
* @property {string} [stop-strings] Custom Stopping Strings
* @property {string} [start-reply-with] Start Reply With
* @property {string} [reasoning-template] Reasoning Template
* @property {string} [prompt-post-processing] Prompt Post-Processing
* @property {string} [sysprompt] System Prompt Name
* @property {string} [sysprompt-state] Use System Prompt
* @property {string} [api-url] Server URL
* @property {string} [secret-id] Secret ID
* @property {string} [regex-preset] Regex Preset ID
* @property {string[]} [exclude] Commands to exclude
*/
/**
* Finds the best match for the search value.
* @param {string} value Search value
* @returns {ConnectionProfile|null} Best match or null
*/
function findProfileByName(value) {
// Try to find exact match
const profile = extension_settings.connectionManager.profiles.find(p => p.name === value);
if (profile) {
return profile;
}
// Try to find fuzzy match
const fuse = new Fuse(extension_settings.connectionManager.profiles, { keys: ['name'] });
const results = fuse.search(value);
if (results.length === 0) {
return null;
}
const bestMatch = results[0];
return bestMatch.item;
}
/**
* Reads the connection profile from the commands.
* @param {string} mode Mode of the connection profile
* @param {ConnectionProfile} profile Connection profile
* @param {boolean} [cleanUp] Whether to clean up the profile
*/
async function readProfileFromCommands(mode, profile, cleanUp = false) {
const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
const opposingCommands = mode === 'cc' ? TC_COMMANDS : CC_COMMANDS;
const excludeList = Array.isArray(profile.exclude) ? profile.exclude : [];
for (const command of commands) {
try {
if (excludeList.includes(command)) {
continue;
}
const allowEmpty = ALLOW_EMPTY.includes(command);
const args = getNamedArguments();
const result = await SlashCommandParser.commands[command].callback(args, '');
if (result || (allowEmpty && result === '')) {
profile[command] = result;
continue;
}
} catch (error) {
console.error(`Failed to execute command: ${command}`, error);
}
}
if (cleanUp) {
for (const command of commands) {
if (command.endsWith('-state') && profile[command] === 'false') {
delete profile[command.replace('-state', '')];
}
}
for (const command of opposingCommands) {
if (commands.includes(command)) {
continue;
}
delete profile[command];
}
}
}
/**
* Creates a new connection profile.
* @param {string} [forceName] Name of the connection profile
* @returns {Promise<ConnectionProfile>} Created connection profile
*/
async function createConnectionProfile(forceName = null) {
const mode = main_api === 'openai' ? 'cc' : 'tc';
const id = uuidv4();
/** @type {ConnectionProfile} */
const profile = {
id,
mode,
exclude: [],
};
await readProfileFromCommands(mode, profile);
const profileForDisplay = makeFancyProfile(profile);
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'profile', { profile: profileForDisplay }));
template.find('input[name="exclude"]').on('input', function () {
const fancyName = String($(this).val());
const keyName = Object.entries(FANCY_NAMES).find(x => x[1] === fancyName)?.[0];
if (!keyName) {
console.warn('Key not found for fancy name:', fancyName);
return;
}
if (!Array.isArray(profile.exclude)) {
profile.exclude = [];
}
const excludeState = !$(this).prop('checked');
if (excludeState) {
profile.exclude.push(keyName);
} else {
const index = profile.exclude.indexOf(keyName);
index !== -1 && profile.exclude.splice(index, 1);
}
});
const isNameTaken = (n) => extension_settings.connectionManager.profiles.some(p => p.name === n);
const suggestedName = getUniqueName(collapseSpaces(`${profile.api ?? ''} ${profile.model ?? ''} - ${profile.preset ?? ''}`), isNameTaken);
let name = forceName ?? await callGenericPopup(template, POPUP_TYPE.INPUT, suggestedName);
// If it's cancelled, it will be false
if (!name) {
return null;
}
name = DOMPurify.sanitize(String(name));
if (!name) {
toastr.error('Name cannot be empty.');
return null;
}
if (isNameTaken(name) || name === NONE) {
toastr.error('A profile with the same name already exists.');
return null;
}
if (Array.isArray(profile.exclude)) {
for (const command of profile.exclude) {
delete profile[command];
}
}
profile.name = String(name);
return profile;
}
/**
* Deletes the selected connection profile.
* @returns {Promise<void>}
*/
async function deleteConnectionProfile() {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
if (!selectedProfile) {
return;
}
const index = extension_settings.connectionManager.profiles.findIndex(p => p.id === selectedProfile);
if (index === -1) {
return;
}
const profile = extension_settings.connectionManager.profiles[index];
const name = profile.name;
const confirm = await Popup.show.confirm(t`Are you sure you want to delete the selected profile?`, name);
if (!confirm) {
return;
}
extension_settings.connectionManager.profiles.splice(index, 1);
extension_settings.connectionManager.selectedProfile = null;
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_DELETED, profile);
}
/**
* Formats the connection profile for display.
* @param {ConnectionProfile} profile Connection profile
* @returns {Object} Fancy profile
*/
function makeFancyProfile(profile) {
return Object.entries(FANCY_NAMES).reduce((acc, [key, value]) => {
const allowEmpty = ALLOW_EMPTY.includes(key);
if (!profile[key]) {
if (profile[key] === '' && allowEmpty) {
acc[value] = EMPTY;
}
return acc;
}
// UUID is not very useful in the UI, so we replace it with a label (if available)
if (key === 'secret-id') {
const label = getSecretLabelById(profile[key]);
if (label) {
acc[value] = label;
return acc;
}
}
if (key === 'regex-preset') {
const label = extension_settings.regex_presets?.find(p => p.id === profile[key])?.name;
if (label) {
acc[value] = label;
return acc;
}
}
acc[value] = profile[key];
return acc;
}, {});
}
/**
* Applies the connection profile.
* @param {ConnectionProfile} profile Connection profile
* @returns {Promise<void>}
*/
async function applyConnectionProfile(profile) {
if (!profile) {
return;
}
// Abort any ongoing profile application
ConnectionManagerSpinner.abort();
const mode = profile.mode;
const commands = mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
const spinner = new ConnectionManagerSpinner();
spinner.start();
for (const command of commands) {
if (spinner.isAborted()) {
throw new Error('Profile application aborted');
}
const argument = profile[command];
const allowEmpty = ALLOW_EMPTY.includes(command);
if (!argument && !(allowEmpty && argument === '')) {
continue;
}
try {
const args = getNamedArguments(allowEmpty ? { force: 'true' } : {});
await SlashCommandParser.commands[command].callback(args, argument);
} catch (error) {
console.error(`Failed to execute command: ${command} ${argument}`, error);
}
}
spinner.stop();
}
/**
* Updates the selected connection profile.
* @param {ConnectionProfile} profile Connection profile
* @returns {Promise<void>}
*/
async function updateConnectionProfile(profile) {
profile.mode = main_api === 'openai' ? 'cc' : 'tc';
await readProfileFromCommands(profile.mode, profile, true);
}
/**
* Renders the connection profile details.
* @param {HTMLSelectElement} profiles Select element containing connection profiles
*/
function renderConnectionProfiles(profiles) {
profiles.innerHTML = '';
const noneOption = document.createElement('option');
noneOption.value = '';
noneOption.textContent = NONE;
noneOption.selected = !extension_settings.connectionManager.selectedProfile;
profiles.appendChild(noneOption);
for (const profile of extension_settings.connectionManager.profiles.sort((a, b) => a.name.localeCompare(b.name))) {
const option = document.createElement('option');
option.value = profile.id;
option.textContent = profile.name;
option.selected = profile.id === extension_settings.connectionManager.selectedProfile;
profiles.appendChild(option);
}
}
/**
* Renders the content of the details element.
* @param {HTMLElement} detailsContent Content element of the details
*/
async function renderDetailsContent(detailsContent) {
detailsContent.innerHTML = '';
if (detailsContent.classList.contains('hidden')) {
return;
}
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (profile) {
const profileForDisplay = makeFancyProfile(profile);
const templateParams = { profile: profileForDisplay };
if (Array.isArray(profile.exclude) && profile.exclude.length > 0) {
templateParams.omitted = profile.exclude.map(e => FANCY_NAMES[e]).join(', ');
}
const template = await renderExtensionTemplateAsync(MODULE_NAME, 'view', templateParams);
detailsContent.innerHTML = template;
} else {
detailsContent.textContent = t`No profile selected`;
}
}
/**
* Callback for the /profile-genstream command
* Generates text using Connection Manager with streaming display support.
* @param {object} args Named arguments
* @param {string} value Unnamed argument (the prompt)
* @returns {Promise<string>} The generated text, optionally with formatted reasoning
*/
async function generateStreamCallback(args, value) {
if (!value) {
console.warn('WARN: No argument provided for /profile-genstream command');
return '';
}
// Check if Connection Manager is available
const context = getContext();
if (context.extensionSettings.disabledExtensions.includes('connection-manager')) {
toastr.error(t`Connection Manager is required for /profile-genstream. Use /gen or /genraw instead.`);
return '';
}
const profileIdOrName = args?.profile;
const includeReasoning = isTrueBoolean(args?.reasoning);
const systemPrompt = typeof args?.system == 'string' ? args.system : '';
const maxTokens = Number(args?.length ?? 2048) || 2048;
const lock = isTrueBoolean(args?.lock);
const generatingLabel = typeof args?.generating === 'string' ? args.generating : 'Generating...';
const completedLabel = typeof args?.completed === 'string' ? args.completed : 'Generated';
const enableStop = !isFalseBoolean(args?.stop);
const onStopClosure = args?.onStop instanceof SlashCommandClosure ? args.onStop : null;
const onCompleteClosure = args?.onComplete instanceof SlashCommandClosure ? args.onComplete : null;
// Parse delay: 'infinite' or negative = null (stay open), number = delay in ms
let completeDelay = 3000; // Default 3 seconds
if (args?.delay !== undefined) {
if (typeof args.delay === 'string' && args.delay.toLowerCase() === 'infinite') {
completeDelay = null; // Stay until user closes
} else {
const parsed = Number(args.delay);
if (!isNaN(parsed) && parsed >= 0) {
completeDelay = parsed;
} else if (!isNaN(parsed) && parsed < 0) {
completeDelay = null; // Negative = infinite
}
}
}
// Create abort controller for stop functionality (when stop is enabled)
const abortController = enableStop ? new AbortController() : null;
// Compose the stop handler: abort the request + optionally invoke user closure
const onStopHandler = enableStop ? async () => {
abortController.abort();
if (onStopClosure) {
try {
const localClosure = onStopClosure.getCopy();
localClosure.onProgress = () => { };
await localClosure.execute();
} catch (e) {
console.error('[GenStream] Error executing onStop closure', e);
}
}
} : null;
try {
if (lock) {
deactivateSendButtons();
}
// Determine which profile to use
// Use the currently selected profile if no profile specified
let effectiveProfileId = context.extensionSettings.connectionManager.selectedProfile;
const profiles = context.extensionSettings.connectionManager.profiles;
if (profileIdOrName) {
// Use try to find profile by id first, then fuse search
const profile = profiles.find(p => p.id === profileIdOrName);
if (profile) {
effectiveProfileId = profile.id;
} else {
const keys = [
{ name: 'name', weight: 10 },
];
const fuseResults = performFuzzySearch('profile', profiles, keys, profileIdOrName);
if (fuseResults.length > 0) {
effectiveProfileId = fuseResults[0].item.id;
} else {
toastr.warning(t`Connection profile not found: ${profileIdOrName}`);
return '';
}
}
}
if (!effectiveProfileId) {
toastr.error(t`No connection profile specified or selected. Use profile= argument or select a profile in Connection Manager.`);
return '';
}
// Create streaming display
const display = new StreamingDisplay();
display.show({
label: generatingLabel,
icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
onStop: onStopHandler,
});
const messages = [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
{ role: 'user', content: value },
];
let finalText = '';
let finalReasoning = '';
/** Gets the final (if requested, formatted) text to return for this command @returns {string} */
function buildResultText() {
// Format output with reasoning if requested
if (includeReasoning && finalReasoning) {
const { formatted } = formatReasoning(finalReasoning, finalText);
return formatted;
}
return finalText;
}
try {
// Attempt streaming first
const streamResponse = await ConnectionManagerRequestService.sendRequest(
effectiveProfileId,
messages,
maxTokens,
{ extractData: true, includePreset: true, stream: true, signal: abortController?.signal ?? undefined },
);
if (typeof streamResponse === 'function') {
const generator = streamResponse();
for await (const chunk of generator) {
finalText = chunk.text;
finalReasoning = chunk.state?.reasoning || '';
display.updateReasoning(finalReasoning);
display.updateContent(finalText);
}
} else {
// Non-streaming fallback within the try block
const extracted = streamResponse;
finalText = extracted?.content || '';
finalReasoning = extracted?.reasoning || '';
if (finalReasoning) {
display.updateReasoning(finalReasoning);
}
display.updateContent(finalText);
}
} catch (error) {
// If the user clicked stop, don't retry — show stopped state and return empty
if (abortController?.signal?.aborted) {
display.markStopped({ label: `${generatingLabel} [Stopped]` });
return buildResultText();
}
console.warn('[Slash Commands] Streaming failed, falling back to non-streaming:', error);
display.hide({ instant: true });
// Retry with non-streaming
const response = await ConnectionManagerRequestService.sendRequest(
effectiveProfileId,
messages,
maxTokens,
{ extractData: true, includePreset: true, stream: false },
);
const extracted = /** @type {import('../../custom-request.js').ExtractedData} */ (response);
finalText = extracted?.content || '';
finalReasoning = extracted?.reasoning || '';
// Show quick non-streaming display
display.show({
label: generatingLabel,
icon: ConnectionManagerRequestService.getProfileIcon(effectiveProfileId),
});
if (finalReasoning) {
display.updateReasoning(finalReasoning);
}
display.updateContent(finalText);
}
// Mark as complete with delay (null = stay open until user closes)
display.complete({ label: completedLabel, delay: completeDelay });
// Invoke onComplete closure if provided
if (onCompleteClosure) {
try {
const localClosure = onCompleteClosure.getCopy();
localClosure.onProgress = () => { };
await localClosure.execute();
} catch (e) {
console.error('[GenStream] Error executing onComplete closure', e);
}
}
if (!finalText) {
toastr.warning(t`Generation returned empty result`);
return '';
}
return buildResultText();
} catch (err) {
console.error('Error on /genstream generation', err);
toastr.error(err.message, t`API Error`, { preventDuplicates: true });
return '';
} finally {
if (lock) {
activateSendButtons();
}
}
}
export async function init() {
extension_settings.connectionManager = extension_settings.connectionManager || structuredClone(DEFAULT_SETTINGS);
for (const key of Object.keys(DEFAULT_SETTINGS)) {
if (extension_settings.connectionManager[key] === undefined) {
extension_settings.connectionManager[key] = DEFAULT_SETTINGS[key];
}
}
const container = document.getElementById('rm_api_block');
const settings = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
container.insertAdjacentHTML('afterbegin', settings);
/** @type {HTMLSelectElement} */
// @ts-ignore
const profiles = document.getElementById('connection_profiles');
renderConnectionProfiles(profiles);
function toggleProfileSpecificButtons() {
const profileId = extension_settings.connectionManager.selectedProfile;
const profileSpecificButtons = ['update_connection_profile', 'reload_connection_profile', 'delete_connection_profile'];
profileSpecificButtons.forEach(id => document.getElementById(id).classList.toggle('disabled', !profileId));
}
toggleProfileSpecificButtons();
profiles.addEventListener('change', async function () {
const selectedProfile = profiles.selectedOptions[0];
if (!selectedProfile) {
// Safety net for preventing the command getting stuck
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
return;
}
const profileId = selectedProfile.value;
extension_settings.connectionManager.selectedProfile = profileId;
saveSettingsDebounced();
await renderDetailsContent(detailsContent);
toggleProfileSpecificButtons();
// None option selected
if (!profileId) {
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
return;
}
const profile = extension_settings.connectionManager.profiles.find(p => p.id === profileId);
if (!profile) {
console.log(`Profile not found: ${profileId}`);
return;
}
await applyConnectionProfile(profile);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
});
const reloadButton = document.getElementById('reload_connection_profile');
reloadButton.addEventListener('click', async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
console.log('No profile selected');
return;
}
await applyConnectionProfile(profile);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
toastr.success('Connection profile reloaded', '', { timeOut: 1500 });
});
const createButton = document.getElementById('create_connection_profile');
createButton.addEventListener('click', async () => {
const profile = await createConnectionProfile();
if (!profile) {
return;
}
extension_settings.connectionManager.profiles.push(profile);
extension_settings.connectionManager.selectedProfile = profile.id;
saveSettingsDebounced();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
});
const updateButton = document.getElementById('update_connection_profile');
updateButton.addEventListener('click', async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
console.log('No profile selected');
return;
}
const oldProfile = structuredClone(profile);
await updateConnectionProfile(profile);
await renderDetailsContent(detailsContent);
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, profile.name);
toastr.success('Connection profile updated', '', { timeOut: 1500 });
});
const deleteButton = document.getElementById('delete_connection_profile');
deleteButton.addEventListener('click', async () => {
await deleteConnectionProfile();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_LOADED, NONE);
});
const editButton = document.getElementById('edit_connection_profile');
editButton.addEventListener('click', async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
console.log('No profile selected');
return;
}
if (!Array.isArray(profile.exclude)) {
profile.exclude = [];
}
let saveChanges = false;
const sortByViewOrder = (a, b) => Object.keys(FANCY_NAMES).indexOf(a) - Object.keys(FANCY_NAMES).indexOf(b);
const commands = profile.mode === 'cc' ? CC_COMMANDS : TC_COMMANDS;
const settings = commands.slice().sort(sortByViewOrder).reduce((acc, command) => {
const fancyName = FANCY_NAMES[command];
acc[fancyName] = !profile.exclude.includes(command);
return acc;
}, {});
const template = $(await renderExtensionTemplateAsync(MODULE_NAME, 'edit', { name: profile.name, settings }));
let newName = await callGenericPopup(template, POPUP_TYPE.INPUT, profile.name, {
customButtons: [{
text: t`Save and Update`,
classes: ['popup-button-ok'],
result: POPUP_RESULT.AFFIRMATIVE,
action: () => {
saveChanges = true;
},
}],
});
// If it's cancelled, it will be false
if (!newName) {
return;
}
newName = DOMPurify.sanitize(String(newName));
if (!newName) {
toastr.error('Name cannot be empty.');
return;
}
if (profile.name !== newName && extension_settings.connectionManager.profiles.some(p => p.name === newName)) {
toastr.error('A profile with the same name already exists.');
return;
}
const newExcludeList = template.find('input[name="exclude"]:not(:checked)').map(function () {
return Object.entries(FANCY_NAMES).find(x => x[1] === String($(this).val()))?.[0];
}).get();
const oldProfile = structuredClone(profile);
if (newExcludeList.length !== profile.exclude.length || !newExcludeList.every(e => profile.exclude.includes(e))) {
profile.exclude = newExcludeList;
for (const command of newExcludeList) {
delete profile[command];
}
if (saveChanges) {
await updateConnectionProfile(profile);
} else {
toastr.info('Press "Update" to record them into the profile.', 'Included settings list updated');
}
}
if (profile.name !== newName) {
toastr.success('Connection profile renamed.');
profile.name = newName;
}
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
});
/** @type {HTMLElement} */
const viewDetails = document.getElementById('view_connection_profile');
const detailsContent = document.getElementById('connection_profile_details_content');
viewDetails.addEventListener('click', async () => {
viewDetails.classList.toggle('active');
detailsContent.classList.toggle('hidden');
await renderDetailsContent(detailsContent);
});
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile',
helpString: 'Switch to a connection profile or return the name of the current profile in no argument is provided. Use <code>&lt;None&gt;</code> to switch to no profile.',
returns: 'name of the profile',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Name of the connection profile',
enumProvider: profilesProvider,
isRequired: false,
}),
],
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'await',
description: 'Wait for the connection profile to be applied before returning.',
isRequired: false,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: commonEnumProviders.boolean('trueFalse')(),
}),
SlashCommandNamedArgument.fromProps({
name: 'timeout',
description: 'Maximum time to wait for the API connection to be established, in milliseconds. Set to 0 to disable. Only applies when await=true.',
isRequired: false,
typeList: [ARGUMENT_TYPE.NUMBER],
defaultValue: '2000',
}),
],
callback: async (args, value) => {
if (!value || typeof value !== 'string') {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
return NONE;
}
return profile.name;
}
if (value === NONE) {
profiles.selectedIndex = 0;
profiles.dispatchEvent(new Event('change'));
return NONE;
}
const profile = findProfileByName(value);
if (!profile) {
return '';
}
const shouldAwait = !isFalseBoolean(String(args?.await));
const awaitPromise = new Promise((resolve) => eventSource.once(event_types.CONNECTION_PROFILE_LOADED, resolve));
profiles.selectedIndex = Array.from(profiles.options).findIndex(o => o.value === profile.id);
profiles.dispatchEvent(new Event('change'));
if (shouldAwait) {
await awaitPromise;
// We should also await the connection to be established
const parsedTimeout = parseInt(args?.timeout?.toString());
const timeout = !isNaN(parsedTimeout) ? Math.max(0, parsedTimeout) : 2000;
if (timeout > 0) {
await waitUntilCondition(() => online_status !== 'no_connection', timeout, 100, { rejectOnTimeout: false });
}
}
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-list',
helpString: 'List all connection profile names.',
returns: 'list of profile names',
callback: () => JSON.stringify(extension_settings.connectionManager.profiles.map(p => p.name)),
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-create',
returns: 'name of the new profile',
helpString: 'Create a new connection profile using the current settings.',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'name of the new connection profile',
isRequired: true,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
callback: async (_args, name) => {
if (!name || typeof name !== 'string') {
toastr.warning('Please provide a name for the new connection profile.');
return '';
}
const profile = await createConnectionProfile(name);
if (!profile) {
return '';
}
extension_settings.connectionManager.profiles.push(profile);
extension_settings.connectionManager.selectedProfile = profile.id;
saveSettingsDebounced();
renderConnectionProfiles(profiles);
await renderDetailsContent(detailsContent);
await eventSource.emit(event_types.CONNECTION_PROFILE_CREATED, profile);
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-update',
helpString: 'Update the selected connection profile.',
callback: async () => {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
toastr.warning('No profile selected.');
return '';
}
const oldProfile = structuredClone(profile);
await updateConnectionProfile(profile);
await renderDetailsContent(detailsContent);
saveSettingsDebounced();
await eventSource.emit(event_types.CONNECTION_PROFILE_UPDATED, oldProfile, profile);
return profile.name;
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-get',
helpString: 'Get the details of the connection profile. Returns the selected profile if no argument is provided.',
returns: 'object of the selected profile',
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Name of the connection profile',
enumProvider: profilesProvider,
isRequired: false,
}),
],
callback: async (_args, value) => {
if (!value || typeof value !== 'string') {
const selectedProfile = extension_settings.connectionManager.selectedProfile;
const profile = extension_settings.connectionManager.profiles.find(p => p.id === selectedProfile);
if (!profile) {
return '';
}
return JSON.stringify(profile);
}
const profile = findProfileByName(value);
if (!profile) {
return '';
}
return JSON.stringify(profile);
},
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'profile-genstream',
callback: generateStreamCallback,
returns: t`generated text`,
namedArgumentList: [
new SlashCommandNamedArgument(
'lock', t`lock user input during generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', commonEnumProviders.boolean('onOff')(),
),
SlashCommandNamedArgument.fromProps({
name: 'profile',
description: t`connection profile ID to use for generation`,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.connectionProfiles(),
}),
SlashCommandNamedArgument.fromProps({
name: 'reasoning',
description: t`include formatted reasoning in the output`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'false',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
SlashCommandNamedArgument.fromProps({
name: 'system',
description: t`system prompt at the start`,
typeList: [ARGUMENT_TYPE.STRING],
}),
SlashCommandNamedArgument.fromProps({
name: 'length',
description: t`API response length in tokens`,
typeList: [ARGUMENT_TYPE.NUMBER],
defaultValue: '2048',
}),
SlashCommandNamedArgument.fromProps({
name: 'generating',
description: t`label/title for the generation display`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'Generating...',
}),
SlashCommandNamedArgument.fromProps({
name: 'completed',
description: t`updated label/title for when generation completes`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'Generated',
}),
SlashCommandNamedArgument.fromProps({
name: 'delay',
description: t`auto-hide delay in ms after generation completes. Use "infinite" or negative to keep until manually closed`,
typeList: [ARGUMENT_TYPE.NUMBER],
defaultValue: '3000',
enumList: [
new SlashCommandEnumValue('infinite', 'Keep the streaming display open until manually closed', 'command', '♾️'),
new SlashCommandEnumValue('any delay in seconds', null, 'number', '⌚', () => true, input => input),
],
}),
SlashCommandNamedArgument.fromProps({
name: 'stop',
description: t`show a stop button on the streaming display that aborts generation when clicked`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
SlashCommandNamedArgument.fromProps({
name: 'onStop',
description: t`closure to execute when the stop button is clicked (in addition to aborting the request)`,
typeList: [ARGUMENT_TYPE.CLOSURE],
}),
SlashCommandNamedArgument.fromProps({
name: 'onComplete',
description: t`closure to execute after generation completes successfully`,
typeList: [ARGUMENT_TYPE.CLOSURE],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'prompt',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
}),
],
helpString: `
<div>
${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
</div>
<div>
${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
</div>
<div>
${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
</div>
<div>
${t`Use delay to control auto-hide behavior: number (ms), "infinite", or negative to keep the display open until manually closed. The display shows a green LED when complete.`}
</div>
<div>
${t`A stop button is shown by default (stop=true). Click it to abort generation and return whatever was streamed so far. Use stop=false to hide the stop button.`}
</div>
<div>
${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
</div>
<div>
${t`Example: <pre><code>/profile-genstream profile=my-profile-id reasoning=true Summarize the following text</code></pre>`}
</div>
<div>
${t`Example with infinite display: <pre><code>/profile-genstream delay=infinite Tell me a story</code></pre>`}
</div>
<div>
${t`Example with custom stop handler: <pre><code>/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story</code></pre>`}
</div>
`,
}));
}