Blame Raw
Cohee · 51ad27fb · · 6289 lines (253.5 KB)
3 contributors
1import { Fuse } from '../lib.js';
2
3import { 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';
4import { 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';
5import { extension_settings, getContext } from './extensions.js';
6import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
7import { isMobile } from './RossAscends-mods.js';
8import { FILTER_TYPES, FilterHelper } from './filters.js';
9import { getTokenCountAsync } from './tokenizers.js';
10import { power_user } from './power-user.js';
11import { getTagKeyForEntity } from './tags.js';
12import { debounce_timeout, GENERATION_TYPE_TRIGGERS } from './constants.js';
13import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
14import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
15import { SlashCommand } from './slash-commands/SlashCommand.js';
16import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
17import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
18import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
19import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
20import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
21import { StructuredCloneMap } from './util/StructuredCloneMap.js';
22import { renderTemplateAsync } from './templates.js';
23import { t } from './i18n.js';
24import { accountStorage } from './util/AccountStorage.js';
25import { getOrCreatePersonaDescriptor, setPersonaDescription, user_avatar } from './personas.js';
26
27export const world_info_insertion_strategy = {
28 evenly: 0,
29 character_first: 1,
30 global_first: 2,
31};
32
33export 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 */
43export 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
62const WI_ENTRY_HEADER_TEMPLATE = $('#entry_edit_template .world_entry');
63const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry_edit');
64
65export let world_info = {};
66export let selected_world_info = [];
67/** @type {string[]} */
68export let world_names;
69export let world_info_depth = 2;
70export let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated
71export let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0)
72
73export let world_info_budget = 25;
74export let world_info_include_names = true;
75export let world_info_recursive = false;
76export let world_info_overflow_alert = false;
77export let world_info_case_sensitive = false;
78export let world_info_match_whole_words = false;
79export let world_info_use_group_scoring = false;
80export let world_info_character_strategy = world_info_insertion_strategy.character_first;
81export let world_info_budget_cap = 0;
82export let world_info_max_recursion_steps = 0;
83const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), debounce_timeout.relaxed);
84const saveSettingsDebounced = debounce(() => {
85 Object.assign(world_info, { globalSelect: selected_world_info });
86 saveSettings();
87}, debounce_timeout.relaxed);
88const sortFn = (a, b) => b.order - a.order;
89let 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.
92export const worldInfoFilter = new FilterHelper(() => updateEditor());
93export const SORT_ORDER_KEY = 'world_info_sort_order';
94export const METADATA_KEY = 'world_info';
95
96export const DEFAULT_DEPTH = 4;
97export const DEFAULT_WEIGHT = 100;
98export const MAX_SCAN_DEPTH = 1000;
99const MAX_COMMENT_LENGTH = 100;
100const 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>} */
186const 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 */
199class 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 */
479class 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
795export 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 */
819export 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
855export 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
866export 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 * */
882export 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 */
892export 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
917export 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 */
1040export 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
1049function 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 */
2018export 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 */
2036export 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
2061export 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
2086async function hideWorldEditor() {
2087 await displayWorldEntries(null, null);
2088}
2089
2090function 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 */
2104function 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 */
2146export 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
2212function 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 */
2217const 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 */
2226function 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
2247function 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
2310async 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
2607export 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 */
2647function 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 */
2676export 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 */
2694export 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 */
2717export 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 */
2745function 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 */
2808function 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 */
2821export 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 */
2858function 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 */
2972function 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 */
2993function 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 */
3010function 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 */
3027function 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 */
3056function 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 */
3093function 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 */
3121function 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 */
3151function 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 */
3175function 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 */
3204function 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 */
3247function 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 */
3270function 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 */
3282export 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 */
3804function 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 */
3848const 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 */
3855function 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
3874function 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
3885function 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 */
3899function 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 */
3939export 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 */
3963export 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 */
4002export 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
4047export 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 */
4057export 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
4071async 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 */
4097export 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
4112async 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 */
4150async 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 */
4234export 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
4283export 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 */
4310export 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 */
4336export 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
4363async 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
4415async 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
4432async 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
4452async 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
4478export 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*/
4540function 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
4597export 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 */
5173function 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 */
5218function 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 */
5269function 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
5358function 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
5403function 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
5448function 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
5498export 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
5557export 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
5572export 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
5612export 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
5653export 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 */
5731export 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 */
5818export 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 */
5832export 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 */
5881export 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 */
5978export 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 */
6015export 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 */
6026export function charSetAuxWorlds(fileName, books) {
6027 updateAuxBooks(fileName, _ => Array.isArray(books) ? books : []);
6028}
6029
6030function 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
6056export 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}