', { class: 'avatar' }).append($('
', { src: asset.url, alt: displayName })));
+ }
+
+ assetBlock.addClass('asset-block');
+ return assetBlock;
}
+/**
+ * Builds and appends the menu section for a single asset type.
+ * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
+ * @returns {Promise
}
+ */
+async function buildAssetTypeSection(assetType) {
+ const assetTypeMenu = $('
', { id: `assets_${assetType}_div`, class: 'assets-list-div' });
+ assetTypeMenu.attr('data-type', assetType);
+ assetTypeMenu.append($('').text(KNOWN_TYPES[assetType] || assetType)).hide();
+
+ if (assetType == 'extension') {
+ assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
+ }
+
+ for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
+ const i = availableAssets[assetType].indexOf(asset);
+ const element = createAssetButton(asset, assetType, i);
+ const assetBlock = createAssetBlock(asset, assetType, element);
+
+ if (assetType === 'extension') {
+ const extensionBlockList = isOfficialExtension(asset.url)
+ ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
+ : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
+ extensionBlockList.append(assetBlock);
+ } else {
+ assetTypeMenu.append(assetBlock);
+ }
+ }
+
+ assetTypeMenu.appendTo('#assets_menu');
+ assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
+}
+
+/**
+ * Parses the fetched assets JSON and renders the full assets menu.
+ * @param {object[]} json Array of asset objects, each containing at least id, name, description, url and type fields
+ */
+async function populateAssetsMenu(json) {
+ availableAssets = {};
+ $('#assets_menu').empty();
+
+ console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
+
+ for (const i of json) {
+ if (availableAssets[i.type] === undefined)
+ availableAssets[i.type] = [];
+ availableAssets[i.type].push(i);
+ }
+
+ console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
+ // First extensions, then everything else
+ const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
+
+ $('#assets_type_select').empty();
+ $('#assets_search').val('');
+ $('#assets_type_select').append($(' ', { value: '', text: t`All` }));
+
+ for (const type of assetTypes) {
+ const text = translate(KNOWN_TYPES[type] || type);
+ const option = $(' ', { value: type, text: text });
+ $('#assets_type_select').append(option);
+ }
+
+ if (assetTypes.includes('extension')) {
+ $('#assets_type_select').val('extension');
+ }
+
+ $('#assets_type_select').off('change').on('change', filterAssets);
+ $('#assets_search').off('input').on('input', filterAssets);
+
+ for (const assetType of assetTypes) {
+ await buildAssetTypeSection(assetType);
+ }
+
+ filterAssets();
+ $('#assets_filters').show();
+ $('#assets_menu').show();
+}
+
+/**
+ * Downloads the assets list from the given URL and populates the menu. Shows error message if something goes wrong.
+ * @param {URL} url URL to fetch from
+ */
+async function downloadAssetsList(url) {
+ await updateCurrentAssets();
+ try {
+ const response = await fetch(url, { cache: 'no-cache' });
+ if (!response.ok) {
+ throw new Error('Cannot download the assets list.');
+ }
+ const json = await response.json();
+ if (!Array.isArray(json)) {
+ throw new Error('Assets list is not an array');
+ }
+ await populateAssetsMenu(json);
+ } catch (error) {
+ // Info hint if the user maybe... likely accidentally was trying to install an extension and we wanna help guide them? uwu :3
+ const installButton = $('#third_party_extension_button');
+ flashHighlight(installButton, 10_000);
+ toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
+
+ // Error logged after, to appear on top
+ console.error(error);
+ toastr.error('Problem with assets URL', 'Cannot get assets list');
+ $('#assets-connect-button').addClass('fa-plug-circle-exclamation');
+ $('#assets-connect-button').addClass('redOverlayGlow');
+ }
+}
+
+/**
+ * Previews the asset by opening its URL. If it's an audio asset, it plays a preview sound. Otherwise, it opens the URL in a new tab.
+ * @param {JQuery.Event} e Click event
+ */
function previewAsset(e) {
const href = $(this).attr('href');
const audioExtensions = ['.mp3', '.ogg', '.wav'];
@@ -295,6 +338,15 @@ function previewAsset(e) {
}
}
+/**
+ * Checks if the asset is already installed.
+ * For extensions, it checks if the extension name is in the list of installed extensions.
+ * For characters, it checks if any character has the same avatar URL.
+ * For other asset types, it checks if any installed asset of the same type has a URL that includes the filename.
+ * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
+ * @param {string} filename Name or ID of the asset
+ * @returns {boolean} True if the asset is installed, false otherwise
+ */
function isAssetInstalled(assetType, filename) {
let assetList = currentAssets[assetType];
@@ -316,15 +368,22 @@ function isAssetInstalled(assetType, filename) {
return false;
}
+/**
+ * Installs the asset by sending a request to the server to download it. If it's an extension, it uses the existing installExtension function.
+ * @param {string} url URL of the asset to download
+ * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
+ * @param {string} filename Name or ID of the asset
+ * @returns {Promise} True if the asset was successfully installed, false otherwise
+ */
async function installAsset(url, assetType, filename) {
console.debug(DEBUG_PREFIX, 'Downloading ', url);
const category = assetType;
try {
if (category === 'extension') {
console.debug(DEBUG_PREFIX, 'Installing extension ', url);
- await installExtension(url, false);
+ const result = await installExtension(url, false);
console.debug(DEBUG_PREFIX, 'Extension installed.');
- return;
+ return result;
}
const body = { url, category, filename };
@@ -340,16 +399,25 @@ async function installAsset(url, assetType, filename) {
console.debug(DEBUG_PREFIX, 'Importing character ', filename);
const blob = await result.blob();
const file = new File([blob], filename, { type: blob.type });
- await processDroppedFiles([file]);
+ const fileNameMap = new Map([[file, filename]]);
+ await processDroppedFiles([file], fileNameMap);
console.debug(DEBUG_PREFIX, 'Character downloaded.');
}
+ return true;
}
+ return false;
} catch (err) {
console.log(err);
- return [];
+ return false;
}
}
+/**
+ * Deletes the asset by sending a request to the server to delete it. If it's an extension, it uses the existing deleteExtension function.
+ * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
+ * @param {string} filename Name or ID of the asset
+ * @returns {Promise} True if the asset was successfully deleted, false otherwise
+ */
async function deleteAsset(assetType, filename) {
console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
const category = assetType;
@@ -358,6 +426,7 @@ async function deleteAsset(assetType, filename) {
console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
await deleteExtension(filename);
console.debug(DEBUG_PREFIX, 'Extension deleted.');
+ return true;
}
const body = { category, filename };
@@ -369,19 +438,37 @@ async function deleteAsset(assetType, filename) {
});
if (result.ok) {
console.debug(DEBUG_PREFIX, 'Deletion success.');
+ return true;
}
+ return false;
} catch (err) {
console.log(err);
- return [];
+ return false;
}
}
+/**
+ * Opens the character browser popup, which shows all available characters and allows downloading them.
+ * @param {boolean} forceDefault If true, it uses the default ASSETS_JSON_URL instead of the one from the input field.
+ * @returns {Promise}
+ */
async function openCharacterBrowser(forceDefault) {
const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
+ if (!isValidUrl(url)) {
+ toastr.error('Please enter a valid URL');
+ return;
+ }
const fetchResult = await fetch(url, { cache: 'no-cache' });
+ if (!fetchResult.ok) {
+ toastr.error('Cannot download the assets list.');
+ return;
+ }
const json = await fetchResult.json();
- const characters = json.filter(x => x.type === 'character');
-
+ if (!Array.isArray(json)) {
+ toastr.error('Assets list is not an array');
+ return;
+ }
+ const characters = json.filter(x => x && x.type === 'character');
if (!characters.length) {
toastr.error('No characters found in the assets list', 'Character browser');
return;
@@ -398,12 +485,19 @@ async function openCharacterBrowser(forceDefault) {
downloadButton.toggle(!isInstalled).on('click', async () => {
downloadButton.toggleClass('fa-download fa-spinner fa-spin');
- await installAsset(character.url, 'character', character.id);
- downloadButton.hide();
- checkMark.show();
+ const result = await installAsset(character.url, 'character', character.id);
+ if (result) {
+ downloadButton.hide();
+ checkMark.show();
+ } else {
+ downloadButton.toggleClass('fa-download fa-spinner fa-spin');
+ }
});
- checkMark.toggle(isInstalled);
+ checkMark.toggle(isInstalled).on('click', async () => {
+ toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
+ await SlashCommandParser.commands.go.callback(null, character.id);
+ });
listElement.append(characterElement);
}
@@ -435,7 +529,7 @@ async function updateCurrentAssets() {
//#############################//
// This function is called when the extension is loaded
-jQuery(async () => {
+export async function init() {
// This is an example of loading HTML from a file
const windowTemplate = await renderExtensionTemplateAsync(MODULE_NAME, 'window', {});
const windowHtml = $(windowTemplate);
@@ -457,11 +551,16 @@ jQuery(async () => {
const connectButton = windowHtml.find('#assets-connect-button');
connectButton.on('click', async function () {
- const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
- const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
+ const urlString = String(assetsJsonUrl.val()).trim();
+ if (!isValidUrl(urlString)) {
+ toastr.error('Please enter a valid URL');
+ return;
+ }
+ const url = new URL(urlString);
+ const rememberKey = `Assets_SkipConfirm_${getStringHash(url.href)}`;
const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
- const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '' + t`Are you sure you want to connect to the following url?` + ` ${url} `, {
+ const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '' + t`Are you sure you want to connect to the following url?` + ` ${escapeHtml(url.href)} `, {
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
onClose: popup => {
if (popup.result) {
@@ -480,7 +579,7 @@ jQuery(async () => {
connectButton.addClass('fa-plug-circle-check');
} catch (error) {
console.error('Error:', error);
- toastr.error(`Cannot get assets list from ${url}`);
+ toastr.error(`Cannot get assets list from ${url.href}`);
connectButton.removeClass('fa-plug-circle-check');
connectButton.addClass('fa-plug-circle-exclamation');
connectButton.removeClass('redOverlayGlow');
@@ -496,4 +595,4 @@ jQuery(async () => {
eventSource.on(event_types.OPEN_CHARACTER_LIBRARY, async (forceDefault) => {
openCharacterBrowser(forceDefault);
});
-});
+}
diff --git a/public/scripts/extensions/assets/installation.html b/public/scripts/extensions/assets/installation.html
index 33e1f644c..56eb8e305 100644
--- a/public/scripts/extensions/assets/installation.html
+++ b/public/scripts/extensions/assets/installation.html
@@ -1,4 +1,22 @@
To download extensions from this page, you need to have Git installed.
Click the icon to visit the Extension's repo for tips on how to use it.
-
\ No newline at end of file
+
+
@@ -504,6 +507,7 @@ jQuery(async function () {
'chutes': SECRET_KEYS.CHUTES,
'electronhub': SECRET_KEYS.ELECTRONHUB,
'pollinations': SECRET_KEYS.POLLINATIONS,
+ 'workers_ai': SECRET_KEYS.WORKERS_AI,
};
if (chatCompletionApis[api] && secret_state[chatCompletionApis[api]]) {
@@ -580,7 +584,7 @@ jQuery(async function () {
}
async function addRemoteEndpointModels() {
- async function processEndpoint(api, url) {
+ async function processEndpoint(api, url, additionalParams = {}) {
const dropdown = document.getElementById('caption_multimodal_model');
if (!(dropdown instanceof HTMLSelectElement)) {
return;
@@ -591,7 +595,8 @@ jQuery(async function () {
const options = Array.from(dropdown.options);
const response = await fetch(url, {
method: 'POST',
- headers: getRequestHeaders({ omitContentType: true }),
+ headers: getRequestHeaders(),
+ body: JSON.stringify(additionalParams),
});
if (!response.ok) {
return;
@@ -620,6 +625,7 @@ jQuery(async function () {
await processEndpoint('mistral', '/api/backends/chat-completions/multimodal-models/mistral');
await processEndpoint('xai', '/api/backends/chat-completions/multimodal-models/xai');
await processEndpoint('moonshot', '/api/backends/chat-completions/multimodal-models/moonshot');
+ await processEndpoint('workers_ai', '/api/backends/chat-completions/multimodal-models/workers_ai', { workers_ai_account_id: oai_settings.workers_ai_account_id });
}
await addSettings();
@@ -804,4 +810,4 @@ jQuery(async function () {
}));
document.body.classList.add('caption');
-});
+}
diff --git a/public/scripts/extensions/caption/manifest.json b/public/scripts/extensions/caption/manifest.json
index 56b03acb0..f5099a01c 100644
--- a/public/scripts/extensions/caption/manifest.json
+++ b/public/scripts/extensions/caption/manifest.json
@@ -9,5 +9,8 @@
"css": "style.css",
"author": "Cohee#1207",
"version": "1.0.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/caption/settings.html b/public/scripts/extensions/caption/settings.html
index fe0c2a3c6..011759a12 100644
--- a/public/scripts/extensions/caption/settings.html
+++ b/public/scripts/extensions/caption/settings.html
@@ -20,6 +20,7 @@
AI/ML API
Chutes
Claude
+
Cloudflare Workers AI
Cohere
Custom (OpenAI-compatible)
Electron Hub
@@ -53,7 +54,14 @@
c4ai-aya-vision-8b
c4ai-aya-vision-32b
command-a-vision-07-2025
+
gpt-5.5
+
gpt-5.5-2026-04-23
gpt-5.4
+
gpt-5.4-2026-03-05
+
gpt-5.4-mini
+
gpt-5.4-mini-2026-03-17
+
gpt-5.4-nano
+
gpt-5.4-nano-2026-03-17
gpt-5.3-chat-latest
gpt-5.2
gpt-5.2-2025-12-11
@@ -88,6 +96,7 @@
o4-mini-2025-04-16
gpt-4.5-preview
gpt-4.5-preview-2025-02-27
+
claude-opus-4-7
claude-opus-4-6
claude-opus-4-5
claude-opus-4-5-20251101
@@ -144,6 +153,11 @@
gemini-2.0-flash-lite-preview
learnlm-2.0-flash-experimental
gemini-robotics-er-1.5-preview
+
gemma-4-31b-it
+
gemma-4-26b-a4b-it
+
gemma-3-27b-it
+
gemma-3-12b-it
+
gemma-3-4b-it
gemini-3.1-pro-preview
gemini-3.1-flash-lite-preview
gemini-3.1-flash-image-preview
@@ -174,6 +188,7 @@
mistral-small3.2
llama3.2-vision
llama4
+
glm-5v-turbo
glm-4.6v
glm-4.6v-flashx
glm-4.6v-flash
diff --git a/public/scripts/extensions/connection-manager/index.js b/public/scripts/extensions/connection-manager/index.js
index e809677ee..6a4c2b0a0 100644
--- a/public/scripts/extensions/connection-manager/index.js
+++ b/public/scripts/extensions/connection-manager/index.js
@@ -1,7 +1,7 @@
import { DOMPurify, Fuse } from '../../../lib.js';
-import { event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
-import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
+import { activateSendButtons, deactivateSendButtons, event_types, eventSource, main_api, online_status, saveSettingsDebounced } from '../../../script.js';
+import { extension_settings, getContext, renderExtensionTemplateAsync } from '../../extensions.js';
import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from '../../popup.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from '../../slash-commands/SlashCommandAbortController.js';
@@ -9,11 +9,16 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandDebugController } from '../../slash-commands/SlashCommandDebugController.js';
import { enumTypes, SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
+import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from '../../slash-commands/SlashCommandScope.js';
-import { collapseSpaces, getUniqueName, isFalseBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
+import { collapseSpaces, getUniqueName, isFalseBoolean, isTrueBoolean, uuidv4, waitUntilCondition } from '../../utils.js';
import { t } from '../../i18n.js';
import { getSecretLabelById } from '../../secrets.js';
+import { performFuzzySearch } from '/scripts/power-user.js';
+import { StreamingDisplay } from '/scripts/streaming-display.js';
+import { ConnectionManagerRequestService } from '../shared.js';
+import { formatReasoning } from '/scripts/reasoning.js';
const MODULE_NAME = 'connection-manager';
const NONE = '
';
@@ -474,7 +479,223 @@ async function renderDetailsContent(detailsContent) {
}
}
-(async function () {
+/**
+ * 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} 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)) {
@@ -824,4 +1045,114 @@ async function renderDetailsContent(detailsContent) {
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: `
+
+ ${t`Generates text using Connection Manager with streaming display. Shows live generation progress including reasoning (thinking) and content.`}
+
+
+ ${t`Requires Connection Manager extension. Uses the currently selected profile or the specified profile= argument.`}
+
+
+ ${t`Use reasoning=true to include formatted reasoning in the output (using the defined reasoning template). This can be parsed later with /reasoning-parse.`}
+
+
+ ${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.`}
+
+
+ ${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.`}
+
+
+ ${t`Use onStop and onComplete closures for custom behavior when generation is stopped or completes.`}
+
+
+ ${t`Example:
/profile-genstream profile=my-profile-id reasoning=true Summarize the following text`}
+
+
+ ${t`Example with infinite display:
/profile-genstream delay=infinite Tell me a story`}
+
+
+ ${t`Example with custom stop handler:
/profile-genstream onStop={: /echo "Generation stopped!" :} Tell me a story`}
+
+ `,
+ }));
+}
diff --git a/public/scripts/extensions/connection-manager/manifest.json b/public/scripts/extensions/connection-manager/manifest.json
index 601f8970c..0bbffc0d4 100644
--- a/public/scripts/extensions/connection-manager/manifest.json
+++ b/public/scripts/extensions/connection-manager/manifest.json
@@ -7,5 +7,8 @@
"css": "style.css",
"author": "Cohee1207",
"version": "1.0.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js
index 185bea349..3ef1ecf8f 100644
--- a/public/scripts/extensions/expressions/index.js
+++ b/public/scripts/extensions/expressions/index.js
@@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateQuietPrompt, generateRaw,
import { dragElement, isMobile } from '../../RossAscends-mods.js';
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
-import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean } from '../../utils.js';
+import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean, includesIgnoreCaseAndAccents } from '../../utils.js';
import { hideMutedSprites, selected_group } from '../../group-chats.js';
import { isJsonSchemaSupported } from '../../textgen-settings.js';
import { debounce_timeout } from '../../constants.js';
@@ -786,6 +786,32 @@ async function setSpriteSlashCommand({ type }, searchTerm) {
return label;
}
+/**
+ * @param {string} expressionName - Label of the expression to set as fallback
+ */
+function setFallBackExpressionSlashCommand(args, expressionName) {
+ expressionName = expressionName.trim().toLowerCase();
+
+ if (!expressionName) return extension_settings?.expressions?.fallback_expression || '';
+
+ const select = /** @type {HTMLSelectElement} */(document.getElementById('expression_fallback'));
+ const fallbackExpressions = Array
+ .from(select?.options || [])
+ .map(option => option.value)
+ .filter(expression => expression?.length > 0);
+
+ const expressionMatch = fallbackExpressions.find(expression => includesIgnoreCaseAndAccents(expression, expressionName));
+
+ if (!expressionMatch) {
+ toastr.warning(t`No expression found for search term ${expressionName}`, t`Set Fallback Expression`);
+ return '';
+ }
+
+ $(select).val(expressionMatch).trigger('change');
+
+ return expressionMatch;
+}
+
/**
* Returns the sprite folder name (including override) for a character.
* @param {object} char Character object
@@ -2140,7 +2166,7 @@ function migrateSettings() {
}
}
-(async function () {
+export async function init() {
function addExpressionImage() {
const html = `
@@ -2316,6 +2342,42 @@ function migrateSettings() {
helpString: 'Force sets the expression for the current character.',
returns: 'The currently set expression label after setting it.',
}));
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'expression-fallback',
+ callback: setFallBackExpressionSlashCommand,
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'expression label to set',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: false,
+ enumProvider: () => [
+ new SlashCommandEnumValue('#none', 'Sets the fallback expression to no image'),
+ new SlashCommandEnumValue('#emoji', 'Sets the fallback expression to emojis'),
+ ...localEnumProviders.expressions(),
+ ],
+ }),
+ ],
+ helpString: `
+
+ Gets the currently selected expression fallback for all characters.
+ If a valid expression label is sent, it will be set as the new fallback.
+
+
+ `,
+ returns: 'The currently set expression label after setting it.',
+ }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'expression-folder-override',
aliases: ['spriteoverride', 'costume'],
@@ -2511,4 +2573,4 @@ function migrateSettings() {
`,
}));
-})();
+}
diff --git a/public/scripts/extensions/expressions/manifest.json b/public/scripts/extensions/expressions/manifest.json
index 2c8076e45..d063c427f 100644
--- a/public/scripts/extensions/expressions/manifest.json
+++ b/public/scripts/extensions/expressions/manifest.json
@@ -9,5 +9,8 @@
"css": "style.css",
"author": "Cohee#1207",
"version": "1.0.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/gallery/index.js b/public/scripts/extensions/gallery/index.js
index ecd20d088..6a581cbcb 100644
--- a/public/scripts/extensions/gallery/index.js
+++ b/public/scripts/extensions/gallery/index.js
@@ -818,7 +818,7 @@ function addGalleryWandButton() {
}
// On extension load, ensure the settings are initialized
-(function () {
+export async function init() {
initSettings();
eventSource.on(event_types.CHARACTER_RENAMED, (oldAvatar, newAvatar) => {
const context = SillyTavern.getContext();
@@ -850,4 +850,4 @@ function addGalleryWandButton() {
}),
);
addGalleryWandButton();
-})();
+}
diff --git a/public/scripts/extensions/gallery/manifest.json b/public/scripts/extensions/gallery/manifest.json
index 0ba46c135..8fcf0e83b 100644
--- a/public/scripts/extensions/gallery/manifest.json
+++ b/public/scripts/extensions/gallery/manifest.json
@@ -8,5 +8,8 @@
"css": "style.css",
"author": "City-Unit",
"version": "1.5.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/memory/index.js b/public/scripts/extensions/memory/index.js
index e9d3da624..af268ba58 100644
--- a/public/scripts/extensions/memory/index.js
+++ b/public/scripts/extensions/memory/index.js
@@ -1063,7 +1063,7 @@ function setupListeners() {
});
}
-jQuery(async function () {
+export async function init() {
async function addExtensionControls() {
const settingsHtml = await renderExtensionTemplateAsync('memory', 'settings', { defaultSettings });
$('#summarize_container').append(settingsHtml);
@@ -1128,4 +1128,4 @@ jQuery(async function () {
() => summaryMacroHandler(),
'Returns the latest memory/summary from the current chat.');
}
-});
+}
diff --git a/public/scripts/extensions/memory/manifest.json b/public/scripts/extensions/memory/manifest.json
index 76deda81e..791e43ffe 100644
--- a/public/scripts/extensions/memory/manifest.json
+++ b/public/scripts/extensions/memory/manifest.json
@@ -9,5 +9,8 @@
"css": "style.css",
"author": "Cohee#1207",
"version": "1.0.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/quick-reply/index.js b/public/scripts/extensions/quick-reply/index.js
index 42d5f2289..fd699945b 100644
--- a/public/scripts/extensions/quick-reply/index.js
+++ b/public/scripts/extensions/quick-reply/index.js
@@ -169,7 +169,7 @@ const handleCharChange = () => {
settings.charConfig = charConfig;
};
-const init = async () => {
+export async function init() {
await loadSets();
await loadSettings();
log('settings: ', settings);
@@ -214,7 +214,8 @@ const init = async () => {
eventSource.on(event_types.APP_READY, async () => await finalizeInit());
globalThis.quickReplyApi = quickReplyApi;
-};
+}
+
const finalizeInit = async () => {
debug('executing startup');
await autoExec.handleStartup();
@@ -229,7 +230,7 @@ const finalizeInit = async () => {
isReady = true;
debug('READY');
};
-await init();
+
const purgeCharacterQuickReplySets = ({ character }) => {
// Remove the character's Quick Reply Sets from the settings.
diff --git a/public/scripts/extensions/quick-reply/manifest.json b/public/scripts/extensions/quick-reply/manifest.json
index 4c773fe11..31cdff787 100644
--- a/public/scripts/extensions/quick-reply/manifest.json
+++ b/public/scripts/extensions/quick-reply/manifest.json
@@ -7,5 +7,8 @@
"css": "style.css",
"author": "RossAscends#1779",
"version": "2.0.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/regex/index.js b/public/scripts/extensions/regex/index.js
index 60f8e71ba..ca51b606c 100644
--- a/public/scripts/extensions/regex/index.js
+++ b/public/scripts/extensions/regex/index.js
@@ -1639,7 +1639,7 @@ async function checkCharEmbeddedRegexScripts() {
function notifyReloadCurrentChat(presetName) {
toastr.info(
t`Reload the chat for regex to take effect` + '' + t`Click here to reload immediately` + ' ',
- t`Preset '${presetName}' contains enabled regex scripts`,
+ t`Preset '${escapeHtml(presetName)}' contains enabled regex scripts`,
{
timeOut: 5000,
escapeHtml: false,
@@ -1709,7 +1709,7 @@ function onPresetRenamed({ apiId, oldName, newName }) {
// Workaround for loading in sequence with other extensions
// NOTE: Always puts extension at the top of the list, but this is fine since it's static
-jQuery(async () => {
+export async function init() {
if (!Array.isArray(extension_settings.regex)) {
extension_settings.regex = [];
}
@@ -2068,6 +2068,37 @@ jQuery(async () => {
],
helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.',
}));
+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
+ name: 'regex-state',
+ /** @param {object} _ @param {string} name */
+ callback: (_, name) => {
+ if (!name) {
+ toastr.warning('No regex script name provided.');
+ return '';
+ }
+
+ const scripts = getRegexScripts();
+ const script = scripts.find(s => equalsIgnoreCaseAndAccents(s.scriptName, name));
+
+ if (!script) {
+ toastr.warning(`Regex script "${name}" not found.`);
+ return '';
+ }
+
+ return script.disabled ? 'false' : 'true';
+ },
+ returns: 'true (for enabled) or false (for disabled)',
+ unnamedArgumentList: [
+ SlashCommandArgument.fromProps({
+ description: 'script name',
+ typeList: [ARGUMENT_TYPE.STRING],
+ isRequired: true,
+ enumProvider: localEnumProviders.regexScripts,
+ }),
+ ],
+ helpString: 'Returns the current state of a regex script.',
+ }));
+
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'regex-toggle',
callback: toggleRegexCallback,
@@ -2123,4 +2154,4 @@ jQuery(async () => {
presetManager.setupEventListeners();
presetManager.registerSlashCommands();
-});
+}
diff --git a/public/scripts/extensions/regex/manifest.json b/public/scripts/extensions/regex/manifest.json
index d2e4215be..431af371c 100644
--- a/public/scripts/extensions/regex/manifest.json
+++ b/public/scripts/extensions/regex/manifest.json
@@ -7,5 +7,8 @@
"css": "style.css",
"author": "kingbri",
"version": "1.0.0",
- "homePage": "https://github.com/SillyTavern/SillyTavern"
+ "homePage": "https://github.com/SillyTavern/SillyTavern",
+ "hooks": {
+ "activate": "init"
+ }
}
diff --git a/public/scripts/extensions/shared.js b/public/scripts/extensions/shared.js
index 420495161..81224859e 100644
--- a/public/scripts/extensions/shared.js
+++ b/public/scripts/extensions/shared.js
@@ -1,4 +1,4 @@
-import { CONNECT_API_MAP, getRequestHeaders } from '../../script.js';
+import { CONNECT_API_MAP, createModelIcon, getRequestHeaders } from '../../script.js';
import { extension_settings, openThirdPartyExtensionMenu } from '../extensions.js';
import { t } from '../i18n.js';
import { oai_settings, proxies, ZAI_ENDPOINT } from '../openai.js';
@@ -126,6 +126,10 @@ export async function getMultimodalCaption(base64Img, prompt) {
requestBody.zai_endpoint = oai_settings.zai_endpoint || ZAI_ENDPOINT.COMMON;
}
+ if (extension_settings.caption.multimodal_api === 'workers_ai') {
+ requestBody.workers_ai_account_id = oai_settings.workers_ai_account_id;
+ }
+
function getEndpointUrl() {
switch (extension_settings.caption.multimodal_api) {
case 'google':
@@ -283,6 +287,10 @@ function throwIfInvalidModel(useReverseProxy) {
if (multimodalApi === 'pollinations' && !secret_state[SECRET_KEYS.POLLINATIONS]) {
throw new Error('Pollinations API key is not set.');
}
+
+ if (multimodalApi === 'workers_ai' && (!secret_state[SECRET_KEYS.WORKERS_AI] || !oai_settings.workers_ai_account_id)) {
+ throw new Error('Workers AI API key or account ID is not set.');
+ }
}
/**
@@ -435,10 +443,12 @@ export class ConnectionManagerRequestService {
max_tokens: maxTokens,
model: profile.model,
chat_completion_source: selectedApiMap.source,
+ secret_id: profile['secret-id'],
custom_url: profile['api-url'],
vertexai_region: profile['api-url'],
zai_endpoint: profile['api-url'],
siliconflow_endpoint: profile['api-url'],
+ minimax_endpoint: profile['api-url'],
reverse_proxy: proxyPreset?.url,
proxy_password: proxyPreset?.password,
custom_prompt_post_processing: profile['prompt-post-processing'],
@@ -459,6 +469,7 @@ export class ConnectionManagerRequestService {
model: profile.model,
api_type: selectedApiMap.type,
api_server: profile['api-url'],
+ secret_id: profile['secret-id'],
...overridePayload,
}, {
instructName: includeInstruct ? profile.instruct : undefined,
@@ -533,6 +544,29 @@ export class ConnectionManagerRequestService {
return profile;
}
+ /**
+ * Creates a model icon Image element for the given profile (or the currently selected profile).
+ * Returns null if the profile is not found, has no API, or Connection Manager is unavailable.
+ * @param {string} [profileId] - Profile ID. If omitted, uses the currently selected profile.
+ * @returns {HTMLImageElement | null}
+ */
+ static getProfileIcon(profileId) {
+ if ((SillyTavern.getContext()).extensionSettings.disabledExtensions.includes('connection-manager')) {
+ return null;
+ }
+
+ const id = profileId ?? (SillyTavern.getContext()).extensionSettings.connectionManager.selectedProfile;
+ if (!id) return null;
+
+ try {
+ const profile = this.getProfile(id);
+ if (!profile?.api) return null;
+ return createModelIcon(profile.api, profile.model);
+ } catch {
+ return null;
+ }
+ }
+
/**
* @param {import('./connection-manager/index.js').ConnectionProfile?} [profile]
* @returns {boolean}
diff --git a/public/scripts/extensions/stable-diffusion/button.html b/public/scripts/extensions/stable-diffusion/button.html
index 2962ff4f4..c16cb15b7 100644
--- a/public/scripts/extensions/stable-diffusion/button.html
+++ b/public/scripts/extensions/stable-diffusion/button.html
@@ -2,7 +2,3 @@
Generate Image
-