| 1 | import { Fuse } from '../lib.js'; |
| 2 | |
| 3 | import { saveSettings, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles, create_save, createOrEditCharacter, name1, getOneCharacter, select_selected_character } from '../script.js'; |
| 4 | import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique, equalsIgnoreCaseAndAccents, uuidv4, normalizeArray, getUniqueName, logSlashCommandWarn, addLongPressEvent, escapeHtml } from './utils.js'; |
| 5 | import { extension_settings, getContext } from './extensions.js'; |
| 6 | import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; |
| 7 | import { isMobile } from './RossAscends-mods.js'; |
| 8 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| 9 | import { getTokenCountAsync } from './tokenizers.js'; |
| 10 | import { power_user } from './power-user.js'; |
| 11 | import { getTagKeyForEntity } from './tags.js'; |
| 12 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS } from './constants.js'; |
| 13 | import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; |
| 14 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 15 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 16 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; |
| 17 | import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; |
| 18 | import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 19 | import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; |
| 20 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; |
| 21 | import { StructuredCloneMap } from './util/StructuredCloneMap.js'; |
| 22 | import { renderTemplateAsync } from './templates.js'; |
| 23 | import { t } from './i18n.js'; |
| 24 | import { accountStorage } from './util/AccountStorage.js'; |
| 25 | import { getOrCreatePersonaDescriptor, setPersonaDescription, user_avatar } from './personas.js'; |
| 26 | |
| 27 | export const world_info_insertion_strategy = { |
| 28 | evenly: 0, |
| 29 | character_first: 1, |
| 30 | global_first: 2, |
| 31 | }; |
| 32 | |
| 33 | export const world_info_logic = { |
| 34 | AND_ANY: 0, |
| 35 | NOT_ALL: 1, |
| 36 | NOT_ANY: 2, |
| 37 | AND_ALL: 3, |
| 38 | }; |
| 39 | |
| 40 | /** |
| 41 | * @enum {number} Possible states of the WI evaluation |
| 42 | */ |
| 43 | export const scan_state = { |
| 44 | /** |
| 45 | * The scan will be stopped. |
| 46 | */ |
| 47 | NONE: 0, |
| 48 | /** |
| 49 | * Initial state. |
| 50 | */ |
| 51 | INITIAL: 1, |
| 52 | /** |
| 53 | * The scan is triggered by a recursion step. |
| 54 | */ |
| 55 | RECURSION: 2, |
| 56 | /** |
| 57 | * The scan is triggered by a min activations depth skew. |
| 58 | */ |
| 59 | MIN_ACTIVATIONS: 3, |
| 60 | }; |
| 61 | |
| 62 | const WI_ENTRY_HEADER_TEMPLATE = $('#entry_edit_template .world_entry'); |
| 63 | const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry_edit'); |
| 64 | |
| 65 | export let world_info = {}; |
| 66 | export let selected_world_info = []; |
| 67 | /** @type {string[]} */ |
| 68 | export let world_names; |
| 69 | export let world_info_depth = 2; |
| 70 | export let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated |
| 71 | export let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0) |
| 72 | |
| 73 | export let world_info_budget = 25; |
| 74 | export let world_info_include_names = true; |
| 75 | export let world_info_recursive = false; |
| 76 | export let world_info_overflow_alert = false; |
| 77 | export let world_info_case_sensitive = false; |
| 78 | export let world_info_match_whole_words = false; |
| 79 | export let world_info_use_group_scoring = false; |
| 80 | export let world_info_character_strategy = world_info_insertion_strategy.character_first; |
| 81 | export let world_info_budget_cap = 0; |
| 82 | export let world_info_max_recursion_steps = 0; |
| 83 | const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), debounce_timeout.relaxed); |
| 84 | const saveSettingsDebounced = debounce(() => { |
| 85 | Object.assign(world_info, { globalSelect: selected_world_info }); |
| 86 | saveSettings(); |
| 87 | }, debounce_timeout.relaxed); |
| 88 | const sortFn = (a, b) => b.order - a.order; |
| 89 | let updateEditor = (navigation, flashOnNav = true) => { console.debug('Triggered WI navigation', navigation, flashOnNav); }; |
| 90 | |
| 91 | // Do not optimize. updateEditor is a function that is updated by the displayWorldEntries with new data. |
| 92 | export const worldInfoFilter = new FilterHelper(() => updateEditor()); |
| 93 | export const SORT_ORDER_KEY = 'world_info_sort_order'; |
| 94 | export const METADATA_KEY = 'world_info'; |
| 95 | |
| 96 | export const DEFAULT_DEPTH = 4; |
| 97 | export const DEFAULT_WEIGHT = 100; |
| 98 | export const MAX_SCAN_DEPTH = 1000; |
| 99 | const MAX_COMMENT_LENGTH = 100; |
| 100 | const KNOWN_DECORATORS = ['@@activate', '@@dont_activate']; |
| 101 | |
| 102 | // Typedef area |
| 103 | /** |
| 104 | * @typedef {object} WIGlobalScanData The chat-independent data to be scanned. Each of |
| 105 | * these fields can be enabled for scanning per entry. |
| 106 | * @property {string} personaDescription User persona description |
| 107 | * @property {string} characterDescription Character description |
| 108 | * @property {string} characterPersonality Character personality |
| 109 | * @property {string} characterDepthPrompt Character depth prompt (sometimes referred to as character notes) |
| 110 | * @property {string} scenario Character defined scenario |
| 111 | * @property {string} creatorNotes Character creator notes |
| 112 | * @property {string} trigger The type that triggered the scan, e.g. 'normal', 'continue', etc. |
| 113 | */ |
| 114 | |
| 115 | /** |
| 116 | * @typedef {object} WIScanEntry The entry that triggered the scan |
| 117 | * @property {number} [scanDepth] The depth of the scan |
| 118 | * @property {boolean} [caseSensitive] If the scan is case sensitive |
| 119 | * @property {boolean} [matchWholeWords] If the scan should match whole words |
| 120 | * @property {boolean} [useGroupScoring] If the scan should use group scoring |
| 121 | * @property {boolean} [matchPersonaDescription] If the scan should match against the persona description |
| 122 | * @property {boolean} [matchCharacterDescription] If the scan should match against the character description |
| 123 | * @property {boolean} [matchCharacterPersonality] If the scan should match against the character personality |
| 124 | * @property {boolean} [matchCharacterDepthPrompt] If the scan should match against the character depth prompt |
| 125 | * @property {boolean} [matchScenario] If the scan should match against the character scenario |
| 126 | * @property {boolean} [matchCreatorNotes] If the scan should match against the creator notes |
| 127 | * @property {number} [uid] The UID of the entry that triggered the scan |
| 128 | * @property {string} [world] The world info book of origin of the entry |
| 129 | * @property {string[]} [key] The primary keys to scan for |
| 130 | * @property {string[]} [keysecondary] The secondary keys to scan for |
| 131 | * @property {number} [selectiveLogic] The logic to use for selective activation |
| 132 | * @property {number} [sticky] The sticky value of the entry |
| 133 | * @property {number} [cooldown] The cooldown of the entry |
| 134 | * @property {number} [delay] The delay of the entry |
| 135 | * @property {string[]} [decorators] Array of decorators for the entry |
| 136 | * @property {number} [hash] The hash of the entry |
| 137 | */ |
| 138 | |
| 139 | /** |
| 140 | * @typedef {object} WITimedEffect Timed effect for world info |
| 141 | * @property {number} hash Hash of the entry that triggered the effect |
| 142 | * @property {number} start The chat index where the effect starts |
| 143 | * @property {number} end The chat index where the effect ends |
| 144 | * @property {boolean} protected The protected effect can't be removed if the chat does not advance |
| 145 | */ |
| 146 | |
| 147 | /** |
| 148 | * @typedef TimedEffectType Type of timed effect |
| 149 | * @type {'sticky'|'cooldown'|'delay'} |
| 150 | */ |
| 151 | |
| 152 | /** |
| 153 | * @typedef {object} WIPromptResult |
| 154 | * @property {string} worldInfoString - Complete world info string |
| 155 | * @property {string} worldInfoBefore - World info that goes before the prompt |
| 156 | * @property {string} worldInfoAfter - World info that goes after the prompt |
| 157 | * @property {Array} worldInfoExamples - Array of example entries |
| 158 | * @property {Array} worldInfoDepth - Array of depth entries |
| 159 | * @property {Array} anBefore - Array of entries before Author's Note |
| 160 | * @property {Array} anAfter - Array of entries after Author's Note |
| 161 | * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet |
| 162 | */ |
| 163 | |
| 164 | /** |
| 165 | * @typedef {object} WIActivated |
| 166 | * @property {string} worldInfoBefore The world info before the chat. |
| 167 | * @property {string} worldInfoAfter The world info after the chat. |
| 168 | * @property {any[]} EMEntries The entries for examples. |
| 169 | * @property {any[]} WIDepthEntries The depth entries. |
| 170 | * @property {any[]} ANBeforeEntries The entries before Author's Note. |
| 171 | * @property {any[]} ANAfterEntries The entries after Author's Note. |
| 172 | * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet |
| 173 | * @property {Set<any>} allActivatedEntries All entries. |
| 174 | */ |
| 175 | |
| 176 | /** |
| 177 | * @typedef {object} WIEntryFieldDefinition |
| 178 | * @property {any} default - Default value for the field |
| 179 | * @property {string} type - Type of the field, can be 'string', 'number', 'boolean', 'array', 'enum' |
| 180 | * @property {boolean} [excludeFromTemplate=false] - Whether to exclude this field from the template |
| 181 | * @property {(value: any) => boolean} [arrayFilter] - Optional filter function for array fields to filter out unwanted values |
| 182 | */ |
| 183 | // End typedef area |
| 184 | |
| 185 | /** @type {Readonly<WIGlobalScanData>} */ |
| 186 | const defaultGlobalScanData = Object.freeze({ |
| 187 | trigger: 'normal', |
| 188 | personaDescription: '', |
| 189 | characterDescription: '', |
| 190 | characterPersonality: '', |
| 191 | characterDepthPrompt: '', |
| 192 | scenario: '', |
| 193 | creatorNotes: '', |
| 194 | }); |
| 195 | |
| 196 | /** |
| 197 | * Represents a scanning buffer for one evaluation of World Info. |
| 198 | */ |
| 199 | class WorldInfoBuffer { |
| 200 | /** |
| 201 | * @type {Map<string, object>} Map of entries that need to be activated no matter what |
| 202 | */ |
| 203 | static externalActivations = new Map(); |
| 204 | |
| 205 | /** |
| 206 | * @type {WIGlobalScanData} Chat independent data to be scanned, such as persona and character descriptions |
| 207 | */ |
| 208 | #globalScanData = null; |
| 209 | |
| 210 | /** |
| 211 | * @type {string[]} Array of messages sorted by ascending depth |
| 212 | */ |
| 213 | #depthBuffer = []; |
| 214 | |
| 215 | /** |
| 216 | * @type {string[]} Array of strings added by recursive scanning |
| 217 | */ |
| 218 | #recurseBuffer = []; |
| 219 | |
| 220 | /** |
| 221 | * @type {string[]} Array of strings added by prompt injections that are valid for the current scan |
| 222 | */ |
| 223 | #injectBuffer = []; |
| 224 | |
| 225 | /** |
| 226 | * @type {number} The skew of the global scan depth. Used in "min activations" |
| 227 | */ |
| 228 | #skew = 0; |
| 229 | |
| 230 | /** |
| 231 | * @type {number} The starting depth of the global scan depth. |
| 232 | */ |
| 233 | #startDepth = 0; |
| 234 | |
| 235 | /** |
| 236 | * Initialize the buffer with the given messages. |
| 237 | * @param {string[]} messages Array of messages to add to the buffer |
| 238 | * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned |
| 239 | */ |
| 240 | constructor(messages, globalScanData) { |
| 241 | this.#initDepthBuffer(messages); |
| 242 | this.#globalScanData = globalScanData; |
| 243 | } |
| 244 | |
| 245 | /** |
| 246 | * Populates the buffer with the given messages. |
| 247 | * @param {string[]} messages Array of messages to add to the buffer |
| 248 | * @returns {void} Hardly seen nothing down here |
| 249 | */ |
| 250 | #initDepthBuffer(messages) { |
| 251 | for (let depth = 0; depth < MAX_SCAN_DEPTH; depth++) { |
| 252 | if (messages[depth]) { |
| 253 | this.#depthBuffer[depth] = messages[depth].trim(); |
| 254 | } |
| 255 | // break if last message is reached |
| 256 | if (depth === messages.length - 1) { |
| 257 | break; |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Gets a string that respects the case sensitivity setting |
| 264 | * @param {string} str The string to transform |
| 265 | * @param {WIScanEntry} entry The entry that triggered the scan |
| 266 | * @returns {string} The transformed string |
| 267 | */ |
| 268 | #transformString(str, entry) { |
| 269 | const caseSensitive = entry.caseSensitive ?? world_info_case_sensitive; |
| 270 | return caseSensitive ? str : str.toLowerCase(); |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Gets all messages up to the given depth + recursion buffer. |
| 275 | * @param {WIScanEntry} entry The entry that triggered the scan |
| 276 | * @param {number} scanState The state of the scan |
| 277 | * @returns {string} A slice of buffer until the given depth (inclusive) |
| 278 | */ |
| 279 | get(entry, scanState) { |
| 280 | let depth = entry.scanDepth ?? this.getDepth(); |
| 281 | if (depth <= this.#startDepth) { |
| 282 | return ''; |
| 283 | } |
| 284 | |
| 285 | if (depth < 0) { |
| 286 | console.error(`[WI] Invalid WI scan depth ${depth}. Must be >= 0`); |
| 287 | return ''; |
| 288 | } |
| 289 | |
| 290 | if (depth > MAX_SCAN_DEPTH) { |
| 291 | console.warn(`[WI] Invalid WI scan depth ${depth}. Truncating to ${MAX_SCAN_DEPTH}`); |
| 292 | depth = MAX_SCAN_DEPTH; |
| 293 | } |
| 294 | |
| 295 | const MATCHER = '\x01'; |
| 296 | const JOINER = '\n' + MATCHER; |
| 297 | let result = MATCHER + this.#depthBuffer.slice(this.#startDepth, depth).join(JOINER); |
| 298 | |
| 299 | if (entry.matchPersonaDescription && this.#globalScanData.personaDescription) { |
| 300 | result += JOINER + this.#globalScanData.personaDescription; |
| 301 | } |
| 302 | if (entry.matchCharacterDescription && this.#globalScanData.characterDescription) { |
| 303 | result += JOINER + this.#globalScanData.characterDescription; |
| 304 | } |
| 305 | if (entry.matchCharacterPersonality && this.#globalScanData.characterPersonality) { |
| 306 | result += JOINER + this.#globalScanData.characterPersonality; |
| 307 | } |
| 308 | if (entry.matchCharacterDepthPrompt && this.#globalScanData.characterDepthPrompt) { |
| 309 | result += JOINER + this.#globalScanData.characterDepthPrompt; |
| 310 | } |
| 311 | if (entry.matchScenario && this.#globalScanData.scenario) { |
| 312 | result += JOINER + this.#globalScanData.scenario; |
| 313 | } |
| 314 | if (entry.matchCreatorNotes && this.#globalScanData.creatorNotes) { |
| 315 | result += JOINER + this.#globalScanData.creatorNotes; |
| 316 | } |
| 317 | |
| 318 | if (this.#injectBuffer.length > 0) { |
| 319 | result += JOINER + this.#injectBuffer.join(JOINER); |
| 320 | } |
| 321 | |
| 322 | // Min activations should not include the recursion buffer |
| 323 | if (this.#recurseBuffer.length > 0 && scanState !== scan_state.MIN_ACTIVATIONS) { |
| 324 | result += JOINER + this.#recurseBuffer.join(JOINER); |
| 325 | } |
| 326 | |
| 327 | return result; |
| 328 | } |
| 329 | |
| 330 | /** |
| 331 | * Matches the given string against the buffer. |
| 332 | * @param {string} haystack The string to search in |
| 333 | * @param {string} needle The string to search for |
| 334 | * @param {WIScanEntry} entry The entry that triggered the scan |
| 335 | * @returns {boolean} True if the string was found in the buffer |
| 336 | */ |
| 337 | matchKeys(haystack, needle, entry) { |
| 338 | // If the needle is a regex, we do regex pattern matching and override all the other options |
| 339 | const keyRegex = parseRegexFromString(needle); |
| 340 | if (keyRegex) { |
| 341 | return keyRegex.test(haystack); |
| 342 | } |
| 343 | |
| 344 | // Otherwise we do normal matching of plaintext with the chosen entry settings |
| 345 | haystack = this.#transformString(haystack, entry); |
| 346 | const transformedString = this.#transformString(needle, entry); |
| 347 | const matchWholeWords = entry.matchWholeWords ?? world_info_match_whole_words; |
| 348 | |
| 349 | if (matchWholeWords) { |
| 350 | const keyWords = transformedString.split(/\s+/); |
| 351 | |
| 352 | if (keyWords.length > 1) { |
| 353 | return haystack.includes(transformedString); |
| 354 | } else { |
| 355 | // Use custom boundaries to include punctuation and other non-alphanumeric characters |
| 356 | const regex = new RegExp(`(?:^|\\W)(${escapeRegex(transformedString)})(?:$|\\W)`); |
| 357 | if (regex.test(haystack)) { |
| 358 | return true; |
| 359 | } |
| 360 | } |
| 361 | } else { |
| 362 | return haystack.includes(transformedString); |
| 363 | } |
| 364 | |
| 365 | return false; |
| 366 | } |
| 367 | |
| 368 | /** |
| 369 | * Adds a message to the recursion buffer. |
| 370 | * @param {string} message The message to add |
| 371 | */ |
| 372 | addRecurse(message) { |
| 373 | this.#recurseBuffer.push(message); |
| 374 | } |
| 375 | |
| 376 | /** |
| 377 | * Adds an injection to the buffer. |
| 378 | * @param {string} message The injection to add |
| 379 | */ |
| 380 | addInject(message) { |
| 381 | this.#injectBuffer.push(message); |
| 382 | } |
| 383 | |
| 384 | /** |
| 385 | * Checks if the recursion buffer is not empty. |
| 386 | * @returns {boolean} Returns true if the recursion buffer is not empty, otherwise false |
| 387 | */ |
| 388 | hasRecurse() { |
| 389 | return this.#recurseBuffer.length > 0; |
| 390 | } |
| 391 | |
| 392 | /** |
| 393 | * Increments skew to advance the scan range. |
| 394 | */ |
| 395 | advanceScan() { |
| 396 | this.#skew++; |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * @returns {number} Settings' depth + current skew. |
| 401 | */ |
| 402 | getDepth() { |
| 403 | return world_info_depth + this.#skew; |
| 404 | } |
| 405 | |
| 406 | /** |
| 407 | * Get the externally activated version of the entry, if there is one. |
| 408 | * @param {object} entry WI entry to check |
| 409 | * @returns {object|undefined} the external version if the entry is forcefully activated, undefined otherwise |
| 410 | */ |
| 411 | getExternallyActivated(entry) { |
| 412 | return WorldInfoBuffer.externalActivations.get(`${entry.world}.${entry.uid}`); |
| 413 | } |
| 414 | |
| 415 | /** |
| 416 | * Clean-up the external effects for entries. |
| 417 | */ |
| 418 | resetExternalEffects() { |
| 419 | WorldInfoBuffer.externalActivations = new Map(); |
| 420 | } |
| 421 | |
| 422 | /** |
| 423 | * Gets the match score for the given entry. |
| 424 | * @param {WIScanEntry} entry Entry to check |
| 425 | * @param {number} scanState The state of the scan |
| 426 | * @returns {number} The number of key activations for the given entry |
| 427 | */ |
| 428 | getScore(entry, scanState) { |
| 429 | const bufferState = this.get(entry, scanState); |
| 430 | let numberOfPrimaryKeys = 0; |
| 431 | let numberOfSecondaryKeys = 0; |
| 432 | let primaryScore = 0; |
| 433 | let secondaryScore = 0; |
| 434 | |
| 435 | // Increment score for every key found in the buffer |
| 436 | if (Array.isArray(entry.key)) { |
| 437 | numberOfPrimaryKeys = entry.key.length; |
| 438 | for (const key of entry.key) { |
| 439 | if (this.matchKeys(bufferState, key, entry)) { |
| 440 | primaryScore++; |
| 441 | } |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | // Increment score for every secondary key found in the buffer |
| 446 | if (Array.isArray(entry.keysecondary)) { |
| 447 | numberOfSecondaryKeys = entry.keysecondary.length; |
| 448 | for (const key of entry.keysecondary) { |
| 449 | if (this.matchKeys(bufferState, key, entry)) { |
| 450 | secondaryScore++; |
| 451 | } |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | // No keys == no score |
| 456 | if (!numberOfPrimaryKeys) { |
| 457 | return 0; |
| 458 | } |
| 459 | |
| 460 | // Only positive logic influences the score |
| 461 | if (numberOfSecondaryKeys > 0) { |
| 462 | switch (entry.selectiveLogic) { |
| 463 | // AND_ANY: Add both scores |
| 464 | case world_info_logic.AND_ANY: |
| 465 | return primaryScore + secondaryScore; |
| 466 | // AND_ALL: Add both scores if all secondary keys are found, otherwise only primary score |
| 467 | case world_info_logic.AND_ALL: |
| 468 | return secondaryScore === numberOfSecondaryKeys ? primaryScore + secondaryScore : primaryScore; |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | return primaryScore; |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | /** |
| 477 | * Represents a timed effects manager for World Info. |
| 478 | */ |
| 479 | class WorldInfoTimedEffects { |
| 480 | /** |
| 481 | * Array of chat messages. |
| 482 | * @type {string[]} |
| 483 | */ |
| 484 | #chat = []; |
| 485 | |
| 486 | /** |
| 487 | * Array of entries. |
| 488 | * @type {WIScanEntry[]} |
| 489 | */ |
| 490 | #entries = []; |
| 491 | |
| 492 | /** |
| 493 | * Is this a dry run? |
| 494 | * @type {boolean} |
| 495 | */ |
| 496 | #isDryRun = false; |
| 497 | |
| 498 | /** |
| 499 | * Buffer for active timed effects. |
| 500 | * @type {Record<TimedEffectType, WIScanEntry[]>} |
| 501 | */ |
| 502 | #buffer = { |
| 503 | 'sticky': [], |
| 504 | 'cooldown': [], |
| 505 | 'delay': [], |
| 506 | }; |
| 507 | |
| 508 | /** |
| 509 | * Callbacks for effect types ending. |
| 510 | * @type {Record<TimedEffectType, (entry: WIScanEntry) => void>} |
| 511 | */ |
| 512 | #onEnded = { |
| 513 | /** |
| 514 | * Callback for when a sticky entry ends. |
| 515 | * Sets an entry on cooldown immediately if it has a cooldown. |
| 516 | * @param {WIScanEntry} entry Entry that ended sticky |
| 517 | */ |
| 518 | 'sticky': (entry) => { |
| 519 | if (!entry.cooldown) { |
| 520 | return; |
| 521 | } |
| 522 | |
| 523 | const key = this.#getEntryKey(entry); |
| 524 | const effect = this.#getEntryTimedEffect('cooldown', entry, true); |
| 525 | chat_metadata.timedWorldInfo.cooldown[key] = effect; |
| 526 | console.log(`[WI] Adding cooldown entry ${key} on ended sticky: start=${effect.start}, end=${effect.end}, protected=${effect.protected}`); |
| 527 | // Set the cooldown immediately for this evaluation |
| 528 | this.#buffer.cooldown.push(entry); |
| 529 | }, |
| 530 | |
| 531 | /** |
| 532 | * Callback for when a cooldown entry ends. |
| 533 | * No-op, essentially. |
| 534 | * @param {WIScanEntry} entry Entry that ended cooldown |
| 535 | */ |
| 536 | 'cooldown': (entry) => { |
| 537 | console.debug('[WI] Cooldown ended for entry', entry.uid); |
| 538 | }, |
| 539 | |
| 540 | 'delay': () => { }, |
| 541 | }; |
| 542 | |
| 543 | /** |
| 544 | * Initialize the timed effects with the given messages. |
| 545 | * @param {string[]} chat Array of chat messages |
| 546 | * @param {WIScanEntry[]} entries Array of entries |
| 547 | * @param {boolean} isDryRun Whether the operation is a dry run |
| 548 | */ |
| 549 | constructor(chat, entries, isDryRun = false) { |
| 550 | this.#chat = chat; |
| 551 | this.#entries = entries; |
| 552 | this.#isDryRun = isDryRun; |
| 553 | this.#ensureChatMetadata(); |
| 554 | } |
| 555 | |
| 556 | /** |
| 557 | * Verify correct structure of chat metadata. |
| 558 | */ |
| 559 | #ensureChatMetadata() { |
| 560 | if (!chat_metadata.timedWorldInfo) { |
| 561 | chat_metadata.timedWorldInfo = {}; |
| 562 | } |
| 563 | |
| 564 | ['sticky', 'cooldown'].forEach(type => { |
| 565 | // Ensure the property exists and is an object |
| 566 | if (!chat_metadata.timedWorldInfo[type] || typeof chat_metadata.timedWorldInfo[type] !== 'object') { |
| 567 | chat_metadata.timedWorldInfo[type] = {}; |
| 568 | } |
| 569 | |
| 570 | // Clean up invalid entries |
| 571 | Object.entries(chat_metadata.timedWorldInfo[type]).forEach(([key, value]) => { |
| 572 | if (!value || typeof value !== 'object') { |
| 573 | delete chat_metadata.timedWorldInfo[type][key]; |
| 574 | } |
| 575 | }); |
| 576 | }); |
| 577 | } |
| 578 | |
| 579 | /** |
| 580 | * Gets a hash for a WI entry. |
| 581 | * @param {WIScanEntry} entry WI entry |
| 582 | * @returns {number} String hash |
| 583 | */ |
| 584 | #getEntryHash(entry) { |
| 585 | return entry.hash; |
| 586 | } |
| 587 | |
| 588 | /** |
| 589 | * Gets a unique-ish key for a WI entry. |
| 590 | * @param {WIScanEntry} entry WI entry |
| 591 | * @returns {string} String key for the entry |
| 592 | */ |
| 593 | #getEntryKey(entry) { |
| 594 | return `${entry.world}.${entry.uid}`; |
| 595 | } |
| 596 | |
| 597 | /** |
| 598 | * Gets a timed effect for a WI entry. |
| 599 | * @param {TimedEffectType} type Type of timed effect |
| 600 | * @param {WIScanEntry} entry WI entry |
| 601 | * @param {boolean} isProtected If the effect should be protected |
| 602 | * @returns {WITimedEffect} Timed effect for the entry |
| 603 | */ |
| 604 | #getEntryTimedEffect(type, entry, isProtected) { |
| 605 | return { |
| 606 | hash: this.#getEntryHash(entry), |
| 607 | start: this.#chat.length, |
| 608 | end: this.#chat.length + Number(entry[type]), |
| 609 | protected: !!isProtected, |
| 610 | }; |
| 611 | } |
| 612 | |
| 613 | /** |
| 614 | * Processes entries for a given type of timed effect. |
| 615 | * @param {TimedEffectType} type Identifier for the type of timed effect |
| 616 | * @param {WIScanEntry[]} buffer Buffer to store the entries |
| 617 | * @param {(entry: WIScanEntry) => void} onEnded Callback for when a timed effect ends |
| 618 | */ |
| 619 | #checkTimedEffectOfType(type, buffer, onEnded) { |
| 620 | /** @type {[string, WITimedEffect][]} */ |
| 621 | const effects = Object.entries(chat_metadata.timedWorldInfo[type]); |
| 622 | for (const [key, value] of effects) { |
| 623 | console.log(`[WI] Processing ${type} entry ${key}`, value); |
| 624 | const entry = this.#entries.find(x => String(this.#getEntryHash(x)) === String(value.hash)); |
| 625 | |
| 626 | if (this.#chat.length <= Number(value.start) && !value.protected) { |
| 627 | console.log(`[WI] Removing ${type} entry ${key} from timedWorldInfo: chat not advanced`, value); |
| 628 | delete chat_metadata.timedWorldInfo[type][key]; |
| 629 | continue; |
| 630 | } |
| 631 | |
| 632 | // Missing entries (they could be from another character's lorebook) |
| 633 | if (!entry) { |
| 634 | if (this.#chat.length >= Number(value.end)) { |
| 635 | console.log(`[WI] Removing ${type} entry from timedWorldInfo: entry not found and interval passed`, entry); |
| 636 | delete chat_metadata.timedWorldInfo[type][key]; |
| 637 | } |
| 638 | continue; |
| 639 | } |
| 640 | |
| 641 | // Ignore invalid entries (not configured for timed effects) |
| 642 | if (!entry[type]) { |
| 643 | console.log(`[WI] Removing ${type} entry from timedWorldInfo: entry not ${type}`, entry); |
| 644 | delete chat_metadata.timedWorldInfo[type][key]; |
| 645 | continue; |
| 646 | } |
| 647 | |
| 648 | if (this.#chat.length >= Number(value.end)) { |
| 649 | console.log(`[WI] Removing ${type} entry from timedWorldInfo: ${type} interval passed`, entry); |
| 650 | delete chat_metadata.timedWorldInfo[type][key]; |
| 651 | if (typeof onEnded === 'function') { |
| 652 | onEnded(entry); |
| 653 | } |
| 654 | continue; |
| 655 | } |
| 656 | |
| 657 | buffer.push(entry); |
| 658 | console.log(`[WI] Timed effect "${type}" applied to entry`, entry); |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | /** |
| 663 | * Processes entries for the "delay" timed effect. |
| 664 | * @param {WIScanEntry[]} buffer Buffer to store the entries |
| 665 | */ |
| 666 | #checkDelayEffect(buffer) { |
| 667 | for (const entry of this.#entries) { |
| 668 | if (!entry.delay) { |
| 669 | continue; |
| 670 | } |
| 671 | |
| 672 | if (this.#chat.length < entry.delay) { |
| 673 | buffer.push(entry); |
| 674 | console.log('[WI] Timed effect "delay" applied to entry', entry); |
| 675 | } |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | /** |
| 680 | * Checks for timed effects on chat messages. |
| 681 | */ |
| 682 | checkTimedEffects() { |
| 683 | if (!this.#isDryRun) { |
| 684 | this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this)); |
| 685 | this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this)); |
| 686 | } |
| 687 | this.#checkDelayEffect(this.#buffer.delay); |
| 688 | } |
| 689 | |
| 690 | /** |
| 691 | * Gets raw timed effect metadatum for a WI entry. |
| 692 | * @param {TimedEffectType} type Type of timed effect |
| 693 | * @param {WIScanEntry} entry WI entry |
| 694 | * @returns {WITimedEffect} Timed effect for the entry |
| 695 | */ |
| 696 | getEffectMetadata(type, entry) { |
| 697 | if (!this.isValidEffectType(type)) { |
| 698 | return null; |
| 699 | } |
| 700 | |
| 701 | const key = this.#getEntryKey(entry); |
| 702 | return chat_metadata.timedWorldInfo[type][key]; |
| 703 | } |
| 704 | |
| 705 | /** |
| 706 | * Sets a timed effect for a WI entry. |
| 707 | * @param {TimedEffectType} type Type of timed effect |
| 708 | * @param {WIScanEntry} entry WI entry to check |
| 709 | */ |
| 710 | #setTimedEffectOfType(type, entry) { |
| 711 | // Skip if entry does not have the type (sticky or cooldown) |
| 712 | if (!entry[type]) { |
| 713 | return; |
| 714 | } |
| 715 | |
| 716 | const key = this.#getEntryKey(entry); |
| 717 | |
| 718 | if (!chat_metadata.timedWorldInfo[type][key]) { |
| 719 | const effect = this.#getEntryTimedEffect(type, entry, false); |
| 720 | chat_metadata.timedWorldInfo[type][key] = effect; |
| 721 | |
| 722 | console.log(`[WI] Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}, protected=${effect.protected}`); |
| 723 | } |
| 724 | } |
| 725 | |
| 726 | /** |
| 727 | * Sets timed effects on chat messages. |
| 728 | * @param {WIScanEntry[]} activatedEntries Entries that were activated |
| 729 | */ |
| 730 | setTimedEffects(activatedEntries) { |
| 731 | if (this.#isDryRun) return; |
| 732 | for (const entry of activatedEntries) { |
| 733 | this.#setTimedEffectOfType('sticky', entry); |
| 734 | this.#setTimedEffectOfType('cooldown', entry); |
| 735 | } |
| 736 | } |
| 737 | |
| 738 | /** |
| 739 | * Force set a timed effect for a WI entry. |
| 740 | * @param {TimedEffectType} type Type of timed effect |
| 741 | * @param {WIScanEntry} entry WI entry |
| 742 | * @param {boolean} newState The state of the effect |
| 743 | */ |
| 744 | setTimedEffect(type, entry, newState) { |
| 745 | if (!this.isValidEffectType(type)) { |
| 746 | return; |
| 747 | } |
| 748 | if (this.#isDryRun && type !== 'delay') { |
| 749 | return; |
| 750 | } |
| 751 | |
| 752 | const key = this.#getEntryKey(entry); |
| 753 | delete chat_metadata.timedWorldInfo[type][key]; |
| 754 | |
| 755 | if (newState) { |
| 756 | const effect = this.#getEntryTimedEffect(type, entry, false); |
| 757 | chat_metadata.timedWorldInfo[type][key] = effect; |
| 758 | console.log(`[WI] Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}, protected=${effect.protected}`); |
| 759 | } |
| 760 | } |
| 761 | |
| 762 | /** |
| 763 | * Check if the string is a valid timed effect type. |
| 764 | * @param {string} type Name of the timed effect |
| 765 | * @returns {boolean} Is recognized type |
| 766 | */ |
| 767 | isValidEffectType(type) { |
| 768 | return typeof type === 'string' && ['sticky', 'cooldown', 'delay'].includes(type.trim().toLowerCase()); |
| 769 | } |
| 770 | |
| 771 | /** |
| 772 | * Check if the current entry is sticky activated. |
| 773 | * @param {TimedEffectType} type Type of timed effect |
| 774 | * @param {WIScanEntry} entry WI entry to check |
| 775 | * @returns {boolean} True if the entry is active |
| 776 | */ |
| 777 | isEffectActive(type, entry) { |
| 778 | if (!this.isValidEffectType(type)) { |
| 779 | return false; |
| 780 | } |
| 781 | |
| 782 | return this.#buffer[type]?.some(x => this.#getEntryHash(x) === this.#getEntryHash(entry)) ?? false; |
| 783 | } |
| 784 | |
| 785 | /** |
| 786 | * Clean-up previously set timed effects. |
| 787 | */ |
| 788 | cleanUp() { |
| 789 | for (const buffer of Object.values(this.#buffer)) { |
| 790 | buffer.splice(0, buffer.length); |
| 791 | } |
| 792 | } |
| 793 | } |
| 794 | |
| 795 | export function getWorldInfoSettings() { |
| 796 | return { |
| 797 | world_info, |
| 798 | world_info_depth, |
| 799 | world_info_min_activations, |
| 800 | world_info_min_activations_depth_max, |
| 801 | world_info_budget, |
| 802 | world_info_include_names, |
| 803 | world_info_recursive, |
| 804 | world_info_overflow_alert, |
| 805 | world_info_case_sensitive, |
| 806 | world_info_match_whole_words, |
| 807 | world_info_character_strategy, |
| 808 | world_info_budget_cap, |
| 809 | world_info_use_group_scoring, |
| 810 | world_info_max_recursion_steps, |
| 811 | }; |
| 812 | } |
| 813 | |
| 814 | /** |
| 815 | * Updates the world info settings. |
| 816 | * @param {WorldInfoSettings} settings - Settings object |
| 817 | * @param {string[]} [activeWorldInfo] - Optional array of active world info names |
| 818 | */ |
| 819 | export function updateWorldInfoSettings(settings, activeWorldInfo) { |
| 820 | console.debug('[WI] Updating world info settings', settings, activeWorldInfo); |
| 821 | |
| 822 | /** @type {Record<keyof WorldInfoSettings, (value: any) => void>} */ |
| 823 | const fields = { |
| 824 | world_info_depth: (value) => world_info_depth = Number(value), |
| 825 | world_info_min_activations: (value) => world_info_min_activations = Number(value), |
| 826 | world_info_min_activations_depth_max: (value) => world_info_min_activations_depth_max = Number(value), |
| 827 | world_info_budget: (value) => world_info_budget = Number(value), |
| 828 | world_info_include_names: (value) => world_info_include_names = Boolean(value), |
| 829 | world_info_recursive: (value) => world_info_recursive = Boolean(value), |
| 830 | world_info_overflow_alert: (value) => world_info_overflow_alert = Boolean(value), |
| 831 | world_info_case_sensitive: (value) => world_info_case_sensitive = Boolean(value), |
| 832 | world_info_match_whole_words: (value) => world_info_match_whole_words = Boolean(value), |
| 833 | world_info_character_strategy: (value) => world_info_character_strategy = Number(value), |
| 834 | world_info_budget_cap: (value) => world_info_budget_cap = Number(value), |
| 835 | world_info_use_group_scoring: (value) => world_info_use_group_scoring = Boolean(value), |
| 836 | world_info_max_recursion_steps: (value) => world_info_max_recursion_steps = Number(value), |
| 837 | // Unused |
| 838 | world_info: (_value) => { }, |
| 839 | }; |
| 840 | |
| 841 | for (const [key, setter] of Object.entries(fields)) { |
| 842 | if (Object.hasOwn(settings, key)) { |
| 843 | setter(settings[key]); |
| 844 | } |
| 845 | } |
| 846 | |
| 847 | if (Array.isArray(activeWorldInfo)) { |
| 848 | delete settings.world_info; |
| 849 | selected_world_info = activeWorldInfo; |
| 850 | } |
| 851 | |
| 852 | saveSettingsDebounced(); |
| 853 | } |
| 854 | |
| 855 | export const world_info_position = { |
| 856 | before: 0, |
| 857 | after: 1, |
| 858 | ANTop: 2, |
| 859 | ANBottom: 3, |
| 860 | atDepth: 4, |
| 861 | EMTop: 5, |
| 862 | EMBottom: 6, |
| 863 | outlet: 7, |
| 864 | }; |
| 865 | |
| 866 | export const wi_anchor_position = { |
| 867 | before: 0, |
| 868 | after: 1, |
| 869 | }; |
| 870 | |
| 871 | /** |
| 872 | * The cache of all world info data that was loaded from the backend. |
| 873 | * |
| 874 | * Calling `loadWorldInfo` will fill this cache and utilize this cache, so should be the preferred way to load any world info data. |
| 875 | * Only use the cache directly if you need synchronous access. |
| 876 | * |
| 877 | * This will return a deep clone of the data, so no way to modify the data without actually saving it. |
| 878 | * Should generally be only used for readonly access. |
| 879 | * |
| 880 | * @type {StructuredCloneMap<string,object>} |
| 881 | * */ |
| 882 | export const worldInfoCache = new StructuredCloneMap({ cloneOnGet: true, cloneOnSet: false }); |
| 883 | |
| 884 | /** |
| 885 | * Gets the world info based on chat messages. |
| 886 | * @param {string[]} chat - The chat messages to scan, in reverse order. |
| 887 | * @param {number} maxContext - The maximum context size of the generation. |
| 888 | * @param {boolean} isDryRun - If true, the function will not emit any events. |
| 889 | * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned |
| 890 | * @returns {Promise<WIPromptResult>} The world info string and depth. |
| 891 | */ |
| 892 | export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanData) { |
| 893 | let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = ''; |
| 894 | |
| 895 | const activatedWorldInfo = await checkWorldInfo(chat, maxContext, isDryRun, globalScanData); |
| 896 | worldInfoBefore = activatedWorldInfo.worldInfoBefore; |
| 897 | worldInfoAfter = activatedWorldInfo.worldInfoAfter; |
| 898 | worldInfoString = worldInfoBefore + worldInfoAfter; |
| 899 | |
| 900 | if (!isDryRun && activatedWorldInfo.allActivatedEntries && activatedWorldInfo.allActivatedEntries.size > 0) { |
| 901 | const arg = Array.from(activatedWorldInfo.allActivatedEntries.values()); |
| 902 | await eventSource.emit(event_types.WORLD_INFO_ACTIVATED, arg); |
| 903 | } |
| 904 | |
| 905 | return { |
| 906 | worldInfoString, |
| 907 | worldInfoBefore, |
| 908 | worldInfoAfter, |
| 909 | worldInfoExamples: activatedWorldInfo.EMEntries ?? [], |
| 910 | worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [], |
| 911 | anBefore: activatedWorldInfo.ANBeforeEntries ?? [], |
| 912 | anAfter: activatedWorldInfo.ANAfterEntries ?? [], |
| 913 | outletEntries: activatedWorldInfo.outletEntries ?? {}, |
| 914 | }; |
| 915 | } |
| 916 | |
| 917 | export function setWorldInfoSettings(settings, data) { |
| 918 | if (settings.world_info_depth !== undefined) |
| 919 | world_info_depth = Number(settings.world_info_depth); |
| 920 | if (settings.world_info_min_activations !== undefined) |
| 921 | world_info_min_activations = Number(settings.world_info_min_activations); |
| 922 | if (settings.world_info_min_activations_depth_max !== undefined) |
| 923 | world_info_min_activations_depth_max = Number(settings.world_info_min_activations_depth_max); |
| 924 | if (settings.world_info_budget !== undefined) |
| 925 | world_info_budget = Number(settings.world_info_budget); |
| 926 | if (settings.world_info_include_names !== undefined) |
| 927 | world_info_include_names = Boolean(settings.world_info_include_names); |
| 928 | if (settings.world_info_recursive !== undefined) |
| 929 | world_info_recursive = Boolean(settings.world_info_recursive); |
| 930 | if (settings.world_info_overflow_alert !== undefined) |
| 931 | world_info_overflow_alert = Boolean(settings.world_info_overflow_alert); |
| 932 | if (settings.world_info_case_sensitive !== undefined) |
| 933 | world_info_case_sensitive = Boolean(settings.world_info_case_sensitive); |
| 934 | if (settings.world_info_match_whole_words !== undefined) |
| 935 | world_info_match_whole_words = Boolean(settings.world_info_match_whole_words); |
| 936 | if (settings.world_info_character_strategy !== undefined) |
| 937 | world_info_character_strategy = Number(settings.world_info_character_strategy); |
| 938 | if (settings.world_info_budget_cap !== undefined) |
| 939 | world_info_budget_cap = Number(settings.world_info_budget_cap); |
| 940 | if (settings.world_info_use_group_scoring !== undefined) |
| 941 | world_info_use_group_scoring = Boolean(settings.world_info_use_group_scoring); |
| 942 | if (settings.world_info_max_recursion_steps !== undefined) |
| 943 | world_info_max_recursion_steps = Number(settings.world_info_max_recursion_steps); |
| 944 | |
| 945 | // Migrate old settings |
| 946 | if (world_info_budget > 100) { |
| 947 | world_info_budget = 25; |
| 948 | } |
| 949 | |
| 950 | if (world_info_use_group_scoring === undefined) { |
| 951 | world_info_use_group_scoring = false; |
| 952 | } |
| 953 | |
| 954 | // Reset selected world from old string and delete old keys |
| 955 | // TODO: Remove next release |
| 956 | const existingWorldInfo = settings.world_info; |
| 957 | if (typeof existingWorldInfo === 'string') { |
| 958 | delete settings.world_info; |
| 959 | selected_world_info = [existingWorldInfo]; |
| 960 | } else if (Array.isArray(existingWorldInfo)) { |
| 961 | delete settings.world_info; |
| 962 | selected_world_info = existingWorldInfo; |
| 963 | } |
| 964 | |
| 965 | world_info = settings.world_info ?? {}; |
| 966 | |
| 967 | $('#world_info_depth_counter').val(world_info_depth); |
| 968 | $('#world_info_depth').val(world_info_depth); |
| 969 | |
| 970 | $('#world_info_min_activations_counter').val(world_info_min_activations); |
| 971 | $('#world_info_min_activations').val(world_info_min_activations); |
| 972 | |
| 973 | $('#world_info_min_activations_depth_max_counter').val(world_info_min_activations_depth_max); |
| 974 | $('#world_info_min_activations_depth_max').val(world_info_min_activations_depth_max); |
| 975 | |
| 976 | $('#world_info_budget_counter').val(world_info_budget); |
| 977 | $('#world_info_budget').val(world_info_budget); |
| 978 | |
| 979 | $('#world_info_include_names').prop('checked', world_info_include_names); |
| 980 | $('#world_info_recursive').prop('checked', world_info_recursive); |
| 981 | $('#world_info_overflow_alert').prop('checked', world_info_overflow_alert); |
| 982 | $('#world_info_case_sensitive').prop('checked', world_info_case_sensitive); |
| 983 | $('#world_info_match_whole_words').prop('checked', world_info_match_whole_words); |
| 984 | $('#world_info_use_group_scoring').prop('checked', world_info_use_group_scoring); |
| 985 | |
| 986 | $(`#world_info_character_strategy option[value='${world_info_character_strategy}']`).prop('selected', true); |
| 987 | $('#world_info_character_strategy').val(world_info_character_strategy); |
| 988 | |
| 989 | $('#world_info_budget_cap').val(world_info_budget_cap); |
| 990 | $('#world_info_budget_cap_counter').val(world_info_budget_cap); |
| 991 | |
| 992 | $('#world_info_max_recursion_steps').val(world_info_max_recursion_steps); |
| 993 | $('#world_info_max_recursion_steps_counter').val(world_info_max_recursion_steps); |
| 994 | |
| 995 | world_names = data.world_names?.length ? data.world_names : []; |
| 996 | |
| 997 | // Add to existing selected WI if it exists |
| 998 | selected_world_info = selected_world_info.concat(settings.world_info?.globalSelect?.filter((e) => world_names.includes(e)) ?? []); |
| 999 | |
| 1000 | if (world_names.length > 0) { |
| 1001 | $('#world_info').empty(); |
| 1002 | } |
| 1003 | |
| 1004 | world_names.forEach((item, i) => { |
| 1005 | $('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`); |
| 1006 | $('#world_editor_select').append(`<option value='${i}'>${item}</option>`); |
| 1007 | }); |
| 1008 | |
| 1009 | $('#world_info_sort_order').val(accountStorage.getItem(SORT_ORDER_KEY) || '0'); |
| 1010 | $('#world_info').trigger('change'); |
| 1011 | $('#world_editor_select').trigger('change'); |
| 1012 | |
| 1013 | eventSource.on(event_types.CHAT_CHANGED, async () => { |
| 1014 | const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]); |
| 1015 | $('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo); |
| 1016 | // Pre-cache the world info data for the chat for quicker first prompt generation |
| 1017 | await getSortedEntries(); |
| 1018 | }); |
| 1019 | |
| 1020 | eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => { |
| 1021 | for (const entry of entries) { |
| 1022 | if (!Object.hasOwn(entry, 'world') || !Object.hasOwn(entry, 'uid')) { |
| 1023 | console.error('[WI] WORLDINFO_FORCE_ACTIVATE requires all entries to have both world and uid fields, entry IGNORED', entry); |
| 1024 | } else { |
| 1025 | WorldInfoBuffer.externalActivations.set(`${entry.world}.${entry.uid}`, entry); |
| 1026 | console.log('[WI] WORLDINFO_FORCE_ACTIVATE added entry', entry); |
| 1027 | } |
| 1028 | } |
| 1029 | }); |
| 1030 | |
| 1031 | // Add slash commands |
| 1032 | registerWorldInfoSlashCommands(); |
| 1033 | } |
| 1034 | |
| 1035 | /** |
| 1036 | * Reloads the editor with the specified world info file |
| 1037 | * @param {string} file - The file to load in the editor |
| 1038 | * @param {boolean} [loadIfNotSelected=false] - Indicates whether to load the file even if it's not currently selected |
| 1039 | */ |
| 1040 | export function reloadEditor(file, loadIfNotSelected = false) { |
| 1041 | const currentIndex = Number($('#world_editor_select').val()); |
| 1042 | const selectedIndex = world_names.indexOf(file); |
| 1043 | if (selectedIndex !== -1 && (loadIfNotSelected || currentIndex === selectedIndex)) { |
| 1044 | $('#world_editor_select').val(selectedIndex).trigger('change'); |
| 1045 | } |
| 1046 | } |
| 1047 | |
| 1048 | //MARK: regWISlashCommands |
| 1049 | function registerWorldInfoSlashCommands() { |
| 1050 | /** |
| 1051 | * Gets a *rough* approximation of the current chat context. |
| 1052 | * Normally, it is provided externally by the prompt builder. |
| 1053 | * Don't use for anything critical! |
| 1054 | * @returns {string[]} |
| 1055 | */ |
| 1056 | function getScanningChat() { |
| 1057 | return getContext().chat.filter(x => !x.is_system).map(x => x.mes); |
| 1058 | } |
| 1059 | |
| 1060 | async function getEntriesFromFile(file, { args = {}, unnamed = null, callbackName = 'getEntriesFromFile' } = {}) { |
| 1061 | if (!file || !world_names.includes(file)) { |
| 1062 | toastr.warning(t`Valid World Info file name is required`); |
| 1063 | logSlashCommandWarn(`${callbackName}: Valid World Info file name is required`, args, unnamed); |
| 1064 | return ''; |
| 1065 | } |
| 1066 | |
| 1067 | const data = await loadWorldInfo(file); |
| 1068 | |
| 1069 | if (!data || !('entries' in data)) { |
| 1070 | toastr.warning(t`World Info file has an invalid format`); |
| 1071 | logSlashCommandWarn(`${callbackName}: World Info file has an invalid format`, args, unnamed); |
| 1072 | return ''; |
| 1073 | } |
| 1074 | |
| 1075 | const entries = Object.values(data.entries); |
| 1076 | |
| 1077 | if (!entries || entries.length === 0) { |
| 1078 | toastr.warning(t`World Info file has no entries`); |
| 1079 | logSlashCommandWarn(`${callbackName}: World Info file has no entries`, args, unnamed); |
| 1080 | return ''; |
| 1081 | } |
| 1082 | |
| 1083 | return entries; |
| 1084 | } |
| 1085 | |
| 1086 | /** |
| 1087 | * Gets the name of the persona-bound lorebook. |
| 1088 | * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments |
| 1089 | * @param {string} _unnamedArg not used |
| 1090 | * @returns {Promise<string>} The name of the persona-bound lorebook |
| 1091 | */ |
| 1092 | async function getPersonaBookCallback({ name, create }, _unnamedArg) { |
| 1093 | let bookName = power_user.persona_description_lorebook || ''; |
| 1094 | if (bookName) { |
| 1095 | return bookName; |
| 1096 | } |
| 1097 | |
| 1098 | if (isTrueBoolean(String(create))) { |
| 1099 | const newName = await createWorldWithName(name, `Persona Book ${name1}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64)); |
| 1100 | power_user.persona_description_lorebook = newName; |
| 1101 | setPersonaDescription(); |
| 1102 | saveSettingsDebounced(); |
| 1103 | return newName; |
| 1104 | } |
| 1105 | |
| 1106 | return ''; |
| 1107 | } |
| 1108 | |
| 1109 | /** |
| 1110 | * Gets the name of the character-bound lorebook. |
| 1111 | * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments |
| 1112 | * @param {string} characterIdentifier Character name |
| 1113 | * @returns {Promise<string>} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string |
| 1114 | */ |
| 1115 | async function getCharBookCallback({ type, name, create }, characterIdentifier) { |
| 1116 | const context = getContext(); |
| 1117 | if (context.groupId && !characterIdentifier) throw new Error('This command is not available in groups without providing a character name'); |
| 1118 | type = String(type ?? '').trim().toLowerCase() || 'primary'; |
| 1119 | characterIdentifier = String(characterIdentifier ?? '') || context.characters[context.characterId]?.avatar || null; |
| 1120 | const character = findChar({ name: characterIdentifier }); |
| 1121 | if (!character) { |
| 1122 | toastr.error(t`Character not found.`); |
| 1123 | logSlashCommandWarn('getCharBookCallback: Character not found', { type, name, create }, { characterIdentifier }); |
| 1124 | return ''; |
| 1125 | } |
| 1126 | const books = []; |
| 1127 | if (type === 'all' || type === 'primary' && character.data?.extensions?.world) { |
| 1128 | books.push(character.data.extensions.world); |
| 1129 | } |
| 1130 | if (type === 'all' || type === 'additional') { |
| 1131 | const fileName = getCharaFilename(context.characters.indexOf(character)); |
| 1132 | const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); |
| 1133 | if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) { |
| 1134 | books.push(...extraCharLore.extraBooks.filter(onlyUnique).filter(Boolean)); |
| 1135 | } |
| 1136 | } |
| 1137 | |
| 1138 | if (isTrueBoolean(String(create)) && books.length === 0) { |
| 1139 | const newName = await createWorldWithName(name, `Character Book ${character.name}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64)); |
| 1140 | // Also assign the book now - additional if requested, otherwise as primary |
| 1141 | if (type === 'additional') { |
| 1142 | await charUpdateAddAuxWorld(character.avatar, newName); |
| 1143 | } else { |
| 1144 | await charUpdatePrimaryWorld(newName); |
| 1145 | } |
| 1146 | // Refresh UI, if needed |
| 1147 | setWorldInfoButtonClass(this_chid); |
| 1148 | books.push(newName); |
| 1149 | } |
| 1150 | |
| 1151 | return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean)); |
| 1152 | } |
| 1153 | |
| 1154 | /** |
| 1155 | * Gets the name of the chat-bound lorebook. Creates a new one if it doesn't exist. |
| 1156 | * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments |
| 1157 | * @returns {Promise<string>} The name of the chat-bound lorebook |
| 1158 | */ |
| 1159 | async function getChatBookCallback(args) { |
| 1160 | const chatId = getCurrentChatId(); |
| 1161 | |
| 1162 | if (!chatId) { |
| 1163 | toastr.warning(t`Open a chat to get a name of the chat-bound lorebook`); |
| 1164 | logSlashCommandWarn('getChatBookCallback: Open a chat to get a name of the chat-bound lorebook', args); |
| 1165 | return ''; |
| 1166 | } |
| 1167 | |
| 1168 | if (chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY])) { |
| 1169 | return chat_metadata[METADATA_KEY]; |
| 1170 | } |
| 1171 | |
| 1172 | if (isFalseBoolean(String(args.create))) { |
| 1173 | return ''; |
| 1174 | } |
| 1175 | |
| 1176 | const name = await createWorldWithName(args.name, `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64)); |
| 1177 | |
| 1178 | chat_metadata[METADATA_KEY] = name; |
| 1179 | await saveMetadata(); |
| 1180 | $('.chat_lorebook_button').addClass('world_set'); |
| 1181 | return name; |
| 1182 | } |
| 1183 | |
| 1184 | async function createWorldWithName(possibleName = undefined, fallbackName = undefined) { |
| 1185 | let newName = (() => { |
| 1186 | // Use the provided name if it's not in use |
| 1187 | if (typeof possibleName === 'string') { |
| 1188 | const name = String(possibleName); |
| 1189 | if (world_names.includes(name)) { |
| 1190 | throw new Error('This World Info file name is already in use'); |
| 1191 | } |
| 1192 | return name; |
| 1193 | } |
| 1194 | |
| 1195 | // Replace non-alphanumeric characters with underscores, cut to 64 characters |
| 1196 | return fallbackName ?? `Lorebook (${uuidv4()})`; |
| 1197 | })(); |
| 1198 | |
| 1199 | // Make sure the name is unique |
| 1200 | newName = getUniqueName(newName, world_names.includes.bind(world_names)); |
| 1201 | |
| 1202 | await createNewWorldInfo(newName); |
| 1203 | return newName; |
| 1204 | } |
| 1205 | |
| 1206 | async function findBookEntryCallback(args, value) { |
| 1207 | const file = args.file; |
| 1208 | const field = args.field || 'key'; |
| 1209 | |
| 1210 | const entries = await getEntriesFromFile(file, { args, unnamed: { value }, callbackName: 'findBookEntryCallback' }); |
| 1211 | |
| 1212 | if (!entries) { |
| 1213 | return ''; |
| 1214 | } |
| 1215 | |
| 1216 | if (typeof newWorldInfoEntryTemplate[field] === 'boolean') { |
| 1217 | const isTrue = isTrueBoolean(value); |
| 1218 | const isFalse = isFalseBoolean(value); |
| 1219 | |
| 1220 | if (isTrue) { |
| 1221 | value = String(true); |
| 1222 | } |
| 1223 | |
| 1224 | if (isFalse) { |
| 1225 | value = String(false); |
| 1226 | } |
| 1227 | } |
| 1228 | |
| 1229 | const fuse = new Fuse(entries, { |
| 1230 | keys: [{ name: field, weight: 1 }], |
| 1231 | includeScore: true, |
| 1232 | threshold: 0.3, |
| 1233 | }); |
| 1234 | |
| 1235 | const results = fuse.search(value); |
| 1236 | |
| 1237 | if (!results || results.length === 0) { |
| 1238 | return ''; |
| 1239 | } |
| 1240 | |
| 1241 | const result = results[0]?.item?.uid; |
| 1242 | |
| 1243 | if (result === undefined) { |
| 1244 | return ''; |
| 1245 | } |
| 1246 | |
| 1247 | return result; |
| 1248 | } |
| 1249 | |
| 1250 | async function getEntryFieldCallback(args, uid) { |
| 1251 | const file = args.file; |
| 1252 | const field = args.field || 'content'; |
| 1253 | const tags = getContext().tags; |
| 1254 | |
| 1255 | const entries = await getEntriesFromFile(file, { args, unnamed: { uid }, callbackName: 'getEntryFieldCallback' }); |
| 1256 | |
| 1257 | if (!entries) { |
| 1258 | return ''; |
| 1259 | } |
| 1260 | |
| 1261 | const entry = entries.find(x => String(x.uid) === String(uid)); |
| 1262 | |
| 1263 | if (!entry) { |
| 1264 | toastr.warning('Valid UID is required'); |
| 1265 | logSlashCommandWarn('getEntryFieldCallback: Valid UID is required', args, { uid }); |
| 1266 | console.warn(); |
| 1267 | return ''; |
| 1268 | } |
| 1269 | |
| 1270 | if (!Object.hasOwn(newWorldInfoEntryDefinition, field)) { |
| 1271 | toastr.warning('Valid field name is required'); |
| 1272 | logSlashCommandWarn('getEntryFieldCallback: Valid field name is required', args, { uid }); |
| 1273 | return ''; |
| 1274 | } |
| 1275 | |
| 1276 | // handle special cases, otherwise execute default logic |
| 1277 | let fieldValue; |
| 1278 | switch (field) { |
| 1279 | case 'characterFilterNames': |
| 1280 | if (entry.characterFilter) { |
| 1281 | fieldValue = entry.characterFilter.names; |
| 1282 | } |
| 1283 | break; |
| 1284 | case 'characterFilterTags': |
| 1285 | if (entry.characterFilter) { |
| 1286 | if (!entry.characterFilter.tags) { |
| 1287 | return ''; |
| 1288 | } |
| 1289 | //Find the tag objects corresponding to each ID in the array, then return the names |
| 1290 | fieldValue = tags.filter((tag) => entry.characterFilter.tags.includes(tag.id)).map((tag) => tag.name); |
| 1291 | } |
| 1292 | break; |
| 1293 | case 'characterFilterExclude': |
| 1294 | if (entry.characterFilter) { |
| 1295 | fieldValue = entry.characterFilter.isExclude; |
| 1296 | } |
| 1297 | break; |
| 1298 | default: |
| 1299 | fieldValue = entry[field] ?? newWorldInfoEntryDefinition[field]?.default; |
| 1300 | } |
| 1301 | |
| 1302 | if (fieldValue === undefined) { |
| 1303 | return ''; |
| 1304 | } |
| 1305 | |
| 1306 | if (Array.isArray(fieldValue)) { |
| 1307 | return JSON.stringify(fieldValue.map(x => substituteParams(x))); |
| 1308 | } |
| 1309 | |
| 1310 | return substituteParams(String(fieldValue)); |
| 1311 | } |
| 1312 | |
| 1313 | async function createEntryCallback(args, content) { |
| 1314 | const file = args.file; |
| 1315 | const key = args.key; |
| 1316 | |
| 1317 | const data = await loadWorldInfo(file); |
| 1318 | |
| 1319 | if (!data || !('entries' in data)) { |
| 1320 | toastr.warning('Valid World Info file name is required'); |
| 1321 | logSlashCommandWarn('createEntryCallback: Valid World Info file name is required', args); |
| 1322 | return ''; |
| 1323 | } |
| 1324 | |
| 1325 | const entry = createWorldInfoEntry(file, data); |
| 1326 | |
| 1327 | if (key) { |
| 1328 | entry.key.push(key); |
| 1329 | entry.addMemo = true; |
| 1330 | entry.comment = key; |
| 1331 | } |
| 1332 | |
| 1333 | if (content) { |
| 1334 | entry.content = content; |
| 1335 | } |
| 1336 | |
| 1337 | await saveWorldInfo(file, data); |
| 1338 | reloadEditor(file); |
| 1339 | |
| 1340 | return String(entry.uid); |
| 1341 | } |
| 1342 | |
| 1343 | async function setEntryFieldCallback(args, value) { |
| 1344 | const file = args.file; |
| 1345 | const uid = args.uid; |
| 1346 | const field = args.field || 'content'; |
| 1347 | const tags = getContext().tags; |
| 1348 | |
| 1349 | // characterFilter is an object with internal fields we need to access, which may also may be null and need to be populated |
| 1350 | const createCharacterFilterFieldObjectIfNeeded = (currentEntry) => { |
| 1351 | if (!currentEntry.characterFilter) { |
| 1352 | Object.assign( |
| 1353 | currentEntry, |
| 1354 | { |
| 1355 | characterFilter: { |
| 1356 | isExclude: false, |
| 1357 | names: [], |
| 1358 | tags: [], |
| 1359 | }, |
| 1360 | }, |
| 1361 | ); |
| 1362 | } |
| 1363 | }; |
| 1364 | |
| 1365 | if (value === undefined) { |
| 1366 | toastr.warning('Value is required'); |
| 1367 | logSlashCommandWarn('setEntryFieldCallback: Value is required', args, { value }); |
| 1368 | return ''; |
| 1369 | } |
| 1370 | |
| 1371 | value = value.replace(/\\([{}|])/g, '$1'); |
| 1372 | |
| 1373 | const data = await loadWorldInfo(file); |
| 1374 | |
| 1375 | if (!data || !('entries' in data)) { |
| 1376 | toastr.warning('Valid World Info file name is required'); |
| 1377 | logSlashCommandWarn('setEntryFieldCallback: Valid World Info file name is required', args, { value }); |
| 1378 | return ''; |
| 1379 | } |
| 1380 | |
| 1381 | const entry = data.entries[uid]; |
| 1382 | |
| 1383 | if (!entry) { |
| 1384 | toastr.warning('Valid UID is required'); |
| 1385 | logSlashCommandWarn('setEntryFieldCallback: Valid UID is required', args, { value }); |
| 1386 | return ''; |
| 1387 | } |
| 1388 | |
| 1389 | if (!Object.hasOwn(newWorldInfoEntryDefinition, field)) { |
| 1390 | toastr.warning('Valid field name is required'); |
| 1391 | logSlashCommandWarn('setEntryFieldCallback: Valid field name is required', args, { value }); |
| 1392 | return ''; |
| 1393 | } |
| 1394 | |
| 1395 | // Init a default value for the field if it does not exist |
| 1396 | if (!Object.hasOwn(entry, field)) { |
| 1397 | entry[field] = newWorldInfoEntryDefinition[field].default; |
| 1398 | } |
| 1399 | |
| 1400 | // Use an array filter if it exists for the field |
| 1401 | const arrayFilter = newWorldInfoEntryDefinition[field]?.arrayFilter || (() => true); |
| 1402 | |
| 1403 | // handle special cases, otherwise execute default logic |
| 1404 | let tagNames; |
| 1405 | let charNames; |
| 1406 | switch (field) { |
| 1407 | case 'characterFilterNames': |
| 1408 | createCharacterFilterFieldObjectIfNeeded(entry); |
| 1409 | charNames = parseStringArray(value); |
| 1410 | entry.characterFilter.names = charNames |
| 1411 | .map((name) => getCharaFilename(null, { manualAvatarKey: findChar({ name, allowAvatar: true, preferCurrentChar: false, quiet: true })?.avatar })) |
| 1412 | .filter(Boolean) |
| 1413 | .filter(onlyUnique); |
| 1414 | setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); |
| 1415 | break; |
| 1416 | case 'characterFilterTags': |
| 1417 | createCharacterFilterFieldObjectIfNeeded(entry); |
| 1418 | tagNames = parseStringArray(value); |
| 1419 | //Find the tag objects corresponding to each name in the user array, then return an array of the corresponding IDs |
| 1420 | entry.characterFilter.tags = tags.filter((tag) => tagNames.includes(tag.name)).map((tag) => tag.id); |
| 1421 | setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); |
| 1422 | break; |
| 1423 | case 'characterFilterExclude': |
| 1424 | createCharacterFilterFieldObjectIfNeeded(entry); |
| 1425 | entry.characterFilter.isExclude = isTrueBoolean(value); |
| 1426 | setWIOriginalDataValue(data, uid, 'character_filter', entry.characterFilter); |
| 1427 | break; |
| 1428 | default: |
| 1429 | if (Array.isArray(entry[field])) { |
| 1430 | entry[field] = parseStringArray(value).filter(arrayFilter); |
| 1431 | } else if (typeof entry[field] === 'boolean') { |
| 1432 | entry[field] = isTrueBoolean(value); |
| 1433 | } else if (typeof entry[field] === 'number') { |
| 1434 | entry[field] = Number(value); |
| 1435 | } else { |
| 1436 | entry[field] = value; |
| 1437 | } |
| 1438 | |
| 1439 | if (originalWIDataKeyMap[field]) { |
| 1440 | setWIOriginalDataValue(data, uid, originalWIDataKeyMap[field], entry[field]); |
| 1441 | } |
| 1442 | } |
| 1443 | |
| 1444 | await saveWorldInfo(file, data); |
| 1445 | reloadEditor(file); |
| 1446 | return ''; |
| 1447 | } |
| 1448 | |
| 1449 | async function getTimedEffectCallback(args, value) { |
| 1450 | if (!getCurrentChatId()) { |
| 1451 | throw new Error('This command can only be used in chat'); |
| 1452 | } |
| 1453 | |
| 1454 | const file = args.file; |
| 1455 | const uid = value; |
| 1456 | const effect = args.effect; |
| 1457 | |
| 1458 | const entries = await getEntriesFromFile(file, { args, unnamed: { uid }, callbackName: 'getTimedEffectCallback' }); |
| 1459 | |
| 1460 | if (!entries) { |
| 1461 | return ''; |
| 1462 | } |
| 1463 | |
| 1464 | /** @type {WIScanEntry} */ |
| 1465 | const entry = structuredClone(entries.find(x => String(x.uid) === String(uid))); |
| 1466 | |
| 1467 | if (!entry) { |
| 1468 | toastr.warning('Valid UID is required'); |
| 1469 | logSlashCommandWarn('getTimedEffectCallback: Valid UID is required', args, { uid }); |
| 1470 | return ''; |
| 1471 | } |
| 1472 | |
| 1473 | entry.world = file; // Required by the timed effects manager |
| 1474 | const chat = getScanningChat(); |
| 1475 | const timedEffects = new WorldInfoTimedEffects(chat, [entry]); |
| 1476 | |
| 1477 | if (!timedEffects.isValidEffectType(effect)) { |
| 1478 | toastr.warning('Valid effect type is required'); |
| 1479 | logSlashCommandWarn('getTimedEffectCallback: Valid effect type is required', args, { uid }); |
| 1480 | return ''; |
| 1481 | } |
| 1482 | |
| 1483 | const data = timedEffects.getEffectMetadata(effect, entry); |
| 1484 | |
| 1485 | if (String(args.format).trim().toLowerCase() === ARGUMENT_TYPE.NUMBER) { |
| 1486 | return String(data ? (data.end - chat.length) : 0); |
| 1487 | } |
| 1488 | |
| 1489 | return String(!!data); |
| 1490 | } |
| 1491 | |
| 1492 | async function setTimedEffectCallback(args, value) { |
| 1493 | if (!getCurrentChatId()) { |
| 1494 | throw new Error('This command can only be used in chat'); |
| 1495 | } |
| 1496 | |
| 1497 | const file = args.file; |
| 1498 | const uid = args.uid; |
| 1499 | const effect = args.effect; |
| 1500 | |
| 1501 | if (value === undefined) { |
| 1502 | toastr.warning('New state is required'); |
| 1503 | logSlashCommandWarn('setTimedEffectCallback: New state is required', args, { value }); |
| 1504 | return ''; |
| 1505 | } |
| 1506 | |
| 1507 | const entries = await getEntriesFromFile(file, { args, unnamed: { value }, callbackName: 'setTimedEffectCallback' }); |
| 1508 | |
| 1509 | if (!entries) { |
| 1510 | return ''; |
| 1511 | } |
| 1512 | |
| 1513 | /** @type {WIScanEntry} */ |
| 1514 | const entry = structuredClone(entries.find(x => String(x.uid) === String(uid))); |
| 1515 | |
| 1516 | if (!entry) { |
| 1517 | toastr.warning('Valid UID is required'); |
| 1518 | logSlashCommandWarn('setTimedEffectCallback: Valid UID is required', args, { value }); |
| 1519 | return ''; |
| 1520 | } |
| 1521 | |
| 1522 | entry.world = file; // Required by the timed effects manager |
| 1523 | const chat = getScanningChat(); |
| 1524 | const timedEffects = new WorldInfoTimedEffects(chat, [entry]); |
| 1525 | |
| 1526 | if (!timedEffects.isValidEffectType(effect)) { |
| 1527 | toastr.warning('Valid effect type is required'); |
| 1528 | logSlashCommandWarn('setTimedEffectCallback: Valid effect type is required', args, { value }); |
| 1529 | return ''; |
| 1530 | } |
| 1531 | |
| 1532 | if (!entry[effect]) { |
| 1533 | toastr.warning('This entry does not have the selected effect. Configure it in the editor first.'); |
| 1534 | logSlashCommandWarn('setTimedEffectCallback: This entry does not have the selected effect', args, { value }); |
| 1535 | return ''; |
| 1536 | } |
| 1537 | |
| 1538 | const getNewEffectState = () => { |
| 1539 | const currentState = !!timedEffects.getEffectMetadata(effect, entry); |
| 1540 | |
| 1541 | if (['toggle', 't', ''].includes(value.trim().toLowerCase())) { |
| 1542 | return !currentState; |
| 1543 | } |
| 1544 | |
| 1545 | if (isTrueBoolean(value)) { |
| 1546 | return true; |
| 1547 | } |
| 1548 | |
| 1549 | if (isFalseBoolean(value)) { |
| 1550 | return false; |
| 1551 | } |
| 1552 | |
| 1553 | return currentState; |
| 1554 | }; |
| 1555 | |
| 1556 | const newEffectState = getNewEffectState(); |
| 1557 | timedEffects.setTimedEffect(effect, entry, newEffectState); |
| 1558 | |
| 1559 | await saveMetadata(); |
| 1560 | toastr.success(`Timed effect "${effect}" for entry ${entry.uid} is now ${newEffectState ? 'active' : 'inactive'}`); |
| 1561 | |
| 1562 | return ''; |
| 1563 | } |
| 1564 | |
| 1565 | /** A collection of local enum providers for this context of world info */ |
| 1566 | const localEnumProviders = { |
| 1567 | /** All possible fields that can be set in a WI entry */ |
| 1568 | wiEntryFields: () => Object.entries(newWorldInfoEntryDefinition).map(([key, value]) => |
| 1569 | new SlashCommandEnumValue(key, `[${value.type}] default: ${(typeof value.default === 'string' ? `'${value.default}'` : JSON.stringify(value.default))}`, |
| 1570 | enumTypes.enum, enumIcons.getDataTypeIcon(value.type))), |
| 1571 | |
| 1572 | /** All existing UIDs based on the file argument as world name */ |
| 1573 | wiUids: (/** @type {import('./slash-commands/SlashCommandExecutor.js').SlashCommandExecutor} */ executor) => { |
| 1574 | const file = executor.namedArgumentList.find(it => it.name == 'file')?.value; |
| 1575 | if (file instanceof SlashCommandClosure) throw new Error('Argument \'file\' does not support closures'); |
| 1576 | // Try find world from cache |
| 1577 | if (!worldInfoCache.has(file)) return []; |
| 1578 | const world = worldInfoCache.get(file); |
| 1579 | if (!world) return []; |
| 1580 | return Object.entries(world.entries).map(([uid, data]) => |
| 1581 | 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)}]`, |
| 1582 | enumTypes.enum, enumIcons.getWiStatusIcon(data))); |
| 1583 | }, |
| 1584 | |
| 1585 | timedEffects: () => [ |
| 1586 | new SlashCommandEnumValue('sticky', 'Stays active for N messages', enumTypes.enum, '📌'), |
| 1587 | new SlashCommandEnumValue('cooldown', 'Cooldown for N messages', enumTypes.enum, '⌛'), |
| 1588 | ], |
| 1589 | }; |
| 1590 | |
| 1591 | function getWiPositionString(entry) { |
| 1592 | switch (entry.position) { |
| 1593 | case world_info_position.before: return '↑Char'; |
| 1594 | case world_info_position.after: return '↓Char'; |
| 1595 | case world_info_position.EMTop: return '↑EM'; |
| 1596 | case world_info_position.EMBottom: return '↓EM'; |
| 1597 | case world_info_position.ANTop: return '↑AT'; |
| 1598 | case world_info_position.ANBottom: return '↓AT'; |
| 1599 | case world_info_position.atDepth: return `@D${enumIcons.getRoleIcon(entry.role)}`; |
| 1600 | default: return '<Unknown>'; |
| 1601 | } |
| 1602 | } |
| 1603 | |
| 1604 | async function getGlobalBooksCallback() { |
| 1605 | if (!selected_world_info?.length) { |
| 1606 | return JSON.stringify([]); |
| 1607 | } |
| 1608 | |
| 1609 | let entries = selected_world_info.slice(); |
| 1610 | |
| 1611 | console.debug(`[WI] Selected global world info has ${entries.length} entries`, selected_world_info); |
| 1612 | |
| 1613 | return JSON.stringify(entries); |
| 1614 | } |
| 1615 | |
| 1616 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1617 | name: 'world', |
| 1618 | callback: onWorldInfoChange, |
| 1619 | namedArgumentList: [ |
| 1620 | new SlashCommandNamedArgument( |
| 1621 | 'state', 'set world state', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOffToggle')(), |
| 1622 | ), |
| 1623 | new SlashCommandNamedArgument( |
| 1624 | 'silent', 'suppress toast messages', [ARGUMENT_TYPE.BOOLEAN], false, |
| 1625 | ), |
| 1626 | ], |
| 1627 | unnamedArgumentList: [ |
| 1628 | SlashCommandArgument.fromProps({ |
| 1629 | description: 'world name', |
| 1630 | typeList: [ARGUMENT_TYPE.STRING], |
| 1631 | enumProvider: commonEnumProviders.worlds, |
| 1632 | }), |
| 1633 | ], |
| 1634 | helpString: ` |
| 1635 | <div> |
| 1636 | 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. |
| 1637 | </div> |
| 1638 | `, |
| 1639 | aliases: [], |
| 1640 | })); |
| 1641 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1642 | name: 'getchatbook', |
| 1643 | callback: getChatBookCallback, |
| 1644 | returns: 'lorebook name', |
| 1645 | helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.', |
| 1646 | namedArgumentList: [ |
| 1647 | SlashCommandNamedArgument.fromProps({ |
| 1648 | name: 'name', |
| 1649 | description: 'lorebook name if creating a new one, will be auto-generated otherwise', |
| 1650 | typeList: [ARGUMENT_TYPE.STRING], |
| 1651 | isRequired: false, |
| 1652 | acceptsMultiple: false, |
| 1653 | }), |
| 1654 | SlashCommandNamedArgument.fromProps({ |
| 1655 | name: 'create', |
| 1656 | description: 'create a new lorebook if it doesn\'t exist', |
| 1657 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 1658 | isRequired: false, |
| 1659 | acceptsMultiple: false, |
| 1660 | enumList: commonEnumProviders.boolean('trueFalse')(), |
| 1661 | defaultValue: 'true', |
| 1662 | }), |
| 1663 | ], |
| 1664 | aliases: ['getchatlore', 'getchatwi'], |
| 1665 | })); |
| 1666 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1667 | name: 'getglobalbooks', |
| 1668 | callback: getGlobalBooksCallback, |
| 1669 | returns: 'list of selected lorebook names', |
| 1670 | helpString: 'Get a list of names of the selected global lorebooks and pass it down the pipe.', |
| 1671 | aliases: ['getgloballore', 'getglobalwi'], |
| 1672 | })); |
| 1673 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1674 | name: 'getpersonabook', |
| 1675 | callback: getPersonaBookCallback, |
| 1676 | returns: 'lorebook name', |
| 1677 | |
| 1678 | namedArgumentList: [ |
| 1679 | SlashCommandNamedArgument.fromProps({ |
| 1680 | name: 'name', |
| 1681 | description: 'lorebook name if creating a new one, will be auto-generated otherwise', |
| 1682 | typeList: [ARGUMENT_TYPE.STRING], |
| 1683 | isRequired: false, |
| 1684 | acceptsMultiple: false, |
| 1685 | }), |
| 1686 | SlashCommandNamedArgument.fromProps({ |
| 1687 | name: 'create', |
| 1688 | description: 'create a new lorebook if it doesn\'t exist', |
| 1689 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 1690 | isRequired: false, |
| 1691 | acceptsMultiple: false, |
| 1692 | enumList: commonEnumProviders.boolean('trueFalse')(), |
| 1693 | defaultValue: 'false', |
| 1694 | }), |
| 1695 | ], |
| 1696 | helpString: 'Get a name of the current persona-bound lorebook and pass it down the pipe. Returns empty string if persona lorebook is not set.', |
| 1697 | aliases: ['getpersonalore', 'getpersonawi'], |
| 1698 | })); |
| 1699 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1700 | name: 'getcharbook', |
| 1701 | callback: getCharBookCallback, |
| 1702 | returns: 'lorebook name or a list of lorebook names', |
| 1703 | namedArgumentList: [ |
| 1704 | SlashCommandNamedArgument.fromProps({ |
| 1705 | name: 'type', |
| 1706 | description: 'type of the lorebook to get, returns a list for "all" and "additional"', |
| 1707 | typeList: [ARGUMENT_TYPE.STRING], |
| 1708 | enumList: ['primary', 'additional', 'all'], |
| 1709 | defaultValue: 'primary', |
| 1710 | }), |
| 1711 | SlashCommandNamedArgument.fromProps({ |
| 1712 | name: 'name', |
| 1713 | description: 'lorebook name if creating a new one, will be auto-generated otherwise', |
| 1714 | typeList: [ARGUMENT_TYPE.STRING], |
| 1715 | isRequired: false, |
| 1716 | acceptsMultiple: false, |
| 1717 | }), |
| 1718 | SlashCommandNamedArgument.fromProps({ |
| 1719 | name: 'create', |
| 1720 | description: 'create a new lorebook if it doesn\'t exist', |
| 1721 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 1722 | isRequired: false, |
| 1723 | acceptsMultiple: false, |
| 1724 | enumList: commonEnumProviders.boolean('trueFalse')(), |
| 1725 | defaultValue: 'false', |
| 1726 | }), |
| 1727 | ], |
| 1728 | unnamedArgumentList: [ |
| 1729 | SlashCommandArgument.fromProps({ |
| 1730 | description: 'Character name - or unique character identifier (avatar key). If not provided, the current character is used.', |
| 1731 | typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], |
| 1732 | isRequired: false, |
| 1733 | enumProvider: commonEnumProviders.characters('character'), |
| 1734 | }), |
| 1735 | ], |
| 1736 | helpString: 'Get a name of the character-bound lorebook and pass it down the pipe. Returns empty string if character lorebook is not set. Does not work in group chats without providing a character avatar name.', |
| 1737 | aliases: ['getcharlore', 'getcharwi'], |
| 1738 | })); |
| 1739 | |
| 1740 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1741 | name: 'findentry', |
| 1742 | aliases: ['findlore', 'findwi'], |
| 1743 | returns: 'UID', |
| 1744 | callback: findBookEntryCallback, |
| 1745 | namedArgumentList: [ |
| 1746 | SlashCommandNamedArgument.fromProps({ |
| 1747 | name: 'file', |
| 1748 | description: 'book name', |
| 1749 | typeList: [ARGUMENT_TYPE.STRING], |
| 1750 | isRequired: true, |
| 1751 | enumProvider: commonEnumProviders.worlds, |
| 1752 | }), |
| 1753 | SlashCommandNamedArgument.fromProps({ |
| 1754 | name: 'field', |
| 1755 | description: 'field value for fuzzy match (default: key)', |
| 1756 | typeList: [ARGUMENT_TYPE.STRING], |
| 1757 | defaultValue: 'key', |
| 1758 | enumList: localEnumProviders.wiEntryFields(), |
| 1759 | }), |
| 1760 | ], |
| 1761 | unnamedArgumentList: [ |
| 1762 | new SlashCommandArgument( |
| 1763 | 'texts', ARGUMENT_TYPE.STRING, true, true, |
| 1764 | ), |
| 1765 | ], |
| 1766 | helpString: ` |
| 1767 | <div> |
| 1768 | 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. |
| 1769 | </div> |
| 1770 | <div> |
| 1771 | <strong>Example:</strong> |
| 1772 | <ul> |
| 1773 | <li> |
| 1774 | <pre><code>/findentry file=chatLore field=key Shadowfang</code></pre> |
| 1775 | </li> |
| 1776 | </ul> |
| 1777 | </div> |
| 1778 | `, |
| 1779 | })); |
| 1780 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1781 | name: 'getentryfield', |
| 1782 | aliases: ['getlorefield', 'getwifield'], |
| 1783 | callback: getEntryFieldCallback, |
| 1784 | returns: 'field value', |
| 1785 | namedArgumentList: [ |
| 1786 | SlashCommandNamedArgument.fromProps({ |
| 1787 | name: 'file', |
| 1788 | description: 'book name', |
| 1789 | typeList: [ARGUMENT_TYPE.STRING], |
| 1790 | isRequired: true, |
| 1791 | enumProvider: commonEnumProviders.worlds, |
| 1792 | }), |
| 1793 | SlashCommandNamedArgument.fromProps({ |
| 1794 | name: 'field', |
| 1795 | description: 'field to retrieve (default: content)', |
| 1796 | typeList: [ARGUMENT_TYPE.STRING], |
| 1797 | defaultValue: 'content', |
| 1798 | enumList: localEnumProviders.wiEntryFields(), |
| 1799 | }), |
| 1800 | ], |
| 1801 | unnamedArgumentList: [ |
| 1802 | SlashCommandArgument.fromProps({ |
| 1803 | description: 'record UID', |
| 1804 | typeList: [ARGUMENT_TYPE.STRING], |
| 1805 | isRequired: true, |
| 1806 | enumProvider: localEnumProviders.wiUids, |
| 1807 | }), |
| 1808 | ], |
| 1809 | helpString: ` |
| 1810 | <div> |
| 1811 | Get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe. |
| 1812 | </div> |
| 1813 | <div> |
| 1814 | <strong>Example:</strong> |
| 1815 | <ul> |
| 1816 | <li> |
| 1817 | <pre><code>/getentryfield file=chatLore field=content 123</code></pre> |
| 1818 | </li> |
| 1819 | </ul> |
| 1820 | </div> |
| 1821 | `, |
| 1822 | })); |
| 1823 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1824 | name: 'createentry', |
| 1825 | callback: createEntryCallback, |
| 1826 | aliases: ['createlore', 'createwi'], |
| 1827 | returns: 'UID of the new record', |
| 1828 | namedArgumentList: [ |
| 1829 | SlashCommandNamedArgument.fromProps({ |
| 1830 | name: 'file', |
| 1831 | description: 'book name', |
| 1832 | typeList: [ARGUMENT_TYPE.STRING], |
| 1833 | isRequired: true, |
| 1834 | enumProvider: commonEnumProviders.worlds, |
| 1835 | }), |
| 1836 | new SlashCommandNamedArgument( |
| 1837 | 'key', 'record key', [ARGUMENT_TYPE.STRING], false, |
| 1838 | ), |
| 1839 | ], |
| 1840 | unnamedArgumentList: [ |
| 1841 | new SlashCommandArgument( |
| 1842 | 'content', [ARGUMENT_TYPE.STRING], false, |
| 1843 | ), |
| 1844 | ], |
| 1845 | helpString: ` |
| 1846 | <div> |
| 1847 | Create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe. |
| 1848 | </div> |
| 1849 | <div> |
| 1850 | <strong>Example:</strong> |
| 1851 | <ul> |
| 1852 | <li> |
| 1853 | <pre><code>/createentry file=chatLore key=Shadowfang The sword of the king</code></pre> |
| 1854 | </li> |
| 1855 | </ul> |
| 1856 | </div> |
| 1857 | `, |
| 1858 | })); |
| 1859 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1860 | name: 'setentryfield', |
| 1861 | callback: setEntryFieldCallback, |
| 1862 | aliases: ['setlorefield', 'setwifield'], |
| 1863 | namedArgumentList: [ |
| 1864 | SlashCommandNamedArgument.fromProps({ |
| 1865 | name: 'file', |
| 1866 | description: 'book name', |
| 1867 | typeList: [ARGUMENT_TYPE.STRING], |
| 1868 | isRequired: true, |
| 1869 | enumProvider: commonEnumProviders.worlds, |
| 1870 | }), |
| 1871 | SlashCommandNamedArgument.fromProps({ |
| 1872 | name: 'uid', |
| 1873 | description: 'record UID', |
| 1874 | typeList: [ARGUMENT_TYPE.STRING], |
| 1875 | isRequired: true, |
| 1876 | enumProvider: localEnumProviders.wiUids, |
| 1877 | }), |
| 1878 | SlashCommandNamedArgument.fromProps({ |
| 1879 | name: 'field', |
| 1880 | description: 'field name (default: content)', |
| 1881 | typeList: [ARGUMENT_TYPE.STRING], |
| 1882 | defaultValue: 'content', |
| 1883 | enumList: localEnumProviders.wiEntryFields(), |
| 1884 | }), |
| 1885 | ], |
| 1886 | unnamedArgumentList: [ |
| 1887 | new SlashCommandArgument( |
| 1888 | 'value', [ARGUMENT_TYPE.STRING], true, |
| 1889 | ), |
| 1890 | ], |
| 1891 | helpString: ` |
| 1892 | <div> |
| 1893 | 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. |
| 1894 | </div> |
| 1895 | <div> |
| 1896 | <strong>Example:</strong> |
| 1897 | <ul> |
| 1898 | <li> |
| 1899 | <pre><code>/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon</code></pre> |
| 1900 | </li> |
| 1901 | </ul> |
| 1902 | </div> |
| 1903 | `, |
| 1904 | })); |
| 1905 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1906 | name: 'wi-set-timed-effect', |
| 1907 | callback: setTimedEffectCallback, |
| 1908 | namedArgumentList: [ |
| 1909 | SlashCommandNamedArgument.fromProps({ |
| 1910 | name: 'file', |
| 1911 | description: 'book name', |
| 1912 | typeList: [ARGUMENT_TYPE.STRING], |
| 1913 | isRequired: true, |
| 1914 | enumProvider: commonEnumProviders.worlds, |
| 1915 | }), |
| 1916 | SlashCommandNamedArgument.fromProps({ |
| 1917 | name: 'uid', |
| 1918 | description: 'record UID', |
| 1919 | typeList: [ARGUMENT_TYPE.STRING], |
| 1920 | isRequired: true, |
| 1921 | enumProvider: localEnumProviders.wiUids, |
| 1922 | }), |
| 1923 | SlashCommandNamedArgument.fromProps({ |
| 1924 | name: 'effect', |
| 1925 | description: 'effect name', |
| 1926 | typeList: [ARGUMENT_TYPE.STRING], |
| 1927 | isRequired: true, |
| 1928 | enumProvider: localEnumProviders.timedEffects, |
| 1929 | }), |
| 1930 | ], |
| 1931 | unnamedArgumentList: [ |
| 1932 | SlashCommandArgument.fromProps({ |
| 1933 | description: 'new state of the effect', |
| 1934 | typeList: [ARGUMENT_TYPE.STRING], |
| 1935 | isRequired: true, |
| 1936 | acceptsMultiple: false, |
| 1937 | enumList: commonEnumProviders.boolean('onOffToggle')(), |
| 1938 | }), |
| 1939 | ], |
| 1940 | helpString: ` |
| 1941 | <div> |
| 1942 | Set a timed effect for the record with the UID from the specified book. The duration must be set in the entry itself. |
| 1943 | Will only be applied for the current chat. Enabling an effect that was already active refreshes the duration. |
| 1944 | If the last chat message is swiped or deleted, the effect will be removed. |
| 1945 | </div> |
| 1946 | <div> |
| 1947 | <strong>Example:</strong> |
| 1948 | <ul> |
| 1949 | <li> |
| 1950 | <pre><code>/wi-set-timed-effect file=chatLore uid=123 effect=sticky on</code></pre> |
| 1951 | </li> |
| 1952 | </ul> |
| 1953 | </div> |
| 1954 | `, |
| 1955 | })); |
| 1956 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 1957 | name: 'wi-get-timed-effect', |
| 1958 | callback: getTimedEffectCallback, |
| 1959 | helpString: ` |
| 1960 | <div> |
| 1961 | Get the current state of the timed effect for the record with the UID from the specified book. |
| 1962 | </div> |
| 1963 | <div> |
| 1964 | <strong>Example:</strong> |
| 1965 | <ul> |
| 1966 | <li> |
| 1967 | <code>/wi-get-timed-effect file=chatLore format=bool effect=sticky 123</code> - returns true or false if the effect is active or not |
| 1968 | </li> |
| 1969 | <li> |
| 1970 | <code>/wi-get-timed-effect file=chatLore format=number effect=sticky 123</code> - returns the remaining duration of the effect, or 0 if inactive |
| 1971 | </li> |
| 1972 | </ul> |
| 1973 | </div> |
| 1974 | `, |
| 1975 | returns: 'state of the effect', |
| 1976 | namedArgumentList: [ |
| 1977 | SlashCommandNamedArgument.fromProps({ |
| 1978 | name: 'file', |
| 1979 | description: 'book name', |
| 1980 | typeList: [ARGUMENT_TYPE.STRING], |
| 1981 | isRequired: true, |
| 1982 | enumProvider: commonEnumProviders.worlds, |
| 1983 | }), |
| 1984 | SlashCommandNamedArgument.fromProps({ |
| 1985 | name: 'effect', |
| 1986 | description: 'effect name', |
| 1987 | typeList: [ARGUMENT_TYPE.STRING], |
| 1988 | isRequired: true, |
| 1989 | enumProvider: localEnumProviders.timedEffects, |
| 1990 | }), |
| 1991 | SlashCommandNamedArgument.fromProps({ |
| 1992 | name: 'format', |
| 1993 | description: 'output format', |
| 1994 | isRequired: false, |
| 1995 | typeList: [ARGUMENT_TYPE.STRING], |
| 1996 | defaultValue: ARGUMENT_TYPE.BOOLEAN, |
| 1997 | enumList: [ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.NUMBER], |
| 1998 | }), |
| 1999 | ], |
| 2000 | unnamedArgumentList: [ |
| 2001 | SlashCommandArgument.fromProps({ |
| 2002 | description: 'record UID', |
| 2003 | typeList: [ARGUMENT_TYPE.STRING], |
| 2004 | isRequired: true, |
| 2005 | enumProvider: localEnumProviders.wiUids, |
| 2006 | }), |
| 2007 | ], |
| 2008 | })); |
| 2009 | } |
| 2010 | |
| 2011 | |
| 2012 | /** |
| 2013 | * Loads the given world into the World Editor. |
| 2014 | * |
| 2015 | * @param {string} name - The name of the world |
| 2016 | * @return {Promise<void>} A promise that resolves when the world editor is loaded |
| 2017 | */ |
| 2018 | export async function showWorldEditor(name) { |
| 2019 | if (!name) { |
| 2020 | await hideWorldEditor(); |
| 2021 | return; |
| 2022 | } |
| 2023 | |
| 2024 | const wiData = await loadWorldInfo(name); |
| 2025 | await displayWorldEntries(name, wiData); |
| 2026 | } |
| 2027 | |
| 2028 | /** |
| 2029 | * Loads world info from the backend. |
| 2030 | * |
| 2031 | * This function will return from `worldInfoCache` if it has already been loaded before. |
| 2032 | * |
| 2033 | * @param {string} name - The name of the world to load |
| 2034 | * @return {Promise<Object|null>} A promise that resolves to the loaded world information, or null if the request fails. |
| 2035 | */ |
| 2036 | export async function loadWorldInfo(name) { |
| 2037 | if (!name) { |
| 2038 | return; |
| 2039 | } |
| 2040 | |
| 2041 | if (worldInfoCache.has(name)) { |
| 2042 | return worldInfoCache.get(name); |
| 2043 | } |
| 2044 | |
| 2045 | const response = await fetch('/api/worldinfo/get', { |
| 2046 | method: 'POST', |
| 2047 | headers: getRequestHeaders(), |
| 2048 | body: JSON.stringify({ name: name }), |
| 2049 | cache: 'no-cache', |
| 2050 | }); |
| 2051 | |
| 2052 | if (response.ok) { |
| 2053 | const data = await response.json(); |
| 2054 | worldInfoCache.set(name, data); |
| 2055 | return data; |
| 2056 | } |
| 2057 | |
| 2058 | return null; |
| 2059 | } |
| 2060 | |
| 2061 | export async function updateWorldInfoList() { |
| 2062 | const result = await fetch('/api/settings/get', { |
| 2063 | method: 'POST', |
| 2064 | headers: getRequestHeaders(), |
| 2065 | body: JSON.stringify({}), |
| 2066 | }); |
| 2067 | |
| 2068 | if (result.ok) { |
| 2069 | const data = await result.json(); |
| 2070 | const editorSelected = String($('#world_editor_select').find(':selected').text()); |
| 2071 | world_names = data.world_names?.length ? data.world_names : []; |
| 2072 | $('#world_info').find('option[value!=""]').remove(); |
| 2073 | $('#world_editor_select').find('option[value!=""]').remove(); |
| 2074 | |
| 2075 | world_names.forEach((item, i) => { |
| 2076 | const globalListOption = new Option(item, i.toString()); |
| 2077 | globalListOption.selected = selected_world_info.includes(item); |
| 2078 | const editorListOption = new Option(item, i.toString()); |
| 2079 | editorListOption.selected = editorSelected === item; |
| 2080 | $('#world_info').append(globalListOption); |
| 2081 | $('#world_editor_select').append(editorListOption); |
| 2082 | }); |
| 2083 | } |
| 2084 | } |
| 2085 | |
| 2086 | async function hideWorldEditor() { |
| 2087 | await displayWorldEntries(null, null); |
| 2088 | } |
| 2089 | |
| 2090 | function getWIElement(name) { |
| 2091 | const wiElement = $('#world_info').children().filter(function () { |
| 2092 | return $(this).text().toLowerCase() === name.toLowerCase(); |
| 2093 | }); |
| 2094 | |
| 2095 | return wiElement; |
| 2096 | } |
| 2097 | |
| 2098 | /** |
| 2099 | * Adds missing fields to WI entries that are present in the entry template, but not in the data. |
| 2100 | * Additionally verify that array/object fields are of the expected type. |
| 2101 | * @param {any[]} data WI entries |
| 2102 | * @returns {any[]} Data with backfilled fields |
| 2103 | */ |
| 2104 | function addMissingWorldInfoFields(data) { |
| 2105 | data.forEach((entry) => { |
| 2106 | // Add missing fields from the template |
| 2107 | Object.entries(newWorldInfoEntryTemplate).forEach(([key, value]) => { |
| 2108 | if (!Object.hasOwn(entry, key)) { |
| 2109 | entry[key] = structuredClone(value); |
| 2110 | } |
| 2111 | }); |
| 2112 | |
| 2113 | // Ensure that the key is always an array |
| 2114 | if (!Array.isArray(entry.key)) { |
| 2115 | console.debug('[WI] Fixing invalid "key" field for entry', entry); |
| 2116 | entry.key = []; |
| 2117 | } |
| 2118 | |
| 2119 | // Ensure that the keysecondary is always an array |
| 2120 | if (!Array.isArray(entry.keysecondary)) { |
| 2121 | console.debug('[WI] Fixing invalid "keysecondary" field for entry', entry); |
| 2122 | entry.keysecondary = []; |
| 2123 | } |
| 2124 | |
| 2125 | // Ensure that the characterFilter is an object with the expected structure |
| 2126 | if (!entry.characterFilter || typeof entry.characterFilter !== 'object' || Array.isArray(entry.characterFilter)) { |
| 2127 | entry.characterFilter = { |
| 2128 | isExclude: false, |
| 2129 | names: [], |
| 2130 | tags: [], |
| 2131 | }; |
| 2132 | } |
| 2133 | }); |
| 2134 | |
| 2135 | return data; |
| 2136 | } |
| 2137 | |
| 2138 | /** |
| 2139 | * Sorts the given data based on the selected sort option |
| 2140 | * |
| 2141 | * @param {any[]} data WI entries |
| 2142 | * @param {object} [options={}] - Optional arguments |
| 2143 | * @param {{sortField?: string, sortOrder?: string, sortRule?: string}} [options.customSort={}] - Custom sort options, instead of the chosen UI sort |
| 2144 | * @returns {any[]} Sorted data |
| 2145 | */ |
| 2146 | export function sortWorldInfoEntries(data, { customSort = null } = {}) { |
| 2147 | const option = $('#world_info_sort_order').find(':selected'); |
| 2148 | const sortField = customSort?.sortField ?? option.data('field'); |
| 2149 | const sortOrder = customSort?.sortOrder ?? option.data('order'); |
| 2150 | const sortRule = customSort?.sortRule ?? option.data('rule'); |
| 2151 | const orderSign = sortOrder === 'asc' ? 1 : -1; |
| 2152 | |
| 2153 | if (!data.length) return data; |
| 2154 | |
| 2155 | /** @type {(a: any, b: any) => number} */ |
| 2156 | let primarySort; |
| 2157 | |
| 2158 | // Secondary and tertiary it will always be sorted by Order descending, and last UID ascending |
| 2159 | // This is the most sensible approach for sorts where the primary sort has a lot of equal values |
| 2160 | const secondarySort = (a, b) => b.order - a.order; |
| 2161 | const tertiarySort = (a, b) => a.uid - b.uid; |
| 2162 | |
| 2163 | // If we have a search term for WI, we are sorting by weighting scores |
| 2164 | if (sortRule === 'search') { |
| 2165 | primarySort = (a, b) => { |
| 2166 | const aScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, a.uid); |
| 2167 | const bScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, b.uid); |
| 2168 | return aScore - bScore; |
| 2169 | }; |
| 2170 | } else if (sortRule === 'custom') { |
| 2171 | // First by display index |
| 2172 | primarySort = (a, b) => { |
| 2173 | const aValue = a.displayIndex; |
| 2174 | const bValue = b.displayIndex; |
| 2175 | return aValue - bValue; |
| 2176 | }; |
| 2177 | } else if (sortRule === 'priority') { |
| 2178 | // First constant, then normal, then disabled. |
| 2179 | primarySort = (a, b) => { |
| 2180 | const aValue = a.disable ? 2 : a.constant ? 0 : 1; |
| 2181 | const bValue = b.disable ? 2 : b.constant ? 0 : 1; |
| 2182 | return aValue - bValue; |
| 2183 | }; |
| 2184 | } else { |
| 2185 | primarySort = (a, b) => { |
| 2186 | const aValue = a[sortField]; |
| 2187 | const bValue = b[sortField]; |
| 2188 | |
| 2189 | // Sort strings |
| 2190 | if (typeof aValue === 'string' && typeof bValue === 'string') { |
| 2191 | if (sortRule === 'length') { |
| 2192 | // Sort by string length |
| 2193 | return orderSign * (aValue.length - bValue.length); |
| 2194 | } else { |
| 2195 | // Sort by A-Z ordinal |
| 2196 | return orderSign * aValue.localeCompare(bValue); |
| 2197 | } |
| 2198 | } |
| 2199 | |
| 2200 | // Sort numbers |
| 2201 | return orderSign * (Number(aValue) - Number(bValue)); |
| 2202 | }; |
| 2203 | } |
| 2204 | |
| 2205 | data.sort((a, b) => { |
| 2206 | return primarySort(a, b) || secondarySort(a, b) || tertiarySort(a, b); |
| 2207 | }); |
| 2208 | |
| 2209 | return data; |
| 2210 | } |
| 2211 | |
| 2212 | function nullWorldInfo() { |
| 2213 | toastr.info('Create or import a new World Info file first.', 'World Info is not set', { timeOut: 10000, preventDuplicates: true }); |
| 2214 | } |
| 2215 | |
| 2216 | /** @type {Select2Option[]} Cache all keys as selectable dropdown option */ |
| 2217 | const worldEntryKeyOptionsCache = []; |
| 2218 | |
| 2219 | /** |
| 2220 | * Update the cache and all select options for the keys with new values to display |
| 2221 | * @param {string[]|Select2Option[]} keyOptions - An array of options to update |
| 2222 | * @param {object} options - Optional arguments |
| 2223 | * @param {boolean?} [options.remove=false] - Whether the option was removed, so the count should be reduced - otherwise it'll be increased |
| 2224 | * @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 |
| 2225 | */ |
| 2226 | function updateWorldEntryKeyOptionsCache(keyOptions, { remove = false, reset = false } = {}) { |
| 2227 | if (!keyOptions.length) return; |
| 2228 | /** @type {Select2Option[]} */ |
| 2229 | const options = keyOptions.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x); |
| 2230 | if (reset) worldEntryKeyOptionsCache.length = 0; |
| 2231 | options.forEach(option => { |
| 2232 | // Update the cache list |
| 2233 | let cachedEntry = worldEntryKeyOptionsCache.find(x => x.id == option.id); |
| 2234 | if (cachedEntry) { |
| 2235 | cachedEntry.count += !remove ? 1 : -1; |
| 2236 | } else if (!remove) { |
| 2237 | worldEntryKeyOptionsCache.push(option); |
| 2238 | cachedEntry = option; |
| 2239 | cachedEntry.count = 1; |
| 2240 | } |
| 2241 | }); |
| 2242 | |
| 2243 | // Sort by count DESC and then alphabetically |
| 2244 | worldEntryKeyOptionsCache.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text)); |
| 2245 | } |
| 2246 | |
| 2247 | function clearEntryList($list) { |
| 2248 | console.time('clearEntryList'); |
| 2249 | |
| 2250 | // List already empty, skipping cleanup |
| 2251 | if (!$list.children().length) { |
| 2252 | console.timeEnd('clearEntryList'); |
| 2253 | return; |
| 2254 | } |
| 2255 | |
| 2256 | // Unsubscribe from toggle events, so that mass open won't create new drawers |
| 2257 | $list.find('.inline-drawer').off('inline-drawer-toggle'); |
| 2258 | |
| 2259 | // Step 1: Clean all <option> elements within <select> |
| 2260 | $list.find('option').each(function () { |
| 2261 | const $option = $(this); |
| 2262 | $option.off(); |
| 2263 | $.cleanData([$option[0]]); |
| 2264 | $option.remove(); |
| 2265 | }); |
| 2266 | |
| 2267 | // Step 2: Clean all <select> elements |
| 2268 | $list.find('select').each(function () { |
| 2269 | const $select = $(this); |
| 2270 | // Remove Select2-related data and container if present |
| 2271 | if ($select.data('select2')) { |
| 2272 | try { |
| 2273 | $select.select2('destroy'); |
| 2274 | } catch (e) { |
| 2275 | console.debug('Select2 destroy failed:', e); |
| 2276 | } |
| 2277 | } |
| 2278 | const $container = $select.parent(); |
| 2279 | if ($container.length) { |
| 2280 | $container.find('*').off(); |
| 2281 | $.cleanData($container.find('*').get()); |
| 2282 | $container.remove(); |
| 2283 | } |
| 2284 | |
| 2285 | $select.off(); |
| 2286 | $.cleanData([$select[0]]); |
| 2287 | }); |
| 2288 | |
| 2289 | // Step 3: Clean <div>, <span>, <input> |
| 2290 | $list.find('div, span, input').each(function () { |
| 2291 | const $elem = $(this); |
| 2292 | $elem.off(); |
| 2293 | $.cleanData([$elem[0]]); |
| 2294 | $elem.remove(); |
| 2295 | }); |
| 2296 | |
| 2297 | const totalElementsOfAnyKindLeftInList = $list.children().length; |
| 2298 | |
| 2299 | // Final cleanup |
| 2300 | if (totalElementsOfAnyKindLeftInList) { |
| 2301 | console.time('empty'); |
| 2302 | $list.empty(); |
| 2303 | console.timeEnd('empty'); |
| 2304 | } |
| 2305 | |
| 2306 | console.timeEnd('clearEntryList'); |
| 2307 | } |
| 2308 | |
| 2309 | //MARK: displayWorldEntries |
| 2310 | async function displayWorldEntries(name, data, navigation = navigation_option.none, flashOnNav = true) { |
| 2311 | updateEditor = async (navigation, flashOnNav = true) => await displayWorldEntries(name, data, navigation, flashOnNav); |
| 2312 | |
| 2313 | const worldEntriesList = $('#world_popup_entries_list'); |
| 2314 | clearEntryList(worldEntriesList); |
| 2315 | worldEntriesList.show(); |
| 2316 | |
| 2317 | if (!data || !('entries' in data)) { |
| 2318 | $('#world_popup_new').off('click').on('click', nullWorldInfo); |
| 2319 | $('#world_popup_name_button').off('click').on('click', nullWorldInfo); |
| 2320 | $('#world_popup_export').off('click').on('click', nullWorldInfo); |
| 2321 | $('#world_popup_delete').off('click').on('click', nullWorldInfo); |
| 2322 | $('#world_duplicate').off('click').on('click', nullWorldInfo); |
| 2323 | worldEntriesList.hide(); |
| 2324 | $('#world_info_pagination').html(''); |
| 2325 | return; |
| 2326 | } |
| 2327 | |
| 2328 | // Regardless of whether success is displayed or not. Make sure the delete button is available. |
| 2329 | // Do not put this code behind. |
| 2330 | $('#world_popup_delete').off('click').on('click', async () => { |
| 2331 | const confirmation = await Popup.show.confirm(`Delete the World/Lorebook: "${name}"?`, 'This action is irreversible!'); |
| 2332 | if (!confirmation) { |
| 2333 | return; |
| 2334 | } |
| 2335 | |
| 2336 | if (world_info.charLore) { |
| 2337 | world_info.charLore.forEach((charLore, index) => { |
| 2338 | if (charLore.extraBooks?.includes(name)) { |
| 2339 | const tempCharLore = charLore.extraBooks.filter((e) => e !== name); |
| 2340 | if (tempCharLore.length === 0) { |
| 2341 | world_info.charLore.splice(index, 1); |
| 2342 | } else { |
| 2343 | charLore.extraBooks = tempCharLore; |
| 2344 | } |
| 2345 | } |
| 2346 | }); |
| 2347 | |
| 2348 | saveSettingsDebounced(); |
| 2349 | } |
| 2350 | |
| 2351 | // Selected world_info automatically refreshes |
| 2352 | await deleteWorldInfo(name); |
| 2353 | }); |
| 2354 | |
| 2355 | // Before printing the WI, we check if we should enable/disable search sorting |
| 2356 | verifyWorldInfoSearchSortRule(); |
| 2357 | |
| 2358 | function getDataArray(callback) { |
| 2359 | // Convert the data.entries object into an array |
| 2360 | let entriesArray = Object.keys(data.entries).map(uid => { |
| 2361 | const entry = data.entries[uid]; |
| 2362 | if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { |
| 2363 | return null; |
| 2364 | } |
| 2365 | entry.displayIndex = entry.displayIndex ?? entry.uid; |
| 2366 | return entry; |
| 2367 | }).filter(entry => entry !== null); |
| 2368 | |
| 2369 | // Apply the filter and do the chosen sorting |
| 2370 | entriesArray = addMissingWorldInfoFields(entriesArray); |
| 2371 | entriesArray = worldInfoFilter.applyFilters(entriesArray); |
| 2372 | entriesArray = sortWorldInfoEntries(entriesArray); |
| 2373 | |
| 2374 | // Cache keys |
| 2375 | const keys = entriesArray.flatMap(entry => [...entry.key, ...entry.keysecondary]); |
| 2376 | updateWorldEntryKeyOptionsCache(keys, { reset: true }); |
| 2377 | |
| 2378 | // Run the callback for printing this |
| 2379 | typeof callback === 'function' && callback(entriesArray); |
| 2380 | return entriesArray; |
| 2381 | } |
| 2382 | |
| 2383 | const storageKey = 'WI_PerPage'; |
| 2384 | const perPageDefault = 25; |
| 2385 | let startPage = 1; |
| 2386 | |
| 2387 | if (navigation === navigation_option.previous) { |
| 2388 | startPage = $('#world_info_pagination').pagination('getCurrentPageNum'); |
| 2389 | } |
| 2390 | |
| 2391 | if (typeof navigation === 'number' && Number(navigation) >= 0) { |
| 2392 | const data = getDataArray(); |
| 2393 | const uidIndex = data.findIndex(x => x.uid === navigation); |
| 2394 | const perPage = Number(accountStorage.getItem(storageKey)) || perPageDefault; |
| 2395 | startPage = Math.floor(uidIndex / perPage) + 1; |
| 2396 | } |
| 2397 | |
| 2398 | $('#world_info_pagination').pagination({ |
| 2399 | dataSource: getDataArray, |
| 2400 | pageSize: Number(accountStorage.getItem(storageKey)) || perPageDefault, |
| 2401 | sizeChangerOptions: [10, 25, 50, 100, 500, 1000], |
| 2402 | showSizeChanger: true, |
| 2403 | pageRange: 1, |
| 2404 | pageNumber: startPage, |
| 2405 | position: 'top', |
| 2406 | showPageNumbers: false, |
| 2407 | prevText: '<', |
| 2408 | nextText: '>', |
| 2409 | formatNavigator: PAGINATION_TEMPLATE, |
| 2410 | showNavigator: true, |
| 2411 | callback: async function (/** @type {object[]} */ page) { |
| 2412 | try { |
| 2413 | clearEntryList(worldEntriesList); |
| 2414 | |
| 2415 | const keywordHeaders = await renderTemplateAsync('worldInfoKeywordHeaders'); |
| 2416 | const blocks = []; |
| 2417 | |
| 2418 | for (const entry of page) { |
| 2419 | try { |
| 2420 | const block = await getWorldEntry(name, data, entry); |
| 2421 | if (block) { |
| 2422 | blocks.push(block); |
| 2423 | } |
| 2424 | } catch (error) { |
| 2425 | console.error(`Error while processing entry ${entry.uid}:`, error); |
| 2426 | } |
| 2427 | } |
| 2428 | |
| 2429 | const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom'; |
| 2430 | if (!isCustomOrder) { |
| 2431 | blocks.forEach(block => { |
| 2432 | block.find('.drag-handle').remove(); |
| 2433 | }); |
| 2434 | } |
| 2435 | |
| 2436 | worldEntriesList.append(keywordHeaders); |
| 2437 | worldEntriesList.append(blocks); |
| 2438 | } catch (error) { |
| 2439 | console.error('Error while rendering WI entries:', error); |
| 2440 | } |
| 2441 | }, |
| 2442 | afterSizeSelectorChange: function (e) { |
| 2443 | accountStorage.setItem(storageKey, e.target.value); |
| 2444 | }, |
| 2445 | afterPaging: function () { |
| 2446 | $('#world_popup_entries_list textarea[name="comment"]').each(function () { |
| 2447 | initScrollHeight($(this)); |
| 2448 | }); |
| 2449 | }, |
| 2450 | }); |
| 2451 | |
| 2452 | if (typeof navigation === 'number' && Number(navigation) >= 0) { |
| 2453 | const selector = `#world_popup_entries_list [uid="${navigation}"]`; |
| 2454 | waitUntilCondition(() => document.querySelector(selector) !== null).finally(() => { |
| 2455 | const element = $(selector); |
| 2456 | |
| 2457 | if (element.length === 0) { |
| 2458 | console.log(`Could not find element for uid ${navigation}`); |
| 2459 | return; |
| 2460 | } |
| 2461 | |
| 2462 | const elementOffset = element.offset(); |
| 2463 | const parentOffset = element.parent().offset(); |
| 2464 | const scrollOffset = elementOffset.top - parentOffset.top; |
| 2465 | $('#WorldInfo').scrollTop(scrollOffset); |
| 2466 | if (flashOnNav) flashHighlight(element); |
| 2467 | }); |
| 2468 | } |
| 2469 | |
| 2470 | $('#world_popup_new').off('click').on('click', () => { |
| 2471 | const entry = createWorldInfoEntry(name, data); |
| 2472 | if (entry) updateEditor(entry.uid); |
| 2473 | }); |
| 2474 | |
| 2475 | $('#world_popup_name_button').off('click').on('click', async () => { |
| 2476 | await renameWorldInfo(name, data); |
| 2477 | }); |
| 2478 | |
| 2479 | $('#world_backfill_memos').off('click').on('click', async () => { |
| 2480 | let counter = 0; |
| 2481 | for (const entry of Object.values(data.entries)) { |
| 2482 | if (!entry.comment && Array.isArray(entry.key) && entry.key.length > 0) { |
| 2483 | entry.comment = entry.key.join(', ').slice(0, MAX_COMMENT_LENGTH); |
| 2484 | setWIOriginalDataValue(data, entry.uid, 'comment', entry.comment); |
| 2485 | counter++; |
| 2486 | } |
| 2487 | } |
| 2488 | |
| 2489 | if (counter > 0) { |
| 2490 | toastr.info(`Backfilled ${counter} titles`); |
| 2491 | await saveWorldInfo(name, data); |
| 2492 | updateEditor(navigation_option.previous); |
| 2493 | } |
| 2494 | }); |
| 2495 | |
| 2496 | $('#world_apply_current_sorting').off('click').on('click', async () => { |
| 2497 | const entryCount = Object.keys(data.entries).length; |
| 2498 | const moreThan100 = entryCount > 100; |
| 2499 | |
| 2500 | let content = '<span>' + t`Apply your current sorting to the "Order" field. The Order values will go down from the chosen number.` + '</span>'; |
| 2501 | if (moreThan100) { |
| 2502 | content += '<div class="m-t-1"><i class="fa-solid fa-triangle-exclamation" style="color: #FFD43B;"></i> ' + t`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>'; |
| 2503 | } |
| 2504 | |
| 2505 | const result = await Popup.show.input(t`Apply Current Sorting`, content, '100', { okButton: t`Apply`, cancelButton: 'Cancel' }); |
| 2506 | if (!result) return; |
| 2507 | |
| 2508 | const start = Number(result); |
| 2509 | if (isNaN(start) || start < 0) { |
| 2510 | toastr.error(t`Invalid number: ${result}`, t`Apply Current Sorting`); |
| 2511 | return; |
| 2512 | } |
| 2513 | if (start < entryCount) { |
| 2514 | toastr.warning(t`A number lower than the entry count has been chosen. All entries below that will default to 0.`, t`Apply Current Sorting`); |
| 2515 | } |
| 2516 | |
| 2517 | // We need to sort the entries here, as the data source isn't sorted |
| 2518 | const entries = Object.values(data.entries); |
| 2519 | sortWorldInfoEntries(entries); |
| 2520 | |
| 2521 | let updated = 0, current = start; |
| 2522 | for (const entry of entries) { |
| 2523 | const newOrder = Math.max(current--, 0); |
| 2524 | if (entry.order === newOrder) continue; |
| 2525 | |
| 2526 | entry.order = newOrder; |
| 2527 | setWIOriginalDataValue(data, entry.order, 'order', entry.order); |
| 2528 | updated++; |
| 2529 | } |
| 2530 | |
| 2531 | if (updated > 0) { |
| 2532 | toastr.info(`Updated ${updated} Order values`, 'Apply Custom Sorting'); |
| 2533 | await saveWorldInfo(name, data, true); |
| 2534 | updateEditor(navigation_option.previous); |
| 2535 | } else { |
| 2536 | toastr.info('All values up to date', 'Apply Custom Sorting'); |
| 2537 | } |
| 2538 | }); |
| 2539 | |
| 2540 | $('#world_popup_export').off('click').on('click', () => { |
| 2541 | if (name && data) { |
| 2542 | const jsonValue = JSON.stringify(data); |
| 2543 | const fileName = `${name}.json`; |
| 2544 | download(jsonValue, fileName, 'application/json'); |
| 2545 | } |
| 2546 | }); |
| 2547 | |
| 2548 | $('#world_duplicate').off('click').on('click', async () => { |
| 2549 | // Find current name for the world selected |
| 2550 | const selectedIndex = String($('#world_editor_select').find(':selected').val()); |
| 2551 | const worldName = world_names[selectedIndex] || null; |
| 2552 | |
| 2553 | // Use the current name as default input, then ask user for the name |
| 2554 | const tempName = getFreeWorldName(worldName); |
| 2555 | const finalName = await Popup.show.input('Create a new World Info?', 'Enter a name for the new file:', tempName); |
| 2556 | |
| 2557 | if (finalName) { |
| 2558 | await saveWorldInfo(finalName, data, true); |
| 2559 | await updateWorldInfoList(); |
| 2560 | |
| 2561 | const selectedIndex = world_names.indexOf(finalName); |
| 2562 | if (selectedIndex !== -1) { |
| 2563 | $('#world_editor_select').val(selectedIndex).trigger('change'); |
| 2564 | } else { |
| 2565 | await hideWorldEditor(); |
| 2566 | } |
| 2567 | } |
| 2568 | }); |
| 2569 | |
| 2570 | // Check if a sortable instance exists |
| 2571 | if (worldEntriesList.sortable('instance') !== undefined) { |
| 2572 | // Destroy the instance |
| 2573 | worldEntriesList.sortable('destroy'); |
| 2574 | } |
| 2575 | |
| 2576 | worldEntriesList.sortable({ |
| 2577 | items: '.world_entry', |
| 2578 | delay: getSortableDelay(), |
| 2579 | handle: '.drag-handle', |
| 2580 | stop: async function (_event, _ui) { |
| 2581 | const firstEntryUid = $('#world_popup_entries_list .world_entry').first().data('uid'); |
| 2582 | const minDisplayIndex = data?.entries[firstEntryUid]?.displayIndex ?? 0; |
| 2583 | $('#world_popup_entries_list .world_entry').each(function (index) { |
| 2584 | const uid = $(this).data('uid'); |
| 2585 | |
| 2586 | // Update the display index in the data array |
| 2587 | const item = data.entries[uid]; |
| 2588 | |
| 2589 | if (!item) { |
| 2590 | console.debug(`Could not find entry with uid ${uid}`); |
| 2591 | return; |
| 2592 | } |
| 2593 | |
| 2594 | item.displayIndex = minDisplayIndex + index; |
| 2595 | setWIOriginalDataValue(data, uid, 'extensions.display_index', item.displayIndex); |
| 2596 | }); |
| 2597 | |
| 2598 | console.table(Object.keys(data.entries).map(uid => data.entries[uid]).map(x => ({ uid: x.uid, key: x.key.join(','), displayIndex: x.displayIndex }))); |
| 2599 | |
| 2600 | await saveWorldInfo(name, data); |
| 2601 | }, |
| 2602 | }); |
| 2603 | |
| 2604 | //$("#world_popup_entries_list").disableSelection(); |
| 2605 | } |
| 2606 | |
| 2607 | export const originalWIDataKeyMap = { |
| 2608 | 'displayIndex': 'extensions.display_index', |
| 2609 | 'excludeRecursion': 'extensions.exclude_recursion', |
| 2610 | 'preventRecursion': 'extensions.prevent_recursion', |
| 2611 | 'delayUntilRecursion': 'extensions.delay_until_recursion', |
| 2612 | 'selectiveLogic': 'selectiveLogic', |
| 2613 | 'comment': 'comment', |
| 2614 | 'constant': 'constant', |
| 2615 | 'order': 'insertion_order', |
| 2616 | 'depth': 'extensions.depth', |
| 2617 | 'probability': 'extensions.probability', |
| 2618 | 'position': 'extensions.position', |
| 2619 | 'role': 'extensions.role', |
| 2620 | 'content': 'content', |
| 2621 | 'enabled': 'enabled', |
| 2622 | 'key': 'keys', |
| 2623 | 'keysecondary': 'secondary_keys', |
| 2624 | 'selective': 'selective', |
| 2625 | 'matchWholeWords': 'extensions.match_whole_words', |
| 2626 | 'useGroupScoring': 'extensions.use_group_scoring', |
| 2627 | 'caseSensitive': 'extensions.case_sensitive', |
| 2628 | 'matchPersonaDescription': 'extensions.match_persona_description', |
| 2629 | 'matchCharacterDescription': 'extensions.match_character_description', |
| 2630 | 'matchCharacterPersonality': 'extensions.match_character_personality', |
| 2631 | 'matchCharacterDepthPrompt': 'extensions.match_character_depth_prompt', |
| 2632 | 'matchScenario': 'extensions.match_scenario', |
| 2633 | 'matchCreatorNotes': 'extensions.match_creator_notes', |
| 2634 | 'scanDepth': 'extensions.scan_depth', |
| 2635 | 'automationId': 'extensions.automation_id', |
| 2636 | 'vectorized': 'extensions.vectorized', |
| 2637 | 'groupOverride': 'extensions.group_override', |
| 2638 | 'groupWeight': 'extensions.group_weight', |
| 2639 | 'sticky': 'extensions.sticky', |
| 2640 | 'cooldown': 'extensions.cooldown', |
| 2641 | 'delay': 'extensions.delay', |
| 2642 | 'triggers': 'extensions.triggers', |
| 2643 | 'ignoreBudget': 'extensions.ignore_budget', |
| 2644 | }; |
| 2645 | |
| 2646 | /** Checks the state of the current search, and adds/removes the search sorting option accordingly */ |
| 2647 | function verifyWorldInfoSearchSortRule() { |
| 2648 | const searchTerm = worldInfoFilter.getFilterData(FILTER_TYPES.WORLD_INFO_SEARCH); |
| 2649 | const searchOption = $('#world_info_sort_order option[data-rule="search"]'); |
| 2650 | const selector = $('#world_info_sort_order'); |
| 2651 | const isHidden = searchOption.attr('hidden') !== undefined; |
| 2652 | |
| 2653 | // If we have a search term, we are displaying the sorting option for it |
| 2654 | if (searchTerm && isHidden) { |
| 2655 | searchOption.removeAttr('hidden'); |
| 2656 | selector.val(searchOption.attr('value') || '0'); |
| 2657 | flashHighlight(selector); |
| 2658 | } |
| 2659 | // If search got cleared, we make sure to hide the option and go back to the one before |
| 2660 | if (!searchTerm && !isHidden) { |
| 2661 | searchOption.attr('hidden', ''); |
| 2662 | selector.val(accountStorage.getItem(SORT_ORDER_KEY) || '0'); |
| 2663 | } |
| 2664 | } |
| 2665 | |
| 2666 | /** |
| 2667 | * Sets the value of a specific key in the original data entry corresponding to the given uid |
| 2668 | * This needs to be called whenever you update JSON data fields. |
| 2669 | * Use `originalWIDataKeyMap` to find the correct value to be set. |
| 2670 | * |
| 2671 | * @param {object} data - The data object containing the original data entries. |
| 2672 | * @param {number} uid - The unique identifier of the data entry. |
| 2673 | * @param {string} key - The key of the value to be set. |
| 2674 | * @param {any} value - The value to be set. |
| 2675 | */ |
| 2676 | export function setWIOriginalDataValue(data, uid, key, value) { |
| 2677 | if (data.originalData && Array.isArray(data.originalData.entries)) { |
| 2678 | let originalEntry = data.originalData.entries.find(x => x.uid === uid); |
| 2679 | |
| 2680 | if (!originalEntry) { |
| 2681 | return; |
| 2682 | } |
| 2683 | |
| 2684 | setValueByPath(originalEntry, key, value); |
| 2685 | } |
| 2686 | } |
| 2687 | |
| 2688 | /** |
| 2689 | * Deletes the original data entry corresponding to the given uid from the provided data object |
| 2690 | * |
| 2691 | * @param {object} data - The data object containing the original data entries |
| 2692 | * @param {string} uid - The unique identifier of the data entry to be deleted |
| 2693 | */ |
| 2694 | export function deleteWIOriginalDataValue(data, uid) { |
| 2695 | if (data.originalData && Array.isArray(data.originalData.entries)) { |
| 2696 | // Non-strict equality is used here to allow for both string and number comparisons |
| 2697 | // @eslint-disable-next-line eqeqeq |
| 2698 | const originalIndex = data.originalData.entries.findIndex(x => x.uid == uid); |
| 2699 | |
| 2700 | if (originalIndex >= 0) { |
| 2701 | data.originalData.entries.splice(originalIndex, 1); |
| 2702 | } |
| 2703 | } |
| 2704 | } |
| 2705 | |
| 2706 | /** @typedef {import('./utils.js').Select2Option} Select2Option */ |
| 2707 | |
| 2708 | /** |
| 2709 | * Splits a given input string that contains one or more keywords or regexes, separated by commas. |
| 2710 | * |
| 2711 | * Each part can be a valid regex following the pattern `/myregex/flags` with optional flags. Commas inside the regex are allowed, slashes have to be escaped like this: `\/` |
| 2712 | * If a regex doesn't stand alone, it is not treated as a regex. |
| 2713 | * |
| 2714 | * @param {string} input - One or multiple keywords or regexes, separated by commas |
| 2715 | * @returns {string[]} An array of keywords and regexes |
| 2716 | */ |
| 2717 | export function splitKeywordsAndRegexes(input) { |
| 2718 | /** @type {string[]} */ |
| 2719 | let keywordsAndRegexes = []; |
| 2720 | |
| 2721 | // We can make this easy. Instead of writing another function to find and parse regexes, |
| 2722 | // we gonna utilize the custom tokenizer that also handles the input. |
| 2723 | // No need for validation here |
| 2724 | const addFindCallback = (/** @type {Select2Option} */ item) => { |
| 2725 | keywordsAndRegexes.push(item.text); |
| 2726 | }; |
| 2727 | |
| 2728 | const { term } = customTokenizer({ _type: 'custom_call', term: input }, undefined, addFindCallback); |
| 2729 | const finalTerm = term.trim(); |
| 2730 | if (finalTerm) { |
| 2731 | addFindCallback({ id: getSelect2OptionId(finalTerm), text: finalTerm }); |
| 2732 | } |
| 2733 | |
| 2734 | return keywordsAndRegexes; |
| 2735 | } |
| 2736 | |
| 2737 | /** |
| 2738 | * Tokenizer parsing input and splitting it into keywords and regexes |
| 2739 | * |
| 2740 | * @param {{_type: string, term: string}} input - The typed input |
| 2741 | * @param {{options: object}} _selection - The selection even object (?) |
| 2742 | * @param {function(Select2Option):void} callback - The original callback function to call if an item should be inserted |
| 2743 | * @returns {{term: string}} - The remaining part that is untokenized in the textbox |
| 2744 | */ |
| 2745 | function customTokenizer(input, _selection, callback) { |
| 2746 | let current = input.term; |
| 2747 | |
| 2748 | let insideRegex = false, regexClosed = false; |
| 2749 | |
| 2750 | // Go over the input and check the current state, if we can get a token |
| 2751 | for (let i = 0; i < current.length; i++) { |
| 2752 | let char = current[i]; |
| 2753 | |
| 2754 | // If we find an unascaped slash, set the current regex state |
| 2755 | if (char === '/' && (i === 0 || current[i - 1] !== '\\')) { |
| 2756 | if (!insideRegex) insideRegex = true; |
| 2757 | else if (!regexClosed) regexClosed = true; |
| 2758 | } |
| 2759 | |
| 2760 | // If a comma is typed, we tokenize the input. |
| 2761 | // unless we are inside a possible regex, which would allow commas inside |
| 2762 | if (char === ',') { |
| 2763 | // We take everything up till now and consider this a token |
| 2764 | const token = current.slice(0, i).trim(); |
| 2765 | |
| 2766 | // Now how we test if this is a regex? And not a finished one, but a half-finished one? |
| 2767 | // We use the state remembered from above to check whether the delimiter was opened but not closed yet. |
| 2768 | // We don't check validity here if we are inside a regex, because it might only get valid after its finished. (Closing brackets, etc) |
| 2769 | // Validity will be finally checked when the next comma is typed. |
| 2770 | if (insideRegex && !regexClosed) { |
| 2771 | continue; |
| 2772 | } |
| 2773 | |
| 2774 | // So now the comma really means the token is done. |
| 2775 | // We take the token up till now, and insert it. Empty will be skipped. |
| 2776 | if (token) { |
| 2777 | const isRegex = isValidRegex(token); |
| 2778 | |
| 2779 | // 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. |
| 2780 | if (token.startsWith('/') && !isRegex) { |
| 2781 | const tokens = token.split(',').map(x => x.trim()); |
| 2782 | tokens.forEach(x => callback({ id: getSelect2OptionId(x), text: x })); |
| 2783 | } else { |
| 2784 | callback({ id: getSelect2OptionId(token), text: token }); |
| 2785 | } |
| 2786 | } |
| 2787 | |
| 2788 | // Now remove the token from the current input, and the comma too |
| 2789 | current = current.slice(i + 1); |
| 2790 | insideRegex = false; |
| 2791 | regexClosed = false; |
| 2792 | i = 0; |
| 2793 | } |
| 2794 | } |
| 2795 | |
| 2796 | // At the end, just return the left-over input |
| 2797 | return { term: current }; |
| 2798 | } |
| 2799 | |
| 2800 | /** |
| 2801 | * Validates if a string is a valid slash-delimited regex, that can be parsed and executed |
| 2802 | * |
| 2803 | * This is a wrapper around `parseRegexFromString` |
| 2804 | * |
| 2805 | * @param {string} input - A delimited regex string |
| 2806 | * @returns {boolean} Whether this would be a valid regex that can be parsed and executed |
| 2807 | */ |
| 2808 | function isValidRegex(input) { |
| 2809 | return parseRegexFromString(input) !== null; |
| 2810 | } |
| 2811 | |
| 2812 | /** |
| 2813 | * Gets a real regex object from a slash-delimited regex string |
| 2814 | * |
| 2815 | * This function works with `/` as delimiter, and each occurance of it inside the regex has to be escaped. |
| 2816 | * Flags are optional, but can only be valid flags supported by JavaScript's `RegExp` (`g`, `i`, `m`, `s`, `u`, `y`). |
| 2817 | * |
| 2818 | * @param {string} input - A delimited regex string |
| 2819 | * @returns {RegExp|null} The regex object, or null if not a valid regex |
| 2820 | */ |
| 2821 | export function parseRegexFromString(input) { |
| 2822 | // Extracting the regex pattern and flags |
| 2823 | let match = input.match(/^\/([\w\W]+?)\/([gimsuy]*)$/); |
| 2824 | if (!match) { |
| 2825 | return null; // Not a valid regex format |
| 2826 | } |
| 2827 | |
| 2828 | let [, pattern, flags] = match; |
| 2829 | |
| 2830 | // If we find any unescaped slash delimiter, we also exit out. |
| 2831 | // JS doesn't care about delimiters inside regex patterns, but for this to be a valid regex outside of our implementation, |
| 2832 | // we have to make sure that our delimiter is correctly escaped. Or every other engine would fail. |
| 2833 | if (pattern.match(/(^|[^\\])\//)) { |
| 2834 | return null; |
| 2835 | } |
| 2836 | |
| 2837 | // Now we need to actually unescape the slash delimiters, because JS doesn't care about delimiters |
| 2838 | pattern = pattern.replace('\\/', '/'); |
| 2839 | |
| 2840 | // Then we return the regex. If it fails, it was invalid syntax. |
| 2841 | try { |
| 2842 | return new RegExp(pattern, flags); |
| 2843 | } catch (e) { |
| 2844 | return null; |
| 2845 | } |
| 2846 | } |
| 2847 | |
| 2848 | /** |
| 2849 | * Enables the input helper for keys in a World Info entry. |
| 2850 | * @param {object} params - Parameters for enabling the keys input helper. |
| 2851 | * @param {JQuery<HTMLElement>} params.template - The template element containing the input. |
| 2852 | * @param {object} params.entry - The entry object containing the keys. |
| 2853 | * @param {string} params.entryPropName - The property name of the entry that holds the keys. |
| 2854 | * @param {string} params.originalDataValueName - The name of the original data value to be set. |
| 2855 | * @param {string} params.name - The name of the world info entry. |
| 2856 | * @param {object} params.data - The data object containing entries. |
| 2857 | */ |
| 2858 | function enableKeysInputHelper({ template, entry, entryPropName, originalDataValueName, name, data }) { |
| 2859 | const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext; |
| 2860 | const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`); |
| 2861 | input.data('uid', entry.uid); |
| 2862 | input[0].dataset.macros = ''; // active |
| 2863 | input.on('click', function (event) { |
| 2864 | event.stopPropagation(); |
| 2865 | }); |
| 2866 | |
| 2867 | function templateStyling(item, { searchStyle = false } = {}) { |
| 2868 | const content = $('<span>').addClass('item').text(item.text).attr('title', `${item.text}\n\nClick to edit`); |
| 2869 | const isRegex = isValidRegex(item.text); |
| 2870 | if (isRegex) { |
| 2871 | content.html(highlightRegex(item.text)); |
| 2872 | content.addClass('regex_item').prepend($('<span>').addClass('regex_icon').text('•*').attr('title', 'Regex')); |
| 2873 | } |
| 2874 | if (searchStyle && item.count) { |
| 2875 | const wrapper = $('<span>').addClass('result_block').append(content); |
| 2876 | 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`)); |
| 2877 | return wrapper; |
| 2878 | } |
| 2879 | return content; |
| 2880 | } |
| 2881 | |
| 2882 | if (isFancyInput) { |
| 2883 | select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } }); |
| 2884 | input.select2({ |
| 2885 | ajax: dynamicSelect2DataViaAjax(() => worldEntryKeyOptionsCache), |
| 2886 | tags: true, |
| 2887 | tokenSeparators: [','], |
| 2888 | // @ts-ignore |
| 2889 | tokenizer: customTokenizer, |
| 2890 | placeholder: input.attr('placeholder'), |
| 2891 | templateResult: item => templateStyling(item, { searchStyle: true }), |
| 2892 | templateSelection: item => templateStyling(item), |
| 2893 | }); |
| 2894 | |
| 2895 | // TypeScript-safe event handler |
| 2896 | /** |
| 2897 | * @param {Event} _event |
| 2898 | * @param {{ skipReset?: boolean, noSave?: boolean }} [arg] |
| 2899 | */ |
| 2900 | input.on('change', async function (_event, arg) { |
| 2901 | const uid = $(this).data('uid'); |
| 2902 | const keys = ($(this).select2('data')).map(x => x.text); |
| 2903 | const skipReset = arg?.skipReset ?? false; |
| 2904 | const noSave = arg?.noSave ?? false; |
| 2905 | if (!skipReset) await resetScrollHeight(this); |
| 2906 | if (!noSave) { |
| 2907 | data.entries[uid][entryPropName] = keys; |
| 2908 | setWIOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]); |
| 2909 | await saveWorldInfo(name, data); |
| 2910 | } |
| 2911 | $(this).toggleClass('empty', !data.entries[uid][entryPropName].length); |
| 2912 | // Update the commentInput's placeholder for primary keys |
| 2913 | if (entryPropName === 'key') { |
| 2914 | const commentInput = $(_event.currentTarget).closest('.world_entry_form').find('textarea[name="comment"]'); |
| 2915 | setCommentPlaceholder(data.entries[uid][entryPropName].join(', '), commentInput); |
| 2916 | } |
| 2917 | }); |
| 2918 | |
| 2919 | input.toggleClass('empty', !entry[entryPropName].length); |
| 2920 | input.on('select2:select', event => updateWorldEntryKeyOptionsCache([event.params.data])); |
| 2921 | input.on('select2:unselect', event => updateWorldEntryKeyOptionsCache([event.params.data], { remove: true })); |
| 2922 | |
| 2923 | select2ChoiceClickSubscribe(input, target => { |
| 2924 | const key = $(target.closest('.regex-highlight, .item')).text(); |
| 2925 | const selected = input.val(); |
| 2926 | if (!Array.isArray(selected)) return; |
| 2927 | var index = selected.indexOf(getSelect2OptionId(key)); |
| 2928 | if (index > -1) selected.splice(index, 1); |
| 2929 | input.val(selected).trigger('change'); |
| 2930 | updateWorldEntryKeyOptionsCache([key], { remove: true }); |
| 2931 | input.next('span.select2-container').find('textarea').val(key).trigger('input'); |
| 2932 | }, { openDrawer: true }); |
| 2933 | } else { |
| 2934 | template.find(`select[name="${entryPropName}"]`).hide(); |
| 2935 | input.show(); |
| 2936 | /** |
| 2937 | * @param {Event} _event |
| 2938 | * @param {{ skipReset?: boolean, noSave?: boolean }} [arg] |
| 2939 | */ |
| 2940 | input.on('change', async function (_event, arg) { |
| 2941 | const uid = $(this).data('uid'); |
| 2942 | const value = String($(this).val()); |
| 2943 | const skipReset = arg?.skipReset ?? false; |
| 2944 | const noSave = arg?.noSave ?? false; |
| 2945 | if (!skipReset) await resetScrollHeight(this); |
| 2946 | if (!noSave) { |
| 2947 | data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value); |
| 2948 | setWIOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]); |
| 2949 | await saveWorldInfo(name, data); |
| 2950 | $(this).toggleClass('empty', !data.entries[uid][entryPropName].length); |
| 2951 | } |
| 2952 | // Update the commentInput's placeholder for primary keys |
| 2953 | if (entryPropName === 'key') { |
| 2954 | const commentInput = $(_event.currentTarget).closest('.world_entry_form').find('textarea[name="comment"]'); |
| 2955 | setCommentPlaceholder(value, commentInput); |
| 2956 | } |
| 2957 | }); |
| 2958 | input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true }); |
| 2959 | } |
| 2960 | return { isFancy: isFancyInput, control: input }; |
| 2961 | } |
| 2962 | |
| 2963 | /** |
| 2964 | * Helper to handle match checkboxes for WI entries. |
| 2965 | * @param {object} params - Parameters for handling match checkboxes. |
| 2966 | * @param {JQuery<HTMLElement>} params.template - The template element containing the checkbox. |
| 2967 | * @param {object} params.entry - The entry object containing the checkbox state. |
| 2968 | * @param {string} params.fieldName - The name of the checkbox field. |
| 2969 | * @param {object} params.data - The data object containing entries. |
| 2970 | * @param {string} params.name - The name of the world info to save changes to. |
| 2971 | */ |
| 2972 | function handleMatchCheckboxHelper({ template, entry, fieldName, data, name }) { |
| 2973 | const key = originalWIDataKeyMap[fieldName]; |
| 2974 | const checkBoxElem = template.find(`input[type="checkbox"][name="${fieldName}"]`); |
| 2975 | checkBoxElem.data('uid', entry.uid); |
| 2976 | checkBoxElem.on('input', async function (_, { noSave = false } = {}) { |
| 2977 | const uid = $(this).data('uid'); |
| 2978 | const value = $(this).prop('checked'); |
| 2979 | data.entries[uid][fieldName] = value; |
| 2980 | setWIOriginalDataValue(data, uid, key, data.entries[uid][fieldName]); |
| 2981 | !noSave && await saveWorldInfo(name, data); |
| 2982 | }); |
| 2983 | checkBoxElem.prop('checked', !!entry[fieldName]).trigger('input', { noSave: true }); |
| 2984 | } |
| 2985 | |
| 2986 | /** |
| 2987 | * Helper to update position/order display. |
| 2988 | * @param {object} params - Parameters for updating position/order display. |
| 2989 | * @param {JQuery<HTMLElement>} params.template - The template element containing the display. |
| 2990 | * @param {object} params.data - The data object containing entries. |
| 2991 | * @param {string} params.uid - The unique identifier of the entry to update. |
| 2992 | */ |
| 2993 | function updatePosOrdDisplayHelper({ template, data, uid }) { |
| 2994 | let entry = data.entries[uid]; |
| 2995 | let posText = entry.position; |
| 2996 | switch (entry.position) { |
| 2997 | case 0: posText = '↑CD'; break; |
| 2998 | case 1: posText = 'CD↓'; break; |
| 2999 | case 2: posText = '↑AN'; break; |
| 3000 | case 3: posText = 'AN↓'; break; |
| 3001 | case 4: posText = `@D${entry.depth}`; break; |
| 3002 | } |
| 3003 | template.find('.world_entry_form_position_value').text(`(${posText} ${entry.order})`); |
| 3004 | } |
| 3005 | |
| 3006 | /** |
| 3007 | * Helper to initialize character filter select2. |
| 3008 | * @param {JQuery<HTMLElement>} characterFilter - The select element for character filter. |
| 3009 | */ |
| 3010 | function initCharacterFilterSelect2Helper(characterFilter) { |
| 3011 | if (!isMobile()) { |
| 3012 | $(characterFilter).select2({ |
| 3013 | width: '100%', |
| 3014 | placeholder: t`Tie this entry to specific characters or characters with specific tags`, |
| 3015 | allowClear: true, |
| 3016 | closeOnSelect: false, |
| 3017 | }); |
| 3018 | } |
| 3019 | } |
| 3020 | |
| 3021 | /** |
| 3022 | * Helper to fill character and tag options for character filter. |
| 3023 | * @param {object} params - Parameters for filling options. |
| 3024 | * @param {JQuery<HTMLElement>} params.characterFilter - The select element to fill with options. |
| 3025 | * @param {object} params.entry - The entry object containing character filter data. |
| 3026 | */ |
| 3027 | function fillCharacterAndTagOptionsHelper({ characterFilter, entry }) { |
| 3028 | const characters = getContext().characters; |
| 3029 | characters.forEach((character) => { |
| 3030 | const option = document.createElement('option'); |
| 3031 | const name = character.avatar.replace(/\.[^/.]+$/, '') ?? character.name; |
| 3032 | option.innerText = name; |
| 3033 | option.selected = entry.characterFilter?.names?.includes(name); |
| 3034 | option.setAttribute('data-type', 'character'); |
| 3035 | characterFilter.append(option); |
| 3036 | }); |
| 3037 | const tags = getContext().tags; |
| 3038 | tags.forEach((tag) => { |
| 3039 | const option = document.createElement('option'); |
| 3040 | option.innerText = `[Tag] ${tag.name}`; |
| 3041 | option.selected = entry.characterFilter?.tags?.includes(tag.id); |
| 3042 | option.value = tag.id; |
| 3043 | option.setAttribute('data-type', 'tag'); |
| 3044 | characterFilter.append(option); |
| 3045 | }); |
| 3046 | } |
| 3047 | |
| 3048 | /** |
| 3049 | * Helper to handle character filter changes. |
| 3050 | * @param {object} params - Parameters for handling character filter changes. |
| 3051 | * @param {JQuery<HTMLElement>} params.characterFilter - The select element for character filter. |
| 3052 | * @param {object} params.data - The data object containing entries. |
| 3053 | * @param {object} params.entry - The entry object to update. |
| 3054 | * @param {string} params.name - The name of the world info to save changes to. |
| 3055 | */ |
| 3056 | function handleCharacterFilterChangeHelper({ characterFilter, data, entry, name }) { |
| 3057 | characterFilter.on('mousedown change', async function (e) { |
| 3058 | if (world_names.length === 0) { |
| 3059 | e.preventDefault(); |
| 3060 | return; |
| 3061 | } |
| 3062 | const uid = $(this).data('uid'); |
| 3063 | const selected = $(this).find(':selected'); |
| 3064 | if ((!selected || selected?.length === 0) && !data.entries[uid].characterFilter?.isExclude) { |
| 3065 | delete data.entries[uid].characterFilter; |
| 3066 | } else { |
| 3067 | const names = selected.filter('[data-type="character"]').map((_, e) => e instanceof HTMLOptionElement && e.innerText).toArray(); |
| 3068 | const tags = selected.filter('[data-type="tag"]').map((_, e) => e instanceof HTMLOptionElement && e.value).toArray(); |
| 3069 | Object.assign( |
| 3070 | data.entries[uid], |
| 3071 | { |
| 3072 | characterFilter: { |
| 3073 | isExclude: data.entries[uid].characterFilter?.isExclude ?? false, |
| 3074 | names: names, |
| 3075 | tags: tags, |
| 3076 | }, |
| 3077 | }, |
| 3078 | ); |
| 3079 | } |
| 3080 | setWIOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter); |
| 3081 | await saveWorldInfo(name, data); |
| 3082 | }); |
| 3083 | } |
| 3084 | |
| 3085 | /** |
| 3086 | * Helper to handle probability input. |
| 3087 | * @param {object} params - Parameters for handling probability input. |
| 3088 | * @param {JQuery<HTMLElement>} params.probabilityInput - The input element for probability. |
| 3089 | * @param {object} params.data - The data object containing entries. |
| 3090 | * @param {object} params.entry - The entry object to update. |
| 3091 | * @param {string} params.name - The name of the world info to save changes to. |
| 3092 | */ |
| 3093 | function handleProbabilityInputHelper({ probabilityInput, data, entry, name }) { |
| 3094 | probabilityInput.data('uid', entry.uid); |
| 3095 | probabilityInput.on('input', async function (_, { noSave = false } = {}) { |
| 3096 | const uid = $(this).data('uid'); |
| 3097 | const value = Number($(this).val()); |
| 3098 | data.entries[uid].probability = !isNaN(value) ? value : null; |
| 3099 | if (data.entries[uid].probability !== null) { |
| 3100 | data.entries[uid].probability = Math.min(100, Math.max(0, data.entries[uid].probability)); |
| 3101 | if (data.entries[uid].probability !== value) { |
| 3102 | $(this).val(data.entries[uid].probability); |
| 3103 | } |
| 3104 | } |
| 3105 | setWIOriginalDataValue(data, uid, 'extensions.probability', data.entries[uid].probability); |
| 3106 | !noSave && await saveWorldInfo(name, data); |
| 3107 | }); |
| 3108 | probabilityInput.val(entry.probability).trigger('input', { noSave: true }); |
| 3109 | probabilityInput.css('width', 'calc(3em + 15px)'); |
| 3110 | } |
| 3111 | |
| 3112 | /** |
| 3113 | * Helper to handle probability toggle. |
| 3114 | * @param {object} params - Parameters for handling probability toggle. |
| 3115 | * @param {JQuery<HTMLElement>} params.probabilityToggle - The toggle element for probability. |
| 3116 | * @param {object} params.data - The data object containing entries. |
| 3117 | * @param {object} params.entry - The entry object to update. |
| 3118 | * @param {string} params.name - The name of the world info to save changes to. |
| 3119 | * @param {JQuery<HTMLElement>} params.probabilityInput - The input element for probability. |
| 3120 | */ |
| 3121 | function handleProbabilityToggleHelper({ probabilityToggle, data, entry, name, probabilityInput }) { |
| 3122 | probabilityToggle.data('uid', entry.uid); |
| 3123 | probabilityToggle.on('input', async function (_, { noSave = false } = {}) { |
| 3124 | const uid = $(this).data('uid'); |
| 3125 | const value = $(this).prop('checked'); |
| 3126 | data.entries[uid].useProbability = value; |
| 3127 | const probabilityContainer = $(this).closest('.world_entry').find('.probabilityContainer'); |
| 3128 | !noSave && await saveWorldInfo(name, data); |
| 3129 | value ? probabilityContainer.show() : probabilityContainer.hide(); |
| 3130 | if (value && data.entries[uid].probability === null) { |
| 3131 | data.entries[uid].probability = 100; |
| 3132 | } |
| 3133 | if (!value) { |
| 3134 | data.entries[uid].probability = null; |
| 3135 | } |
| 3136 | probabilityInput.val(data.entries[uid].probability).trigger('input', { noSave }); |
| 3137 | }); |
| 3138 | probabilityToggle.prop('checked', true).trigger('input', { noSave: true }); |
| 3139 | probabilityToggle.parent().hide(); |
| 3140 | } |
| 3141 | |
| 3142 | /** |
| 3143 | * Helper to handle select2 dropdowns for boolean selects. |
| 3144 | * @param {object} params - Parameters for handling boolean selects. |
| 3145 | * @param {JQuery<HTMLElement>} params.selectElem - The select element for boolean values. |
| 3146 | * @param {object} params.entry - The entry object containing the boolean value. |
| 3147 | * @param {string} params.entryKey - The key in the entry object for the boolean value. |
| 3148 | * @param {object} params.data - The data object containing entries. |
| 3149 | * @param {string} params.name - The name of the world info to save changes to. |
| 3150 | */ |
| 3151 | function handleBooleanSelectHelper({ selectElem, entry, entryKey, data, name }) { |
| 3152 | selectElem.data('uid', entry.uid); |
| 3153 | selectElem.on('input', async function (_, { noSave = false } = {}) { |
| 3154 | const uid = $(this).data('uid'); |
| 3155 | const value = $(this).val(); |
| 3156 | data.entries[uid][entryKey] = value === 'null' ? null : value === 'true'; |
| 3157 | setWIOriginalDataValue(data, uid, `extensions.${entryKey.replace(/[A-Z]/g, m => `_${m.toLowerCase()}`)}`, data.entries[uid][entryKey]); |
| 3158 | !noSave && await saveWorldInfo(name, data); |
| 3159 | }); |
| 3160 | selectElem.val((entry[entryKey] === null || entry[entryKey] === undefined) ? 'null' : entry[entryKey] ? 'true' : 'false').trigger('input', { noSave: true }); |
| 3161 | } |
| 3162 | |
| 3163 | /** |
| 3164 | * Helper to handle input fields for numbers. |
| 3165 | * @param {object} params - Parameters for handling number inputs. |
| 3166 | * @param {JQuery<HTMLElement>} params.inputElem - The input element for the number. |
| 3167 | * @param {object} params.entry - The entry object containing the number value. |
| 3168 | * @param {string} params.entryKey - The key in the entry object for the number value. |
| 3169 | * @param {object} params.data - The data object containing entries. |
| 3170 | * @param {string} params.name - The name of the world info to save changes to. |
| 3171 | * @param {number} params.min - The minimum value for the number input. |
| 3172 | * @param {number} params.max - The maximum value for the number input. |
| 3173 | * @param {boolean} [params.clamp=false] - Whether to clamp the value within the min and max range. |
| 3174 | */ |
| 3175 | function handleNumberInputHelper({ inputElem, entry, entryKey, data, name, min, max, clamp = false }) { |
| 3176 | inputElem.data('uid', entry.uid); |
| 3177 | inputElem.on('input', async function (_, { noSave = false } = {}) { |
| 3178 | const uid = $(this).data('uid'); |
| 3179 | let value = Number($(this).val()); |
| 3180 | if (clamp) { |
| 3181 | if (value < min) { |
| 3182 | value = min; |
| 3183 | $(this).val(min); |
| 3184 | } else if (value > max) { |
| 3185 | value = max; |
| 3186 | $(this).val(max); |
| 3187 | } |
| 3188 | } |
| 3189 | data.entries[uid][entryKey] = !isNaN(value) ? value : null; |
| 3190 | setWIOriginalDataValue(data, uid, `extensions.${entryKey.replace(/[A-Z]/g, m => `_${m.toLowerCase()}`)}`, data.entries[uid][entryKey]); |
| 3191 | !noSave && await saveWorldInfo(name, data); |
| 3192 | }); |
| 3193 | inputElem.val(entry[entryKey] ?? (clamp ? min : '')).trigger('input', { noSave: true }); |
| 3194 | } |
| 3195 | |
| 3196 | /** |
| 3197 | * Helper to handle tri-state selector for constant/normal/vectorized. |
| 3198 | * @param {object} params - Parameters for handling the entry state selector. |
| 3199 | * @param {JQuery<HTMLElement>} params.entryStateSelector - The select element for entry state. |
| 3200 | * @param {object} params.entry - The entry object containing the state. |
| 3201 | * @param {object} params.data - The data object containing entries. |
| 3202 | * @param {string} params.name - The name of the world info to save changes to. |
| 3203 | */ |
| 3204 | function handleEntryStateSelectorHelper({ entryStateSelector, entry, data, name }) { |
| 3205 | entryStateSelector.data('uid', entry.uid); |
| 3206 | entryStateSelector.on('click', function (event) { |
| 3207 | event.stopPropagation(); |
| 3208 | }); |
| 3209 | entryStateSelector.on('input', async function (_, { noSave = false } = {}) { |
| 3210 | const uid = entry.uid; |
| 3211 | const value = $(this).val(); |
| 3212 | switch (value) { |
| 3213 | case 'constant': |
| 3214 | data.entries[uid].constant = true; |
| 3215 | data.entries[uid].vectorized = false; |
| 3216 | setWIOriginalDataValue(data, uid, 'constant', true); |
| 3217 | setWIOriginalDataValue(data, uid, 'extensions.vectorized', false); |
| 3218 | break; |
| 3219 | case 'normal': |
| 3220 | data.entries[uid].constant = false; |
| 3221 | data.entries[uid].vectorized = false; |
| 3222 | setWIOriginalDataValue(data, uid, 'constant', false); |
| 3223 | setWIOriginalDataValue(data, uid, 'extensions.vectorized', false); |
| 3224 | break; |
| 3225 | case 'vectorized': |
| 3226 | data.entries[uid].constant = false; |
| 3227 | data.entries[uid].vectorized = true; |
| 3228 | setWIOriginalDataValue(data, uid, 'constant', false); |
| 3229 | setWIOriginalDataValue(data, uid, 'extensions.vectorized', true); |
| 3230 | break; |
| 3231 | } |
| 3232 | !noSave && await saveWorldInfo(name, data); |
| 3233 | }); |
| 3234 | const entryState = () => entry.constant === true ? 'constant' : entry.vectorized === true ? 'vectorized' : 'normal'; |
| 3235 | entryStateSelector.find(`option[value=${entryState()}]`).prop('selected', true).trigger('input', { noSave: true }); |
| 3236 | } |
| 3237 | |
| 3238 | /** |
| 3239 | * Helper to handle kill switch toggle. |
| 3240 | * @param {object} params - Parameters for handling the kill switch toggle. |
| 3241 | * @param {JQuery<HTMLElement>} params.entryKillSwitch - The toggle element for the kill switch. |
| 3242 | * @param {object} params.entry - The entry object containing the state. |
| 3243 | * @param {object} params.data - The data object containing entries. |
| 3244 | * @param {string} params.name - The name of the world info to save changes to. |
| 3245 | * @param {JQuery<HTMLElement>} params.template - The template element for the entry. |
| 3246 | */ |
| 3247 | function handleEntryKillSwitchHelper({ entryKillSwitch, entry, data, name, template }) { |
| 3248 | entryKillSwitch.data('uid', entry.uid); |
| 3249 | entryKillSwitch.on('click', async function () { |
| 3250 | const uid = entry.uid; |
| 3251 | data.entries[uid].disable = !data.entries[uid].disable; |
| 3252 | const isActive = !data.entries[uid].disable; |
| 3253 | setWIOriginalDataValue(data, uid, 'enabled', isActive); |
| 3254 | template.toggleClass('disabledWIEntry', !isActive); |
| 3255 | entryKillSwitch.toggleClass('fa-toggle-off', !isActive); |
| 3256 | entryKillSwitch.toggleClass('fa-toggle-on', isActive); |
| 3257 | await saveWorldInfo(name, data); |
| 3258 | }); |
| 3259 | const isActive = !entry.disable; |
| 3260 | template.toggleClass('disabledWIEntry', !isActive); |
| 3261 | entryKillSwitch.toggleClass('fa-toggle-off', !isActive); |
| 3262 | entryKillSwitch.toggleClass('fa-toggle-on', isActive); |
| 3263 | } |
| 3264 | |
| 3265 | /** |
| 3266 | * Update commentInput's placeholder. |
| 3267 | * @param {string} keys Text to display in commentInput's placeholder. |
| 3268 | * @param {JQuery<HTMLElement>} commentInput The comment input element. |
| 3269 | */ |
| 3270 | function setCommentPlaceholder(keys, commentInput) { |
| 3271 | // Limit placeholder text to avoid performance issues. |
| 3272 | keys = keys.slice(0, MAX_COMMENT_LENGTH); |
| 3273 | commentInput.attr('placeholder', (keys || t`Entry Title/Memo`)); |
| 3274 | } |
| 3275 | |
| 3276 | /** |
| 3277 | * Main function to build the WI entry editor template. |
| 3278 | * @param {string} name - The name of the world info file. |
| 3279 | * @param {object} data - The world info data object. |
| 3280 | * @param {object} entry - The entry object to be edited. |
| 3281 | */ |
| 3282 | export async function getWorldEntry(name, data, entry) { |
| 3283 | if (!data.entries[entry.uid]) return; |
| 3284 | |
| 3285 | const headerTemplate = WI_ENTRY_HEADER_TEMPLATE.clone(); |
| 3286 | headerTemplate.data('uid', entry.uid); |
| 3287 | headerTemplate.attr('uid', entry.uid); |
| 3288 | |
| 3289 | if (typeof power_user.wi_key_input_plaintext === 'undefined') power_user.wi_key_input_plaintext = true; |
| 3290 | |
| 3291 | // Comment |
| 3292 | const commentInput = headerTemplate.find('textarea[name="comment"]'); |
| 3293 | |
| 3294 | //Update the commentInput's placeholder. |
| 3295 | const keys = entry.key.join(', '); |
| 3296 | setCommentPlaceholder(keys, commentInput); |
| 3297 | |
| 3298 | commentInput.data('uid', entry.uid); |
| 3299 | commentInput.on('input', async function (_, { skipReset = false, noSave = false } = {}) { |
| 3300 | const uid = $(this).data('uid'); |
| 3301 | const value = $(this).val(); |
| 3302 | !skipReset && await resetScrollHeight(this); |
| 3303 | data.entries[uid].comment = value; |
| 3304 | setWIOriginalDataValue(data, uid, 'comment', data.entries[uid].comment); |
| 3305 | !noSave && await saveWorldInfo(name, data); |
| 3306 | }); |
| 3307 | commentInput.val(entry.comment).trigger('input', { skipReset: true, noSave: true }); |
| 3308 | |
| 3309 | // Order |
| 3310 | const orderInput = headerTemplate.find('input[name="order"]'); |
| 3311 | orderInput.data('uid', entry.uid); |
| 3312 | orderInput.on('input', async function (_, { noSave = false } = {}) { |
| 3313 | const uid = $(this).data('uid'); |
| 3314 | const value = Number($(this).val()); |
| 3315 | data.entries[uid].order = !isNaN(value) ? value : 0; |
| 3316 | updatePosOrdDisplayHelper({ template: headerTemplate, data, uid }); |
| 3317 | setWIOriginalDataValue(data, uid, 'insertion_order', data.entries[uid].order); |
| 3318 | !noSave && await saveWorldInfo(name, data); |
| 3319 | }); |
| 3320 | orderInput.val(entry.order).trigger('input', { noSave: true }); |
| 3321 | orderInput.css('width', 'calc(3em + 15px)'); |
| 3322 | |
| 3323 | // Probability |
| 3324 | handleProbabilityInputHelper({ probabilityInput: headerTemplate.find('input[name="probability"]'), data, entry, name }); |
| 3325 | |
| 3326 | // Depth |
| 3327 | handleNumberInputHelper({ |
| 3328 | inputElem: headerTemplate.find('input[name="depth"]'), |
| 3329 | entry, entryKey: 'depth', data, name, min: 0, max: MAX_SCAN_DEPTH, clamp: false, |
| 3330 | }); |
| 3331 | headerTemplate.find('input[name="depth"]').css('width', 'calc(3em + 15px)'); |
| 3332 | |
| 3333 | // Position |
| 3334 | if (entry.position === undefined) entry.position = 0; |
| 3335 | const positionInput = headerTemplate.find('select[name="position"]'); |
| 3336 | positionInput.data('uid', entry.uid); |
| 3337 | positionInput.on('click', e => e.stopPropagation()); |
| 3338 | positionInput.on('input', async function (_, { noSave = false } = {}) { |
| 3339 | const uid = $(this).data('uid'); |
| 3340 | const value = Number($(this).val()); |
| 3341 | data.entries[uid].position = !isNaN(value) ? value : 0; |
| 3342 | const depthInput = headerTemplate.find('input[name="depth"]'); |
| 3343 | if (value === world_info_position.atDepth) { |
| 3344 | depthInput.prop('disabled', false); |
| 3345 | depthInput.css('visibility', 'visible'); |
| 3346 | const role = Number($(this).find(':selected').data('role')); |
| 3347 | data.entries[uid].role = role; |
| 3348 | } else { |
| 3349 | depthInput.prop('disabled', true); |
| 3350 | depthInput.css('visibility', 'hidden'); |
| 3351 | data.entries[uid].role = null; |
| 3352 | } |
| 3353 | updatePosOrdDisplayHelper({ template: headerTemplate, data, uid }); |
| 3354 | setWIOriginalDataValue(data, uid, 'position', data.entries[uid].position == 0 ? 'before_char' : 'after_char'); |
| 3355 | setWIOriginalDataValue(data, uid, 'extensions.position', data.entries[uid].position); |
| 3356 | setWIOriginalDataValue(data, uid, 'extensions.role', data.entries[uid].role); |
| 3357 | !noSave && await saveWorldInfo(name, data); |
| 3358 | }); |
| 3359 | const roleValue = entry.position === world_info_position.atDepth ? String(entry.role ?? extension_prompt_roles.SYSTEM) : ''; |
| 3360 | headerTemplate.find(`select[name="position"] option[value="${entry.position}"][data-role="${roleValue}"]`).prop('selected', true).trigger('input', { noSave: true }); |
| 3361 | |
| 3362 | // Tri-state selector |
| 3363 | handleEntryStateSelectorHelper({ |
| 3364 | entryStateSelector: headerTemplate.find('select[name="entryStateSelector"]'), |
| 3365 | entry, data, name, |
| 3366 | }); |
| 3367 | |
| 3368 | // Kill switch |
| 3369 | handleEntryKillSwitchHelper({ |
| 3370 | entryKillSwitch: headerTemplate.find('div[name="entryKillSwitch"]'), |
| 3371 | entry, data, name, template: headerTemplate, |
| 3372 | }); |
| 3373 | |
| 3374 | // Duplicate/delete/move buttons |
| 3375 | headerTemplate.find('.duplicate_entry_button').data('uid', entry.uid).on('click', async function () { |
| 3376 | const uid = $(this).data('uid'); |
| 3377 | const entryDup = duplicateWorldInfoEntry(data, uid); |
| 3378 | if (entryDup) { |
| 3379 | await saveWorldInfo(name, data); |
| 3380 | updateEditor(entryDup.uid); |
| 3381 | } |
| 3382 | }); |
| 3383 | headerTemplate.find('.delete_entry_button').data('uid', entry.uid).on('click', async function (e) { |
| 3384 | e.stopPropagation(); |
| 3385 | const uid = $(this).data('uid'); |
| 3386 | const deleted = await deleteWorldInfoEntry(data, uid); |
| 3387 | if (!deleted) return; |
| 3388 | deleteWIOriginalDataValue(data, uid); |
| 3389 | await saveWorldInfo(name, data); |
| 3390 | updateEditor(navigation_option.previous); |
| 3391 | }); |
| 3392 | headerTemplate.find('.move_entry_button').attr('data-uid', entry.uid).attr('data-current-world', name).on('click', async function (e) { |
| 3393 | e.stopPropagation(); |
| 3394 | const sourceUid = $(this).attr('data-uid'); |
| 3395 | const sourceWorld = $(this).attr('data-current-world'); |
| 3396 | const sourceWorldInfo = await loadWorldInfo(sourceWorld); |
| 3397 | if (!sourceWorldInfo) return; |
| 3398 | const sourceName = sourceWorldInfo.entries[sourceUid]?.comment; |
| 3399 | if (sourceName === undefined) return; |
| 3400 | const select = document.createElement('select'); |
| 3401 | select.id = 'move_entry_target_select'; |
| 3402 | select.classList.add('text_pole', 'wide100p', 'marginTop10'); |
| 3403 | const defaultOption = document.createElement('option'); |
| 3404 | defaultOption.value = ''; |
| 3405 | defaultOption.textContent = `-- ${t`Select Target Lorebook`} --`; |
| 3406 | select.appendChild(defaultOption); |
| 3407 | let selectableWorldCount = 0; |
| 3408 | world_names.forEach(worldName => { |
| 3409 | if (worldName !== sourceWorld) { |
| 3410 | const option = document.createElement('option'); |
| 3411 | option.value = world_names.indexOf(worldName).toString(); |
| 3412 | option.textContent = worldName; |
| 3413 | select.appendChild(option); |
| 3414 | selectableWorldCount++; |
| 3415 | } |
| 3416 | }); |
| 3417 | if (selectableWorldCount === 0) { |
| 3418 | toastr.warning(t`There are no other lorebooks to move to.`); |
| 3419 | return; |
| 3420 | } |
| 3421 | const wrapper = document.createElement('div'); |
| 3422 | wrapper.textContent = t`Move/Copy '${sourceName}' to:`; |
| 3423 | const container = document.createElement('div'); |
| 3424 | container.appendChild(wrapper); |
| 3425 | container.appendChild(select); |
| 3426 | let selectedWorldIndex = -1; |
| 3427 | select.addEventListener('change', function () { |
| 3428 | selectedWorldIndex = this.value === '' ? -1 : Number(this.value); |
| 3429 | }); |
| 3430 | const popup = new Popup(container, POPUP_TYPE.CONFIRM, '', { |
| 3431 | cancelButton: t`Cancel`, |
| 3432 | customButtons: [ |
| 3433 | { text: t`Move`, result: POPUP_RESULT.CUSTOM1 }, |
| 3434 | { text: t`Copy`, result: POPUP_RESULT.CUSTOM2 }, |
| 3435 | ], |
| 3436 | }); |
| 3437 | popup.okButton.style.display = 'none'; // Hide the default OK button |
| 3438 | const popupConfirm = await popup.show(); |
| 3439 | if (!popupConfirm) return; |
| 3440 | if (selectedWorldIndex === -1) return; |
| 3441 | const selectedValue = world_names[selectedWorldIndex]; |
| 3442 | if (!selectedValue) { |
| 3443 | toastr.warning(t`Please select a target lorebook.`); |
| 3444 | return; |
| 3445 | } |
| 3446 | const deleteOriginal = popupConfirm === POPUP_RESULT.CUSTOM1; |
| 3447 | await moveWorldInfoEntry(sourceWorld, selectedValue, sourceUid, { deleteOriginal }); |
| 3448 | }); |
| 3449 | |
| 3450 | let drawerInitialized = false; |
| 3451 | let drawerDestroyTimeout = null; |
| 3452 | headerTemplate.find('.inline-drawer').on('inline-drawer-toggle', function () { |
| 3453 | if (drawerDestroyTimeout) { |
| 3454 | clearTimeout(drawerDestroyTimeout); |
| 3455 | drawerDestroyTimeout = null; |
| 3456 | } |
| 3457 | if (drawerInitialized) { |
| 3458 | drawerDestroyTimeout = setTimeout(() => { |
| 3459 | // Drawer was reopened, so we don't destroy it |
| 3460 | if (editOutlet.is(':visible')) { |
| 3461 | return; |
| 3462 | } |
| 3463 | drawerInitialized = false; |
| 3464 | clearEntryList(editOutlet); |
| 3465 | drawerDestroyTimeout = null; |
| 3466 | }, debounce_timeout.relaxed); |
| 3467 | } else { |
| 3468 | drawerInitialized = true; |
| 3469 | addEditorDrawerContent(); |
| 3470 | } |
| 3471 | }); |
| 3472 | |
| 3473 | const editOutlet = headerTemplate.find('.inline-drawer-outlet'); |
| 3474 | |
| 3475 | function addEditorDrawerContent() { |
| 3476 | const editTemplate = WI_ENTRY_EDIT_TEMPLATE.clone(); |
| 3477 | |
| 3478 | // UID display |
| 3479 | editTemplate.find('.world_entry_form_uid_value').text(`(UID: ${entry.uid})`); |
| 3480 | |
| 3481 | // Key inputs |
| 3482 | const keyInput = enableKeysInputHelper({ template: editTemplate, entry, entryPropName: 'key', originalDataValueName: 'keys', name, data }); |
| 3483 | const keySecondaryInput = enableKeysInputHelper({ template: editTemplate, entry, entryPropName: 'keysecondary', originalDataValueName: 'secondary_keys', name, data }); |
| 3484 | if (!keyInput.isFancy) initScrollHeight(keyInput.control); |
| 3485 | if (!keySecondaryInput.isFancy) initScrollHeight(keySecondaryInput.control); |
| 3486 | |
| 3487 | // Key input switch |
| 3488 | editTemplate.find('.switch_input_type_icon').on('click', function () { |
| 3489 | power_user.wi_key_input_plaintext = !power_user.wi_key_input_plaintext; |
| 3490 | saveSettingsDebounced(); |
| 3491 | const uid = ($(this).parents('.world_entry')).data('uid'); |
| 3492 | updateEditor(uid, false); |
| 3493 | $(`.world_entry[uid="${uid}"] .inline-drawer-icon`).trigger('click'); |
| 3494 | }).each((_, icon) => { |
| 3495 | $(icon).attr('title', $(icon).data(power_user.wi_key_input_plaintext ? 'tooltip-on' : 'tooltip-off')); |
| 3496 | $(icon).text($(icon).data(power_user.wi_key_input_plaintext ? 'icon-on' : 'icon-off')); |
| 3497 | }); |
| 3498 | |
| 3499 | // Probability toggle |
| 3500 | handleProbabilityToggleHelper({ |
| 3501 | probabilityToggle: editTemplate.find('input[name="useProbability"]'), |
| 3502 | data, entry, name, |
| 3503 | probabilityInput: headerTemplate.find('input[name="probability"]'), |
| 3504 | }); |
| 3505 | |
| 3506 | // Comment toggle |
| 3507 | const commentToggle = editTemplate.find('input[name="addMemo"]'); |
| 3508 | commentToggle.data('uid', entry.uid); |
| 3509 | commentToggle.on('input', async function (_, { noSave = false } = {}) { |
| 3510 | const uid = $(this).data('uid'); |
| 3511 | const value = $(this).prop('checked'); |
| 3512 | const commentContainer = $(this).closest('.world_entry').find('.commentContainer'); |
| 3513 | data.entries[uid].addMemo = value; |
| 3514 | !noSave && await saveWorldInfo(name, data); |
| 3515 | value ? commentContainer.show() : commentContainer.hide(); |
| 3516 | }); |
| 3517 | commentToggle.prop('checked', true).trigger('input', { noSave: true }); |
| 3518 | commentToggle.parent().hide(); |
| 3519 | |
| 3520 | // Logic AND/NOT |
| 3521 | const selectiveLogicDropdown = editTemplate.find('select[name="entryLogicType"]'); |
| 3522 | selectiveLogicDropdown.data('uid', entry.uid); |
| 3523 | selectiveLogicDropdown.on('click', e => e.stopPropagation()); |
| 3524 | selectiveLogicDropdown.on('input', async function (_, { noSave = false } = {}) { |
| 3525 | const uid = $(this).data('uid'); |
| 3526 | const value = Number($(this).val()); |
| 3527 | data.entries[uid].selectiveLogic = !isNaN(value) ? value : world_info_logic.AND_ANY; |
| 3528 | setWIOriginalDataValue(data, uid, 'selectiveLogic', data.entries[uid].selectiveLogic); |
| 3529 | !noSave && await saveWorldInfo(name, data); |
| 3530 | }); |
| 3531 | editTemplate.find(`select[name="entryLogicType"] option[value=${entry.selectiveLogic}]`).prop('selected', true).trigger('input', { noSave: true }); |
| 3532 | |
| 3533 | // Selective |
| 3534 | const selectiveInput = editTemplate.find('input[name="selective"]'); |
| 3535 | selectiveInput.data('uid', entry.uid); |
| 3536 | selectiveInput.on('input', async function (_, { noSave = false } = {}) { |
| 3537 | const uid = $(this).data('uid'); |
| 3538 | const value = $(this).prop('checked'); |
| 3539 | data.entries[uid].selective = value; |
| 3540 | setWIOriginalDataValue(data, uid, 'selective', data.entries[uid].selective); |
| 3541 | !noSave && await saveWorldInfo(name, data); |
| 3542 | const keysecondary = $(this).closest('.world_entry').find('.keysecondary'); |
| 3543 | const keysecondarytextpole = $(this).closest('.world_entry').find('.keysecondarytextpole'); |
| 3544 | const keyprimaryselect = $(this).closest('.world_entry').find('.keyprimaryselect'); |
| 3545 | const keyprimaryHeight = keyprimaryselect.outerHeight(); |
| 3546 | keysecondarytextpole.css('height', keyprimaryHeight + 'px'); |
| 3547 | value ? keysecondary.show() : keysecondary.hide(); |
| 3548 | }); |
| 3549 | selectiveInput.prop('checked', true).trigger('input', { noSave: true }); |
| 3550 | selectiveInput.parent().hide(); |
| 3551 | |
| 3552 | // Character filter |
| 3553 | const characterFilterLabel = editTemplate.find('label[for="characterFilter"] > small'); |
| 3554 | characterFilterLabel.text(entry.characterFilter?.isExclude ? 'Exclude Character(s)' : 'Filter to Character(s)'); |
| 3555 | const characterExclusionInput = editTemplate.find('input[name="character_exclusion"]'); |
| 3556 | characterExclusionInput.data('uid', entry.uid); |
| 3557 | characterExclusionInput.on('input', async function (_, { noSave = false } = {}) { |
| 3558 | const uid = $(this).data('uid'); |
| 3559 | const value = $(this).prop('checked'); |
| 3560 | characterFilterLabel.text(value ? 'Exclude Character(s)' : 'Filter to Character(s)'); |
| 3561 | if (data.entries[uid].characterFilter) { |
| 3562 | if (!value && data.entries[uid].characterFilter.names.length === 0 && data.entries[uid].characterFilter.tags.length === 0) { |
| 3563 | delete data.entries[uid].characterFilter; |
| 3564 | } else { |
| 3565 | data.entries[uid].characterFilter.isExclude = value; |
| 3566 | } |
| 3567 | } else if (value) { |
| 3568 | Object.assign(data.entries[uid], { characterFilter: { isExclude: true, names: [], tags: [] } }); |
| 3569 | } |
| 3570 | if (data.entries[uid]?.characterFilter?.names?.length > 0) { |
| 3571 | for (const name of [...data.entries[uid].characterFilter.names]) { |
| 3572 | if (!getContext().characters.find(x => x.avatar.replace(/\.[^/.]+$/, '') === name)) { |
| 3573 | data.entries[uid].characterFilter.names = data.entries[uid].characterFilter.names.filter(x => x !== name); |
| 3574 | } |
| 3575 | } |
| 3576 | } |
| 3577 | setWIOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter); |
| 3578 | !noSave && await saveWorldInfo(name, data); |
| 3579 | }); |
| 3580 | characterExclusionInput.prop('checked', entry.characterFilter?.isExclude ?? false).trigger('input', { noSave: true }); |
| 3581 | |
| 3582 | const characterFilter = editTemplate.find('select[name="characterFilter"]'); |
| 3583 | characterFilter.data('uid', entry.uid); |
| 3584 | initCharacterFilterSelect2Helper(characterFilter); |
| 3585 | fillCharacterAndTagOptionsHelper({ characterFilter, entry }); |
| 3586 | handleCharacterFilterChangeHelper({ characterFilter, data, entry, name }); |
| 3587 | |
| 3588 | // Content |
| 3589 | const counter = editTemplate.find('.world_entry_form_token_counter'); |
| 3590 | const countTokensDebounced = debounce(async function (counter, value) { |
| 3591 | const numberOfTokens = await getTokenCountAsync(value); |
| 3592 | $(counter).text(numberOfTokens); |
| 3593 | }, debounce_timeout.relaxed); |
| 3594 | const contentInputId = `world_entry_content_${entry.uid}`; |
| 3595 | const contentInput = editTemplate.find('textarea[name="content"]'); |
| 3596 | contentInput.data('uid', entry.uid); |
| 3597 | contentInput.attr('id', contentInputId); |
| 3598 | contentInput[0].dataset.macros = ''; // active |
| 3599 | contentInput.on('input', async function (_, { skipCount, noSave } = {}) { |
| 3600 | const uid = $(this).data('uid'); |
| 3601 | const value = $(this).val(); |
| 3602 | data.entries[uid].content = value; |
| 3603 | setWIOriginalDataValue(data, uid, 'content', data.entries[uid].content); |
| 3604 | !noSave && await saveWorldInfo(name, data); |
| 3605 | if (!skipCount) countTokensDebounced(counter, value); |
| 3606 | }); |
| 3607 | contentInput.val(entry.content).trigger('input', { skipCount: true, noSave: true }); |
| 3608 | editTemplate.find('.editor_maximize').attr('data-for', contentInputId); |
| 3609 | |
| 3610 | // Outlet name |
| 3611 | const outletNameInput = editTemplate.find('input[name="outletName"]'); |
| 3612 | outletNameInput.data('uid', entry.uid); |
| 3613 | outletNameInput.on('input', async function (_, { noSave = false } = {}) { |
| 3614 | const uid = $(this).data('uid'); |
| 3615 | const value = $(this).val(); |
| 3616 | data.entries[uid].outletName = value; |
| 3617 | setWIOriginalDataValue(data, uid, 'extensions.outlet_name', data.entries[uid].outletName); |
| 3618 | !noSave && await saveWorldInfo(name, data); |
| 3619 | }); |
| 3620 | outletNameInput.val(entry.outletName ?? '').trigger('input', { noSave: true }); |
| 3621 | setTimeout(() => createEntryInputAutocomplete(outletNameInput, getOutletNameCallback(data), { allowMultiple: true }), 1); |
| 3622 | |
| 3623 | // Scan depth |
| 3624 | const scanDepthInput = editTemplate.find('input[name="scanDepth"]'); |
| 3625 | scanDepthInput.data('uid', entry.uid); |
| 3626 | scanDepthInput.on('input', async function (_, { noSave = false } = {}) { |
| 3627 | const uid = $(this).data('uid'); |
| 3628 | const isEmpty = $(this).val() === ''; |
| 3629 | const value = Number($(this).val()); |
| 3630 | if (value < 0) { |
| 3631 | $(this).val(0).trigger('input'); |
| 3632 | toastr.warning('Scan depth cannot be negative'); |
| 3633 | return; |
| 3634 | } |
| 3635 | if (value > MAX_SCAN_DEPTH) { |
| 3636 | $(this).val(MAX_SCAN_DEPTH).trigger('input'); |
| 3637 | toastr.warning(`Scan depth cannot exceed ${MAX_SCAN_DEPTH}`); |
| 3638 | return; |
| 3639 | } |
| 3640 | data.entries[uid].scanDepth = !isEmpty && !isNaN(value) && value >= 0 && value <= MAX_SCAN_DEPTH ? Math.floor(value) : null; |
| 3641 | setWIOriginalDataValue(data, uid, 'extensions.scan_depth', data.entries[uid].scanDepth); |
| 3642 | !noSave && await saveWorldInfo(name, data); |
| 3643 | }); |
| 3644 | scanDepthInput.val(entry.scanDepth ?? null).trigger('input', { noSave: true }); |
| 3645 | |
| 3646 | // Group |
| 3647 | const groupInput = editTemplate.find('input[name="group"]'); |
| 3648 | groupInput.data('uid', entry.uid); |
| 3649 | groupInput.on('input', async function (_, { noSave = false } = {}) { |
| 3650 | const uid = $(this).data('uid'); |
| 3651 | const value = String($(this).val()).trim(); |
| 3652 | data.entries[uid].group = value; |
| 3653 | setWIOriginalDataValue(data, uid, 'extensions.group', data.entries[uid].group); |
| 3654 | !noSave && await saveWorldInfo(name, data); |
| 3655 | }); |
| 3656 | groupInput.val(entry.group ?? '').trigger('input', { noSave: true }); |
| 3657 | setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data), { allowMultiple: true }), 1); |
| 3658 | |
| 3659 | // Inclusion priority |
| 3660 | const groupOverrideInput = editTemplate.find('input[name="groupOverride"]'); |
| 3661 | groupOverrideInput.data('uid', entry.uid); |
| 3662 | groupOverrideInput.on('input', async function (_, { noSave = false } = {}) { |
| 3663 | const uid = $(this).data('uid'); |
| 3664 | const value = $(this).prop('checked'); |
| 3665 | data.entries[uid].groupOverride = value; |
| 3666 | setWIOriginalDataValue(data, uid, 'extensions.group_override', data.entries[uid].groupOverride); |
| 3667 | !noSave && await saveWorldInfo(name, data); |
| 3668 | }); |
| 3669 | groupOverrideInput.prop('checked', entry.groupOverride).trigger('input', { noSave: true }); |
| 3670 | |
| 3671 | // Group weight |
| 3672 | handleNumberInputHelper({ |
| 3673 | inputElem: editTemplate.find('input[name="groupWeight"]'), |
| 3674 | entry, entryKey: 'groupWeight', data, name, min: 1, max: 10000, clamp: true, |
| 3675 | }); |
| 3676 | |
| 3677 | // Sticky, cooldown, delay |
| 3678 | handleNumberInputHelper({ |
| 3679 | inputElem: editTemplate.find('input[name="sticky"]'), |
| 3680 | entry, entryKey: 'sticky', data, name, min: 1, max: 10000, clamp: false, |
| 3681 | }); |
| 3682 | handleNumberInputHelper({ |
| 3683 | inputElem: editTemplate.find('input[name="cooldown"]'), |
| 3684 | entry, entryKey: 'cooldown', data, name, min: 1, max: 10000, clamp: false, |
| 3685 | }); |
| 3686 | handleNumberInputHelper({ |
| 3687 | inputElem: editTemplate.find('input[name="delay"]'), |
| 3688 | entry, entryKey: 'delay', data, name, min: 1, max: 10000, clamp: false, |
| 3689 | }); |
| 3690 | |
| 3691 | // Exclude/prevent recursion |
| 3692 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'excludeRecursion', data, name }); |
| 3693 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'preventRecursion', data, name }); |
| 3694 | |
| 3695 | // Delay until recursion |
| 3696 | const delayUntilRecursionInput = editTemplate.find('input[name="delay_until_recursion"]'); |
| 3697 | delayUntilRecursionInput.data('uid', entry.uid); |
| 3698 | const delayUntilRecursionLevelInput = editTemplate.find('input[name="delayUntilRecursionLevel"]'); |
| 3699 | delayUntilRecursionLevelInput.data('uid', entry.uid); |
| 3700 | delayUntilRecursionInput.on('input', async function (_, { noSave = false } = {}) { |
| 3701 | const uid = $(this).data('uid'); |
| 3702 | const toggled = $(this).prop('checked'); |
| 3703 | const value = toggled ? data.entries[uid].delayUntilRecursion || true : false; |
| 3704 | if (!toggled) delayUntilRecursionLevelInput.val(''); |
| 3705 | data.entries[uid].delayUntilRecursion = value; |
| 3706 | setWIOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion); |
| 3707 | !noSave && await saveWorldInfo(name, data); |
| 3708 | }); |
| 3709 | delayUntilRecursionInput.prop('checked', entry.delayUntilRecursion).trigger('input', { noSave: true }); |
| 3710 | delayUntilRecursionLevelInput.on('input', async function (_, { noSave = false } = {}) { |
| 3711 | const uid = $(this).data('uid'); |
| 3712 | const content = $(this).val(); |
| 3713 | const value = content === '' ? (typeof data.entries[uid].delayUntilRecursion === 'boolean' ? data.entries[uid].delayUntilRecursion : true) |
| 3714 | : content === 1 ? true |
| 3715 | : !isNaN(Number(content)) ? Number(content) |
| 3716 | : false; |
| 3717 | data.entries[uid].delayUntilRecursion = value; |
| 3718 | setWIOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion); |
| 3719 | !noSave && await saveWorldInfo(name, data); |
| 3720 | }); |
| 3721 | delayUntilRecursionLevelInput.val(['number', 'string'].includes(typeof entry.delayUntilRecursion) ? entry.delayUntilRecursion : '').trigger('input', { noSave: true }); |
| 3722 | |
| 3723 | // Boolean selects |
| 3724 | handleBooleanSelectHelper({ selectElem: editTemplate.find('select[name="caseSensitive"]'), entry, entryKey: 'caseSensitive', data, name }); |
| 3725 | handleBooleanSelectHelper({ selectElem: editTemplate.find('select[name="matchWholeWords"]'), entry, entryKey: 'matchWholeWords', data, name }); |
| 3726 | handleBooleanSelectHelper({ selectElem: editTemplate.find('select[name="useGroupScoring"]'), entry, entryKey: 'useGroupScoring', data, name }); |
| 3727 | |
| 3728 | // Match checkboxes |
| 3729 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'matchPersonaDescription', data, name }); |
| 3730 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'matchCharacterDescription', data, name }); |
| 3731 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'matchCharacterPersonality', data, name }); |
| 3732 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'matchCharacterDepthPrompt', data, name }); |
| 3733 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'matchScenario', data, name }); |
| 3734 | handleMatchCheckboxHelper({ template: editTemplate, entry, fieldName: 'matchCreatorNotes', data, name }); |
| 3735 | |
| 3736 | // Automation ID |
| 3737 | const automationIdInput = editTemplate.find('input[name="automationId"]'); |
| 3738 | automationIdInput.data('uid', entry.uid); |
| 3739 | automationIdInput.on('input', async function (_, { noSave = false } = {}) { |
| 3740 | const uid = $(this).data('uid'); |
| 3741 | const value = $(this).val(); |
| 3742 | data.entries[uid].automationId = value; |
| 3743 | setWIOriginalDataValue(data, uid, 'extensions.automation_id', data.entries[uid].automationId); |
| 3744 | !noSave && await saveWorldInfo(name, data); |
| 3745 | }); |
| 3746 | automationIdInput.val(entry.automationId ?? '').trigger('input', { noSave: true }); |
| 3747 | setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1); |
| 3748 | |
| 3749 | // Generation Type Triggers |
| 3750 | const generationTypeTriggers = editTemplate.find('select[name="triggers"]'); |
| 3751 | generationTypeTriggers.data('uid', entry.uid); |
| 3752 | generationTypeTriggers.on('input', async function (_, { noSave = false } = {}) { |
| 3753 | const uid = $(this).data('uid'); |
| 3754 | const value = $(this).val(); |
| 3755 | data.entries[uid].triggers = Array.isArray(value) ? value : []; |
| 3756 | setWIOriginalDataValue(data, uid, 'extensions.triggers', data.entries[uid].triggers); |
| 3757 | !noSave && await saveWorldInfo(name, data); |
| 3758 | }); |
| 3759 | if (!isMobile()) { |
| 3760 | generationTypeTriggers.select2({ |
| 3761 | placeholder: t`All types (default)`, |
| 3762 | width: '100%', |
| 3763 | closeOnSelect: false, |
| 3764 | allowClear: true, |
| 3765 | }); |
| 3766 | } |
| 3767 | generationTypeTriggers |
| 3768 | .val(Array.isArray(entry.triggers) ? entry.triggers : []) |
| 3769 | .trigger('input', { noSave: true }) |
| 3770 | .trigger('change'); |
| 3771 | |
| 3772 | // Ignore budget |
| 3773 | const ignoreBudgetInput = editTemplate.find('input[name="ignoreBudget"]'); |
| 3774 | ignoreBudgetInput.data('uid', entry.uid); |
| 3775 | ignoreBudgetInput.on('input', async function (_, { noSave = false } = {}) { |
| 3776 | const uid = $(this).data('uid'); |
| 3777 | const value = $(this).prop('checked'); |
| 3778 | data.entries[uid].ignoreBudget = value; |
| 3779 | setWIOriginalDataValue(data, uid, 'extensions.ignore_budget', data.entries[uid].ignoreBudget); |
| 3780 | !noSave && await saveWorldInfo(name, data); |
| 3781 | }); |
| 3782 | ignoreBudgetInput.prop('checked', entry.ignoreBudget ?? false).trigger('input', { noSave: true }); |
| 3783 | |
| 3784 | countTokensDebounced(counter, contentInput.val()); |
| 3785 | |
| 3786 | editTemplate.find('.inline-drawer-content').css('display', 'none'); |
| 3787 | editOutlet.append(editTemplate); |
| 3788 | } |
| 3789 | |
| 3790 | headerTemplate.find('.inline-drawer-content').css('display', 'none'); |
| 3791 | |
| 3792 | return headerTemplate; |
| 3793 | } |
| 3794 | |
| 3795 | |
| 3796 | /** |
| 3797 | * Builds a jQuery UI autocomplete callback: (control, request, response) => void |
| 3798 | * @param {object} [opt={}] - Optional arguments |
| 3799 | * @param {{entries: Record<string, any>}} [opt.data] - Your WI data |
| 3800 | * @param {(entry:any)=>string|string[]|null|undefined} [opt.collectValues] - Extract values from one entry |
| 3801 | * @param {() => Iterable<string>} [opt.includeExtras] - Optional global extras to include |
| 3802 | * @param {(ctx:{result:string[], control:JQuery, input:any, haystack:string[]})=>string[]} [opt.postFilter] - Optional final filter step (for special rules like your "group" de-dupe logic) |
| 3803 | */ |
| 3804 | function buildAutocompleteCallback({ data, collectValues, includeExtras = () => [], postFilter } = {}) { |
| 3805 | return function (control, input, output) { |
| 3806 | const uid = $(control).data('uid'); |
| 3807 | |
| 3808 | // Collect unique values from all *other* entries |
| 3809 | const values = new Set(); |
| 3810 | for (const entry of Object.values(data.entries ?? {})) { |
| 3811 | if (entry?.uid == uid) continue; |
| 3812 | const raw = collectValues(entry); |
| 3813 | if (raw == null) continue; |
| 3814 | const arr = Array.isArray(raw) ? raw : [raw]; |
| 3815 | for (const v of arr) { |
| 3816 | const s = String(v).trim(); |
| 3817 | if (s) values.add(s); |
| 3818 | } |
| 3819 | } |
| 3820 | |
| 3821 | // Add optional global extras |
| 3822 | for (const v of includeExtras()) { |
| 3823 | const s = String(v).trim(); |
| 3824 | if (s) values.add(s); |
| 3825 | } |
| 3826 | |
| 3827 | // Sort stable & locale-aware |
| 3828 | const haystack = Array.from(values).sort((a, b) => a.localeCompare(b)); |
| 3829 | |
| 3830 | // Case-insensitive contains |
| 3831 | const needle = String(input.term ?? '').toLowerCase(); |
| 3832 | let result = haystack.filter(x => x.toLowerCase().includes(needle)); |
| 3833 | |
| 3834 | // Optional final-pass semantics |
| 3835 | if (postFilter) { |
| 3836 | result = postFilter({ result, control: $(control), input, haystack }); |
| 3837 | } |
| 3838 | |
| 3839 | output(result); |
| 3840 | }; |
| 3841 | } |
| 3842 | |
| 3843 | /** |
| 3844 | * Splits a string into an array of strings, separated by commas and trimmed |
| 3845 | * @param {string} s - The string to split |
| 3846 | * @returns {string[]} An array of strings, separated by commas and trimmed |
| 3847 | */ |
| 3848 | const splitCsv = s => String(s ?? '').split(/,\s*/).filter(Boolean); |
| 3849 | |
| 3850 | /** |
| 3851 | * Get the inclusion groups for the autocomplete. |
| 3852 | * @param {any} data WI data |
| 3853 | * @returns {(input: any, output: any) => any} Callback function for the autocomplete |
| 3854 | */ |
| 3855 | function getInclusionGroupCallback(data) { |
| 3856 | return buildAutocompleteCallback({ |
| 3857 | data, |
| 3858 | collectValues: entry => entry.group ? splitCsv(entry.group) : [], |
| 3859 | postFilter: ({ result, control, input, haystack }) => { |
| 3860 | const thisGroups = splitCsv(String($(control).val())); |
| 3861 | const needle = String(input.term ?? '').toLowerCase(); |
| 3862 | const hasExactMatch = haystack.some(x => x.toLowerCase() === needle); |
| 3863 | |
| 3864 | // include suggestion if it contains the needle AND |
| 3865 | // (not already present OR (exact match typed && appears only once)) |
| 3866 | return result.filter(x => |
| 3867 | !thisGroups.includes(x) || |
| 3868 | (hasExactMatch && thisGroups.filter(g => g === x).length === 1), |
| 3869 | ); |
| 3870 | }, |
| 3871 | }); |
| 3872 | } |
| 3873 | |
| 3874 | function getAutomationIdCallback(data) { |
| 3875 | return buildAutocompleteCallback({ |
| 3876 | data, |
| 3877 | collectValues: entry => entry.automationId != null ? [String(entry.automationId)] : [], |
| 3878 | includeExtras: () => |
| 3879 | ('quickReplyApi' in globalThis && globalThis.quickReplyApi?.listAutomationIds) |
| 3880 | ? globalThis.quickReplyApi.listAutomationIds() |
| 3881 | : [], |
| 3882 | }); |
| 3883 | } |
| 3884 | |
| 3885 | function getOutletNameCallback(data) { |
| 3886 | return buildAutocompleteCallback({ |
| 3887 | data, |
| 3888 | collectValues: entry => entry.position === world_info_position.outlet && entry.outletName ? [entry.outletName] : [], |
| 3889 | }); |
| 3890 | } |
| 3891 | |
| 3892 | /** |
| 3893 | * Create an autocomplete for an input element. |
| 3894 | * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to |
| 3895 | * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks |
| 3896 | * @param {object} [options={}] - Optional arguments |
| 3897 | * @param {boolean} [options.allowMultiple=false] - Whether to allow multiple comma-separated values |
| 3898 | */ |
| 3899 | function createEntryInputAutocomplete(input, callback, { allowMultiple = false } = {}) { |
| 3900 | const handleSelect = (event, ui) => { |
| 3901 | // Prevent default autocomplete select, so we can manually set the value |
| 3902 | event.preventDefault(); |
| 3903 | if (!allowMultiple) { |
| 3904 | $(input).val(ui.item.value).trigger('input').trigger('blur'); |
| 3905 | } else { |
| 3906 | var terms = String($(input).val()).split(/,\s*/); |
| 3907 | terms.pop(); // remove the current input |
| 3908 | terms.push(ui.item.value); // add the selected item |
| 3909 | $(input).val(terms.filter(x => x).join(', ')).trigger('input').trigger('blur'); |
| 3910 | } |
| 3911 | }; |
| 3912 | |
| 3913 | $(input).autocomplete({ |
| 3914 | minLength: 0, |
| 3915 | source: function (request, response) { |
| 3916 | if (!allowMultiple) { |
| 3917 | callback(input, request, response); |
| 3918 | } else { |
| 3919 | const term = request.term.split(/,\s*/).pop(); |
| 3920 | request.term = term; |
| 3921 | callback(input, request, response); |
| 3922 | } |
| 3923 | }, |
| 3924 | select: handleSelect, |
| 3925 | }); |
| 3926 | |
| 3927 | $(input).on('focus click', function () { |
| 3928 | $(input).autocomplete('search', allowMultiple ? String($(input).val()).split(/,\s*/).pop() : String($(input).val())); |
| 3929 | }); |
| 3930 | } |
| 3931 | |
| 3932 | |
| 3933 | /** |
| 3934 | * Duplicate a WI entry by copying all of its properties and assigning a new uid |
| 3935 | * @param {*} data - The data of the book |
| 3936 | * @param {number} uid - The uid of the entry to copy in this book |
| 3937 | * @returns {*} The new WI duplicated entry |
| 3938 | */ |
| 3939 | export function duplicateWorldInfoEntry(data, uid) { |
| 3940 | if (!data || !('entries' in data) || !data.entries[uid]) { |
| 3941 | return; |
| 3942 | } |
| 3943 | |
| 3944 | // Exclude uid and gather the rest of the properties |
| 3945 | const originalData = structuredClone(data.entries[uid]); |
| 3946 | delete originalData.uid; |
| 3947 | |
| 3948 | // Create new entry and copy over data |
| 3949 | const entry = createWorldInfoEntry(data.name, data); |
| 3950 | Object.assign(entry, originalData); |
| 3951 | |
| 3952 | return entry; |
| 3953 | } |
| 3954 | |
| 3955 | /** |
| 3956 | * Deletes a WI entry, with a user confirmation dialog |
| 3957 | * @param {*[]} data - The data of the book |
| 3958 | * @param {number} uid - The uid of the entry to copy in this book |
| 3959 | * @param {object} [options={}] - Optional arguments |
| 3960 | * @param {boolean} [options.silent=false] - Whether to prompt the user for deletion or just do it |
| 3961 | * @returns {Promise<boolean>} Whether the entry deletion was successful |
| 3962 | */ |
| 3963 | export async function deleteWorldInfoEntry(data, uid, { silent = false } = {}) { |
| 3964 | if (!data || !('entries' in data)) { |
| 3965 | return; |
| 3966 | } |
| 3967 | |
| 3968 | const entry = data.entries[uid]; |
| 3969 | if (!entry) { |
| 3970 | return false; |
| 3971 | } |
| 3972 | |
| 3973 | let previewText = ''; |
| 3974 | if (entry.comment && entry.comment.trim()) { |
| 3975 | previewText = entry.comment.trim(); |
| 3976 | } else if (entry.content) { |
| 3977 | const lines = entry.content.split(/\r?\n/).filter(line => line.trim()); |
| 3978 | previewText = lines.slice(0, 2).join('\n'); |
| 3979 | } |
| 3980 | |
| 3981 | const popupHeader = t`Delete world info entry with UID: ${uid}?`; |
| 3982 | const popupText = previewText |
| 3983 | ? `<strong>${t`Entry`}:</strong><br>${escapeHtml(previewText).replace(/\n/g, '<br>')}<br><br>${t`This action is irreversible!`}` |
| 3984 | : t`This action is irreversible!`; |
| 3985 | |
| 3986 | const confirmation = silent || await Popup.show.confirm(popupHeader, popupText); |
| 3987 | if (!confirmation) { |
| 3988 | return false; |
| 3989 | } |
| 3990 | |
| 3991 | delete data.entries[uid]; |
| 3992 | return true; |
| 3993 | } |
| 3994 | |
| 3995 | /** |
| 3996 | * Definitions of types for new WI entries |
| 3997 | * |
| 3998 | * Use `newEntryTemplate` if you just need the template that contains default values |
| 3999 | * |
| 4000 | * @type {{[key: string]: WIEntryFieldDefinition}} |
| 4001 | */ |
| 4002 | export const newWorldInfoEntryDefinition = { |
| 4003 | key: { default: [], type: 'array' }, |
| 4004 | keysecondary: { default: [], type: 'array' }, |
| 4005 | comment: { default: '', type: 'string' }, |
| 4006 | content: { default: '', type: 'string' }, |
| 4007 | constant: { default: false, type: 'boolean' }, |
| 4008 | vectorized: { default: false, type: 'boolean' }, |
| 4009 | selective: { default: true, type: 'boolean' }, |
| 4010 | selectiveLogic: { default: world_info_logic.AND_ANY, type: 'enum' }, |
| 4011 | addMemo: { default: false, type: 'boolean' }, |
| 4012 | order: { default: 100, type: 'number' }, |
| 4013 | position: { default: 0, type: 'number' }, |
| 4014 | disable: { default: false, type: 'boolean' }, |
| 4015 | ignoreBudget: { default: false, type: 'boolean' }, |
| 4016 | excludeRecursion: { default: false, type: 'boolean' }, |
| 4017 | preventRecursion: { default: false, type: 'boolean' }, |
| 4018 | matchPersonaDescription: { default: false, type: 'boolean' }, |
| 4019 | matchCharacterDescription: { default: false, type: 'boolean' }, |
| 4020 | matchCharacterPersonality: { default: false, type: 'boolean' }, |
| 4021 | matchCharacterDepthPrompt: { default: false, type: 'boolean' }, |
| 4022 | matchScenario: { default: false, type: 'boolean' }, |
| 4023 | matchCreatorNotes: { default: false, type: 'boolean' }, |
| 4024 | delayUntilRecursion: { default: 0, type: 'number' }, |
| 4025 | probability: { default: 100, type: 'number' }, |
| 4026 | useProbability: { default: true, type: 'boolean' }, |
| 4027 | depth: { default: DEFAULT_DEPTH, type: 'number' }, |
| 4028 | outletName: { default: '', type: 'string' }, |
| 4029 | group: { default: '', type: 'string' }, |
| 4030 | groupOverride: { default: false, type: 'boolean' }, |
| 4031 | groupWeight: { default: DEFAULT_WEIGHT, type: 'number' }, |
| 4032 | scanDepth: { default: null, type: 'number?' }, |
| 4033 | caseSensitive: { default: null, type: 'boolean?' }, |
| 4034 | matchWholeWords: { default: null, type: 'boolean?' }, |
| 4035 | useGroupScoring: { default: null, type: 'boolean?' }, |
| 4036 | automationId: { default: '', type: 'string' }, |
| 4037 | role: { default: 0, type: 'enum' }, |
| 4038 | sticky: { default: null, type: 'number?' }, |
| 4039 | cooldown: { default: null, type: 'number?' }, |
| 4040 | delay: { default: null, type: 'number?' }, |
| 4041 | characterFilterNames: { default: [], type: 'array', excludeFromTemplate: true }, |
| 4042 | characterFilterTags: { default: [], type: 'array', excludeFromTemplate: true }, |
| 4043 | characterFilterExclude: { default: false, type: 'boolean', excludeFromTemplate: true }, |
| 4044 | triggers: { default: [], type: 'array', arrayFilter: (value) => GENERATION_TYPE_TRIGGERS.includes(value) }, |
| 4045 | }; |
| 4046 | |
| 4047 | export const newWorldInfoEntryTemplate = Object.fromEntries( |
| 4048 | Object.entries(newWorldInfoEntryDefinition).filter(([_, value]) => !value.excludeFromTemplate).map(([key, value]) => [key, value.default]), |
| 4049 | ); |
| 4050 | |
| 4051 | /** |
| 4052 | * Creates a new world info entry from template. |
| 4053 | * @param {string} _name Name of the WI (unused) |
| 4054 | * @param {any} data WI data |
| 4055 | * @returns {object | undefined} New entry object or undefined if failed |
| 4056 | */ |
| 4057 | export function createWorldInfoEntry(_name, data) { |
| 4058 | const newUid = getFreeWorldEntryUid(data); |
| 4059 | |
| 4060 | if (!Number.isInteger(newUid)) { |
| 4061 | console.error('Couldn\'t assign UID to a new entry'); |
| 4062 | return; |
| 4063 | } |
| 4064 | |
| 4065 | const newEntry = { uid: newUid, ...structuredClone(newWorldInfoEntryTemplate) }; |
| 4066 | data.entries[newUid] = newEntry; |
| 4067 | |
| 4068 | return newEntry; |
| 4069 | } |
| 4070 | |
| 4071 | async function _save(name, data) { |
| 4072 | // Prevent double saving if both immediate and debounced save are called |
| 4073 | cancelDebounce(saveWorldDebounced); |
| 4074 | |
| 4075 | await fetch('/api/worldinfo/edit', { |
| 4076 | method: 'POST', |
| 4077 | headers: getRequestHeaders(), |
| 4078 | body: JSON.stringify({ name: name, data: data }), |
| 4079 | }); |
| 4080 | await eventSource.emit(event_types.WORLDINFO_UPDATED, name, data); |
| 4081 | } |
| 4082 | |
| 4083 | |
| 4084 | /** |
| 4085 | * Saves the world info |
| 4086 | * |
| 4087 | * This will also refresh the `worldInfoCache`. |
| 4088 | * Note, for performance reasons the saved cache will not make a deep clone of the data. |
| 4089 | * It is your responsibility to not modify the saved data object after calling this function, or there will be data inconsistencies. |
| 4090 | * Call `loadWorldInfoData` or query directly from cache if you need the object again. |
| 4091 | * |
| 4092 | * @param {string} name - The name of the world info |
| 4093 | * @param {any} data - The data to be saved |
| 4094 | * @param {boolean} [immediately=false] - Whether to save immediately or use debouncing |
| 4095 | * @return {Promise<void>} A promise that resolves when the world info is saved |
| 4096 | */ |
| 4097 | export async function saveWorldInfo(name, data, immediately = false) { |
| 4098 | if (!name || !data) { |
| 4099 | return; |
| 4100 | } |
| 4101 | |
| 4102 | // Update cache immediately, so any future call can pull from this |
| 4103 | worldInfoCache.set(name, data); |
| 4104 | |
| 4105 | if (immediately) { |
| 4106 | return await _save(name, data); |
| 4107 | } |
| 4108 | |
| 4109 | saveWorldDebounced(name, data); |
| 4110 | } |
| 4111 | |
| 4112 | async function renameWorldInfo(name, data) { |
| 4113 | const oldName = name; |
| 4114 | const newName = await Popup.show.input('Rename World Info', 'Enter a new name:', oldName); |
| 4115 | |
| 4116 | if (oldName === newName || !newName) { |
| 4117 | console.debug('World info rename cancelled'); |
| 4118 | return; |
| 4119 | } |
| 4120 | if (equalsIgnoreCaseAndAccents(oldName, newName)) { |
| 4121 | toastr.warning(t`Name not accepted, as it is the same as before (ignoring case and accents).`, t`Rename World Info`); |
| 4122 | return; |
| 4123 | } |
| 4124 | |
| 4125 | const entryPreviouslySelected = selected_world_info.findIndex((e) => e === oldName); |
| 4126 | |
| 4127 | await saveWorldInfo(newName, data, true); |
| 4128 | await deleteWorldInfo(oldName); |
| 4129 | |
| 4130 | await updateWorldInfoLinks(oldName, newName); |
| 4131 | |
| 4132 | if (entryPreviouslySelected !== -1) { |
| 4133 | const wiElement = getWIElement(newName); |
| 4134 | wiElement.prop('selected', true); |
| 4135 | $('#world_info').trigger('change'); |
| 4136 | } |
| 4137 | |
| 4138 | const selectedIndex = world_names.indexOf(newName); |
| 4139 | if (selectedIndex !== -1) { |
| 4140 | $('#world_editor_select').val(selectedIndex).trigger('change'); |
| 4141 | } |
| 4142 | } |
| 4143 | |
| 4144 | /** |
| 4145 | * Retargets all character lore links from an old world info name to a new one, with an optional confirmation for primary lorebook links |
| 4146 | * @param {string} oldName Previous WI file name |
| 4147 | * @param {string} newName New WI file name |
| 4148 | * @returns {Promise<void>} |
| 4149 | */ |
| 4150 | async function updateWorldInfoLinks(oldName, newName) { |
| 4151 | const existingCharLores = world_info.charLore?.filter((e) => e.extraBooks.includes(oldName)); |
| 4152 | if (existingCharLores && existingCharLores.length > 0) { |
| 4153 | existingCharLores.forEach((charLore) => { |
| 4154 | const tempCharLore = charLore.extraBooks.filter((e) => e !== oldName); |
| 4155 | tempCharLore.push(newName); |
| 4156 | charLore.extraBooks = tempCharLore; |
| 4157 | }); |
| 4158 | saveSettingsDebounced(); |
| 4159 | } |
| 4160 | |
| 4161 | // find all characters using the old lorebook name as their primary world |
| 4162 | const linkedChIDs = []; |
| 4163 | characters.forEach((character, chid) => { |
| 4164 | if (character.data?.extensions?.world === oldName) { |
| 4165 | linkedChIDs.push(chid); |
| 4166 | } |
| 4167 | }); |
| 4168 | |
| 4169 | if (!linkedChIDs.length) { |
| 4170 | return; |
| 4171 | } |
| 4172 | |
| 4173 | // Trigger the confirmation popup |
| 4174 | const updatePastLinksConfirm = await Popup.show.confirm( |
| 4175 | t`World/Lorebook renamed!`, |
| 4176 | `<p>${t`Auxiliary Lorebook links have been updated. Would you like to update primary lorebook links for ${linkedChIDs.length} character(s) as well?`}</p>`, |
| 4177 | ) == POPUP_RESULT.AFFIRMATIVE; |
| 4178 | |
| 4179 | if (updatePastLinksConfirm) { |
| 4180 | let activeCharacterUpdated = false; |
| 4181 | |
| 4182 | for (const chid of linkedChIDs) { |
| 4183 | const character = characters[chid]; |
| 4184 | |
| 4185 | try { |
| 4186 | // /merge-attributes API call to update the file on the backend silently |
| 4187 | const response = await fetch('/api/characters/merge-attributes', { |
| 4188 | method: 'POST', |
| 4189 | headers: getRequestHeaders(), |
| 4190 | body: JSON.stringify({ |
| 4191 | avatar: character.avatar, |
| 4192 | data: { |
| 4193 | extensions: { |
| 4194 | world: newName, |
| 4195 | }, |
| 4196 | }, |
| 4197 | }), |
| 4198 | }); |
| 4199 | |
| 4200 | if (!response.ok) { |
| 4201 | throw new Error(`Merge API returned ${response.status}`); |
| 4202 | } |
| 4203 | |
| 4204 | // used to update the data in the browser's memory |
| 4205 | await getOneCharacter(character.avatar); |
| 4206 | |
| 4207 | // Flag if the currently open character was affected |
| 4208 | if (String(chid) === String(this_chid)) { |
| 4209 | activeCharacterUpdated = true; |
| 4210 | } |
| 4211 | |
| 4212 | toastr.success(`Successfully updated link for ${character.name}.`); |
| 4213 | } catch (e) { |
| 4214 | toastr.error(`Failed to update link for ${character.name}.`); |
| 4215 | console.error(`Backend update for character ${character.name} failed:`, e); |
| 4216 | } |
| 4217 | } |
| 4218 | |
| 4219 | // update the UI fields |
| 4220 | // only required if the currently selected character was changed |
| 4221 | if (activeCharacterUpdated) { |
| 4222 | select_selected_character(this_chid, { switchMenu: false }); |
| 4223 | setWorldInfoButtonClass(this_chid, true); |
| 4224 | } |
| 4225 | } |
| 4226 | } |
| 4227 | |
| 4228 | /** |
| 4229 | * Deletes a world info with the given name |
| 4230 | * |
| 4231 | * @param {string} worldInfoName - The name of the world info to delete |
| 4232 | * @returns {Promise<boolean>} A promise that resolves to true if the world info was successfully deleted, false otherwise |
| 4233 | */ |
| 4234 | export async function deleteWorldInfo(worldInfoName) { |
| 4235 | if (!world_names.includes(worldInfoName)) { |
| 4236 | return false; |
| 4237 | } |
| 4238 | |
| 4239 | const response = await fetch('/api/worldinfo/delete', { |
| 4240 | method: 'POST', |
| 4241 | headers: getRequestHeaders(), |
| 4242 | body: JSON.stringify({ name: worldInfoName }), |
| 4243 | }); |
| 4244 | |
| 4245 | if (!response.ok) { |
| 4246 | return false; |
| 4247 | } |
| 4248 | |
| 4249 | if (worldInfoCache.has(worldInfoName)) { |
| 4250 | worldInfoCache.delete(worldInfoName); |
| 4251 | } |
| 4252 | |
| 4253 | const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName); |
| 4254 | if (existingWorldIndex !== -1) { |
| 4255 | selected_world_info.splice(existingWorldIndex, 1); |
| 4256 | saveSettingsDebounced(); |
| 4257 | } |
| 4258 | |
| 4259 | await updateWorldInfoList(); |
| 4260 | $('#world_editor_select').trigger('change'); |
| 4261 | |
| 4262 | if ($('#character_world').val() === worldInfoName) { |
| 4263 | $('#character_world').val('').trigger('change'); |
| 4264 | setWorldInfoButtonClass(undefined, false); |
| 4265 | if (menu_type != 'create') { |
| 4266 | saveCharacterDebounced(); |
| 4267 | } |
| 4268 | } |
| 4269 | |
| 4270 | if (power_user.persona_description_lorebook === worldInfoName) { |
| 4271 | power_user.persona_description_lorebook = ''; |
| 4272 | if (power_user.personas[user_avatar]) { |
| 4273 | const object = getOrCreatePersonaDescriptor(); |
| 4274 | object.lorebook = ''; |
| 4275 | } |
| 4276 | $('#persona_lore_button').toggleClass('world_set', false); |
| 4277 | saveSettingsDebounced(); |
| 4278 | } |
| 4279 | |
| 4280 | return true; |
| 4281 | } |
| 4282 | |
| 4283 | export function getFreeWorldEntryUid(data) { |
| 4284 | if (!data || !('entries' in data)) { |
| 4285 | return null; |
| 4286 | } |
| 4287 | |
| 4288 | const MAX_UID = 1_000_000; // <- should be safe enough :) |
| 4289 | for (let uid = 0; uid < MAX_UID; uid++) { |
| 4290 | if (uid in data.entries) { |
| 4291 | continue; |
| 4292 | } |
| 4293 | return uid; |
| 4294 | } |
| 4295 | |
| 4296 | return null; |
| 4297 | } |
| 4298 | |
| 4299 | |
| 4300 | /** |
| 4301 | * Generates a free world name based on the given input name. |
| 4302 | * If the input name is null, a default name is used. |
| 4303 | * If the input name already exists, a numbered suffix is added. |
| 4304 | * |
| 4305 | * @param {string|null} worldName - The name to base the new world name on. If null, a default name is used. |
| 4306 | * @param {Object} [options={}] - Optional parameters. |
| 4307 | * @param {boolean} [options.stripIndex=true] - Whether to strip any numbered suffix from the input name before generating the new name. |
| 4308 | * @return {string|undefined} The generated free world name, or undefined if no free name could be found after trying 100,000 times. |
| 4309 | */ |
| 4310 | export function getFreeWorldName(worldName = null, { stripIndex = true } = {}) { |
| 4311 | worldName ??= t`New World`; |
| 4312 | if (stripIndex) { |
| 4313 | worldName = worldName.replace(/\s*\(\d+\)$/, ''); |
| 4314 | } |
| 4315 | const MAX_FREE_NAME = 100_000; |
| 4316 | for (let index = 1; index < MAX_FREE_NAME; index++) { |
| 4317 | const newName = `${worldName} (${index})`; |
| 4318 | if (world_names.includes(newName)) { |
| 4319 | continue; |
| 4320 | } |
| 4321 | return newName; |
| 4322 | } |
| 4323 | |
| 4324 | return undefined; |
| 4325 | } |
| 4326 | |
| 4327 | /** |
| 4328 | * Creates a new world info/lorebook with the given name. |
| 4329 | * Checks if a world with the same name already exists, providing a warning or optionally a user confirmation dialog. |
| 4330 | * |
| 4331 | * @param {string} worldName - The name of the new world info |
| 4332 | * @param {Object} options - Optional parameters |
| 4333 | * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world |
| 4334 | * @returns {Promise<boolean>} - True if the world info was successfully created, false otherwise |
| 4335 | */ |
| 4336 | export async function createNewWorldInfo(worldName, { interactive = false } = {}) { |
| 4337 | const worldInfoTemplate = { entries: {} }; |
| 4338 | |
| 4339 | if (!worldName) { |
| 4340 | return false; |
| 4341 | } |
| 4342 | |
| 4343 | const sanitizedWorldName = await getSanitizedFilename(worldName); |
| 4344 | |
| 4345 | const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) }); |
| 4346 | if (!allowed) { |
| 4347 | return false; |
| 4348 | } |
| 4349 | |
| 4350 | await saveWorldInfo(worldName, worldInfoTemplate, true); |
| 4351 | await updateWorldInfoList(); |
| 4352 | |
| 4353 | const selectedIndex = world_names.indexOf(worldName); |
| 4354 | if (selectedIndex !== -1) { |
| 4355 | $('#world_editor_select').val(selectedIndex).trigger('change'); |
| 4356 | } else { |
| 4357 | await hideWorldEditor(); |
| 4358 | } |
| 4359 | |
| 4360 | return true; |
| 4361 | } |
| 4362 | |
| 4363 | async function getCharacterLore() { |
| 4364 | const character = characters[this_chid]; |
| 4365 | const name = character?.name; |
| 4366 | /** @type {Set<string>} */ |
| 4367 | let worldsToSearch = new Set(); |
| 4368 | |
| 4369 | const baseWorldName = character?.data?.extensions?.world; |
| 4370 | if (baseWorldName) { |
| 4371 | worldsToSearch.add(baseWorldName); |
| 4372 | } |
| 4373 | |
| 4374 | // TODO: Maybe make the utility function not use the window context? |
| 4375 | const fileName = getCharaFilename(this_chid); |
| 4376 | const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); |
| 4377 | if (extraCharLore) { |
| 4378 | worldsToSearch = new Set([...worldsToSearch, ...extraCharLore.extraBooks]); |
| 4379 | } |
| 4380 | |
| 4381 | if (!worldsToSearch.size) { |
| 4382 | return []; |
| 4383 | } |
| 4384 | |
| 4385 | let entries = []; |
| 4386 | for (const worldName of worldsToSearch) { |
| 4387 | if (selected_world_info.includes(worldName)) { |
| 4388 | console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in global world info! Skipping...`); |
| 4389 | continue; |
| 4390 | } |
| 4391 | |
| 4392 | if (chat_metadata[METADATA_KEY] === worldName) { |
| 4393 | console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in chat lore! Skipping...`); |
| 4394 | continue; |
| 4395 | } |
| 4396 | |
| 4397 | if (power_user.persona_description_lorebook === worldName) { |
| 4398 | console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in persona lore! Skipping...`); |
| 4399 | continue; |
| 4400 | } |
| 4401 | |
| 4402 | const data = await loadWorldInfo(worldName); |
| 4403 | const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : []; |
| 4404 | entries = entries.concat(newEntries); |
| 4405 | |
| 4406 | if (!newEntries.length) { |
| 4407 | console.debug(`[WI] Character ${name}'s world ${worldName} could not be found or is empty`); |
| 4408 | } |
| 4409 | } |
| 4410 | |
| 4411 | console.debug(`[WI] Character ${name}'s lore has ${entries.length} world info entries`, [...worldsToSearch]); |
| 4412 | return entries; |
| 4413 | } |
| 4414 | |
| 4415 | async function getGlobalLore() { |
| 4416 | if (!selected_world_info?.length) { |
| 4417 | return []; |
| 4418 | } |
| 4419 | |
| 4420 | let entries = []; |
| 4421 | for (const worldName of selected_world_info) { |
| 4422 | const data = await loadWorldInfo(worldName); |
| 4423 | const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : []; |
| 4424 | entries = entries.concat(newEntries); |
| 4425 | } |
| 4426 | |
| 4427 | console.debug(`[WI] Global world info has ${entries.length} entries`, selected_world_info); |
| 4428 | |
| 4429 | return entries; |
| 4430 | } |
| 4431 | |
| 4432 | async function getChatLore() { |
| 4433 | const chatWorld = chat_metadata[METADATA_KEY]; |
| 4434 | |
| 4435 | if (!chatWorld) { |
| 4436 | return []; |
| 4437 | } |
| 4438 | |
| 4439 | if (selected_world_info.includes(chatWorld)) { |
| 4440 | console.debug(`[WI] Chat world ${chatWorld} is already activated in global world info! Skipping...`); |
| 4441 | return []; |
| 4442 | } |
| 4443 | |
| 4444 | const data = await loadWorldInfo(chatWorld); |
| 4445 | const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: chatWorld, ...rest })) : []; |
| 4446 | |
| 4447 | console.debug(`[WI] Chat lore has ${entries.length} entries`, [chatWorld]); |
| 4448 | |
| 4449 | return entries; |
| 4450 | } |
| 4451 | |
| 4452 | async function getPersonaLore() { |
| 4453 | const chatWorld = chat_metadata[METADATA_KEY]; |
| 4454 | const personaWorld = power_user.persona_description_lorebook; |
| 4455 | |
| 4456 | if (!personaWorld) { |
| 4457 | return []; |
| 4458 | } |
| 4459 | |
| 4460 | if (chatWorld === personaWorld) { |
| 4461 | console.debug(`[WI] Persona world ${personaWorld} is already activated in chat world! Skipping...`); |
| 4462 | return []; |
| 4463 | } |
| 4464 | |
| 4465 | if (selected_world_info.includes(personaWorld)) { |
| 4466 | console.debug(`[WI] Persona world ${personaWorld} is already activated in global world info! Skipping...`); |
| 4467 | return []; |
| 4468 | } |
| 4469 | |
| 4470 | const data = await loadWorldInfo(personaWorld); |
| 4471 | const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: personaWorld, ...rest })) : []; |
| 4472 | |
| 4473 | console.debug(`[WI] Persona lore has ${entries.length} entries`, [personaWorld]); |
| 4474 | |
| 4475 | return entries; |
| 4476 | } |
| 4477 | |
| 4478 | export async function getSortedEntries() { |
| 4479 | try { |
| 4480 | const [ |
| 4481 | globalLore, |
| 4482 | characterLore, |
| 4483 | chatLore, |
| 4484 | personaLore, |
| 4485 | ] = await Promise.all([ |
| 4486 | getGlobalLore(), |
| 4487 | getCharacterLore(), |
| 4488 | getChatLore(), |
| 4489 | getPersonaLore(), |
| 4490 | ]); |
| 4491 | |
| 4492 | await eventSource.emit(event_types.WORLDINFO_ENTRIES_LOADED, { globalLore, characterLore, chatLore, personaLore }); |
| 4493 | |
| 4494 | let entries; |
| 4495 | |
| 4496 | switch (Number(world_info_character_strategy)) { |
| 4497 | case world_info_insertion_strategy.evenly: |
| 4498 | entries = [...globalLore, ...characterLore].sort(sortFn); |
| 4499 | break; |
| 4500 | case world_info_insertion_strategy.character_first: |
| 4501 | entries = [...characterLore.sort(sortFn), ...globalLore.sort(sortFn)]; |
| 4502 | break; |
| 4503 | case world_info_insertion_strategy.global_first: |
| 4504 | entries = [...globalLore.sort(sortFn), ...characterLore.sort(sortFn)]; |
| 4505 | break; |
| 4506 | default: |
| 4507 | console.error('[WI] Unknown WI insertion strategy:', world_info_character_strategy, 'defaulting to evenly'); |
| 4508 | entries = [...globalLore, ...characterLore].sort(sortFn); |
| 4509 | break; |
| 4510 | } |
| 4511 | |
| 4512 | // Chat lore always goes first, then persona lore, then the rest |
| 4513 | entries = [...chatLore.sort(sortFn), ...personaLore.sort(sortFn), ...entries]; |
| 4514 | |
| 4515 | // Calculate hash and parse decorators. Split maps to preserve old hashes. |
| 4516 | entries = entries.map((entry) => { |
| 4517 | const [decorators, content] = parseDecorators(entry.content || ''); |
| 4518 | return { ...entry, decorators, content }; |
| 4519 | }).map((entry) => { |
| 4520 | const hash = getStringHash(JSON.stringify(entry)); |
| 4521 | return { ...entry, hash }; |
| 4522 | }); |
| 4523 | |
| 4524 | 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)); |
| 4525 | |
| 4526 | // Need to deep clone the entries to avoid modifying the cached data |
| 4527 | return structuredClone(entries); |
| 4528 | } catch (e) { |
| 4529 | console.error(e); |
| 4530 | return []; |
| 4531 | } |
| 4532 | } |
| 4533 | |
| 4534 | |
| 4535 | /** |
| 4536 | * Parse decorators from worldinfo content |
| 4537 | * @param {string} content The content to parse |
| 4538 | * @returns {[string[],string]} The decorators found in the content and the content without decorators |
| 4539 | */ |
| 4540 | function parseDecorators(content) { |
| 4541 | /** |
| 4542 | * Check if the decorator is known |
| 4543 | * @param {string} data string to check |
| 4544 | * @returns {boolean} true if the decorator is known |
| 4545 | */ |
| 4546 | const isKnownDecorator = (data) => { |
| 4547 | if (data.startsWith('@@@')) { |
| 4548 | data = data.substring(1); |
| 4549 | } |
| 4550 | |
| 4551 | for (let i = 0; i < KNOWN_DECORATORS.length; i++) { |
| 4552 | if (data.startsWith(KNOWN_DECORATORS[i])) { |
| 4553 | return true; |
| 4554 | } |
| 4555 | } |
| 4556 | return false; |
| 4557 | }; |
| 4558 | |
| 4559 | if (content.startsWith('@@')) { |
| 4560 | let newContent = content; |
| 4561 | const splited = content.split('\n'); |
| 4562 | let decorators = []; |
| 4563 | let fallbacked = false; |
| 4564 | |
| 4565 | for (let i = 0; i < splited.length; i++) { |
| 4566 | if (splited[i].startsWith('@@')) { |
| 4567 | if (splited[i].startsWith('@@@') && !fallbacked) { |
| 4568 | continue; |
| 4569 | } |
| 4570 | |
| 4571 | if (isKnownDecorator(splited[i])) { |
| 4572 | decorators.push(splited[i].startsWith('@@@') ? splited[i].substring(1) : splited[i]); |
| 4573 | fallbacked = false; |
| 4574 | } else { |
| 4575 | fallbacked = true; |
| 4576 | } |
| 4577 | } else { |
| 4578 | newContent = splited.slice(i).join('\n'); |
| 4579 | break; |
| 4580 | } |
| 4581 | } |
| 4582 | return [decorators, newContent]; |
| 4583 | } |
| 4584 | |
| 4585 | return [[], content]; |
| 4586 | } |
| 4587 | |
| 4588 | /** |
| 4589 | * Performs a scan on the chat and returns the world info activated. |
| 4590 | * @param {string[]} chat The chat messages to scan, in reverse order. |
| 4591 | * @param {number} maxContext The maximum context size of the generation. |
| 4592 | * @param {boolean} isDryRun Whether to perform a dry run. |
| 4593 | * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned |
| 4594 | * @returns {Promise<WIActivated>} The world info activated. |
| 4595 | */ |
| 4596 | //MARK: checkWorldInfo |
| 4597 | export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData = defaultGlobalScanData) { |
| 4598 | const context = getContext(); |
| 4599 | const buffer = new WorldInfoBuffer(chat, globalScanData); |
| 4600 | |
| 4601 | console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages, trigger = ${globalScanData.trigger})${isDryRun ? ' (DRY RUN)' : ''} ---`); |
| 4602 | |
| 4603 | // Combine the chat |
| 4604 | |
| 4605 | // Add the depth or AN if enabled |
| 4606 | // Put this code here since otherwise, the chat reference is modified |
| 4607 | for (const key of Object.keys(context.extensionPrompts)) { |
| 4608 | if (context.extensionPrompts[key]?.scan) { |
| 4609 | const prompt = await getExtensionPromptByName(key); |
| 4610 | if (prompt) { |
| 4611 | buffer.addInject(prompt); |
| 4612 | } |
| 4613 | } |
| 4614 | } |
| 4615 | |
| 4616 | /** @type {scan_state} */ |
| 4617 | let scanState = scan_state.INITIAL; |
| 4618 | let token_budget_overflowed = false; |
| 4619 | let count = 0; |
| 4620 | let allActivatedEntries = new Map(); |
| 4621 | let failedProbabilityChecks = new Set(); |
| 4622 | let allActivatedText = ''; |
| 4623 | |
| 4624 | let budget = Math.round(world_info_budget * maxContext / 100) || 1; |
| 4625 | |
| 4626 | if (world_info_budget_cap > 0 && budget > world_info_budget_cap) { |
| 4627 | console.debug(`[WI] Budget ${budget} exceeds cap ${world_info_budget_cap}, using cap`); |
| 4628 | budget = world_info_budget_cap; |
| 4629 | } |
| 4630 | |
| 4631 | console.debug(`[WI] Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`); |
| 4632 | const sortedEntries = await getSortedEntries(); |
| 4633 | const timedEffects = new WorldInfoTimedEffects(chat, sortedEntries, isDryRun); |
| 4634 | |
| 4635 | timedEffects.checkTimedEffects(); |
| 4636 | |
| 4637 | if (sortedEntries.length === 0) { |
| 4638 | return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], ANBeforeEntries: [], ANAfterEntries: [], outletEntries: {}, allActivatedEntries: new Set() }; |
| 4639 | } |
| 4640 | |
| 4641 | /** @type {number[]} Represents the delay levels for entries that are delayed until recursion */ |
| 4642 | const availableRecursionDelayLevels = [...new Set(sortedEntries |
| 4643 | .filter(entry => entry.delayUntilRecursion) |
| 4644 | .map(entry => entry.delayUntilRecursion === true ? 1 : entry.delayUntilRecursion), |
| 4645 | )].sort((a, b) => a - b); |
| 4646 | // Already preset with the first level |
| 4647 | let currentRecursionDelayLevel = availableRecursionDelayLevels.shift() ?? 0; |
| 4648 | if (currentRecursionDelayLevel > 0 && availableRecursionDelayLevels.length) { |
| 4649 | console.debug('[WI] Preparing first delayed recursion level', currentRecursionDelayLevel, '. Still delayed:', availableRecursionDelayLevels); |
| 4650 | } |
| 4651 | |
| 4652 | console.debug(`[WI] --- SEARCHING ENTRIES (on ${sortedEntries.length} entries) ---`); |
| 4653 | |
| 4654 | while (scanState) { |
| 4655 | //if world_info_max_recursion_steps is non-zero min activations are disabled, and vice versa |
| 4656 | if (world_info_max_recursion_steps && world_info_max_recursion_steps <= count) { |
| 4657 | console.debug('[WI] Search stopped by reaching max recursion steps', world_info_max_recursion_steps); |
| 4658 | break; |
| 4659 | } |
| 4660 | |
| 4661 | // Track how many times the loop has run. May be useful for debugging. |
| 4662 | count++; |
| 4663 | |
| 4664 | console.debug(`[WI] --- LOOP #${count} START ---`); |
| 4665 | console.debug('[WI] Scan state', Object.entries(scan_state).find(x => x[1] === scanState)); |
| 4666 | |
| 4667 | // Until decided otherwise, we set the loop to stop scanning after this |
| 4668 | let nextScanState = scan_state.NONE; |
| 4669 | |
| 4670 | // Loop and find all entries that can activate here |
| 4671 | let activatedNow = new Set(); |
| 4672 | |
| 4673 | for (const entry of sortedEntries) { |
| 4674 | // Logging preparation |
| 4675 | let headerLogged = false; |
| 4676 | function log(...args) { |
| 4677 | if (!headerLogged) { |
| 4678 | console.debug(`[WI] Entry ${entry.uid}`, `from '${entry.world}' processing`, entry); |
| 4679 | headerLogged = true; |
| 4680 | } |
| 4681 | console.debug(`[WI] Entry ${entry.uid}`, ...args); |
| 4682 | } |
| 4683 | |
| 4684 | // Already processed, considered and then skipped entries should still be skipped |
| 4685 | if (failedProbabilityChecks.has(entry) || allActivatedEntries.has(`${entry.world}.${entry.uid}`)) { |
| 4686 | continue; |
| 4687 | } |
| 4688 | |
| 4689 | if (entry.disable == true) { |
| 4690 | log('disabled'); |
| 4691 | continue; |
| 4692 | } |
| 4693 | |
| 4694 | // Check for generation type trigger filter |
| 4695 | if (Array.isArray(entry.triggers) && entry.triggers.length > 0) { |
| 4696 | const isTriggered = entry.triggers.includes(globalScanData.trigger); |
| 4697 | if (!isTriggered) { |
| 4698 | log(`skipped by generation type trigger filter (${globalScanData.trigger} ∉ ${entry.triggers})`); |
| 4699 | continue; |
| 4700 | } |
| 4701 | } |
| 4702 | |
| 4703 | // Check if this entry applies to the character or if it's excluded |
| 4704 | if (entry.characterFilter && entry.characterFilter?.names?.length > 0) { |
| 4705 | const nameIncluded = entry.characterFilter.names.includes(getCharaFilename()); |
| 4706 | const filtered = entry.characterFilter.isExclude ? nameIncluded : !nameIncluded; |
| 4707 | |
| 4708 | if (filtered) { |
| 4709 | log('filtered out by character'); |
| 4710 | continue; |
| 4711 | } |
| 4712 | } |
| 4713 | |
| 4714 | if (entry.characterFilter && entry.characterFilter?.tags?.length > 0) { |
| 4715 | const tagKey = getTagKeyForEntity(this_chid); |
| 4716 | |
| 4717 | if (tagKey) { |
| 4718 | const tagMapEntry = context.tagMap[tagKey]; |
| 4719 | |
| 4720 | if (Array.isArray(tagMapEntry)) { |
| 4721 | // If tag map intersects with the tag exclusion list, skip |
| 4722 | const includesTag = tagMapEntry.some((tag) => entry.characterFilter.tags.includes(tag)); |
| 4723 | const filtered = entry.characterFilter.isExclude ? includesTag : !includesTag; |
| 4724 | |
| 4725 | if (filtered) { |
| 4726 | log('filtered out by tag'); |
| 4727 | continue; |
| 4728 | } |
| 4729 | } |
| 4730 | } |
| 4731 | } |
| 4732 | |
| 4733 | const isSticky = timedEffects.isEffectActive('sticky', entry); |
| 4734 | const isCooldown = timedEffects.isEffectActive('cooldown', entry); |
| 4735 | const isDelay = timedEffects.isEffectActive('delay', entry); |
| 4736 | |
| 4737 | if (isDelay) { |
| 4738 | log('suppressed by delay'); |
| 4739 | continue; |
| 4740 | } |
| 4741 | |
| 4742 | if (isCooldown && !isSticky) { |
| 4743 | log('suppressed by cooldown'); |
| 4744 | continue; |
| 4745 | } |
| 4746 | |
| 4747 | // Only use checks for recursion flags if the scan step was activated by recursion |
| 4748 | if (scanState !== scan_state.RECURSION && entry.delayUntilRecursion && !isSticky) { |
| 4749 | log('suppressed by delay until recursion'); |
| 4750 | continue; |
| 4751 | } |
| 4752 | |
| 4753 | if (scanState === scan_state.RECURSION && entry.delayUntilRecursion && entry.delayUntilRecursion > currentRecursionDelayLevel && !isSticky) { |
| 4754 | log('suppressed by delay until recursion level', entry.delayUntilRecursion, '. Currently', currentRecursionDelayLevel); |
| 4755 | continue; |
| 4756 | } |
| 4757 | |
| 4758 | if (scanState === scan_state.RECURSION && world_info_recursive && entry.excludeRecursion && !isSticky) { |
| 4759 | log('suppressed by exclude recursion'); |
| 4760 | continue; |
| 4761 | } |
| 4762 | |
| 4763 | if (entry.decorators.includes('@@activate')) { |
| 4764 | log('activated by @@activate decorator'); |
| 4765 | activatedNow.add(entry); |
| 4766 | continue; |
| 4767 | } |
| 4768 | |
| 4769 | if (entry.decorators.includes('@@dont_activate')) { |
| 4770 | log('suppressed by @@dont_activate decorator'); |
| 4771 | continue; |
| 4772 | } |
| 4773 | |
| 4774 | if (buffer.getExternallyActivated(entry)) { |
| 4775 | log('externally activated'); |
| 4776 | activatedNow.add(buffer.getExternallyActivated(entry)); |
| 4777 | continue; |
| 4778 | } |
| 4779 | |
| 4780 | // Now do checks for immediate activations |
| 4781 | if (entry.constant) { |
| 4782 | log('activated because of constant'); |
| 4783 | activatedNow.add(entry); |
| 4784 | continue; |
| 4785 | } |
| 4786 | |
| 4787 | if (isSticky) { |
| 4788 | log('activated because active sticky'); |
| 4789 | activatedNow.add(entry); |
| 4790 | continue; |
| 4791 | } |
| 4792 | |
| 4793 | if (!Array.isArray(entry.key) || !entry.key.length) { |
| 4794 | log('has no keys defined, skipped'); |
| 4795 | continue; |
| 4796 | } |
| 4797 | |
| 4798 | // Cache the text to scan before the loop, it won't change its content |
| 4799 | const textToScan = buffer.get(entry, scanState); |
| 4800 | |
| 4801 | // PRIMARY KEYWORDS |
| 4802 | let primaryKeyMatch = entry.key.find(key => { |
| 4803 | const substituted = substituteParams(key); |
| 4804 | return substituted && buffer.matchKeys(textToScan, substituted.trim(), entry); |
| 4805 | }); |
| 4806 | |
| 4807 | if (!primaryKeyMatch) { |
| 4808 | // Don't write logs for simple no-matches |
| 4809 | continue; |
| 4810 | } |
| 4811 | |
| 4812 | const hasSecondaryKeywords = ( |
| 4813 | entry.selective && //all entries are selective now |
| 4814 | Array.isArray(entry.keysecondary) && //always true |
| 4815 | entry.keysecondary.length //ignore empties |
| 4816 | ); |
| 4817 | |
| 4818 | if (!hasSecondaryKeywords) { |
| 4819 | // Handle cases where secondary is empty |
| 4820 | log('activated by primary key match', primaryKeyMatch); |
| 4821 | activatedNow.add(entry); |
| 4822 | continue; |
| 4823 | } |
| 4824 | |
| 4825 | |
| 4826 | // SECONDARY KEYWORDS |
| 4827 | const selectiveLogic = entry.selectiveLogic ?? 0; // If selectiveLogic isn't found, assume it's AND, only do this once per entry |
| 4828 | 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)); |
| 4829 | |
| 4830 | /** @type {() => boolean} */ |
| 4831 | function matchSecondaryKeys() { |
| 4832 | let hasAnyMatch = false; |
| 4833 | let hasAllMatch = true; |
| 4834 | for (let keysecondary of entry.keysecondary) { |
| 4835 | const secondarySubstituted = substituteParams(keysecondary); |
| 4836 | const hasSecondaryMatch = secondarySubstituted && buffer.matchKeys(textToScan, secondarySubstituted.trim(), entry); |
| 4837 | |
| 4838 | if (hasSecondaryMatch) hasAnyMatch = true; |
| 4839 | if (!hasSecondaryMatch) hasAllMatch = false; |
| 4840 | |
| 4841 | // Simplified AND ANY / NOT ALL if statement. (Proper fix for PR#1356 by Bronya) |
| 4842 | // If AND ANY logic and the main checks pass OR if NOT ALL logic and the main checks do not pass |
| 4843 | if (selectiveLogic === world_info_logic.AND_ANY && hasSecondaryMatch) { |
| 4844 | log('activated. (AND ANY) Found match secondary keyword', secondarySubstituted); |
| 4845 | return true; |
| 4846 | } |
| 4847 | if (selectiveLogic === world_info_logic.NOT_ALL && !hasSecondaryMatch) { |
| 4848 | log('activated. (NOT ALL) Found not matching secondary keyword', secondarySubstituted); |
| 4849 | return true; |
| 4850 | } |
| 4851 | } |
| 4852 | |
| 4853 | // Handle NOT ANY logic |
| 4854 | if (selectiveLogic === world_info_logic.NOT_ANY && !hasAnyMatch) { |
| 4855 | log('activated. (NOT ANY) No secondary keywords found', entry.keysecondary); |
| 4856 | return true; |
| 4857 | } |
| 4858 | |
| 4859 | // Handle AND ALL logic |
| 4860 | if (selectiveLogic === world_info_logic.AND_ALL && hasAllMatch) { |
| 4861 | log('activated. (AND ALL) All secondary keywords found', entry.keysecondary); |
| 4862 | return true; |
| 4863 | } |
| 4864 | |
| 4865 | return false; |
| 4866 | } |
| 4867 | |
| 4868 | const matched = matchSecondaryKeys(); |
| 4869 | if (!matched) { |
| 4870 | log('skipped. Secondary keywords not satisfied', entry.keysecondary); |
| 4871 | continue; |
| 4872 | } |
| 4873 | |
| 4874 | // Success logging was already done inside the function, so just add the entry |
| 4875 | activatedNow.add(entry); |
| 4876 | continue; |
| 4877 | } |
| 4878 | |
| 4879 | console.debug(`[WI] Search done. Found ${activatedNow.size} possible entries.`); |
| 4880 | |
| 4881 | // Sort the entries for the probability and the budget limit checks |
| 4882 | const newEntries = [...activatedNow] |
| 4883 | .sort((a, b) => { |
| 4884 | const isASticky = timedEffects.isEffectActive('sticky', a) ? 1 : 0; |
| 4885 | const isBSticky = timedEffects.isEffectActive('sticky', b) ? 1 : 0; |
| 4886 | return isBSticky - isASticky || sortedEntries.indexOf(a) - sortedEntries.indexOf(b); |
| 4887 | }); |
| 4888 | |
| 4889 | |
| 4890 | let newContent = ''; |
| 4891 | const textToScanTokens = await getTokenCountAsync(allActivatedText); |
| 4892 | |
| 4893 | filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState, timedEffects); |
| 4894 | |
| 4895 | console.debug('[WI] --- PROBABILITY CHECKS ---'); |
| 4896 | !newEntries.length && console.debug('[WI] No probability checks to do'); |
| 4897 | |
| 4898 | let ignoresBudget = newEntries.filter(e => e.ignoreBudget).length; |
| 4899 | |
| 4900 | for (const entry of newEntries) { |
| 4901 | ignoresBudget -= (entry.ignoreBudget ? 1 : 0); |
| 4902 | if (token_budget_overflowed && !entry.ignoreBudget) { |
| 4903 | if (ignoresBudget > 0) { |
| 4904 | continue; |
| 4905 | } |
| 4906 | break; |
| 4907 | } |
| 4908 | |
| 4909 | function verifyProbability() { |
| 4910 | // If we don't need to roll, it's always true |
| 4911 | if (!entry.useProbability || entry.probability === 100) { |
| 4912 | console.debug(`WI entry ${entry.uid} does not use probability`); |
| 4913 | return true; |
| 4914 | } |
| 4915 | |
| 4916 | const isSticky = timedEffects.isEffectActive('sticky', entry); |
| 4917 | if (isSticky) { |
| 4918 | console.debug(`WI entry ${entry.uid} is sticky, does not need to re-roll probability`); |
| 4919 | return true; |
| 4920 | } |
| 4921 | |
| 4922 | const rollValue = Math.random() * 100; |
| 4923 | if (rollValue <= entry.probability) { |
| 4924 | console.debug(`WI entry ${entry.uid} passed probability check of ${entry.probability}%`); |
| 4925 | return true; |
| 4926 | } |
| 4927 | |
| 4928 | failedProbabilityChecks.add(entry); |
| 4929 | return false; |
| 4930 | } |
| 4931 | |
| 4932 | const success = verifyProbability(); |
| 4933 | if (!success) { |
| 4934 | console.debug(`WI entry ${entry.uid} failed probability check, removing from activated entries`, entry); |
| 4935 | continue; |
| 4936 | } |
| 4937 | |
| 4938 | // Substitute macros inline, for both this checking and also future processing |
| 4939 | entry.content = substituteParams(entry.content); |
| 4940 | newContent += `${entry.content}\n`; |
| 4941 | |
| 4942 | if (!entry.ignoreBudget && (textToScanTokens + (await getTokenCountAsync(newContent))) >= budget) { |
| 4943 | if (!token_budget_overflowed) { |
| 4944 | console.debug('[WI] --- BUDGET OVERFLOW CHECK ---'); |
| 4945 | if (world_info_overflow_alert) { |
| 4946 | console.warn(`[WI] budget of ${budget} reached, stopping after ${allActivatedEntries.size} entries`); |
| 4947 | toastr.warning(`World info budget reached after ${allActivatedEntries.size} entries.`, 'World Info'); |
| 4948 | } else { |
| 4949 | console.debug(`[WI] budget of ${budget} reached, stopping after ${allActivatedEntries.size} entries`); |
| 4950 | } |
| 4951 | token_budget_overflowed = true; |
| 4952 | } |
| 4953 | continue; |
| 4954 | } |
| 4955 | |
| 4956 | allActivatedEntries.set(`${entry.world}.${entry.uid}`, entry); |
| 4957 | console.debug(`[WI] Entry ${entry.uid} activation successful, adding to prompt`, entry); |
| 4958 | } |
| 4959 | |
| 4960 | const successfulNewEntries = newEntries.filter(x => !failedProbabilityChecks.has(x)); |
| 4961 | const successfulNewEntriesForRecursion = successfulNewEntries.filter(x => !x.preventRecursion); |
| 4962 | |
| 4963 | console.debug(`[WI] --- LOOP #${count} RESULT ---`); |
| 4964 | if (!newEntries.length) { |
| 4965 | console.debug('[WI] No new entries activated.'); |
| 4966 | } else if (!successfulNewEntries.length) { |
| 4967 | console.debug('[WI] Probability checks failed for all activated entries. No new entries activated.'); |
| 4968 | } else { |
| 4969 | console.debug(`[WI] Successfully activated ${successfulNewEntries.length} new entries to prompt. ${allActivatedEntries.size} total entries activated.`, successfulNewEntries); |
| 4970 | } |
| 4971 | |
| 4972 | function logNextState(...args) { |
| 4973 | args.length && console.debug(args.shift(), ...args); |
| 4974 | console.debug('[WI] Setting scan state', Object.entries(scan_state).find(x => x[1] === scanState)); |
| 4975 | } |
| 4976 | |
| 4977 | // After processing and rolling entries is done, see if we should continue with normal recursion |
| 4978 | if (world_info_recursive && !token_budget_overflowed && successfulNewEntriesForRecursion.length) { |
| 4979 | nextScanState = scan_state.RECURSION; |
| 4980 | logNextState('[WI] Found', successfulNewEntriesForRecursion.length, 'new entries for recursion'); |
| 4981 | } |
| 4982 | |
| 4983 | // If we are inside min activations scan, and we have recursive buffer, we should do a recursive scan before increasing the buffer again |
| 4984 | // There might be recurse-trigger-able entries that match the buffer, so we need to check that |
| 4985 | if (world_info_recursive && !token_budget_overflowed && scanState === scan_state.MIN_ACTIVATIONS && buffer.hasRecurse()) { |
| 4986 | nextScanState = scan_state.RECURSION; |
| 4987 | logNextState('[WI] Min Activations run done, whill will always be followed by a recursive scan'); |
| 4988 | } |
| 4989 | |
| 4990 | // If scanning is planned to stop, but min activations is set and not satisfied, check if we should continue |
| 4991 | const minActivationsNotSatisfied = world_info_min_activations > 0 && (allActivatedEntries.size < world_info_min_activations); |
| 4992 | if (!nextScanState && !token_budget_overflowed && minActivationsNotSatisfied) { |
| 4993 | console.debug('[WI] --- MIN ACTIVATIONS CHECK ---'); |
| 4994 | |
| 4995 | let over_max = ( |
| 4996 | world_info_min_activations_depth_max > 0 && |
| 4997 | buffer.getDepth() > world_info_min_activations_depth_max |
| 4998 | ) || (buffer.getDepth() > chat.length); |
| 4999 | |
| 5000 | if (!over_max) { |
| 5001 | nextScanState = scan_state.MIN_ACTIVATIONS; // loop |
| 5002 | logNextState(`[WI] Min activations not reached (${allActivatedEntries.size}/${world_info_min_activations}), advancing depth to ${buffer.getDepth() + 1}, starting another scan`); |
| 5003 | buffer.advanceScan(); |
| 5004 | } else { |
| 5005 | console.debug(`[WI] Min activations not reached (${allActivatedEntries.size}/${world_info_min_activations}), but reached on of depth. Stopping`); |
| 5006 | } |
| 5007 | } |
| 5008 | |
| 5009 | // If the scan is done, but we still have open "delay until recursion" levels, we should continue with the next one |
| 5010 | if (nextScanState === scan_state.NONE && availableRecursionDelayLevels.length) { |
| 5011 | nextScanState = scan_state.RECURSION; |
| 5012 | currentRecursionDelayLevel = availableRecursionDelayLevels.shift(); |
| 5013 | logNextState('[WI] Open delayed recursion levels left. Preparing next delayed recursion level', currentRecursionDelayLevel, '. Still delayed:', availableRecursionDelayLevels); |
| 5014 | } |
| 5015 | |
| 5016 | // Final check if we should really continue scan, and extend the current WI recurse buffer |
| 5017 | const curScanState = scanState; |
| 5018 | scanState = nextScanState; |
| 5019 | if (scanState) { |
| 5020 | const text = successfulNewEntriesForRecursion |
| 5021 | .map(x => x.content).join('\n'); |
| 5022 | if (text) { |
| 5023 | buffer.addRecurse(text); |
| 5024 | allActivatedText = (text + '\n' + allActivatedText); |
| 5025 | } |
| 5026 | } else { |
| 5027 | logNextState('[WI] Scan done. No new entries to prompt. Stopping.'); |
| 5028 | } |
| 5029 | |
| 5030 | // Fire an event after each scan loop, so extensions can hook into the current scanning state |
| 5031 | const args = { |
| 5032 | state: { |
| 5033 | current: curScanState, |
| 5034 | next: scanState, |
| 5035 | loopCount: count, |
| 5036 | }, |
| 5037 | new: { |
| 5038 | all: newEntries, |
| 5039 | successful: successfulNewEntries, |
| 5040 | }, |
| 5041 | activated: { |
| 5042 | entries: allActivatedEntries, |
| 5043 | text: allActivatedText, |
| 5044 | }, |
| 5045 | sortedEntries, |
| 5046 | recursionDelay: { |
| 5047 | availableLevels: availableRecursionDelayLevels, |
| 5048 | currentLevel: currentRecursionDelayLevel, |
| 5049 | }, |
| 5050 | budget: { |
| 5051 | current: budget, |
| 5052 | overflowed: token_budget_overflowed, |
| 5053 | }, |
| 5054 | timedEffects, |
| 5055 | }; |
| 5056 | await eventSource.emit(event_types.WORLDINFO_SCAN_DONE, args); |
| 5057 | |
| 5058 | // Some fields are allowed to be changed by listeners, those will be handled here manually. They can be updated via changed the args from the listeners. |
| 5059 | // Any array provided directly can be modified by updating it's elements, adding or removing elements. This has to be done consistently. |
| 5060 | if (args.state.next !== scanState) { |
| 5061 | logNextState('[WI] Scan state changed from', scanState, 'to', args.state.next); |
| 5062 | scanState = args.state.next; |
| 5063 | } |
| 5064 | allActivatedText = args.activated.text; |
| 5065 | currentRecursionDelayLevel = args.recursionDelay.currentLevel; |
| 5066 | budget = args.budget.current; |
| 5067 | token_budget_overflowed = args.budget.overflowed; |
| 5068 | } |
| 5069 | |
| 5070 | console.debug('[WI] --- BUILDING PROMPT ---'); |
| 5071 | |
| 5072 | // Forward-sorted list of entries for joining |
| 5073 | const WIBeforeEntries = []; |
| 5074 | const WIAfterEntries = []; |
| 5075 | const EMEntries = []; |
| 5076 | const ANTopEntries = []; |
| 5077 | const ANBottomEntries = []; |
| 5078 | const WIDepthEntries = []; |
| 5079 | /** @type {{[key: string]: string[]}} */ |
| 5080 | const WIOutletEntries = {}; |
| 5081 | |
| 5082 | // Appends from insertion order 999 to 1. Use unshift for this purpose |
| 5083 | // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays |
| 5084 | [...allActivatedEntries.values()].sort(sortFn).forEach((entry) => { |
| 5085 | const regexDepth = entry.position === world_info_position.atDepth ? (entry.depth ?? DEFAULT_DEPTH) : null; |
| 5086 | const content = getRegexedString(entry.content, regex_placement.WORLD_INFO, { depth: regexDepth, isMarkdown: false, isPrompt: true }); |
| 5087 | |
| 5088 | if (!content) { |
| 5089 | console.debug(`[WI] Entry ${entry.uid}`, 'skipped adding to prompt due to empty content', entry); |
| 5090 | return; |
| 5091 | } |
| 5092 | |
| 5093 | switch (entry.position) { |
| 5094 | case world_info_position.before: |
| 5095 | WIBeforeEntries.unshift(content); |
| 5096 | break; |
| 5097 | case world_info_position.after: |
| 5098 | WIAfterEntries.unshift(content); |
| 5099 | break; |
| 5100 | case world_info_position.EMTop: |
| 5101 | EMEntries.unshift( |
| 5102 | { position: wi_anchor_position.before, content: content }, |
| 5103 | ); |
| 5104 | break; |
| 5105 | case world_info_position.EMBottom: |
| 5106 | EMEntries.unshift( |
| 5107 | { position: wi_anchor_position.after, content: content }, |
| 5108 | ); |
| 5109 | break; |
| 5110 | case world_info_position.ANTop: |
| 5111 | ANTopEntries.unshift(content); |
| 5112 | break; |
| 5113 | case world_info_position.ANBottom: |
| 5114 | ANBottomEntries.unshift(content); |
| 5115 | break; |
| 5116 | case world_info_position.atDepth: { |
| 5117 | const existingDepthIndex = WIDepthEntries.findIndex((e) => e.depth === (entry.depth ?? DEFAULT_DEPTH) && e.role === (entry.role ?? extension_prompt_roles.SYSTEM)); |
| 5118 | if (existingDepthIndex !== -1) { |
| 5119 | WIDepthEntries[existingDepthIndex].entries.unshift(content); |
| 5120 | } else { |
| 5121 | WIDepthEntries.push({ |
| 5122 | depth: entry.depth, |
| 5123 | entries: [content], |
| 5124 | role: entry.role ?? extension_prompt_roles.SYSTEM, |
| 5125 | }); |
| 5126 | } |
| 5127 | break; |
| 5128 | } |
| 5129 | case world_info_position.outlet: { |
| 5130 | if (!entry.outletName) { |
| 5131 | console.warn(`[WI] Entry ${entry.uid} has position 'outlet' but no outlet name. Skipping.`); |
| 5132 | break; |
| 5133 | } |
| 5134 | if (Array.isArray(WIOutletEntries[entry.outletName])) { |
| 5135 | WIOutletEntries[entry.outletName].push(content); |
| 5136 | } else { |
| 5137 | WIOutletEntries[entry.outletName] = [content]; |
| 5138 | } |
| 5139 | break; |
| 5140 | } |
| 5141 | default: |
| 5142 | break; |
| 5143 | } |
| 5144 | }); |
| 5145 | |
| 5146 | const worldInfoBefore = WIBeforeEntries.length ? WIBeforeEntries.join('\n') : ''; |
| 5147 | const worldInfoAfter = WIAfterEntries.length ? WIAfterEntries.join('\n') : ''; |
| 5148 | |
| 5149 | if (shouldWIAddPrompt) { |
| 5150 | const originalAN = context.extensionPrompts[NOTE_MODULE_NAME].value; |
| 5151 | const ANWithWI = `${ANTopEntries.join('\n')}\n${originalAN}\n${ANBottomEntries.join('\n')}`.replace(/(^\n)|(\n$)/g, ''); |
| 5152 | 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]); |
| 5153 | } |
| 5154 | |
| 5155 | timedEffects.setTimedEffects(Array.from(allActivatedEntries.values())); |
| 5156 | buffer.resetExternalEffects(); |
| 5157 | timedEffects.cleanUp(); |
| 5158 | |
| 5159 | console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values())); |
| 5160 | console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`); |
| 5161 | |
| 5162 | return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, ANBeforeEntries: ANTopEntries, ANAfterEntries: ANBottomEntries, outletEntries: WIOutletEntries, allActivatedEntries: new Set(allActivatedEntries.values()) }; |
| 5163 | } |
| 5164 | |
| 5165 | /** |
| 5166 | * Only leaves entries with the highest key matching score in each group. |
| 5167 | * @param {Record<string, WIScanEntry[]>} groups The groups to filter |
| 5168 | * @param {WorldInfoBuffer} buffer The buffer to use for scoring |
| 5169 | * @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry |
| 5170 | * @param {number} scanState The current scan state |
| 5171 | * @param {Map<string, boolean>} hasStickyMap The sticky entries map |
| 5172 | */ |
| 5173 | function filterGroupsByScoring(groups, buffer, removeEntry, scanState, hasStickyMap) { |
| 5174 | for (const [key, group] of Object.entries(groups)) { |
| 5175 | // Group scoring is disabled both globally and for the group entries |
| 5176 | if (!world_info_use_group_scoring && !group.some(x => x.useGroupScoring)) { |
| 5177 | console.debug(`[WI] Skipping group scoring for group '${key}'`); |
| 5178 | continue; |
| 5179 | } |
| 5180 | |
| 5181 | // If the group has any sticky entries, the rest are already removed by the timed effects filter |
| 5182 | const hasAnySticky = hasStickyMap.get(key); |
| 5183 | if (hasAnySticky) { |
| 5184 | console.debug(`[WI] Skipping group scoring check, group '${key}' has sticky entries`); |
| 5185 | continue; |
| 5186 | } |
| 5187 | |
| 5188 | const scores = group.map(entry => buffer.getScore(entry, scanState)); |
| 5189 | const maxScore = Math.max(...scores); |
| 5190 | console.debug(`[WI] Group '${key}' max score:`, maxScore); |
| 5191 | //console.table(group.map((entry, i) => ({ uid: entry.uid, key: JSON.stringify(entry.key), score: scores[i] }))); |
| 5192 | |
| 5193 | for (let i = 0; i < group.length; i++) { |
| 5194 | const isScored = group[i].useGroupScoring ?? world_info_use_group_scoring; |
| 5195 | |
| 5196 | if (!isScored) { |
| 5197 | continue; |
| 5198 | } |
| 5199 | |
| 5200 | if (scores[i] < maxScore) { |
| 5201 | console.debug(`[WI] Entry ${group[i].uid}`, `removed as score loser from inclusion group '${key}'`, group[i]); |
| 5202 | removeEntry(group[i]); |
| 5203 | group.splice(i, 1); |
| 5204 | scores.splice(i, 1); |
| 5205 | i--; |
| 5206 | } |
| 5207 | } |
| 5208 | } |
| 5209 | } |
| 5210 | |
| 5211 | /** |
| 5212 | * Removes entries on cooldown and forces sticky entries as winners. |
| 5213 | * @param {Record<string, WIScanEntry[]>} groups The groups to filter |
| 5214 | * @param {WorldInfoTimedEffects} timedEffects The timed effects to use |
| 5215 | * @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry |
| 5216 | * @returns {Map<string, boolean>} If any sticky entries were found |
| 5217 | */ |
| 5218 | function filterGroupsByTimedEffects(groups, timedEffects, removeEntry) { |
| 5219 | /** @type {Map<string, boolean>} */ |
| 5220 | const hasStickyMap = new Map(); |
| 5221 | |
| 5222 | for (const [key, group] of Object.entries(groups)) { |
| 5223 | hasStickyMap.set(key, false); |
| 5224 | |
| 5225 | // If the group has any sticky entries, leave only the sticky entries |
| 5226 | const stickyEntries = group.filter(x => timedEffects.isEffectActive('sticky', x)); |
| 5227 | if (stickyEntries.length) { |
| 5228 | for (const entry of group) { |
| 5229 | if (stickyEntries.includes(entry)) { |
| 5230 | continue; |
| 5231 | } |
| 5232 | |
| 5233 | console.debug(`[WI] Entry ${entry.uid}`, `removed as a non-sticky loser from inclusion group '${key}'`, entry); |
| 5234 | removeEntry(entry); |
| 5235 | } |
| 5236 | |
| 5237 | hasStickyMap.set(key, true); |
| 5238 | } |
| 5239 | |
| 5240 | // 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. |
| 5241 | const cooldownEntries = group.filter(x => timedEffects.isEffectActive('cooldown', x)); |
| 5242 | if (cooldownEntries.length) { |
| 5243 | console.debug(`[WI] Inclusion group '${key}' has entries on cooldown. They will be removed.`, cooldownEntries); |
| 5244 | for (const entry of cooldownEntries) { |
| 5245 | removeEntry(entry); |
| 5246 | } |
| 5247 | } |
| 5248 | |
| 5249 | const delayEntries = group.filter(x => timedEffects.isEffectActive('delay', x)); |
| 5250 | if (delayEntries.length) { |
| 5251 | console.debug(`[WI] Inclusion group '${key}' has entries with delay. They will be removed.`, delayEntries); |
| 5252 | for (const entry of delayEntries) { |
| 5253 | removeEntry(entry); |
| 5254 | } |
| 5255 | } |
| 5256 | } |
| 5257 | |
| 5258 | return hasStickyMap; |
| 5259 | } |
| 5260 | |
| 5261 | /** |
| 5262 | * Filters entries by inclusion groups. |
| 5263 | * @param {object[]} newEntries Entries activated on current recursion level |
| 5264 | * @param {Map<string, object>} allActivatedEntries Map of all activated entries |
| 5265 | * @param {WorldInfoBuffer} buffer The buffer to use for scanning |
| 5266 | * @param {number} scanState The current scan state |
| 5267 | * @param {WorldInfoTimedEffects} timedEffects The timed effects currently active |
| 5268 | */ |
| 5269 | function filterByInclusionGroups(newEntries, allActivatedEntries, buffer, scanState, timedEffects) { |
| 5270 | console.debug('[WI] --- INCLUSION GROUP CHECKS ---'); |
| 5271 | |
| 5272 | const grouped = newEntries.filter(x => x.group).reduce((acc, item) => { |
| 5273 | item.group.split(/,\s*/).filter(x => x).forEach(group => { |
| 5274 | if (!acc[group]) { |
| 5275 | acc[group] = []; |
| 5276 | } |
| 5277 | acc[group].push(item); |
| 5278 | }); |
| 5279 | return acc; |
| 5280 | }, {}); |
| 5281 | |
| 5282 | if (Object.keys(grouped).length === 0) { |
| 5283 | console.debug('[WI] No inclusion groups found'); |
| 5284 | return; |
| 5285 | } |
| 5286 | |
| 5287 | const removeEntry = (entry) => newEntries.splice(newEntries.indexOf(entry), 1); |
| 5288 | function removeAllBut(group, chosen, logging = true) { |
| 5289 | for (const entry of group) { |
| 5290 | if (entry === chosen) { |
| 5291 | continue; |
| 5292 | } |
| 5293 | |
| 5294 | if (logging) console.debug(`[WI] Entry ${entry.uid}`, `removed as loser from inclusion group '${entry.group}'`, entry); |
| 5295 | removeEntry(entry); |
| 5296 | } |
| 5297 | } |
| 5298 | |
| 5299 | const hasStickyMap = filterGroupsByTimedEffects(grouped, timedEffects, removeEntry); |
| 5300 | filterGroupsByScoring(grouped, buffer, removeEntry, scanState, hasStickyMap); |
| 5301 | |
| 5302 | for (const [key, group] of Object.entries(grouped)) { |
| 5303 | console.debug(`[WI] Checking inclusion group '${key}' with ${group.length} entries`, group); |
| 5304 | |
| 5305 | // If the group has any sticky entries, the rest are already removed by the timed effects filter |
| 5306 | const hasAnySticky = hasStickyMap.get(key); |
| 5307 | if (hasAnySticky) { |
| 5308 | console.debug(`[WI] Skipping inclusion group check, group '${key}' has sticky entries`); |
| 5309 | continue; |
| 5310 | } |
| 5311 | |
| 5312 | if (Array.from(allActivatedEntries.values()).some(x => x.group === key)) { |
| 5313 | console.debug(`[WI] Skipping inclusion group check, group '${key}' was already activated`); |
| 5314 | // We need to forcefully deactivate all other entries in the group |
| 5315 | removeAllBut(group, null, false); |
| 5316 | continue; |
| 5317 | } |
| 5318 | |
| 5319 | if (!Array.isArray(group) || group.length <= 1) { |
| 5320 | console.debug('[WI] Skipping inclusion group check, only one entry'); |
| 5321 | continue; |
| 5322 | } |
| 5323 | |
| 5324 | // Check for group prio |
| 5325 | const prios = group.filter(x => x.groupOverride).sort(sortFn); |
| 5326 | if (prios.length) { |
| 5327 | console.debug(`[WI] Entry ${prios[0].uid}`, `activated as prio winner from inclusion group '${key}'`, prios[0]); |
| 5328 | removeAllBut(group, prios[0]); |
| 5329 | continue; |
| 5330 | } |
| 5331 | |
| 5332 | // Do weighted random using entry's weight |
| 5333 | const totalWeight = group.reduce((acc, item) => acc + (item.groupWeight ?? DEFAULT_WEIGHT), 0); |
| 5334 | const rollValue = Math.random() * totalWeight; |
| 5335 | let currentWeight = 0; |
| 5336 | let winner = null; |
| 5337 | |
| 5338 | for (const entry of group) { |
| 5339 | currentWeight += (entry.groupWeight ?? DEFAULT_WEIGHT); |
| 5340 | |
| 5341 | if (rollValue <= currentWeight) { |
| 5342 | console.debug(`[WI] Entry ${entry.uid}`, `activated as roll winner from inclusion group '${key}'`, entry); |
| 5343 | winner = entry; |
| 5344 | break; |
| 5345 | } |
| 5346 | } |
| 5347 | |
| 5348 | if (!winner) { |
| 5349 | console.debug(`[WI] Failed to activate inclusion group '${key}', no winner found`); |
| 5350 | continue; |
| 5351 | } |
| 5352 | |
| 5353 | // Remove every group item from newEntries but the winner |
| 5354 | removeAllBut(group, winner); |
| 5355 | } |
| 5356 | } |
| 5357 | |
| 5358 | function convertAgnaiMemoryBook(inputObj) { |
| 5359 | const outputObj = { entries: {} }; |
| 5360 | |
| 5361 | inputObj.entries.forEach((entry, index) => { |
| 5362 | outputObj.entries[index] = { |
| 5363 | ...newWorldInfoEntryTemplate, |
| 5364 | uid: index, |
| 5365 | key: entry.keywords, |
| 5366 | keysecondary: [], |
| 5367 | comment: entry.name, |
| 5368 | content: entry.entry, |
| 5369 | constant: false, |
| 5370 | selective: false, |
| 5371 | vectorized: false, |
| 5372 | selectiveLogic: world_info_logic.AND_ANY, |
| 5373 | order: entry.weight, |
| 5374 | position: 0, |
| 5375 | disable: !entry.enabled, |
| 5376 | addMemo: !!entry.name, |
| 5377 | excludeRecursion: false, |
| 5378 | delayUntilRecursion: false, |
| 5379 | displayIndex: index, |
| 5380 | probability: 100, |
| 5381 | useProbability: true, |
| 5382 | outletName: '', |
| 5383 | group: '', |
| 5384 | groupOverride: false, |
| 5385 | groupWeight: DEFAULT_WEIGHT, |
| 5386 | scanDepth: null, |
| 5387 | caseSensitive: null, |
| 5388 | matchWholeWords: null, |
| 5389 | useGroupScoring: null, |
| 5390 | automationId: '', |
| 5391 | role: extension_prompt_roles.SYSTEM, |
| 5392 | sticky: null, |
| 5393 | cooldown: null, |
| 5394 | delay: null, |
| 5395 | triggers: [], |
| 5396 | ignoreBudget: false, |
| 5397 | }; |
| 5398 | }); |
| 5399 | |
| 5400 | return outputObj; |
| 5401 | } |
| 5402 | |
| 5403 | function convertRisuLorebook(inputObj) { |
| 5404 | const outputObj = { entries: {} }; |
| 5405 | |
| 5406 | inputObj.data.forEach((entry, index) => { |
| 5407 | outputObj.entries[index] = { |
| 5408 | ...newWorldInfoEntryTemplate, |
| 5409 | uid: index, |
| 5410 | key: entry.key.split(',').map(x => x.trim()), |
| 5411 | keysecondary: entry.secondkey ? entry.secondkey.split(',').map(x => x.trim()) : [], |
| 5412 | comment: entry.comment, |
| 5413 | content: entry.content, |
| 5414 | constant: entry.alwaysActive, |
| 5415 | selective: entry.selective, |
| 5416 | vectorized: false, |
| 5417 | selectiveLogic: world_info_logic.AND_ANY, |
| 5418 | order: entry.insertorder, |
| 5419 | position: world_info_position.before, |
| 5420 | disable: false, |
| 5421 | addMemo: true, |
| 5422 | excludeRecursion: false, |
| 5423 | delayUntilRecursion: false, |
| 5424 | displayIndex: index, |
| 5425 | probability: entry.activationPercent ?? 100, |
| 5426 | useProbability: entry.activationPercent ?? true, |
| 5427 | outletName: '', |
| 5428 | group: '', |
| 5429 | groupOverride: false, |
| 5430 | groupWeight: DEFAULT_WEIGHT, |
| 5431 | scanDepth: null, |
| 5432 | caseSensitive: null, |
| 5433 | matchWholeWords: null, |
| 5434 | useGroupScoring: null, |
| 5435 | automationId: '', |
| 5436 | role: extension_prompt_roles.SYSTEM, |
| 5437 | sticky: null, |
| 5438 | cooldown: null, |
| 5439 | delay: null, |
| 5440 | triggers: [], |
| 5441 | ignoreBudget: false, |
| 5442 | }; |
| 5443 | }); |
| 5444 | |
| 5445 | return outputObj; |
| 5446 | } |
| 5447 | |
| 5448 | function convertNovelLorebook(inputObj) { |
| 5449 | const outputObj = { |
| 5450 | entries: {}, |
| 5451 | }; |
| 5452 | |
| 5453 | inputObj.entries.forEach((entry, index) => { |
| 5454 | const displayName = entry.displayName; |
| 5455 | const addMemo = displayName !== undefined && displayName.trim() !== ''; |
| 5456 | |
| 5457 | outputObj.entries[index] = { |
| 5458 | ...newWorldInfoEntryTemplate, |
| 5459 | uid: index, |
| 5460 | key: entry.keys, |
| 5461 | keysecondary: [], |
| 5462 | comment: displayName || '', |
| 5463 | content: entry.text, |
| 5464 | constant: false, |
| 5465 | selective: false, |
| 5466 | vectorized: false, |
| 5467 | selectiveLogic: world_info_logic.AND_ANY, |
| 5468 | order: entry.contextConfig?.budgetPriority ?? 0, |
| 5469 | position: 0, |
| 5470 | disable: !entry.enabled, |
| 5471 | addMemo: addMemo, |
| 5472 | excludeRecursion: false, |
| 5473 | delayUntilRecursion: false, |
| 5474 | displayIndex: index, |
| 5475 | probability: 100, |
| 5476 | useProbability: true, |
| 5477 | outletName: '', |
| 5478 | group: '', |
| 5479 | groupOverride: false, |
| 5480 | groupWeight: DEFAULT_WEIGHT, |
| 5481 | scanDepth: null, |
| 5482 | caseSensitive: null, |
| 5483 | matchWholeWords: null, |
| 5484 | useGroupScoring: null, |
| 5485 | automationId: '', |
| 5486 | role: extension_prompt_roles.SYSTEM, |
| 5487 | sticky: null, |
| 5488 | cooldown: null, |
| 5489 | delay: null, |
| 5490 | triggers: [], |
| 5491 | ignoreBudget: false, |
| 5492 | }; |
| 5493 | }); |
| 5494 | |
| 5495 | return outputObj; |
| 5496 | } |
| 5497 | |
| 5498 | export function convertCharacterBook(characterBook) { |
| 5499 | const result = { entries: {}, originalData: characterBook }; |
| 5500 | |
| 5501 | characterBook.entries.forEach((entry, index) => { |
| 5502 | // Not in the spec, but this is needed to find the entry in the original data |
| 5503 | if (entry.id === undefined) { |
| 5504 | entry.id = index; |
| 5505 | } |
| 5506 | |
| 5507 | result.entries[entry.id] = { |
| 5508 | ...newWorldInfoEntryTemplate, |
| 5509 | uid: entry.id, |
| 5510 | key: entry.keys, |
| 5511 | keysecondary: entry.secondary_keys || [], |
| 5512 | comment: entry.comment || '', |
| 5513 | content: entry.content, |
| 5514 | constant: entry.constant || false, |
| 5515 | selective: entry.selective || false, |
| 5516 | order: entry.insertion_order, |
| 5517 | position: entry.extensions?.position ?? (entry.position === 'before_char' ? world_info_position.before : world_info_position.after), |
| 5518 | excludeRecursion: entry.extensions?.exclude_recursion ?? false, |
| 5519 | preventRecursion: entry.extensions?.prevent_recursion ?? false, |
| 5520 | delayUntilRecursion: entry.extensions?.delay_until_recursion ?? false, |
| 5521 | disable: !entry.enabled, |
| 5522 | addMemo: !!entry.comment, |
| 5523 | displayIndex: entry.extensions?.display_index ?? index, |
| 5524 | probability: entry.extensions?.probability ?? 100, |
| 5525 | useProbability: entry.extensions?.useProbability ?? true, |
| 5526 | depth: entry.extensions?.depth ?? DEFAULT_DEPTH, |
| 5527 | selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY, |
| 5528 | outletName: entry.extensions?.outlet_name ?? '', |
| 5529 | group: entry.extensions?.group ?? '', |
| 5530 | groupOverride: entry.extensions?.group_override ?? false, |
| 5531 | groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT, |
| 5532 | scanDepth: entry.extensions?.scan_depth ?? null, |
| 5533 | caseSensitive: entry.extensions?.case_sensitive ?? null, |
| 5534 | matchWholeWords: entry.extensions?.match_whole_words ?? null, |
| 5535 | useGroupScoring: entry.extensions?.use_group_scoring ?? null, |
| 5536 | automationId: entry.extensions?.automation_id ?? '', |
| 5537 | role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM, |
| 5538 | vectorized: entry.extensions?.vectorized ?? false, |
| 5539 | sticky: entry.extensions?.sticky ?? null, |
| 5540 | cooldown: entry.extensions?.cooldown ?? null, |
| 5541 | delay: entry.extensions?.delay ?? null, |
| 5542 | matchPersonaDescription: entry.extensions?.match_persona_description ?? false, |
| 5543 | matchCharacterDescription: entry.extensions?.match_character_description ?? false, |
| 5544 | matchCharacterPersonality: entry.extensions?.match_character_personality ?? false, |
| 5545 | matchCharacterDepthPrompt: entry.extensions?.match_character_depth_prompt ?? false, |
| 5546 | matchScenario: entry.extensions?.match_scenario ?? false, |
| 5547 | matchCreatorNotes: entry.extensions?.match_creator_notes ?? false, |
| 5548 | extensions: entry.extensions ?? {}, |
| 5549 | triggers: entry.extensions?.triggers || [], |
| 5550 | ignoreBudget: entry.extensions?.ignore_budget ?? false, |
| 5551 | }; |
| 5552 | }); |
| 5553 | |
| 5554 | return result; |
| 5555 | } |
| 5556 | |
| 5557 | export function setWorldInfoButtonClass(chid, forceValue = undefined) { |
| 5558 | if (forceValue !== undefined) { |
| 5559 | $('#set_character_world, #world_button').toggleClass('world_set', forceValue); |
| 5560 | return; |
| 5561 | } |
| 5562 | |
| 5563 | if (chid === undefined) { |
| 5564 | return; |
| 5565 | } |
| 5566 | |
| 5567 | const world = characters[chid]?.data?.extensions?.world; |
| 5568 | const worldSet = Boolean(world && world_names.includes(world)); |
| 5569 | $('#set_character_world, #world_button').toggleClass('world_set', worldSet); |
| 5570 | } |
| 5571 | |
| 5572 | export function checkEmbeddedWorld(chid) { |
| 5573 | $('#import_character_info').hide(); |
| 5574 | |
| 5575 | if (chid === undefined) { |
| 5576 | return false; |
| 5577 | } |
| 5578 | |
| 5579 | if (characters[chid]?.data?.character_book) { |
| 5580 | $('#import_character_info').data('chid', chid).show(); |
| 5581 | |
| 5582 | // Only show the alert once per character |
| 5583 | const checkKey = `AlertWI_${characters[chid].avatar}`; |
| 5584 | const worldName = characters[chid]?.data?.extensions?.world; |
| 5585 | if (!accountStorage.getItem(checkKey) && (!worldName || !world_names.includes(worldName))) { |
| 5586 | accountStorage.setItem(checkKey, 'true'); |
| 5587 | |
| 5588 | if (power_user.world_import_dialog) { |
| 5589 | const html = `<h3>This character has an embedded World/Lorebook.</h3> |
| 5590 | <h3>Would you like to import it now?</h3> |
| 5591 | <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>`; |
| 5592 | const checkResult = (result) => { |
| 5593 | if (result) { |
| 5594 | importEmbeddedWorldInfo(true); |
| 5595 | } |
| 5596 | }; |
| 5597 | callGenericPopup(html, POPUP_TYPE.CONFIRM, '', { okButton: 'Yes' }).then(checkResult); |
| 5598 | } else { |
| 5599 | toastr.info( |
| 5600 | 'To import and use it, select "Import Card Lore" in the "More..." dropdown menu on the character panel.', |
| 5601 | `${characters[chid].name} has an embedded World/Lorebook`, |
| 5602 | { timeOut: 5000, extendedTimeOut: 10000 }, |
| 5603 | ); |
| 5604 | } |
| 5605 | } |
| 5606 | return true; |
| 5607 | } |
| 5608 | |
| 5609 | return false; |
| 5610 | } |
| 5611 | |
| 5612 | export async function importEmbeddedWorldInfo(skipPopup = false) { |
| 5613 | const chid = $('#import_character_info').data('chid'); |
| 5614 | |
| 5615 | if (chid === undefined || chid === -1) { |
| 5616 | return; |
| 5617 | } |
| 5618 | |
| 5619 | const hasEmbed = checkEmbeddedWorld(chid); |
| 5620 | |
| 5621 | if (!hasEmbed) { |
| 5622 | return; |
| 5623 | } |
| 5624 | |
| 5625 | const bookName = characters[chid]?.data?.character_book?.name || `${characters[chid]?.name}'s Lorebook`; |
| 5626 | |
| 5627 | if (!skipPopup) { |
| 5628 | const confirmation = await Popup.show.confirm(t`Are you sure you want to import '${bookName}'?`, world_names.includes(bookName) ? t`It will overwrite the World/Lorebook with the same name.` : ''); |
| 5629 | if (!confirmation) { |
| 5630 | return; |
| 5631 | } |
| 5632 | } |
| 5633 | |
| 5634 | const convertedBook = convertCharacterBook(characters[chid].data.character_book); |
| 5635 | |
| 5636 | await saveWorldInfo(bookName, convertedBook, true); |
| 5637 | await updateWorldInfoList(); |
| 5638 | $('#character_world').val(bookName).trigger('change'); |
| 5639 | |
| 5640 | toastr.success(t`The world '${bookName}' has been imported and linked to the character successfully.`, t`World/Lorebook imported`); |
| 5641 | |
| 5642 | const newIndex = world_names.indexOf(bookName); |
| 5643 | if (newIndex >= 0) { |
| 5644 | //show&draw the WI panel before.. |
| 5645 | $('#WIDrawerIcon').trigger('click'); |
| 5646 | //..auto-opening the new imported WI |
| 5647 | $('#world_editor_select').val(newIndex).trigger('change'); |
| 5648 | } |
| 5649 | |
| 5650 | setWorldInfoButtonClass(chid, true); |
| 5651 | } |
| 5652 | |
| 5653 | export function onWorldInfoChange(args, text) { |
| 5654 | if (args !== '__notSlashCommand__') { // if it's a slash command |
| 5655 | const silent = isTrueBoolean(args.silent); |
| 5656 | if (text.trim() !== '') { // and args are provided |
| 5657 | const slashInputSplitText = text.trim().toLowerCase().split(','); |
| 5658 | |
| 5659 | slashInputSplitText.forEach((worldName) => { |
| 5660 | const wiElement = getWIElement(worldName); |
| 5661 | if (wiElement.length > 0) { |
| 5662 | const name = wiElement.text(); |
| 5663 | switch (args.state) { |
| 5664 | case 'off': { |
| 5665 | if (selected_world_info.includes(name)) { |
| 5666 | selected_world_info.splice(selected_world_info.indexOf(name), 1); |
| 5667 | wiElement.prop('selected', false); |
| 5668 | if (!silent) toastr.success(t`Deactivated world: ${name}`); |
| 5669 | } else { |
| 5670 | if (!silent) toastr.error(t`World was not active: ${name}`); |
| 5671 | } |
| 5672 | break; |
| 5673 | } |
| 5674 | case 'toggle': { |
| 5675 | if (selected_world_info.includes(name)) { |
| 5676 | selected_world_info.splice(selected_world_info.indexOf(name), 1); |
| 5677 | wiElement.prop('selected', false); |
| 5678 | if (!silent) toastr.success(t`Deactivated world: ${name}`); |
| 5679 | } else { |
| 5680 | selected_world_info.push(name); |
| 5681 | wiElement.prop('selected', true); |
| 5682 | if (!silent) toastr.success(t`Activated world: ${name}`); |
| 5683 | } |
| 5684 | break; |
| 5685 | } |
| 5686 | case 'on': |
| 5687 | default: { |
| 5688 | selected_world_info.push(name); |
| 5689 | wiElement.prop('selected', true); |
| 5690 | if (!silent) toastr.success(t`Activated world: ${name}`); |
| 5691 | } |
| 5692 | } |
| 5693 | } else { |
| 5694 | if (!silent) toastr.error(t`No world found named: ${worldName}`); |
| 5695 | } |
| 5696 | }); |
| 5697 | $('#world_info').trigger('change'); |
| 5698 | } else { // if no args, unset all worlds |
| 5699 | if (!silent) toastr.success(t`Deactivated all worlds`); |
| 5700 | selected_world_info = []; |
| 5701 | $('#world_info').val(null).trigger('change'); |
| 5702 | } |
| 5703 | } else { //if it's a pointer selection |
| 5704 | const tempWorldInfo = []; |
| 5705 | const val = $('#world_info').val(); |
| 5706 | const selectedWorlds = (Array.isArray(val) ? val : [val]).map((e) => Number(e)).filter((e) => !isNaN(e)); |
| 5707 | if (selectedWorlds.length > 0) { |
| 5708 | selectedWorlds.forEach((worldIndex) => { |
| 5709 | const existingWorldName = world_names[worldIndex]; |
| 5710 | if (existingWorldName) { |
| 5711 | tempWorldInfo.push(existingWorldName); |
| 5712 | } else { |
| 5713 | const wiElement = getWIElement(existingWorldName); |
| 5714 | wiElement.prop('selected', false); |
| 5715 | toastr.error(t`The world with ${existingWorldName} is invalid or corrupted.`); |
| 5716 | } |
| 5717 | }); |
| 5718 | } |
| 5719 | selected_world_info = tempWorldInfo; |
| 5720 | } |
| 5721 | |
| 5722 | saveSettingsDebounced(); |
| 5723 | eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED); |
| 5724 | return ''; |
| 5725 | } |
| 5726 | |
| 5727 | /** |
| 5728 | * Imports world info from a file. |
| 5729 | * @param {File} file File to import |
| 5730 | */ |
| 5731 | export async function importWorldInfo(file) { |
| 5732 | if (!file) { |
| 5733 | return; |
| 5734 | } |
| 5735 | |
| 5736 | const formData = new FormData(); |
| 5737 | formData.append('avatar', file); |
| 5738 | |
| 5739 | try { |
| 5740 | let jsonData; |
| 5741 | |
| 5742 | if (file.name.endsWith('.png')) { |
| 5743 | const buffer = new Uint8Array(await getFileBuffer(file)); |
| 5744 | jsonData = extractDataFromPng(buffer, 'naidata'); |
| 5745 | } else { |
| 5746 | // File should be a JSON file |
| 5747 | jsonData = await parseJsonFile(file); |
| 5748 | } |
| 5749 | |
| 5750 | if (jsonData === undefined || jsonData === null) { |
| 5751 | toastr.error(t`File is not valid: ${file.name}`); |
| 5752 | return; |
| 5753 | } |
| 5754 | |
| 5755 | // Convert Novel Lorebook |
| 5756 | if (jsonData.lorebookVersion !== undefined) { |
| 5757 | console.log('Converting Novel Lorebook'); |
| 5758 | formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData))); |
| 5759 | } |
| 5760 | |
| 5761 | // Convert Agnai Memory Book |
| 5762 | if (jsonData.kind === 'memory') { |
| 5763 | console.log('Converting Agnai Memory Book'); |
| 5764 | formData.append('convertedData', JSON.stringify(convertAgnaiMemoryBook(jsonData))); |
| 5765 | } |
| 5766 | |
| 5767 | // Convert Risu Lorebook |
| 5768 | if (jsonData.type === 'risu') { |
| 5769 | console.log('Converting Risu Lorebook'); |
| 5770 | formData.append('convertedData', JSON.stringify(convertRisuLorebook(jsonData))); |
| 5771 | } |
| 5772 | } catch (error) { |
| 5773 | toastr.error(`Error parsing file: ${error}`); |
| 5774 | return; |
| 5775 | } |
| 5776 | |
| 5777 | const worldName = file.name.substr(0, file.name.lastIndexOf('.')); |
| 5778 | const sanitizedWorldName = await getSanitizedFilename(worldName); |
| 5779 | const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) }); |
| 5780 | if (!allowed) { |
| 5781 | return false; |
| 5782 | } |
| 5783 | |
| 5784 | try { |
| 5785 | const result = await fetch('/api/worldinfo/import', { |
| 5786 | method: 'POST', |
| 5787 | headers: getRequestHeaders({ omitContentType: true }), |
| 5788 | body: formData, |
| 5789 | cache: 'no-cache', |
| 5790 | }); |
| 5791 | |
| 5792 | if (!result.ok) { |
| 5793 | throw new Error(`Failed to import world info: ${result.statusText}`); |
| 5794 | } |
| 5795 | |
| 5796 | const data = await result.json(); |
| 5797 | |
| 5798 | if (data.name) { |
| 5799 | await updateWorldInfoList(); |
| 5800 | |
| 5801 | const newIndex = world_names.indexOf(data.name); |
| 5802 | if (newIndex >= 0) { |
| 5803 | $('#world_editor_select').val(newIndex).trigger('change'); |
| 5804 | } |
| 5805 | |
| 5806 | toastr.success(t`World Info "${data.name}" imported successfully!`); |
| 5807 | } |
| 5808 | } catch (error) { |
| 5809 | console.error('Error importing world info:', error); |
| 5810 | toastr.error(t`Failed to import World Info`); |
| 5811 | } |
| 5812 | } |
| 5813 | |
| 5814 | /** |
| 5815 | * Forces the world info editor to open on a specific world. |
| 5816 | * @param {string} worldName The name of the world to open |
| 5817 | */ |
| 5818 | export function openWorldInfoEditor(worldName) { |
| 5819 | console.log(`Opening lorebook for ${worldName}`); |
| 5820 | if (!$('#WorldInfo').is(':visible')) { |
| 5821 | $('#WIDrawerIcon').trigger('click'); |
| 5822 | } |
| 5823 | const index = world_names.indexOf(worldName); |
| 5824 | $('#world_editor_select').val(index).trigger('change'); |
| 5825 | } |
| 5826 | |
| 5827 | /** |
| 5828 | * Assigns a lorebook to the current chat. |
| 5829 | * @param {Pick<JQuery.ClickEvent, 'shiftKey' | 'altKey'>} event Click event |
| 5830 | * @returns {Promise<void>} |
| 5831 | */ |
| 5832 | export async function assignLorebookToChat({ shiftKey, altKey }) { |
| 5833 | const selectedName = chat_metadata[METADATA_KEY]; |
| 5834 | |
| 5835 | if (selectedName && !shiftKey && !altKey) { |
| 5836 | openWorldInfoEditor(selectedName); |
| 5837 | return; |
| 5838 | } |
| 5839 | |
| 5840 | const template = $(await renderTemplateAsync('chatLorebook')); |
| 5841 | |
| 5842 | const worldSelect = template.find('select'); |
| 5843 | const chatName = template.find('.chat_name'); |
| 5844 | chatName.text(getCurrentChatId()); |
| 5845 | |
| 5846 | for (const worldName of world_names) { |
| 5847 | const option = document.createElement('option'); |
| 5848 | option.value = worldName; |
| 5849 | option.innerText = worldName; |
| 5850 | option.selected = selectedName === worldName; |
| 5851 | worldSelect.append(option); |
| 5852 | } |
| 5853 | |
| 5854 | worldSelect.on('change', function () { |
| 5855 | const worldName = $(this).val(); |
| 5856 | |
| 5857 | if (worldName) { |
| 5858 | chat_metadata[METADATA_KEY] = worldName; |
| 5859 | $('.chat_lorebook_button').addClass('world_set'); |
| 5860 | } else { |
| 5861 | delete chat_metadata[METADATA_KEY]; |
| 5862 | $('.chat_lorebook_button').removeClass('world_set'); |
| 5863 | } |
| 5864 | |
| 5865 | saveMetadata(); |
| 5866 | }); |
| 5867 | |
| 5868 | await callGenericPopup(template, POPUP_TYPE.TEXT); |
| 5869 | } |
| 5870 | |
| 5871 | /** |
| 5872 | * Moves a World Info entry from a source lorebook to a target lorebook. |
| 5873 | * |
| 5874 | * @param {string} sourceName - The name of the source lorebook file. |
| 5875 | * @param {string} targetName - The name of the target lorebook file. |
| 5876 | * @param {string|number} uid - The UID of the entry to move from the source lorebook. |
| 5877 | * @param {Object} options - Additional options for the move operation. |
| 5878 | * @param {boolean} [options.deleteOriginal=true] - Whether to delete the original entry from the source lorebook after moving it. |
| 5879 | * @returns {Promise<boolean>} True if the move was successful, false otherwise. |
| 5880 | */ |
| 5881 | export async function moveWorldInfoEntry(sourceName, targetName, uid, { deleteOriginal = true } = {}) { |
| 5882 | if (sourceName === targetName) { |
| 5883 | return false; |
| 5884 | } |
| 5885 | |
| 5886 | if (!world_names.includes(sourceName)) { |
| 5887 | toastr.error(t`Source lorebook '${sourceName}' not found.`); |
| 5888 | console.error(`[WI Move] Source lorebook '${sourceName}' does not exist.`); |
| 5889 | return false; |
| 5890 | } |
| 5891 | |
| 5892 | if (!world_names.includes(targetName)) { |
| 5893 | toastr.error(t`Target lorebook '${targetName}' not found.`); |
| 5894 | console.error(`[WI Move] Target lorebook '${targetName}' does not exist.`); |
| 5895 | return false; |
| 5896 | } |
| 5897 | |
| 5898 | const entryUidString = String(uid); |
| 5899 | |
| 5900 | try { |
| 5901 | const sourceData = await loadWorldInfo(sourceName); |
| 5902 | const targetData = await loadWorldInfo(targetName); |
| 5903 | |
| 5904 | if (!sourceData || !sourceData.entries) { |
| 5905 | toastr.error(t`Failed to load data for source lorebook '${sourceName}'.`); |
| 5906 | console.error(`[WI Move] Could not load source data for '${sourceName}'.`); |
| 5907 | return false; |
| 5908 | } |
| 5909 | if (!targetData || !targetData.entries) { |
| 5910 | toastr.error(t`Failed to load data for target lorebook '${targetName}'.`); |
| 5911 | console.error(`[WI Move] Could not load target data for '${targetName}'.`); |
| 5912 | return false; |
| 5913 | } |
| 5914 | |
| 5915 | if (!sourceData.entries[entryUidString]) { |
| 5916 | toastr.error(t`Entry not found in source lorebook '${sourceName}'.`); |
| 5917 | console.error(`[WI Move] Entry UID ${entryUidString} not found in '${sourceName}'.`); |
| 5918 | return false; |
| 5919 | } |
| 5920 | |
| 5921 | const entryToMove = structuredClone(sourceData.entries[entryUidString]); |
| 5922 | |
| 5923 | const newUid = getFreeWorldEntryUid(targetData); |
| 5924 | if (newUid === null) { |
| 5925 | console.error(`[WI Move] Failed to get a free UID in '${targetName}'.`); |
| 5926 | return false; |
| 5927 | } |
| 5928 | |
| 5929 | entryToMove.uid = newUid; |
| 5930 | // Place the entry at the end of the target lorebook |
| 5931 | const maxDisplayIndex = Object.values(targetData.entries).reduce((max, entry) => Math.max(max, entry.displayIndex ?? -1), -1); |
| 5932 | entryToMove.displayIndex = maxDisplayIndex + 1; |
| 5933 | |
| 5934 | targetData.entries[newUid] = entryToMove; |
| 5935 | |
| 5936 | if (deleteOriginal) { |
| 5937 | delete sourceData.entries[entryUidString]; |
| 5938 | // Remove from originalData if it exists |
| 5939 | deleteWIOriginalDataValue(sourceData, entryUidString); |
| 5940 | // TODO: setWIOriginalDataValue |
| 5941 | console.debug(`[WI Move] Removed entry UID ${entryUidString} from source '${sourceName}'.`); |
| 5942 | } |
| 5943 | |
| 5944 | await saveWorldInfo(targetName, targetData, true); |
| 5945 | console.debug(`[WI Move] Saved target lorebook '${targetName}'.`); |
| 5946 | await saveWorldInfo(sourceName, sourceData, true); |
| 5947 | console.debug(`[WI Move] Saved source lorebook '${sourceName}'.`); |
| 5948 | |
| 5949 | console.log(`[WI Move] ${entryToMove.comment} ${deleteOriginal ? 'moved' : 'copied'} successfully to '${targetName}'.`); |
| 5950 | |
| 5951 | // Check if the currently viewed book in the editor is the source or target and reload it |
| 5952 | const currentEditorBookIndex = Number($('#world_editor_select').val()); |
| 5953 | if (!isNaN(currentEditorBookIndex)) { |
| 5954 | const currentEditorBookName = world_names[currentEditorBookIndex]; |
| 5955 | if (currentEditorBookName === sourceName || currentEditorBookName === targetName) { |
| 5956 | reloadEditor(currentEditorBookName); |
| 5957 | } |
| 5958 | } |
| 5959 | |
| 5960 | toastr.success(deleteOriginal |
| 5961 | ? t`Entry moved successfully from '${sourceName}' to '${targetName}'.` |
| 5962 | : t`Entry copied successfully to '${targetName}'.`); |
| 5963 | |
| 5964 | return true; |
| 5965 | } catch (error) { |
| 5966 | toastr.error(t`An unexpected error occurred while moving the entry: ${error.message}`); |
| 5967 | console.error('[WI Move] Unexpected error:', error); |
| 5968 | return false; |
| 5969 | } |
| 5970 | } |
| 5971 | |
| 5972 | |
| 5973 | /** |
| 5974 | * Updates the primary world info linked to a character. |
| 5975 | * Can also unset it to null. |
| 5976 | * @param {string} name - The name of the world info to link to the character. |
| 5977 | */ |
| 5978 | export async function charUpdatePrimaryWorld(name) { |
| 5979 | const previousValue = $('#character_world').val(); |
| 5980 | $('#character_world').val(name); |
| 5981 | |
| 5982 | console.debug('Character world selected:', name); |
| 5983 | |
| 5984 | if (menu_type == 'create') { |
| 5985 | create_save.world = name; |
| 5986 | return; |
| 5987 | } |
| 5988 | |
| 5989 | if (previousValue && !name) { |
| 5990 | try { |
| 5991 | // Dirty hack to remove embedded lorebook from character JSON data. |
| 5992 | const data = JSON.parse(String($('#character_json_data').val())); |
| 5993 | |
| 5994 | if (data?.data?.character_book) { |
| 5995 | data.data.character_book = undefined; |
| 5996 | } |
| 5997 | |
| 5998 | $('#character_json_data').val(JSON.stringify(data)); |
| 5999 | toastr.info(t`Embedded lorebook will be removed from this character.`); |
| 6000 | } catch { |
| 6001 | console.error('Failed to parse character JSON data.'); |
| 6002 | } |
| 6003 | } |
| 6004 | |
| 6005 | await createOrEditCharacter(); |
| 6006 | |
| 6007 | setWorldInfoButtonClass(undefined, !!name); |
| 6008 | } |
| 6009 | |
| 6010 | /** |
| 6011 | * Adds one or more auxiliary world books to a character. |
| 6012 | * @param {string} characterKey - The key of the character to add auxiliary world books to |
| 6013 | * @param {string|string[]} nameOrNames - The name or names of the auxiliary world books to add |
| 6014 | */ |
| 6015 | export async function charUpdateAddAuxWorld(characterKey, nameOrNames) { |
| 6016 | const fileName = getCharaFilename(null, { manualAvatarKey: characterKey }); |
| 6017 | const toAdd = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames]; |
| 6018 | updateAuxBooks(fileName, curr => [...curr, ...toAdd]); |
| 6019 | } |
| 6020 | |
| 6021 | /** |
| 6022 | * Replaces the entire list of auxiliary world books for a character. |
| 6023 | * @param {string} fileName - The filename of the character to update |
| 6024 | * @param {string[]} books - The new list of auxiliary world books to replace the existing list with |
| 6025 | */ |
| 6026 | export function charSetAuxWorlds(fileName, books) { |
| 6027 | updateAuxBooks(fileName, _ => Array.isArray(books) ? books : []); |
| 6028 | } |
| 6029 | |
| 6030 | function updateAuxBooks(fileName, computeNext) { |
| 6031 | if (!fileName) return; |
| 6032 | |
| 6033 | if (menu_type === 'create') { |
| 6034 | const current = create_save.extra_books ?? []; |
| 6035 | create_save.extra_books = normalizeArray(computeNext(current)); |
| 6036 | return; // no debounced save in create flow |
| 6037 | } |
| 6038 | |
| 6039 | const charLore = world_info.charLore ?? []; |
| 6040 | const idx = charLore.findIndex(e => e.name === fileName); |
| 6041 | const current = idx !== -1 ? (charLore[idx].extraBooks ?? []) : []; |
| 6042 | const next = normalizeArray(computeNext(current)); |
| 6043 | |
| 6044 | if (next.length === 0) { |
| 6045 | if (idx !== -1) charLore.splice(idx, 1); |
| 6046 | } else if (idx === -1) { |
| 6047 | charLore.push({ name: fileName, extraBooks: next }); |
| 6048 | } else { |
| 6049 | charLore[idx] = { ...charLore[idx], extraBooks: next }; |
| 6050 | } |
| 6051 | |
| 6052 | Object.assign(world_info, { charLore }); |
| 6053 | saveSettingsDebounced(); |
| 6054 | } |
| 6055 | |
| 6056 | export function initWorldInfo() { |
| 6057 | $('#world_info').on('mousedown change', async function (e) { |
| 6058 | // If there's no world names, don't do anything |
| 6059 | if (world_names.length === 0) { |
| 6060 | e.preventDefault(); |
| 6061 | return; |
| 6062 | } |
| 6063 | |
| 6064 | onWorldInfoChange('__notSlashCommand__'); |
| 6065 | }); |
| 6066 | |
| 6067 | //**************************WORLD INFO IMPORT EXPORT*************************// |
| 6068 | $('#world_import_button').on('click', function () { |
| 6069 | $('#world_import_file').trigger('click'); |
| 6070 | }); |
| 6071 | |
| 6072 | $('#world_import_file').on('change', async function (e) { |
| 6073 | if (!(e.target instanceof HTMLInputElement)) { |
| 6074 | return; |
| 6075 | } |
| 6076 | |
| 6077 | const file = e.target.files[0]; |
| 6078 | |
| 6079 | await importWorldInfo(file); |
| 6080 | |
| 6081 | // Will allow to select the same file twice in a row |
| 6082 | e.target.value = ''; |
| 6083 | }); |
| 6084 | |
| 6085 | $('#world_create_button').on('click', async () => { |
| 6086 | const tempName = getFreeWorldName(); |
| 6087 | const finalName = await Popup.show.input(t`Create a new World Info`, t`Enter a name for the new file:`, tempName); |
| 6088 | |
| 6089 | if (finalName) { |
| 6090 | await createNewWorldInfo(finalName, { interactive: true }); |
| 6091 | } |
| 6092 | }); |
| 6093 | |
| 6094 | $('#world_editor_select').on('change', async () => { |
| 6095 | $('#world_info_search').val(''); |
| 6096 | worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, '', true); |
| 6097 | const selectedIndex = String($('#world_editor_select').find(':selected').val()); |
| 6098 | |
| 6099 | if (selectedIndex === '') { |
| 6100 | await hideWorldEditor(); |
| 6101 | } else { |
| 6102 | const worldName = world_names[selectedIndex]; |
| 6103 | showWorldEditor(worldName); |
| 6104 | } |
| 6105 | }); |
| 6106 | |
| 6107 | const saveSettings = () => { |
| 6108 | saveSettingsDebounced(); |
| 6109 | eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED); |
| 6110 | }; |
| 6111 | |
| 6112 | $('#world_info_depth').on('input', function () { |
| 6113 | world_info_depth = Number($(this).val()); |
| 6114 | $('#world_info_depth_counter').val($(this).val()); |
| 6115 | saveSettings(); |
| 6116 | }); |
| 6117 | |
| 6118 | $('#world_info_min_activations').on('input', function () { |
| 6119 | world_info_min_activations = Number($(this).val()); |
| 6120 | $('#world_info_min_activations_counter').val(world_info_min_activations); |
| 6121 | |
| 6122 | if (world_info_min_activations !== 0 && world_info_max_recursion_steps !== 0) { |
| 6123 | $('#world_info_max_recursion_steps').val(0).trigger('input'); |
| 6124 | flashHighlight($('#world_info_max_recursion_steps').parent()); // flash the other control to show it has changed |
| 6125 | console.info('[WI] Max recursion steps set to 0, as min activations is set to', world_info_min_activations); |
| 6126 | } else { |
| 6127 | saveSettings(); |
| 6128 | } |
| 6129 | }); |
| 6130 | |
| 6131 | $('#world_info_min_activations_depth_max').on('input', function () { |
| 6132 | world_info_min_activations_depth_max = Number($(this).val()); |
| 6133 | $('#world_info_min_activations_depth_max_counter').val($(this).val()); |
| 6134 | saveSettings(); |
| 6135 | }); |
| 6136 | |
| 6137 | $('#world_info_budget').on('input', function () { |
| 6138 | world_info_budget = Number($(this).val()); |
| 6139 | $('#world_info_budget_counter').val($(this).val()); |
| 6140 | saveSettings(); |
| 6141 | }); |
| 6142 | |
| 6143 | $('#world_info_include_names').on('input', function () { |
| 6144 | world_info_include_names = !!$(this).prop('checked'); |
| 6145 | saveSettings(); |
| 6146 | }); |
| 6147 | |
| 6148 | $('#world_info_recursive').on('input', function () { |
| 6149 | world_info_recursive = !!$(this).prop('checked'); |
| 6150 | saveSettings(); |
| 6151 | }); |
| 6152 | |
| 6153 | $('#world_info_case_sensitive').on('input', function () { |
| 6154 | world_info_case_sensitive = !!$(this).prop('checked'); |
| 6155 | saveSettings(); |
| 6156 | }); |
| 6157 | |
| 6158 | $('#world_info_match_whole_words').on('input', function () { |
| 6159 | world_info_match_whole_words = !!$(this).prop('checked'); |
| 6160 | saveSettings(); |
| 6161 | }); |
| 6162 | |
| 6163 | $('#world_info_character_strategy').on('change', function () { |
| 6164 | world_info_character_strategy = Number($(this).val()); |
| 6165 | saveSettings(); |
| 6166 | }); |
| 6167 | |
| 6168 | $('#world_info_overflow_alert').on('change', function () { |
| 6169 | world_info_overflow_alert = !!$(this).prop('checked'); |
| 6170 | saveSettingsDebounced(); |
| 6171 | }); |
| 6172 | |
| 6173 | $('#world_info_use_group_scoring').on('change', function () { |
| 6174 | world_info_use_group_scoring = !!$(this).prop('checked'); |
| 6175 | saveSettingsDebounced(); |
| 6176 | }); |
| 6177 | |
| 6178 | $('#world_info_budget_cap').on('input', function () { |
| 6179 | world_info_budget_cap = Number($(this).val()); |
| 6180 | $('#world_info_budget_cap_counter').val(world_info_budget_cap); |
| 6181 | saveSettings(); |
| 6182 | }); |
| 6183 | |
| 6184 | $('#world_info_max_recursion_steps').on('input', function () { |
| 6185 | world_info_max_recursion_steps = Number($(this).val()); |
| 6186 | $('#world_info_max_recursion_steps_counter').val(world_info_max_recursion_steps); |
| 6187 | if (world_info_max_recursion_steps !== 0 && world_info_min_activations !== 0) { |
| 6188 | $('#world_info_min_activations').val(0).trigger('input'); |
| 6189 | flashHighlight($('#world_info_min_activations').parent()); // flash the other control to show it has changed |
| 6190 | console.info('[WI] Min activations set to 0, as max recursion steps is set to', world_info_max_recursion_steps); |
| 6191 | } else { |
| 6192 | saveSettings(); |
| 6193 | } |
| 6194 | }); |
| 6195 | |
| 6196 | $('#world_button').on('click', async function (event) { |
| 6197 | const openSetWorldMenu = () => $('#char-management-dropdown').val($('#set_character_world').val()).trigger('change'); |
| 6198 | const chid = $('#set_character_world').data('chid'); |
| 6199 | |
| 6200 | if (chid === -1) { |
| 6201 | openSetWorldMenu(); |
| 6202 | return; |
| 6203 | } |
| 6204 | |
| 6205 | const worldName = characters[chid]?.data?.extensions?.world; |
| 6206 | const hasEmbed = checkEmbeddedWorld(chid); |
| 6207 | if (worldName && world_names.includes(worldName) && !event.shiftKey && !event.altKey) { |
| 6208 | openWorldInfoEditor(worldName); |
| 6209 | } else if (hasEmbed && !event.shiftKey && !event.altKey) { |
| 6210 | await importEmbeddedWorldInfo(); |
| 6211 | saveCharacterDebounced(); |
| 6212 | } else { |
| 6213 | openSetWorldMenu(); |
| 6214 | } |
| 6215 | }); |
| 6216 | addLongPressEvent('#world_button', function () { |
| 6217 | $(this).trigger($.Event('click', { shiftKey: true })); |
| 6218 | }); |
| 6219 | |
| 6220 | const debouncedWorldInfoSearch = debounce((searchQuery) => { |
| 6221 | worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, searchQuery); |
| 6222 | }); |
| 6223 | $('#world_info_search').on('input', function () { |
| 6224 | const searchQuery = $(this).val(); |
| 6225 | debouncedWorldInfoSearch(searchQuery); |
| 6226 | }); |
| 6227 | |
| 6228 | $('#world_refresh').on('click', () => { |
| 6229 | updateEditor(navigation_option.previous); |
| 6230 | }); |
| 6231 | |
| 6232 | $('#world_info_sort_order').on('change', function () { |
| 6233 | const value = String($(this).find(':selected').val()); |
| 6234 | // Save sort order, but do not save search sorting, as this is a temporary sorting option |
| 6235 | if (value !== 'search') accountStorage.setItem(SORT_ORDER_KEY, value); |
| 6236 | updateEditor(navigation_option.none); |
| 6237 | }); |
| 6238 | |
| 6239 | $(document).on('click', '.chat_lorebook_button', assignLorebookToChat); |
| 6240 | addLongPressEvent('.chat_lorebook_button', function () { |
| 6241 | assignLorebookToChat({ shiftKey: true, altKey: false }); |
| 6242 | }); |
| 6243 | |
| 6244 | $('#group-chat-lorebook-dropdown').on('change', async function () { |
| 6245 | $(this).prop('selectedIndex', 0); |
| 6246 | await assignLorebookToChat({ shiftKey: true, altKey: false }); |
| 6247 | }); |
| 6248 | |
| 6249 | // Not needed on mobile |
| 6250 | if (!isMobile()) { |
| 6251 | $('#world_editor_select').select2({ |
| 6252 | placeholder: t`--- Pick to Edit ---`, |
| 6253 | searchInputPlaceholder: t`Search...`, |
| 6254 | allowClear: true, |
| 6255 | closeOnSelect: true, |
| 6256 | multiple: false, |
| 6257 | }); |
| 6258 | |
| 6259 | $('#world_info').select2({ |
| 6260 | width: '100%', |
| 6261 | placeholder: t`No Worlds active. Click here to select.`, |
| 6262 | allowClear: true, |
| 6263 | closeOnSelect: false, |
| 6264 | }); |
| 6265 | |
| 6266 | // Subscribe world loading to the select2 multiselect items (We need to target the specific select2 control) |
| 6267 | select2ChoiceClickSubscribe($('#world_info'), target => { |
| 6268 | const name = $(target).text(); |
| 6269 | const selectedIndex = world_names.indexOf(name); |
| 6270 | const alreadySelectedInEditor = $('#world_editor_select option:selected').text() === name; |
| 6271 | if (selectedIndex !== -1 && !alreadySelectedInEditor) { |
| 6272 | $('#world_editor_select').val(selectedIndex).trigger('change'); |
| 6273 | console.log('Quick selection of world', name); |
| 6274 | } else { |
| 6275 | console.warn('lets not reload an already loaded list yes?'); |
| 6276 | } |
| 6277 | }, { buttonStyle: true, closeDrawer: true }); |
| 6278 | } |
| 6279 | |
| 6280 | $('#WorldInfo').on('scroll', () => { |
| 6281 | $('.world_entry input[name="group"], .world_entry input[name="automationId"]').each((_, el) => { |
| 6282 | const instance = $(el).autocomplete('instance'); |
| 6283 | |
| 6284 | if (instance !== undefined) { |
| 6285 | $(el).autocomplete('close'); |
| 6286 | } |
| 6287 | }); |
| 6288 | }); |
| 6289 | } |