import { Fuse } from '../lib.js'; import { saveSettings, 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, delay, 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, equalsIgnoreCaseAndAccents } 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); }; let isSaveWorldInfoDisabled = false; // 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} WIGlobalScanData The chat-independent data to be scanned. Each of * these fields can be enabled for scanning per entry. * @property {string} personaDescription User persona description * @property {string} characterDescription Character description * @property {string} characterPersonality Character personality * @property {string} characterDepthPrompt Character depth prompt (sometimes referred to as character notes) * @property {string} scenario Character defined scenario * @property {string} creatorNotes Character creator notes */ /** * @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 {boolean} [matchPersonaDescription] If the scan should match against the persona description * @property {boolean} [matchCharacterDescription] If the scan should match against the character description * @property {boolean} [matchCharacterPersonality] If the scan should match against the character personality * @property {boolean} [matchCharacterDepthPrompt] If the scan should match against the character depth prompt * @property {boolean} [matchScenario] If the scan should match against the character scenario * @property {boolean} [matchCreatorNotes] If the scan should match against the creator notes * @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} Map of entries that need to be activated no matter what */ static externalActivations = new Map(); /** * @type {WIGlobalScanData} Chat independent data to be scanned, such as persona and character descriptions */ #globalScanData = null; /** * @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 * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned */ constructor(messages, globalScanData) { this.#initDepthBuffer(messages); this.#globalScanData = globalScanData; } /** * 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 (entry.matchPersonaDescription && this.#globalScanData.personaDescription) { result += JOINER + this.#globalScanData.personaDescription; } if (entry.matchCharacterDescription && this.#globalScanData.characterDescription) { result += JOINER + this.#globalScanData.characterDescription; } if (entry.matchCharacterPersonality && this.#globalScanData.characterPersonality) { result += JOINER + this.#globalScanData.characterPersonality; } if (entry.matchCharacterDepthPrompt && this.#globalScanData.characterDepthPrompt) { result += JOINER + this.#globalScanData.characterDepthPrompt; } if (entry.matchScenario && this.#globalScanData.scenario) { result += JOINER + this.#globalScanData.scenario; } if (entry.matchCreatorNotes && this.#globalScanData.creatorNotes) { result += JOINER + this.#globalScanData.creatorNotes; } 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} */ #buffer = { 'sticky': [], 'cooldown': [], 'delay': [], }; /** * Callbacks for effect types ending. * @type {Record 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} * */ 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. * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned * @typedef {object} WIPromptResult * @property {string} worldInfoString - Complete world info string * @property {string} worldInfoBefore - World info that goes before the prompt * @property {string} worldInfoAfter - World info that goes after the prompt * @property {Array} worldInfoExamples - Array of example entries * @property {Array} worldInfoDepth - Array of depth entries * @property {Array} anBefore - Array of entries before Author's Note * @property {Array} anAfter - Array of entries after Author's Note * @returns {Promise} The world info string and depth. */ export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanData) { let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = ''; const activatedWorldInfo = await checkWorldInfo(chat, maxContext, isDryRun, globalScanData); 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 ?? [], anBefore: activatedWorldInfo.ANBeforeEntries ?? [], anAfter: activatedWorldInfo.ANAfterEntries ?? [], }; } 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(``); $('#world_editor_select').append(``); }); $('#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(); } /** * 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 */ export 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'); } } function registerWorldInfoSlashCommands() { /** * 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 {string} 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} 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 ''; } } async function getGlobalBooksCallback() { if (!selected_world_info?.length) { return JSON.stringify([]); } let entries = selected_world_info.slice(); console.debug(`[WI] Selected global world info has ${entries.length} entries`, selected_world_info); return JSON.stringify(entries); } 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: `
Sets active World, or unsets if no args provided, use state=off and state=toggle to deactivate or toggle a World, use silent=true to suppress toast messages.
`, 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: 'getglobalbooks', callback: getGlobalBooksCallback, returns: 'list of selected lorebook names', helpString: 'Get a list of names of the selected global lorebooks and pass it down the pipe.', aliases: ['getgloballore', 'getglobalwi'], })); 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: `
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.
Example:
  • /findentry file=chatLore field=key Shadowfang
`, })); 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: `
Get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe.
Example:
  • /getentryfield file=chatLore field=content 123
`, })); 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: `
Create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe.
Example:
  • /createentry file=chatLore key=Shadowfang The sword of the king
`, })); 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: `
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.
Example:
  • /setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon
`, })); 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: `
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.
Example:
  • /wi-set-timed-effect file=chatLore uid=123 effect=sticky on
`, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'wi-get-timed-effect', callback: getTimedEffectCallback, helpString: `
Get the current state of the timed effect for the record with the UID from the specified book.
Example:
  • /wi-get-timed-effect file=chatLore format=bool effect=sticky 123 - returns true or false if the effect is active or not
  • /wi-get-timed-effect file=chatLore format=number effect=sticky 123 - returns the remaining duration of the effect, or 0 if inactive
`, 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} A promise that resolves when the world editor is loaded */ export async function showWorldEditor(name) { if (!name) { await hideWorldEditor(); return; } const wiData = await loadWorldInfo(name); await 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} 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(``); $('#world_editor_select').append(``); }); } } async function hideWorldEditor() { await 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 clearEntryList() { console.time('clearEntryList'); const $list = $('#world_popup_entries_list'); if (!$list.children().length) { console.debug('List already empty, skipping cleanup.'); console.timeEnd('clearEntryList'); return; } // Step 1: Clean all