mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			5238 lines
		
	
	
		
			200 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			5238 lines
		
	
	
		
			200 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import { Fuse } from '../lib.js';
 | |
| 
 | |
| import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
 | |
| import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique } from './utils.js';
 | |
| import { extension_settings, getContext } from './extensions.js';
 | |
| import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
 | |
| import { isMobile } from './RossAscends-mods.js';
 | |
| import { FILTER_TYPES, FilterHelper } from './filters.js';
 | |
| import { getTokenCountAsync } from './tokenizers.js';
 | |
| import { power_user } from './power-user.js';
 | |
| import { getTagKeyForEntity } from './tags.js';
 | |
| import { debounce_timeout } from './constants.js';
 | |
| import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
 | |
| import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
 | |
| import { SlashCommand } from './slash-commands/SlashCommand.js';
 | |
| import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
 | |
| import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
 | |
| import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
 | |
| import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
 | |
| import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
 | |
| import { StructuredCloneMap } from './util/StructuredCloneMap.js';
 | |
| import { renderTemplateAsync } from './templates.js';
 | |
| import { t } from './i18n.js';
 | |
| import { accountStorage } from './util/AccountStorage.js';
 | |
| 
 | |
| export const world_info_insertion_strategy = {
 | |
|     evenly: 0,
 | |
|     character_first: 1,
 | |
|     global_first: 2,
 | |
| };
 | |
| 
 | |
| export const world_info_logic = {
 | |
|     AND_ANY: 0,
 | |
|     NOT_ALL: 1,
 | |
|     NOT_ANY: 2,
 | |
|     AND_ALL: 3,
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * @enum {number} Possible states of the WI evaluation
 | |
|  */
 | |
| export const scan_state = {
 | |
|     /**
 | |
|      * The scan will be stopped.
 | |
|      */
 | |
|     NONE: 0,
 | |
|     /**
 | |
|      * Initial state.
 | |
|      */
 | |
|     INITIAL: 1,
 | |
|     /**
 | |
|      * The scan is triggered by a recursion step.
 | |
|      */
 | |
|     RECURSION: 2,
 | |
|     /**
 | |
|      * The scan is triggered by a min activations depth skew.
 | |
|      */
 | |
|     MIN_ACTIVATIONS: 3,
 | |
| };
 | |
| 
 | |
| const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry');
 | |
| 
 | |
| export let world_info = {};
 | |
| export let selected_world_info = [];
 | |
| /** @type {string[]} */
 | |
| export let world_names;
 | |
| export let world_info_depth = 2;
 | |
| export let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated
 | |
| export let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0)
 | |
| 
 | |
| export let world_info_budget = 25;
 | |
| export let world_info_include_names = true;
 | |
| export let world_info_recursive = false;
 | |
| export let world_info_overflow_alert = false;
 | |
| export let world_info_case_sensitive = false;
 | |
| export let world_info_match_whole_words = false;
 | |
| export let world_info_use_group_scoring = false;
 | |
| export let world_info_character_strategy = world_info_insertion_strategy.character_first;
 | |
| export let world_info_budget_cap = 0;
 | |
| export let world_info_max_recursion_steps = 0;
 | |
| const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), debounce_timeout.relaxed);
 | |
| const saveSettingsDebounced = debounce(() => {
 | |
|     Object.assign(world_info, { globalSelect: selected_world_info });
 | |
|     saveSettings();
 | |
| }, debounce_timeout.relaxed);
 | |
| const sortFn = (a, b) => b.order - a.order;
 | |
| let updateEditor = (navigation, flashOnNav = true) => { console.debug('Triggered WI navigation', navigation, flashOnNav); };
 | |
| 
 | |
| // Do not optimize. updateEditor is a function that is updated by the displayWorldEntries with new data.
 | |
| export const worldInfoFilter = new FilterHelper(() => updateEditor());
 | |
| export const SORT_ORDER_KEY = 'world_info_sort_order';
 | |
| export const METADATA_KEY = 'world_info';
 | |
| 
 | |
| export const DEFAULT_DEPTH = 4;
 | |
| export const DEFAULT_WEIGHT = 100;
 | |
| export const MAX_SCAN_DEPTH = 1000;
 | |
| const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
 | |
| 
 | |
| // Typedef area
 | |
| /**
 | |
|  * @typedef {object} WIScanEntry The entry that triggered the scan
 | |
|  * @property {number} [scanDepth] The depth of the scan
 | |
|  * @property {boolean} [caseSensitive] If the scan is case sensitive
 | |
|  * @property {boolean} [matchWholeWords] If the scan should match whole words
 | |
|  * @property {boolean} [useGroupScoring] If the scan should use group scoring
 | |
|  * @property {number} [uid] The UID of the entry that triggered the scan
 | |
|  * @property {string} [world] The world info book of origin of the entry
 | |
|  * @property {string[]} [key] The primary keys to scan for
 | |
|  * @property {string[]} [keysecondary] The secondary keys to scan for
 | |
|  * @property {number} [selectiveLogic] The logic to use for selective activation
 | |
|  * @property {number} [sticky] The sticky value of the entry
 | |
|  * @property {number} [cooldown] The cooldown of the entry
 | |
|  * @property {number} [delay] The delay of the entry
 | |
|  * @property {string[]} [decorators] Array of decorators for the entry
 | |
|  * @property {number} [hash] The hash of the entry
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * @typedef {object} WITimedEffect Timed effect for world info
 | |
|  * @property {number} hash Hash of the entry that triggered the effect
 | |
|  * @property {number} start The chat index where the effect starts
 | |
|  * @property {number} end The chat index where the effect ends
 | |
|  * @property {boolean} protected The protected effect can't be removed if the chat does not advance
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * @typedef TimedEffectType Type of timed effect
 | |
|  * @type {'sticky'|'cooldown'|'delay'}
 | |
|  */
 | |
| // End typedef area
 | |
| 
 | |
| /**
 | |
|  * Represents a scanning buffer for one evaluation of World Info.
 | |
|  */
 | |
| class WorldInfoBuffer {
 | |
|     /**
 | |
|      * @type {Map<string, object>} Map of entries that need to be activated no matter what
 | |
|      */
 | |
|     static externalActivations = new Map();
 | |
| 
 | |
|     /**
 | |
|      * @type {string[]} Array of messages sorted by ascending depth
 | |
|      */
 | |
|     #depthBuffer = [];
 | |
| 
 | |
|     /**
 | |
|      * @type {string[]} Array of strings added by recursive scanning
 | |
|      */
 | |
|     #recurseBuffer = [];
 | |
| 
 | |
|     /**
 | |
|      * @type {string[]} Array of strings added by prompt injections that are valid for the current scan
 | |
|      */
 | |
|     #injectBuffer = [];
 | |
| 
 | |
|     /**
 | |
|      * @type {number} The skew of the global scan depth. Used in "min activations"
 | |
|      */
 | |
|     #skew = 0;
 | |
| 
 | |
|     /**
 | |
|      * @type {number} The starting depth of the global scan depth.
 | |
|      */
 | |
|     #startDepth = 0;
 | |
| 
 | |
|     /**
 | |
|      * Initialize the buffer with the given messages.
 | |
|      * @param {string[]} messages Array of messages to add to the buffer
 | |
|      */
 | |
|     constructor(messages) {
 | |
|         this.#initDepthBuffer(messages);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Populates the buffer with the given messages.
 | |
|      * @param {string[]} messages Array of messages to add to the buffer
 | |
|      * @returns {void} Hardly seen nothing down here
 | |
|      */
 | |
|     #initDepthBuffer(messages) {
 | |
|         for (let depth = 0; depth < MAX_SCAN_DEPTH; depth++) {
 | |
|             if (messages[depth]) {
 | |
|                 this.#depthBuffer[depth] = messages[depth].trim();
 | |
|             }
 | |
|             // break if last message is reached
 | |
|             if (depth === messages.length - 1) {
 | |
|                 break;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets a string that respects the case sensitivity setting
 | |
|      * @param {string} str The string to transform
 | |
|      * @param {WIScanEntry} entry The entry that triggered the scan
 | |
|      * @returns {string} The transformed string
 | |
|     */
 | |
|     #transformString(str, entry) {
 | |
|         const caseSensitive = entry.caseSensitive ?? world_info_case_sensitive;
 | |
|         return caseSensitive ? str : str.toLowerCase();
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets all messages up to the given depth + recursion buffer.
 | |
|      * @param {WIScanEntry} entry The entry that triggered the scan
 | |
|      * @param {number} scanState The state of the scan
 | |
|      * @returns {string} A slice of buffer until the given depth (inclusive)
 | |
|      */
 | |
|     get(entry, scanState) {
 | |
|         let depth = entry.scanDepth ?? this.getDepth();
 | |
|         if (depth <= this.#startDepth) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (depth < 0) {
 | |
|             console.error(`[WI] Invalid WI scan depth ${depth}. Must be >= 0`);
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (depth > MAX_SCAN_DEPTH) {
 | |
|             console.warn(`[WI] Invalid WI scan depth ${depth}. Truncating to ${MAX_SCAN_DEPTH}`);
 | |
|             depth = MAX_SCAN_DEPTH;
 | |
|         }
 | |
| 
 | |
|         const MATCHER = '\x01';
 | |
|         const JOINER = '\n' + MATCHER;
 | |
|         let result = MATCHER + this.#depthBuffer.slice(this.#startDepth, depth).join(JOINER);
 | |
| 
 | |
|         if (this.#injectBuffer.length > 0) {
 | |
|             result += JOINER + this.#injectBuffer.join(JOINER);
 | |
|         }
 | |
| 
 | |
|         // Min activations should not include the recursion buffer
 | |
|         if (this.#recurseBuffer.length > 0 && scanState !== scan_state.MIN_ACTIVATIONS) {
 | |
|             result += JOINER + this.#recurseBuffer.join(JOINER);
 | |
|         }
 | |
| 
 | |
|         return result;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Matches the given string against the buffer.
 | |
|      * @param {string} haystack The string to search in
 | |
|      * @param {string} needle The string to search for
 | |
|      * @param {WIScanEntry} entry The entry that triggered the scan
 | |
|      * @returns {boolean} True if the string was found in the buffer
 | |
|      */
 | |
|     matchKeys(haystack, needle, entry) {
 | |
|         // If the needle is a regex, we do regex pattern matching and override all the other options
 | |
|         const keyRegex = parseRegexFromString(needle);
 | |
|         if (keyRegex) {
 | |
|             return keyRegex.test(haystack);
 | |
|         }
 | |
| 
 | |
|         // Otherwise we do normal matching of plaintext with the chosen entry settings
 | |
|         haystack = this.#transformString(haystack, entry);
 | |
|         const transformedString = this.#transformString(needle, entry);
 | |
|         const matchWholeWords = entry.matchWholeWords ?? world_info_match_whole_words;
 | |
| 
 | |
|         if (matchWholeWords) {
 | |
|             const keyWords = transformedString.split(/\s+/);
 | |
| 
 | |
|             if (keyWords.length > 1) {
 | |
|                 return haystack.includes(transformedString);
 | |
|             }
 | |
|             else {
 | |
|                 // Use custom boundaries to include punctuation and other non-alphanumeric characters
 | |
|                 const regex = new RegExp(`(?:^|\\W)(${escapeRegex(transformedString)})(?:$|\\W)`);
 | |
|                 if (regex.test(haystack)) {
 | |
|                     return true;
 | |
|                 }
 | |
|             }
 | |
|         } else {
 | |
|             return haystack.includes(transformedString);
 | |
|         }
 | |
| 
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Adds a message to the recursion buffer.
 | |
|      * @param {string} message The message to add
 | |
|      */
 | |
|     addRecurse(message) {
 | |
|         this.#recurseBuffer.push(message);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Adds an injection to the buffer.
 | |
|      * @param {string} message The injection to add
 | |
|      */
 | |
|     addInject(message) {
 | |
|         this.#injectBuffer.push(message);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Checks if the recursion buffer is not empty.
 | |
|      * @returns {boolean} Returns true if the recursion buffer is not empty, otherwise false
 | |
|      */
 | |
|     hasRecurse() {
 | |
|         return this.#recurseBuffer.length > 0;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Increments skew to advance the scan range.
 | |
|      */
 | |
|     advanceScan() {
 | |
|         this.#skew++;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * @returns {number} Settings' depth + current skew.
 | |
|      */
 | |
|     getDepth() {
 | |
|         return world_info_depth + this.#skew;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Get the externally activated version of the entry, if there is one.
 | |
|      * @param {object} entry WI entry to check
 | |
|      * @returns {object|undefined} the external version if the entry is forcefully activated, undefined otherwise
 | |
|      */
 | |
|     getExternallyActivated(entry) {
 | |
|         return WorldInfoBuffer.externalActivations.get(`${entry.world}.${entry.uid}`);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Clean-up the external effects for entries.
 | |
|      */
 | |
|     resetExternalEffects() {
 | |
|         WorldInfoBuffer.externalActivations = new Map();
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets the match score for the given entry.
 | |
|      * @param {WIScanEntry} entry Entry to check
 | |
|      * @param {number} scanState The state of the scan
 | |
|      * @returns {number} The number of key activations for the given entry
 | |
|      */
 | |
|     getScore(entry, scanState) {
 | |
|         const bufferState = this.get(entry, scanState);
 | |
|         let numberOfPrimaryKeys = 0;
 | |
|         let numberOfSecondaryKeys = 0;
 | |
|         let primaryScore = 0;
 | |
|         let secondaryScore = 0;
 | |
| 
 | |
|         // Increment score for every key found in the buffer
 | |
|         if (Array.isArray(entry.key)) {
 | |
|             numberOfPrimaryKeys = entry.key.length;
 | |
|             for (const key of entry.key) {
 | |
|                 if (this.matchKeys(bufferState, key, entry)) {
 | |
|                     primaryScore++;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Increment score for every secondary key found in the buffer
 | |
|         if (Array.isArray(entry.keysecondary)) {
 | |
|             numberOfSecondaryKeys = entry.keysecondary.length;
 | |
|             for (const key of entry.keysecondary) {
 | |
|                 if (this.matchKeys(bufferState, key, entry)) {
 | |
|                     secondaryScore++;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // No keys == no score
 | |
|         if (!numberOfPrimaryKeys) {
 | |
|             return 0;
 | |
|         }
 | |
| 
 | |
|         // Only positive logic influences the score
 | |
|         if (numberOfSecondaryKeys > 0) {
 | |
|             switch (entry.selectiveLogic) {
 | |
|                 // AND_ANY: Add both scores
 | |
|                 case world_info_logic.AND_ANY:
 | |
|                     return primaryScore + secondaryScore;
 | |
|                 // AND_ALL: Add both scores if all secondary keys are found, otherwise only primary score
 | |
|                 case world_info_logic.AND_ALL:
 | |
|                     return secondaryScore === numberOfSecondaryKeys ? primaryScore + secondaryScore : primaryScore;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return primaryScore;
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Represents a timed effects manager for World Info.
 | |
|  */
 | |
| class WorldInfoTimedEffects {
 | |
|     /**
 | |
|      * Array of chat messages.
 | |
|      * @type {string[]}
 | |
|      */
 | |
|     #chat = [];
 | |
| 
 | |
|     /**
 | |
|      * Array of entries.
 | |
|      * @type {WIScanEntry[]}
 | |
|      */
 | |
|     #entries = [];
 | |
| 
 | |
|     /**
 | |
|      * Is this a dry run?
 | |
|      * @type {boolean}
 | |
|      */
 | |
|     #isDryRun = false;
 | |
| 
 | |
|     /**
 | |
|      * Buffer for active timed effects.
 | |
|      * @type {Record<TimedEffectType, WIScanEntry[]>}
 | |
|      */
 | |
|     #buffer = {
 | |
|         'sticky': [],
 | |
|         'cooldown': [],
 | |
|         'delay': [],
 | |
|     };
 | |
| 
 | |
|     /**
 | |
|      * Callbacks for effect types ending.
 | |
|      * @type {Record<TimedEffectType, (entry: WIScanEntry) => void>}
 | |
|      */
 | |
|     #onEnded = {
 | |
|         /**
 | |
|          * Callback for when a sticky entry ends.
 | |
|          * Sets an entry on cooldown immediately if it has a cooldown.
 | |
|          * @param {WIScanEntry} entry Entry that ended sticky
 | |
|          */
 | |
|         'sticky': (entry) => {
 | |
|             if (!entry.cooldown) {
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             const key = this.#getEntryKey(entry);
 | |
|             const effect = this.#getEntryTimedEffect('cooldown', entry, true);
 | |
|             chat_metadata.timedWorldInfo.cooldown[key] = effect;
 | |
|             console.log(`[WI] Adding cooldown entry ${key} on ended sticky: start=${effect.start}, end=${effect.end}, protected=${effect.protected}`);
 | |
|             // Set the cooldown immediately for this evaluation
 | |
|             this.#buffer.cooldown.push(entry);
 | |
|         },
 | |
| 
 | |
|         /**
 | |
|          * Callback for when a cooldown entry ends.
 | |
|          * No-op, essentially.
 | |
|          * @param {WIScanEntry} entry Entry that ended cooldown
 | |
|          */
 | |
|         'cooldown': (entry) => {
 | |
|             console.debug('[WI] Cooldown ended for entry', entry.uid);
 | |
|         },
 | |
| 
 | |
|         'delay': () => { },
 | |
|     };
 | |
| 
 | |
|     /**
 | |
|      * Initialize the timed effects with the given messages.
 | |
|      * @param {string[]} chat Array of chat messages
 | |
|      * @param {WIScanEntry[]} entries Array of entries
 | |
|      * @param {boolean} isDryRun Whether the operation is a dry run
 | |
|      */
 | |
|     constructor(chat, entries, isDryRun = false) {
 | |
|         this.#chat = chat;
 | |
|         this.#entries = entries;
 | |
|         this.#isDryRun = isDryRun;
 | |
|         this.#ensureChatMetadata();
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Verify correct structure of chat metadata.
 | |
|      */
 | |
|     #ensureChatMetadata() {
 | |
|         if (!chat_metadata.timedWorldInfo) {
 | |
|             chat_metadata.timedWorldInfo = {};
 | |
|         }
 | |
| 
 | |
|         ['sticky', 'cooldown'].forEach(type => {
 | |
|             // Ensure the property exists and is an object
 | |
|             if (!chat_metadata.timedWorldInfo[type] || typeof chat_metadata.timedWorldInfo[type] !== 'object') {
 | |
|                 chat_metadata.timedWorldInfo[type] = {};
 | |
|             }
 | |
| 
 | |
|             // Clean up invalid entries
 | |
|             Object.entries(chat_metadata.timedWorldInfo[type]).forEach(([key, value]) => {
 | |
|                 if (!value || typeof value !== 'object') {
 | |
|                     delete chat_metadata.timedWorldInfo[type][key];
 | |
|                 }
 | |
|             });
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|     * Gets a hash for a WI entry.
 | |
|     * @param {WIScanEntry} entry WI entry
 | |
|     * @returns {number} String hash
 | |
|     */
 | |
|     #getEntryHash(entry) {
 | |
|         return entry.hash;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets a unique-ish key for a WI entry.
 | |
|      * @param {WIScanEntry} entry WI entry
 | |
|      * @returns {string} String key for the entry
 | |
|      */
 | |
|     #getEntryKey(entry) {
 | |
|         return `${entry.world}.${entry.uid}`;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets a timed effect for a WI entry.
 | |
|      * @param {TimedEffectType} type Type of timed effect
 | |
|      * @param {WIScanEntry} entry WI entry
 | |
|      * @param {boolean} isProtected If the effect should be protected
 | |
|      * @returns {WITimedEffect} Timed effect for the entry
 | |
|      */
 | |
|     #getEntryTimedEffect(type, entry, isProtected) {
 | |
|         return {
 | |
|             hash: this.#getEntryHash(entry),
 | |
|             start: this.#chat.length,
 | |
|             end: this.#chat.length + Number(entry[type]),
 | |
|             protected: !!isProtected,
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Processes entries for a given type of timed effect.
 | |
|      * @param {TimedEffectType} type Identifier for the type of timed effect
 | |
|      * @param {WIScanEntry[]} buffer Buffer to store the entries
 | |
|      * @param {(entry: WIScanEntry) => void} onEnded Callback for when a timed effect ends
 | |
|      */
 | |
|     #checkTimedEffectOfType(type, buffer, onEnded) {
 | |
|         /** @type {[string, WITimedEffect][]} */
 | |
|         const effects = Object.entries(chat_metadata.timedWorldInfo[type]);
 | |
|         for (const [key, value] of effects) {
 | |
|             console.log(`[WI] Processing ${type} entry ${key}`, value);
 | |
|             const entry = this.#entries.find(x => String(this.#getEntryHash(x)) === String(value.hash));
 | |
| 
 | |
|             if (this.#chat.length <= Number(value.start) && !value.protected) {
 | |
|                 console.log(`[WI] Removing ${type} entry ${key} from timedWorldInfo: chat not advanced`, value);
 | |
|                 delete chat_metadata.timedWorldInfo[type][key];
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Missing entries (they could be from another character's lorebook)
 | |
|             if (!entry) {
 | |
|                 if (this.#chat.length >= Number(value.end)) {
 | |
|                     console.log(`[WI] Removing ${type} entry from timedWorldInfo: entry not found and interval passed`, entry);
 | |
|                     delete chat_metadata.timedWorldInfo[type][key];
 | |
|                 }
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Ignore invalid entries (not configured for timed effects)
 | |
|             if (!entry[type]) {
 | |
|                 console.log(`[WI] Removing ${type} entry from timedWorldInfo: entry not ${type}`, entry);
 | |
|                 delete chat_metadata.timedWorldInfo[type][key];
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (this.#chat.length >= Number(value.end)) {
 | |
|                 console.log(`[WI] Removing ${type} entry from timedWorldInfo: ${type} interval passed`, entry);
 | |
|                 delete chat_metadata.timedWorldInfo[type][key];
 | |
|                 if (typeof onEnded === 'function') {
 | |
|                     onEnded(entry);
 | |
|                 }
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             buffer.push(entry);
 | |
|             console.log(`[WI] Timed effect "${type}" applied to entry`, entry);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Processes entries for the "delay" timed effect.
 | |
|      * @param {WIScanEntry[]} buffer Buffer to store the entries
 | |
|      */
 | |
|     #checkDelayEffect(buffer) {
 | |
|         for (const entry of this.#entries) {
 | |
|             if (!entry.delay) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (this.#chat.length < entry.delay) {
 | |
|                 buffer.push(entry);
 | |
|                 console.log('[WI] Timed effect "delay" applied to entry', entry);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Checks for timed effects on chat messages.
 | |
|      */
 | |
|     checkTimedEffects() {
 | |
|         if (!this.#isDryRun) {
 | |
|             this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this));
 | |
|             this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this));
 | |
|         }
 | |
|         this.#checkDelayEffect(this.#buffer.delay);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets raw timed effect metadatum for a WI entry.
 | |
|      * @param {TimedEffectType} type Type of timed effect
 | |
|      * @param {WIScanEntry} entry WI entry
 | |
|      * @returns {WITimedEffect} Timed effect for the entry
 | |
|      */
 | |
|     getEffectMetadata(type, entry) {
 | |
|         if (!this.isValidEffectType(type)) {
 | |
|             return null;
 | |
|         }
 | |
| 
 | |
|         const key = this.#getEntryKey(entry);
 | |
|         return chat_metadata.timedWorldInfo[type][key];
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Sets a timed effect for a WI entry.
 | |
|      * @param {TimedEffectType} type Type of timed effect
 | |
|      * @param {WIScanEntry} entry WI entry to check
 | |
|      */
 | |
|     #setTimedEffectOfType(type, entry) {
 | |
|         // Skip if entry does not have the type (sticky or cooldown)
 | |
|         if (!entry[type]) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const key = this.#getEntryKey(entry);
 | |
| 
 | |
|         if (!chat_metadata.timedWorldInfo[type][key]) {
 | |
|             const effect = this.#getEntryTimedEffect(type, entry, false);
 | |
|             chat_metadata.timedWorldInfo[type][key] = effect;
 | |
| 
 | |
|             console.log(`[WI] Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}, protected=${effect.protected}`);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Sets timed effects on chat messages.
 | |
|      * @param {WIScanEntry[]} activatedEntries Entries that were activated
 | |
|      */
 | |
|     setTimedEffects(activatedEntries) {
 | |
|         if (this.#isDryRun) return;
 | |
|         for (const entry of activatedEntries) {
 | |
|             this.#setTimedEffectOfType('sticky', entry);
 | |
|             this.#setTimedEffectOfType('cooldown', entry);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Force set a timed effect for a WI entry.
 | |
|      * @param {TimedEffectType} type Type of timed effect
 | |
|      * @param {WIScanEntry} entry WI entry
 | |
|      * @param {boolean} newState The state of the effect
 | |
|      */
 | |
|     setTimedEffect(type, entry, newState) {
 | |
|         if (!this.isValidEffectType(type)) {
 | |
|             return;
 | |
|         }
 | |
|         if (this.#isDryRun && type !== 'delay') {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const key = this.#getEntryKey(entry);
 | |
|         delete chat_metadata.timedWorldInfo[type][key];
 | |
| 
 | |
|         if (newState) {
 | |
|             const effect = this.#getEntryTimedEffect(type, entry, false);
 | |
|             chat_metadata.timedWorldInfo[type][key] = effect;
 | |
|             console.log(`[WI] Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}, protected=${effect.protected}`);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Check if the string is a valid timed effect type.
 | |
|      * @param {string} type Name of the timed effect
 | |
|      * @returns {boolean} Is recognized type
 | |
|      */
 | |
|     isValidEffectType(type) {
 | |
|         return typeof type === 'string' && ['sticky', 'cooldown', 'delay'].includes(type.trim().toLowerCase());
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Check if the current entry is sticky activated.
 | |
|      * @param {TimedEffectType} type Type of timed effect
 | |
|      * @param {WIScanEntry} entry WI entry to check
 | |
|      * @returns {boolean} True if the entry is active
 | |
|      */
 | |
|     isEffectActive(type, entry) {
 | |
|         if (!this.isValidEffectType(type)) {
 | |
|             return false;
 | |
|         }
 | |
| 
 | |
|         return this.#buffer[type]?.some(x => this.#getEntryHash(x) === this.#getEntryHash(entry)) ?? false;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Clean-up previously set timed effects.
 | |
|      */
 | |
|     cleanUp() {
 | |
|         for (const buffer of Object.values(this.#buffer)) {
 | |
|             buffer.splice(0, buffer.length);
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| export function getWorldInfoSettings() {
 | |
|     return {
 | |
|         world_info,
 | |
|         world_info_depth,
 | |
|         world_info_min_activations,
 | |
|         world_info_min_activations_depth_max,
 | |
|         world_info_budget,
 | |
|         world_info_include_names,
 | |
|         world_info_recursive,
 | |
|         world_info_overflow_alert,
 | |
|         world_info_case_sensitive,
 | |
|         world_info_match_whole_words,
 | |
|         world_info_character_strategy,
 | |
|         world_info_budget_cap,
 | |
|         world_info_use_group_scoring,
 | |
|         world_info_max_recursion_steps,
 | |
|     };
 | |
| }
 | |
| 
 | |
| export const world_info_position = {
 | |
|     before: 0,
 | |
|     after: 1,
 | |
|     ANTop: 2,
 | |
|     ANBottom: 3,
 | |
|     atDepth: 4,
 | |
|     EMTop: 5,
 | |
|     EMBottom: 6,
 | |
| };
 | |
| 
 | |
| export const wi_anchor_position = {
 | |
|     before: 0,
 | |
|     after: 1,
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * The cache of all world info data that was loaded from the backend.
 | |
|  *
 | |
|  * Calling `loadWorldInfo` will fill this cache and utilize this cache, so should be the preferred way to load any world info data.
 | |
|  * Only use the cache directly if you need synchronous access.
 | |
|  *
 | |
|  * This will return a deep clone of the data, so no way to modify the data without actually saving it.
 | |
|  * Should generally be only used for readonly access.
 | |
|  *
 | |
|  * @type {StructuredCloneMap<string,object>}
 | |
|  * */
 | |
| export const worldInfoCache = new StructuredCloneMap({ cloneOnGet: true, cloneOnSet: false });
 | |
| 
 | |
| /**
 | |
|  * Gets the world info based on chat messages.
 | |
|  * @param {string[]} chat The chat messages to scan, in reverse order.
 | |
|  * @param {number} maxContext The maximum context size of the generation.
 | |
|  * @param {boolean} isDryRun If true, the function will not emit any events.
 | |
|  * @typedef {{worldInfoString: string, worldInfoBefore: string, worldInfoAfter: string, worldInfoExamples: any[], worldInfoDepth: any[]}} WIPromptResult
 | |
|  * @returns {Promise<WIPromptResult>} The world info string and depth.
 | |
|  */
 | |
| export async function getWorldInfoPrompt(chat, maxContext, isDryRun) {
 | |
|     let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = '';
 | |
| 
 | |
|     const activatedWorldInfo = await checkWorldInfo(chat, maxContext, isDryRun);
 | |
|     worldInfoBefore = activatedWorldInfo.worldInfoBefore;
 | |
|     worldInfoAfter = activatedWorldInfo.worldInfoAfter;
 | |
|     worldInfoString = worldInfoBefore + worldInfoAfter;
 | |
| 
 | |
|     if (!isDryRun && activatedWorldInfo.allActivatedEntries && activatedWorldInfo.allActivatedEntries.size > 0) {
 | |
|         const arg = Array.from(activatedWorldInfo.allActivatedEntries.values());
 | |
|         await eventSource.emit(event_types.WORLD_INFO_ACTIVATED, arg);
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|         worldInfoString,
 | |
|         worldInfoBefore,
 | |
|         worldInfoAfter,
 | |
|         worldInfoExamples: activatedWorldInfo.EMEntries ?? [],
 | |
|         worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],
 | |
|     };
 | |
| }
 | |
| 
 | |
| export function setWorldInfoSettings(settings, data) {
 | |
|     if (settings.world_info_depth !== undefined)
 | |
|         world_info_depth = Number(settings.world_info_depth);
 | |
|     if (settings.world_info_min_activations !== undefined)
 | |
|         world_info_min_activations = Number(settings.world_info_min_activations);
 | |
|     if (settings.world_info_min_activations_depth_max !== undefined)
 | |
|         world_info_min_activations_depth_max = Number(settings.world_info_min_activations_depth_max);
 | |
|     if (settings.world_info_budget !== undefined)
 | |
|         world_info_budget = Number(settings.world_info_budget);
 | |
|     if (settings.world_info_include_names !== undefined)
 | |
|         world_info_include_names = Boolean(settings.world_info_include_names);
 | |
|     if (settings.world_info_recursive !== undefined)
 | |
|         world_info_recursive = Boolean(settings.world_info_recursive);
 | |
|     if (settings.world_info_overflow_alert !== undefined)
 | |
|         world_info_overflow_alert = Boolean(settings.world_info_overflow_alert);
 | |
|     if (settings.world_info_case_sensitive !== undefined)
 | |
|         world_info_case_sensitive = Boolean(settings.world_info_case_sensitive);
 | |
|     if (settings.world_info_match_whole_words !== undefined)
 | |
|         world_info_match_whole_words = Boolean(settings.world_info_match_whole_words);
 | |
|     if (settings.world_info_character_strategy !== undefined)
 | |
|         world_info_character_strategy = Number(settings.world_info_character_strategy);
 | |
|     if (settings.world_info_budget_cap !== undefined)
 | |
|         world_info_budget_cap = Number(settings.world_info_budget_cap);
 | |
|     if (settings.world_info_use_group_scoring !== undefined)
 | |
|         world_info_use_group_scoring = Boolean(settings.world_info_use_group_scoring);
 | |
|     if (settings.world_info_max_recursion_steps !== undefined)
 | |
|         world_info_max_recursion_steps = Number(settings.world_info_max_recursion_steps);
 | |
| 
 | |
|     // Migrate old settings
 | |
|     if (world_info_budget > 100) {
 | |
|         world_info_budget = 25;
 | |
|     }
 | |
| 
 | |
|     if (world_info_use_group_scoring === undefined) {
 | |
|         world_info_use_group_scoring = false;
 | |
|     }
 | |
| 
 | |
|     // Reset selected world from old string and delete old keys
 | |
|     // TODO: Remove next release
 | |
|     const existingWorldInfo = settings.world_info;
 | |
|     if (typeof existingWorldInfo === 'string') {
 | |
|         delete settings.world_info;
 | |
|         selected_world_info = [existingWorldInfo];
 | |
|     } else if (Array.isArray(existingWorldInfo)) {
 | |
|         delete settings.world_info;
 | |
|         selected_world_info = existingWorldInfo;
 | |
|     }
 | |
| 
 | |
|     world_info = settings.world_info ?? {};
 | |
| 
 | |
|     $('#world_info_depth_counter').val(world_info_depth);
 | |
|     $('#world_info_depth').val(world_info_depth);
 | |
| 
 | |
|     $('#world_info_min_activations_counter').val(world_info_min_activations);
 | |
|     $('#world_info_min_activations').val(world_info_min_activations);
 | |
| 
 | |
|     $('#world_info_min_activations_depth_max_counter').val(world_info_min_activations_depth_max);
 | |
|     $('#world_info_min_activations_depth_max').val(world_info_min_activations_depth_max);
 | |
| 
 | |
|     $('#world_info_budget_counter').val(world_info_budget);
 | |
|     $('#world_info_budget').val(world_info_budget);
 | |
| 
 | |
|     $('#world_info_include_names').prop('checked', world_info_include_names);
 | |
|     $('#world_info_recursive').prop('checked', world_info_recursive);
 | |
|     $('#world_info_overflow_alert').prop('checked', world_info_overflow_alert);
 | |
|     $('#world_info_case_sensitive').prop('checked', world_info_case_sensitive);
 | |
|     $('#world_info_match_whole_words').prop('checked', world_info_match_whole_words);
 | |
|     $('#world_info_use_group_scoring').prop('checked', world_info_use_group_scoring);
 | |
| 
 | |
|     $(`#world_info_character_strategy option[value='${world_info_character_strategy}']`).prop('selected', true);
 | |
|     $('#world_info_character_strategy').val(world_info_character_strategy);
 | |
| 
 | |
|     $('#world_info_budget_cap').val(world_info_budget_cap);
 | |
|     $('#world_info_budget_cap_counter').val(world_info_budget_cap);
 | |
| 
 | |
|     $('#world_info_max_recursion_steps').val(world_info_max_recursion_steps);
 | |
|     $('#world_info_max_recursion_steps_counter').val(world_info_max_recursion_steps);
 | |
| 
 | |
|     world_names = data.world_names?.length ? data.world_names : [];
 | |
| 
 | |
|     // Add to existing selected WI if it exists
 | |
|     selected_world_info = selected_world_info.concat(settings.world_info?.globalSelect?.filter((e) => world_names.includes(e)) ?? []);
 | |
| 
 | |
|     if (world_names.length > 0) {
 | |
|         $('#world_info').empty();
 | |
|     }
 | |
| 
 | |
|     world_names.forEach((item, i) => {
 | |
|         $('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
 | |
|         $('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
 | |
|     });
 | |
| 
 | |
|     $('#world_info_sort_order').val(accountStorage.getItem(SORT_ORDER_KEY) || '0');
 | |
|     $('#world_info').trigger('change');
 | |
|     $('#world_editor_select').trigger('change');
 | |
| 
 | |
|     eventSource.on(event_types.CHAT_CHANGED, async () => {
 | |
|         const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]);
 | |
|         $('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo);
 | |
|         // Pre-cache the world info data for the chat for quicker first prompt generation
 | |
|         await getSortedEntries();
 | |
|     });
 | |
| 
 | |
|     eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => {
 | |
|         for (const entry of entries) {
 | |
|             if (!Object.hasOwn(entry, 'world') || !Object.hasOwn(entry, 'uid')) {
 | |
|                 console.error('[WI] WORLDINFO_FORCE_ACTIVATE requires all entries to have both world and uid fields, entry IGNORED', entry);
 | |
|             } else {
 | |
|                 WorldInfoBuffer.externalActivations.set(`${entry.world}.${entry.uid}`, entry);
 | |
|                 console.log('[WI] WORLDINFO_FORCE_ACTIVATE added entry', entry);
 | |
|             }
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     // Add slash commands
 | |
|     registerWorldInfoSlashCommands();
 | |
| }
 | |
| 
 | |
| function registerWorldInfoSlashCommands() {
 | |
|     /**
 | |
|      * Reloads the editor with the specified world info file
 | |
|      * @param {string} file - The file to load in the editor
 | |
|      * @param {boolean} [loadIfNotSelected=false] - Indicates whether to load the file even if it's not currently selected
 | |
|      */
 | |
|     function reloadEditor(file, loadIfNotSelected = false) {
 | |
|         const currentIndex = Number($('#world_editor_select').val());
 | |
|         const selectedIndex = world_names.indexOf(file);
 | |
|         if (selectedIndex !== -1 && (loadIfNotSelected || currentIndex === selectedIndex)) {
 | |
|             $('#world_editor_select').val(selectedIndex).trigger('change');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets a *rough* approximation of the current chat context.
 | |
|      * Normally, it is provided externally by the prompt builder.
 | |
|      * Don't use for anything critical!
 | |
|      * @returns {string[]}
 | |
|      */
 | |
|     function getScanningChat() {
 | |
|         return getContext().chat.filter(x => !x.is_system).map(x => x.mes);
 | |
|     }
 | |
| 
 | |
|     async function getEntriesFromFile(file) {
 | |
|         if (!file || !world_names.includes(file)) {
 | |
|             toastr.warning(t`Valid World Info file name is required`);
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const data = await loadWorldInfo(file);
 | |
| 
 | |
|         if (!data || !('entries' in data)) {
 | |
|             toastr.warning(t`World Info file has an invalid format`);
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const entries = Object.values(data.entries);
 | |
| 
 | |
|         if (!entries || entries.length === 0) {
 | |
|             toastr.warning(t`World Info file has no entries`);
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         return entries;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets the name of the persona-bound lorebook.
 | |
|      * @returns {string} The name of the persona-bound lorebook
 | |
|      */
 | |
|     function getPersonaBookCallback() {
 | |
|         return power_user.persona_description_lorebook || '';
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets the name of the character-bound lorebook.
 | |
|      * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
 | |
|      * @param {import('./slash-commands/SlashCommand.js').UnnamedArguments} name Character name
 | |
|      * @returns {string} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string
 | |
|      */
 | |
|     function getCharBookCallback({ type }, name) {
 | |
|         const context = getContext();
 | |
|         if (context.groupId && !name) throw new Error('This command is not available in groups without providing a character name');
 | |
|         type = String(type ?? '').trim().toLowerCase() || 'primary';
 | |
|         name = String(name ?? '') || context.characters[context.characterId]?.avatar || null;
 | |
|         const character = findChar({ name });
 | |
|         if (!character) {
 | |
|             toastr.error(t`Character not found.`);
 | |
|             return '';
 | |
|         }
 | |
|         const books = [];
 | |
|         if (type === 'all' || type === 'primary') {
 | |
|             books.push(character.data?.extensions?.world);
 | |
|         }
 | |
|         if (type === 'all' || type === 'additional') {
 | |
|             const fileName = getCharaFilename(context.characters.indexOf(character));
 | |
|             const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
 | |
|             if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) {
 | |
|                 books.push(...extraCharLore.extraBooks);
 | |
|             }
 | |
|         }
 | |
|         return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean));
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gets the name of the chat-bound lorebook. Creates a new one if it doesn't exist.
 | |
|      * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
 | |
|      * @returns {Promise<string>} The name of the chat-bound lorebook
 | |
|      */
 | |
|     async function getChatBookCallback(args) {
 | |
|         const chatId = getCurrentChatId();
 | |
| 
 | |
|         if (!chatId) {
 | |
|             toastr.warning(t`Open a chat to get a name of the chat-bound lorebook`);
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY])) {
 | |
|             return chat_metadata[METADATA_KEY];
 | |
|         }
 | |
| 
 | |
|         const name = (() => {
 | |
|             // Use the provided name if it's not in use
 | |
|             if (typeof args.name === 'string') {
 | |
|                 const name = String(args.name);
 | |
|                 if (world_names.includes(name)) {
 | |
|                     throw new Error('This World Info file name is already in use');
 | |
|                 }
 | |
|                 return name;
 | |
|             }
 | |
| 
 | |
|             // Replace non-alphanumeric characters with underscores, cut to 64 characters
 | |
|             return `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64);
 | |
|         })();
 | |
|         await createNewWorldInfo(name);
 | |
| 
 | |
|         chat_metadata[METADATA_KEY] = name;
 | |
|         await saveMetadata();
 | |
|         $('.chat_lorebook_button').addClass('world_set');
 | |
|         return name;
 | |
|     }
 | |
| 
 | |
|     async function findBookEntryCallback(args, value) {
 | |
|         const file = args.file;
 | |
|         const field = args.field || 'key';
 | |
| 
 | |
|         const entries = await getEntriesFromFile(file);
 | |
| 
 | |
|         if (!entries) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (typeof newWorldInfoEntryTemplate[field] === 'boolean') {
 | |
|             const isTrue = isTrueBoolean(value);
 | |
|             const isFalse = isFalseBoolean(value);
 | |
| 
 | |
|             if (isTrue) {
 | |
|                 value = String(true);
 | |
|             }
 | |
| 
 | |
|             if (isFalse) {
 | |
|                 value = String(false);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         const fuse = new Fuse(entries, {
 | |
|             keys: [{ name: field, weight: 1 }],
 | |
|             includeScore: true,
 | |
|             threshold: 0.3,
 | |
|         });
 | |
| 
 | |
|         const results = fuse.search(value);
 | |
| 
 | |
|         if (!results || results.length === 0) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const result = results[0]?.item?.uid;
 | |
| 
 | |
|         if (result === undefined) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         return result;
 | |
|     }
 | |
| 
 | |
|     async function getEntryFieldCallback(args, uid) {
 | |
|         const file = args.file;
 | |
|         const field = args.field || 'content';
 | |
| 
 | |
|         const entries = await getEntriesFromFile(file);
 | |
| 
 | |
|         if (!entries) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const entry = entries.find(x => String(x.uid) === String(uid));
 | |
| 
 | |
|         if (!entry) {
 | |
|             toastr.warning('Valid UID is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (newWorldInfoEntryTemplate[field] === undefined) {
 | |
|             toastr.warning('Valid field name is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const fieldValue = entry[field];
 | |
| 
 | |
|         if (fieldValue === undefined) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (Array.isArray(fieldValue)) {
 | |
|             return JSON.stringify(fieldValue.map(x => substituteParams(x)));
 | |
|         }
 | |
| 
 | |
|         return substituteParams(String(fieldValue));
 | |
|     }
 | |
| 
 | |
|     async function createEntryCallback(args, content) {
 | |
|         const file = args.file;
 | |
|         const key = args.key;
 | |
| 
 | |
|         const data = await loadWorldInfo(file);
 | |
| 
 | |
|         if (!data || !('entries' in data)) {
 | |
|             toastr.warning('Valid World Info file name is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const entry = createWorldInfoEntry(file, data);
 | |
| 
 | |
|         if (key) {
 | |
|             entry.key.push(key);
 | |
|             entry.addMemo = true;
 | |
|             entry.comment = key;
 | |
|         }
 | |
| 
 | |
|         if (content) {
 | |
|             entry.content = content;
 | |
|         }
 | |
| 
 | |
|         await saveWorldInfo(file, data);
 | |
|         reloadEditor(file);
 | |
| 
 | |
|         return String(entry.uid);
 | |
|     }
 | |
| 
 | |
|     async function setEntryFieldCallback(args, value) {
 | |
|         const file = args.file;
 | |
|         const uid = args.uid;
 | |
|         const field = args.field || 'content';
 | |
| 
 | |
|         if (value === undefined) {
 | |
|             toastr.warning('Value is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         value = value.replace(/\\([{}|])/g, '$1');
 | |
| 
 | |
|         const data = await loadWorldInfo(file);
 | |
| 
 | |
|         if (!data || !('entries' in data)) {
 | |
|             toastr.warning('Valid World Info file name is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const entry = data.entries[uid];
 | |
| 
 | |
|         if (!entry) {
 | |
|             toastr.warning('Valid UID is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (newWorldInfoEntryTemplate[field] === undefined) {
 | |
|             toastr.warning('Valid field name is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (Array.isArray(entry[field])) {
 | |
|             entry[field] = parseStringArray(value);
 | |
|         } else if (typeof entry[field] === 'boolean') {
 | |
|             entry[field] = isTrueBoolean(value);
 | |
|         } else if (typeof entry[field] === 'number') {
 | |
|             entry[field] = Number(value);
 | |
|         } else {
 | |
|             entry[field] = value;
 | |
|         }
 | |
| 
 | |
|         if (originalWIDataKeyMap[field]) {
 | |
|             setWIOriginalDataValue(data, uid, originalWIDataKeyMap[field], entry[field]);
 | |
|         }
 | |
| 
 | |
|         await saveWorldInfo(file, data);
 | |
|         reloadEditor(file);
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     async function getTimedEffectCallback(args, value) {
 | |
|         if (!getCurrentChatId()) {
 | |
|             throw new Error('This command can only be used in chat');
 | |
|         }
 | |
| 
 | |
|         const file = args.file;
 | |
|         const uid = value;
 | |
|         const effect = args.effect;
 | |
| 
 | |
|         const entries = await getEntriesFromFile(file);
 | |
| 
 | |
|         if (!entries) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         /** @type {WIScanEntry} */
 | |
|         const entry = structuredClone(entries.find(x => String(x.uid) === String(uid)));
 | |
| 
 | |
|         if (!entry) {
 | |
|             toastr.warning('Valid UID is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         entry.world = file; // Required by the timed effects manager
 | |
|         const chat = getScanningChat();
 | |
|         const timedEffects = new WorldInfoTimedEffects(chat, [entry]);
 | |
| 
 | |
|         if (!timedEffects.isValidEffectType(effect)) {
 | |
|             toastr.warning('Valid effect type is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const data = timedEffects.getEffectMetadata(effect, entry);
 | |
| 
 | |
|         if (String(args.format).trim().toLowerCase() === ARGUMENT_TYPE.NUMBER) {
 | |
|             return String(data ? (data.end - chat.length) : 0);
 | |
|         }
 | |
| 
 | |
|         return String(!!data);
 | |
|     }
 | |
| 
 | |
|     async function setTimedEffectCallback(args, value) {
 | |
|         if (!getCurrentChatId()) {
 | |
|             throw new Error('This command can only be used in chat');
 | |
|         }
 | |
| 
 | |
|         const file = args.file;
 | |
|         const uid = args.uid;
 | |
|         const effect = args.effect;
 | |
| 
 | |
|         if (value === undefined) {
 | |
|             toastr.warning('New state is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const entries = await getEntriesFromFile(file);
 | |
| 
 | |
|         if (!entries) {
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         /** @type {WIScanEntry} */
 | |
|         const entry = structuredClone(entries.find(x => String(x.uid) === String(uid)));
 | |
| 
 | |
|         if (!entry) {
 | |
|             toastr.warning('Valid UID is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         entry.world = file; // Required by the timed effects manager
 | |
|         const chat = getScanningChat();
 | |
|         const timedEffects = new WorldInfoTimedEffects(chat, [entry]);
 | |
| 
 | |
|         if (!timedEffects.isValidEffectType(effect)) {
 | |
|             toastr.warning('Valid effect type is required');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         if (!entry[effect]) {
 | |
|             toastr.warning('This entry does not have the selected effect. Configure it in the editor first.');
 | |
|             return '';
 | |
|         }
 | |
| 
 | |
|         const getNewEffectState = () => {
 | |
|             const currentState = !!timedEffects.getEffectMetadata(effect, entry);
 | |
| 
 | |
|             if (['toggle', 't', ''].includes(value.trim().toLowerCase())) {
 | |
|                 return !currentState;
 | |
|             }
 | |
| 
 | |
|             if (isTrueBoolean(value)) {
 | |
|                 return true;
 | |
|             }
 | |
| 
 | |
|             if (isFalseBoolean(value)) {
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             return currentState;
 | |
|         };
 | |
| 
 | |
|         const newEffectState = getNewEffectState();
 | |
|         timedEffects.setTimedEffect(effect, entry, newEffectState);
 | |
| 
 | |
|         await saveMetadata();
 | |
|         toastr.success(`Timed effect "${effect}" for entry ${entry.uid} is now ${newEffectState ? 'active' : 'inactive'}`);
 | |
| 
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     /** A collection of local enum providers for this context of world info */
 | |
|     const localEnumProviders = {
 | |
|         /** All possible fields that can be set in a WI entry */
 | |
|         wiEntryFields: () => Object.entries(newWorldInfoEntryDefinition).map(([key, value]) =>
 | |
|             new SlashCommandEnumValue(key, `[${value.type}] default: ${(typeof value.default === 'string' ? `'${value.default}'` : value.default)}`,
 | |
|                 enumTypes.enum, enumIcons.getDataTypeIcon(value.type))),
 | |
| 
 | |
|         /** All existing UIDs based on the file argument as world name */
 | |
|         wiUids: (/** @type {import('./slash-commands/SlashCommandExecutor.js').SlashCommandExecutor} */ executor) => {
 | |
|             const file = executor.namedArgumentList.find(it => it.name == 'file')?.value;
 | |
|             if (file instanceof SlashCommandClosure) throw new Error('Argument \'file\' does not support closures');
 | |
|             // Try find world from cache
 | |
|             if (!worldInfoCache.has(file)) return [];
 | |
|             const world = worldInfoCache.get(file);
 | |
|             if (!world) return [];
 | |
|             return Object.entries(world.entries).map(([uid, data]) =>
 | |
|                 new SlashCommandEnumValue(uid, `${data.comment ? `${data.comment}: ` : ''}${data.key.join(', ')}${data.keysecondary?.length ? ` [${Object.entries(world_info_logic).find(([_, value]) => value == data.selectiveLogic)[0]}] ${data.keysecondary.join(', ')}` : ''} [${getWiPositionString(data)}]`,
 | |
|                     enumTypes.enum, enumIcons.getWiStatusIcon(data)));
 | |
|         },
 | |
| 
 | |
|         timedEffects: () => [
 | |
|             new SlashCommandEnumValue('sticky', 'Stays active for N messages', enumTypes.enum, '📌'),
 | |
|             new SlashCommandEnumValue('cooldown', 'Cooldown for N messages', enumTypes.enum, '⌛'),
 | |
|         ],
 | |
|     };
 | |
| 
 | |
|     function getWiPositionString(entry) {
 | |
|         switch (entry.position) {
 | |
|             case world_info_position.before: return '↑Char';
 | |
|             case world_info_position.after: return '↓Char';
 | |
|             case world_info_position.EMTop: return '↑EM';
 | |
|             case world_info_position.EMBottom: return '↓EM';
 | |
|             case world_info_position.ANTop: return '↑AT';
 | |
|             case world_info_position.ANBottom: return '↓AT';
 | |
|             case world_info_position.atDepth: return `@D${enumIcons.getRoleIcon(entry.role)}`;
 | |
|             default: return '<Unknown>';
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'world',
 | |
|         callback: onWorldInfoChange,
 | |
|         namedArgumentList: [
 | |
|             new SlashCommandNamedArgument(
 | |
|                 'state', 'set world state', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOffToggle')(),
 | |
|             ),
 | |
|             new SlashCommandNamedArgument(
 | |
|                 'silent', 'suppress toast messages', [ARGUMENT_TYPE.BOOLEAN], false,
 | |
|             ),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'world name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages.
 | |
|             </div>
 | |
|         `,
 | |
|         aliases: [],
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'getchatbook',
 | |
|         callback: getChatBookCallback,
 | |
|         returns: 'lorebook name',
 | |
|         helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.',
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'name',
 | |
|                 description: 'lorebook name if creating a new one, will be auto-generated otherwise',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: false,
 | |
|                 acceptsMultiple: false,
 | |
|             }),
 | |
|         ],
 | |
|         aliases: ['getchatlore', 'getchatwi'],
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'getpersonabook',
 | |
|         callback: getPersonaBookCallback,
 | |
|         returns: 'lorebook name',
 | |
|         helpString: 'Get a name of the current persona-bound lorebook and pass it down the pipe. Returns empty string if persona lorebook is not set.',
 | |
|         aliases: ['getpersonalore', 'getpersonawi'],
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'getcharbook',
 | |
|         callback: getCharBookCallback,
 | |
|         returns: 'lorebook name or a list of lorebook names',
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'type',
 | |
|                 description: 'type of the lorebook to get, returns a list for "all" and "additional"',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 enumList: ['primary', 'additional', 'all'],
 | |
|                 defaultValue: 'primary',
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'Character name - or unique character identifier (avatar key). If not provided, the current character is used.',
 | |
|                 typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: false,
 | |
|                 enumProvider: commonEnumProviders.characters('character'),
 | |
|             }),
 | |
|         ],
 | |
|         helpString: 'Get a name of the character-bound lorebook and pass it down the pipe. Returns empty string if character lorebook is not set. Does not work in group chats without providing a character avatar name.',
 | |
|         aliases: ['getcharlore', 'getcharwi'],
 | |
|     }));
 | |
| 
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'findentry',
 | |
|         aliases: ['findlore', 'findwi'],
 | |
|         returns: 'UID',
 | |
|         callback: findBookEntryCallback,
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'file',
 | |
|                 description: 'book name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'field',
 | |
|                 description: 'field value for fuzzy match (default: key)',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 defaultValue: 'key',
 | |
|                 enumList: localEnumProviders.wiEntryFields(),
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             new SlashCommandArgument(
 | |
|                 'texts', ARGUMENT_TYPE.STRING, true, true,
 | |
|             ),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Find a UID of the record from the specified book using the fuzzy match of a field value (default: key) and pass it down the pipe.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/findentry file=chatLore field=key Shadowfang</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'getentryfield',
 | |
|         aliases: ['getlorefield', 'getwifield'],
 | |
|         callback: getEntryFieldCallback,
 | |
|         returns: 'field value',
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'file',
 | |
|                 description: 'book name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'field',
 | |
|                 description: 'field to retrieve (default: content)',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 defaultValue: 'content',
 | |
|                 enumList: localEnumProviders.wiEntryFields(),
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'record UID',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: localEnumProviders.wiUids,
 | |
|             }),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/getentryfield file=chatLore field=content 123</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'createentry',
 | |
|         callback: createEntryCallback,
 | |
|         aliases: ['createlore', 'createwi'],
 | |
|         returns: 'UID of the new record',
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'file',
 | |
|                 description: 'book name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|             new SlashCommandNamedArgument(
 | |
|                 'key', 'record key', [ARGUMENT_TYPE.STRING], false,
 | |
|             ),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             new SlashCommandArgument(
 | |
|                 'content', [ARGUMENT_TYPE.STRING], false,
 | |
|             ),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/createentry file=chatLore key=Shadowfang The sword of the king</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'setentryfield',
 | |
|         callback: setEntryFieldCallback,
 | |
|         aliases: ['setlorefield', 'setwifield'],
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'file',
 | |
|                 description: 'book name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'uid',
 | |
|                 description: 'record UID',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: localEnumProviders.wiUids,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'field',
 | |
|                 description: 'field name (default: content)',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 defaultValue: 'content',
 | |
|                 enumList: localEnumProviders.wiEntryFields(),
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             new SlashCommandArgument(
 | |
|                 'value', [ARGUMENT_TYPE.STRING], true,
 | |
|             ),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Set a field value (default: content) of the record with the UID from the specified book. To set multiple values for key fields, use comma-delimited list as a value.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'wi-set-timed-effect',
 | |
|         callback: setTimedEffectCallback,
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'file',
 | |
|                 description: 'book name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'uid',
 | |
|                 description: 'record UID',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: localEnumProviders.wiUids,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'effect',
 | |
|                 description: 'effect name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: localEnumProviders.timedEffects,
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'new state of the effect',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 acceptsMultiple: false,
 | |
|                 enumList: commonEnumProviders.boolean('onOffToggle')(),
 | |
|             }),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Set a timed effect for the record with the UID from the specified book. The duration must be set in the entry itself.
 | |
|                 Will only be applied for the current chat. Enabling an effect that was already active refreshes the duration.
 | |
|                 If the last chat message is swiped or deleted, the effect will be removed.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/wi-set-timed-effect file=chatLore uid=123 effect=sticky on</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'wi-get-timed-effect',
 | |
|         callback: getTimedEffectCallback,
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Get the current state of the timed effect for the record with the UID from the specified book.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <code>/wi-get-timed-effect file=chatLore format=bool effect=sticky 123</code> - returns true or false if the effect is active or not
 | |
|                     </li>
 | |
|                     <li>
 | |
|                         <code>/wi-get-timed-effect file=chatLore format=number effect=sticky 123</code> - returns the remaining duration of the effect, or 0 if inactive
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|         returns: 'state of the effect',
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'file',
 | |
|                 description: 'book name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: commonEnumProviders.worlds,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'effect',
 | |
|                 description: 'effect name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: localEnumProviders.timedEffects,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'format',
 | |
|                 description: 'output format',
 | |
|                 isRequired: false,
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 defaultValue: ARGUMENT_TYPE.BOOLEAN,
 | |
|                 enumList: [ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.NUMBER],
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'record UID',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: localEnumProviders.wiUids,
 | |
|             }),
 | |
|         ],
 | |
|     }));
 | |
| }
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Loads the given world into the World Editor.
 | |
|  *
 | |
|  * @param {string} name - The name of the world
 | |
|  * @return {Promise<void>} A promise that resolves when the world editor is loaded
 | |
|  */
 | |
| export async function showWorldEditor(name) {
 | |
|     if (!name) {
 | |
|         hideWorldEditor();
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const wiData = await loadWorldInfo(name);
 | |
|     displayWorldEntries(name, wiData);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Loads world info from the backend.
 | |
|  *
 | |
|  * This function will return from `worldInfoCache` if it has already been loaded before.
 | |
|  *
 | |
|  * @param {string} name - The name of the world to load
 | |
|  * @return {Promise<Object|null>} A promise that resolves to the loaded world information, or null if the request fails.
 | |
|  */
 | |
| export async function loadWorldInfo(name) {
 | |
|     if (!name) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     if (worldInfoCache.has(name)) {
 | |
|         return worldInfoCache.get(name);
 | |
|     }
 | |
| 
 | |
|     const response = await fetch('/api/worldinfo/get', {
 | |
|         method: 'POST',
 | |
|         headers: getRequestHeaders(),
 | |
|         body: JSON.stringify({ name: name }),
 | |
|         cache: 'no-cache',
 | |
|     });
 | |
| 
 | |
|     if (response.ok) {
 | |
|         const data = await response.json();
 | |
|         worldInfoCache.set(name, data);
 | |
|         return data;
 | |
|     }
 | |
| 
 | |
|     return null;
 | |
| }
 | |
| 
 | |
| export async function updateWorldInfoList() {
 | |
|     const result = await fetch('/api/settings/get', {
 | |
|         method: 'POST',
 | |
|         headers: getRequestHeaders(),
 | |
|         body: JSON.stringify({}),
 | |
|     });
 | |
| 
 | |
|     if (result.ok) {
 | |
|         var data = await result.json();
 | |
|         world_names = data.world_names?.length ? data.world_names : [];
 | |
|         $('#world_info').find('option[value!=""]').remove();
 | |
|         $('#world_editor_select').find('option[value!=""]').remove();
 | |
| 
 | |
|         world_names.forEach((item, i) => {
 | |
|             $('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
 | |
|             $('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
 | |
|         });
 | |
|     }
 | |
| }
 | |
| 
 | |
| function hideWorldEditor() {
 | |
|     displayWorldEntries(null, null);
 | |
| }
 | |
| 
 | |
| function getWIElement(name) {
 | |
|     const wiElement = $('#world_info').children().filter(function () {
 | |
|         return $(this).text().toLowerCase() === name.toLowerCase();
 | |
|     });
 | |
| 
 | |
|     return wiElement;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sorts the given data based on the selected sort option
 | |
|  *
 | |
|  * @param {any[]} data WI entries
 | |
|  * @param {object} [options={}] - Optional arguments
 | |
|  * @param {{sortField?: string, sortOrder?: string, sortRule?: string}} [options.customSort={}] - Custom sort options, instead of the chosen UI sort
 | |
|  * @returns {any[]} Sorted data
 | |
|  */
 | |
| export function sortWorldInfoEntries(data, { customSort = null } = {}) {
 | |
|     const option = $('#world_info_sort_order').find(':selected');
 | |
|     const sortField = customSort?.sortField ?? option.data('field');
 | |
|     const sortOrder = customSort?.sortOrder ?? option.data('order');
 | |
|     const sortRule = customSort?.sortRule ?? option.data('rule');
 | |
|     const orderSign = sortOrder === 'asc' ? 1 : -1;
 | |
| 
 | |
|     if (!data.length) return data;
 | |
| 
 | |
|     /** @type {(a: any, b: any) => number} */
 | |
|     let primarySort;
 | |
| 
 | |
|     // Secondary and tertiary it will always be sorted by Order descending, and last UID ascending
 | |
|     // This is the most sensible approach for sorts where the primary sort has a lot of equal values
 | |
|     const secondarySort = (a, b) => b.order - a.order;
 | |
|     const tertiarySort = (a, b) => a.uid - b.uid;
 | |
| 
 | |
|     // If we have a search term for WI, we are sorting by weighting scores
 | |
|     if (sortRule === 'search') {
 | |
|         primarySort = (a, b) => {
 | |
|             const aScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, a.uid);
 | |
|             const bScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, b.uid);
 | |
|             return aScore - bScore;
 | |
|         };
 | |
|     }
 | |
|     else if (sortRule === 'custom') {
 | |
|         // First by display index
 | |
|         primarySort = (a, b) => {
 | |
|             const aValue = a.displayIndex;
 | |
|             const bValue = b.displayIndex;
 | |
|             return aValue - bValue;
 | |
|         };
 | |
|     } else if (sortRule === 'priority') {
 | |
|         // First constant, then normal, then disabled.
 | |
|         primarySort = (a, b) => {
 | |
|             const aValue = a.disable ? 2 : a.constant ? 0 : 1;
 | |
|             const bValue = b.disable ? 2 : b.constant ? 0 : 1;
 | |
|             return aValue - bValue;
 | |
|         };
 | |
|     } else {
 | |
|         primarySort = (a, b) => {
 | |
|             const aValue = a[sortField];
 | |
|             const bValue = b[sortField];
 | |
| 
 | |
|             // Sort strings
 | |
|             if (typeof aValue === 'string' && typeof bValue === 'string') {
 | |
|                 if (sortRule === 'length') {
 | |
|                     // Sort by string length
 | |
|                     return orderSign * (aValue.length - bValue.length);
 | |
|                 } else {
 | |
|                     // Sort by A-Z ordinal
 | |
|                     return orderSign * aValue.localeCompare(bValue);
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             // Sort numbers
 | |
|             return orderSign * (Number(aValue) - Number(bValue));
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     data.sort((a, b) => {
 | |
|         return primarySort(a, b) || secondarySort(a, b) || tertiarySort(a, b);
 | |
|     });
 | |
| 
 | |
|     return data;
 | |
| }
 | |
| 
 | |
| function nullWorldInfo() {
 | |
|     toastr.info('Create or import a new World Info file first.', 'World Info is not set', { timeOut: 10000, preventDuplicates: true });
 | |
| }
 | |
| 
 | |
| /** @type {Select2Option[]} Cache all keys as selectable dropdown option */
 | |
| const worldEntryKeyOptionsCache = [];
 | |
| 
 | |
| /**
 | |
|  * Update the cache and all select options for the keys with new values to display
 | |
|  * @param {string[]|Select2Option[]} keyOptions - An array of options to update
 | |
|  * @param {object} options - Optional arguments
 | |
|  * @param {boolean?} [options.remove=false] - Whether the option was removed, so the count should be reduced - otherwise it'll be increased
 | |
|  * @param {boolean?} [options.reset=false] - Whether the cache should be reset. Reset will also not trigger update of the controls, as we expect them to be redrawn anyway
 | |
|  */
 | |
| function updateWorldEntryKeyOptionsCache(keyOptions, { remove = false, reset = false } = {}) {
 | |
|     if (!keyOptions.length) return;
 | |
|     /** @type {Select2Option[]} */
 | |
|     const options = keyOptions.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x);
 | |
|     if (reset) worldEntryKeyOptionsCache.length = 0;
 | |
|     options.forEach(option => {
 | |
|         // Update the cache list
 | |
|         let cachedEntry = worldEntryKeyOptionsCache.find(x => x.id == option.id);
 | |
|         if (cachedEntry) {
 | |
|             cachedEntry.count += !remove ? 1 : -1;
 | |
|         } else if (!remove) {
 | |
|             worldEntryKeyOptionsCache.push(option);
 | |
|             cachedEntry = option;
 | |
|             cachedEntry.count = 1;
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     // Sort by count DESC and then alphabetically
 | |
|     worldEntryKeyOptionsCache.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
 | |
| }
 | |
| 
 | |
| function displayWorldEntries(name, data, navigation = navigation_option.none, flashOnNav = true) {
 | |
|     updateEditor = (navigation, flashOnNav = true) => displayWorldEntries(name, data, navigation, flashOnNav);
 | |
| 
 | |
|     const worldEntriesList = $('#world_popup_entries_list');
 | |
| 
 | |
|     // We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements
 | |
|     // This prevents jQuery from actually going through all registered events on the controls for each entry when removing it
 | |
|     worldEntriesList.find('*').off();
 | |
|     worldEntriesList.empty().show();
 | |
| 
 | |
|     if (!data || !('entries' in data)) {
 | |
|         $('#world_popup_new').off('click').on('click', nullWorldInfo);
 | |
|         $('#world_popup_name_button').off('click').on('click', nullWorldInfo);
 | |
|         $('#world_popup_export').off('click').on('click', nullWorldInfo);
 | |
|         $('#world_popup_delete').off('click').on('click', nullWorldInfo);
 | |
|         $('#world_duplicate').off('click').on('click', nullWorldInfo);
 | |
|         worldEntriesList.hide();
 | |
|         $('#world_info_pagination').html('');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Regardless of whether success is displayed or not. Make sure the delete button is available.
 | |
|     // Do not put this code behind.
 | |
|     $('#world_popup_delete').off('click').on('click', async () => {
 | |
|         const confirmation = await Popup.show.confirm(`Delete the World/Lorebook: "${name}"?`, 'This action is irreversible!');
 | |
|         if (!confirmation) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if (world_info.charLore) {
 | |
|             world_info.charLore.forEach((charLore, index) => {
 | |
|                 if (charLore.extraBooks?.includes(name)) {
 | |
|                     const tempCharLore = charLore.extraBooks.filter((e) => e !== name);
 | |
|                     if (tempCharLore.length === 0) {
 | |
|                         world_info.charLore.splice(index, 1);
 | |
|                     } else {
 | |
|                         charLore.extraBooks = tempCharLore;
 | |
|                     }
 | |
|                 }
 | |
|             });
 | |
| 
 | |
|             saveSettingsDebounced();
 | |
|         }
 | |
| 
 | |
|         // Selected world_info automatically refreshes
 | |
|         await deleteWorldInfo(name);
 | |
|     });
 | |
| 
 | |
|     // Before printing the WI, we check if we should enable/disable search sorting
 | |
|     verifyWorldInfoSearchSortRule();
 | |
| 
 | |
|     function getDataArray(callback) {
 | |
|         // Convert the data.entries object into an array
 | |
|         let entriesArray = Object.keys(data.entries).map(uid => {
 | |
|             const entry = data.entries[uid];
 | |
|             entry.displayIndex = entry.displayIndex ?? entry.uid;
 | |
|             return entry;
 | |
|         });
 | |
| 
 | |
|         // Apply the filter and do the chosen sorting
 | |
|         entriesArray = worldInfoFilter.applyFilters(entriesArray);
 | |
|         entriesArray = sortWorldInfoEntries(entriesArray);
 | |
| 
 | |
|         // Cache keys
 | |
|         const keys = entriesArray.flatMap(entry => [...entry.key, ...entry.keysecondary]);
 | |
|         updateWorldEntryKeyOptionsCache(keys, { reset: true });
 | |
| 
 | |
|         // Run the callback for printing this
 | |
|         typeof callback === 'function' && callback(entriesArray);
 | |
|         return entriesArray;
 | |
|     }
 | |
| 
 | |
|     const storageKey = 'WI_PerPage';
 | |
|     const perPageDefault = 25;
 | |
|     let startPage = 1;
 | |
| 
 | |
|     if (navigation === navigation_option.previous) {
 | |
|         startPage = $('#world_info_pagination').pagination('getCurrentPageNum');
 | |
|     }
 | |
| 
 | |
|     if (typeof navigation === 'number' && Number(navigation) >= 0) {
 | |
|         const data = getDataArray();
 | |
|         const uidIndex = data.findIndex(x => x.uid === navigation);
 | |
|         const perPage = Number(accountStorage.getItem(storageKey)) || perPageDefault;
 | |
|         startPage = Math.floor(uidIndex / perPage) + 1;
 | |
|     }
 | |
| 
 | |
|     $('#world_info_pagination').pagination({
 | |
|         dataSource: getDataArray,
 | |
|         pageSize: Number(accountStorage.getItem(storageKey)) || perPageDefault,
 | |
|         sizeChangerOptions: [10, 25, 50, 100, 500, 1000],
 | |
|         showSizeChanger: true,
 | |
|         pageRange: 1,
 | |
|         pageNumber: startPage,
 | |
|         position: 'top',
 | |
|         showPageNumbers: false,
 | |
|         prevText: '<',
 | |
|         nextText: '>',
 | |
|         formatNavigator: PAGINATION_TEMPLATE,
 | |
|         showNavigator: true,
 | |
|         callback: async function (/** @type {object[]} */ page) {
 | |
|             // We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements
 | |
|             // This prevents jQuery from actually going through all registered events on the controls for each entry when removing it
 | |
|             worldEntriesList.find('*').off();
 | |
|             worldEntriesList.empty();
 | |
| 
 | |
|             const keywordHeaders = await renderTemplateAsync('worldInfoKeywordHeaders');
 | |
|             const blocksPromises = page.map(async (entry) => await getWorldEntry(name, data, entry)).filter(x => x);
 | |
|             const blocks = await Promise.all(blocksPromises);
 | |
|             const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom';
 | |
|             if (!isCustomOrder) {
 | |
|                 blocks.forEach(block => {
 | |
|                     block.find('.drag-handle').remove();
 | |
|                 });
 | |
|             }
 | |
|             worldEntriesList.append(keywordHeaders);
 | |
|             worldEntriesList.append(blocks);
 | |
|         },
 | |
|         afterSizeSelectorChange: function (e) {
 | |
|             accountStorage.setItem(storageKey, e.target.value);
 | |
|         },
 | |
|         afterPaging: function () {
 | |
|             $('#world_popup_entries_list textarea[name="comment"]').each(function () {
 | |
|                 initScrollHeight($(this));
 | |
|             });
 | |
|         },
 | |
|     });
 | |
| 
 | |
|     if (typeof navigation === 'number' && Number(navigation) >= 0) {
 | |
|         const selector = `#world_popup_entries_list [uid="${navigation}"]`;
 | |
|         waitUntilCondition(() => document.querySelector(selector) !== null).finally(() => {
 | |
|             const element = $(selector);
 | |
| 
 | |
|             if (element.length === 0) {
 | |
|                 console.log(`Could not find element for uid ${navigation}`);
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             const elementOffset = element.offset();
 | |
|             const parentOffset = element.parent().offset();
 | |
|             const scrollOffset = elementOffset.top - parentOffset.top;
 | |
|             $('#WorldInfo').scrollTop(scrollOffset);
 | |
|             if (flashOnNav) flashHighlight(element);
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     $('#world_popup_new').off('click').on('click', () => {
 | |
|         const entry = createWorldInfoEntry(name, data);
 | |
|         if (entry) updateEditor(entry.uid);
 | |
|     });
 | |
| 
 | |
|     $('#world_popup_name_button').off('click').on('click', async () => {
 | |
|         await renameWorldInfo(name, data);
 | |
|     });
 | |
| 
 | |
|     $('#world_backfill_memos').off('click').on('click', async () => {
 | |
|         let counter = 0;
 | |
|         for (const entry of Object.values(data.entries)) {
 | |
|             if (!entry.comment && Array.isArray(entry.key) && entry.key.length > 0) {
 | |
|                 entry.comment = entry.key[0];
 | |
|                 setWIOriginalDataValue(data, entry.uid, 'comment', entry.comment);
 | |
|                 counter++;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (counter > 0) {
 | |
|             toastr.info(`Backfilled ${counter} titles`);
 | |
|             await saveWorldInfo(name, data);
 | |
|             updateEditor(navigation_option.previous);
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     $('#world_apply_current_sorting').off('click').on('click', async () => {
 | |
|         const entryCount = Object.keys(data.entries).length;
 | |
|         const moreThan100 = entryCount > 100;
 | |
| 
 | |
|         let content = '<span>Apply your current sorting to the "Order" field. The Order values will go down from the chosen number.</span>';
 | |
|         if (moreThan100) {
 | |
|             content += `<div class="m-t-1"><i class="fa-solid fa-triangle-exclamation" style="color: #FFD43B;"></i> More than 100 entries in this world. If you don't choose a number higher than that, the lower entries will default to 0.<br />(Usual default: 100)<br />Minimum: ${entryCount}</div>`;
 | |
|         }
 | |
| 
 | |
|         const result = await Popup.show.input('Apply Current Sorting', content, '100', { okButton: 'Apply', cancelButton: 'Cancel' });
 | |
|         if (!result) return;
 | |
| 
 | |
|         const start = Number(result);
 | |
|         if (isNaN(start) || start < 0) {
 | |
|             toastr.error('Invalid number: ' + result, 'Apply Current Sorting');
 | |
|             return;
 | |
|         }
 | |
|         if (start < entryCount) {
 | |
|             toastr.warning('A number lower than the entry count has been chosen. All entries below that will default to 0.', 'Apply Current Sorting');
 | |
|         }
 | |
| 
 | |
|         // We need to sort the entries here, as the data source isn't sorted
 | |
|         const entries = Object.values(data.entries);
 | |
|         sortWorldInfoEntries(entries);
 | |
| 
 | |
|         let updated = 0, current = start;
 | |
|         for (const entry of entries) {
 | |
|             const newOrder = Math.max(current--, 0);
 | |
|             if (entry.order === newOrder) continue;
 | |
| 
 | |
|             entry.order = newOrder;
 | |
|             setWIOriginalDataValue(data, entry.order, 'order', entry.order);
 | |
|             updated++;
 | |
|         }
 | |
| 
 | |
|         if (updated > 0) {
 | |
|             toastr.info(`Updated ${updated} Order values`, 'Apply Custom Sorting');
 | |
|             await saveWorldInfo(name, data, true);
 | |
|             updateEditor(navigation_option.previous);
 | |
|         } else {
 | |
|             toastr.info('All values up to date', 'Apply Custom Sorting');
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     $('#world_popup_export').off('click').on('click', () => {
 | |
|         if (name && data) {
 | |
|             const jsonValue = JSON.stringify(data);
 | |
|             const fileName = `${name}.json`;
 | |
|             download(jsonValue, fileName, 'application/json');
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     $('#world_duplicate').off('click').on('click', async () => {
 | |
|         const tempName = getFreeWorldName();
 | |
|         const finalName = await Popup.show.input('Create a new World Info?', 'Enter a name for the new file:', tempName);
 | |
| 
 | |
|         if (finalName) {
 | |
|             await saveWorldInfo(finalName, data, true);
 | |
|             await updateWorldInfoList();
 | |
| 
 | |
|             const selectedIndex = world_names.indexOf(finalName);
 | |
|             if (selectedIndex !== -1) {
 | |
|                 $('#world_editor_select').val(selectedIndex).trigger('change');
 | |
|             } else {
 | |
|                 hideWorldEditor();
 | |
|             }
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     // Check if a sortable instance exists
 | |
|     if (worldEntriesList.sortable('instance') !== undefined) {
 | |
|         // Destroy the instance
 | |
|         worldEntriesList.sortable('destroy');
 | |
|     }
 | |
| 
 | |
|     worldEntriesList.sortable({
 | |
|         items: '.world_entry',
 | |
|         delay: getSortableDelay(),
 | |
|         handle: '.drag-handle',
 | |
|         stop: async function (_event, _ui) {
 | |
|             const firstEntryUid = $('#world_popup_entries_list .world_entry').first().data('uid');
 | |
|             const minDisplayIndex = data?.entries[firstEntryUid]?.displayIndex ?? 0;
 | |
|             $('#world_popup_entries_list .world_entry').each(function (index) {
 | |
|                 const uid = $(this).data('uid');
 | |
| 
 | |
|                 // Update the display index in the data array
 | |
|                 const item = data.entries[uid];
 | |
| 
 | |
|                 if (!item) {
 | |
|                     console.debug(`Could not find entry with uid ${uid}`);
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 item.displayIndex = minDisplayIndex + index;
 | |
|                 setWIOriginalDataValue(data, uid, 'extensions.display_index', item.displayIndex);
 | |
|             });
 | |
| 
 | |
|             console.table(Object.keys(data.entries).map(uid => data.entries[uid]).map(x => ({ uid: x.uid, key: x.key.join(','), displayIndex: x.displayIndex })));
 | |
| 
 | |
|             await saveWorldInfo(name, data);
 | |
|         },
 | |
|     });
 | |
|     //$("#world_popup_entries_list").disableSelection();
 | |
| }
 | |
| 
 | |
| export const originalWIDataKeyMap = {
 | |
|     'displayIndex': 'extensions.display_index',
 | |
|     'excludeRecursion': 'extensions.exclude_recursion',
 | |
|     'preventRecursion': 'extensions.prevent_recursion',
 | |
|     'delayUntilRecursion': 'extensions.delay_until_recursion',
 | |
|     'selectiveLogic': 'selectiveLogic',
 | |
|     'comment': 'comment',
 | |
|     'constant': 'constant',
 | |
|     'order': 'insertion_order',
 | |
|     'depth': 'extensions.depth',
 | |
|     'probability': 'extensions.probability',
 | |
|     'position': 'extensions.position',
 | |
|     'role': 'extensions.role',
 | |
|     'content': 'content',
 | |
|     'enabled': 'enabled',
 | |
|     'key': 'keys',
 | |
|     'keysecondary': 'secondary_keys',
 | |
|     'selective': 'selective',
 | |
|     'matchWholeWords': 'extensions.match_whole_words',
 | |
|     'useGroupScoring': 'extensions.use_group_scoring',
 | |
|     'caseSensitive': 'extensions.case_sensitive',
 | |
|     'scanDepth': 'extensions.scan_depth',
 | |
|     'automationId': 'extensions.automation_id',
 | |
|     'vectorized': 'extensions.vectorized',
 | |
|     'groupOverride': 'extensions.group_override',
 | |
|     'groupWeight': 'extensions.group_weight',
 | |
|     'sticky': 'extensions.sticky',
 | |
|     'cooldown': 'extensions.cooldown',
 | |
|     'delay': 'extensions.delay',
 | |
| };
 | |
| 
 | |
| /** Checks the state of the current search, and adds/removes the search sorting option accordingly */
 | |
| function verifyWorldInfoSearchSortRule() {
 | |
|     const searchTerm = worldInfoFilter.getFilterData(FILTER_TYPES.WORLD_INFO_SEARCH);
 | |
|     const searchOption = $('#world_info_sort_order option[data-rule="search"]');
 | |
|     const selector = $('#world_info_sort_order');
 | |
|     const isHidden = searchOption.attr('hidden') !== undefined;
 | |
| 
 | |
|     // If we have a search term, we are displaying the sorting option for it
 | |
|     if (searchTerm && isHidden) {
 | |
|         searchOption.removeAttr('hidden');
 | |
|         selector.val(searchOption.attr('value') || '0');
 | |
|         flashHighlight(selector);
 | |
|     }
 | |
|     // If search got cleared, we make sure to hide the option and go back to the one before
 | |
|     if (!searchTerm && !isHidden) {
 | |
|         searchOption.attr('hidden', '');
 | |
|         selector.val(accountStorage.getItem(SORT_ORDER_KEY) || '0');
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sets the value of a specific key in the original data entry corresponding to the given uid
 | |
|  * This needs to be called whenever you update JSON data fields.
 | |
|  * Use `originalWIDataKeyMap` to find the correct value to be set.
 | |
|  *
 | |
|  * @param {object} data - The data object containing the original data entries.
 | |
|  * @param {string} uid - The unique identifier of the data entry.
 | |
|  * @param {string} key - The key of the value to be set.
 | |
|  * @param {any} value - The value to be set.
 | |
|  */
 | |
| export function setWIOriginalDataValue(data, uid, key, value) {
 | |
|     if (data.originalData && Array.isArray(data.originalData.entries)) {
 | |
|         let originalEntry = data.originalData.entries.find(x => x.uid === uid);
 | |
| 
 | |
|         if (!originalEntry) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         setValueByPath(originalEntry, key, value);
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Deletes the original data entry corresponding to the given uid from the provided data object
 | |
|  *
 | |
|  * @param {object} data - The data object containing the original data entries
 | |
|  * @param {string} uid - The unique identifier of the data entry to be deleted
 | |
|  */
 | |
| export function deleteWIOriginalDataValue(data, uid) {
 | |
|     if (data.originalData && Array.isArray(data.originalData.entries)) {
 | |
|         const originalIndex = data.originalData.entries.findIndex(x => x.uid === uid);
 | |
| 
 | |
|         if (originalIndex >= 0) {
 | |
|             data.originalData.entries.splice(originalIndex, 1);
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| /** @typedef {import('./utils.js').Select2Option} Select2Option */
 | |
| 
 | |
| /**
 | |
|  * Splits a given input string that contains one or more keywords or regexes, separated by commas.
 | |
|  *
 | |
|  * Each part can be a valid regex following the pattern `/myregex/flags` with optional flags. Commmas inside the regex are allowed, slashes have to be escaped like this: `\/`
 | |
|  * If a regex doesn't stand alone, it is not treated as a regex.
 | |
|  *
 | |
|  * @param {string} input - One or multiple keywords or regexes, separated by commas
 | |
|  * @returns {string[]} An array of keywords and regexes
 | |
|  */
 | |
| export function splitKeywordsAndRegexes(input) {
 | |
|     /** @type {string[]} */
 | |
|     let keywordsAndRegexes = [];
 | |
| 
 | |
|     // We can make this easy. Instead of writing another function to find and parse regexes,
 | |
|     // we gonna utilize the custom tokenizer that also handles the input.
 | |
|     // No need for validation here
 | |
|     const addFindCallback = (/** @type {Select2Option} */ item) => {
 | |
|         keywordsAndRegexes.push(item.text);
 | |
|     };
 | |
| 
 | |
|     const { term } = customTokenizer({ _type: 'custom_call', term: input }, undefined, addFindCallback);
 | |
|     const finalTerm = term.trim();
 | |
|     if (finalTerm) {
 | |
|         addFindCallback({ id: getSelect2OptionId(finalTerm), text: finalTerm });
 | |
|     }
 | |
| 
 | |
|     return keywordsAndRegexes;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Tokenizer parsing input and splitting it into keywords and regexes
 | |
|  *
 | |
|  * @param {{_type: string, term: string}} input - The typed input
 | |
|  * @param {{options: object}} _selection - The selection even object (?)
 | |
|  * @param {function(Select2Option):void} callback - The original callback function to call if an item should be inserted
 | |
|  * @returns {{term: string}} - The remaining part that is untokenized in the textbox
 | |
|  */
 | |
| function customTokenizer(input, _selection, callback) {
 | |
|     let current = input.term;
 | |
| 
 | |
|     let insideRegex = false, regexClosed = false;
 | |
| 
 | |
|     // Go over the input and check the current state, if we can get a token
 | |
|     for (let i = 0; i < current.length; i++) {
 | |
|         let char = current[i];
 | |
| 
 | |
|         // If we find an unascaped slash, set the current regex state
 | |
|         if (char === '/' && (i === 0 || current[i - 1] !== '\\')) {
 | |
|             if (!insideRegex) insideRegex = true;
 | |
|             else if (!regexClosed) regexClosed = true;
 | |
|         }
 | |
| 
 | |
|         // If a comma is typed, we tokenize the input.
 | |
|         // unless we are inside a possible regex, which would allow commas inside
 | |
|         if (char === ',') {
 | |
|             // We take everything up till now and consider this a token
 | |
|             const token = current.slice(0, i).trim();
 | |
| 
 | |
|             // Now how we test if this is a regex? And not a finished one, but a half-finished one?
 | |
|             // We use the state remembered from above to check whether the delimiter was opened but not closed yet.
 | |
|             // We don't check validity here if we are inside a regex, because it might only get valid after its finished. (Closing brackets, etc)
 | |
|             // Validity will be finally checked when the next comma is typed.
 | |
|             if (insideRegex && !regexClosed) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // So now the comma really means the token is done.
 | |
|             // We take the token up till now, and insert it. Empty will be skipped.
 | |
|             if (token) {
 | |
|                 const isRegex = isValidRegex(token);
 | |
| 
 | |
|                 // Last chance to check for valid regex again. Because it might have been valid while typing, but now is not valid anymore and contains commas we need to split.
 | |
|                 if (token.startsWith('/') && !isRegex) {
 | |
|                     const tokens = token.split(',').map(x => x.trim());
 | |
|                     tokens.forEach(x => callback({ id: getSelect2OptionId(x), text: x }));
 | |
|                 } else {
 | |
|                     callback({ id: getSelect2OptionId(token), text: token });
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             // Now remove the token from the current input, and the comma too
 | |
|             current = current.slice(i + 1);
 | |
|             insideRegex = false;
 | |
|             regexClosed = false;
 | |
|             i = 0;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // At the end, just return the left-over input
 | |
|     return { term: current };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Validates if a string is a valid slash-delimited regex, that can be parsed and executed
 | |
|  *
 | |
|  * This is a wrapper around `parseRegexFromString`
 | |
|  *
 | |
|  * @param {string} input - A delimited regex string
 | |
|  * @returns {boolean} Whether this would be a valid regex that can be parsed and executed
 | |
|  */
 | |
| function isValidRegex(input) {
 | |
|     return parseRegexFromString(input) !== null;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Gets a real regex object from a slash-delimited regex string
 | |
|  *
 | |
|  * This function works with `/` as delimiter, and each occurance of it inside the regex has to be escaped.
 | |
|  * Flags are optional, but can only be valid flags supported by JavaScript's `RegExp` (`g`, `i`, `m`, `s`, `u`, `y`).
 | |
|  *
 | |
|  * @param {string} input - A delimited regex string
 | |
|  * @returns {RegExp|null} The regex object, or null if not a valid regex
 | |
|  */
 | |
| export function parseRegexFromString(input) {
 | |
|     // Extracting the regex pattern and flags
 | |
|     let match = input.match(/^\/([\w\W]+?)\/([gimsuy]*)$/);
 | |
|     if (!match) {
 | |
|         return null; // Not a valid regex format
 | |
|     }
 | |
| 
 | |
|     let [, pattern, flags] = match;
 | |
| 
 | |
|     // If we find any unescaped slash delimiter, we also exit out.
 | |
|     // JS doesn't care about delimiters inside regex patterns, but for this to be a valid regex outside of our implementation,
 | |
|     // we have to make sure that our delimiter is correctly escaped. Or every other engine would fail.
 | |
|     if (pattern.match(/(^|[^\\])\//)) {
 | |
|         return null;
 | |
|     }
 | |
| 
 | |
|     // Now we need to actually unescape the slash delimiters, because JS doesn't care about delimiters
 | |
|     pattern = pattern.replace('\\/', '/');
 | |
| 
 | |
|     // Then we return the regex. If it fails, it was invalid syntax.
 | |
|     try {
 | |
|         return new RegExp(pattern, flags);
 | |
|     } catch (e) {
 | |
|         return null;
 | |
|     }
 | |
| }
 | |
| 
 | |
| export async function getWorldEntry(name, data, entry) {
 | |
|     if (!data.entries[entry.uid]) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const template = WI_ENTRY_EDIT_TEMPLATE.clone();
 | |
|     template.data('uid', entry.uid);
 | |
|     template.attr('uid', entry.uid);
 | |
| 
 | |
|     // Init default state of WI Key toggle (=> true)
 | |
|     if (typeof power_user.wi_key_input_plaintext === 'undefined') power_user.wi_key_input_plaintext = true;
 | |
| 
 | |
|     /** Function to build the keys input controls @param {string} entryPropName @param {string} originalDataValueName */
 | |
|     function enableKeysInput(entryPropName, originalDataValueName) {
 | |
|         const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;
 | |
|         const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
 | |
|         input.data('uid', entry.uid);
 | |
|         input.on('click', function (event) {
 | |
|             // Prevent closing the drawer on clicking the input
 | |
|             event.stopPropagation();
 | |
|         });
 | |
| 
 | |
|         function templateStyling(/** @type {Select2Option} */ item, { searchStyle = false } = {}) {
 | |
|             const content = $('<span>').addClass('item').text(item.text).attr('title', `${item.text}\n\nClick to edit`);
 | |
|             const isRegex = isValidRegex(item.text);
 | |
|             if (isRegex) {
 | |
|                 content.html(highlightRegex(item.text));
 | |
|                 content.addClass('regex_item').prepend($('<span>').addClass('regex_icon').text('•*').attr('title', 'Regex'));
 | |
|             }
 | |
| 
 | |
|             if (searchStyle && item.count) {
 | |
|                 // Build a wrapping element
 | |
|                 const wrapper = $('<span>').addClass('result_block')
 | |
|                     .append(content);
 | |
|                 wrapper.append($('<span>').addClass('item_count').text(item.count).attr('title', `Used as a key ${item.count} ${item.count != 1 ? 'times' : 'time'} in this lorebook`));
 | |
|                 return wrapper;
 | |
|             }
 | |
| 
 | |
|             return content;
 | |
|         }
 | |
| 
 | |
|         if (isFancyInput) {
 | |
|             // First initialize existing values as options, before initializing select2, to speed up performance
 | |
|             select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } });
 | |
| 
 | |
|             input.select2({
 | |
|                 ajax: dynamicSelect2DataViaAjax(() => worldEntryKeyOptionsCache),
 | |
|                 tags: true,
 | |
|                 tokenSeparators: [','],
 | |
|                 tokenizer: customTokenizer,
 | |
|                 placeholder: input.attr('placeholder'),
 | |
|                 templateResult: item => templateStyling(item, { searchStyle: true }),
 | |
|                 templateSelection: item => templateStyling(item),
 | |
|             });
 | |
|             input.on('change', async function (_, { skipReset, noSave } = {}) {
 | |
|                 const uid = $(this).data('uid');
 | |
|                 /** @type {string[]} */
 | |
|                 const keys = ($(this).select2('data')).map(x => x.text);
 | |
| 
 | |
|                 !skipReset && await resetScrollHeight(this);
 | |
|                 if (!noSave) {
 | |
|                     data.entries[uid][entryPropName] = keys;
 | |
|                     setWIOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
 | |
|                     await saveWorldInfo(name, data);
 | |
|                 }
 | |
|                 $(this).toggleClass('empty', !data.entries[uid][entryPropName].length);
 | |
|             });
 | |
|             input.toggleClass('empty', !entry[entryPropName].length);
 | |
|             input.on('select2:select', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data]));
 | |
|             input.on('select2:unselect', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data], { remove: true }));
 | |
| 
 | |
|             select2ChoiceClickSubscribe(input, target => {
 | |
|                 const key = $(target.closest('.regex-highlight, .item')).text();
 | |
|                 console.debug('Editing WI key', key);
 | |
| 
 | |
|                 // Remove the current key from the actual selection
 | |
|                 const selected = input.val();
 | |
|                 if (!Array.isArray(selected)) return;
 | |
|                 var index = selected.indexOf(getSelect2OptionId(key));
 | |
|                 if (index > -1) selected.splice(index, 1);
 | |
|                 input.val(selected).trigger('change');
 | |
|                 // Manually update the cache, that change event is not gonna trigger it
 | |
|                 updateWorldEntryKeyOptionsCache([key], { remove: true });
 | |
| 
 | |
|                 // We need to "hack" the actual text input into the currently open textarea
 | |
|                 input.next('span.select2-container').find('textarea')
 | |
|                     .val(key).trigger('input');
 | |
|             }, { openDrawer: true });
 | |
|         }
 | |
|         else {
 | |
|             // Compatibility with mobile devices. On mobile we need a text input field, not a select option control, so we need its own event handlers
 | |
|             template.find(`select[name="${entryPropName}"]`).hide();
 | |
|             input.show();
 | |
| 
 | |
|             input.on('input', async function (_, { skipReset, noSave } = {}) {
 | |
|                 const uid = $(this).data('uid');
 | |
|                 const value = String($(this).val());
 | |
|                 !skipReset && await resetScrollHeight(this);
 | |
|                 if (!noSave) {
 | |
|                     data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value);
 | |
|                     setWIOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
 | |
|                     await saveWorldInfo(name, data);
 | |
|                     $(this).toggleClass('empty', !data.entries[uid][entryPropName].length);
 | |
|                 }
 | |
|             });
 | |
|             input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true });
 | |
|         }
 | |
|         return { isFancy: isFancyInput, control: input };
 | |
|     }
 | |
| 
 | |
|     // key
 | |
|     const keyInput = enableKeysInput('key', 'keys');
 | |
| 
 | |
|     // keysecondary
 | |
|     const keySecondaryInput = enableKeysInput('keysecondary', 'secondary_keys');
 | |
| 
 | |
|     // draw key input switch button
 | |
|     template.find('.switch_input_type_icon').on('click', function () {
 | |
|         power_user.wi_key_input_plaintext = !power_user.wi_key_input_plaintext;
 | |
|         saveSettingsDebounced();
 | |
| 
 | |
|         // Just redraw the panel
 | |
|         const uid = ($(this).parents('.world_entry')).data('uid');
 | |
|         updateEditor(uid, false);
 | |
| 
 | |
|         $(`.world_entry[uid="${uid}"] .inline-drawer-icon`).trigger('click');
 | |
|         // setTimeout(() => {
 | |
|         // }, debounce_timeout.standard);
 | |
|     }).each((_, icon) => {
 | |
|         $(icon).attr('title', $(icon).data(power_user.wi_key_input_plaintext ? 'tooltip-on' : 'tooltip-off'));
 | |
|         $(icon).text($(icon).data(power_user.wi_key_input_plaintext ? 'icon-on' : 'icon-off'));
 | |
|     });
 | |
| 
 | |
|     // logic AND/NOT
 | |
|     const selectiveLogicDropdown = template.find('select[name="entryLogicType"]');
 | |
|     selectiveLogicDropdown.data('uid', entry.uid);
 | |
| 
 | |
|     selectiveLogicDropdown.on('click', function (event) {
 | |
|         event.stopPropagation();
 | |
|     });
 | |
| 
 | |
|     selectiveLogicDropdown.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
|         data.entries[uid].selectiveLogic = !isNaN(value) ? value : world_info_logic.AND_ANY;
 | |
|         setWIOriginalDataValue(data, uid, 'selectiveLogic', data.entries[uid].selectiveLogic);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
| 
 | |
|     template
 | |
|         .find(`select[name="entryLogicType"] option[value=${entry.selectiveLogic}]`)
 | |
|         .prop('selected', true)
 | |
|         .trigger('input');
 | |
| 
 | |
|     // Character filter
 | |
|     const characterFilterLabel = template.find('label[for="characterFilter"] > small');
 | |
|     characterFilterLabel.text(entry.characterFilter?.isExclude ? 'Exclude Character(s)' : 'Filter to Character(s)');
 | |
| 
 | |
|     // exclude characters checkbox
 | |
|     const characterExclusionInput = template.find('input[name="character_exclusion"]');
 | |
|     characterExclusionInput.data('uid', entry.uid);
 | |
|     characterExclusionInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         characterFilterLabel.text(value ? 'Exclude Character(s)' : 'Filter to Character(s)');
 | |
|         if (data.entries[uid].characterFilter) {
 | |
|             if (!value && data.entries[uid].characterFilter.names.length === 0 && data.entries[uid].characterFilter.tags.length === 0) {
 | |
|                 delete data.entries[uid].characterFilter;
 | |
|             } else {
 | |
|                 data.entries[uid].characterFilter.isExclude = value;
 | |
|             }
 | |
|         } else if (value) {
 | |
|             Object.assign(
 | |
|                 data.entries[uid],
 | |
|                 {
 | |
|                     characterFilter: {
 | |
|                         isExclude: true,
 | |
|                         names: [],
 | |
|                         tags: [],
 | |
|                     },
 | |
|                 },
 | |
|             );
 | |
|         }
 | |
| 
 | |
|         // Verify names to exist in the system
 | |
|         if (data.entries[uid]?.characterFilter?.names?.length > 0) {
 | |
|             for (const name of [...data.entries[uid].characterFilter.names]) {
 | |
|                 if (!getContext().characters.find(x => x.avatar.replace(/\.[^/.]+$/, '') === name)) {
 | |
|                     console.warn(`World Info: Character ${name} not found. Removing from the entry filter.`, entry);
 | |
|                     data.entries[uid].characterFilter.names = data.entries[uid].characterFilter.names.filter(x => x !== name);
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     characterExclusionInput.prop('checked', entry.characterFilter?.isExclude ?? false).trigger('input');
 | |
| 
 | |
|     const characterFilter = template.find('select[name="characterFilter"]');
 | |
|     characterFilter.data('uid', entry.uid);
 | |
| 
 | |
|     if (!isMobile()) {
 | |
|         $(characterFilter).select2({
 | |
|             width: '100%',
 | |
|             placeholder: 'Tie this entry to specific characters or characters with specific tags',
 | |
|             allowClear: true,
 | |
|             closeOnSelect: false,
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     const characters = getContext().characters;
 | |
|     characters.forEach((character) => {
 | |
|         const option = document.createElement('option');
 | |
|         const name = character.avatar.replace(/\.[^/.]+$/, '') ?? character.name;
 | |
|         option.innerText = name;
 | |
|         option.selected = entry.characterFilter?.names?.includes(name);
 | |
|         option.setAttribute('data-type', 'character');
 | |
|         characterFilter.append(option);
 | |
|     });
 | |
| 
 | |
|     const tags = getContext().tags;
 | |
|     tags.forEach((tag) => {
 | |
|         const option = document.createElement('option');
 | |
|         option.innerText = `[Tag] ${tag.name}`;
 | |
|         option.selected = entry.characterFilter?.tags?.includes(tag.id);
 | |
|         option.value = tag.id;
 | |
|         option.setAttribute('data-type', 'tag');
 | |
|         characterFilter.append(option);
 | |
|     });
 | |
| 
 | |
|     characterFilter.on('mousedown change', async function (e) {
 | |
|         // If there's no world names, don't do anything
 | |
|         if (world_names.length === 0) {
 | |
|             e.preventDefault();
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const uid = $(this).data('uid');
 | |
|         const selected = $(this).find(':selected');
 | |
|         if ((!selected || selected?.length === 0) && !data.entries[uid].characterFilter?.isExclude) {
 | |
|             delete data.entries[uid].characterFilter;
 | |
|         } else {
 | |
|             const names = selected.filter('[data-type="character"]').map((_, e) => e instanceof HTMLOptionElement && e.innerText).toArray();
 | |
|             const tags = selected.filter('[data-type="tag"]').map((_, e) => e instanceof HTMLOptionElement && e.value).toArray();
 | |
|             Object.assign(
 | |
|                 data.entries[uid],
 | |
|                 {
 | |
|                     characterFilter: {
 | |
|                         isExclude: data.entries[uid].characterFilter?.isExclude ?? false,
 | |
|                         names: names,
 | |
|                         tags: tags,
 | |
|                     },
 | |
|                 },
 | |
|             );
 | |
|         }
 | |
|         setWIOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
| 
 | |
|     // comment
 | |
|     const commentInput = template.find('textarea[name="comment"]');
 | |
|     const commentToggle = template.find('input[name="addMemo"]');
 | |
|     commentInput.data('uid', entry.uid);
 | |
|     commentInput.on('input', async function (_, { skipReset } = {}) {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).val();
 | |
|         !skipReset && await resetScrollHeight(this);
 | |
|         data.entries[uid].comment = value;
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'comment', data.entries[uid].comment);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     commentToggle.data('uid', entry.uid);
 | |
|     commentToggle.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         //console.log(value)
 | |
|         const commentContainer = $(this)
 | |
|             .closest('.world_entry')
 | |
|             .find('.commentContainer');
 | |
|         data.entries[uid].addMemo = value;
 | |
|         await saveWorldInfo(name, data);
 | |
|         value ? commentContainer.show() : commentContainer.hide();
 | |
|     });
 | |
| 
 | |
|     commentInput.val(entry.comment).trigger('input', { skipReset: true });
 | |
|     //initScrollHeight(commentInput);
 | |
|     commentToggle.prop('checked', true /* entry.addMemo */).trigger('input');
 | |
|     commentToggle.parent().hide();
 | |
| 
 | |
|     // content
 | |
|     const counter = template.find('.world_entry_form_token_counter');
 | |
|     const countTokensDebounced = debounce(async function (counter, value) {
 | |
|         const numberOfTokens = await getTokenCountAsync(value);
 | |
|         $(counter).text(numberOfTokens);
 | |
|     }, debounce_timeout.relaxed);
 | |
| 
 | |
|     const contentInput = template.find('textarea[name="content"]');
 | |
|     contentInput.data('uid', entry.uid);
 | |
|     contentInput.on('input', async function (_, { skipCount } = {}) {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).val();
 | |
|         data.entries[uid].content = value;
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'content', data.entries[uid].content);
 | |
|         await saveWorldInfo(name, data);
 | |
| 
 | |
|         if (skipCount) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // count tokens
 | |
|         countTokensDebounced(counter, value);
 | |
|     });
 | |
|     contentInput.val(entry.content).trigger('input', { skipCount: true });
 | |
|     //initScrollHeight(contentInput);
 | |
| 
 | |
|     template.find('.inline-drawer-toggle').on('click', function () {
 | |
|         if (counter.data('first-run')) {
 | |
|             counter.data('first-run', false);
 | |
|             countTokensDebounced(counter, contentInput.val());
 | |
|             if (!keyInput.isFancy) initScrollHeight(keyInput.control);
 | |
|             if (!keySecondaryInput.isFancy) initScrollHeight(keySecondaryInput.control);
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     // selective
 | |
|     const selectiveInput = template.find('input[name="selective"]');
 | |
|     selectiveInput.data('uid', entry.uid);
 | |
|     selectiveInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         data.entries[uid].selective = value;
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'selective', data.entries[uid].selective);
 | |
|         await saveWorldInfo(name, data);
 | |
| 
 | |
|         const keysecondary = $(this)
 | |
|             .closest('.world_entry')
 | |
|             .find('.keysecondary');
 | |
| 
 | |
|         const keysecondarytextpole = $(this)
 | |
|             .closest('.world_entry')
 | |
|             .find('.keysecondarytextpole');
 | |
| 
 | |
|         const keyprimaryselect = $(this)
 | |
|             .closest('.world_entry')
 | |
|             .find('.keyprimaryselect');
 | |
| 
 | |
|         const keyprimaryHeight = keyprimaryselect.outerHeight();
 | |
|         keysecondarytextpole.css('height', keyprimaryHeight + 'px');
 | |
| 
 | |
|         value ? keysecondary.show() : keysecondary.hide();
 | |
| 
 | |
|     });
 | |
|     //forced on, ignored if empty
 | |
|     selectiveInput.prop('checked', true /* entry.selective */).trigger('input');
 | |
|     selectiveInput.parent().hide();
 | |
| 
 | |
| 
 | |
|     // constant
 | |
|     /*
 | |
|     const constantInput = template.find('input[name="constant"]');
 | |
|     constantInput.data("uid", entry.uid);
 | |
|     constantInput.on("input", async function () {
 | |
|         const uid = $(this).data("uid");
 | |
|         const value = $(this).prop("checked");
 | |
|         data.entries[uid].constant = value;
 | |
|         setOriginalDataValue(data, uid, "constant", data.entries[uid].constant);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     constantInput.prop("checked", entry.constant).trigger("input");
 | |
|     */
 | |
| 
 | |
|     // order
 | |
|     const orderInput = template.find('input[name="order"]');
 | |
|     orderInput.data('uid', entry.uid);
 | |
|     orderInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
| 
 | |
|         data.entries[uid].order = !isNaN(value) ? value : 0;
 | |
|         updatePosOrdDisplay(uid);
 | |
|         setWIOriginalDataValue(data, uid, 'insertion_order', data.entries[uid].order);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     orderInput.val(entry.order).trigger('input');
 | |
|     orderInput.css('width', 'calc(3em + 15px)');
 | |
| 
 | |
|     // group
 | |
|     const groupInput = template.find('input[name="group"]');
 | |
|     groupInput.data('uid', entry.uid);
 | |
|     groupInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = String($(this).val()).trim();
 | |
| 
 | |
|         data.entries[uid].group = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.group', data.entries[uid].group);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     groupInput.val(entry.group ?? '').trigger('input');
 | |
|     setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data), { allowMultiple: true }), 1);
 | |
| 
 | |
|     // inclusion priority
 | |
|     const groupOverrideInput = template.find('input[name="groupOverride"]');
 | |
|     groupOverrideInput.data('uid', entry.uid);
 | |
|     groupOverrideInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         data.entries[uid].groupOverride = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.group_override', data.entries[uid].groupOverride);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     groupOverrideInput.prop('checked', entry.groupOverride).trigger('input');
 | |
| 
 | |
|     // group weight
 | |
|     const groupWeightInput = template.find('input[name="groupWeight"]');
 | |
|     groupWeightInput.data('uid', entry.uid);
 | |
|     groupWeightInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         let value = Number($(this).val());
 | |
|         const min = Number($(this).attr('min'));
 | |
|         const max = Number($(this).attr('max'));
 | |
| 
 | |
|         // Clamp the value
 | |
|         if (value < min) {
 | |
|             value = min;
 | |
|             $(this).val(min);
 | |
|         } else if (value > max) {
 | |
|             value = max;
 | |
|             $(this).val(max);
 | |
|         }
 | |
| 
 | |
|         data.entries[uid].groupWeight = !isNaN(value) ? Math.abs(value) : 1;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.group_weight', data.entries[uid].groupWeight);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     groupWeightInput.val(entry.groupWeight ?? DEFAULT_WEIGHT).trigger('input');
 | |
| 
 | |
|     // sticky
 | |
|     const sticky = template.find('input[name="sticky"]');
 | |
|     sticky.data('uid', entry.uid);
 | |
|     sticky.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
|         data.entries[uid].sticky = !isNaN(value) ? value : null;
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.sticky', data.entries[uid].sticky);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     sticky.val(entry.sticky > 0 ? entry.sticky : '').trigger('input');
 | |
| 
 | |
|     // cooldown
 | |
|     const cooldown = template.find('input[name="cooldown"]');
 | |
|     cooldown.data('uid', entry.uid);
 | |
|     cooldown.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
|         data.entries[uid].cooldown = !isNaN(value) ? value : null;
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.cooldown', data.entries[uid].cooldown);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     cooldown.val(entry.cooldown > 0 ? entry.cooldown : '').trigger('input');
 | |
| 
 | |
|     // delay
 | |
|     const delay = template.find('input[name="delay"]');
 | |
|     delay.data('uid', entry.uid);
 | |
|     delay.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
|         data.entries[uid].delay = !isNaN(value) ? value : null;
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.delay', data.entries[uid].delay);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     delay.val(entry.delay > 0 ? entry.delay : '').trigger('input');
 | |
| 
 | |
|     // probability
 | |
|     if (entry.probability === undefined) {
 | |
|         entry.probability = null;
 | |
|     }
 | |
| 
 | |
|     // depth
 | |
|     const depthInput = template.find('input[name="depth"]');
 | |
|     depthInput.data('uid', entry.uid);
 | |
| 
 | |
|     depthInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
| 
 | |
|         data.entries[uid].depth = !isNaN(value) ? value : 0;
 | |
|         updatePosOrdDisplay(uid);
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.depth', data.entries[uid].depth);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     depthInput.val(entry.depth ?? DEFAULT_DEPTH).trigger('input');
 | |
|     depthInput.css('width', 'calc(3em + 15px)');
 | |
| 
 | |
|     // Hide by default unless depth is specified
 | |
|     if (entry.position === world_info_position.atDepth) {
 | |
|         //depthInput.parent().hide();
 | |
|     }
 | |
| 
 | |
|     const probabilityInput = template.find('input[name="probability"]');
 | |
|     probabilityInput.data('uid', entry.uid);
 | |
|     probabilityInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
| 
 | |
|         data.entries[uid].probability = !isNaN(value) ? value : null;
 | |
| 
 | |
|         // Clamp probability to 0-100
 | |
|         if (data.entries[uid].probability !== null) {
 | |
|             data.entries[uid].probability = Math.min(100, Math.max(0, data.entries[uid].probability));
 | |
| 
 | |
|             if (data.entries[uid].probability !== value) {
 | |
|                 $(this).val(data.entries[uid].probability);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.probability', data.entries[uid].probability);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     probabilityInput.val(entry.probability).trigger('input');
 | |
|     probabilityInput.css('width', 'calc(3em + 15px)');
 | |
| 
 | |
|     // probability toggle
 | |
|     if (entry.useProbability === undefined) {
 | |
|         entry.useProbability = false;
 | |
|     }
 | |
| 
 | |
|     const probabilityToggle = template.find('input[name="useProbability"]');
 | |
|     probabilityToggle.data('uid', entry.uid);
 | |
|     probabilityToggle.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         data.entries[uid].useProbability = value;
 | |
|         const probabilityContainer = $(this)
 | |
|             .closest('.world_entry')
 | |
|             .find('.probabilityContainer');
 | |
|         await saveWorldInfo(name, data);
 | |
|         value ? probabilityContainer.show() : probabilityContainer.hide();
 | |
| 
 | |
|         if (value && data.entries[uid].probability === null) {
 | |
|             data.entries[uid].probability = 100;
 | |
|         }
 | |
| 
 | |
|         if (!value) {
 | |
|             data.entries[uid].probability = null;
 | |
|         }
 | |
| 
 | |
|         probabilityInput.val(data.entries[uid].probability).trigger('input');
 | |
|     });
 | |
|     //forced on, 100% by default
 | |
|     probabilityToggle.prop('checked', true /* entry.useProbability */).trigger('input');
 | |
|     probabilityToggle.parent().hide();
 | |
| 
 | |
|     // position
 | |
|     if (entry.position === undefined) {
 | |
|         entry.position = 0;
 | |
|     }
 | |
| 
 | |
|     const positionInput = template.find('select[name="position"]');
 | |
|     //initScrollHeight(positionInput);
 | |
|     positionInput.data('uid', entry.uid);
 | |
|     positionInput.on('click', function (event) {
 | |
|         // Prevent closing the drawer on clicking the input
 | |
|         event.stopPropagation();
 | |
|     });
 | |
|     positionInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = Number($(this).val());
 | |
|         data.entries[uid].position = !isNaN(value) ? value : 0;
 | |
|         if (value === world_info_position.atDepth) {
 | |
|             depthInput.prop('disabled', false);
 | |
|             depthInput.css('visibility', 'visible');
 | |
|             //depthInput.parent().show();
 | |
|             const role = Number($(this).find(':selected').data('role'));
 | |
|             data.entries[uid].role = role;
 | |
|         } else {
 | |
|             depthInput.prop('disabled', true);
 | |
|             depthInput.css('visibility', 'hidden');
 | |
|             data.entries[uid].role = null;
 | |
|             //depthInput.parent().hide();
 | |
|         }
 | |
|         updatePosOrdDisplay(uid);
 | |
|         // Spec v2 only supports before_char and after_char
 | |
|         setWIOriginalDataValue(data, uid, 'position', data.entries[uid].position == 0 ? 'before_char' : 'after_char');
 | |
|         // Write the original value as extensions field
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.position', data.entries[uid].position);
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.role', data.entries[uid].role);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
| 
 | |
|     const roleValue = entry.position === world_info_position.atDepth ? String(entry.role ?? extension_prompt_roles.SYSTEM) : '';
 | |
|     template
 | |
|         .find(`select[name="position"] option[value=${entry.position}][data-role="${roleValue}"]`)
 | |
|         .prop('selected', true)
 | |
|         .trigger('input');
 | |
| 
 | |
|     //add UID above content box (less important doesn't need to be always visible)
 | |
|     template.find('.world_entry_form_uid_value').text(`(UID: ${entry.uid})`);
 | |
| 
 | |
|     //new tri-state selector for constant/normal/vectorized
 | |
|     const entryStateSelector = template.find('select[name="entryStateSelector"]');
 | |
|     entryStateSelector.data('uid', entry.uid);
 | |
|     entryStateSelector.on('click', function (event) {
 | |
|         // Prevent closing the drawer on clicking the input
 | |
|         event.stopPropagation();
 | |
|     });
 | |
|     entryStateSelector.on('input', async function () {
 | |
|         const uid = entry.uid;
 | |
|         const value = $(this).val();
 | |
|         switch (value) {
 | |
|             case 'constant':
 | |
|                 data.entries[uid].constant = true;
 | |
|                 data.entries[uid].vectorized = false;
 | |
|                 setWIOriginalDataValue(data, uid, 'constant', true);
 | |
|                 setWIOriginalDataValue(data, uid, 'extensions.vectorized', false);
 | |
|                 break;
 | |
|             case 'normal':
 | |
|                 data.entries[uid].constant = false;
 | |
|                 data.entries[uid].vectorized = false;
 | |
|                 setWIOriginalDataValue(data, uid, 'constant', false);
 | |
|                 setWIOriginalDataValue(data, uid, 'extensions.vectorized', false);
 | |
|                 break;
 | |
|             case 'vectorized':
 | |
|                 data.entries[uid].constant = false;
 | |
|                 data.entries[uid].vectorized = true;
 | |
|                 setWIOriginalDataValue(data, uid, 'constant', false);
 | |
|                 setWIOriginalDataValue(data, uid, 'extensions.vectorized', true);
 | |
|                 break;
 | |
|         }
 | |
|         await saveWorldInfo(name, data);
 | |
| 
 | |
|     });
 | |
| 
 | |
|     const entryKillSwitch = template.find('div[name="entryKillSwitch"]');
 | |
|     entryKillSwitch.data('uid', entry.uid);
 | |
|     entryKillSwitch.on('click', async function (event) {
 | |
|         const uid = entry.uid;
 | |
|         data.entries[uid].disable = !data.entries[uid].disable;
 | |
|         const isActive = !data.entries[uid].disable;
 | |
|         setWIOriginalDataValue(data, uid, 'enabled', isActive);
 | |
|         template.toggleClass('disabledWIEntry', !isActive);
 | |
|         entryKillSwitch.toggleClass('fa-toggle-off', !isActive);
 | |
|         entryKillSwitch.toggleClass('fa-toggle-on', isActive);
 | |
|         await saveWorldInfo(name, data);
 | |
| 
 | |
|     });
 | |
| 
 | |
|     const entryState = function () {
 | |
|         if (entry.constant === true) {
 | |
|             return 'constant';
 | |
|         } else if (entry.vectorized === true) {
 | |
|             return 'vectorized';
 | |
|         } else {
 | |
|             return 'normal';
 | |
|         }
 | |
|     };
 | |
| 
 | |
|     const isActive = !entry.disable;
 | |
|     template.toggleClass('disabledWIEntry', !isActive);
 | |
|     entryKillSwitch.toggleClass('fa-toggle-off', !isActive);
 | |
|     entryKillSwitch.toggleClass('fa-toggle-on', isActive);
 | |
| 
 | |
|     template
 | |
|         .find(`select[name="entryStateSelector"] option[value=${entryState()}]`)
 | |
|         .prop('selected', true)
 | |
|         .trigger('input');
 | |
| 
 | |
|     // exclude recursion
 | |
|     const excludeRecursionInput = template.find('input[name="exclude_recursion"]');
 | |
|     excludeRecursionInput.data('uid', entry.uid);
 | |
|     excludeRecursionInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         data.entries[uid].excludeRecursion = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.exclude_recursion', data.entries[uid].excludeRecursion);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     excludeRecursionInput.prop('checked', entry.excludeRecursion).trigger('input');
 | |
| 
 | |
|     // prevent recursion
 | |
|     const preventRecursionInput = template.find('input[name="prevent_recursion"]');
 | |
|     preventRecursionInput.data('uid', entry.uid);
 | |
|     preventRecursionInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).prop('checked');
 | |
|         data.entries[uid].preventRecursion = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.prevent_recursion', data.entries[uid].preventRecursion);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input');
 | |
| 
 | |
|     // delay until recursion
 | |
|     // delay until recursion level
 | |
|     const delayUntilRecursionInput = template.find('input[name="delay_until_recursion"]');
 | |
|     delayUntilRecursionInput.data('uid', entry.uid);
 | |
|     const delayUntilRecursionLevelInput = template.find('input[name="delayUntilRecursionLevel"]');
 | |
|     delayUntilRecursionLevelInput.data('uid', entry.uid);
 | |
|     delayUntilRecursionInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const toggled = $(this).prop('checked');
 | |
| 
 | |
|         // If the value contains a number, we'll take that one (set by the level input), otherwise we can use true/false switch
 | |
|         const value = toggled ? data.entries[uid].delayUntilRecursion || true : false;
 | |
| 
 | |
|         if (!toggled) delayUntilRecursionLevelInput.val('');
 | |
| 
 | |
|         data.entries[uid].delayUntilRecursion = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     delayUntilRecursionInput.prop('checked', entry.delayUntilRecursion).trigger('input');
 | |
|     delayUntilRecursionLevelInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const content = $(this).val();
 | |
|         const value = content === '' ? (typeof data.entries[uid].delayUntilRecursion === 'boolean' ? data.entries[uid].delayUntilRecursion : true)
 | |
|             : content === 1 ? true
 | |
|                 : !isNaN(Number(content)) ? Number(content)
 | |
|                     : false;
 | |
| 
 | |
|         data.entries[uid].delayUntilRecursion = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     // No need to retrigger inpout event, we'll just set the curret current value. It was edited/saved above already
 | |
|     delayUntilRecursionLevelInput.val(['number', 'string'].includes(typeof entry.delayUntilRecursion) ? entry.delayUntilRecursion : '').trigger('input');
 | |
| 
 | |
|     // duplicate button
 | |
|     const duplicateButton = template.find('.duplicate_entry_button');
 | |
|     duplicateButton.data('uid', entry.uid);
 | |
|     duplicateButton.on('click', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const entry = duplicateWorldInfoEntry(data, uid);
 | |
|         if (entry) {
 | |
|             await saveWorldInfo(name, data);
 | |
|             updateEditor(entry.uid);
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     // delete button
 | |
|     const deleteButton = template.find('.delete_entry_button');
 | |
|     deleteButton.data('uid', entry.uid);
 | |
|     deleteButton.on('click', async function (e) {
 | |
|         e.stopPropagation();
 | |
|         const uid = $(this).data('uid');
 | |
|         const deleted = await deleteWorldInfoEntry(data, uid);
 | |
|         if (!deleted) return;
 | |
|         deleteWIOriginalDataValue(data, uid);
 | |
|         await saveWorldInfo(name, data);
 | |
|         updateEditor(navigation_option.previous);
 | |
|     });
 | |
| 
 | |
|     // scan depth
 | |
|     const scanDepthInput = template.find('input[name="scanDepth"]');
 | |
|     scanDepthInput.data('uid', entry.uid);
 | |
|     scanDepthInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const isEmpty = $(this).val() === '';
 | |
|         const value = Number($(this).val());
 | |
| 
 | |
|         // Clamp if necessary
 | |
|         if (value < 0) {
 | |
|             $(this).val(0).trigger('input');
 | |
|             toastr.warning('Scan depth cannot be negative');
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         if (value > MAX_SCAN_DEPTH) {
 | |
|             $(this).val(MAX_SCAN_DEPTH).trigger('input');
 | |
|             toastr.warning(`Scan depth cannot exceed ${MAX_SCAN_DEPTH}`);
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         data.entries[uid].scanDepth = !isEmpty && !isNaN(value) && value >= 0 && value <= MAX_SCAN_DEPTH ? Math.floor(value) : null;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.scan_depth', data.entries[uid].scanDepth);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     scanDepthInput.val(entry.scanDepth ?? null).trigger('input');
 | |
| 
 | |
|     // case sensitive select
 | |
|     const caseSensitiveSelect = template.find('select[name="caseSensitive"]');
 | |
|     caseSensitiveSelect.data('uid', entry.uid);
 | |
|     caseSensitiveSelect.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).val();
 | |
| 
 | |
|         data.entries[uid].caseSensitive = value === 'null' ? null : value === 'true';
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.case_sensitive', data.entries[uid].caseSensitive);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     caseSensitiveSelect.val((entry.caseSensitive === null || entry.caseSensitive === undefined) ? 'null' : entry.caseSensitive ? 'true' : 'false').trigger('input');
 | |
| 
 | |
|     // match whole words select
 | |
|     const matchWholeWordsSelect = template.find('select[name="matchWholeWords"]');
 | |
|     matchWholeWordsSelect.data('uid', entry.uid);
 | |
|     matchWholeWordsSelect.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).val();
 | |
| 
 | |
|         data.entries[uid].matchWholeWords = value === 'null' ? null : value === 'true';
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.match_whole_words', data.entries[uid].matchWholeWords);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     matchWholeWordsSelect.val((entry.matchWholeWords === null || entry.matchWholeWords === undefined) ? 'null' : entry.matchWholeWords ? 'true' : 'false').trigger('input');
 | |
| 
 | |
|     // use group scoring select
 | |
|     const useGroupScoringSelect = template.find('select[name="useGroupScoring"]');
 | |
|     useGroupScoringSelect.data('uid', entry.uid);
 | |
|     useGroupScoringSelect.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).val();
 | |
| 
 | |
|         data.entries[uid].useGroupScoring = value === 'null' ? null : value === 'true';
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.use_group_scoring', data.entries[uid].useGroupScoring);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     useGroupScoringSelect.val((entry.useGroupScoring === null || entry.useGroupScoring === undefined) ? 'null' : entry.useGroupScoring ? 'true' : 'false').trigger('input');
 | |
| 
 | |
|     // automation id
 | |
|     const automationIdInput = template.find('input[name="automationId"]');
 | |
|     automationIdInput.data('uid', entry.uid);
 | |
|     automationIdInput.on('input', async function () {
 | |
|         const uid = $(this).data('uid');
 | |
|         const value = $(this).val();
 | |
| 
 | |
|         data.entries[uid].automationId = value;
 | |
|         setWIOriginalDataValue(data, uid, 'extensions.automation_id', data.entries[uid].automationId);
 | |
|         await saveWorldInfo(name, data);
 | |
|     });
 | |
|     automationIdInput.val(entry.automationId ?? '').trigger('input');
 | |
|     setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1);
 | |
| 
 | |
|     template.find('.inline-drawer-content').css('display', 'none'); //entries start collapsed
 | |
| 
 | |
|     function updatePosOrdDisplay(uid) {
 | |
|         // display position/order info left of keyword box
 | |
|         let entry = data.entries[uid];
 | |
|         let posText = entry.position;
 | |
|         switch (entry.position) {
 | |
|             case 0:
 | |
|                 posText = '↑CD';
 | |
|                 break;
 | |
|             case 1:
 | |
|                 posText = 'CD↓';
 | |
|                 break;
 | |
|             case 2:
 | |
|                 posText = '↑AN';
 | |
|                 break;
 | |
|             case 3:
 | |
|                 posText = 'AN↓';
 | |
|                 break;
 | |
|             case 4:
 | |
|                 posText = `@D${entry.depth}`;
 | |
|                 break;
 | |
|         }
 | |
|         template.find('.world_entry_form_position_value').text(`(${posText} ${entry.order})`);
 | |
|     }
 | |
| 
 | |
|     return template;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Get the inclusion groups for the autocomplete.
 | |
|  * @param {any} data WI data
 | |
|  * @returns {(input: any, output: any) => any} Callback function for the autocomplete
 | |
|  */
 | |
| function getInclusionGroupCallback(data) {
 | |
|     return function (control, input, output) {
 | |
|         const uid = $(control).data('uid');
 | |
|         const thisGroups = String($(control).val()).split(/,\s*/).filter(x => x).map(x => x.toLowerCase());
 | |
|         const groups = new Set();
 | |
|         for (const entry of Object.values(data.entries)) {
 | |
|             // Skip the groups of this entry, because auto-complete should only suggest the ones that are already available on other entries
 | |
|             if (entry.uid == uid) continue;
 | |
|             if (entry.group) {
 | |
|                 entry.group.split(/,\s*/).filter(x => x).forEach(x => groups.add(x));
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         const haystack = Array.from(groups);
 | |
|         haystack.sort((a, b) => a.localeCompare(b));
 | |
|         const needle = input.term.toLowerCase();
 | |
|         const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
 | |
|         const result = haystack.filter(x => x.toLowerCase().includes(needle) && (!thisGroups.includes(x) || hasExactMatch && thisGroups.filter(g => g == x).length == 1));
 | |
| 
 | |
|         output(result);
 | |
|     };
 | |
| }
 | |
| 
 | |
| function getAutomationIdCallback(data) {
 | |
|     return function (control, input, output) {
 | |
|         const uid = $(control).data('uid');
 | |
|         const ids = new Set();
 | |
|         for (const entry of Object.values(data.entries)) {
 | |
|             // Skip automation id of this entry, because auto-complete should only suggest the ones that are already available on other entries
 | |
|             if (entry.uid == uid) continue;
 | |
|             if (entry.automationId) {
 | |
|                 ids.add(String(entry.automationId));
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if ('quickReplyApi' in window) {
 | |
|             // @ts-ignore
 | |
|             for (const automationId of window['quickReplyApi'].listAutomationIds()) {
 | |
|                 ids.add(String(automationId));
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         const haystack = Array.from(ids);
 | |
|         haystack.sort((a, b) => a.localeCompare(b));
 | |
|         const needle = input.term.toLowerCase();
 | |
|         const result = haystack.filter(x => x.toLowerCase().includes(needle));
 | |
| 
 | |
|         output(result);
 | |
|     };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Create an autocomplete for the inclusion group.
 | |
|  * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
 | |
|  * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
 | |
|  * @param {object} [options={}] - Optional arguments
 | |
|  * @param {boolean} [options.allowMultiple=false] - Whether to allow multiple comma-separated values
 | |
|  */
 | |
| function createEntryInputAutocomplete(input, callback, { allowMultiple = false } = {}) {
 | |
|     const handleSelect = (event, ui) => {
 | |
|         // Prevent default autocomplete select, so we can manually set the value
 | |
|         event.preventDefault();
 | |
|         if (!allowMultiple) {
 | |
|             $(input).val(ui.item.value).trigger('input').trigger('blur');
 | |
|         } else {
 | |
|             var terms = String($(input).val()).split(/,\s*/);
 | |
|             terms.pop(); // remove the current input
 | |
|             terms.push(ui.item.value); // add the selected item
 | |
|             $(input).val(terms.filter(x => x).join(', ')).trigger('input').trigger('blur');
 | |
|         }
 | |
|     };
 | |
| 
 | |
|     $(input).autocomplete({
 | |
|         minLength: 0,
 | |
|         source: function (request, response) {
 | |
|             if (!allowMultiple) {
 | |
|                 callback(input, request, response);
 | |
|             } else {
 | |
|                 const term = request.term.split(/,\s*/).pop();
 | |
|                 request.term = term;
 | |
|                 callback(input, request, response);
 | |
|             }
 | |
|         },
 | |
|         select: handleSelect,
 | |
|     });
 | |
| 
 | |
|     $(input).on('focus click', function () {
 | |
|         $(input).autocomplete('search', allowMultiple ? String($(input).val()).split(/,\s*/).pop() : $(input).val());
 | |
|     });
 | |
| }
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Duplicate a WI entry by copying all of its properties and assigning a new uid
 | |
|  * @param {*} data - The data of the book
 | |
|  * @param {number} uid - The uid of the entry to copy in this book
 | |
|  * @returns {*} The new WI duplicated entry
 | |
|  */
 | |
| export function duplicateWorldInfoEntry(data, uid) {
 | |
|     if (!data || !('entries' in data) || !data.entries[uid]) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Exclude uid and gather the rest of the properties
 | |
|     const originalData = Object.assign({}, data.entries[uid]);
 | |
|     delete originalData.uid;
 | |
| 
 | |
|     // Create new entry and copy over data
 | |
|     const entry = createWorldInfoEntry(data.name, data);
 | |
|     Object.assign(entry, originalData);
 | |
| 
 | |
|     return entry;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Deletes a WI entry, with a user confirmation dialog
 | |
|  * @param {*[]} data - The data of the book
 | |
|  * @param {number} uid - The uid of the entry to copy in this book
 | |
|  * @param {object} [options={}] - Optional arguments
 | |
|  * @param {boolean} [options.silent=false] - Whether to prompt the user for deletion or just do it
 | |
|  * @returns {Promise<boolean>} Whether the entry deletion was successful
 | |
|  */
 | |
| export async function deleteWorldInfoEntry(data, uid, { silent = false } = {}) {
 | |
|     if (!data || !('entries' in data)) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const confirmation = silent || await Popup.show.confirm(`Delete the entry with UID: ${uid}?`, 'This action is irreversible!');
 | |
|     if (!confirmation) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     delete data.entries[uid];
 | |
|     return true;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Definitions of types for new WI entries
 | |
|  *
 | |
|  * Use `newEntryTemplate` if you just need the template that contains default values
 | |
|  *
 | |
|  * @type {{[key: string]: { default: any, type: string }}}
 | |
|  */
 | |
| export const newWorldInfoEntryDefinition = {
 | |
|     key: { default: [], type: 'array' },
 | |
|     keysecondary: { default: [], type: 'array' },
 | |
|     comment: { default: '', type: 'string' },
 | |
|     content: { default: '', type: 'string' },
 | |
|     constant: { default: false, type: 'boolean' },
 | |
|     vectorized: { default: false, type: 'boolean' },
 | |
|     selective: { default: true, type: 'boolean' },
 | |
|     selectiveLogic: { default: world_info_logic.AND_ANY, type: 'enum' },
 | |
|     addMemo: { default: false, type: 'boolean' },
 | |
|     order: { default: 100, type: 'number' },
 | |
|     position: { default: 0, type: 'number' },
 | |
|     disable: { default: false, type: 'boolean' },
 | |
|     excludeRecursion: { default: false, type: 'boolean' },
 | |
|     preventRecursion: { default: false, type: 'boolean' },
 | |
|     delayUntilRecursion: { default: 0, type: 'number' },
 | |
|     probability: { default: 100, type: 'number' },
 | |
|     useProbability: { default: true, type: 'boolean' },
 | |
|     depth: { default: DEFAULT_DEPTH, type: 'number' },
 | |
|     group: { default: '', type: 'string' },
 | |
|     groupOverride: { default: false, type: 'boolean' },
 | |
|     groupWeight: { default: DEFAULT_WEIGHT, type: 'number' },
 | |
|     scanDepth: { default: null, type: 'number?' },
 | |
|     caseSensitive: { default: null, type: 'boolean?' },
 | |
|     matchWholeWords: { default: null, type: 'boolean?' },
 | |
|     useGroupScoring: { default: null, type: 'boolean?' },
 | |
|     automationId: { default: '', type: 'string' },
 | |
|     role: { default: 0, type: 'enum' },
 | |
|     sticky: { default: null, type: 'number?' },
 | |
|     cooldown: { default: null, type: 'number?' },
 | |
|     delay: { default: null, type: 'number?' },
 | |
| };
 | |
| 
 | |
| export const newWorldInfoEntryTemplate = Object.fromEntries(
 | |
|     Object.entries(newWorldInfoEntryDefinition).map(([key, value]) => [key, value.default]),
 | |
| );
 | |
| 
 | |
| /**
 | |
|  * Creates a new world info entry from template.
 | |
|  * @param {string} _name Name of the WI (unused)
 | |
|  * @param {any} data WI data
 | |
|  * @returns {object | undefined} New entry object or undefined if failed
 | |
|  */
 | |
| export function createWorldInfoEntry(_name, data) {
 | |
|     const newUid = getFreeWorldEntryUid(data);
 | |
| 
 | |
|     if (!Number.isInteger(newUid)) {
 | |
|         console.error('Couldn\'t assign UID to a new entry');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const newEntry = { uid: newUid, ...structuredClone(newWorldInfoEntryTemplate) };
 | |
|     data.entries[newUid] = newEntry;
 | |
| 
 | |
|     return newEntry;
 | |
| }
 | |
| 
 | |
| async function _save(name, data) {
 | |
|     // Prevent double saving if both immediate and debounced save are called
 | |
|     cancelDebounce(saveWorldDebounced);
 | |
| 
 | |
|     await fetch('/api/worldinfo/edit', {
 | |
|         method: 'POST',
 | |
|         headers: getRequestHeaders(),
 | |
|         body: JSON.stringify({ name: name, data: data }),
 | |
|     });
 | |
|     await eventSource.emit(event_types.WORLDINFO_UPDATED, name, data);
 | |
| }
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Saves the world info
 | |
|  *
 | |
|  * This will also refresh the `worldInfoCache`.
 | |
|  * Note, for performance reasons the saved cache will not make a deep clone of the data.
 | |
|  * It is your responsibility to not modify the saved data object after calling this function, or there will be data inconsistencies.
 | |
|  * Call `loadWorldInfoData` or query directly from cache if you need the object again.
 | |
|  *
 | |
|  * @param {string} name - The name of the world info
 | |
|  * @param {any} data - The data to be saved
 | |
|  * @param {boolean} [immediately=false] - Whether to save immediately or use debouncing
 | |
|  * @return {Promise<void>} A promise that resolves when the world info is saved
 | |
|  */
 | |
| export async function saveWorldInfo(name, data, immediately = false) {
 | |
|     if (!name || !data) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Update cache immediately, so any future call can pull from this
 | |
|     worldInfoCache.set(name, data);
 | |
| 
 | |
|     if (immediately) {
 | |
|         return await _save(name, data);
 | |
|     }
 | |
| 
 | |
|     saveWorldDebounced(name, data);
 | |
| }
 | |
| 
 | |
| async function renameWorldInfo(name, data) {
 | |
|     const oldName = name;
 | |
|     const newName = await Popup.show.input('Rename World Info', 'Enter a new name:', oldName);
 | |
| 
 | |
|     if (oldName === newName || !newName) {
 | |
|         console.debug('World info rename cancelled');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const entryPreviouslySelected = selected_world_info.findIndex((e) => e === oldName);
 | |
| 
 | |
|     await saveWorldInfo(newName, data, true);
 | |
|     await deleteWorldInfo(oldName);
 | |
| 
 | |
|     const existingCharLores = world_info.charLore?.filter((e) => e.extraBooks.includes(oldName));
 | |
|     if (existingCharLores && existingCharLores.length > 0) {
 | |
|         existingCharLores.forEach((charLore) => {
 | |
|             const tempCharLore = charLore.extraBooks.filter((e) => e !== oldName);
 | |
|             tempCharLore.push(newName);
 | |
|             charLore.extraBooks = tempCharLore;
 | |
|         });
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| 
 | |
|     if (entryPreviouslySelected !== -1) {
 | |
|         const wiElement = getWIElement(newName);
 | |
|         wiElement.prop('selected', true);
 | |
|         $('#world_info').trigger('change');
 | |
|     }
 | |
| 
 | |
|     const selectedIndex = world_names.indexOf(newName);
 | |
|     if (selectedIndex !== -1) {
 | |
|         $('#world_editor_select').val(selectedIndex).trigger('change');
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Deletes a world info with the given name
 | |
|  *
 | |
|  * @param {string} worldInfoName - The name of the world info to delete
 | |
|  * @returns {Promise<boolean>} A promise that resolves to true if the world info was successfully deleted, false otherwise
 | |
|  */
 | |
| export async function deleteWorldInfo(worldInfoName) {
 | |
|     if (!world_names.includes(worldInfoName)) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     const response = await fetch('/api/worldinfo/delete', {
 | |
|         method: 'POST',
 | |
|         headers: getRequestHeaders(),
 | |
|         body: JSON.stringify({ name: worldInfoName }),
 | |
|     });
 | |
| 
 | |
|     if (!response.ok) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName);
 | |
|     if (existingWorldIndex !== -1) {
 | |
|         selected_world_info.splice(existingWorldIndex, 1);
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| 
 | |
|     await updateWorldInfoList();
 | |
|     $('#world_editor_select').trigger('change');
 | |
| 
 | |
|     if ($('#character_world').val() === worldInfoName) {
 | |
|         $('#character_world').val('').trigger('change');
 | |
|         setWorldInfoButtonClass(undefined, false);
 | |
|         if (menu_type != 'create') {
 | |
|             saveCharacterDebounced();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
| }
 | |
| 
 | |
| export function getFreeWorldEntryUid(data) {
 | |
|     if (!data || !('entries' in data)) {
 | |
|         return null;
 | |
|     }
 | |
| 
 | |
|     const MAX_UID = 1_000_000; // <- should be safe enough :)
 | |
|     for (let uid = 0; uid < MAX_UID; uid++) {
 | |
|         if (uid in data.entries) {
 | |
|             continue;
 | |
|         }
 | |
|         return uid;
 | |
|     }
 | |
| 
 | |
|     return null;
 | |
| }
 | |
| 
 | |
| export function getFreeWorldName() {
 | |
|     const MAX_FREE_NAME = 100_000;
 | |
|     for (let index = 1; index < MAX_FREE_NAME; index++) {
 | |
|         const newName = `New World (${index})`;
 | |
|         if (world_names.includes(newName)) {
 | |
|             continue;
 | |
|         }
 | |
|         return newName;
 | |
|     }
 | |
| 
 | |
|     return undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Creates a new world info/lorebook with the given name.
 | |
|  * Checks if a world with the same name already exists, providing a warning or optionally a user confirmation dialog.
 | |
|  *
 | |
|  * @param {string} worldName - The name of the new world info
 | |
|  * @param {Object} options - Optional parameters
 | |
|  * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world
 | |
|  * @returns {Promise<boolean>} - True if the world info was successfully created, false otherwise
 | |
|  */
 | |
| export async function createNewWorldInfo(worldName, { interactive = false } = {}) {
 | |
|     const worldInfoTemplate = { entries: {} };
 | |
| 
 | |
|     if (!worldName) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     const sanitizedWorldName = await getSanitizedFilename(worldName);
 | |
| 
 | |
|     const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) });
 | |
|     if (!allowed) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     await saveWorldInfo(worldName, worldInfoTemplate, true);
 | |
|     await updateWorldInfoList();
 | |
| 
 | |
|     const selectedIndex = world_names.indexOf(worldName);
 | |
|     if (selectedIndex !== -1) {
 | |
|         $('#world_editor_select').val(selectedIndex).trigger('change');
 | |
|     } else {
 | |
|         hideWorldEditor();
 | |
|     }
 | |
| 
 | |
|     return true;
 | |
| }
 | |
| 
 | |
| async function getCharacterLore() {
 | |
|     const character = characters[this_chid];
 | |
|     const name = character?.name;
 | |
|     /** @type {Set<string>} */
 | |
|     let worldsToSearch = new Set();
 | |
| 
 | |
|     const baseWorldName = character?.data?.extensions?.world;
 | |
|     if (baseWorldName) {
 | |
|         worldsToSearch.add(baseWorldName);
 | |
|     }
 | |
| 
 | |
|     // TODO: Maybe make the utility function not use the window context?
 | |
|     const fileName = getCharaFilename(this_chid);
 | |
|     const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
 | |
|     if (extraCharLore) {
 | |
|         worldsToSearch = new Set([...worldsToSearch, ...extraCharLore.extraBooks]);
 | |
|     }
 | |
| 
 | |
|     if (!worldsToSearch.size) {
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     let entries = [];
 | |
|     for (const worldName of worldsToSearch) {
 | |
|         if (selected_world_info.includes(worldName)) {
 | |
|             console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in global world info! Skipping...`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         if (chat_metadata[METADATA_KEY] === worldName) {
 | |
|             console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in chat lore! Skipping...`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         if (power_user.persona_description_lorebook === worldName) {
 | |
|             console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in persona lore! Skipping...`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         const data = await loadWorldInfo(worldName);
 | |
|         const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : [];
 | |
|         entries = entries.concat(newEntries);
 | |
| 
 | |
|         if (!newEntries.length) {
 | |
|             console.debug(`[WI] Character ${name}'s world ${worldName} could not be found or is empty`);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     console.debug(`[WI] Character ${name}'s lore has ${entries.length} world info entries`, [...worldsToSearch]);
 | |
|     return entries;
 | |
| }
 | |
| 
 | |
| async function getGlobalLore() {
 | |
|     if (!selected_world_info?.length) {
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     let entries = [];
 | |
|     for (const worldName of selected_world_info) {
 | |
|         const data = await loadWorldInfo(worldName);
 | |
|         const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : [];
 | |
|         entries = entries.concat(newEntries);
 | |
|     }
 | |
| 
 | |
|     console.debug(`[WI] Global world info has ${entries.length} entries`, selected_world_info);
 | |
| 
 | |
|     return entries;
 | |
| }
 | |
| 
 | |
| async function getChatLore() {
 | |
|     const chatWorld = chat_metadata[METADATA_KEY];
 | |
| 
 | |
|     if (!chatWorld) {
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     if (selected_world_info.includes(chatWorld)) {
 | |
|         console.debug(`[WI] Chat world ${chatWorld} is already activated in global world info! Skipping...`);
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     const data = await loadWorldInfo(chatWorld);
 | |
|     const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: chatWorld, ...rest })) : [];
 | |
| 
 | |
|     console.debug(`[WI] Chat lore has ${entries.length} entries`, [chatWorld]);
 | |
| 
 | |
|     return entries;
 | |
| }
 | |
| 
 | |
| async function getPersonaLore() {
 | |
|     const chatWorld = chat_metadata[METADATA_KEY];
 | |
|     const personaWorld = power_user.persona_description_lorebook;
 | |
| 
 | |
|     if (!personaWorld) {
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     if (chatWorld === personaWorld) {
 | |
|         console.debug(`[WI] Persona world ${personaWorld} is already activated in chat world! Skipping...`);
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     if (selected_world_info.includes(personaWorld)) {
 | |
|         console.debug(`[WI] Persona world ${personaWorld} is already activated in global world info! Skipping...`);
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     const data = await loadWorldInfo(personaWorld);
 | |
|     const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: personaWorld, ...rest })) : [];
 | |
| 
 | |
|     console.debug(`[WI] Persona lore has ${entries.length} entries`, [personaWorld]);
 | |
| 
 | |
|     return entries;
 | |
| }
 | |
| 
 | |
| export async function getSortedEntries() {
 | |
|     try {
 | |
|         const [
 | |
|             globalLore,
 | |
|             characterLore,
 | |
|             chatLore,
 | |
|             personaLore,
 | |
|         ] = await Promise.all([
 | |
|             getGlobalLore(),
 | |
|             getCharacterLore(),
 | |
|             getChatLore(),
 | |
|             getPersonaLore(),
 | |
|         ]);
 | |
| 
 | |
|         let entries;
 | |
| 
 | |
|         switch (Number(world_info_character_strategy)) {
 | |
|             case world_info_insertion_strategy.evenly:
 | |
|                 entries = [...globalLore, ...characterLore].sort(sortFn);
 | |
|                 break;
 | |
|             case world_info_insertion_strategy.character_first:
 | |
|                 entries = [...characterLore.sort(sortFn), ...globalLore.sort(sortFn)];
 | |
|                 break;
 | |
|             case world_info_insertion_strategy.global_first:
 | |
|                 entries = [...globalLore.sort(sortFn), ...characterLore.sort(sortFn)];
 | |
|                 break;
 | |
|             default:
 | |
|                 console.error('[WI] Unknown WI insertion strategy:', world_info_character_strategy, 'defaulting to evenly');
 | |
|                 entries = [...globalLore, ...characterLore].sort(sortFn);
 | |
|                 break;
 | |
|         }
 | |
| 
 | |
|         // Chat lore always goes first, then persona lore, then the rest
 | |
|         entries = [...chatLore.sort(sortFn), ...personaLore.sort(sortFn), ...entries];
 | |
| 
 | |
|         // Calculate hash and parse decorators. Split maps to preserve old hashes.
 | |
|         entries = entries.map((entry) => {
 | |
|             const [decorators, content] = parseDecorators(entry.content || '');
 | |
|             return { ...entry, decorators, content };
 | |
|         }).map((entry) => {
 | |
|             const hash = getStringHash(JSON.stringify(entry));
 | |
|             return { ...entry, hash };
 | |
|         });
 | |
| 
 | |
|         console.debug(`[WI] Found ${entries.length} world lore entries. Sorted by strategy`, Object.entries(world_info_insertion_strategy).find((x) => x[1] === world_info_character_strategy));
 | |
| 
 | |
|         // Need to deep clone the entries to avoid modifying the cached data
 | |
|         return structuredClone(entries);
 | |
|     }
 | |
|     catch (e) {
 | |
|         console.error(e);
 | |
|         return [];
 | |
|     }
 | |
| }
 | |
| 
 | |
| 
 | |
| /**
 | |
|  * Parse decorators from worldinfo content
 | |
|  * @param {string} content The content to parse
 | |
|  * @returns {[string[],string]} The decorators found in the content and the content without decorators
 | |
| */
 | |
| function parseDecorators(content) {
 | |
|     /**
 | |
|      * Check if the decorator is known
 | |
|      * @param {string} data string to check
 | |
|      * @returns {boolean} true if the decorator is known
 | |
|     */
 | |
|     const isKnownDecorator = (data) => {
 | |
|         if (data.startsWith('@@@')) {
 | |
|             data = data.substring(1);
 | |
|         }
 | |
| 
 | |
|         for (let i = 0; i < KNOWN_DECORATORS.length; i++) {
 | |
|             if (data.startsWith(KNOWN_DECORATORS[i])) {
 | |
|                 return true;
 | |
|             }
 | |
|         }
 | |
|         return false;
 | |
|     };
 | |
| 
 | |
|     if (content.startsWith('@@')) {
 | |
|         let newContent = content;
 | |
|         const splited = content.split('\n');
 | |
|         let decorators = [];
 | |
|         let fallbacked = false;
 | |
| 
 | |
|         for (let i = 0; i < splited.length; i++) {
 | |
|             if (splited[i].startsWith('@@')) {
 | |
|                 if (splited[i].startsWith('@@@') && !fallbacked) {
 | |
|                     continue;
 | |
|                 }
 | |
| 
 | |
|                 if (isKnownDecorator(splited[i])) {
 | |
|                     decorators.push(splited[i].startsWith('@@@') ? splited[i].substring(1) : splited[i]);
 | |
|                     fallbacked = false;
 | |
|                 }
 | |
|                 else {
 | |
|                     fallbacked = true;
 | |
|                 }
 | |
|             } else {
 | |
|                 newContent = splited.slice(i).join('\n');
 | |
|                 break;
 | |
|             }
 | |
|         }
 | |
|         return [decorators, newContent];
 | |
|     }
 | |
| 
 | |
|     return [[], content];
 | |
| 
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Performs a scan on the chat and returns the world info activated.
 | |
|  * @param {string[]} chat The chat messages to scan, in reverse order.
 | |
|  * @param {number} maxContext The maximum context size of the generation.
 | |
|  * @param {boolean} isDryRun Whether to perform a dry run.
 | |
|  * @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated
 | |
|  * @returns {Promise<WIActivated>} The world info activated.
 | |
|  */
 | |
| export async function checkWorldInfo(chat, maxContext, isDryRun) {
 | |
|     const context = getContext();
 | |
|     const buffer = new WorldInfoBuffer(chat);
 | |
| 
 | |
|     console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages)${isDryRun ? ' (DRY RUN)' : ''} ---`);
 | |
| 
 | |
|     // Combine the chat
 | |
| 
 | |
|     // Add the depth or AN if enabled
 | |
|     // Put this code here since otherwise, the chat reference is modified
 | |
|     for (const key of Object.keys(context.extensionPrompts)) {
 | |
|         if (context.extensionPrompts[key]?.scan) {
 | |
|             const prompt = await getExtensionPromptByName(key);
 | |
|             if (prompt) {
 | |
|                 buffer.addInject(prompt);
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /** @type {scan_state} */
 | |
|     let scanState = scan_state.INITIAL;
 | |
|     let token_budget_overflowed = false;
 | |
|     let count = 0;
 | |
|     let allActivatedEntries = new Map();
 | |
|     let failedProbabilityChecks = new Set();
 | |
|     let allActivatedText = '';
 | |
| 
 | |
|     let budget = Math.round(world_info_budget * maxContext / 100) || 1;
 | |
| 
 | |
|     if (world_info_budget_cap > 0 && budget > world_info_budget_cap) {
 | |
|         console.debug(`[WI] Budget ${budget} exceeds cap ${world_info_budget_cap}, using cap`);
 | |
|         budget = world_info_budget_cap;
 | |
|     }
 | |
| 
 | |
|     console.debug(`[WI] Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`);
 | |
|     const sortedEntries = await getSortedEntries();
 | |
|     const timedEffects = new WorldInfoTimedEffects(chat, sortedEntries, isDryRun);
 | |
| 
 | |
|     timedEffects.checkTimedEffects();
 | |
| 
 | |
|     if (sortedEntries.length === 0) {
 | |
|         return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() };
 | |
|     }
 | |
| 
 | |
|     /** @type {number[]} Represents the delay levels for entries that are delayed until recursion */
 | |
|     const availableRecursionDelayLevels = [...new Set(sortedEntries
 | |
|         .filter(entry => entry.delayUntilRecursion)
 | |
|         .map(entry => entry.delayUntilRecursion === true ? 1 : entry.delayUntilRecursion),
 | |
|     )].sort((a, b) => a - b);
 | |
|     // Already preset with the first level
 | |
|     let currentRecursionDelayLevel = availableRecursionDelayLevels.shift() ?? 0;
 | |
|     if (currentRecursionDelayLevel > 0 && availableRecursionDelayLevels.length) {
 | |
|         console.debug('[WI] Preparing first delayed recursion level', currentRecursionDelayLevel, '. Still delayed:', availableRecursionDelayLevels);
 | |
|     }
 | |
| 
 | |
|     console.debug(`[WI] --- SEARCHING ENTRIES (on ${sortedEntries.length} entries) ---`);
 | |
| 
 | |
|     while (scanState) {
 | |
|         //if world_info_max_recursion_steps is non-zero min activations are disabled, and vice versa
 | |
|         if (world_info_max_recursion_steps && world_info_max_recursion_steps <= count) {
 | |
|             console.debug('[WI] Search stopped by reaching max recursion steps', world_info_max_recursion_steps);
 | |
|             break;
 | |
|         }
 | |
| 
 | |
|         // Track how many times the loop has run. May be useful for debugging.
 | |
|         count++;
 | |
| 
 | |
|         console.debug(`[WI] --- LOOP #${count} START ---`);
 | |
|         console.debug('[WI] Scan state', Object.entries(scan_state).find(x => x[1] === scanState));
 | |
| 
 | |
|         // Until decided otherwise, we set the loop to stop scanning after this
 | |
|         let nextScanState = scan_state.NONE;
 | |
| 
 | |
|         // Loop and find all entries that can activate here
 | |
|         let activatedNow = new Set();
 | |
| 
 | |
|         for (let entry of sortedEntries) {
 | |
|             // Logging preparation
 | |
|             let headerLogged = false;
 | |
|             function log(...args) {
 | |
|                 if (!headerLogged) {
 | |
|                     console.debug(`[WI] Entry ${entry.uid}`, `from '${entry.world}' processing`, entry);
 | |
|                     headerLogged = true;
 | |
|                 }
 | |
|                 console.debug(`[WI] Entry ${entry.uid}`, ...args);
 | |
|             }
 | |
| 
 | |
|             // Already processed, considered and then skipped entries should still be skipped
 | |
|             if (failedProbabilityChecks.has(entry) || allActivatedEntries.has(`${entry.world}.${entry.uid}`)) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (entry.disable == true) {
 | |
|                 log('disabled');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Check if this entry applies to the character or if it's excluded
 | |
|             if (entry.characterFilter && entry.characterFilter?.names?.length > 0) {
 | |
|                 const nameIncluded = entry.characterFilter.names.includes(getCharaFilename());
 | |
|                 const filtered = entry.characterFilter.isExclude ? nameIncluded : !nameIncluded;
 | |
| 
 | |
|                 if (filtered) {
 | |
|                     log('filtered out by character');
 | |
|                     continue;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             if (entry.characterFilter && entry.characterFilter?.tags?.length > 0) {
 | |
|                 const tagKey = getTagKeyForEntity(this_chid);
 | |
| 
 | |
|                 if (tagKey) {
 | |
|                     const tagMapEntry = context.tagMap[tagKey];
 | |
| 
 | |
|                     if (Array.isArray(tagMapEntry)) {
 | |
|                         // If tag map intersects with the tag exclusion list, skip
 | |
|                         const includesTag = tagMapEntry.some((tag) => entry.characterFilter.tags.includes(tag));
 | |
|                         const filtered = entry.characterFilter.isExclude ? includesTag : !includesTag;
 | |
| 
 | |
|                         if (filtered) {
 | |
|                             log('filtered out by tag');
 | |
|                             continue;
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             const isSticky = timedEffects.isEffectActive('sticky', entry);
 | |
|             const isCooldown = timedEffects.isEffectActive('cooldown', entry);
 | |
|             const isDelay = timedEffects.isEffectActive('delay', entry);
 | |
| 
 | |
|             if (isDelay) {
 | |
|                 log('suppressed by delay');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (isCooldown && !isSticky) {
 | |
|                 log('suppressed by cooldown');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Only use checks for recursion flags if the scan step was activated by recursion
 | |
|             if (scanState !== scan_state.RECURSION && entry.delayUntilRecursion && !isSticky) {
 | |
|                 log('suppressed by delay until recursion');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (scanState === scan_state.RECURSION && entry.delayUntilRecursion && entry.delayUntilRecursion > currentRecursionDelayLevel && !isSticky) {
 | |
|                 log('suppressed by delay until recursion level', entry.delayUntilRecursion, '. Currently', currentRecursionDelayLevel);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (scanState === scan_state.RECURSION && world_info_recursive && entry.excludeRecursion && !isSticky) {
 | |
|                 log('suppressed by exclude recursion');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (entry.decorators.includes('@@activate')) {
 | |
|                 log('activated by @@activate decorator');
 | |
|                 activatedNow.add(entry);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (entry.decorators.includes('@@dont_activate')) {
 | |
|                 log('suppressed by @@dont_activate decorator');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (buffer.getExternallyActivated(entry)) {
 | |
|                 log('externally activated');
 | |
|                 activatedNow.add(buffer.getExternallyActivated(entry));
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Now do checks for immediate activations
 | |
|             if (entry.constant) {
 | |
|                 log('activated because of constant');
 | |
|                 activatedNow.add(entry);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (isSticky) {
 | |
|                 log('activated because active sticky');
 | |
|                 activatedNow.add(entry);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (!Array.isArray(entry.key) || !entry.key.length) {
 | |
|                 log('has no keys defined, skipped');
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Cache the text to scan before the loop, it won't change its content
 | |
|             const textToScan = buffer.get(entry, scanState);
 | |
| 
 | |
|             // PRIMARY KEYWORDS
 | |
|             let primaryKeyMatch = entry.key.find(key => {
 | |
|                 const substituted = substituteParams(key);
 | |
|                 return substituted && buffer.matchKeys(textToScan, substituted.trim(), entry);
 | |
|             });
 | |
| 
 | |
|             if (!primaryKeyMatch) {
 | |
|                 // Don't write logs for simple no-matches
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             const hasSecondaryKeywords = (
 | |
|                 entry.selective && //all entries are selective now
 | |
|                 Array.isArray(entry.keysecondary) && //always true
 | |
|                 entry.keysecondary.length //ignore empties
 | |
|             );
 | |
| 
 | |
|             if (!hasSecondaryKeywords) {
 | |
|                 // Handle cases where secondary is empty
 | |
|                 log('activated by primary key match', primaryKeyMatch);
 | |
|                 activatedNow.add(entry);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
| 
 | |
|             // SECONDARY KEYWORDS
 | |
|             const selectiveLogic = entry.selectiveLogic ?? 0; // If selectiveLogic isn't found, assume it's AND, only do this once per entry
 | |
|             log('Entry with primary key match', primaryKeyMatch, 'has secondary keywords. Checking with logic logic', Object.entries(world_info_logic).find(x => x[1] === entry.selectiveLogic));
 | |
| 
 | |
|             /** @type {() => boolean} */
 | |
|             function matchSecondaryKeys() {
 | |
|                 let hasAnyMatch = false;
 | |
|                 let hasAllMatch = true;
 | |
|                 for (let keysecondary of entry.keysecondary) {
 | |
|                     const secondarySubstituted = substituteParams(keysecondary);
 | |
|                     const hasSecondaryMatch = secondarySubstituted && buffer.matchKeys(textToScan, secondarySubstituted.trim(), entry);
 | |
| 
 | |
|                     if (hasSecondaryMatch) hasAnyMatch = true;
 | |
|                     if (!hasSecondaryMatch) hasAllMatch = false;
 | |
| 
 | |
|                     // Simplified AND ANY / NOT ALL if statement. (Proper fix for PR#1356 by Bronya)
 | |
|                     // If AND ANY logic and the main checks pass OR if NOT ALL logic and the main checks do not pass
 | |
|                     if (selectiveLogic === world_info_logic.AND_ANY && hasSecondaryMatch) {
 | |
|                         log('activated. (AND ANY) Found match secondary keyword', secondarySubstituted);
 | |
|                         return true;
 | |
|                     }
 | |
|                     if (selectiveLogic === world_info_logic.NOT_ALL && !hasSecondaryMatch) {
 | |
|                         log('activated. (NOT ALL) Found not matching secondary keyword', secondarySubstituted);
 | |
|                         return true;
 | |
|                     }
 | |
|                 }
 | |
| 
 | |
|                 // Handle NOT ANY logic
 | |
|                 if (selectiveLogic === world_info_logic.NOT_ANY && !hasAnyMatch) {
 | |
|                     log('activated. (NOT ANY) No secondary keywords found', entry.keysecondary);
 | |
|                     return true;
 | |
|                 }
 | |
| 
 | |
|                 // Handle AND ALL logic
 | |
|                 if (selectiveLogic === world_info_logic.AND_ALL && hasAllMatch) {
 | |
|                     log('activated. (AND ALL) All secondary keywords found', entry.keysecondary);
 | |
|                     return true;
 | |
|                 }
 | |
| 
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             const matched = matchSecondaryKeys();
 | |
|             if (!matched) {
 | |
|                 log('skipped. Secondary keywords not satisfied', entry.keysecondary);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Success logging was already done inside the function, so just add the entry
 | |
|             activatedNow.add(entry);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         console.debug(`[WI] Search done. Found ${activatedNow.size} possible entries.`);
 | |
| 
 | |
|         // Sort the entries for the probability and the budget limit checks
 | |
|         const newEntries = [...activatedNow]
 | |
|             .sort((a, b) => {
 | |
|                 const isASticky = timedEffects.isEffectActive('sticky', a) ? 1 : 0;
 | |
|                 const isBSticky = timedEffects.isEffectActive('sticky', b) ? 1 : 0;
 | |
|                 return isBSticky - isASticky || sortedEntries.indexOf(a) - sortedEntries.indexOf(b);
 | |
|             });
 | |
| 
 | |
| 
 | |
|         let newContent = '';
 | |
|         const textToScanTokens = await getTokenCountAsync(allActivatedText);
 | |
| 
 | |
|         filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState, timedEffects);
 | |
| 
 | |
|         console.debug('[WI] --- PROBABILITY CHECKS ---');
 | |
|         !newEntries.length && console.debug('[WI] No probability checks to do');
 | |
| 
 | |
|         for (const entry of newEntries) {
 | |
|             function verifyProbability() {
 | |
|                 // If we don't need to roll, it's always true
 | |
|                 if (!entry.useProbability || entry.probability === 100) {
 | |
|                     console.debug(`WI entry ${entry.uid} does not use probability`);
 | |
|                     return true;
 | |
|                 }
 | |
| 
 | |
|                 const isSticky = timedEffects.isEffectActive('sticky', entry);
 | |
|                 if (isSticky) {
 | |
|                     console.debug(`WI entry ${entry.uid} is sticky, does not need to re-roll probability`);
 | |
|                     return true;
 | |
|                 }
 | |
| 
 | |
|                 const rollValue = Math.random() * 100;
 | |
|                 if (rollValue <= entry.probability) {
 | |
|                     console.debug(`WI entry ${entry.uid} passed probability check of ${entry.probability}%`);
 | |
|                     return true;
 | |
|                 }
 | |
| 
 | |
|                 failedProbabilityChecks.add(entry);
 | |
|                 return false;
 | |
|             }
 | |
| 
 | |
|             const success = verifyProbability();
 | |
|             if (!success) {
 | |
|                 console.debug(`WI entry ${entry.uid} failed probability check, removing from activated entries`, entry);
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             // Substitute macros inline, for both this checking and also future processing
 | |
|             entry.content = substituteParams(entry.content);
 | |
|             newContent += `${entry.content}\n`;
 | |
| 
 | |
|             if ((textToScanTokens + (await getTokenCountAsync(newContent))) >= budget) {
 | |
|                 console.debug('[WI] --- BUDGET OVERFLOW CHECK ---');
 | |
|                 if (world_info_overflow_alert) {
 | |
|                     console.warn(`[WI] budget of ${budget} reached, stopping after ${allActivatedEntries.size} entries`);
 | |
|                     toastr.warning(`World info budget reached after ${allActivatedEntries.size} entries.`, 'World Info');
 | |
|                 } else {
 | |
|                     console.debug(`[WI] budget of ${budget} reached, stopping after ${allActivatedEntries.size} entries`);
 | |
|                 }
 | |
|                 token_budget_overflowed = true;
 | |
|                 break;
 | |
|             }
 | |
| 
 | |
|             allActivatedEntries.set(`${entry.world}.${entry.uid}`, entry);
 | |
|             console.debug(`[WI] Entry ${entry.uid} activation successful, adding to prompt`, entry);
 | |
|         }
 | |
| 
 | |
|         const successfulNewEntries = newEntries.filter(x => !failedProbabilityChecks.has(x));
 | |
|         const successfulNewEntriesForRecursion = successfulNewEntries.filter(x => !x.preventRecursion);
 | |
| 
 | |
|         console.debug(`[WI] --- LOOP #${count} RESULT ---`);
 | |
|         if (!newEntries.length) {
 | |
|             console.debug('[WI] No new entries activated.');
 | |
|         } else if (!successfulNewEntries.length) {
 | |
|             console.debug('[WI] Probability checks failed for all activated entries. No new entries activated.');
 | |
|         } else {
 | |
|             console.debug(`[WI] Successfully activated ${successfulNewEntries.length} new entries to prompt. ${allActivatedEntries.size} total entries activated.`, successfulNewEntries);
 | |
|         }
 | |
| 
 | |
|         function logNextState(...args) {
 | |
|             args.length && console.debug(args.shift(), ...args);
 | |
|             console.debug('[WI] Setting scan state', Object.entries(scan_state).find(x => x[1] === scanState));
 | |
|         }
 | |
| 
 | |
|         // After processing and rolling entries is done, see if we should continue with normal recursion
 | |
|         if (world_info_recursive && !token_budget_overflowed && successfulNewEntriesForRecursion.length) {
 | |
|             nextScanState = scan_state.RECURSION;
 | |
|             logNextState('[WI] Found', successfulNewEntriesForRecursion.length, 'new entries for recursion');
 | |
|         }
 | |
| 
 | |
|         // If we are inside min activations scan, and we have recursive buffer, we should do a recursive scan before increasing the buffer again
 | |
|         // There might be recurse-trigger-able entries that match the buffer, so we need to check that
 | |
|         if (world_info_recursive && !token_budget_overflowed && scanState === scan_state.MIN_ACTIVATIONS && buffer.hasRecurse()) {
 | |
|             nextScanState = scan_state.RECURSION;
 | |
|             logNextState('[WI] Min Activations run done, whill will always be followed by a recursive scan');
 | |
|         }
 | |
| 
 | |
|         // If scanning is planned to stop, but min activations is set and not satisfied, check if we should continue
 | |
|         const minActivationsNotSatisfied = world_info_min_activations > 0 && (allActivatedEntries.size < world_info_min_activations);
 | |
|         if (!nextScanState && !token_budget_overflowed && minActivationsNotSatisfied) {
 | |
|             console.debug('[WI] --- MIN ACTIVATIONS CHECK ---');
 | |
| 
 | |
|             let over_max = (
 | |
|                 world_info_min_activations_depth_max > 0 &&
 | |
|                 buffer.getDepth() > world_info_min_activations_depth_max
 | |
|             ) || (buffer.getDepth() > chat.length);
 | |
| 
 | |
|             if (!over_max) {
 | |
|                 nextScanState = scan_state.MIN_ACTIVATIONS; // loop
 | |
|                 logNextState(`[WI] Min activations not reached (${allActivatedEntries.size}/${world_info_min_activations}), advancing depth to ${buffer.getDepth() + 1}, starting another scan`);
 | |
|                 buffer.advanceScan();
 | |
|             } else {
 | |
|                 console.debug(`[WI] Min activations not reached (${allActivatedEntries.size}/${world_info_min_activations}), but reached on of depth. Stopping`);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // If the scan is done, but we still have open "delay until recursion" levels, we should continue with the next one
 | |
|         if (nextScanState === scan_state.NONE && availableRecursionDelayLevels.length) {
 | |
|             nextScanState = scan_state.RECURSION;
 | |
|             currentRecursionDelayLevel = availableRecursionDelayLevels.shift();
 | |
|             logNextState('[WI] Open delayed recursion levels left. Preparing next delayed recursion level', currentRecursionDelayLevel, '. Still delayed:', availableRecursionDelayLevels);
 | |
|         }
 | |
| 
 | |
|         // Final check if we should really continue scan, and extend the current WI recurse buffer
 | |
|         scanState = nextScanState;
 | |
|         if (scanState) {
 | |
|             const text = successfulNewEntriesForRecursion
 | |
|                 .map(x => x.content).join('\n');
 | |
|             buffer.addRecurse(text);
 | |
|             allActivatedText = (text + '\n' + allActivatedText);
 | |
|         } else {
 | |
|             logNextState('[WI] Scan done. No new entries to prompt. Stopping.');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     console.debug('[WI] --- BUILDING PROMPT ---');
 | |
| 
 | |
|     // Forward-sorted list of entries for joining
 | |
|     const WIBeforeEntries = [];
 | |
|     const WIAfterEntries = [];
 | |
|     const EMEntries = [];
 | |
|     const ANTopEntries = [];
 | |
|     const ANBottomEntries = [];
 | |
|     const WIDepthEntries = [];
 | |
| 
 | |
|     // Appends from insertion order 999 to 1. Use unshift for this purpose
 | |
|     // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays
 | |
|     [...allActivatedEntries.values()].sort(sortFn).forEach((entry) => {
 | |
|         const regexDepth = entry.position === world_info_position.atDepth ? (entry.depth ?? DEFAULT_DEPTH) : null;
 | |
|         const content = getRegexedString(entry.content, regex_placement.WORLD_INFO, { depth: regexDepth, isMarkdown: false, isPrompt: true });
 | |
| 
 | |
|         if (!content) {
 | |
|             console.debug(`[WI] Entry ${entry.uid}`, 'skipped adding to prompt due to empty content', entry);
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         switch (entry.position) {
 | |
|             case world_info_position.before:
 | |
|                 WIBeforeEntries.unshift(content);
 | |
|                 break;
 | |
|             case world_info_position.after:
 | |
|                 WIAfterEntries.unshift(content);
 | |
|                 break;
 | |
|             case world_info_position.EMTop:
 | |
|                 EMEntries.unshift(
 | |
|                     { position: wi_anchor_position.before, content: content },
 | |
|                 );
 | |
|                 break;
 | |
|             case world_info_position.EMBottom:
 | |
|                 EMEntries.unshift(
 | |
|                     { position: wi_anchor_position.after, content: content },
 | |
|                 );
 | |
|                 break;
 | |
|             case world_info_position.ANTop:
 | |
|                 ANTopEntries.unshift(content);
 | |
|                 break;
 | |
|             case world_info_position.ANBottom:
 | |
|                 ANBottomEntries.unshift(content);
 | |
|                 break;
 | |
|             case world_info_position.atDepth: {
 | |
|                 const existingDepthIndex = WIDepthEntries.findIndex((e) => e.depth === (entry.depth ?? DEFAULT_DEPTH) && e.role === (entry.role ?? extension_prompt_roles.SYSTEM));
 | |
|                 if (existingDepthIndex !== -1) {
 | |
|                     WIDepthEntries[existingDepthIndex].entries.unshift(content);
 | |
|                 } else {
 | |
|                     WIDepthEntries.push({
 | |
|                         depth: entry.depth,
 | |
|                         entries: [content],
 | |
|                         role: entry.role ?? extension_prompt_roles.SYSTEM,
 | |
|                     });
 | |
|                 }
 | |
|                 break;
 | |
|             }
 | |
|             default:
 | |
|                 break;
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     const worldInfoBefore = WIBeforeEntries.length ? WIBeforeEntries.join('\n') : '';
 | |
|     const worldInfoAfter = WIAfterEntries.length ? WIAfterEntries.join('\n') : '';
 | |
| 
 | |
|     if (shouldWIAddPrompt) {
 | |
|         const originalAN = context.extensionPrompts[NOTE_MODULE_NAME].value;
 | |
|         const ANWithWI = `${ANTopEntries.join('\n')}\n${originalAN}\n${ANBottomEntries.join('\n')}`.replace(/(^\n)|(\n$)/g, '');
 | |
|         context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
 | |
|     }
 | |
| 
 | |
|     timedEffects.setTimedEffects(Array.from(allActivatedEntries.values()));
 | |
|     buffer.resetExternalEffects();
 | |
|     timedEffects.cleanUp();
 | |
| 
 | |
|     console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));
 | |
|     console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`);
 | |
| 
 | |
|     return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Only leaves entries with the highest key matching score in each group.
 | |
|  * @param {Record<string, WIScanEntry[]>} groups The groups to filter
 | |
|  * @param {WorldInfoBuffer} buffer The buffer to use for scoring
 | |
|  * @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry
 | |
|  * @param {number} scanState The current scan state
 | |
|  * @param {Map<string, boolean>} hasStickyMap The sticky entries map
 | |
|  */
 | |
| function filterGroupsByScoring(groups, buffer, removeEntry, scanState, hasStickyMap) {
 | |
|     for (const [key, group] of Object.entries(groups)) {
 | |
|         // Group scoring is disabled both globally and for the group entries
 | |
|         if (!world_info_use_group_scoring && !group.some(x => x.useGroupScoring)) {
 | |
|             console.debug(`[WI] Skipping group scoring for group '${key}'`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         // If the group has any sticky entries, the rest are already removed by the timed effects filter
 | |
|         const hasAnySticky = hasStickyMap.get(key);
 | |
|         if (hasAnySticky) {
 | |
|             console.debug(`[WI] Skipping group scoring check, group '${key}' has sticky entries`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         const scores = group.map(entry => buffer.getScore(entry, scanState));
 | |
|         const maxScore = Math.max(...scores);
 | |
|         console.debug(`[WI] Group '${key}' max score:`, maxScore);
 | |
|         //console.table(group.map((entry, i) => ({ uid: entry.uid, key: JSON.stringify(entry.key), score: scores[i] })));
 | |
| 
 | |
|         for (let i = 0; i < group.length; i++) {
 | |
|             const isScored = group[i].useGroupScoring ?? world_info_use_group_scoring;
 | |
| 
 | |
|             if (!isScored) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (scores[i] < maxScore) {
 | |
|                 console.debug(`[WI] Entry ${group[i].uid}`, `removed as score loser from inclusion group '${key}'`, group[i]);
 | |
|                 removeEntry(group[i]);
 | |
|                 group.splice(i, 1);
 | |
|                 scores.splice(i, 1);
 | |
|                 i--;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Removes entries on cooldown and forces sticky entries as winners.
 | |
|  * @param {Record<string, WIScanEntry[]>} groups The groups to filter
 | |
|  * @param {WorldInfoTimedEffects} timedEffects The timed effects to use
 | |
|  * @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry
 | |
|  * @returns {Map<string, boolean>} If any sticky entries were found
 | |
|  */
 | |
| function filterGroupsByTimedEffects(groups, timedEffects, removeEntry) {
 | |
|     /** @type {Map<string, boolean>} */
 | |
|     const hasStickyMap = new Map();
 | |
| 
 | |
|     for (const [key, group] of Object.entries(groups)) {
 | |
|         hasStickyMap.set(key, false);
 | |
| 
 | |
|         // If the group has any sticky entries, leave only the sticky entries
 | |
|         const stickyEntries = group.filter(x => timedEffects.isEffectActive('sticky', x));
 | |
|         if (stickyEntries.length) {
 | |
|             for (const entry of group) {
 | |
|                 if (stickyEntries.includes(entry)) {
 | |
|                     continue;
 | |
|                 }
 | |
| 
 | |
|                 console.debug(`[WI] Entry ${entry.uid}`, `removed as a non-sticky loser from inclusion group '${key}'`, entry);
 | |
|                 removeEntry(entry);
 | |
|             }
 | |
| 
 | |
|             hasStickyMap.set(key, true);
 | |
|         }
 | |
| 
 | |
|         // It should not be possible for an entry on cooldown/delay to event get into the grouping phase but @Wolfsblvt told me to leave it here.
 | |
|         const cooldownEntries = group.filter(x => timedEffects.isEffectActive('cooldown', x));
 | |
|         if (cooldownEntries.length) {
 | |
|             console.debug(`[WI] Inclusion group '${key}' has entries on cooldown. They will be removed.`, cooldownEntries);
 | |
|             for (const entry of cooldownEntries) {
 | |
|                 removeEntry(entry);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         const delayEntries = group.filter(x => timedEffects.isEffectActive('delay', x));
 | |
|         if (delayEntries.length) {
 | |
|             console.debug(`[WI] Inclusion group '${key}' has entries with delay. They will be removed.`, delayEntries);
 | |
|             for (const entry of delayEntries) {
 | |
|                 removeEntry(entry);
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     return hasStickyMap;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Filters entries by inclusion groups.
 | |
|  * @param {object[]} newEntries Entries activated on current recursion level
 | |
|  * @param {Map<string, object>} allActivatedEntries Map of all activated entries
 | |
|  * @param {WorldInfoBuffer} buffer The buffer to use for scanning
 | |
|  * @param {number} scanState The current scan state
 | |
|  * @param {WorldInfoTimedEffects} timedEffects The timed effects currently active
 | |
|  */
 | |
| function filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState, timedEffects) {
 | |
|     console.debug('[WI] --- INCLUSION GROUP CHECKS ---');
 | |
| 
 | |
|     const grouped = newEntries.filter(x => x.group).reduce((acc, item) => {
 | |
|         item.group.split(/,\s*/).filter(x => x).forEach(group => {
 | |
|             if (!acc[group]) {
 | |
|                 acc[group] = [];
 | |
|             }
 | |
|             acc[group].push(item);
 | |
|         });
 | |
|         return acc;
 | |
|     }, {});
 | |
| 
 | |
|     if (Object.keys(grouped).length === 0) {
 | |
|         console.debug('[WI] No inclusion groups found');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const removeEntry = (entry) => newEntries.splice(newEntries.indexOf(entry), 1);
 | |
|     function removeAllBut(group, chosen, logging = true) {
 | |
|         for (const entry of group) {
 | |
|             if (entry === chosen) {
 | |
|                 continue;
 | |
|             }
 | |
| 
 | |
|             if (logging) console.debug(`[WI] Entry ${entry.uid}`, `removed as loser from inclusion group '${entry.group}'`, entry);
 | |
|             removeEntry(entry);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     const hasStickyMap = filterGroupsByTimedEffects(grouped, timedEffects, removeEntry);
 | |
|     filterGroupsByScoring(grouped, buffer, removeEntry, scanState, hasStickyMap);
 | |
| 
 | |
|     for (const [key, group] of Object.entries(grouped)) {
 | |
|         console.debug(`[WI] Checking inclusion group '${key}' with ${group.length} entries`, group);
 | |
| 
 | |
|         // If the group has any sticky entries, the rest are already removed by the timed effects filter
 | |
|         const hasAnySticky = hasStickyMap.get(key);
 | |
|         if (hasAnySticky) {
 | |
|             console.debug(`[WI] Skipping inclusion group check, group '${key}' has sticky entries`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         if (Array.from(allActivatedEntries.values()).some(x => x.group === key)) {
 | |
|             console.debug(`[WI] Skipping inclusion group check, group '${key}' was already activated`);
 | |
|             // We need to forcefully deactivate all other entries in the group
 | |
|             removeAllBut(group, null, false);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         if (!Array.isArray(group) || group.length <= 1) {
 | |
|             console.debug('[WI] Skipping inclusion group check, only one entry');
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         // Check for group prio
 | |
|         const prios = group.filter(x => x.groupOverride).sort(sortFn);
 | |
|         if (prios.length) {
 | |
|             console.debug(`[WI] Entry ${prios[0].uid}`, `activated as prio winner from inclusion group '${key}'`, prios[0]);
 | |
|             removeAllBut(group, prios[0]);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         // Do weighted random using entry's weight
 | |
|         const totalWeight = group.reduce((acc, item) => acc + (item.groupWeight ?? DEFAULT_WEIGHT), 0);
 | |
|         const rollValue = Math.random() * totalWeight;
 | |
|         let currentWeight = 0;
 | |
|         let winner = null;
 | |
| 
 | |
|         for (const entry of group) {
 | |
|             currentWeight += (entry.groupWeight ?? DEFAULT_WEIGHT);
 | |
| 
 | |
|             if (rollValue <= currentWeight) {
 | |
|                 console.debug(`[WI] Entry ${entry.uid}`, `activated as roll winner from inclusion group '${key}'`, entry);
 | |
|                 winner = entry;
 | |
|                 break;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         if (!winner) {
 | |
|             console.debug(`[WI] Failed to activate inclusion group '${key}', no winner found`);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         // Remove every group item from newEntries but the winner
 | |
|         removeAllBut(group, winner);
 | |
|     }
 | |
| }
 | |
| 
 | |
| function convertAgnaiMemoryBook(inputObj) {
 | |
|     const outputObj = { entries: {} };
 | |
| 
 | |
|     inputObj.entries.forEach((entry, index) => {
 | |
|         outputObj.entries[index] = {
 | |
|             ...newWorldInfoEntryTemplate,
 | |
|             uid: index,
 | |
|             key: entry.keywords,
 | |
|             keysecondary: [],
 | |
|             comment: entry.name,
 | |
|             content: entry.entry,
 | |
|             constant: false,
 | |
|             selective: false,
 | |
|             vectorized: false,
 | |
|             selectiveLogic: world_info_logic.AND_ANY,
 | |
|             order: entry.weight,
 | |
|             position: 0,
 | |
|             disable: !entry.enabled,
 | |
|             addMemo: !!entry.name,
 | |
|             excludeRecursion: false,
 | |
|             delayUntilRecursion: false,
 | |
|             displayIndex: index,
 | |
|             probability: 100,
 | |
|             useProbability: true,
 | |
|             group: '',
 | |
|             groupOverride: false,
 | |
|             groupWeight: DEFAULT_WEIGHT,
 | |
|             scanDepth: null,
 | |
|             caseSensitive: null,
 | |
|             matchWholeWords: null,
 | |
|             useGroupScoring: null,
 | |
|             automationId: '',
 | |
|             role: extension_prompt_roles.SYSTEM,
 | |
|             sticky: null,
 | |
|             cooldown: null,
 | |
|             delay: null,
 | |
|         };
 | |
|     });
 | |
| 
 | |
|     return outputObj;
 | |
| }
 | |
| 
 | |
| function convertRisuLorebook(inputObj) {
 | |
|     const outputObj = { entries: {} };
 | |
| 
 | |
|     inputObj.data.forEach((entry, index) => {
 | |
|         outputObj.entries[index] = {
 | |
|             ...newWorldInfoEntryTemplate,
 | |
|             uid: index,
 | |
|             key: entry.key.split(',').map(x => x.trim()),
 | |
|             keysecondary: entry.secondkey ? entry.secondkey.split(',').map(x => x.trim()) : [],
 | |
|             comment: entry.comment,
 | |
|             content: entry.content,
 | |
|             constant: entry.alwaysActive,
 | |
|             selective: entry.selective,
 | |
|             vectorized: false,
 | |
|             selectiveLogic: world_info_logic.AND_ANY,
 | |
|             order: entry.insertorder,
 | |
|             position: world_info_position.before,
 | |
|             disable: false,
 | |
|             addMemo: true,
 | |
|             excludeRecursion: false,
 | |
|             delayUntilRecursion: false,
 | |
|             displayIndex: index,
 | |
|             probability: entry.activationPercent ?? 100,
 | |
|             useProbability: entry.activationPercent ?? true,
 | |
|             group: '',
 | |
|             groupOverride: false,
 | |
|             groupWeight: DEFAULT_WEIGHT,
 | |
|             scanDepth: null,
 | |
|             caseSensitive: null,
 | |
|             matchWholeWords: null,
 | |
|             useGroupScoring: null,
 | |
|             automationId: '',
 | |
|             role: extension_prompt_roles.SYSTEM,
 | |
|             sticky: null,
 | |
|             cooldown: null,
 | |
|             delay: null,
 | |
|         };
 | |
|     });
 | |
| 
 | |
|     return outputObj;
 | |
| }
 | |
| 
 | |
| function convertNovelLorebook(inputObj) {
 | |
|     const outputObj = {
 | |
|         entries: {},
 | |
|     };
 | |
| 
 | |
|     inputObj.entries.forEach((entry, index) => {
 | |
|         const displayName = entry.displayName;
 | |
|         const addMemo = displayName !== undefined && displayName.trim() !== '';
 | |
| 
 | |
|         outputObj.entries[index] = {
 | |
|             ...newWorldInfoEntryTemplate,
 | |
|             uid: index,
 | |
|             key: entry.keys,
 | |
|             keysecondary: [],
 | |
|             comment: displayName || '',
 | |
|             content: entry.text,
 | |
|             constant: false,
 | |
|             selective: false,
 | |
|             vectorized: false,
 | |
|             selectiveLogic: world_info_logic.AND_ANY,
 | |
|             order: entry.contextConfig?.budgetPriority ?? 0,
 | |
|             position: 0,
 | |
|             disable: !entry.enabled,
 | |
|             addMemo: addMemo,
 | |
|             excludeRecursion: false,
 | |
|             delayUntilRecursion: false,
 | |
|             displayIndex: index,
 | |
|             probability: 100,
 | |
|             useProbability: true,
 | |
|             group: '',
 | |
|             groupOverride: false,
 | |
|             groupWeight: DEFAULT_WEIGHT,
 | |
|             scanDepth: null,
 | |
|             caseSensitive: null,
 | |
|             matchWholeWords: null,
 | |
|             useGroupScoring: null,
 | |
|             automationId: '',
 | |
|             role: extension_prompt_roles.SYSTEM,
 | |
|             sticky: null,
 | |
|             cooldown: null,
 | |
|             delay: null,
 | |
|         };
 | |
|     });
 | |
| 
 | |
|     return outputObj;
 | |
| }
 | |
| 
 | |
| export function convertCharacterBook(characterBook) {
 | |
|     const result = { entries: {}, originalData: characterBook };
 | |
| 
 | |
|     characterBook.entries.forEach((entry, index) => {
 | |
|         // Not in the spec, but this is needed to find the entry in the original data
 | |
|         if (entry.id === undefined) {
 | |
|             entry.id = index;
 | |
|         }
 | |
| 
 | |
|         result.entries[entry.id] = {
 | |
|             ...newWorldInfoEntryTemplate,
 | |
|             uid: entry.id,
 | |
|             key: entry.keys,
 | |
|             keysecondary: entry.secondary_keys || [],
 | |
|             comment: entry.comment || '',
 | |
|             content: entry.content,
 | |
|             constant: entry.constant || false,
 | |
|             selective: entry.selective || false,
 | |
|             order: entry.insertion_order,
 | |
|             position: entry.extensions?.position ?? (entry.position === 'before_char' ? world_info_position.before : world_info_position.after),
 | |
|             excludeRecursion: entry.extensions?.exclude_recursion ?? false,
 | |
|             preventRecursion: entry.extensions?.prevent_recursion ?? false,
 | |
|             delayUntilRecursion: entry.extensions?.delay_until_recursion ?? false,
 | |
|             disable: !entry.enabled,
 | |
|             addMemo: !!entry.comment,
 | |
|             displayIndex: entry.extensions?.display_index ?? index,
 | |
|             probability: entry.extensions?.probability ?? 100,
 | |
|             useProbability: entry.extensions?.useProbability ?? true,
 | |
|             depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
 | |
|             selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
 | |
|             group: entry.extensions?.group ?? '',
 | |
|             groupOverride: entry.extensions?.group_override ?? false,
 | |
|             groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,
 | |
|             scanDepth: entry.extensions?.scan_depth ?? null,
 | |
|             caseSensitive: entry.extensions?.case_sensitive ?? null,
 | |
|             matchWholeWords: entry.extensions?.match_whole_words ?? null,
 | |
|             useGroupScoring: entry.extensions?.use_group_scoring ?? null,
 | |
|             automationId: entry.extensions?.automation_id ?? '',
 | |
|             role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
 | |
|             vectorized: entry.extensions?.vectorized ?? false,
 | |
|             sticky: entry.extensions?.sticky ?? null,
 | |
|             cooldown: entry.extensions?.cooldown ?? null,
 | |
|             delay: entry.extensions?.delay ?? null,
 | |
|             extensions: entry.extensions ?? {},
 | |
|         };
 | |
|     });
 | |
| 
 | |
|     return result;
 | |
| }
 | |
| 
 | |
| export function setWorldInfoButtonClass(chid, forceValue = undefined) {
 | |
|     if (forceValue !== undefined) {
 | |
|         $('#set_character_world, #world_button').toggleClass('world_set', forceValue);
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     if (!chid) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const world = characters[chid]?.data?.extensions?.world;
 | |
|     const worldSet = Boolean(world && world_names.includes(world));
 | |
|     $('#set_character_world, #world_button').toggleClass('world_set', worldSet);
 | |
| }
 | |
| 
 | |
| export function checkEmbeddedWorld(chid) {
 | |
|     $('#import_character_info').hide();
 | |
| 
 | |
|     if (chid === undefined) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     if (characters[chid]?.data?.character_book) {
 | |
|         $('#import_character_info').data('data-chid', chid).show();
 | |
| 
 | |
|         // Only show the alert once per character
 | |
|         const checkKey = `AlertWI_${characters[chid].avatar}`;
 | |
|         const worldName = characters[chid]?.data?.extensions?.world;
 | |
|         if (!accountStorage.getItem(checkKey) && (!worldName || !world_names.includes(worldName))) {
 | |
|             accountStorage.setItem(checkKey, 'true');
 | |
| 
 | |
|             if (power_user.world_import_dialog) {
 | |
|                 const html = `<h3>This character has an embedded World/Lorebook.</h3>
 | |
|                 <h3>Would you like to import it now?</h3>
 | |
|                 <div class="m-b-1">If you want to import it later, select "Import Card Lore" in the "More..." dropdown menu on the character panel.</div>`;
 | |
|                 const checkResult = (result) => {
 | |
|                     if (result) {
 | |
|                         importEmbeddedWorldInfo(true);
 | |
|                     }
 | |
|                 };
 | |
|                 callGenericPopup(html, POPUP_TYPE.CONFIRM, '', { okButton: 'Yes' }).then(checkResult);
 | |
|             }
 | |
|             else {
 | |
|                 toastr.info(
 | |
|                     'To import and use it, select "Import Card Lore" in the "More..." dropdown menu on the character panel.',
 | |
|                     `${characters[chid].name} has an embedded World/Lorebook`,
 | |
|                     { timeOut: 5000, extendedTimeOut: 10000, positionClass: 'toast-top-center' },
 | |
|                 );
 | |
|             }
 | |
|         }
 | |
|         return true;
 | |
|     }
 | |
| 
 | |
|     return false;
 | |
| }
 | |
| 
 | |
| export async function importEmbeddedWorldInfo(skipPopup = false) {
 | |
|     const chid = $('#import_character_info').data('data-chid');
 | |
| 
 | |
|     if (chid === undefined) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const bookName = characters[chid]?.data?.character_book?.name || `${characters[chid]?.name}'s Lorebook`;
 | |
| 
 | |
|     if (!skipPopup) {
 | |
|         const confirmation = await Popup.show.confirm(t`Are you sure you want to import '${bookName}'?`, world_names.includes(bookName) ? t`It will overwrite the World/Lorebook with the same name.` : '');
 | |
|         if (!confirmation) {
 | |
|             return;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     const convertedBook = convertCharacterBook(characters[chid].data.character_book);
 | |
| 
 | |
|     await saveWorldInfo(bookName, convertedBook, true);
 | |
|     await updateWorldInfoList();
 | |
|     $('#character_world').val(bookName).trigger('change');
 | |
| 
 | |
|     toastr.success(t`The world '${bookName}' has been imported and linked to the character successfully.`, t`World/Lorebook imported`);
 | |
| 
 | |
|     const newIndex = world_names.indexOf(bookName);
 | |
|     if (newIndex >= 0) {
 | |
|         //show&draw the WI panel before..
 | |
|         $('#WIDrawerIcon').trigger('click');
 | |
|         //..auto-opening the new imported WI
 | |
|         $('#world_editor_select').val(newIndex).trigger('change');
 | |
|     }
 | |
| 
 | |
|     setWorldInfoButtonClass(chid, true);
 | |
| }
 | |
| 
 | |
| export function onWorldInfoChange(args, text) {
 | |
|     if (args !== '__notSlashCommand__') { // if it's a slash command
 | |
|         const silent = isTrueBoolean(args.silent);
 | |
|         if (text.trim() !== '') { // and args are provided
 | |
|             const slashInputSplitText = text.trim().toLowerCase().split(',');
 | |
| 
 | |
|             slashInputSplitText.forEach((worldName) => {
 | |
|                 const wiElement = getWIElement(worldName);
 | |
|                 if (wiElement.length > 0) {
 | |
|                     const name = wiElement.text();
 | |
|                     switch (args.state) {
 | |
|                         case 'off': {
 | |
|                             if (selected_world_info.includes(name)) {
 | |
|                                 selected_world_info.splice(selected_world_info.indexOf(name), 1);
 | |
|                                 wiElement.prop('selected', false);
 | |
|                                 if (!silent) toastr.success(t`Deactivated world: ${name}`);
 | |
|                             } else {
 | |
|                                 if (!silent) toastr.error(t`World was not active: ${name}`);
 | |
|                             }
 | |
|                             break;
 | |
|                         }
 | |
|                         case 'toggle': {
 | |
|                             if (selected_world_info.includes(name)) {
 | |
|                                 selected_world_info.splice(selected_world_info.indexOf(name), 1);
 | |
|                                 wiElement.prop('selected', false);
 | |
|                                 if (!silent) toastr.success(t`Deactivated world: ${name}`);
 | |
|                             } else {
 | |
|                                 selected_world_info.push(name);
 | |
|                                 wiElement.prop('selected', true);
 | |
|                                 if (!silent) toastr.success(t`Activated world: ${name}`);
 | |
|                             }
 | |
|                             break;
 | |
|                         }
 | |
|                         case 'on':
 | |
|                         default: {
 | |
|                             selected_world_info.push(name);
 | |
|                             wiElement.prop('selected', true);
 | |
|                             if (!silent) toastr.success(t`Activated world: ${name}`);
 | |
|                         }
 | |
|                     }
 | |
|                 } else {
 | |
|                     if (!silent) toastr.error(t`No world found named: ${worldName}`);
 | |
|                 }
 | |
|             });
 | |
|             $('#world_info').trigger('change');
 | |
|         } else { // if no args, unset all worlds
 | |
|             if (!silent) toastr.success(t`Deactivated all worlds`);
 | |
|             selected_world_info = [];
 | |
|             $('#world_info').val(null).trigger('change');
 | |
|         }
 | |
|     } else { //if it's a pointer selection
 | |
|         const tempWorldInfo = [];
 | |
|         const val = $('#world_info').val();
 | |
|         const selectedWorlds = (Array.isArray(val) ? val : [val]).map((e) => Number(e)).filter((e) => !isNaN(e));
 | |
|         if (selectedWorlds.length > 0) {
 | |
|             selectedWorlds.forEach((worldIndex) => {
 | |
|                 const existingWorldName = world_names[worldIndex];
 | |
|                 if (existingWorldName) {
 | |
|                     tempWorldInfo.push(existingWorldName);
 | |
|                 } else {
 | |
|                     const wiElement = getWIElement(existingWorldName);
 | |
|                     wiElement.prop('selected', false);
 | |
|                     toastr.error(t`The world with ${existingWorldName} is invalid or corrupted.`);
 | |
|                 }
 | |
|             });
 | |
|         }
 | |
|         selected_world_info = tempWorldInfo;
 | |
|     }
 | |
| 
 | |
|     saveSettingsDebounced();
 | |
|     eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
 | |
|     return '';
 | |
| }
 | |
| 
 | |
| export async function importWorldInfo(file) {
 | |
|     if (!file) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const formData = new FormData();
 | |
|     formData.append('avatar', file);
 | |
| 
 | |
|     try {
 | |
|         let jsonData;
 | |
| 
 | |
|         if (file.name.endsWith('.png')) {
 | |
|             const buffer = new Uint8Array(await getFileBuffer(file));
 | |
|             jsonData = extractDataFromPng(buffer, 'naidata');
 | |
|         } else {
 | |
|             // File should be a JSON file
 | |
|             jsonData = await parseJsonFile(file);
 | |
|         }
 | |
| 
 | |
|         if (jsonData === undefined || jsonData === null) {
 | |
|             toastr.error(t`File is not valid: ${file.name}`);
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         // Convert Novel Lorebook
 | |
|         if (jsonData.lorebookVersion !== undefined) {
 | |
|             console.log('Converting Novel Lorebook');
 | |
|             formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData)));
 | |
|         }
 | |
| 
 | |
|         // Convert Agnai Memory Book
 | |
|         if (jsonData.kind === 'memory') {
 | |
|             console.log('Converting Agnai Memory Book');
 | |
|             formData.append('convertedData', JSON.stringify(convertAgnaiMemoryBook(jsonData)));
 | |
|         }
 | |
| 
 | |
|         // Convert Risu Lorebook
 | |
|         if (jsonData.type === 'risu') {
 | |
|             console.log('Converting Risu Lorebook');
 | |
|             formData.append('convertedData', JSON.stringify(convertRisuLorebook(jsonData)));
 | |
|         }
 | |
|     } catch (error) {
 | |
|         toastr.error(`Error parsing file: ${error}`);
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const worldName = file.name.substr(0, file.name.lastIndexOf('.'));
 | |
|     const sanitizedWorldName = await getSanitizedFilename(worldName);
 | |
|     const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) });
 | |
|     if (!allowed) {
 | |
|         return false;
 | |
|     }
 | |
| 
 | |
|     jQuery.ajax({
 | |
|         type: 'POST',
 | |
|         url: '/api/worldinfo/import',
 | |
|         data: formData,
 | |
|         beforeSend: () => { },
 | |
|         cache: false,
 | |
|         contentType: false,
 | |
|         processData: false,
 | |
|         success: async function (data) {
 | |
|             if (data.name) {
 | |
|                 await updateWorldInfoList();
 | |
| 
 | |
|                 const newIndex = world_names.indexOf(data.name);
 | |
|                 if (newIndex >= 0) {
 | |
|                     $('#world_editor_select').val(newIndex).trigger('change');
 | |
|                 }
 | |
| 
 | |
|                 toastr.success(`World Info "${data.name}" imported successfully!`);
 | |
|             }
 | |
|         },
 | |
|         error: (_jqXHR, _exception) => { },
 | |
|     });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Forces the world info editor to open on a specific world.
 | |
|  * @param {string} worldName The name of the world to open
 | |
|  */
 | |
| export function openWorldInfoEditor(worldName) {
 | |
|     console.log(`Opening lorebook for ${worldName}`);
 | |
|     if (!$('#WorldInfo').is(':visible')) {
 | |
|         $('#WIDrawerIcon').trigger('click');
 | |
|     }
 | |
|     const index = world_names.indexOf(worldName);
 | |
|     $('#world_editor_select').val(index).trigger('change');
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Assigns a lorebook to the current chat.
 | |
|  * @param {PointerEvent} event Pointer event
 | |
|  * @returns {Promise<void>}
 | |
|  */
 | |
| export async function assignLorebookToChat(event) {
 | |
|     const selectedName = chat_metadata[METADATA_KEY];
 | |
| 
 | |
|     if (selectedName && event.altKey) {
 | |
|         openWorldInfoEditor(selectedName);
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const template = $(await renderTemplateAsync('chatLorebook'));
 | |
| 
 | |
|     const worldSelect = template.find('select');
 | |
|     const chatName = template.find('.chat_name');
 | |
|     chatName.text(getCurrentChatId());
 | |
| 
 | |
|     for (const worldName of world_names) {
 | |
|         const option = document.createElement('option');
 | |
|         option.value = worldName;
 | |
|         option.innerText = worldName;
 | |
|         option.selected = selectedName === worldName;
 | |
|         worldSelect.append(option);
 | |
|     }
 | |
| 
 | |
|     worldSelect.on('change', function () {
 | |
|         const worldName = $(this).val();
 | |
| 
 | |
|         if (worldName) {
 | |
|             chat_metadata[METADATA_KEY] = worldName;
 | |
|             $('.chat_lorebook_button').addClass('world_set');
 | |
|         } else {
 | |
|             delete chat_metadata[METADATA_KEY];
 | |
|             $('.chat_lorebook_button').removeClass('world_set');
 | |
|         }
 | |
| 
 | |
|         saveMetadata();
 | |
|     });
 | |
| 
 | |
|     return callGenericPopup(template, POPUP_TYPE.TEXT);
 | |
| }
 | |
| 
 | |
| jQuery(() => {
 | |
| 
 | |
|     $('#world_info').on('mousedown change', async function (e) {
 | |
|         // If there's no world names, don't do anything
 | |
|         if (world_names.length === 0) {
 | |
|             e.preventDefault();
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         onWorldInfoChange('__notSlashCommand__');
 | |
|     });
 | |
| 
 | |
|     //**************************WORLD INFO IMPORT EXPORT*************************//
 | |
|     $('#world_import_button').on('click', function () {
 | |
|         $('#world_import_file').trigger('click');
 | |
|     });
 | |
| 
 | |
|     $('#world_import_file').on('change', async function (e) {
 | |
|         if (!(e.target instanceof HTMLInputElement)) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const file = e.target.files[0];
 | |
| 
 | |
|         await importWorldInfo(file);
 | |
| 
 | |
|         // Will allow to select the same file twice in a row
 | |
|         e.target.value = '';
 | |
|     });
 | |
| 
 | |
|     $('#world_create_button').on('click', async () => {
 | |
|         const tempName = getFreeWorldName();
 | |
|         const finalName = await Popup.show.input(t`Create a new World Info`, t`Enter a name for the new file:`, tempName);
 | |
| 
 | |
|         if (finalName) {
 | |
|             await createNewWorldInfo(finalName, { interactive: true });
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     $('#world_editor_select').on('change', async () => {
 | |
|         $('#world_info_search').val('');
 | |
|         worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, '', true);
 | |
|         const selectedIndex = String($('#world_editor_select').find(':selected').val());
 | |
| 
 | |
|         if (selectedIndex === '') {
 | |
|             hideWorldEditor();
 | |
|         } else {
 | |
|             const worldName = world_names[selectedIndex];
 | |
|             showWorldEditor(worldName);
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     const saveSettings = () => {
 | |
|         saveSettingsDebounced();
 | |
|         eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
 | |
|     };
 | |
| 
 | |
|     $('#world_info_depth').on('input', function () {
 | |
|         world_info_depth = Number($(this).val());
 | |
|         $('#world_info_depth_counter').val($(this).val());
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_min_activations').on('input', function () {
 | |
|         world_info_min_activations = Number($(this).val());
 | |
|         $('#world_info_min_activations_counter').val(world_info_min_activations);
 | |
| 
 | |
|         if (world_info_min_activations !== 0 && world_info_max_recursion_steps !== 0) {
 | |
|             $('#world_info_max_recursion_steps').val(0).trigger('input');
 | |
|             flashHighlight($('#world_info_max_recursion_steps').parent()); // flash the other control to show it has changed
 | |
|             console.info('[WI] Max recursion steps set to 0, as min activations is set to', world_info_min_activations);
 | |
|         } else {
 | |
|             saveSettings();
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     $('#world_info_min_activations_depth_max').on('input', function () {
 | |
|         world_info_min_activations_depth_max = Number($(this).val());
 | |
|         $('#world_info_min_activations_depth_max_counter').val($(this).val());
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_budget').on('input', function () {
 | |
|         world_info_budget = Number($(this).val());
 | |
|         $('#world_info_budget_counter').val($(this).val());
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_include_names').on('input', function () {
 | |
|         world_info_include_names = !!$(this).prop('checked');
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_recursive').on('input', function () {
 | |
|         world_info_recursive = !!$(this).prop('checked');
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_case_sensitive').on('input', function () {
 | |
|         world_info_case_sensitive = !!$(this).prop('checked');
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_match_whole_words').on('input', function () {
 | |
|         world_info_match_whole_words = !!$(this).prop('checked');
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_character_strategy').on('change', function () {
 | |
|         world_info_character_strategy = Number($(this).val());
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_overflow_alert').on('change', function () {
 | |
|         world_info_overflow_alert = !!$(this).prop('checked');
 | |
|         saveSettingsDebounced();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_use_group_scoring').on('change', function () {
 | |
|         world_info_use_group_scoring = !!$(this).prop('checked');
 | |
|         saveSettingsDebounced();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_budget_cap').on('input', function () {
 | |
|         world_info_budget_cap = Number($(this).val());
 | |
|         $('#world_info_budget_cap_counter').val(world_info_budget_cap);
 | |
|         saveSettings();
 | |
|     });
 | |
| 
 | |
|     $('#world_info_max_recursion_steps').on('input', function () {
 | |
|         world_info_max_recursion_steps = Number($(this).val());
 | |
|         $('#world_info_max_recursion_steps_counter').val(world_info_max_recursion_steps);
 | |
|         if (world_info_max_recursion_steps !== 0 && world_info_min_activations !== 0) {
 | |
|             $('#world_info_min_activations').val(0).trigger('input');
 | |
|             flashHighlight($('#world_info_min_activations').parent()); // flash the other control to show it has changed
 | |
|             console.info('[WI] Min activations set to 0, as max recursion steps is set to', world_info_max_recursion_steps);
 | |
|         } else {
 | |
|             saveSettings();
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     $('#world_button').on('click', async function (event) {
 | |
|         const chid = $('#set_character_world').data('chid');
 | |
| 
 | |
|         if (chid) {
 | |
|             const worldName = characters[chid]?.data?.extensions?.world;
 | |
|             const hasEmbed = checkEmbeddedWorld(chid);
 | |
|             if (worldName && world_names.includes(worldName) && !event.shiftKey) {
 | |
|                 openWorldInfoEditor(worldName);
 | |
|             } else if (hasEmbed && !event.shiftKey) {
 | |
|                 await importEmbeddedWorldInfo();
 | |
|                 saveCharacterDebounced();
 | |
|             }
 | |
|             else {
 | |
|                 $('#char-management-dropdown').val($('#set_character_world').val()).trigger('change');
 | |
|             }
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     const debouncedWorldInfoSearch = debounce((searchQuery) => {
 | |
|         worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, searchQuery);
 | |
|     });
 | |
|     $('#world_info_search').on('input', function () {
 | |
|         const searchQuery = $(this).val();
 | |
|         debouncedWorldInfoSearch(searchQuery);
 | |
|     });
 | |
| 
 | |
|     $('#world_refresh').on('click', () => {
 | |
|         updateEditor(navigation_option.previous);
 | |
|     });
 | |
| 
 | |
|     $('#world_info_sort_order').on('change', function () {
 | |
|         const value = String($(this).find(':selected').val());
 | |
|         // Save sort order, but do not save search sorting, as this is a temporary sorting option
 | |
|         if (value !== 'search') accountStorage.setItem(SORT_ORDER_KEY, value);
 | |
|         updateEditor(navigation_option.none);
 | |
|     });
 | |
| 
 | |
|     $(document).on('click', '.chat_lorebook_button', assignLorebookToChat);
 | |
| 
 | |
|     // Not needed on mobile
 | |
|     if (!isMobile()) {
 | |
|         $('#world_info').select2({
 | |
|             width: '100%',
 | |
|             placeholder: 'No Worlds active. Click here to select.',
 | |
|             allowClear: true,
 | |
|             closeOnSelect: false,
 | |
|         });
 | |
| 
 | |
|         // Subscribe world loading to the select2 multiselect items (We need to target the specific select2 control)
 | |
|         select2ChoiceClickSubscribe($('#world_info'), target => {
 | |
|             const name = $(target).text();
 | |
|             const selectedIndex = world_names.indexOf(name);
 | |
|             if (selectedIndex !== -1) {
 | |
|                 $('#world_editor_select').val(selectedIndex).trigger('change');
 | |
|                 console.log('Quick selection of world', name);
 | |
|             }
 | |
|         }, { buttonStyle: true, closeDrawer: true });
 | |
|     }
 | |
| 
 | |
|     $('#WorldInfo').on('scroll', () => {
 | |
|         $('.world_entry input[name="group"], .world_entry input[name="automationId"]').each((_, el) => {
 | |
|             const instance = $(el).autocomplete('instance');
 | |
| 
 | |
|             if (instance !== undefined) {
 | |
|                 $(el).autocomplete('close');
 | |
|             }
 | |
|         });
 | |
|     });
 | |
| });
 |