import { callPopup, eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, substituteParams, renderTemplate, animation_duration } from '../script.js'; import { hideLoader, showLoader } from './loader.js'; import { isSubsetOf } from './utils.js'; export { getContext, getApiUrl, loadExtensionSettings, runGenerationInterceptors, doExtrasFetch, modules, extension_settings, ModuleWorkerWrapper, }; export let extensionNames = []; let manifests = {}; const defaultUrl = 'http://localhost:5100'; let saveMetadataTimeout = null; export function saveMetadataDebounced() { const context = getContext(); const groupId = context.groupId; const characterId = context.characterId; if (saveMetadataTimeout) { console.debug('Clearing save metadata timeout'); clearTimeout(saveMetadataTimeout); } saveMetadataTimeout = setTimeout(async () => { const newContext = getContext(); if (groupId !== newContext.groupId) { console.warn('Group changed, not saving metadata'); return; } if (characterId !== newContext.characterId) { console.warn('Character changed, not saving metadata'); return; } console.debug('Saving metadata...'); newContext.saveMetadata(); console.debug('Saved metadata...'); }, 1000); } export const extensionsHandlebars = Handlebars.create(); /** * Provides an ability for extensions to render HTML templates. * Templates sanitation and localization is forced. * @param {string} extensionName Extension name * @param {string} templateId Template ID * @param {object} templateData Additional data to pass to the template * @returns {string} Rendered HTML */ export function renderExtensionTemplate(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { return renderTemplate(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); } /** * Registers a Handlebars helper for use in extensions. * @param {string} name Handlebars helper name * @param {function} helper Handlebars helper function */ export function registerExtensionHelper(name, helper) { extensionsHandlebars.registerHelper(name, helper); } /** * Applies handlebars extension helpers to a message. * @param {number} messageId Message index in the chat. */ export function processExtensionHelpers(messageId) { const context = getContext(); const message = context.chat[messageId]; if (!message?.mes || typeof message.mes !== 'string') { return; } // Don't waste time if there are no mustaches if (!substituteParams(message.mes).includes('{{')) { return; } try { const template = extensionsHandlebars.compile(substituteParams(message.mes), { noEscape: true }); message.mes = template({}); } catch { // Ignore } } // Disables parallel updates class ModuleWorkerWrapper { constructor(callback) { this.isBusy = false; this.callback = callback; } // Called by the extension async update(...args) { // Don't touch me I'm busy... if (this.isBusy) { return; } // I'm free. Let's update! try { this.isBusy = true; await this.callback(...args); } finally { this.isBusy = false; } } } const extension_settings = { apiUrl: defaultUrl, apiKey: '', autoConnect: false, notifyUpdates: false, disabledExtensions: [], expressionOverrides: [], memory: {}, note: { default: '', chara: [], wiAddition: [], }, caption: { refine_mode: false, }, expressions: { /** @type {string[]} */ custom: [], }, dice: {}, regex: [], tts: {}, sd: { prompts: {}, character_prompts: {}, }, chromadb: {}, translate: {}, objective: {}, quickReply: {}, randomizer: { controls: [], fluctuation: 0.1, enabled: false, }, speech_recognition: {}, rvc: {}, hypebot: {}, vectors: {}, variables: { global: {}, }, }; let modules = []; let activeExtensions = new Set(); const getContext = () => window['SillyTavern'].getContext(); const getApiUrl = () => extension_settings.apiUrl; let connectedToApi = false; function showHideExtensionsMenu() { // Get the number of menu items that are not hidden const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0; // We have menu items, so we can stop checking if (hasMenuItems) { clearInterval(menuInterval); } // Show or hide the menu button $('#extensionsMenuButton').toggle(hasMenuItems); } // Periodically check for new extensions const menuInterval = setInterval(showHideExtensionsMenu, 1000); async function doExtrasFetch(endpoint, args) { if (!args) { args = {}; } if (!args.method) { Object.assign(args, { method: 'GET' }); } if (!args.headers) { args.headers = {}; } Object.assign(args.headers, { 'Authorization': `Bearer ${extension_settings.apiKey}`, }); const response = await fetch(endpoint, args); return response; } async function discoverExtensions() { try { const response = await fetch('/api/extensions/discover'); if (response.ok) { const extensions = await response.json(); return extensions; } else { return []; } } catch (err) { console.error(err); return []; } } function onDisableExtensionClick() { const name = $(this).data('name'); disableExtension(name); } function onEnableExtensionClick() { const name = $(this).data('name'); enableExtension(name); } async function enableExtension(name) { extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name); await saveSettings(); location.reload(); } async function disableExtension(name) { extension_settings.disabledExtensions.push(name); await saveSettings(); location.reload(); } async function getManifests(names) { const obj = {}; const promises = []; for (const name of names) { const promise = new Promise((resolve, reject) => { fetch(`/scripts/extensions/${name}/manifest.json`).then(async response => { if (response.ok) { const json = await response.json(); obj[name] = json; resolve(); } else { reject(); } }).catch(err => { reject(); console.log('Could not load manifest.json for ' + name, err); }); }); promises.push(promise); } await Promise.allSettled(promises); return obj; } async function activateExtensions() { const extensions = Object.entries(manifests).sort((a, b) => a[1].loading_order - b[1].loading_order); const promises = []; for (let entry of extensions) { const name = entry[0]; const manifest = entry[1]; const elementExists = document.getElementById(name) !== null; if (elementExists || activeExtensions.has(name)) { continue; } // all required modules are active (offline extensions require none) if (isSubsetOf(modules, manifest.requires)) { try { const isDisabled = extension_settings.disabledExtensions.includes(name); const li = document.createElement('li'); if (!isDisabled) { const promise = Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]); promise .then(() => activeExtensions.add(name)) .catch(err => console.log('Could not activate extension: ' + name, err)); promises.push(promise); } else { li.classList.add('disabled'); } li.id = name; li.innerText = manifest.display_name; $('#extensions_list').append(li); } catch (error) { console.error(`Could not activate extension: ${name}`); console.error(error); } } } await Promise.allSettled(promises); } async function connectClickHandler() { const baseUrl = $('#extensions_url').val(); extension_settings.apiUrl = String(baseUrl); const testApiKey = $('#extensions_api_key').val(); extension_settings.apiKey = String(testApiKey); saveSettingsDebounced(); await connectToApi(baseUrl); } function autoConnectInputHandler() { const value = $(this).prop('checked'); extension_settings.autoConnect = !!value; if (value && !connectedToApi) { $('#extensions_connect').trigger('click'); } saveSettingsDebounced(); } function addExtensionsButtonAndMenu() { const buttonHTML = '
'; const extensionsMenuHTML = ''; $(document.body).append(extensionsMenuHTML); $('#leftSendForm').prepend(buttonHTML); const button = $('#extensionsMenuButton'); const dropdown = $('#extensionsMenu'); //dropdown.hide(); let popper = Popper.createPopper(button.get(0), dropdown.get(0), { placement: 'top-start', }); $(button).on('click', function () { if (dropdown.is(':visible')) { dropdown.fadeOut(animation_duration); } else { dropdown.fadeIn(animation_duration); } popper.update(); }); $('html').on('click', function (e) { const clickTarget = $(e.target); const noCloseTargets = ['#sd_gen', '#extensionsMenuButton']; if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) { $(dropdown).fadeOut(animation_duration); } }); } function notifyUpdatesInputHandler() { extension_settings.notifyUpdates = !!$('#extensions_notify_updates').prop('checked'); saveSettingsDebounced(); if (extension_settings.notifyUpdates) { checkForExtensionUpdates(true); } } /* $(document).on('click', function (e) { const target = $(e.target); if (target.is(dropdown)) return; if (target.is(button) && dropdown.is(':hidden')) { dropdown.toggle(200); popper.update(); } if (target !== dropdown && target !== button && dropdown.is(":visible")) { dropdown.hide(200); } }); } */ async function connectToApi(baseUrl) { if (!baseUrl) { return; } const url = new URL(baseUrl); url.pathname = '/api/modules'; try { const getExtensionsResult = await doExtrasFetch(url); if (getExtensionsResult.ok) { const data = await getExtensionsResult.json(); modules = data.modules; await activateExtensions(); eventSource.emit(event_types.EXTRAS_CONNECTED, modules); } updateStatus(getExtensionsResult.ok); } catch { updateStatus(false); } } function updateStatus(success) { connectedToApi = success; const _text = success ? 'Connected to API' : 'Could not connect to API'; const _class = success ? 'success' : 'failure'; $('#extensions_status').text(_text); $('#extensions_status').attr('class', _class); } function addExtensionStyle(name, manifest) { if (manifest.css) { return new Promise((resolve, reject) => { const url = `/scripts/extensions/${name}/${manifest.css}`; if ($(`link[id="${name}"]`).length === 0) { const link = document.createElement('link'); link.id = name; link.rel = 'stylesheet'; link.type = 'text/css'; link.href = url; link.onload = function () { resolve(); }; link.onerror = function (e) { reject(e); }; document.head.appendChild(link); } }); } return Promise.resolve(); } function addExtensionScript(name, manifest) { if (manifest.js) { return new Promise((resolve, reject) => { const url = `/scripts/extensions/${name}/${manifest.js}`; let ready = false; if ($(`script[id="${name}"]`).length === 0) { const script = document.createElement('script'); script.id = name; script.type = 'module'; script.src = url; script.async = true; script.onerror = function (err) { reject(err, script); }; script.onload = script.onreadystatechange = function () { // console.log(this.readyState); // uncomment this line to see which ready states are called. if (!ready && (!this.readyState || this.readyState == 'complete')) { ready = true; resolve(); } }; document.body.appendChild(script); } }); } return Promise.resolve(); } /** * Generates HTML string for displaying an extension in the UI. * * @param {string} name - The name of the extension. * @param {object} manifest - The manifest of the extension. * @param {boolean} isActive - Whether the extension is active or not. * @param {boolean} isDisabled - Whether the extension is disabled or not. * @param {boolean} isExternal - Whether the extension is external or not. * @param {string} checkboxClass - The class for the checkbox HTML element. * @return {string} - The HTML string that represents the extension. */ async function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { const displayName = manifest.display_name; let displayVersion = manifest.version ? ` v${manifest.version}` : ''; let isUpToDate = true; let updateButton = ''; let originHtml = ''; if (isExternal) { let data = await getExtensionVersion(name.replace('third-party', '')); let branch = data.currentBranchName; let commitHash = data.currentCommitHash; let origin = data.remoteUrl; isUpToDate = data.isUpToDate; displayVersion = ` (${branch}-${commitHash.substring(0, 7)})`; updateButton = isUpToDate ? `` : ``; originHtml = ``; } let toggleElement = isActive || isDisabled ? `` : ``; let deleteButton = isExternal ? `` : ''; // if external, wrap the name in a link to the repo let extensionHtml = `Optional modules: ${optionalString}
`; } } else if (!isDisabled) { // Neither active nor disabled const requirements = new Set(manifest.requires); modules.forEach(x => requirements.delete(x)); const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); extensionHtml += `Missing modules: ${requirementsString}
`; } return extensionHtml; } /** * Gets extension data and generates the corresponding HTML for displaying the extension. * * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. * @return {Promise