2024-06-25 10:54:59 +02:00
import { eventSource , event _types , saveSettings , saveSettingsDebounced , getRequestHeaders , animation _duration } from '../script.js' ;
2024-09-25 22:46:19 +02:00
import { showLoader } from './loader.js' ;
2024-06-25 10:54:59 +02:00
import { POPUP _RESULT , POPUP _TYPE , Popup , callGenericPopup } from './popup.js' ;
2024-04-11 21:39:42 +02:00
import { renderTemplate , renderTemplateAsync } from './templates.js' ;
2024-09-25 23:18:37 +02:00
import { isSubsetOf , setValueByPath } from './utils.js' ;
2023-07-20 19:32:15 +02:00
export {
getContext ,
getApiUrl ,
loadExtensionSettings ,
runGenerationInterceptors ,
doExtrasFetch ,
modules ,
extension _settings ,
ModuleWorkerWrapper ,
} ;
2024-09-25 22:46:19 +02:00
/** @type {string[]} */
2023-10-08 22:20:01 +02:00
export let extensionNames = [ ] ;
2024-09-25 22:46:19 +02:00
2023-08-22 21:45:12 +02:00
let manifests = { } ;
2023-12-02 19:04:51 +01:00
const defaultUrl = 'http://localhost:5100' ;
2023-08-23 20:32:38 +02:00
let saveMetadataTimeout = null ;
2023-12-28 11:46:25 +01:00
let requiresReload = false ;
2024-08-19 22:49:15 +02:00
let stateChanged = false ;
2023-12-28 11:46:25 +01:00
2023-08-23 20:32:38 +02:00
export function saveMetadataDebounced ( ) {
const context = getContext ( ) ;
const groupId = context . groupId ;
const characterId = context . characterId ;
if ( saveMetadataTimeout ) {
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 ) ;
}
2023-07-20 19:32:15 +02:00
2023-08-25 19:34:26 +02:00
/ * *
2024-04-11 21:36:23 +02:00
* Provides an ability for extensions to render HTML templates synchronously .
2023-08-25 19:34:26 +02:00
* 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
2024-04-11 21:36:23 +02:00
*
* @ deprecated Use renderExtensionTemplateAsync instead .
2023-08-25 19:34:26 +02:00
* /
export function renderExtensionTemplate ( extensionName , templateId , templateData = { } , sanitize = true , localize = true ) {
return renderTemplate ( ` scripts/extensions/ ${ extensionName } / ${ templateId } .html ` , templateData , sanitize , localize , true ) ;
}
2024-04-11 21:36:23 +02:00
/ * *
* 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 ) ;
}
2023-07-20 19:32:15 +02:00
// Disables parallel updates
class ModuleWorkerWrapper {
constructor ( callback ) {
this . isBusy = false ;
this . callback = callback ;
}
// Called by the extension
2023-11-17 00:30:32 +01:00
async update ( ... args ) {
2023-07-20 19:32:15 +02:00
// Don't touch me I'm busy...
if ( this . isBusy ) {
return ;
}
// I'm free. Let's update!
try {
this . isBusy = true ;
2023-11-17 00:30:32 +01:00
await this . callback ( ... args ) ;
2023-07-20 19:32:15 +02:00
}
finally {
this . isBusy = false ;
}
}
}
const extension _settings = {
apiUrl : defaultUrl ,
apiKey : '' ,
autoConnect : false ,
2023-10-23 15:53:31 +02:00
notifyUpdates : false ,
2023-07-20 19:32:15 +02:00
disabledExtensions : [ ] ,
expressionOverrides : [ ] ,
memory : { } ,
note : {
default : '' ,
chara : [ ] ,
wiAddition : [ ] ,
} ,
caption : {
refine _mode : false ,
} ,
2023-09-14 20:30:02 +02:00
expressions : {
/** @type {string[]} */
custom : [ ] ,
} ,
2024-09-06 23:23:42 +02:00
connectionManager : {
selectedProfile : '' ,
/** @type {import('./extensions/connection-manager/index.js').ConnectionProfile[]} */
profiles : [ ] ,
} ,
2023-07-20 19:32:15 +02:00
dice : { } ,
2024-05-26 16:19:00 +02:00
/** @type {import('./char-data.js').RegexScriptData[]} */
2023-07-20 19:32:15 +02:00
regex : [ ] ,
2024-05-26 16:19:00 +02:00
character _allowed _regex : [ ] ,
2023-07-20 19:32:15 +02:00
tts : { } ,
2023-07-22 20:12:23 +02:00
sd : {
prompts : { } ,
2023-07-22 22:57:48 +02:00
character _prompts : { } ,
2023-12-23 17:19:22 +01:00
character _negative _prompts : { } ,
2023-07-22 20:12:23 +02:00
} ,
2023-07-20 19:32:15 +02:00
chromadb : { } ,
translate : { } ,
objective : { } ,
quickReply : { } ,
randomizer : {
controls : [ ] ,
fluctuation : 0.1 ,
enabled : false ,
} ,
2023-07-27 19:29:36 +02:00
speech _recognition : { } ,
2023-08-10 16:46:04 +02:00
rvc : { } ,
2023-09-07 23:28:06 +02:00
hypebot : { } ,
vectors : { } ,
2023-11-06 21:50:32 +01:00
variables : {
global : { } ,
} ,
2024-04-29 23:06:14 +02:00
/ * *
* @ type { import ( './chats.js' ) . FileAttachment [ ] }
* /
2024-04-16 01:14:34 +02:00
attachments : [ ] ,
2024-04-29 23:06:14 +02:00
/ * *
* @ type { Record < string , import ( './chats.js' ) . FileAttachment [ ] > }
* /
2024-04-18 21:16:51 +02:00
character _attachments : { } ,
2024-04-29 23:06:14 +02:00
/ * *
* @ type { string [ ] }
* /
disabled _attachments : [ ] ,
2023-07-20 19:32:15 +02:00
} ;
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 ) {
2023-12-02 20:11:06 +01:00
args = { } ;
2023-07-20 19:32:15 +02:00
}
if ( ! args . method ) {
Object . assign ( args , { method : 'GET' } ) ;
}
if ( ! args . headers ) {
2023-12-02 20:11:06 +01:00
args . headers = { } ;
2023-07-20 19:32:15 +02:00
}
2024-01-18 15:33:02 +01:00
if ( extension _settings . apiKey ) {
Object . assign ( args . headers , {
'Authorization' : ` Bearer ${ extension _settings . apiKey } ` ,
} ) ;
}
2023-07-20 19:32:15 +02:00
const response = await fetch ( endpoint , args ) ;
return response ;
}
async function discoverExtensions ( ) {
try {
2023-09-16 15:16:48 +02:00
const response = await fetch ( '/api/extensions/discover' ) ;
2023-07-20 19:32:15 +02:00
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' ) ;
2023-12-28 11:46:25 +01:00
disableExtension ( name , false ) ;
2023-07-20 19:32:15 +02:00
}
function onEnableExtensionClick ( ) {
const name = $ ( this ) . data ( 'name' ) ;
2023-12-28 11:46:25 +01:00
enableExtension ( name , false ) ;
2023-07-20 19:32:15 +02:00
}
2024-09-25 23:18:37 +02:00
export async function enableExtension ( name , reload = true ) {
2023-07-20 19:32:15 +02:00
extension _settings . disabledExtensions = extension _settings . disabledExtensions . filter ( x => x !== name ) ;
2024-08-19 22:49:15 +02:00
stateChanged = true ;
2023-07-20 19:32:15 +02:00
await saveSettings ( ) ;
2023-12-28 11:46:25 +01:00
if ( reload ) {
location . reload ( ) ;
} else {
requiresReload = true ;
}
2023-07-20 19:32:15 +02:00
}
2024-09-25 23:18:37 +02:00
export async function disableExtension ( name , reload = true ) {
2023-07-20 19:32:15 +02:00
extension _settings . disabledExtensions . push ( name ) ;
2024-08-19 22:49:15 +02:00
stateChanged = true ;
2023-07-20 19:32:15 +02:00
await saveSettings ( ) ;
2023-12-28 11:46:25 +01:00
if ( reload ) {
location . reload ( ) ;
} else {
requiresReload = true ;
}
2023-07-20 19:32:15 +02:00
}
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 ( ) ;
}
2023-08-22 21:45:12 +02:00
} ) . catch ( err => {
reject ( ) ;
console . log ( 'Could not load manifest.json for ' + name , err ) ;
} ) ;
2023-07-20 19:32:15 +02:00
} ) ;
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 ) ] ) ;
2024-08-25 18:27:18 +02:00
await promise
2023-07-20 19:32:15 +02:00
. 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 ( ) {
2023-12-02 19:04:51 +01:00
const baseUrl = $ ( '#extensions_url' ) . val ( ) ;
2023-08-22 21:45:12 +02:00
extension _settings . apiUrl = String ( baseUrl ) ;
2023-12-02 19:04:51 +01:00
const testApiKey = $ ( '#extensions_api_key' ) . val ( ) ;
2023-08-22 21:45:12 +02:00
extension _settings . apiKey = String ( testApiKey ) ;
2023-07-20 19:32:15 +02:00
saveSettingsDebounced ( ) ;
await connectToApi ( baseUrl ) ;
}
function autoConnectInputHandler ( ) {
const value = $ ( this ) . prop ( 'checked' ) ;
extension _settings . autoConnect = ! ! value ;
if ( value && ! connectedToApi ) {
2023-12-02 19:04:51 +01:00
$ ( '#extensions_connect' ) . trigger ( 'click' ) ;
2023-07-20 19:32:15 +02:00
}
saveSettingsDebounced ( ) ;
}
2024-06-24 22:17:58 +02:00
async function addExtensionsButtonAndMenu ( ) {
const buttonHTML = await renderTemplateAsync ( 'wandButton' ) ;
const extensionsMenuHTML = await renderTemplateAsync ( 'wandMenu' ) ;
2023-07-20 19:32:15 +02:00
$ ( document . body ) . append ( extensionsMenuHTML ) ;
2024-06-06 02:48:06 +02:00
$ ( '#leftSendForm' ) . append ( buttonHTML ) ;
2023-07-20 19:32:15 +02:00
const button = $ ( '#extensionsMenuButton' ) ;
const dropdown = $ ( '#extensionsMenu' ) ;
//dropdown.hide();
let popper = Popper . createPopper ( button . get ( 0 ) , dropdown . get ( 0 ) , {
2023-11-14 07:53:26 +01:00
placement : 'top-start' ,
2023-07-20 19:32:15 +02:00
} ) ;
$ ( button ) . on ( 'click' , function ( ) {
2023-11-28 01:43:24 +01:00
if ( dropdown . is ( ':visible' ) ) {
dropdown . fadeOut ( animation _duration ) ;
} else {
2023-11-18 20:17:53 +01:00
dropdown . fadeIn ( animation _duration ) ;
}
2023-11-28 01:43:24 +01:00
popper . update ( ) ;
2023-07-20 19:32:15 +02:00
} ) ;
2023-12-02 19:04:51 +01:00
$ ( 'html' ) . on ( 'click' , function ( e ) {
2023-11-18 23:40:21 +01:00
const clickTarget = $ ( e . target ) ;
2024-06-30 20:19:07 +02:00
const noCloseTargets = [ '#sd_gen' , '#extensionsMenuButton' , '#roll_dice' ] ;
2023-11-18 23:40:21 +01:00
if ( dropdown . is ( ':visible' ) && ! noCloseTargets . some ( id => clickTarget . closest ( id ) . length > 0 ) ) {
2023-11-18 20:17:53 +01:00
$ ( dropdown ) . fadeOut ( animation _duration ) ;
2023-07-20 19:32:15 +02:00
}
} ) ;
}
2023-10-23 15:53:31 +02:00
function notifyUpdatesInputHandler ( ) {
extension _settings . notifyUpdates = ! ! $ ( '#extensions_notify_updates' ) . prop ( 'checked' ) ;
saveSettingsDebounced ( ) ;
if ( extension _settings . notifyUpdates ) {
checkForExtensionUpdates ( true ) ;
}
}
2023-07-20 19:32:15 +02:00
/ * $ ( d o c u m e n t ) . o n ( ' c l i c k ' , f u n c t i o n ( 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 ;
2023-12-02 19:04:51 +01:00
link . rel = 'stylesheet' ;
link . type = 'text/css' ;
2023-07-20 19:32:15 +02:00
link . href = url ;
link . onload = function ( ) {
resolve ( ) ;
2023-12-02 20:11:06 +01:00
} ;
2023-07-20 19:32:15 +02:00
link . onerror = function ( e ) {
reject ( e ) ;
2023-12-02 20:11:06 +01:00
} ;
2023-07-20 19:32:15 +02:00
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 .
2024-06-26 14:49:23 +02:00
* @ return { Promise < string > } - The HTML string that represents the extension .
2023-07-20 19:32:15 +02:00
* /
async function generateExtensionHtml ( name , manifest , isActive , isDisabled , isExternal , checkboxClass ) {
const displayName = manifest . display _name ;
2023-12-02 19:04:51 +01:00
let displayVersion = manifest . version ? ` v ${ manifest . version } ` : '' ;
2023-07-20 19:32:15 +02:00
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 ;
2023-12-02 20:11:06 +01:00
let origin = data . remoteUrl ;
2023-07-20 19:32:15 +02:00
isUpToDate = data . isUpToDate ;
displayVersion = ` ( ${ branch } - ${ commitHash . substring ( 0 , 7 ) } ) ` ;
updateButton = isUpToDate ?
2023-11-19 00:33:54 +01:00
` <span class="update-button"><button class="btn_update menu_button" data-name=" ${ name . replace ( 'third-party' , '' ) } " title="Up to date"><i class="fa-solid fa-code-commit fa-fw"></i></button></span> ` :
` <span class="update-button"><button class="btn_update menu_button" data-name=" ${ name . replace ( 'third-party' , '' ) } " title="Update available"><i class="fa-solid fa-download fa-fw"></i></button></span> ` ;
2023-07-20 19:32:15 +02:00
originHtml = ` <a href=" ${ origin } " target="_blank" rel="noopener noreferrer"> ` ;
}
let toggleElement = isActive || isDisabled ?
` <input type="checkbox" title="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 ? ` <span class="delete-button"><button class="btn_delete menu_button" data-name=" ${ name . replace ( 'third-party' , '' ) } " title="Delete"><i class="fa-solid fa-trash-can"></i></button></span> ` : '' ;
// if external, wrap the name in a link to the repo
let extensionHtml = ` <hr>
< h4 >
$ { updateButton }
$ { deleteButton }
$ { originHtml }
2023-12-02 19:04:51 +01:00
< span class = "${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}" >
2023-07-20 19:32:15 +02:00
$ { DOMPurify . sanitize ( displayName ) } $ { displayVersion }
< / s p a n >
$ { isExternal ? '</a>' : '' }
< span style = "float:right;" > $ { toggleElement } < / s p a n >
< / h 4 > ` ;
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 ( ', ' ) ) ;
extensionHtml += ` <p>Optional modules: <span class="optional"> ${ optionalString } </span></p> ` ;
}
} else if ( ! isDisabled ) { // Neither active nor disabled
const requirements = new Set ( manifest . requires ) ;
modules . forEach ( x => requirements . delete ( x ) ) ;
2024-06-26 14:49:23 +02:00
if ( requirements . size > 0 ) {
const requirementsString = DOMPurify . sanitize ( [ ... requirements ] . join ( ', ' ) ) ;
extensionHtml += ` <p>Missing modules: <span class="failure"> ${ requirementsString } </span></p> ` ;
}
2023-07-20 19:32:15 +02:00
}
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 .
2023-08-22 21:45:12 +02:00
* @ return { Promise < object > } - An object with 'isExternal' indicating whether the extension is external , and 'extensionHtml' for the extension ' s HTML string .
2023-07-20 19:32:15 +02:00
* /
async 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' ) ;
2023-12-02 19:04:51 +01:00
const checkboxClass = isDisabled ? 'checkbox_disabled' : '' ;
2023-07-20 19:32:15 +02:00
const extensionHtml = await 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">Not connected to the API!</p>' ;
return `
2023-10-31 21:16:33 +01:00
< h3 > Modules provided by your Extras API : < / h 3 >
2023-07-20 19:32:15 +02:00
$ { moduleInfo }
` ;
}
/ * *
* Generates the HTML strings for all extensions and displays them in a popup .
* /
async function showExtensionsDetails ( ) {
2023-12-28 11:46:25 +01:00
let popupPromise ;
2023-11-14 07:53:26 +01:00
try {
2024-07-20 13:08:24 +02:00
const htmlDefault = $ ( '<h3>Built-in Extensions:</h3>' ) ;
const htmlExternal = $ ( '<h3>Installed Extensions:</h3>' ) . addClass ( 'opacity50p' ) ;
const htmlLoading = $ ( ` <h3 class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
< i class = "fa-solid fa-spinner fa-spin" > < / i >
< span > Loading third - party extensions ... Please wait ... < / s p a n >
< / h 3 > ` ) ;
/** @type {Promise<any>[]} */
2023-11-10 09:25:34 +01:00
const promises = [ ] ;
2024-07-20 13:08:24 +02:00
const extensions = Object . entries ( manifests ) . sort ( ( a , b ) => a [ 1 ] . loading _order - b [ 1 ] . loading _order ) ;
2023-07-20 19:32:15 +02:00
2023-11-10 09:25:34 +01:00
for ( const extension of extensions ) {
promises . push ( getExtensionData ( extension ) ) ;
}
2023-07-20 19:32:15 +02:00
2024-07-20 13:08:24 +02:00
promises . forEach ( promise => {
promise . then ( value => {
const { isExternal , extensionHtml } = value ;
const container = isExternal ? htmlExternal : htmlDefault ;
container . append ( extensionHtml ) ;
} ) ;
} ) ;
2023-10-23 15:53:31 +02:00
2024-07-20 13:08:24 +02:00
Promise . allSettled ( promises ) . then ( ( ) => {
htmlLoading . remove ( ) ;
htmlExternal . removeClass ( 'opacity50p' ) ;
2023-11-10 09:25:34 +01:00
} ) ;
2023-10-23 15:53:31 +02:00
2024-07-20 13:08:24 +02:00
const html = $ ( '<div></div>' )
. addClass ( 'extensions_info' )
. append ( getModuleInformation ( ) )
. append ( htmlDefault )
. append ( htmlLoading )
. append ( htmlExternal ) ;
2024-06-25 10:54:59 +02:00
/** @type {import('./popup.js').CustomPopupButton} */
const updateAllButton = {
text : 'Update all' ,
appendAtEnd : true ,
action : async ( ) => {
requiresReload = true ;
await autoUpdateExtensions ( true ) ;
2024-06-30 20:44:29 +02:00
await popup . complete ( POPUP _RESULT . AFFIRMATIVE ) ;
2024-06-25 10:54:59 +02:00
} ,
} ;
2024-06-30 19:42:17 +02:00
// If we are updating an extension, the "old" popup is still active. We should close that.
const oldPopup = Popup . util . popups . find ( popup => popup . content . querySelector ( '.extensions_info' ) ) ;
if ( oldPopup ) {
2024-06-30 20:44:29 +02:00
await oldPopup . complete ( POPUP _RESULT . CANCELLED ) ;
2024-06-30 19:42:17 +02:00
}
2024-08-19 23:08:36 +02:00
let waitingForSave = false ;
2024-08-19 22:49:15 +02:00
const popup = new Popup ( html , POPUP _TYPE . TEXT , '' , {
okButton : 'Close' ,
wide : true ,
large : true ,
customButtons : [ updateAllButton ] ,
allowVerticalScrolling : true ,
onClosing : async ( ) => {
2024-08-19 23:08:36 +02:00
if ( waitingForSave ) {
return false ;
}
2024-08-19 22:49:15 +02:00
if ( stateChanged ) {
2024-08-19 23:08:36 +02:00
waitingForSave = true ;
const toast = toastr . info ( 'The page will be reloaded shortly...' , 'Extensions state changed' ) ;
2024-08-19 22:49:15 +02:00
await saveSettings ( ) ;
2024-08-19 23:08:36 +02:00
toastr . clear ( toast ) ;
2024-08-19 23:11:22 +02:00
waitingForSave = false ;
requiresReload = true ;
2024-08-19 22:49:15 +02:00
}
return true ;
} ,
} ) ;
2024-06-25 10:54:59 +02:00
popupPromise = popup . show ( ) ;
2023-11-10 09:25:34 +01:00
} catch ( error ) {
toastr . error ( 'Error loading extensions. See browser console for details.' ) ;
console . error ( error ) ;
}
2023-12-28 11:46:25 +01:00
if ( popupPromise ) {
await popupPromise ;
}
if ( requiresReload ) {
showLoader ( ) ;
location . reload ( ) ;
}
2023-07-20 19:32:15 +02:00
}
/ * *
* Handles the click event for the update button of an extension .
* This function makes a POST request to '/update_extension' 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 extensionName = $ ( this ) . data ( 'name' ) ;
2023-11-19 00:33:54 +01:00
$ ( this ) . find ( 'i' ) . addClass ( 'fa-spin' ) ;
2023-10-09 22:45:09 +02:00
await updateExtension ( extensionName , false ) ;
}
/ * *
* 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
* /
async function updateExtension ( extensionName , quiet ) {
2023-07-20 19:32:15 +02:00
try {
2023-09-21 19:42:06 +02:00
const response = await fetch ( '/api/extensions/update' , {
2023-07-20 19:32:15 +02:00
method : 'POST' ,
headers : getRequestHeaders ( ) ,
2023-12-02 21:06:57 +01:00
body : JSON . stringify ( { extensionName } ) ,
2023-07-20 19:32:15 +02:00
} ) ;
const data = await response . json ( ) ;
2023-11-19 00:33:54 +01:00
if ( ! quiet ) {
showExtensionsDetails ( ) ;
}
2023-07-20 19:32:15 +02:00
if ( data . isUpToDate ) {
2023-10-09 22:45:09 +02:00
if ( ! quiet ) {
toastr . success ( 'Extension is already up to date' ) ;
}
2023-07-20 19:32:15 +02:00
} else {
2024-01-11 14:03:55 +01:00
toastr . success ( ` Extension ${ extensionName } updated to ${ data . shortCommitHash } ` , 'Reload the page to apply updates' ) ;
2023-10-09 22:45:09 +02:00
}
2023-07-20 19:32:15 +02:00
} catch ( error ) {
console . error ( 'Error:' , error ) ;
}
2023-10-09 22:45:09 +02:00
}
2023-07-20 19:32:15 +02:00
/ * *
* Handles the click event for the delete button of an extension .
2023-09-16 15:16:48 +02:00
* This function makes a POST request to '/api/extensions/delete' with the extension ' s name .
2023-07-20 19:32:15 +02:00
* 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' ) ;
// use callPopup to create a popup for the user to confirm before delete
2024-06-25 10:54:59 +02:00
const confirmation = await callGenericPopup ( ` Are you sure you want to delete ${ extensionName } ? ` , POPUP _TYPE . CONFIRM , '' , { } ) ;
if ( confirmation === POPUP _RESULT . AFFIRMATIVE ) {
2023-10-08 22:20:01 +02:00
await deleteExtension ( extensionName ) ;
2023-07-20 19:32:15 +02:00
}
2023-12-02 16:15:03 +01:00
}
2023-07-20 19:32:15 +02:00
2023-10-08 22:20:01 +02:00
export async function deleteExtension ( extensionName ) {
try {
2023-12-02 17:25:30 +01:00
await fetch ( '/api/extensions/delete' , {
2023-10-08 22:20:01 +02:00
method : 'POST' ,
headers : getRequestHeaders ( ) ,
2023-12-02 21:06:57 +01:00
body : JSON . stringify ( { extensionName } ) ,
2023-10-08 22:20:01 +02:00
} ) ;
} catch ( error ) {
console . error ( 'Error:' , error ) ;
}
2023-07-20 19:32:15 +02:00
2023-10-08 22:20:01 +02:00
toastr . success ( ` Extension ${ extensionName } deleted ` ) ;
showExtensionsDetails ( ) ;
// reload the page to remove the extension from the list
location . reload ( ) ;
}
2023-07-20 19:32:15 +02:00
/ * *
* Fetches the version details of a specific extension .
*
* @ param { string } extensionName - The name of the extension .
2023-08-22 21:45:12 +02:00
* @ return { Promise < object > } - An object containing the extension ' s version details .
2023-07-20 19:32:15 +02:00
* 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 ) {
try {
2023-09-16 15:16:48 +02:00
const response = await fetch ( '/api/extensions/version' , {
2023-07-20 19:32:15 +02:00
method : 'POST' ,
headers : getRequestHeaders ( ) ,
2023-12-02 21:06:57 +01:00
body : JSON . stringify ( { extensionName } ) ,
2023-07-20 19:32:15 +02:00
} ) ;
const data = await response . json ( ) ;
return data ;
} catch ( error ) {
console . error ( 'Error:' , error ) ;
}
}
2023-10-08 22:20:01 +02:00
/ * *
* Installs a third - party extension via the API .
* @ param { string } url Extension repository URL
* @ returns { Promise < void > }
* /
export async function installExtension ( url ) {
2023-10-23 15:53:31 +02:00
console . debug ( 'Extension installation started' , url ) ;
2023-07-20 19:32:15 +02:00
2023-10-23 15:53:31 +02:00
toastr . info ( 'Please wait...' , 'Installing extension' ) ;
2023-10-23 12:27:04 +02:00
2023-10-08 22:20:01 +02:00
const request = await fetch ( '/api/extensions/install' , {
method : 'POST' ,
headers : getRequestHeaders ( ) ,
body : JSON . stringify ( { url } ) ,
} ) ;
if ( ! request . ok ) {
2023-10-27 12:07:56 +02:00
const text = await request . text ( ) ;
toastr . warning ( text || request . statusText , 'Extension installation failed' , { timeOut : 5000 } ) ;
console . error ( 'Extension installation failed' , request . status , request . statusText , text ) ;
2023-10-08 22:20:01 +02:00
return ;
}
const response = await request . json ( ) ;
2023-10-23 15:53:31 +02:00
toastr . success ( ` Extension " ${ response . display _name } " by ${ response . author } (version ${ response . version } ) has been installed successfully! ` , 'Extension installation successful' ) ;
console . debug ( ` Extension " ${ response . display _name } " has been installed successfully at ${ response . extensionPath } ` ) ;
2024-08-25 18:48:01 +02:00
await loadExtensionSettings ( { } , false , false ) ;
await eventSource . emit ( event _types . EXTENSION _SETTINGS _LOADED ) ;
2023-10-08 22:20:01 +02:00
}
2023-07-20 19:32:15 +02:00
2023-10-09 22:45:09 +02:00
/ * *
* Loads extension settings from the app settings .
* @ param { object } settings App Settings
* @ param { boolean } versionChanged Is this a version change ?
2024-08-25 18:48:01 +02:00
* @ param { boolean } enableAutoUpdate Enable auto - update
2023-10-09 22:45:09 +02:00
* /
2024-08-25 18:48:01 +02:00
async function loadExtensionSettings ( settings , versionChanged , enableAutoUpdate ) {
2023-07-20 19:32:15 +02:00
if ( settings . extension _settings ) {
Object . assign ( extension _settings , settings . extension _settings ) ;
}
2023-12-02 19:04:51 +01:00
$ ( '#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 ) ;
2023-07-20 19:32:15 +02:00
// Activate offline extensions
2024-08-25 18:48:01 +02:00
await eventSource . emit ( event _types . EXTENSIONS _FIRST _LOAD ) ;
2023-07-20 19:32:15 +02:00
extensionNames = await discoverExtensions ( ) ;
2023-12-02 20:11:06 +01:00
manifests = await getManifests ( extensionNames ) ;
2023-10-09 22:45:09 +02:00
2024-08-25 18:48:01 +02:00
if ( versionChanged && enableAutoUpdate ) {
2024-06-25 10:54:59 +02:00
await autoUpdateExtensions ( false ) ;
2023-10-09 22:45:09 +02:00
}
2023-07-20 19:32:15 +02:00
await activateExtensions ( ) ;
if ( extension _settings . autoConnect && extension _settings . apiUrl ) {
connectToApi ( extension _settings . apiUrl ) ;
}
2023-10-27 20:23:58 +02:00
}
2023-10-09 22:45:09 +02:00
2023-10-27 20:23:58 +02:00
export function doDailyExtensionUpdatesCheck ( ) {
setTimeout ( ( ) => {
if ( extension _settings . notifyUpdates ) {
checkForExtensionUpdates ( false ) ;
}
} , 1 ) ;
2023-10-23 15:53:31 +02:00
}
/ * *
* Checks if there are updates available for 3 rd - 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 ( localStorage . getItem ( STORAGE _NAG _KEY ) === currentDate ) {
return ;
}
localStorage . setItem ( STORAGE _NAG _KEY , currentDate ) ;
}
const updatesAvailable = [ ] ;
const promises = [ ] ;
for ( const [ id , manifest ] of Object . entries ( manifests ) ) {
if ( manifest . auto _update && id . startsWith ( 'third-party' ) ) {
const promise = new Promise ( async ( resolve , reject ) => {
try {
const data = await getExtensionVersion ( id . replace ( 'third-party' , '' ) ) ;
if ( data . isUpToDate === false ) {
updatesAvailable . push ( manifest . display _name ) ;
}
resolve ( ) ;
} catch ( error ) {
console . error ( 'Error checking for extension updates' , error ) ;
reject ( ) ;
}
} ) ;
promises . push ( promise ) ;
}
}
await Promise . allSettled ( promises ) ;
if ( updatesAvailable . length > 0 ) {
toastr . info ( ` ${ updatesAvailable . map ( x => ` • ${ x } ` ) . join ( '\n' ) } ` , 'Extension updates available' ) ;
}
2023-10-09 22:45:09 +02:00
}
2024-06-25 10:54:59 +02:00
/ * *
* Updates all 3 rd - party extensions that have auto - update enabled .
* @ param { boolean } forceAll Force update all even if not auto - updating
* @ returns { Promise < void > }
* /
async function autoUpdateExtensions ( forceAll ) {
2023-11-25 23:52:00 +01:00
if ( ! Object . values ( manifests ) . some ( x => x . auto _update ) ) {
return ;
}
2023-11-30 00:41:20 +01:00
const banner = toastr . info ( 'Auto-updating extensions. This may take several minutes.' , 'Please wait...' , { timeOut : 10000 , extendedTimeOut : 10000 } ) ;
2023-11-21 23:58:06 +01:00
const promises = [ ] ;
2023-10-09 22:45:09 +02:00
for ( const [ id , manifest ] of Object . entries ( manifests ) ) {
2024-06-25 10:54:59 +02:00
if ( ( forceAll || manifest . auto _update ) && id . startsWith ( 'third-party' ) ) {
2023-10-09 22:45:09 +02:00
console . debug ( ` Auto-updating 3rd-party extension: ${ manifest . display _name } ( ${ id } ) ` ) ;
2023-11-21 23:58:06 +01:00
promises . push ( updateExtension ( id . replace ( 'third-party' , '' ) , true ) ) ;
2023-10-09 22:45:09 +02:00
}
}
2023-11-21 23:58:06 +01:00
await Promise . allSettled ( promises ) ;
2023-11-30 00:41:20 +01:00
toastr . clear ( banner ) ;
2023-07-20 19:32:15 +02:00
}
2023-10-29 17:29:10 +01:00
/ * *
* Runs the generate interceptors for all extensions .
* @ param { any [ ] } chat Chat array
* @ param { number } contextSize Context size
* @ returns { Promise < boolean > } True if generation should be aborted
* /
2023-07-20 19:32:15 +02:00
async function runGenerationInterceptors ( chat , contextSize ) {
2023-10-29 17:29:10 +01:00
let aborted = false ;
let exitImmediately = false ;
const abort = ( /** @type {boolean} */ immediately ) => {
aborted = true ;
exitImmediately = immediately ;
} ;
2023-12-12 00:08:47 +01:00
for ( const manifest of Object . values ( manifests ) . sort ( ( a , b ) => a . loading _order - b . loading _order ) ) {
2023-07-20 19:32:15 +02:00
const interceptorKey = manifest . generate _interceptor ;
if ( typeof window [ interceptorKey ] === 'function' ) {
try {
2023-10-29 17:29:10 +01:00
await window [ interceptorKey ] ( chat , contextSize , abort ) ;
2023-07-20 19:32:15 +02:00
} catch ( e ) {
console . error ( ` Failed running interceptor for ${ manifest . display _name } ` , e ) ;
}
}
2023-10-29 17:29:10 +01:00
if ( exitImmediately ) {
break ;
}
2023-07-20 19:32:15 +02:00
}
2023-10-29 17:29:10 +01:00
return aborted ;
2023-07-20 19:32:15 +02:00
}
2024-03-12 00:49:05 +01:00
/ * *
* Writes a field to the character ' s data extensions object .
* @ param { number } 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 ) ;
}
}
2024-08-13 00:09:14 +02:00
/ * *
* 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 html = await renderTemplateAsync ( 'installExtension' ) ;
const input = await callGenericPopup ( html , POPUP _TYPE . INPUT , suggestUrl ? ? '' ) ;
if ( ! input ) {
console . debug ( 'Extension install cancelled' ) ;
return ;
}
const url = String ( input ) . trim ( ) ;
await installExtension ( url ) ;
}
2024-09-25 23:10:00 +02:00
2024-09-25 22:46:19 +02:00
2024-09-25 21:58:46 +02:00
export async function initExtensions ( ) {
2024-06-24 22:17:58 +02:00
await addExtensionsButtonAndMenu ( ) ;
2023-12-02 19:04:51 +01:00
$ ( '#extensionsMenuButton' ) . css ( 'display' , 'flex' ) ;
2023-07-20 19:32:15 +02:00
2023-12-02 19:04:51 +01:00
$ ( '#extensions_connect' ) . on ( 'click' , connectClickHandler ) ;
$ ( '#extensions_autoconnect' ) . on ( 'input' , autoConnectInputHandler ) ;
$ ( '#extensions_details' ) . on ( 'click' , showExtensionsDetails ) ;
$ ( '#extensions_notify_updates' ) . on ( 'input' , notifyUpdatesInputHandler ) ;
2023-07-20 19:32:15 +02:00
$ ( document ) . on ( 'click' , '.toggle_disable' , onDisableExtensionClick ) ;
$ ( document ) . on ( 'click' , '.toggle_enable' , onEnableExtensionClick ) ;
$ ( document ) . on ( 'click' , '.btn_update' , onUpdateClick ) ;
$ ( document ) . on ( 'click' , '.btn_delete' , onDeleteClick ) ;
2023-10-23 15:53:31 +02:00
/ * *
* 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 .
* /
2024-08-13 00:09:14 +02:00
$ ( '#third_party_extension_button' ) . on ( 'click' , ( ) => openThirdPartyExtensionMenu ( ) ) ;
2024-09-25 21:58:46 +02:00
}