SillyTavern/public/scripts/world-info.js

5064 lines
193 KiB
JavaScript

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 } 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';
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 = [];
/**
* 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
*/
constructor(chat, entries) {
this.#chat = chat;
this.#entries = entries;
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() {
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) {
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;
}
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(localStorage.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('Valid World Info file name is required');
return '';
}
const data = await loadWorldInfo(file);
if (!data || !('entries' in data)) {
toastr.warning('World Info file has an invalid format');
return '';
}
const entries = Object.values(data.entries);
if (!entries || entries.length === 0) {
toastr.warning('World Info file has no entries');
return '';
}
return entries;
}
async function getChatBookCallback() {
const chatId = getCurrentChatId();
if (!chatId) {
toastr.warning('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];
}
// Replace non-alphanumeric characters with underscores, cut to 64 characters
const name = `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.',
aliases: ['getchatlore', 'getchatwi'],
}));
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;
}
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(localStorage.getItem(storageKey)) || perPageDefault;
startPage = Math.floor(uidIndex / perPage) + 1;
}
$('#world_info_pagination').pagination({
dataSource: getDataArray,
pageSize: Number(localStorage.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) {
localStorage.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(localStorage.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);
}
});
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);
}
});
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 }),
});
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;
}
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;
}
export async function getSortedEntries() {
try {
const globalLore = await getGlobalLore();
const characterLore = await getCharacterLore();
const chatLore = await getChatLore();
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
entries = [...chatLore.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) ---`);
// 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 = 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]);
}
!isDryRun && timedEffects.setTimedEffects(Array.from(allActivatedEntries.values()));
buffer.resetExternalEffects();
timedEffects.cleanUp();
console.log(`[WI] Adding ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));
console.debug('[WI] --- DONE ---');
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;
}
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 ? true : false,
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('chid', chid).show();
// Only show the alert once per character
const checkKey = `AlertWI_${characters[chid].avatar}`;
const worldName = characters[chid]?.data?.extensions?.world;
if (!localStorage.getItem(checkKey) && (!worldName || !world_names.includes(worldName))) {
localStorage.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('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(`Are you sure you want to import "${bookName}"?`, world_names.includes(bookName) ? '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(`The world "${bookName}" has been imported and linked to the character successfully.`, '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(`Deactivated world: ${name}`);
} else {
if (!silent) toastr.error(`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(`Deactivated world: ${name}`);
} else {
selected_world_info.push(name);
wiElement.prop('selected', true);
if (!silent) toastr.success(`Activated world: ${name}`);
}
break;
}
case 'on':
default: {
selected_world_info.push(name);
wiElement.prop('selected', true);
if (!silent) toastr.success(`Activated world: ${name}`);
}
}
} else {
if (!silent) toastr.error(`No world found named: ${worldName}`);
}
});
$('#world_info').trigger('change');
} else { // if no args, unset all worlds
if (!silent) toastr.success('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(`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(`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) => { },
});
}
export function assignLorebookToChat() {
const selectedName = chat_metadata[METADATA_KEY];
const template = $('#chat_world_template .chat_world').clone();
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();
});
callPopup(template, '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('Create a new World Info', '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) {
if (!$('#WorldInfo').is(':visible')) {
$('#WIDrawerIcon').trigger('click');
}
const index = world_names.indexOf(worldName);
$('#world_editor_select').val(index).trigger('change');
} 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') localStorage.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');
}
});
});
});