Spaces:
Running
Running
import { DOMPurify, Popper } from '../lib.js'; | |
import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration } from '../script.js'; | |
import { showLoader } from './loader.js'; | |
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | |
import { renderTemplate, renderTemplateAsync } from './templates.js'; | |
import { delay, isSubsetOf, sanitizeSelector, setValueByPath } from './utils.js'; | |
import { getContext } from './st-context.js'; | |
import { isAdmin } from './user.js'; | |
import { addLocaleData, getCurrentLocale, t } from './i18n.js'; | |
import { debounce_timeout } from './constants.js'; | |
import { accountStorage } from './util/AccountStorage.js'; | |
export { | |
getContext, | |
getApiUrl, | |
}; | |
/** @type {string[]} */ | |
export let extensionNames = []; | |
/** | |
* Holds the type of each extension. | |
* Don't use this directly, use getExtensionType instead! | |
* @type {Record<string, string>} | |
*/ | |
export let extensionTypes = {}; | |
/** | |
* A list of active modules provided by the Extras API. | |
* @type {string[]} | |
*/ | |
export let modules = []; | |
/** | |
* A set of active extensions. | |
* @type {Set<string>} | |
*/ | |
const activeExtensions = new Set(); | |
/** | |
* Errors that occurred while loading extensions. | |
* @type {Set<string>} | |
*/ | |
const extensionLoadErrors = new Set(); | |
const getApiUrl = () => extension_settings.apiUrl; | |
const sortManifestsByOrder = (a, b) => parseInt(a.loading_order) - parseInt(b.loading_order) || String(a.display_name).localeCompare(String(b.display_name)); | |
const sortManifestsByName = (a, b) => String(a.display_name).localeCompare(String(b.display_name)) || parseInt(a.loading_order) - parseInt(b.loading_order); | |
let connectedToApi = false; | |
/** | |
* Holds manifest data for each extension. | |
* @type {Record<string, object>} | |
*/ | |
let manifests = {}; | |
/** | |
* Default URL for the Extras API. | |
*/ | |
const defaultUrl = 'http://localhost:5100'; | |
let requiresReload = false; | |
let stateChanged = false; | |
let saveMetadataTimeout = null; | |
export function cancelDebouncedMetadataSave() { | |
if (saveMetadataTimeout) { | |
console.debug('Debounced metadata save cancelled'); | |
clearTimeout(saveMetadataTimeout); | |
saveMetadataTimeout = null; | |
} | |
} | |
export function saveMetadataDebounced() { | |
const context = getContext(); | |
const groupId = context.groupId; | |
const characterId = context.characterId; | |
cancelDebouncedMetadataSave(); | |
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...'); | |
await newContext.saveMetadata(); | |
console.debug('Saved metadata...'); | |
}, debounce_timeout.relaxed); | |
} | |
/** | |
* Provides an ability for extensions to render HTML templates synchronously. | |
* 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 | |
* | |
* @deprecated Use renderExtensionTemplateAsync instead. | |
*/ | |
export function renderExtensionTemplate(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { | |
return renderTemplate(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); | |
} | |
/** | |
* Provides an ability for extensions to render HTML templates asynchronously. | |
* 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 {Promise<string>} Rendered HTML | |
*/ | |
export function renderExtensionTemplateAsync(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { | |
return renderTemplateAsync(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); | |
} | |
// Disables parallel updates | |
export 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; | |
} | |
} | |
} | |
export const extension_settings = { | |
apiUrl: defaultUrl, | |
apiKey: '', | |
autoConnect: false, | |
notifyUpdates: false, | |
disabledExtensions: [], | |
expressionOverrides: [], | |
memory: {}, | |
note: { | |
default: '', | |
chara: [], | |
wiAddition: [], | |
}, | |
caption: { | |
refine_mode: false, | |
}, | |
expressions: { | |
/** @type {number} see `EXPRESSION_API` */ | |
api: undefined, | |
/** @type {string[]} */ | |
custom: [], | |
showDefault: false, | |
translate: false, | |
/** @type {string} */ | |
fallback_expression: undefined, | |
/** @type {string} */ | |
llmPrompt: undefined, | |
allowMultiple: true, | |
rerollIfSame: false, | |
promptType: 'raw', | |
}, | |
connectionManager: { | |
selectedProfile: '', | |
/** @type {import('./extensions/connection-manager/index.js').ConnectionProfile[]} */ | |
profiles: [], | |
}, | |
dice: {}, | |
/** @type {import('./char-data.js').RegexScriptData[]} */ | |
regex: [], | |
character_allowed_regex: [], | |
tts: {}, | |
sd: { | |
prompts: {}, | |
character_prompts: {}, | |
character_negative_prompts: {}, | |
}, | |
chromadb: {}, | |
translate: {}, | |
objective: {}, | |
quickReply: {}, | |
randomizer: { | |
controls: [], | |
fluctuation: 0.1, | |
enabled: false, | |
}, | |
speech_recognition: {}, | |
rvc: {}, | |
hypebot: {}, | |
vectors: {}, | |
variables: { | |
global: {}, | |
}, | |
/** | |
* @type {import('./chats.js').FileAttachment[]} | |
*/ | |
attachments: [], | |
/** | |
* @type {Record<string, import('./chats.js').FileAttachment[]>} | |
*/ | |
character_attachments: {}, | |
/** | |
* @type {string[]} | |
*/ | |
disabled_attachments: [], | |
gallery: { | |
/** @type {{[characterKey: string]: string}} */ | |
folders: {}, | |
/** @type {string} */ | |
sort: 'dateAsc', | |
}, | |
}; | |
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); | |
/** | |
* Gets the type of an extension based on its external ID. | |
* @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/') | |
* @returns {string} Type of the extension (global, local, system, or empty string if not found) | |
*/ | |
function getExtensionType(externalId) { | |
const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId))); | |
return id ? extensionTypes[id] : ''; | |
} | |
/** | |
* Performs a fetch of the Extras API. | |
* @param {string|URL} endpoint Extras API endpoint | |
* @param {RequestInit} args Request arguments | |
* @returns {Promise<Response>} Response from the fetch | |
*/ | |
export async function doExtrasFetch(endpoint, args = {}) { | |
if (!args) { | |
args = {}; | |
} | |
if (!args.method) { | |
Object.assign(args, { method: 'GET' }); | |
} | |
if (!args.headers) { | |
args.headers = {}; | |
} | |
if (extension_settings.apiKey) { | |
Object.assign(args.headers, { | |
'Authorization': `Bearer ${extension_settings.apiKey}`, | |
}); | |
} | |
return await fetch(endpoint, args); | |
} | |
/** | |
* Discovers extensions from the API. | |
* @returns {Promise<{name: string, type: string}[]>} | |
*/ | |
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, false); | |
} | |
function onEnableExtensionClick() { | |
const name = $(this).data('name'); | |
enableExtension(name, false); | |
} | |
/** | |
* Enables an extension by name. | |
* @param {string} name Extension name | |
* @param {boolean} [reload=true] If true, reload the page after enabling the extension | |
*/ | |
export async function enableExtension(name, reload = true) { | |
extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name); | |
stateChanged = true; | |
await saveSettings(); | |
if (reload) { | |
location.reload(); | |
} else { | |
requiresReload = true; | |
} | |
} | |
/** | |
* Disables an extension by name. | |
* @param {string} name Extension name | |
* @param {boolean} [reload=true] If true, reload the page after disabling the extension | |
*/ | |
export async function disableExtension(name, reload = true) { | |
extension_settings.disabledExtensions.push(name); | |
stateChanged = true; | |
await saveSettings(); | |
if (reload) { | |
location.reload(); | |
} else { | |
requiresReload = true; | |
} | |
} | |
/** | |
* Loads manifest.json files for extensions. | |
* @param {string[]} names Array of extension names | |
* @returns {Promise<Record<string, object>>} Object with extension names as keys and their manifests as values | |
*/ | |
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; | |
} | |
/** | |
* Tries to activate all available extensions that are not already active. | |
* @returns {Promise<void>} | |
*/ | |
async function activateExtensions() { | |
extensionLoadErrors.clear(); | |
const extensions = Object.entries(manifests).sort((a, b) => sortManifestsByOrder(a[1], b[1])); | |
const extensionNames = extensions.map(x => x[0]); | |
const promises = []; | |
for (let entry of extensions) { | |
const name = entry[0]; | |
const manifest = entry[1]; | |
const extrasRequirements = manifest.requires; | |
const extensionDependencies = manifest.dependencies; | |
const displayName = manifest.display_name || name; | |
if (activeExtensions.has(name)) { | |
continue; | |
} | |
// Module requirements: pass if 'requires' is undefined, null, or not an array; check subset if it's an array | |
let meetsModuleRequirements = true; | |
let missingModules = []; | |
if (extrasRequirements !== undefined) { | |
if (Array.isArray(extrasRequirements)) { | |
meetsModuleRequirements = isSubsetOf(modules, extrasRequirements); | |
missingModules = extrasRequirements.filter(req => !modules.includes(req)); | |
} else { | |
console.warn(`Extension ${name}: manifest.json 'requires' field is not an array. Loading allowed, but any intended requirements were not verified to exist.`); | |
} | |
} | |
// Extension dependencies: pass if 'dependencies' is undefined or not an array; check subset and disabled status if it's an array | |
let meetsExtensionDeps = true; | |
let missingDependencies = []; | |
let disabledDependencies = []; | |
if (extensionDependencies !== undefined) { | |
if (Array.isArray(extensionDependencies)) { | |
// Check if all dependencies exist | |
meetsExtensionDeps = isSubsetOf(extensionNames, extensionDependencies); | |
missingDependencies = extensionDependencies.filter(dep => !extensionNames.includes(dep)); | |
// Check for disabled dependencies | |
if (meetsExtensionDeps) { | |
disabledDependencies = extensionDependencies.filter(dep => extension_settings.disabledExtensions.includes(dep)); | |
if (disabledDependencies.length > 0) { | |
// Fail if any dependencies are disabled | |
meetsExtensionDeps = false; | |
} | |
} | |
} else { | |
console.warn(`Extension ${name}: manifest.json 'dependencies' field is not an array. Loading allowed, but any intended requirements were not verified to exist.`); | |
} | |
} | |
const isDisabled = extension_settings.disabledExtensions.includes(name); | |
if (meetsModuleRequirements && meetsExtensionDeps && !isDisabled) { | |
try { | |
console.debug('Activating extension', name); | |
const promise = addExtensionLocale(name, manifest).finally(() => | |
Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]), | |
); | |
await promise | |
.then(() => activeExtensions.add(name)) | |
.catch(err => { | |
console.log('Could not activate extension', name, err); | |
extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`); | |
}); | |
promises.push(promise); | |
} catch (error) { | |
console.error('Could not activate extension', name, error); | |
} | |
} else if (!meetsModuleRequirements && !isDisabled) { | |
console.warn(t`Extension "${name}" did not load. Missing required Extras module(s): "${missingModules.join(', ')}"`); | |
extensionLoadErrors.add(t`Extension "${displayName}" did not load. Missing required Extras module(s): "${missingModules.join(', ')}"`); | |
} else if (!meetsExtensionDeps && !isDisabled) { | |
if (disabledDependencies.length > 0) { | |
console.warn(t`Extension "${name}" did not load. Required extensions exist but are disabled: "${disabledDependencies.join(', ')}". Enable them first, then reload.`); | |
extensionLoadErrors.add(t`Extension "${displayName}" did not load. Required extensions exist but are disabled: "${disabledDependencies.join(', ')}". Enable them first, then reload.`); | |
} else { | |
console.warn(t`Extension "${name}" did not load. Missing required extensions: "${missingDependencies.join(', ')}"`); | |
extensionLoadErrors.add(t`Extension "${displayName}" did not load. Missing required extensions: "${missingDependencies.join(', ')}"`); | |
} | |
} | |
} | |
await Promise.allSettled(promises); | |
$('#extensions_details').toggleClass('warning', extensionLoadErrors.size > 0); | |
} | |
async function connectClickHandler() { | |
const baseUrl = String($('#extensions_url').val()); | |
extension_settings.apiUrl = 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(); | |
} | |
async function addExtensionsButtonAndMenu() { | |
const buttonHTML = await renderTemplateAsync('wandButton'); | |
const extensionsMenuHTML = await renderTemplateAsync('wandMenu'); | |
$(document.body).append(extensionsMenuHTML); | |
$('#leftSendForm').append(buttonHTML); | |
const button = $('#extensionsMenuButton'); | |
const dropdown = $('#extensionsMenu'); | |
let isDropdownVisible = false; | |
let popper = Popper.createPopper(button.get(0), dropdown.get(0), { | |
placement: 'top-start', | |
}); | |
$(button).on('click', function () { | |
if (isDropdownVisible) { | |
dropdown.fadeOut(animation_duration); | |
isDropdownVisible = false; | |
} else { | |
dropdown.fadeIn(animation_duration); | |
isDropdownVisible = true; | |
} | |
popper.update(); | |
}); | |
$('html').on('click', function (e) { | |
if (!isDropdownVisible) return; | |
const clickTarget = $(e.target); | |
const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice']; | |
if (!noCloseTargets.some(id => clickTarget.closest(id).length > 0)) { | |
dropdown.fadeOut(animation_duration); | |
isDropdownVisible = false; | |
} | |
}); | |
} | |
function notifyUpdatesInputHandler() { | |
extension_settings.notifyUpdates = !!$('#extensions_notify_updates').prop('checked'); | |
saveSettingsDebounced(); | |
if (extension_settings.notifyUpdates) { | |
checkForExtensionUpdates(true); | |
} | |
} | |
/** | |
* Connects to the Extras API. | |
* @param {string} baseUrl Extras API base URL | |
* @returns {Promise<void>} | |
*/ | |
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(); | |
await eventSource.emit(event_types.EXTRAS_CONNECTED, modules); | |
} | |
updateStatus(getExtensionsResult.ok); | |
} | |
catch { | |
updateStatus(false); | |
} | |
} | |
/** | |
* Updates the status of Extras API connection. | |
* @param {boolean} success Whether the connection was successful | |
*/ | |
function updateStatus(success) { | |
connectedToApi = success; | |
const _text = success ? t`Connected to API` : t`Could not connect to API`; | |
const _class = success ? 'success' : 'failure'; | |
$('#extensions_status').text(_text); | |
$('#extensions_status').attr('class', _class); | |
} | |
/** | |
* Adds a CSS file for an extension. | |
* @param {string} name Extension name | |
* @param {object} manifest Extension manifest | |
* @returns {Promise<void>} When the CSS is loaded | |
*/ | |
function addExtensionStyle(name, manifest) { | |
if (!manifest.css) { | |
return Promise.resolve(); | |
} | |
return new Promise((resolve, reject) => { | |
const url = `/scripts/extensions/${name}/${manifest.css}`; | |
const id = sanitizeSelector(`${name}-css`); | |
if ($(`link[id="${id}"]`).length === 0) { | |
const link = document.createElement('link'); | |
link.id = id; | |
link.rel = 'stylesheet'; | |
link.type = 'text/css'; | |
link.href = url; | |
link.onload = function () { | |
resolve(); | |
}; | |
link.onerror = function (e) { | |
reject(e); | |
}; | |
document.head.appendChild(link); | |
} | |
}); | |
} | |
/** | |
* Loads a JS file for an extension. | |
* @param {string} name Extension name | |
* @param {object} manifest Extension manifest | |
* @returns {Promise<void>} When the script is loaded | |
*/ | |
function addExtensionScript(name, manifest) { | |
if (!manifest.js) { | |
return Promise.resolve(); | |
} | |
return new Promise((resolve, reject) => { | |
const url = `/scripts/extensions/${name}/${manifest.js}`; | |
const id = sanitizeSelector(`${name}-js`); | |
let ready = false; | |
if ($(`script[id="${id}"]`).length === 0) { | |
const script = document.createElement('script'); | |
script.id = id; | |
script.type = 'module'; | |
script.src = url; | |
script.async = true; | |
script.onerror = function (err) { | |
reject(err); | |
}; | |
script.onload = function () { | |
if (!ready) { | |
ready = true; | |
resolve(); | |
} | |
}; | |
document.body.appendChild(script); | |
} | |
}); | |
} | |
/** | |
* Adds a localization data for an extension. | |
* @param {string} name Extension name | |
* @param {object} manifest Manifest object | |
*/ | |
function addExtensionLocale(name, manifest) { | |
// No i18n data in the manifest | |
if (!manifest.i18n || typeof manifest.i18n !== 'object') { | |
return Promise.resolve(); | |
} | |
const currentLocale = getCurrentLocale(); | |
const localeFile = manifest.i18n[currentLocale]; | |
// Manifest doesn't provide a locale file for the current locale | |
if (!localeFile) { | |
return Promise.resolve(); | |
} | |
return fetch(`/scripts/extensions/${name}/${localeFile}`) | |
.then(async response => { | |
if (!response.ok) { | |
throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
} | |
const data = await response.json(); | |
if (data && typeof data === 'object') { | |
addLocaleData(currentLocale, data); | |
} | |
}) | |
.catch(err => { | |
console.log('Could not load extension locale data for ' + name, err); | |
}); | |
} | |
/** | |
* 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. | |
*/ | |
function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { | |
function getExtensionIcon() { | |
const type = getExtensionType(name); | |
switch (type) { | |
case 'global': | |
return '<i class="fa-sm fa-fw fa-solid fa-server" data-i18n="[title]ext_type_global" title="This is a global extension, available for all users."></i>'; | |
case 'local': | |
return '<i class="fa-sm fa-fw fa-solid fa-user" data-i18n="[title]ext_type_local" title="This is a local extension, available only for you."></i>'; | |
case 'system': | |
return '<i class="fa-sm fa-fw fa-solid fa-cog" data-i18n="[title]ext_type_system" title="This is a built-in extension. It cannot be deleted and updates with the app."></i>'; | |
default: | |
return '<i class="fa-sm fa-fw fa-solid fa-question" title="Unknown extension type."></i>'; | |
} | |
} | |
const isUserAdmin = isAdmin(); | |
const extensionIcon = getExtensionIcon(); | |
const displayName = manifest.display_name; | |
const displayVersion = manifest.version || ''; | |
const externalId = name.replace('third-party', ''); | |
let originHtml = ''; | |
if (isExternal) { | |
originHtml = '<a>'; | |
} | |
let toggleElement = isActive || isDisabled ? | |
'<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` : | |
`<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`; | |
let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : ''; | |
let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : ''; | |
let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : ''; | |
let branchButton = isExternal && isUserAdmin ? `<button class="btn_branch menu_button" data-name="${externalId}" data-i18n="[title]Switch branch" title="Switch branch"><i class="fa-solid fa-code-branch fa-fw"></i></button>` : ''; | |
let modulesInfo = ''; | |
if (isActive && Array.isArray(manifest.optional)) { | |
const optional = new Set(manifest.optional); | |
modules.forEach(x => optional.delete(x)); | |
if (optional.size > 0) { | |
const optionalString = DOMPurify.sanitize([...optional].join(', ')); | |
modulesInfo = '<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`; | |
} | |
} else if (!isDisabled) { // Neither active nor disabled | |
const requirements = new Set(manifest.requires); | |
modules.forEach(x => requirements.delete(x)); | |
if (requirements.size > 0) { | |
const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); | |
modulesInfo = `<div class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></div>`; | |
} | |
} | |
// if external, wrap the name in a link to the repo | |
let extensionHtml = ` | |
<div class="extension_block" data-name="${externalId}"> | |
<div class="extension_toggle"> | |
${toggleElement} | |
</div> | |
<div class="extension_icon"> | |
${extensionIcon} | |
</div> | |
<div class="flexGrow extension_text_block"> | |
${originHtml} | |
<span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}"> | |
<span class="extension_name">${DOMPurify.sanitize(displayName)}</span> | |
<span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span> | |
${modulesInfo} | |
</span> | |
${isExternal ? '</a>' : ''} | |
</div> | |
<div class="extension_actions flex-container alignItemsCenter"> | |
${updateButton} | |
${branchButton} | |
${moveButton} | |
${deleteButton} | |
</div> | |
</div>`; | |
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 {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. | |
*/ | |
function getExtensionData(extension) { | |
const name = extension[0]; | |
const manifest = extension[1]; | |
const isActive = activeExtensions.has(name); | |
const isDisabled = extension_settings.disabledExtensions.includes(name); | |
const isExternal = name.startsWith('third-party'); | |
const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; | |
const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); | |
return { isExternal, extensionHtml }; | |
} | |
/** | |
* Gets the module information to be displayed. | |
* | |
* @return {string} - The HTML string for the module information. | |
*/ | |
function getModuleInformation() { | |
let moduleInfo = modules.length ? `<p>${DOMPurify.sanitize(modules.join(', '))}</p>` : '<p class="failure">' + t`Not connected to the API!` + '</p>'; | |
return ` | |
<h3>` + t`Modules provided by your Extras API:` + `</h3> | |
${moduleInfo} | |
`; | |
} | |
/** | |
* Generates HTML for the extension load errors. | |
* @returns {string} HTML string containing the errors that occurred while loading extensions. | |
*/ | |
function getExtensionLoadErrorsHtml() { | |
if (extensionLoadErrors.size === 0) { | |
return ''; | |
} | |
const container = document.createElement('div'); | |
container.classList.add('info-block', 'error'); | |
for (const error of extensionLoadErrors) { | |
const errorElement = document.createElement('div'); | |
errorElement.textContent = error; | |
container.appendChild(errorElement); | |
} | |
return container.outerHTML; | |
} | |
/** | |
* Generates the HTML strings for all extensions and displays them in a popup. | |
*/ | |
async function showExtensionsDetails() { | |
const abortController = new AbortController(); | |
let popupPromise; | |
try { | |
// If we are updating an extension, the "old" popup is still active. We should close that. | |
let initialScrollTop = 0; | |
const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info')); | |
if (oldPopup) { | |
initialScrollTop = oldPopup.content.scrollTop; | |
await oldPopup.completeCancelled(); | |
} | |
const htmlErrors = getExtensionLoadErrorsHtml(); | |
const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Built-in Extensions:` + '</h3></div>'); | |
const htmlExternal = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Installed Extensions:` + '</h3></div>'); | |
const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5"> | |
<i class="fa-solid fa-spinner fa-spin"></i> | |
<span>` + t`Loading third-party extensions... Please wait...` + `</span> | |
</div>`); | |
htmlExternal.append(htmlLoading); | |
const sortOrderKey = 'extensions_sortByName'; | |
const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; | |
const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder; | |
const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData); | |
extensions.forEach(value => { | |
const { isExternal, extensionHtml } = value; | |
const container = isExternal ? htmlExternal : htmlDefault; | |
container.append(extensionHtml); | |
}); | |
const html = $('<div></div>') | |
.addClass('extensions_info') | |
.append(htmlErrors) | |
.append(htmlDefault) | |
.append(htmlExternal) | |
.append(getModuleInformation()); | |
{ | |
const updateAction = async (force) => { | |
requiresReload = true; | |
await autoUpdateExtensions(force); | |
await popup.complete(POPUP_RESULT.AFFIRMATIVE); | |
}; | |
const toolbar = document.createElement('div'); | |
toolbar.classList.add('extensions_toolbar'); | |
const updateAllButton = document.createElement('button'); | |
updateAllButton.classList.add('menu_button', 'menu_button_icon'); | |
updateAllButton.textContent = t`Update all`; | |
updateAllButton.addEventListener('click', () => updateAction(true)); | |
const updateEnabledOnlyButton = document.createElement('button'); | |
updateEnabledOnlyButton.classList.add('menu_button', 'menu_button_icon'); | |
updateEnabledOnlyButton.textContent = t`Update enabled`; | |
updateEnabledOnlyButton.addEventListener('click', () => updateAction(false)); | |
const flexExpander = document.createElement('div'); | |
flexExpander.classList.add('expander'); | |
const sortOrderButton = document.createElement('button'); | |
sortOrderButton.classList.add('menu_button', 'menu_button_icon'); | |
sortOrderButton.textContent = sortByName ? t`Sort: Display Name` : t`Sort: Loading Order`; | |
sortOrderButton.addEventListener('click', async () => { | |
abortController.abort(); | |
accountStorage.setItem(sortOrderKey, sortByName ? 'false' : 'true'); | |
await showExtensionsDetails(); | |
}); | |
toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); | |
html.prepend(toolbar); | |
} | |
let waitingForSave = false; | |
const popup = new Popup(html, POPUP_TYPE.TEXT, '', { | |
okButton: t`Close`, | |
wide: true, | |
large: true, | |
customButtons: [], | |
allowVerticalScrolling: true, | |
onClosing: async () => { | |
if (waitingForSave) { | |
return false; | |
} | |
if (stateChanged) { | |
waitingForSave = true; | |
const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`); | |
await saveSettings(); | |
toastr.clear(toast); | |
waitingForSave = false; | |
requiresReload = true; | |
} | |
return true; | |
}, | |
}); | |
popupPromise = popup.show(); | |
popup.content.scrollTop = initialScrollTop; | |
checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove()); | |
} catch (error) { | |
toastr.error(t`Error loading extensions. See browser console for details.`); | |
console.error(error); | |
} | |
if (popupPromise) { | |
await popupPromise; | |
abortController.abort(); | |
} | |
if (requiresReload) { | |
showLoader(); | |
location.reload(); | |
} | |
} | |
/** | |
* Handles the click event for the update button of an extension. | |
* This function makes a POST request to '/api/extensions/update' with the extension's name. | |
* If the extension is already up to date, it displays a success message. | |
* If the extension is not up to date, it updates the extension and displays a success message with the new commit hash. | |
*/ | |
async function onUpdateClick() { | |
const isCurrentUserAdmin = isAdmin(); | |
const extensionName = $(this).data('name'); | |
const isGlobal = getExtensionType(extensionName) === 'global'; | |
if (isGlobal && !isCurrentUserAdmin) { | |
toastr.error(t`You don't have permission to update global extensions.`); | |
return; | |
} | |
const icon = $(this).find('i'); | |
icon.addClass('fa-spin'); | |
await updateExtension(extensionName, false); | |
// updateExtension eats the error, but we can at least stop the spinner | |
icon.removeClass('fa-spin'); | |
} | |
/** | |
* Updates a third-party extension via the API. | |
* @param {string} extensionName Extension folder name | |
* @param {boolean} quiet If true, don't show a success message | |
* @param {number?} timeout Timeout in milliseconds to wait for the update to complete. If null, no timeout is set. | |
*/ | |
async function updateExtension(extensionName, quiet, timeout = null) { | |
try { | |
const signal = timeout ? AbortSignal.timeout(timeout) : undefined; | |
const response = await fetch('/api/extensions/update', { | |
method: 'POST', | |
signal: signal, | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
extensionName, | |
global: getExtensionType(extensionName) === 'global', | |
}), | |
}); | |
if (!response.ok) { | |
const text = await response.text(); | |
toastr.error(text || response.statusText, t`Extension update failed`, { timeOut: 5000 }); | |
console.error('Extension update failed', response.status, response.statusText, text); | |
return; | |
} | |
const data = await response.json(); | |
if (!quiet) { | |
void showExtensionsDetails(); | |
} | |
if (data.isUpToDate) { | |
if (!quiet) { | |
toastr.success('Extension is already up to date'); | |
} | |
} else { | |
toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`); | |
} | |
} catch (error) { | |
console.error('Extension update error:', error); | |
} | |
} | |
/** | |
* Handles the click event for the delete button of an extension. | |
* This function makes a POST request to '/api/extensions/delete' with the extension's name. | |
* If the extension is deleted, it displays a success message. | |
* Creates a popup for the user to confirm before delete. | |
*/ | |
async function onDeleteClick() { | |
const extensionName = $(this).data('name'); | |
const isCurrentUserAdmin = isAdmin(); | |
const isGlobal = getExtensionType(extensionName) === 'global'; | |
if (isGlobal && !isCurrentUserAdmin) { | |
toastr.error(t`You don't have permission to delete global extensions.`); | |
return; | |
} | |
// use callPopup to create a popup for the user to confirm before delete | |
const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); | |
if (confirmation === POPUP_RESULT.AFFIRMATIVE) { | |
await deleteExtension(extensionName); | |
} | |
} | |
async function onBranchClick() { | |
const extensionName = $(this).data('name'); | |
const isCurrentUserAdmin = isAdmin(); | |
const isGlobal = getExtensionType(extensionName) === 'global'; | |
if (isGlobal && !isCurrentUserAdmin) { | |
toastr.error(t`You don't have permission to switch branch.`); | |
return; | |
} | |
let newBranch = ''; | |
const branches = await getExtensionBranches(extensionName, isGlobal); | |
const selectElement = document.createElement('select'); | |
selectElement.classList.add('text_pole', 'wide100p'); | |
selectElement.addEventListener('change', function () { | |
newBranch = this.value; | |
}); | |
for (const branch of branches) { | |
const option = document.createElement('option'); | |
option.value = branch.name; | |
option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`; | |
option.selected = branch.current; | |
selectElement.appendChild(option); | |
} | |
const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', { | |
okButton: t`Switch`, | |
cancelButton: t`Cancel`, | |
}); | |
const popupResult = await popup.show(); | |
if (!popupResult || !newBranch) { | |
return; | |
} | |
await switchExtensionBranch(extensionName, isGlobal, newBranch); | |
} | |
async function onMoveClick() { | |
const extensionName = $(this).data('name'); | |
const isCurrentUserAdmin = isAdmin(); | |
const isGlobal = getExtensionType(extensionName) === 'global'; | |
if (isGlobal && !isCurrentUserAdmin) { | |
toastr.error(t`You don't have permission to move extensions.`); | |
return; | |
} | |
const source = getExtensionType(extensionName); | |
const destination = source === 'global' ? 'local' : 'global'; | |
const confirmationHeader = t`Move extension`; | |
const confirmationText = source == 'global' | |
? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.` | |
: t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`; | |
const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); | |
if (!confirmation) { | |
return; | |
} | |
$(this).find('i').addClass('fa-spin'); | |
await moveExtension(extensionName, source, destination); | |
} | |
/** | |
* Moves an extension via the API. | |
* @param {string} extensionName Extension name | |
* @param {string} source Source type | |
* @param {string} destination Destination type | |
* @returns {Promise<void>} | |
*/ | |
async function moveExtension(extensionName, source, destination) { | |
try { | |
const result = await fetch('/api/extensions/move', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
extensionName, | |
source, | |
destination, | |
}), | |
}); | |
if (!result.ok) { | |
const text = await result.text(); | |
toastr.error(text || result.statusText, t`Extension move failed`, { timeOut: 5000 }); | |
console.error('Extension move failed', result.status, result.statusText, text); | |
return; | |
} | |
toastr.success(t`Extension ${extensionName} moved.`); | |
await loadExtensionSettings({}, false, false); | |
void showExtensionsDetails(); | |
} catch (error) { | |
console.error('Error:', error); | |
} | |
} | |
/** | |
* Deletes an extension via the API. | |
* @param {string} extensionName Extension name to delete | |
*/ | |
export async function deleteExtension(extensionName) { | |
try { | |
await fetch('/api/extensions/delete', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
extensionName, | |
global: getExtensionType(extensionName) === 'global', | |
}), | |
}); | |
} catch (error) { | |
console.error('Error:', error); | |
} | |
toastr.success(t`Extension ${extensionName} deleted`); | |
delay(1000).then(() => location.reload()); | |
} | |
/** | |
* Fetches the version details of a specific extension. | |
* | |
* @param {string} extensionName - The name of the extension. | |
* @param {AbortSignal} [abortSignal] - The signal to abort the operation. | |
* @return {Promise<object>} - An object containing the extension's version details. | |
* This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl. | |
* @throws {error} - If there is an error during the fetch operation, it logs the error to the console. | |
*/ | |
async function getExtensionVersion(extensionName, abortSignal) { | |
try { | |
const response = await fetch('/api/extensions/version', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
extensionName, | |
global: getExtensionType(extensionName) === 'global', | |
}), | |
signal: abortSignal, | |
}); | |
const data = await response.json(); | |
return data; | |
} catch (error) { | |
console.error('Error:', error); | |
} | |
} | |
/** | |
* Gets the list of branches for a specific extension. | |
* @param {string} extensionName The name of the extension | |
* @param {boolean} isGlobal Whether the extension is global or not | |
* @returns {Promise<ExtensionBranch[]>} List of branches for the extension | |
* @typedef {object} ExtensionBranch | |
* @property {string} name The name of the branch | |
* @property {string} commit The commit hash of the branch | |
* @property {boolean} current Whether this branch is the current one | |
* @property {string} label The commit label of the branch | |
*/ | |
async function getExtensionBranches(extensionName, isGlobal) { | |
try { | |
const response = await fetch('/api/extensions/branches', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
extensionName, | |
global: isGlobal, | |
}), | |
}); | |
if (!response.ok) { | |
const text = await response.text(); | |
toastr.error(text || response.statusText, t`Extension branches fetch failed`); | |
console.error('Extension branches fetch failed', response.status, response.statusText, text); | |
return []; | |
} | |
return await response.json(); | |
} catch (error) { | |
console.error('Error:', error); | |
return []; | |
} | |
} | |
/** | |
* Switches the branch of an extension. | |
* @param {string} extensionName The name of the extension | |
* @param {boolean} isGlobal If the extension is global | |
* @param {string} branch Branch name to switch to | |
* @returns {Promise<void>} | |
*/ | |
async function switchExtensionBranch(extensionName, isGlobal, branch) { | |
try { | |
const response = await fetch('/api/extensions/switch', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
extensionName, | |
branch, | |
global: isGlobal, | |
}), | |
}); | |
if (!response.ok) { | |
const text = await response.text(); | |
toastr.error(text || response.statusText, t`Extension branch switch failed`); | |
console.error('Extension branch switch failed', response.status, response.statusText, text); | |
return; | |
} | |
toastr.success(t`Extension ${extensionName} switched to ${branch}`); | |
await loadExtensionSettings({}, false, false); | |
void showExtensionsDetails(); | |
} catch (error) { | |
console.error('Error:', error); | |
} | |
} | |
/** | |
* Installs a third-party extension via the API. | |
* @param {string} url Extension repository URL | |
* @param {boolean} global Is the extension global? | |
* @returns {Promise<void>} | |
*/ | |
export async function installExtension(url, global, branch = '') { | |
console.debug('Extension installation started', url); | |
toastr.info(t`Please wait...`, t`Installing extension`); | |
const request = await fetch('/api/extensions/install', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify({ | |
url, | |
global, | |
branch, | |
}), | |
}); | |
if (!request.ok) { | |
const text = await request.text(); | |
toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 }); | |
console.error('Extension installation failed', request.status, request.statusText, text); | |
return; | |
} | |
const response = await request.json(); | |
toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`); | |
console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`); | |
await loadExtensionSettings({}, false, false); | |
await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response); | |
} | |
/** | |
* Loads extension settings from the app settings. | |
* @param {object} settings App Settings | |
* @param {boolean} versionChanged Is this a version change? | |
* @param {boolean} enableAutoUpdate Enable auto-update | |
*/ | |
export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) { | |
if (settings.extension_settings) { | |
Object.assign(extension_settings, settings.extension_settings); | |
} | |
$('#extensions_url').val(extension_settings.apiUrl); | |
$('#extensions_api_key').val(extension_settings.apiKey); | |
$('#extensions_autoconnect').prop('checked', extension_settings.autoConnect); | |
$('#extensions_notify_updates').prop('checked', extension_settings.notifyUpdates); | |
// Activate offline extensions | |
await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD); | |
const extensions = await discoverExtensions(); | |
extensionNames = extensions.map(x => x.name); | |
extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type])); | |
manifests = await getManifests(extensionNames); | |
if (versionChanged && enableAutoUpdate) { | |
await autoUpdateExtensions(false); | |
} | |
await activateExtensions(); | |
if (extension_settings.autoConnect && extension_settings.apiUrl) { | |
connectToApi(extension_settings.apiUrl); | |
} | |
} | |
export function doDailyExtensionUpdatesCheck() { | |
setTimeout(() => { | |
if (extension_settings.notifyUpdates) { | |
checkForExtensionUpdates(false); | |
} | |
}, 1); | |
} | |
const concurrencyLimit = 5; | |
let activeRequestsCount = 0; | |
const versionCheckQueue = []; | |
function enqueueVersionCheck(fn) { | |
return new Promise((resolve, reject) => { | |
versionCheckQueue.push(() => fn().then(resolve).catch(reject)); | |
processVersionCheckQueue(); | |
}); | |
} | |
function processVersionCheckQueue() { | |
if (activeRequestsCount >= concurrencyLimit || versionCheckQueue.length === 0) { | |
return; | |
} | |
activeRequestsCount++; | |
const fn = versionCheckQueue.shift(); | |
fn().finally(() => { | |
activeRequestsCount--; | |
processVersionCheckQueue(); | |
}); | |
} | |
/** | |
* Performs a manual check for updates on all 3rd-party extensions. | |
* @param {function} sortFn Sort function | |
* @param {AbortSignal} abortSignal Signal to abort the operation | |
* @returns {Promise<any[]>} | |
*/ | |
async function checkForUpdatesManual(sortFn, abortSignal) { | |
const promises = []; | |
for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortFn(manifests[a], manifests[b]))) { | |
const externalId = id.replace('third-party', ''); | |
const promise = enqueueVersionCheck(async () => { | |
try { | |
const data = await getExtensionVersion(externalId, abortSignal); | |
const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); | |
if (extensionBlock && data) { | |
if (data.isUpToDate === false) { | |
const buttonElement = extensionBlock.querySelector('.btn_update'); | |
if (buttonElement) { | |
buttonElement.classList.remove('displayNone'); | |
} | |
const nameElement = extensionBlock.querySelector('.extension_name'); | |
if (nameElement) { | |
nameElement.classList.add('update_available'); | |
} | |
} | |
let branch = data.currentBranchName; | |
let commitHash = data.currentCommitHash; | |
let origin = data.remoteUrl; | |
const originLink = extensionBlock.querySelector('a'); | |
if (originLink) { | |
try { | |
const url = new URL(origin); | |
if (!['https:', 'http:'].includes(url.protocol)) { | |
throw new Error('Invalid protocol'); | |
} | |
originLink.href = url.href; | |
originLink.target = '_blank'; | |
originLink.rel = 'noopener noreferrer'; | |
} catch (error) { | |
console.log('Error setting origin link', originLink, error); | |
} | |
} | |
const versionElement = extensionBlock.querySelector('.extension_version'); | |
if (versionElement) { | |
versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`; | |
} | |
} | |
} catch (error) { | |
console.error('Error checking for extension updates', error); | |
} | |
}); | |
promises.push(promise); | |
} | |
return Promise.allSettled(promises); | |
} | |
/** | |
* Checks if there are updates available for enabled 3rd-party extensions. | |
* @param {boolean} force Skip nag check | |
* @returns {Promise<any>} | |
*/ | |
async function checkForExtensionUpdates(force) { | |
if (!force) { | |
const STORAGE_NAG_KEY = 'extension_update_nag'; | |
const currentDate = new Date().toDateString(); | |
// Don't nag more than once a day | |
if (accountStorage.getItem(STORAGE_NAG_KEY) === currentDate) { | |
return; | |
} | |
accountStorage.setItem(STORAGE_NAG_KEY, currentDate); | |
} | |
const isCurrentUserAdmin = isAdmin(); | |
const updatesAvailable = []; | |
const promises = []; | |
for (const [id, manifest] of Object.entries(manifests)) { | |
const isDisabled = extension_settings.disabledExtensions.includes(id); | |
if (isDisabled) { | |
console.debug(`Skipping extension: ${manifest.display_name} (${id}) for non-admin user`); | |
continue; | |
} | |
const isGlobal = getExtensionType(id) === 'global'; | |
if (isGlobal && !isCurrentUserAdmin) { | |
console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); | |
continue; | |
} | |
if (manifest.auto_update && id.startsWith('third-party')) { | |
const promise = enqueueVersionCheck(async () => { | |
try { | |
const data = await getExtensionVersion(id.replace('third-party', '')); | |
if (!data.isUpToDate) { | |
updatesAvailable.push(manifest.display_name); | |
} | |
} catch (error) { | |
console.error('Error checking for extension updates', error); | |
} | |
}); | |
promises.push(promise); | |
} | |
} | |
await Promise.allSettled(promises); | |
if (updatesAvailable.length > 0) { | |
toastr.info(`${updatesAvailable.map(x => `• ${x}`).join('\n')}`, t`Extension updates available`); | |
} | |
} | |
/** | |
* Updates all enabled 3rd-party extensions that have auto-update enabled. | |
* @param {boolean} forceAll Include disabled and not auto-updating | |
* @returns {Promise<void>} | |
*/ | |
async function autoUpdateExtensions(forceAll) { | |
if (!Object.values(manifests).some(x => x.auto_update)) { | |
return; | |
} | |
const banner = toastr.info(t`Auto-updating extensions. This may take several minutes.`, t`Please wait...`, { timeOut: 10000, extendedTimeOut: 10000 }); | |
const isCurrentUserAdmin = isAdmin(); | |
const promises = []; | |
const autoUpdateTimeout = 60 * 1000; | |
for (const [id, manifest] of Object.entries(manifests)) { | |
const isDisabled = extension_settings.disabledExtensions.includes(id); | |
if (!forceAll && isDisabled) { | |
console.debug(`Skipping extension: ${manifest.display_name} (${id}) for non-admin user`); | |
continue; | |
} | |
const isGlobal = getExtensionType(id) === 'global'; | |
if (isGlobal && !isCurrentUserAdmin) { | |
console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); | |
continue; | |
} | |
if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) { | |
console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`); | |
promises.push(updateExtension(id.replace('third-party', ''), true, autoUpdateTimeout)); | |
} | |
} | |
await Promise.allSettled(promises); | |
toastr.clear(banner); | |
} | |
/** | |
* Runs the generate interceptors for all extensions. | |
* @param {any[]} chat Chat array | |
* @param {number} contextSize Context size | |
* @param {string} type Generation type | |
* @returns {Promise<boolean>} True if generation should be aborted | |
*/ | |
export async function runGenerationInterceptors(chat, contextSize, type) { | |
let aborted = false; | |
let exitImmediately = false; | |
const abort = (/** @type {boolean} */ immediately) => { | |
aborted = true; | |
exitImmediately = immediately; | |
}; | |
for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifestsByOrder(a, b))) { | |
const interceptorKey = manifest.generate_interceptor; | |
if (typeof globalThis[interceptorKey] === 'function') { | |
try { | |
await globalThis[interceptorKey](chat, contextSize, abort, type); | |
} catch (e) { | |
console.error(`Failed running interceptor for ${manifest.display_name}`, e); | |
} | |
} | |
if (exitImmediately) { | |
break; | |
} | |
} | |
return aborted; | |
} | |
/** | |
* Writes a field to the character's data extensions object. | |
* @param {number|string} characterId Index in the character array | |
* @param {string} key Field name | |
* @param {any} value Field value | |
* @returns {Promise<void>} When the field is written | |
*/ | |
export async function writeExtensionField(characterId, key, value) { | |
const context = getContext(); | |
const character = context.characters[characterId]; | |
if (!character) { | |
console.warn('Character not found', characterId); | |
return; | |
} | |
const path = `data.extensions.${key}`; | |
setValueByPath(character, path, value); | |
// Process JSON data | |
if (character.json_data) { | |
const jsonData = JSON.parse(character.json_data); | |
setValueByPath(jsonData, path, value); | |
character.json_data = JSON.stringify(jsonData); | |
// Make sure the data doesn't get lost when saving the current character | |
if (Number(characterId) === Number(context.characterId)) { | |
$('#character_json_data').val(character.json_data); | |
} | |
} | |
// Save data to the server | |
const saveDataRequest = { | |
avatar: character.avatar, | |
data: { | |
extensions: { | |
[key]: value, | |
}, | |
}, | |
}; | |
const mergeResponse = await fetch('/api/characters/merge-attributes', { | |
method: 'POST', | |
headers: getRequestHeaders(), | |
body: JSON.stringify(saveDataRequest), | |
}); | |
if (!mergeResponse.ok) { | |
console.error('Failed to save extension field', mergeResponse.statusText); | |
} | |
} | |
/** | |
* Prompts the user to enter the Git URL of the extension to import. | |
* After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. | |
* If the extension is imported successfully, a success message is displayed. | |
* If the extension import fails, an error message is displayed and the error is logged to the console. | |
* After successfully importing the extension, the extension settings are reloaded and a 'EXTENSION_SETTINGS_LOADED' event is emitted. | |
* @param {string} [suggestUrl] Suggested URL to install | |
* @returns {Promise<void>} | |
*/ | |
export async function openThirdPartyExtensionMenu(suggestUrl = '') { | |
const isCurrentUserAdmin = isAdmin(); | |
const html = await renderTemplateAsync('installExtension', { isCurrentUserAdmin }); | |
const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`; | |
let global = false; | |
const installForAllButton = { | |
text: t`Install for all users`, | |
appendAtEnd: false, | |
action: async () => { | |
global = true; | |
await popup.complete(POPUP_RESULT.AFFIRMATIVE); | |
}, | |
}; | |
/** @type {import('./popup.js').CustomPopupInput} */ | |
const branchNameInput = { | |
id: 'extension_branch_name', | |
label: t`Branch or tag name (optional)`, | |
type: 'text', | |
tooltip: 'e.g. main, dev, v1.0.0', | |
}; | |
const customButtons = isCurrentUserAdmin ? [installForAllButton] : []; | |
const customInputs = [branchNameInput]; | |
const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs }); | |
const input = await popup.show(); | |
if (!input) { | |
console.debug('Extension install cancelled'); | |
return; | |
} | |
const url = String(input).trim(); | |
const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim(); | |
await installExtension(url, global, branchName); | |
} | |
export async function initExtensions() { | |
await addExtensionsButtonAndMenu(); | |
$('#extensionsMenuButton').css('display', 'flex'); | |
$('#extensions_connect').on('click', connectClickHandler); | |
$('#extensions_autoconnect').on('input', autoConnectInputHandler); | |
$('#extensions_details').on('click', showExtensionsDetails); | |
$('#extensions_notify_updates').on('input', notifyUpdatesInputHandler); | |
$(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick); | |
$(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); | |
$(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); | |
$(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); | |
$(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); | |
$(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); | |
/** | |
* Handles the click event for the third-party extension import button. | |
* | |
* @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element. | |
*/ | |
$('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu()); | |
} | |