SillyTavern/public/scripts/world-info.js

3846 lines
144 KiB
JavaScript

import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, equalsIgnoreCaseAndAccents, getSanitizedFilename, checkOverwriteExistingData } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { isMobile } from './RossAscends-mods.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { getTokenCountAsync } from './tokenizers.js';
import { power_user } from './power-user.js';
import { getTagKeyForEntity } from './tags.js';
import { resolveVariable } from './variables.js';
import { debounce_timeout } from './constants.js';
import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
export {
world_info,
world_info_budget,
world_info_depth,
world_info_min_activations,
world_info_min_activations_depth_max,
world_info_recursive,
world_info_overflow_alert,
world_info_case_sensitive,
world_info_match_whole_words,
world_info_character_strategy,
world_info_budget_cap,
world_names,
checkWorldInfo,
deleteWorldInfo,
setWorldInfoSettings,
getWorldInfoPrompt,
};
const world_info_insertion_strategy = {
evenly: 0,
character_first: 1,
global_first: 2,
};
const world_info_logic = {
AND_ANY: 0,
NOT_ALL: 1,
NOT_ANY: 2,
AND_ALL: 3,
};
const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry');
let world_info = {};
let selected_world_info = [];
/** @type {string[]} */
let world_names;
let world_info_depth = 2;
let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated
let world_info_min_activations_depth_max = 0; // used when (world_info_min_activations > 0)
let world_info_budget = 25;
let world_info_recursive = false;
let world_info_overflow_alert = false;
let world_info_case_sensitive = false;
let world_info_match_whole_words = false;
let world_info_use_group_scoring = false;
let world_info_character_strategy = world_info_insertion_strategy.character_first;
let world_info_budget_cap = 0;
const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), debounce_timeout.relaxed);
const saveSettingsDebounced = debounce(() => {
Object.assign(world_info, { globalSelect: selected_world_info });
saveSettings();
}, debounce_timeout.relaxed);
const sortFn = (a, b) => b.order - a.order;
let updateEditor = (navigation, flashOnNav = true) => { console.debug('Triggered WI navigation', navigation, flashOnNav); };
// Do not optimize. updateEditor is a function that is updated by the displayWorldEntries with new data.
const worldInfoFilter = new FilterHelper(() => updateEditor());
const SORT_ORDER_KEY = 'world_info_sort_order';
const METADATA_KEY = 'world_info';
const DEFAULT_DEPTH = 4;
const DEFAULT_WEIGHT = 100;
const MAX_SCAN_DEPTH = 1000;
/**
* Represents a scanning buffer for one evaluation of World Info.
*/
class WorldInfoBuffer {
// Typedef area
/**
* @typedef {object} WIScanEntry The entry that triggered the scan
* @property {number} [scanDepth] The depth of the scan
* @property {boolean} [caseSensitive] If the scan is case sensitive
* @property {boolean} [matchWholeWords] If the scan should match whole words
* @property {boolean} [useGroupScoring] If the scan should use group scoring
* @property {number} [uid] The UID of the entry that triggered the scan
* @property {string[]} [key] The primary keys to scan for
* @property {string[]} [keysecondary] The secondary keys to scan for
* @property {number} [selectiveLogic] The logic to use for selective activation
*/
// End typedef area
/**
* @type {object[]} Array of entries that need to be activated no matter what
*/
static externalActivations = [];
/**
* @type {string[]} Array of messages sorted by ascending depth
*/
#depthBuffer = [];
/**
* @type {string[]} Array of strings added by recursive scanning
*/
#recurseBuffer = [];
/**
* @type {number} The skew of the global scan depth. Used in "min activations"
*/
#skew = 0;
/**
* @type {number} The starting depth of the global scan depth. Incremented by "min activations" feature to not repeat scans. When > 0 it means a complete scan was done up to #startDepth already, and `advanceScanPosition` was called.
*/
#startDepth = 0;
/**
* Initialize the buffer with the given messages.
* @param {string[]} messages Array of messages to add to the buffer
*/
constructor(messages) {
this.#initDepthBuffer(messages);
}
/**
* Populates the buffer with the given messages.
* @param {string[]} messages Array of messages to add to the buffer
* @returns {void} Hardly seen nothing down here
*/
#initDepthBuffer(messages) {
for (let depth = 0; depth < MAX_SCAN_DEPTH; depth++) {
if (messages[depth]) {
this.#depthBuffer[depth] = messages[depth].trim();
}
// break if last message is reached
if (depth === messages.length - 1) {
break;
}
}
}
/**
* Gets a string that respects the case sensitivity setting
* @param {string} str The string to transform
* @param {WIScanEntry} entry The entry that triggered the scan
* @returns {string} The transformed string
*/
#transformString(str, entry) {
const caseSensitive = entry.caseSensitive ?? world_info_case_sensitive;
return caseSensitive ? str : str.toLowerCase();
}
/**
* Gets all messages up to the given depth + recursion buffer.
* @param {WIScanEntry} entry The entry that triggered the scan
* @returns {string} A slice of buffer until the given depth (inclusive)
*/
get(entry) {
let depth = entry.scanDepth ?? this.getDepth();
if (depth <= this.#startDepth) {
return '';
}
if (depth < 0) {
console.error(`Invalid WI scan depth ${depth}. Must be >= 0`);
return '';
}
if (depth > MAX_SCAN_DEPTH) {
console.warn(`Invalid WI scan depth ${depth}. Truncating to ${MAX_SCAN_DEPTH}`);
depth = MAX_SCAN_DEPTH;
}
let result = this.#depthBuffer.slice(this.#startDepth, depth).join('\n');
if (this.#recurseBuffer.length > 0) {
result += '\n' + this.#recurseBuffer.join('\n');
}
return result;
}
/**
* Matches the given string against the buffer.
* @param {string} haystack The string to search in
* @param {string} needle The string to search for
* @param {WIScanEntry} entry The entry that triggered the scan
* @returns {boolean} True if the string was found in the buffer
*/
matchKeys(haystack, needle, entry) {
// If the needle is a regex, we do regex pattern matching and override all the other options
const keyRegex = parseRegexFromString(needle);
if (keyRegex) {
return keyRegex.test(haystack);
}
// Otherwise we do normal matching of plaintext with the chosen entry settings
haystack = this.#transformString(haystack, entry);
const transformedString = this.#transformString(needle, entry);
const matchWholeWords = entry.matchWholeWords ?? world_info_match_whole_words;
if (matchWholeWords) {
const keyWords = transformedString.split(/\s+/);
if (keyWords.length > 1) {
return haystack.includes(transformedString);
}
else {
// Use custom boundaries to include punctuation and other non-alphanumeric characters
const regex = new RegExp(`(?:^|\\W)(${escapeRegex(transformedString)})(?:$|\\W)`);
if (regex.test(haystack)) {
return true;
}
}
} else {
return haystack.includes(transformedString);
}
return false;
}
/**
* Adds a message to the recursion buffer.
* @param {string} message The message to add
*/
addRecurse(message) {
this.#recurseBuffer.push(message);
}
/**
* Increments skew and sets startDepth to previous depth.
*/
advanceScanPosition() {
this.#startDepth = this.getDepth();
this.#skew++;
}
/**
* @returns {number} Settings' depth + current skew.
*/
getDepth() {
return world_info_depth + this.#skew;
}
/**
* Check if the current entry is externally activated.
* @param {object} entry WI entry to check
* @returns {boolean} True if the entry is forcefully activated
*/
isExternallyActivated(entry) {
// Entries could be copied with structuredClone, so we need to compare them by string representation
return WorldInfoBuffer.externalActivations.some(x => JSON.stringify(x) === JSON.stringify(entry));
}
/**
* Clears the force activations buffer.
*/
cleanExternalActivations() {
WorldInfoBuffer.externalActivations.splice(0, WorldInfoBuffer.externalActivations.length);
}
/**
* Gets the match score for the given entry.
* @param {WIScanEntry} entry Entry to check
* @returns {number} The number of key activations for the given entry
*/
getScore(entry) {
const bufferState = this.get(entry);
let numberOfPrimaryKeys = 0;
let numberOfSecondaryKeys = 0;
let primaryScore = 0;
let secondaryScore = 0;
// Increment score for every key found in the buffer
if (Array.isArray(entry.key)) {
numberOfPrimaryKeys = entry.key.length;
for (const key of entry.key) {
if (this.matchKeys(bufferState, key, entry)) {
primaryScore++;
}
}
}
// Increment score for every secondary key found in the buffer
if (Array.isArray(entry.keysecondary)) {
numberOfSecondaryKeys = entry.keysecondary.length;
for (const key of entry.keysecondary) {
if (this.matchKeys(bufferState, key, entry)) {
secondaryScore++;
}
}
}
// No keys == no score
if (!numberOfPrimaryKeys) {
return 0;
}
// Only positive logic influences the score
if (numberOfSecondaryKeys > 0) {
switch (entry.selectiveLogic) {
// AND_ANY: Add both scores
case world_info_logic.AND_ANY:
return primaryScore + secondaryScore;
// AND_ALL: Add both scores if all secondary keys are found, otherwise only primary score
case world_info_logic.AND_ALL:
return secondaryScore === numberOfSecondaryKeys ? primaryScore + secondaryScore : primaryScore;
}
}
return primaryScore;
}
}
export function getWorldInfoSettings() {
return {
world_info,
world_info_depth,
world_info_min_activations,
world_info_min_activations_depth_max,
world_info_budget,
world_info_recursive,
world_info_overflow_alert,
world_info_case_sensitive,
world_info_match_whole_words,
world_info_character_strategy,
world_info_budget_cap,
world_info_use_group_scoring,
};
}
const world_info_position = {
before: 0,
after: 1,
ANTop: 2,
ANBottom: 3,
atDepth: 4,
EMTop: 5,
EMBottom: 6,
};
export const wi_anchor_position = {
before: 0,
after: 1,
};
const worldInfoCache = {};
/**
* Gets the world info based on chat messages.
* @param {string[]} chat The chat messages to scan.
* @param {number} maxContext The maximum context size of the generation.
* @param {boolean} isDryRun If true, the function will not emit any events.
* @typedef {{worldInfoString: string, worldInfoBefore: string, worldInfoAfter: string, worldInfoExamples: any[], worldInfoDepth: any[]}} WIPromptResult
* @returns {Promise<WIPromptResult>} The world info string and depth.
*/
async function getWorldInfoPrompt(chat, maxContext, isDryRun) {
let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = '';
const activatedWorldInfo = await checkWorldInfo(chat, maxContext);
worldInfoBefore = activatedWorldInfo.worldInfoBefore;
worldInfoAfter = activatedWorldInfo.worldInfoAfter;
worldInfoString = worldInfoBefore + worldInfoAfter;
if (!isDryRun && activatedWorldInfo.allActivatedEntries && activatedWorldInfo.allActivatedEntries.size > 0) {
const arg = Array.from(activatedWorldInfo.allActivatedEntries);
await eventSource.emit(event_types.WORLD_INFO_ACTIVATED, arg);
}
return {
worldInfoString,
worldInfoBefore,
worldInfoAfter,
worldInfoExamples: activatedWorldInfo.EMEntries ?? [],
worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],
};
}
function setWorldInfoSettings(settings, data) {
if (settings.world_info_depth !== undefined)
world_info_depth = Number(settings.world_info_depth);
if (settings.world_info_min_activations !== undefined)
world_info_min_activations = Number(settings.world_info_min_activations);
if (settings.world_info_min_activations_depth_max !== undefined)
world_info_min_activations_depth_max = Number(settings.world_info_min_activations_depth_max);
if (settings.world_info_budget !== undefined)
world_info_budget = Number(settings.world_info_budget);
if (settings.world_info_recursive !== undefined)
world_info_recursive = Boolean(settings.world_info_recursive);
if (settings.world_info_overflow_alert !== undefined)
world_info_overflow_alert = Boolean(settings.world_info_overflow_alert);
if (settings.world_info_case_sensitive !== undefined)
world_info_case_sensitive = Boolean(settings.world_info_case_sensitive);
if (settings.world_info_match_whole_words !== undefined)
world_info_match_whole_words = Boolean(settings.world_info_match_whole_words);
if (settings.world_info_character_strategy !== undefined)
world_info_character_strategy = Number(settings.world_info_character_strategy);
if (settings.world_info_budget_cap !== undefined)
world_info_budget_cap = Number(settings.world_info_budget_cap);
if (settings.world_info_use_group_scoring !== undefined)
world_info_use_group_scoring = Boolean(settings.world_info_use_group_scoring);
// Migrate old settings
if (world_info_budget > 100) {
world_info_budget = 25;
}
if (world_info_use_group_scoring === undefined) {
world_info_use_group_scoring = false;
}
// Reset selected world from old string and delete old keys
// TODO: Remove next release
const existingWorldInfo = settings.world_info;
if (typeof existingWorldInfo === 'string') {
delete settings.world_info;
selected_world_info = [existingWorldInfo];
} else if (Array.isArray(existingWorldInfo)) {
delete settings.world_info;
selected_world_info = existingWorldInfo;
}
world_info = settings.world_info ?? {};
$('#world_info_depth_counter').val(world_info_depth);
$('#world_info_depth').val(world_info_depth);
$('#world_info_min_activations_counter').val(world_info_min_activations);
$('#world_info_min_activations').val(world_info_min_activations);
$('#world_info_min_activations_depth_max_counter').val(world_info_min_activations_depth_max);
$('#world_info_min_activations_depth_max').val(world_info_min_activations_depth_max);
$('#world_info_budget_counter').val(world_info_budget);
$('#world_info_budget').val(world_info_budget);
$('#world_info_recursive').prop('checked', world_info_recursive);
$('#world_info_overflow_alert').prop('checked', world_info_overflow_alert);
$('#world_info_case_sensitive').prop('checked', world_info_case_sensitive);
$('#world_info_match_whole_words').prop('checked', world_info_match_whole_words);
$('#world_info_use_group_scoring').prop('checked', world_info_use_group_scoring);
$(`#world_info_character_strategy option[value='${world_info_character_strategy}']`).prop('selected', true);
$('#world_info_character_strategy').val(world_info_character_strategy);
$('#world_info_budget_cap').val(world_info_budget_cap);
$('#world_info_budget_cap_counter').val(world_info_budget_cap);
world_names = data.world_names?.length ? data.world_names : [];
// Add to existing selected WI if it exists
selected_world_info = selected_world_info.concat(settings.world_info?.globalSelect?.filter((e) => world_names.includes(e)) ?? []);
if (world_names.length > 0) {
$('#world_info').empty();
}
world_names.forEach((item, i) => {
$('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
$('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
});
$('#world_info_sort_order').val(localStorage.getItem(SORT_ORDER_KEY) || '0');
$('#world_info').trigger('change');
$('#world_editor_select').trigger('change');
eventSource.on(event_types.CHAT_CHANGED, () => {
const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]);
$('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo);
});
eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => {
WorldInfoBuffer.externalActivations.push(...entries);
});
// Add slash commands
registerWorldInfoSlashCommands();
}
function registerWorldInfoSlashCommands() {
function reloadEditor(file) {
const selectedIndex = world_names.indexOf(file);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
}
}
async function getEntriesFromFile(file) {
if (!file || !world_names.includes(file)) {
toastr.warning('Valid World Info file name is required');
return '';
}
const data = await loadWorldInfoData(file);
if (!data || !('entries' in data)) {
toastr.warning('World Info file has an invalid format');
return '';
}
const entries = Object.values(data.entries);
if (!entries || entries.length === 0) {
toastr.warning('World Info file has no entries');
return '';
}
return entries;
}
async function getChatBookCallback() {
const chatId = getCurrentChatId();
if (!chatId) {
toastr.warning('Open a chat to get a name of the chat-bound lorebook');
return '';
}
if (chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY])) {
return chat_metadata[METADATA_KEY];
}
// Replace non-alphanumeric characters with underscores, cut to 64 characters
const name = `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64);
await createNewWorldInfo(name);
chat_metadata[METADATA_KEY] = name;
await saveMetadata();
$('.chat_lorebook_button').addClass('world_set');
return name;
}
async function findBookEntryCallback(args, value) {
const file = resolveVariable(args.file);
const field = args.field || 'key';
const entries = await getEntriesFromFile(file);
if (!entries) {
return '';
}
if (typeof newEntryTemplate[field] === 'boolean') {
const isTrue = isTrueBoolean(value);
const isFalse = isFalseBoolean(value);
if (isTrue) {
value = String(true);
}
if (isFalse) {
value = String(false);
}
}
const fuse = new Fuse(entries, {
keys: [{ name: field, weight: 1 }],
includeScore: true,
threshold: 0.3,
});
const results = fuse.search(value);
if (!results || results.length === 0) {
return '';
}
const result = results[0]?.item?.uid;
if (result === undefined) {
return '';
}
return result;
}
async function getEntryFieldCallback(args, uid) {
const file = resolveVariable(args.file);
const field = args.field || 'content';
const entries = await getEntriesFromFile(file);
if (!entries) {
return '';
}
const entry = entries.find(x => String(x.uid) === String(uid));
if (!entry) {
toastr.warning('Valid UID is required');
return '';
}
if (newEntryTemplate[field] === undefined) {
toastr.warning('Valid field name is required');
return '';
}
const fieldValue = entry[field];
if (fieldValue === undefined) {
return '';
}
if (Array.isArray(fieldValue)) {
return fieldValue.map(x => substituteParams(x)).join(', ');
}
return substituteParams(String(fieldValue));
}
async function createEntryCallback(args, content) {
const file = resolveVariable(args.file);
const key = args.key;
const data = await loadWorldInfoData(file);
if (!data || !('entries' in data)) {
toastr.warning('Valid World Info file name is required');
return '';
}
const entry = createWorldInfoEntry(file, data);
if (key) {
entry.key.push(key);
entry.addMemo = true;
entry.comment = key;
}
if (content) {
entry.content = content;
}
await saveWorldInfo(file, data, true);
reloadEditor(file);
return String(entry.uid);
}
async function setEntryFieldCallback(args, value) {
const file = resolveVariable(args.file);
const uid = resolveVariable(args.uid);
const field = args.field || 'content';
if (value === undefined) {
toastr.warning('Value is required');
return '';
}
value = value.replace(/\\([{}|])/g, '$1');
const data = await loadWorldInfoData(file);
if (!data || !('entries' in data)) {
toastr.warning('Valid World Info file name is required');
return '';
}
const entry = data.entries[uid];
if (!entry) {
toastr.warning('Valid UID is required');
return '';
}
if (newEntryTemplate[field] === undefined) {
toastr.warning('Valid field name is required');
return '';
}
if (Array.isArray(entry[field])) {
entry[field] = value.split(',').map(x => x.trim()).filter(x => x);
} else if (typeof entry[field] === 'boolean') {
entry[field] = isTrueBoolean(value);
} else if (typeof entry[field] === 'number') {
entry[field] = Number(value);
} else {
entry[field] = value;
}
if (originalDataKeyMap[field]) {
setOriginalDataValue(data, uid, originalDataKeyMap[field], entry[field]);
}
await saveWorldInfo(file, data, true);
reloadEditor(file);
return '';
}
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'world',
callback: onWorldInfoChange,
namedArgumentList: [
new SlashCommandNamedArgument(
'state', 'set world state', [ARGUMENT_TYPE.STRING], false, false, null, ['off', 'toggle'],
),
new SlashCommandNamedArgument(
'silent', 'suppress toast messages', [ARGUMENT_TYPE.BOOLEAN], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'name', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Sets active World, or unsets if no args provided, use <code>state=off</code> and <code>state=toggle</code> to deactivate or toggle a World, use <code>silent=true</code> to suppress toast messages.
</div>
`,
aliases: [],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getchatbook',
callback: getChatBookCallback,
returns: 'lorebook name',
helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.',
aliases: ['getchatlore', 'getchatwi'],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'findentry',
aliases: ['findlore', 'findwi'],
returns: 'UID',
callback: findBookEntryCallback,
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'bookName', ARGUMENT_TYPE.STRING, true,
),
new SlashCommandNamedArgument(
'field', 'field value for fuzzy match (default: key)', ARGUMENT_TYPE.STRING, false, false, 'key',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'texts', ARGUMENT_TYPE.STRING, true, true,
),
],
helpString: `
<div>
Find a UID of the record from the specified book using the fuzzy match of a field value (default: key) and pass it down the pipe.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/findentry file=chatLore field=key Shadowfang</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getentryfield',
aliases: ['getlorefield', 'getwifield'],
callback: getEntryFieldCallback,
returns: 'field value',
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'bookName', ARGUMENT_TYPE.STRING, true,
),
new SlashCommandNamedArgument(
'field', 'field to retrieve (default: content)', ARGUMENT_TYPE.STRING, false, false, 'content',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'UID', ARGUMENT_TYPE.STRING, true,
),
],
helpString: `
<div>
Get a field value (default: content) of the record with the UID from the specified book and pass it down the pipe.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/getentryfield file=chatLore field=content 123</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'createentry',
callback: createEntryCallback,
aliases: ['createlore', 'createwi'],
returns: 'UID of the new record',
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'book name', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'key', 'record key', [ARGUMENT_TYPE.STRING], false,
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'content', [ARGUMENT_TYPE.STRING], false,
),
],
helpString: `
<div>
Create a new record in the specified book with the key and content (both are optional) and pass the UID down the pipe.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/createentry file=chatLore key=Shadowfang The sword of the king</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'setentryfield',
callback: setEntryFieldCallback,
aliases: ['setlorefield', 'setwifield'],
namedArgumentList: [
new SlashCommandNamedArgument(
'file', 'book name', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'uid', 'record UID', [ARGUMENT_TYPE.STRING], true,
),
new SlashCommandNamedArgument(
'field', 'field name', [ARGUMENT_TYPE.STRING], true, false, 'content',
),
],
unnamedArgumentList: [
new SlashCommandArgument(
'value', [ARGUMENT_TYPE.STRING], true,
),
],
helpString: `
<div>
Set a field value (default: content) of the record with the UID from the specified book. To set multiple values for key fields, use comma-delimited list as a value.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/setentryfield file=chatLore uid=123 field=key Shadowfang,sword,weapon</code></pre>
</li>
</ul>
</div>
`,
}));
}
// World Info Editor
async function showWorldEditor(name) {
if (!name) {
hideWorldEditor();
return;
}
const wiData = await loadWorldInfoData(name);
displayWorldEntries(name, wiData);
}
async function loadWorldInfoData(name) {
if (!name) {
return;
}
if (worldInfoCache[name]) {
return worldInfoCache[name];
}
const response = await fetch('/api/worldinfo/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: name }),
cache: 'no-cache',
});
if (response.ok) {
const data = await response.json();
worldInfoCache[name] = data;
return data;
}
return null;
}
async function updateWorldInfoList() {
const result = await fetch('/api/settings/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({}),
});
if (result.ok) {
var data = await result.json();
world_names = data.world_names?.length ? data.world_names : [];
$('#world_info').find('option[value!=""]').remove();
$('#world_editor_select').find('option[value!=""]').remove();
world_names.forEach((item, i) => {
$('#world_info').append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
$('#world_editor_select').append(`<option value='${i}'>${item}</option>`);
});
}
}
function hideWorldEditor() {
displayWorldEntries(null, null);
}
function getWIElement(name) {
const wiElement = $('#world_info').children().filter(function () {
return $(this).text().toLowerCase() === name.toLowerCase();
});
return wiElement;
}
/**
* @param {any[]} data WI entries
* @returns {any[]} Sorted data
*/
function sortEntries(data) {
const option = $('#world_info_sort_order').find(':selected');
const sortField = option.data('field');
const sortOrder = option.data('order');
const sortRule = option.data('rule');
const orderSign = sortOrder === 'asc' ? 1 : -1;
if (!data.length) return data;
// If we have a search term for WI, we are sorting by weighting scores
if (sortRule === 'search') {
data.sort((a, b) => {
const aScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, a.uid);
const bScore = worldInfoFilter.getScore(FILTER_TYPES.WORLD_INFO_SEARCH, b.uid);
return (aScore - bScore);
});
}
else if (sortRule === 'custom') {
// First by display index, then by order, then by uid
data.sort((a, b) => {
const aValue = a.displayIndex;
const bValue = b.displayIndex;
return (aValue - bValue || b.order - a.order || a.uid - b.uid);
});
} else if (sortRule === 'priority') {
// First constant, then normal, then disabled. Then sort by order
data.sort((a, b) => {
const aValue = a.constant ? 0 : a.disable ? 2 : 1;
const bValue = b.constant ? 0 : b.disable ? 2 : 1;
return (aValue - bValue || b.order - a.order);
});
} else {
const primarySort = (a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
// Sort strings
if (typeof aValue === 'string' && typeof bValue === 'string') {
if (sortRule === 'length') {
// Sort by string length
return orderSign * (aValue.length - bValue.length);
} else {
// Sort by A-Z ordinal
return orderSign * aValue.localeCompare(bValue);
}
}
// Sort numbers
return orderSign * (Number(aValue) - Number(bValue));
};
const secondarySort = (a, b) => a.order - b.order;
const tertiarySort = (a, b) => a.uid - b.uid;
data.sort((a, b) => {
const primary = primarySort(a, b);
if (primary !== 0) {
return primary;
}
const secondary = secondarySort(a, b);
if (secondary !== 0) {
return secondary;
}
return tertiarySort(a, b);
});
}
return data;
}
function nullWorldInfo() {
toastr.info('Create or import a new World Info file first.', 'World Info is not set', { timeOut: 10000, preventDuplicates: true });
}
/** @type {Select2Option[]} Cache all keys as selectable dropdown option */
const worldEntryKeyOptionsCache = [];
/**
* Update the cache and all select options for the keys with new values to display
* @param {string[]|Select2Option[]} keyOptions - An array of options to update
* @param {object} options - Optional arguments
* @param {boolean?} [options.remove=false] - Whether the option was removed, so the count should be reduced - otherwise it'll be increased
* @param {boolean?} [options.reset=false] - Whether the cache should be reset. Reset will also not trigger update of the controls, as we expect them to be redrawn anyway
*/
function updateWorldEntryKeyOptionsCache(keyOptions, { remove = false, reset = false } = {}) {
if (!keyOptions.length) return;
/** @type {Select2Option[]} */
const options = keyOptions.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x);
if (reset) worldEntryKeyOptionsCache.length = 0;
options.forEach(option => {
// Update the cache list
let cachedEntry = worldEntryKeyOptionsCache.find(x => x.id == option.id);
if (cachedEntry) {
cachedEntry.count += !remove ? 1 : -1;
} else if (!remove) {
worldEntryKeyOptionsCache.push(option);
cachedEntry = option;
cachedEntry.count = 1;
}
});
// Sort by count DESC and then alphabetically
worldEntryKeyOptionsCache.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
}
function displayWorldEntries(name, data, navigation = navigation_option.none, flashOnNav = true) {
updateEditor = (navigation, flashOnNav = true) => displayWorldEntries(name, data, navigation, flashOnNav);
const worldEntriesList = $('#world_popup_entries_list');
// We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements
// This prevents jQuery from actually going through all registered events on the controls for each entry when removing it
worldEntriesList.find('*').off();
worldEntriesList.empty().show();
if (!data || !('entries' in data)) {
$('#world_popup_new').off('click').on('click', nullWorldInfo);
$('#world_popup_name_button').off('click').on('click', nullWorldInfo);
$('#world_popup_export').off('click').on('click', nullWorldInfo);
$('#world_popup_delete').off('click').on('click', nullWorldInfo);
$('#world_duplicate').off('click').on('click', nullWorldInfo);
worldEntriesList.hide();
$('#world_info_pagination').html('');
return;
}
// Regardless of whether success is displayed or not. Make sure the delete button is available.
// Do not put this code behind.
$('#world_popup_delete').off('click').on('click', async () => {
const confirmation = await callPopup(`<h3>Delete the World/Lorebook: "${name}"?</h3>This action is irreversible!`, 'confirm');
if (!confirmation) {
return;
}
if (world_info.charLore) {
world_info.charLore.forEach((charLore, index) => {
if (charLore.extraBooks?.includes(name)) {
const tempCharLore = charLore.extraBooks.filter((e) => e !== name);
if (tempCharLore.length === 0) {
world_info.charLore.splice(index, 1);
} else {
charLore.extraBooks = tempCharLore;
}
}
});
saveSettingsDebounced();
}
// Selected world_info automatically refreshes
await deleteWorldInfo(name);
});
// Before printing the WI, we check if we should enable/disable search sorting
verifyWorldInfoSearchSortRule();
function getDataArray(callback) {
// Convert the data.entries object into an array
let entriesArray = Object.keys(data.entries).map(uid => {
const entry = data.entries[uid];
entry.displayIndex = entry.displayIndex ?? entry.uid;
return entry;
});
// Apply the filter and do the chosen sorting
entriesArray = worldInfoFilter.applyFilters(entriesArray);
entriesArray = sortEntries(entriesArray);
// Cache keys
const keys = entriesArray.flatMap(entry => [...entry.key, ...entry.keysecondary]);
updateWorldEntryKeyOptionsCache(keys, { reset: true });
// Run the callback for printing this
typeof callback === 'function' && callback(entriesArray);
return entriesArray;
}
let startPage = 1;
if (navigation === navigation_option.previous) {
startPage = $('#world_info_pagination').pagination('getCurrentPageNum');
}
const storageKey = 'WI_PerPage';
const perPageDefault = 25;
$('#world_info_pagination').pagination({
dataSource: getDataArray,
pageSize: Number(localStorage.getItem(storageKey)) || perPageDefault,
sizeChangerOptions: [10, 25, 50, 100, 500, 1000],
showSizeChanger: true,
pageRange: 1,
pageNumber: startPage,
position: 'top',
showPageNumbers: false,
prevText: '<',
nextText: '>',
formatNavigator: PAGINATION_TEMPLATE,
showNavigator: true,
callback: function (/** @type {object[]} */ page) {
// We save costly performance by removing all events before emptying. Because we know there are no relevant event handlers reacting on removing elements
// This prevents jQuery from actually going through all registered events on the controls for each entry when removing it
worldEntriesList.find('*').off();
worldEntriesList.empty();
const keywordHeaders = `
<div id="WIEntryHeaderTitlesPC" class="flex-container wide100p spaceBetween justifyCenter textAlignCenter" style="padding:0 4.5em;">
<small class="flex1">
Title/Memo
</small>
<small style="width: calc(3.5em + 15px)">
Status
</small>
<small style="width: calc(3.5em + 30px)">
Position
</small>
<small style="width: calc(3.5em + 20px)">
Depth
</small>
<small style="width: calc(3.5em + 20px)">
Order
</small>
<small style="width: calc(3.5em + 15px)">
Trigger %
</small>
</div>`;
const blocks = page.map(entry => getWorldEntry(name, data, entry)).filter(x => x);
const isCustomOrder = $('#world_info_sort_order').find(':selected').data('rule') === 'custom';
if (!isCustomOrder) {
blocks.forEach(block => {
block.find('.drag-handle').remove();
});
}
worldEntriesList.append(keywordHeaders);
worldEntriesList.append(blocks);
},
afterSizeSelectorChange: function (e) {
localStorage.setItem(storageKey, e.target.value);
},
afterPaging: function () {
$('#world_popup_entries_list textarea[name="comment"]').each(function () {
initScrollHeight($(this));
});
},
});
if (typeof navigation === 'number' && Number(navigation) >= 0) {
const selector = `#world_popup_entries_list [uid="${navigation}"]`;
const data = getDataArray();
const uidIndex = data.findIndex(x => x.uid === navigation);
const perPage = Number(localStorage.getItem(storageKey)) || perPageDefault;
const page = Math.floor(uidIndex / perPage) + 1;
$('#world_info_pagination').pagination('go', page);
waitUntilCondition(() => document.querySelector(selector) !== null).finally(() => {
const element = $(selector);
if (element.length === 0) {
console.log(`Could not find element for uid ${navigation}`);
return;
}
const elementOffset = element.offset();
const parentOffset = element.parent().offset();
const scrollOffset = elementOffset.top - parentOffset.top;
$('#WorldInfo').scrollTop(scrollOffset);
if (flashOnNav) flashHighlight(element);
});
}
$('#world_popup_new').off('click').on('click', () => {
const entry = createWorldInfoEntry(name, data);
if (entry) updateEditor(entry.uid);
});
$('#world_popup_name_button').off('click').on('click', async () => {
await renameWorldInfo(name, data);
});
$('#world_backfill_memos').off('click').on('click', async () => {
let counter = 0;
for (const entry of Object.values(data.entries)) {
if (!entry.comment && Array.isArray(entry.key) && entry.key.length > 0) {
entry.comment = entry.key[0];
setOriginalDataValue(data, entry.uid, 'comment', entry.comment);
counter++;
}
}
if (counter > 0) {
toastr.info(`Backfilled ${counter} titles`);
await saveWorldInfo(name, data, true);
updateEditor(navigation_option.previous);
}
});
$('#world_popup_export').off('click').on('click', () => {
if (name && data) {
const jsonValue = JSON.stringify(data);
const fileName = `${name}.json`;
download(jsonValue, fileName, 'application/json');
}
});
$('#world_duplicate').off('click').on('click', async () => {
const tempName = getFreeWorldName();
const finalName = await callPopup('<h3>Create a new World Info?</h3>Enter a name for the new file:', 'input', tempName);
if (finalName) {
await saveWorldInfo(finalName, data, true);
await updateWorldInfoList();
const selectedIndex = world_names.indexOf(finalName);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
} else {
hideWorldEditor();
}
}
});
// Check if a sortable instance exists
if (worldEntriesList.sortable('instance') !== undefined) {
// Destroy the instance
worldEntriesList.sortable('destroy');
}
worldEntriesList.sortable({
items: '.world_entry',
delay: getSortableDelay(),
handle: '.drag-handle',
stop: async function (_event, _ui) {
const firstEntryUid = $('#world_popup_entries_list .world_entry').first().data('uid');
const minDisplayIndex = data?.entries[firstEntryUid]?.displayIndex ?? 0;
$('#world_popup_entries_list .world_entry').each(function (index) {
const uid = $(this).data('uid');
// Update the display index in the data array
const item = data.entries[uid];
if (!item) {
console.debug(`Could not find entry with uid ${uid}`);
return;
}
item.displayIndex = minDisplayIndex + index;
setOriginalDataValue(data, uid, 'extensions.display_index', item.displayIndex);
});
console.table(Object.keys(data.entries).map(uid => data.entries[uid]).map(x => ({ uid: x.uid, key: x.key.join(','), displayIndex: x.displayIndex })));
await saveWorldInfo(name, data, true);
},
});
//$("#world_popup_entries_list").disableSelection();
}
const originalDataKeyMap = {
'displayIndex': 'extensions.display_index',
'excludeRecursion': 'extensions.exclude_recursion',
'preventRecursion': 'extensions.prevent_recursion',
'delayUntilRecursion': 'extensions.delay_until_recursion',
'selectiveLogic': 'selectiveLogic',
'comment': 'comment',
'constant': 'constant',
'order': 'insertion_order',
'depth': 'extensions.depth',
'probability': 'extensions.probability',
'position': 'extensions.position',
'role': 'extensions.role',
'content': 'content',
'enabled': 'enabled',
'key': 'keys',
'keysecondary': 'secondary_keys',
'selective': 'selective',
'matchWholeWords': 'extensions.match_whole_words',
'useGroupScoring': 'extensions.use_group_scoring',
'caseSensitive': 'extensions.case_sensitive',
'scanDepth': 'extensions.scan_depth',
'automationId': 'extensions.automation_id',
'vectorized': 'extensions.vectorized',
'groupOverride': 'extensions.group_override',
'groupWeight': 'extensions.group_weight',
};
/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
function verifyWorldInfoSearchSortRule() {
const searchTerm = worldInfoFilter.getFilterData(FILTER_TYPES.WORLD_INFO_SEARCH);
const searchOption = $('#world_info_sort_order option[data-rule="search"]');
const selector = $('#world_info_sort_order');
const isHidden = searchOption.attr('hidden') !== undefined;
// If we have a search term, we are displaying the sorting option for it
if (searchTerm && isHidden) {
searchOption.removeAttr('hidden');
selector.val(searchOption.attr('value') || '0');
flashHighlight(selector);
}
// If search got cleared, we make sure to hide the option and go back to the one before
if (!searchTerm && !isHidden) {
searchOption.attr('hidden', '');
selector.val(localStorage.getItem(SORT_ORDER_KEY) || '0');
}
}
function setOriginalDataValue(data, uid, key, value) {
if (data.originalData && Array.isArray(data.originalData.entries)) {
let originalEntry = data.originalData.entries.find(x => x.uid === uid);
if (!originalEntry) {
return;
}
setValueByPath(originalEntry, key, value);
}
}
function deleteOriginalDataValue(data, uid) {
if (data.originalData && Array.isArray(data.originalData.entries)) {
const originalIndex = data.originalData.entries.findIndex(x => x.uid === uid);
if (originalIndex >= 0) {
data.originalData.entries.splice(originalIndex, 1);
}
}
}
/** @typedef {import('./utils.js').Select2Option} Select2Option */
/**
* Splits a given input string that contains one or more keywords or regexes, separated by commas.
*
* Each part can be a valid regex following the pattern `/myregex/flags` with optional flags. Commmas inside the regex are allowed, slashes have to be escaped like this: `\/`
* If a regex doesn't stand alone, it is not treated as a regex.
*
* @param {string} input - One or multiple keywords or regexes, separated by commas
* @returns {string[]} An array of keywords and regexes
*/
function splitKeywordsAndRegexes(input) {
/** @type {string[]} */
let keywordsAndRegexes = [];
// We can make this easy. Instead of writing another function to find and parse regexes,
// we gonna utilize the custom tokenizer that also handles the input.
// No need for validation here
const addFindCallback = (/** @type {Select2Option} */ item) => {
keywordsAndRegexes.push(item.text);
};
const { term } = customTokenizer({ _type: 'custom_call', term: input }, undefined, addFindCallback);
const finalTerm = term.trim();
if (finalTerm) {
addFindCallback({ id: getSelect2OptionId(finalTerm), text: finalTerm });
}
return keywordsAndRegexes;
}
/**
* Tokenizer parsing input and splitting it into keywords and regexes
*
* @param {{_type: string, term: string}} input - The typed input
* @param {{options: object}} _selection - The selection even object (?)
* @param {function(Select2Option):void} callback - The original callback function to call if an item should be inserted
* @returns {{term: string}} - The remaining part that is untokenized in the textbox
*/
function customTokenizer(input, _selection, callback) {
let current = input.term;
let insideRegex = false, regexClosed = false;
// Go over the input and check the current state, if we can get a token
for (let i = 0; i < current.length; i++) {
let char = current[i];
// If we find an unascaped slash, set the current regex state
if (char === '/' && (i === 0 || current[i - 1] !== '\\')) {
if (!insideRegex) insideRegex = true;
else if (!regexClosed) regexClosed = true;
}
// If a comma is typed, we tokenize the input.
// unless we are inside a possible regex, which would allow commas inside
if (char === ',') {
// We take everything up till now and consider this a token
const token = current.slice(0, i).trim();
// Now how we test if this is a regex? And not a finished one, but a half-finished one?
// We use the state remembered from above to check whether the delimiter was opened but not closed yet.
// We don't check validity here if we are inside a regex, because it might only get valid after its finished. (Closing brackets, etc)
// Validity will be finally checked when the next comma is typed.
if (insideRegex && !regexClosed) {
continue;
}
// So now the comma really means the token is done.
// We take the token up till now, and insert it. Empty will be skipped.
if (token) {
const isRegex = isValidRegex(token);
// Last chance to check for valid regex again. Because it might have been valid while typing, but now is not valid anymore and contains commas we need to split.
if (token.startsWith('/') && !isRegex) {
const tokens = token.split(',').map(x => x.trim());
tokens.forEach(x => callback({ id: getSelect2OptionId(x), text: x }));
} else {
callback({ id: getSelect2OptionId(token), text: token });
}
}
// Now remove the token from the current input, and the comma too
current = current.slice(i + 1);
insideRegex = false, regexClosed = false;
i = 0;
}
}
// At the end, just return the left-over input
return { term: current };
}
/**
* Validates if a string is a valid slash-delimited regex, that can be parsed and executed
*
* This is a wrapper around `parseRegexFromString`
*
* @param {string} input - A delimited regex string
* @returns {boolean} Whether this would be a valid regex that can be parsed and executed
*/
function isValidRegex(input) {
return parseRegexFromString(input) !== null;
}
/**
* Gets a real regex object from a slash-delimited regex string
*
* This function works with `/` as delimiter, and each occurance of it inside the regex has to be escaped.
* Flags are optional, but can only be valid flags supported by JavaScript's `RegExp` (`g`, `i`, `m`, `s`, `u`, `y`).
*
* @param {string} input - A delimited regex string
* @returns {RegExp|null} The regex object, or null if not a valid regex
*/
function parseRegexFromString(input) {
// Extracting the regex pattern and flags
let match = input.match(/^\/([\w\W]+?)\/([gimsuy]*)$/);
if (!match) {
return null; // Not a valid regex format
}
let [, pattern, flags] = match;
// If we find any unescaped slash delimiter, we also exit out.
// JS doesn't care about delimiters inside regex patterns, but for this to be a valid regex outside of our implementation,
// we have to make sure that our delimiter is correctly escaped. Or every other engine would fail.
if (pattern.match(/(^|[^\\])\//)) {
return null;
}
// Now we need to actually unescape the slash delimiters, because JS doesn't care about delimiters
pattern = pattern.replace('\\/', '/');
// Then we return the regex. If it fails, it was invalid syntax.
try {
return new RegExp(pattern, flags);
} catch (e) {
return null;
}
}
function getWorldEntry(name, data, entry) {
if (!data.entries[entry.uid]) {
return;
}
const template = WI_ENTRY_EDIT_TEMPLATE.clone();
template.data('uid', entry.uid);
template.attr('uid', entry.uid);
// Init default state of WI Key toggle (=> true)
if (typeof power_user.wi_key_input_plaintext === 'undefined') power_user.wi_key_input_plaintext = true;
/** Function to build the keys input controls @param {string} entryPropName @param {string} originalDataValueName */
function enableKeysInput(entryPropName, originalDataValueName) {
const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;
const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
input.data('uid', entry.uid);
input.on('click', function (event) {
// Prevent closing the drawer on clicking the input
event.stopPropagation();
});
function templateStyling(/** @type {Select2Option} */ item, { searchStyle = false } = {}) {
const content = $('<span>').addClass('item').text(item.text).attr('title', `${item.text}\n\nClick to edit`);
const isRegex = isValidRegex(item.text);
if (isRegex) {
content.html(highlightRegex(item.text));
content.addClass('regex_item').prepend($('<span>').addClass('regex_icon').text('•*').attr('title', 'Regex'));
}
if (searchStyle && item.count) {
// Build a wrapping element
const wrapper = $('<span>').addClass('result_block')
.append(content);
wrapper.append($('<span>').addClass('item_count').text(item.count).attr('title', `Used as a key ${item.count} ${item.count != 1 ? 'times' : 'time'} in this lorebook`));
return wrapper;
}
return content;
}
if (isFancyInput) {
input.select2({
ajax: dynamicSelect2DataViaAjax(() => worldEntryKeyOptionsCache),
tags: true,
tokenSeparators: [','],
tokenizer: customTokenizer,
placeholder: input.attr('placeholder'),
templateResult: item => templateStyling(item, { searchStyle: true }),
templateSelection: item => templateStyling(item),
});
input.on('change', function (_, { skipReset, noSave } = {}) {
const uid = $(this).data('uid');
/** @type {string[]} */
const keys = ($(this).select2('data')).map(x => x.text);
!skipReset && resetScrollHeight(this);
if (!noSave) {
data.entries[uid][entryPropName] = keys;
setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
saveWorldInfo(name, data);
}
});
input.on('select2:select', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data]));
input.on('select2:unselect', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data], { remove: true }));
select2ChoiceClickSubscribe(input, target => {
const key = $(target).text();
console.debug('Editing WI key', key);
// Remove the current key from the actual selection
const selected = input.val();
if (!Array.isArray(selected)) return;
var index = selected.indexOf(getSelect2OptionId(key));
if (index > -1) selected.splice(index, 1);
input.val(selected).trigger('change');
// Manually update the cache, that change event is not gonna trigger it
updateWorldEntryKeyOptionsCache([key], { remove: true });
// We need to "hack" the actual text input into the currently open textarea
input.next('span.select2-container').find('textarea')
.val(key).trigger('input');
}, { openDrawer: true });
select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } });
}
else {
// Compatibility with mobile devices. On mobile we need a text input field, not a select option control, so we need its own event handlers
template.find(`select[name="${entryPropName}"]`).hide();
input.show();
input.on('input', function (_, { skipReset, noSave } = {}) {
const uid = $(this).data('uid');
const value = String($(this).val());
!skipReset && resetScrollHeight(this);
if (!noSave) {
data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value);
setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
saveWorldInfo(name, data);
}
});
input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true });
}
return { isFancy: isFancyInput, control: input };
}
// key
const keyInput = enableKeysInput('key', 'keys');
// keysecondary
const keySecondaryInput = enableKeysInput('keysecondary', 'secondary_keys');
// draw key input switch button
template.find('.switch_input_type_icon').on('click', function () {
power_user.wi_key_input_plaintext = !power_user.wi_key_input_plaintext;
saveSettingsDebounced();
// Just redraw the panel
const uid = ($(this).parents('.world_entry')).data('uid');
updateEditor(uid, false);
$(`.world_entry[uid="${uid}"] .inline-drawer-icon`).trigger('click');
// setTimeout(() => {
// }, debounce_timeout.standard);
}).each((_, icon) => {
$(icon).attr('title', $(icon).data(power_user.wi_key_input_plaintext ? 'tooltip-on' : 'tooltip-off'));
$(icon).text($(icon).data(power_user.wi_key_input_plaintext ? 'icon-on' : 'icon-off'));
});
// logic AND/NOT
const selectiveLogicDropdown = template.find('select[name="entryLogicType"]');
selectiveLogicDropdown.data('uid', entry.uid);
selectiveLogicDropdown.on('click', function (event) {
event.stopPropagation();
});
selectiveLogicDropdown.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].selectiveLogic = !isNaN(value) ? value : world_info_logic.AND_ANY;
setOriginalDataValue(data, uid, 'selectiveLogic', data.entries[uid].selectiveLogic);
saveWorldInfo(name, data);
});
template
.find(`select[name="entryLogicType"] option[value=${entry.selectiveLogic}]`)
.prop('selected', true)
.trigger('input');
// Character filter
const characterFilterLabel = template.find('label[for="characterFilter"] > small');
characterFilterLabel.text(entry.characterFilter?.isExclude ? 'Exclude Character(s)' : 'Filter to Character(s)');
// exclude characters checkbox
const characterExclusionInput = template.find('input[name="character_exclusion"]');
characterExclusionInput.data('uid', entry.uid);
characterExclusionInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
characterFilterLabel.text(value ? 'Exclude Character(s)' : 'Filter to Character(s)');
if (data.entries[uid].characterFilter) {
if (!value && data.entries[uid].characterFilter.names.length === 0 && data.entries[uid].characterFilter.tags.length === 0) {
delete data.entries[uid].characterFilter;
} else {
data.entries[uid].characterFilter.isExclude = value;
}
} else if (value) {
Object.assign(
data.entries[uid],
{
characterFilter: {
isExclude: true,
names: [],
tags: [],
},
},
);
}
// Verify names to exist in the system
if (data.entries[uid]?.characterFilter?.names?.length > 0) {
for (const name of [...data.entries[uid].characterFilter.names]) {
if (!getContext().characters.find(x => x.avatar.replace(/\.[^/.]+$/, '') === name)) {
console.warn(`World Info: Character ${name} not found. Removing from the entry filter.`, entry);
data.entries[uid].characterFilter.names = data.entries[uid].characterFilter.names.filter(x => x !== name);
}
}
}
setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
saveWorldInfo(name, data);
});
characterExclusionInput.prop('checked', entry.characterFilter?.isExclude ?? false).trigger('input');
const characterFilter = template.find('select[name="characterFilter"]');
characterFilter.data('uid', entry.uid);
if (!isMobile()) {
$(characterFilter).select2({
width: '100%',
placeholder: 'All characters will pull from this entry.',
allowClear: true,
closeOnSelect: false,
});
}
const characters = getContext().characters;
characters.forEach((character) => {
const option = document.createElement('option');
const name = character.avatar.replace(/\.[^/.]+$/, '') ?? character.name;
option.innerText = name;
option.selected = entry.characterFilter?.names?.includes(name);
option.setAttribute('data-type', 'character');
characterFilter.append(option);
});
const tags = getContext().tags;
tags.forEach((tag) => {
const option = document.createElement('option');
option.innerText = `[Tag] ${tag.name}`;
option.selected = entry.characterFilter?.tags?.includes(tag.id);
option.value = tag.id;
option.setAttribute('data-type', 'tag');
characterFilter.append(option);
});
characterFilter.on('mousedown change', async function (e) {
// If there's no world names, don't do anything
if (world_names.length === 0) {
e.preventDefault();
return;
}
const uid = $(this).data('uid');
const selected = $(this).find(':selected');
if ((!selected || selected?.length === 0) && !data.entries[uid].characterFilter?.isExclude) {
delete data.entries[uid].characterFilter;
} else {
const names = selected.filter('[data-type="character"]').map((_, e) => e instanceof HTMLOptionElement && e.innerText).toArray();
const tags = selected.filter('[data-type="tag"]').map((_, e) => e instanceof HTMLOptionElement && e.value).toArray();
Object.assign(
data.entries[uid],
{
characterFilter: {
isExclude: data.entries[uid].characterFilter?.isExclude ?? false,
names: names,
tags: tags,
},
},
);
}
setOriginalDataValue(data, uid, 'character_filter', data.entries[uid].characterFilter);
saveWorldInfo(name, data);
});
// comment
const commentInput = template.find('textarea[name="comment"]');
const commentToggle = template.find('input[name="addMemo"]');
commentInput.data('uid', entry.uid);
commentInput.on('input', function (_, { skipReset } = {}) {
const uid = $(this).data('uid');
const value = $(this).val();
!skipReset && resetScrollHeight(this);
data.entries[uid].comment = value;
setOriginalDataValue(data, uid, 'comment', data.entries[uid].comment);
saveWorldInfo(name, data);
});
commentToggle.data('uid', entry.uid);
commentToggle.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
//console.log(value)
const commentContainer = $(this)
.closest('.world_entry')
.find('.commentContainer');
data.entries[uid].addMemo = value;
saveWorldInfo(name, data);
value ? commentContainer.show() : commentContainer.hide();
});
commentInput.val(entry.comment).trigger('input', { skipReset: true });
//initScrollHeight(commentInput);
commentToggle.prop('checked', true /* entry.addMemo */).trigger('input');
commentToggle.parent().hide();
// content
const counter = template.find('.world_entry_form_token_counter');
const countTokensDebounced = debounce(async function (counter, value) {
const numberOfTokens = await getTokenCountAsync(value);
$(counter).text(numberOfTokens);
}, debounce_timeout.relaxed);
const contentInput = template.find('textarea[name="content"]');
contentInput.data('uid', entry.uid);
contentInput.on('input', function (_, { skipCount } = {}) {
const uid = $(this).data('uid');
const value = $(this).val();
data.entries[uid].content = value;
setOriginalDataValue(data, uid, 'content', data.entries[uid].content);
saveWorldInfo(name, data);
if (skipCount) {
return;
}
// count tokens
countTokensDebounced(counter, value);
});
contentInput.val(entry.content).trigger('input', { skipCount: true });
//initScrollHeight(contentInput);
template.find('.inline-drawer-toggle').on('click', function () {
if (counter.data('first-run')) {
counter.data('first-run', false);
countTokensDebounced(counter, contentInput.val());
if (!keyInput.isFancy) initScrollHeight(keyInput.control);
if (!keySecondaryInput.isFancy) initScrollHeight(keySecondaryInput.control);
}
});
// selective
const selectiveInput = template.find('input[name="selective"]');
selectiveInput.data('uid', entry.uid);
selectiveInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].selective = value;
setOriginalDataValue(data, uid, 'selective', data.entries[uid].selective);
saveWorldInfo(name, data);
const keysecondary = $(this)
.closest('.world_entry')
.find('.keysecondary');
const keysecondarytextpole = $(this)
.closest('.world_entry')
.find('.keysecondarytextpole');
const keyprimaryselect = $(this)
.closest('.world_entry')
.find('.keyprimaryselect');
const keyprimaryHeight = keyprimaryselect.outerHeight();
keysecondarytextpole.css('height', keyprimaryHeight + 'px');
value ? keysecondary.show() : keysecondary.hide();
});
//forced on, ignored if empty
selectiveInput.prop('checked', true /* entry.selective */).trigger('input');
selectiveInput.parent().hide();
// constant
/*
const constantInput = template.find('input[name="constant"]');
constantInput.data("uid", entry.uid);
constantInput.on("input", function () {
const uid = $(this).data("uid");
const value = $(this).prop("checked");
data.entries[uid].constant = value;
setOriginalDataValue(data, uid, "constant", data.entries[uid].constant);
saveWorldInfo(name, data);
});
constantInput.prop("checked", entry.constant).trigger("input");
*/
// order
const orderInput = template.find('input[name="order"]');
orderInput.data('uid', entry.uid);
orderInput.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].order = !isNaN(value) ? value : 0;
updatePosOrdDisplay(uid);
setOriginalDataValue(data, uid, 'insertion_order', data.entries[uid].order);
saveWorldInfo(name, data);
});
orderInput.val(entry.order).trigger('input');
orderInput.css('width', 'calc(3em + 15px)');
// group
const groupInput = template.find('input[name="group"]');
groupInput.data('uid', entry.uid);
groupInput.on('input', function () {
const uid = $(this).data('uid');
const value = String($(this).val()).trim();
data.entries[uid].group = value;
setOriginalDataValue(data, uid, 'extensions.group', data.entries[uid].group);
saveWorldInfo(name, data);
});
groupInput.val(entry.group ?? '').trigger('input');
setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data), { allowMultiple: true }), 1);
// inclusion priority
const groupOverrideInput = template.find('input[name="groupOverride"]');
groupOverrideInput.data('uid', entry.uid);
groupOverrideInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].groupOverride = value;
setOriginalDataValue(data, uid, 'extensions.group_override', data.entries[uid].groupOverride);
saveWorldInfo(name, data);
});
groupOverrideInput.prop('checked', entry.groupOverride).trigger('input');
// group weight
const groupWeightInput = template.find('input[name="groupWeight"]');
groupWeightInput.data('uid', entry.uid);
groupWeightInput.on('input', function () {
const uid = $(this).data('uid');
let value = Number($(this).val());
const min = Number($(this).attr('min'));
const max = Number($(this).attr('max'));
// Clamp the value
if (value < min) {
value = min;
$(this).val(min);
} else if (value > max) {
value = max;
$(this).val(max);
}
data.entries[uid].groupWeight = !isNaN(value) ? Math.abs(value) : 1;
setOriginalDataValue(data, uid, 'extensions.group_weight', data.entries[uid].groupWeight);
saveWorldInfo(name, data);
});
groupWeightInput.val(entry.groupWeight ?? DEFAULT_WEIGHT).trigger('input');
// probability
if (entry.probability === undefined) {
entry.probability = null;
}
// depth
const depthInput = template.find('input[name="depth"]');
depthInput.data('uid', entry.uid);
depthInput.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].depth = !isNaN(value) ? value : 0;
updatePosOrdDisplay(uid);
setOriginalDataValue(data, uid, 'extensions.depth', data.entries[uid].depth);
saveWorldInfo(name, data);
});
depthInput.val(entry.depth ?? DEFAULT_DEPTH).trigger('input');
depthInput.css('width', 'calc(3em + 15px)');
// Hide by default unless depth is specified
if (entry.position === world_info_position.atDepth) {
//depthInput.parent().hide();
}
const probabilityInput = template.find('input[name="probability"]');
probabilityInput.data('uid', entry.uid);
probabilityInput.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].probability = !isNaN(value) ? value : null;
// Clamp probability to 0-100
if (data.entries[uid].probability !== null) {
data.entries[uid].probability = Math.min(100, Math.max(0, data.entries[uid].probability));
if (data.entries[uid].probability !== value) {
$(this).val(data.entries[uid].probability);
}
}
setOriginalDataValue(data, uid, 'extensions.probability', data.entries[uid].probability);
saveWorldInfo(name, data);
});
probabilityInput.val(entry.probability).trigger('input');
probabilityInput.css('width', 'calc(3em + 15px)');
// probability toggle
if (entry.useProbability === undefined) {
entry.useProbability = false;
}
const probabilityToggle = template.find('input[name="useProbability"]');
probabilityToggle.data('uid', entry.uid);
probabilityToggle.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].useProbability = value;
const probabilityContainer = $(this)
.closest('.world_entry')
.find('.probabilityContainer');
saveWorldInfo(name, data);
value ? probabilityContainer.show() : probabilityContainer.hide();
if (value && data.entries[uid].probability === null) {
data.entries[uid].probability = 100;
}
if (!value) {
data.entries[uid].probability = null;
}
probabilityInput.val(data.entries[uid].probability).trigger('input');
});
//forced on, 100% by default
probabilityToggle.prop('checked', true /* entry.useProbability */).trigger('input');
probabilityToggle.parent().hide();
// position
if (entry.position === undefined) {
entry.position = 0;
}
const positionInput = template.find('select[name="position"]');
//initScrollHeight(positionInput);
positionInput.data('uid', entry.uid);
positionInput.on('click', function (event) {
// Prevent closing the drawer on clicking the input
event.stopPropagation();
});
positionInput.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].position = !isNaN(value) ? value : 0;
if (value === world_info_position.atDepth) {
depthInput.prop('disabled', false);
depthInput.css('visibility', 'visible');
//depthInput.parent().show();
const role = Number($(this).find(':selected').data('role'));
data.entries[uid].role = role;
} else {
depthInput.prop('disabled', true);
depthInput.css('visibility', 'hidden');
data.entries[uid].role = null;
//depthInput.parent().hide();
}
updatePosOrdDisplay(uid);
// Spec v2 only supports before_char and after_char
setOriginalDataValue(data, uid, 'position', data.entries[uid].position == 0 ? 'before_char' : 'after_char');
// Write the original value as extensions field
setOriginalDataValue(data, uid, 'extensions.position', data.entries[uid].position);
setOriginalDataValue(data, uid, 'extensions.role', data.entries[uid].role);
saveWorldInfo(name, data);
});
const roleValue = entry.position === world_info_position.atDepth ? String(entry.role ?? extension_prompt_roles.SYSTEM) : '';
template
.find(`select[name="position"] option[value=${entry.position}][data-role="${roleValue}"]`)
.prop('selected', true)
.trigger('input');
//add UID above content box (less important doesn't need to be always visible)
template.find('.world_entry_form_uid_value').text(`(UID: ${entry.uid})`);
// disable
/*
const disableInput = template.find('input[name="disable"]');
disableInput.data("uid", entry.uid);
disableInput.on("input", function () {
const uid = $(this).data("uid");
const value = $(this).prop("checked");
data.entries[uid].disable = value;
setOriginalDataValue(data, uid, "enabled", !data.entries[uid].disable);
saveWorldInfo(name, data);
});
disableInput.prop("checked", entry.disable).trigger("input");
*/
//new tri-state selector for constant/normal/disabled
const entryStateSelector = template.find('select[name="entryStateSelector"]');
entryStateSelector.data('uid', entry.uid);
entryStateSelector.on('click', function (event) {
// Prevent closing the drawer on clicking the input
event.stopPropagation();
});
entryStateSelector.on('input', function () {
const uid = entry.uid;
const value = $(this).val();
switch (value) {
case 'constant':
data.entries[uid].constant = true;
data.entries[uid].disable = false;
data.entries[uid].vectorized = false;
setOriginalDataValue(data, uid, 'enabled', true);
setOriginalDataValue(data, uid, 'constant', true);
setOriginalDataValue(data, uid, 'extensions.vectorized', false);
template.removeClass('disabledWIEntry');
break;
case 'normal':
data.entries[uid].constant = false;
data.entries[uid].disable = false;
data.entries[uid].vectorized = false;
setOriginalDataValue(data, uid, 'enabled', true);
setOriginalDataValue(data, uid, 'constant', false);
setOriginalDataValue(data, uid, 'extensions.vectorized', false);
template.removeClass('disabledWIEntry');
break;
case 'vectorized':
data.entries[uid].constant = false;
data.entries[uid].disable = false;
data.entries[uid].vectorized = true;
setOriginalDataValue(data, uid, 'enabled', true);
setOriginalDataValue(data, uid, 'constant', false);
setOriginalDataValue(data, uid, 'extensions.vectorized', true);
template.removeClass('disabledWIEntry');
break;
case 'disabled':
data.entries[uid].constant = false;
data.entries[uid].disable = true;
data.entries[uid].vectorized = false;
setOriginalDataValue(data, uid, 'enabled', false);
setOriginalDataValue(data, uid, 'constant', false);
setOriginalDataValue(data, uid, 'extensions.vectorized', false);
template.addClass('disabledWIEntry');
break;
}
saveWorldInfo(name, data);
});
const entryState = function () {
if (entry.constant === true) {
return 'constant';
} else if (entry.vectorized === true) {
return 'vectorized';
} else if (entry.disable === true) {
return 'disabled';
} else {
return 'normal';
}
};
template
.find(`select[name="entryStateSelector"] option[value=${entryState()}]`)
.prop('selected', true)
.trigger('input');
saveWorldInfo(name, data);
// exclude recursion
const excludeRecursionInput = template.find('input[name="exclude_recursion"]');
excludeRecursionInput.data('uid', entry.uid);
excludeRecursionInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].excludeRecursion = value;
setOriginalDataValue(data, uid, 'extensions.exclude_recursion', data.entries[uid].excludeRecursion);
saveWorldInfo(name, data);
});
excludeRecursionInput.prop('checked', entry.excludeRecursion).trigger('input');
// prevent recursion
const preventRecursionInput = template.find('input[name="prevent_recursion"]');
preventRecursionInput.data('uid', entry.uid);
preventRecursionInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].preventRecursion = value;
setOriginalDataValue(data, uid, 'extensions.prevent_recursion', data.entries[uid].preventRecursion);
saveWorldInfo(name, data);
});
preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input');
// delay until recursion
const delayUntilRecursionInput = template.find('input[name="delay_until_recursion"]');
delayUntilRecursionInput.data('uid', entry.uid);
delayUntilRecursionInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).prop('checked');
data.entries[uid].delayUntilRecursion = value;
setOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion);
saveWorldInfo(name, data);
});
delayUntilRecursionInput.prop('checked', entry.delayUntilRecursion).trigger('input');
// duplicate button
const duplicateButton = template.find('.duplicate_entry_button');
duplicateButton.data('uid', entry.uid);
duplicateButton.on('click', function () {
const uid = $(this).data('uid');
const entry = duplicateWorldInfoEntry(data, uid);
if (entry) {
saveWorldInfo(name, data);
updateEditor(entry.uid);
}
});
// delete button
const deleteButton = template.find('.delete_entry_button');
deleteButton.data('uid', entry.uid);
deleteButton.on('click', function () {
const uid = $(this).data('uid');
deleteWorldInfoEntry(data, uid);
deleteOriginalDataValue(data, uid);
saveWorldInfo(name, data);
updateEditor(navigation_option.previous);
});
// scan depth
const scanDepthInput = template.find('input[name="scanDepth"]');
scanDepthInput.data('uid', entry.uid);
scanDepthInput.on('input', function () {
const uid = $(this).data('uid');
const isEmpty = $(this).val() === '';
const value = Number($(this).val());
// Clamp if necessary
if (value < 0) {
$(this).val(0).trigger('input');
toastr.warning('Scan depth cannot be negative');
return;
}
if (value > MAX_SCAN_DEPTH) {
$(this).val(MAX_SCAN_DEPTH).trigger('input');
toastr.warning(`Scan depth cannot exceed ${MAX_SCAN_DEPTH}`);
return;
}
data.entries[uid].scanDepth = !isEmpty && !isNaN(value) && value >= 0 && value <= MAX_SCAN_DEPTH ? Math.floor(value) : null;
setOriginalDataValue(data, uid, 'extensions.scan_depth', data.entries[uid].scanDepth);
saveWorldInfo(name, data);
});
scanDepthInput.val(entry.scanDepth ?? null).trigger('input');
// case sensitive select
const caseSensitiveSelect = template.find('select[name="caseSensitive"]');
caseSensitiveSelect.data('uid', entry.uid);
caseSensitiveSelect.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).val();
data.entries[uid].caseSensitive = value === 'null' ? null : value === 'true';
setOriginalDataValue(data, uid, 'extensions.case_sensitive', data.entries[uid].caseSensitive);
saveWorldInfo(name, data);
});
caseSensitiveSelect.val((entry.caseSensitive === null || entry.caseSensitive === undefined) ? 'null' : entry.caseSensitive ? 'true' : 'false').trigger('input');
// match whole words select
const matchWholeWordsSelect = template.find('select[name="matchWholeWords"]');
matchWholeWordsSelect.data('uid', entry.uid);
matchWholeWordsSelect.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).val();
data.entries[uid].matchWholeWords = value === 'null' ? null : value === 'true';
setOriginalDataValue(data, uid, 'extensions.match_whole_words', data.entries[uid].matchWholeWords);
saveWorldInfo(name, data);
});
matchWholeWordsSelect.val((entry.matchWholeWords === null || entry.matchWholeWords === undefined) ? 'null' : entry.matchWholeWords ? 'true' : 'false').trigger('input');
// use group scoring select
const useGroupScoringSelect = template.find('select[name="useGroupScoring"]');
useGroupScoringSelect.data('uid', entry.uid);
useGroupScoringSelect.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).val();
data.entries[uid].useGroupScoring = value === 'null' ? null : value === 'true';
setOriginalDataValue(data, uid, 'extensions.use_group_scoring', data.entries[uid].useGroupScoring);
saveWorldInfo(name, data);
});
useGroupScoringSelect.val((entry.useGroupScoring === null || entry.useGroupScoring === undefined) ? 'null' : entry.useGroupScoring ? 'true' : 'false').trigger('input');
// automation id
const automationIdInput = template.find('input[name="automationId"]');
automationIdInput.data('uid', entry.uid);
automationIdInput.on('input', function () {
const uid = $(this).data('uid');
const value = $(this).val();
data.entries[uid].automationId = value;
setOriginalDataValue(data, uid, 'extensions.automation_id', data.entries[uid].automationId);
saveWorldInfo(name, data);
});
automationIdInput.val(entry.automationId ?? '').trigger('input');
setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1);
template.find('.inline-drawer-content').css('display', 'none'); //entries start collapsed
function updatePosOrdDisplay(uid) {
// display position/order info left of keyword box
let entry = data.entries[uid];
let posText = entry.position;
switch (entry.position) {
case 0:
posText = '↑CD';
break;
case 1:
posText = 'CD↓';
break;
case 2:
posText = '↑AN';
break;
case 3:
posText = 'AN↓';
break;
case 4:
posText = `@D${entry.depth}`;
break;
}
template.find('.world_entry_form_position_value').text(`(${posText} ${entry.order})`);
}
return template;
}
/**
* Get the inclusion groups for the autocomplete.
* @param {any} data WI data
* @returns {(input: any, output: any) => any} Callback function for the autocomplete
*/
function getInclusionGroupCallback(data) {
return function (control, input, output) {
const uid = $(control).data('uid');
const thisGroups = String($(control).val()).split(/,\s*/).filter(x => x).map(x => x.toLowerCase());
const groups = new Set();
for (const entry of Object.values(data.entries)) {
// Skip the groups of this entry, because auto-complete should only suggest the ones that are already available on other entries
if (entry.uid == uid) continue;
if (entry.group) {
entry.group.split(/,\s*/).filter(x => x).forEach(x => groups.add(x));
}
}
const haystack = Array.from(groups);
haystack.sort((a, b) => a.localeCompare(b));
const needle = input.term.toLowerCase();
const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
const result = haystack.filter(x => x.toLowerCase().includes(needle) && (!thisGroups.includes(x) || hasExactMatch && thisGroups.filter(g => g == x).length == 1));
output(result);
};
}
function getAutomationIdCallback(data) {
return function (control, input, output) {
const uid = $(control).data('uid');
const ids = new Set();
for (const entry of Object.values(data.entries)) {
// Skip automation id of this entry, because auto-complete should only suggest the ones that are already available on other entries
if (entry.uid == uid) continue;
if (entry.automationId) {
ids.add(String(entry.automationId));
}
}
if ('quickReplyApi' in window) {
// @ts-ignore
for (const automationId of window['quickReplyApi'].listAutomationIds()) {
ids.add(String(automationId));
}
}
const haystack = Array.from(ids);
haystack.sort((a, b) => a.localeCompare(b));
const needle = input.term.toLowerCase();
const result = haystack.filter(x => x.toLowerCase().includes(needle));
output(result);
};
}
/**
* Create an autocomplete for the inclusion group.
* @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
* @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
* @param {object} [options={}] - Optional arguments
* @param {boolean} [options.allowMultiple=false] - Whether to allow multiple comma-separated values
*/
function createEntryInputAutocomplete(input, callback, { allowMultiple = false } = {}) {
const handleSelect = (event, ui) => {
// Prevent default autocomplete select, so we can manually set the value
event.preventDefault();
if (!allowMultiple) {
$(input).val(ui.item.value).trigger('input').trigger('blur');
} else {
var terms = String($(input).val()).split(/,\s*/);
terms.pop(); // remove the current input
terms.push(ui.item.value); // add the selected item
$(input).val(terms.filter(x => x).join(', ')).trigger('input').trigger('blur');
}
};
$(input).autocomplete({
minLength: 0,
source: function (request, response) {
if (!allowMultiple) {
callback(input, request, response);
} else {
const term = request.term.split(/,\s*/).pop();
request.term = term;
callback(input, request, response);
}
},
select: handleSelect,
});
$(input).on('focus click', function () {
$(input).autocomplete('search', allowMultiple ? String($(input).val()).split(/,\s*/).pop() : $(input).val());
});
}
/**
* Duplicated a WI entry by copying all of its properties and assigning a new uid
* @param {*} data - The data of the book
* @param {number} uid - The uid of the entry to copy in this book
* @returns {*} The new WI duplicated entry
*/
function duplicateWorldInfoEntry(data, uid) {
if (!data || !('entries' in data) || !data.entries[uid]) {
return;
}
// Exclude uid and gather the rest of the properties
const { uid: _, ...originalData } = data.entries[uid];
// Create new entry and copy over data
const entry = createWorldInfoEntry(data.name, data);
Object.assign(entry, originalData);
return entry;
}
/**
* Deletes a WI entry, with a user confirmation dialog
* @param {*[]} data - The data of the book
* @param {number} uid - The uid of the entry to copy in this book
*/
function deleteWorldInfoEntry(data, uid) {
if (!data || !('entries' in data)) {
return;
}
if (!confirm(`Delete the entry with UID: ${uid}? This action is irreversible!`)) {
throw new Error('User cancelled deletion');
}
delete data.entries[uid];
}
const newEntryTemplate = {
key: [],
keysecondary: [],
comment: '',
content: '',
constant: false,
vectorized: false,
selective: true,
selectiveLogic: world_info_logic.AND_ANY,
addMemo: false,
order: 100,
position: 0,
disable: false,
excludeRecursion: false,
preventRecursion: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: DEFAULT_DEPTH,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: 0,
};
function createWorldInfoEntry(_name, data) {
const newUid = getFreeWorldEntryUid(data);
if (!Number.isInteger(newUid)) {
console.error('Couldn\'t assign UID to a new entry');
return;
}
const newEntry = { uid: newUid, ...structuredClone(newEntryTemplate) };
data.entries[newUid] = newEntry;
return newEntry;
}
async function _save(name, data) {
await fetch('/api/worldinfo/edit', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: name, data: data }),
});
eventSource.emit(event_types.WORLDINFO_UPDATED, name, data);
}
async function saveWorldInfo(name, data, immediately) {
if (!name || !data) {
return;
}
delete worldInfoCache[name];
if (immediately) {
return await _save(name, data);
}
saveWorldDebounced(name, data);
}
async function renameWorldInfo(name, data) {
const oldName = name;
const newName = await callPopup('<h3>Rename World Info</h3>Enter a new name:', 'input', oldName);
if (oldName === newName || !newName) {
console.debug('World info rename cancelled');
return;
}
const entryPreviouslySelected = selected_world_info.findIndex((e) => e === oldName);
await saveWorldInfo(newName, data, true);
await deleteWorldInfo(oldName);
const existingCharLores = world_info.charLore?.filter((e) => e.extraBooks.includes(oldName));
if (existingCharLores && existingCharLores.length > 0) {
existingCharLores.forEach((charLore) => {
const tempCharLore = charLore.extraBooks.filter((e) => e !== oldName);
tempCharLore.push(newName);
charLore.extraBooks = tempCharLore;
});
saveSettingsDebounced();
}
if (entryPreviouslySelected !== -1) {
const wiElement = getWIElement(newName);
wiElement.prop('selected', true);
$('#world_info').trigger('change');
}
const selectedIndex = world_names.indexOf(newName);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
}
}
/**
* Deletes a world info with the given name
*
* @param {string} worldInfoName - The name of the world info to delete
* @returns {Promise<boolean>} A promise that resolves to true if the world info was successfully deleted, false otherwise
*/
async function deleteWorldInfo(worldInfoName) {
if (!world_names.includes(worldInfoName)) {
return false;
}
const response = await fetch('/api/worldinfo/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: worldInfoName }),
});
if (!response.ok) {
return false;
}
const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName);
if (existingWorldIndex !== -1) {
selected_world_info.splice(existingWorldIndex, 1);
saveSettingsDebounced();
}
await updateWorldInfoList();
$('#world_editor_select').trigger('change');
if ($('#character_world').val() === worldInfoName) {
$('#character_world').val('').trigger('change');
setWorldInfoButtonClass(undefined, false);
if (menu_type != 'create') {
saveCharacterDebounced();
}
}
return true;
}
function getFreeWorldEntryUid(data) {
if (!data || !('entries' in data)) {
return null;
}
const MAX_UID = 1_000_000; // <- should be safe enough :)
for (let uid = 0; uid < MAX_UID; uid++) {
if (uid in data.entries) {
continue;
}
return uid;
}
return null;
}
function getFreeWorldName() {
const MAX_FREE_NAME = 100_000;
for (let index = 1; index < MAX_FREE_NAME; index++) {
const newName = `New World (${index})`;
if (world_names.includes(newName)) {
continue;
}
return newName;
}
return undefined;
}
/**
* Creates a new world info/lorebook with the given name.
* Checks if a world with the same name already exists, providing a warning or optionally a user confirmation dialog.
*
* @param {string} worldName - The name of the new world info
* @param {Object} options - Optional parameters
* @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world
* @returns {Promise<boolean>} - True if the world info was successfully created, false otherwise
*/
async function createNewWorldInfo(worldName, { interactive = false } = {}) {
const worldInfoTemplate = { entries: {} };
if (!worldName) {
return false;
}
const sanitizedWorldName = await getSanitizedFilename(worldName);
const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) });
if (!allowed) {
return false;
}
await saveWorldInfo(worldName, worldInfoTemplate, true);
await updateWorldInfoList();
const selectedIndex = world_names.indexOf(worldName);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
} else {
hideWorldEditor();
}
return true;
}
async function getCharacterLore() {
const character = characters[this_chid];
const name = character?.name;
let worldsToSearch = new Set();
const baseWorldName = character?.data?.extensions?.world;
if (baseWorldName) {
worldsToSearch.add(baseWorldName);
} else {
console.debug(`Character ${name}'s base world could not be found or is empty! Skipping...`);
}
// TODO: Maybe make the utility function not use the window context?
const fileName = getCharaFilename(this_chid);
const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
if (extraCharLore) {
worldsToSearch = new Set([...worldsToSearch, ...extraCharLore.extraBooks]);
}
let entries = [];
for (const worldName of worldsToSearch) {
if (selected_world_info.includes(worldName)) {
console.debug(`Character ${name}'s world ${worldName} is already activated in global world info! Skipping...`);
continue;
}
if (chat_metadata[METADATA_KEY] === worldName) {
console.debug(`Character ${name}'s world ${worldName} is already activated in chat lore! Skipping...`);
continue;
}
const data = await loadWorldInfoData(worldName);
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: worldName })) : [];
entries = entries.concat(newEntries);
}
console.debug(`Character ${name} lore (${Array.from(worldsToSearch)}) has ${entries.length} world info entries`);
return entries;
}
async function getGlobalLore() {
if (!selected_world_info) {
return [];
}
let entries = [];
for (const worldName of selected_world_info) {
const data = await loadWorldInfoData(worldName);
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: worldName })) : [];
entries = entries.concat(newEntries);
}
console.debug(`Global world info has ${entries.length} entries`);
return entries;
}
async function getChatLore() {
const chatWorld = chat_metadata[METADATA_KEY];
if (!chatWorld) {
return [];
}
if (selected_world_info.includes(chatWorld)) {
console.debug(`Chat world ${chatWorld} is already activated in global world info! Skipping...`);
return [];
}
const data = await loadWorldInfoData(chatWorld);
const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(x => ({ ...x, world: chatWorld })) : [];
console.debug(`Chat lore has ${entries.length} entries`);
return entries;
}
export async function getSortedEntries() {
try {
const globalLore = await getGlobalLore();
const characterLore = await getCharacterLore();
const chatLore = await getChatLore();
let entries;
switch (Number(world_info_character_strategy)) {
case world_info_insertion_strategy.evenly:
entries = [...globalLore, ...characterLore].sort(sortFn);
break;
case world_info_insertion_strategy.character_first:
entries = [...characterLore.sort(sortFn), ...globalLore.sort(sortFn)];
break;
case world_info_insertion_strategy.global_first:
entries = [...globalLore.sort(sortFn), ...characterLore.sort(sortFn)];
break;
default:
console.error('Unknown WI insertion strategy: ', world_info_character_strategy, 'defaulting to evenly');
entries = [...globalLore, ...characterLore].sort(sortFn);
break;
}
// Chat lore always goes first
entries = [...chatLore.sort(sortFn), ...entries];
console.debug(`Sorted ${entries.length} world lore entries using strategy ${world_info_character_strategy}`);
// Need to deep clone the entries to avoid modifying the cached data
return structuredClone(entries);
}
catch (e) {
console.error(e);
return [];
}
}
/**
* Performs a scan on the chat and returns the world info activated.
* @param {string[]} chat The chat messages to scan.
* @param {number} maxContext The maximum context size of the generation.
* @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated
* @returns {Promise<WIActivated>} The world info activated.
*/
async function checkWorldInfo(chat, maxContext) {
const context = getContext();
const buffer = new WorldInfoBuffer(chat);
// Combine the chat
// Add the depth or AN if enabled
// Put this code here since otherwise, the chat reference is modified
for (const key of Object.keys(context.extensionPrompts)) {
if (context.extensionPrompts[key]?.scan) {
const prompt = getExtensionPromptByName(key);
if (prompt) {
buffer.addRecurse(prompt);
}
}
}
let needsToScan = true;
let token_budget_overflowed = false;
let count = 0;
let allActivatedEntries = new Set();
let failedProbabilityChecks = new Set();
let allActivatedText = '';
let budget = Math.round(world_info_budget * maxContext / 100) || 1;
if (world_info_budget_cap > 0 && budget > world_info_budget_cap) {
console.debug(`Budget ${budget} exceeds cap ${world_info_budget_cap}, using cap`);
budget = world_info_budget_cap;
}
console.debug(`Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`);
const sortedEntries = await getSortedEntries();
if (sortedEntries.length === 0) {
return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() };
}
while (needsToScan) {
// Track how many times the loop has run
count++;
let activatedNow = new Set();
for (let entry of sortedEntries) {
// Check if this entry applies to the character or if it's excluded
if (entry.characterFilter && entry.characterFilter?.names?.length > 0) {
const nameIncluded = entry.characterFilter.names.includes(getCharaFilename());
const filtered = entry.characterFilter.isExclude ? nameIncluded : !nameIncluded;
if (filtered) {
console.debug(`WI entry ${entry.uid} filtered out by character`);
continue;
}
}
if (entry.characterFilter && entry.characterFilter?.tags?.length > 0) {
const tagKey = getTagKeyForEntity(this_chid);
if (tagKey) {
const tagMapEntry = context.tagMap[tagKey];
if (Array.isArray(tagMapEntry)) {
// If tag map intersects with the tag exclusion list, skip
const includesTag = tagMapEntry.some((tag) => entry.characterFilter.tags.includes(tag));
const filtered = entry.characterFilter.isExclude ? includesTag : !includesTag;
if (filtered) {
console.debug(`WI entry ${entry.uid} filtered out by tag`);
continue;
}
}
}
}
if (failedProbabilityChecks.has(entry)) {
continue;
}
if (allActivatedEntries.has(entry) || entry.disable == true || (count > 1 && world_info_recursive && entry.excludeRecursion) || (count == 1 && entry.delayUntilRecursion)) {
continue;
}
if (entry.constant || buffer.isExternallyActivated(entry)) {
activatedNow.add(entry);
continue;
}
if (Array.isArray(entry.key) && entry.key.length) { //check for keywords existing
// If selectiveLogic isn't found, assume it's AND, only do this once per entry
const selectiveLogic = entry.selectiveLogic ?? 0;
primary: for (let key of entry.key) {
const substituted = substituteParams(key);
const textToScan = buffer.get(entry);
if (substituted && buffer.matchKeys(textToScan, substituted.trim(), entry)) {
console.debug(`WI UID ${entry.uid} found by primary match: ${substituted}.`);
//selective logic begins
if (
entry.selective && //all entries are selective now
Array.isArray(entry.keysecondary) && //always true
entry.keysecondary.length //ignore empties
) {
console.debug(`WI UID:${entry.uid} found. Checking logic: ${entry.selectiveLogic}`);
let hasAnyMatch = false;
let hasAllMatch = true;
secondary: for (let keysecondary of entry.keysecondary) {
const secondarySubstituted = substituteParams(keysecondary);
const hasSecondaryMatch = secondarySubstituted && buffer.matchKeys(textToScan, secondarySubstituted.trim(), entry);
console.debug(`WI UID:${entry.uid}: Filtering for secondary keyword - "${secondarySubstituted}".`);
if (hasSecondaryMatch) {
hasAnyMatch = true;
}
if (!hasSecondaryMatch) {
hasAllMatch = false;
}
// Simplified AND ANY / NOT ALL if statement. (Proper fix for PR#1356 by Bronya)
// If AND ANY logic and the main checks pass OR if NOT ALL logic and the main checks do not pass
if ((selectiveLogic === world_info_logic.AND_ANY && hasSecondaryMatch) || (selectiveLogic === world_info_logic.NOT_ALL && !hasSecondaryMatch)) {
// Differ both logic statements in the debugger
if (selectiveLogic === world_info_logic.AND_ANY) {
console.debug(`(AND ANY Check) Activating WI Entry ${entry.uid}. Found match for word: ${substituted} ${secondarySubstituted}`);
} else {
console.debug(`(NOT ALL Check) Activating WI Entry ${entry.uid}. Found match for word "${substituted}" without secondary keyword: ${secondarySubstituted}`);
}
activatedNow.add(entry);
break secondary;
}
}
// Handle NOT ANY logic
if (selectiveLogic === world_info_logic.NOT_ANY && !hasAnyMatch) {
console.debug(`(NOT ANY Check) Activating WI Entry ${entry.uid}, no secondary keywords found.`);
activatedNow.add(entry);
}
// Handle AND ALL logic
if (selectiveLogic === world_info_logic.AND_ALL && hasAllMatch) {
console.debug(`(AND ALL Check) Activating WI Entry ${entry.uid}, all secondary keywords found.`);
activatedNow.add(entry);
}
} else {
// Handle cases where secondary is empty
console.debug(`WI UID ${entry.uid}: Activated without filter logic.`);
activatedNow.add(entry);
break primary;
}
}
}
}
}
needsToScan = world_info_recursive && activatedNow.size > 0;
const newEntries = [...activatedNow]
.sort((a, b) => sortedEntries.indexOf(a) - sortedEntries.indexOf(b));
let newContent = '';
const textToScanTokens = await getTokenCountAsync(allActivatedText);
const probabilityChecksBefore = failedProbabilityChecks.size;
filterByInclusionGroups(newEntries, allActivatedEntries, buffer);
console.debug('-- PROBABILITY CHECKS BEGIN --');
for (const entry of newEntries) {
const rollValue = Math.random() * 100;
if (entry.useProbability && rollValue > entry.probability) {
console.debug(`WI entry ${entry.uid} ${entry.key} failed probability check, skipping`);
failedProbabilityChecks.add(entry);
continue;
} else { console.debug(`uid:${entry.uid} passed probability check, inserting to prompt`); }
// Substitute macros inline, for both this checking and also future processing
entry.content = substituteParams(entry.content);
newContent += `${entry.content}\n`;
if ((textToScanTokens + (await getTokenCountAsync(newContent))) >= budget) {
console.debug('WI budget reached, stopping');
if (world_info_overflow_alert) {
console.log('Alerting');
toastr.warning(`World info budget reached after ${allActivatedEntries.size} entries.`, 'World Info');
}
needsToScan = false;
token_budget_overflowed = true;
break;
}
allActivatedEntries.add(entry);
console.debug('WI entry activated:', entry);
}
const probabilityChecksAfter = failedProbabilityChecks.size;
if ((probabilityChecksAfter - probabilityChecksBefore) === activatedNow.size) {
console.debug('WI probability checks failed for all activated entries, stopping');
needsToScan = false;
}
if (newEntries.length === 0) {
console.debug('No new entries activated, stopping');
needsToScan = false;
}
if (needsToScan) {
const text = newEntries
.filter(x => !failedProbabilityChecks.has(x))
.filter(x => !x.preventRecursion)
.map(x => x.content).join('\n');
buffer.addRecurse(text);
allActivatedText = (text + '\n' + allActivatedText);
}
// world_info_min_activations
if (!needsToScan && !token_budget_overflowed) {
if (world_info_min_activations > 0 && (allActivatedEntries.size < world_info_min_activations)) {
let over_max = (
world_info_min_activations_depth_max > 0 &&
buffer.getDepth() > world_info_min_activations_depth_max
) || (buffer.getDepth() > chat.length);
if (!over_max) {
needsToScan = true; // loop
buffer.advanceScanPosition();
}
}
}
}
// Forward-sorted list of entries for joining
const WIBeforeEntries = [];
const WIAfterEntries = [];
const EMEntries = [];
const ANTopEntries = [];
const ANBottomEntries = [];
const WIDepthEntries = [];
// Appends from insertion order 999 to 1. Use unshift for this purpose
// TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays
[...allActivatedEntries].sort(sortFn).forEach((entry) => {
const regexDepth = entry.position === world_info_position.atDepth ? (entry.depth ?? DEFAULT_DEPTH) : null;
const content = getRegexedString(entry.content, regex_placement.WORLD_INFO, { depth: regexDepth, isMarkdown: false, isPrompt: true });
if (!content) {
console.debug('Skipping adding WI entry to prompt due to empty content:', entry);
return;
}
switch (entry.position) {
case world_info_position.before:
WIBeforeEntries.unshift(substituteParams(content));
break;
case world_info_position.after:
WIAfterEntries.unshift(substituteParams(content));
break;
case world_info_position.EMTop:
EMEntries.unshift(
{ position: wi_anchor_position.before, content: content },
);
break;
case world_info_position.EMBottom:
EMEntries.unshift(
{ position: wi_anchor_position.after, content: content },
);
break;
case world_info_position.ANTop:
ANTopEntries.unshift(content);
break;
case world_info_position.ANBottom:
ANBottomEntries.unshift(content);
break;
case world_info_position.atDepth: {
const existingDepthIndex = WIDepthEntries.findIndex((e) => e.depth === (entry.depth ?? DEFAULT_DEPTH) && e.role === (entry.role ?? extension_prompt_roles.SYSTEM));
if (existingDepthIndex !== -1) {
WIDepthEntries[existingDepthIndex].entries.unshift(content);
} else {
WIDepthEntries.push({
depth: entry.depth,
entries: [content],
role: entry.role ?? extension_prompt_roles.SYSTEM,
});
}
break;
}
default:
break;
}
});
const worldInfoBefore = WIBeforeEntries.length ? WIBeforeEntries.join('\n') : '';
const worldInfoAfter = WIAfterEntries.length ? WIAfterEntries.join('\n') : '';
if (shouldWIAddPrompt) {
const originalAN = context.extensionPrompts[NOTE_MODULE_NAME].value;
const ANWithWI = `${ANTopEntries.join('\n')}\n${originalAN}\n${ANBottomEntries.join('\n')}`.replace(/(^\n)|(\n$)/g, '');
context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
}
buffer.cleanExternalActivations();
return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, allActivatedEntries };
}
/**
* Only leaves entries with the highest key matching score in each group.
* @param {Record<string, WIScanEntry[]>} groups The groups to filter
* @param {WorldInfoBuffer} buffer The buffer to use for scoring
* @param {(entry: WIScanEntry) => void} removeEntry The function to remove an entry
*/
function filterGroupsByScoring(groups, buffer, removeEntry) {
for (const [key, group] of Object.entries(groups)) {
// Group scoring is disabled both globally and for the group entries
if (!world_info_use_group_scoring && !group.some(x => x.useGroupScoring)) {
console.debug(`Skipping group scoring for group '${key}'`);
continue;
}
const scores = group.map(entry => buffer.getScore(entry));
const maxScore = Math.max(...scores);
console.debug(`Group '${key}' max score: ${maxScore}`);
//console.table(group.map((entry, i) => ({ uid: entry.uid, key: JSON.stringify(entry.key), score: scores[i] })));
for (let i = 0; i < group.length; i++) {
const isScored = group[i].useGroupScoring ?? world_info_use_group_scoring;
if (!isScored) {
continue;
}
if (scores[i] < maxScore) {
console.debug(`Removing score loser from inclusion group '${key}' entry '${group[i].uid}'`, group[i]);
removeEntry(group[i]);
group.splice(i, 1);
scores.splice(i, 1);
i--;
}
}
}
}
/**
* Filters entries by inclusion groups.
* @param {object[]} newEntries Entries activated on current recursion level
* @param {Set<object>} allActivatedEntries Set of all activated entries
* @param {WorldInfoBuffer} buffer The buffer to use for scanning
*/
function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) {
console.debug('-- INCLUSION GROUP CHECKS BEGIN --');
const grouped = newEntries.filter(x => x.group).reduce((acc, item) => {
item.group.split(/,\s*/).filter(x => x).forEach(group => {
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(item);
});
return acc;
}, {});
if (Object.keys(grouped).length === 0) {
console.debug('No inclusion groups found');
return;
}
const removeEntry = (entry) => newEntries.splice(newEntries.indexOf(entry), 1);
function removeAllBut(group, chosen, logging = true) {
for (const entry of group) {
if (entry === chosen) {
continue;
}
if (logging) console.debug(`Removing loser from inclusion group '${entry.group}' entry '${entry.uid}'`, entry);
removeEntry(entry);
}
}
filterGroupsByScoring(grouped, buffer, removeEntry);
for (const [key, group] of Object.entries(grouped)) {
console.debug(`Checking inclusion group '${key}' with ${group.length} entries`, group);
if (Array.from(allActivatedEntries).some(x => x.group === key)) {
console.debug(`Skipping inclusion group check, group already activated '${key}'`);
// We need to forcefully deactivate all other entries in the group
removeAllBut(group, null, false);
continue;
}
if (!Array.isArray(group) || group.length <= 1) {
console.debug('Skipping inclusion group check, only one entry');
continue;
}
// Check for group prio
const prios = group.filter(x => x.groupOverride).sort(sortFn);
if (prios.length) {
console.debug(`Activated inclusion group '${key}' with by prio winner entry '${prios[0].uid}'`, prios[0]);
removeAllBut(group, prios[0]);
continue;
}
// Do weighted random using entry's weight
const totalWeight = group.reduce((acc, item) => acc + (item.groupWeight ?? DEFAULT_WEIGHT), 0);
const rollValue = Math.random() * totalWeight;
let currentWeight = 0;
let winner = null;
for (const entry of group) {
currentWeight += (entry.groupWeight ?? DEFAULT_WEIGHT);
if (rollValue <= currentWeight) {
console.debug(`Activated inclusion group '${key}' with roll winner entry '${entry.uid}'`, entry);
winner = entry;
break;
}
}
if (!winner) {
console.debug(`Failed to activate inclusion group '${key}', no winner found`);
continue;
}
// Remove every group item from newEntries but the winner
removeAllBut(group, winner);
}
}
function convertAgnaiMemoryBook(inputObj) {
const outputObj = { entries: {} };
inputObj.entries.forEach((entry, index) => {
outputObj.entries[index] = {
...newEntryTemplate,
uid: index,
key: entry.keywords,
keysecondary: [],
comment: entry.name,
content: entry.entry,
constant: false,
selective: false,
vectorized: false,
selectiveLogic: world_info_logic.AND_ANY,
order: entry.weight,
position: 0,
disable: !entry.enabled,
addMemo: !!entry.name,
excludeRecursion: false,
delayUntilRecursion: false,
displayIndex: index,
probability: 100,
useProbability: true,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: extension_prompt_roles.SYSTEM,
};
});
return outputObj;
}
function convertRisuLorebook(inputObj) {
const outputObj = { entries: {} };
inputObj.data.forEach((entry, index) => {
outputObj.entries[index] = {
...newEntryTemplate,
uid: index,
key: entry.key.split(',').map(x => x.trim()),
keysecondary: entry.secondkey ? entry.secondkey.split(',').map(x => x.trim()) : [],
comment: entry.comment,
content: entry.content,
constant: entry.alwaysActive,
selective: entry.selective,
vectorized: false,
selectiveLogic: world_info_logic.AND_ANY,
order: entry.insertorder,
position: world_info_position.before,
disable: false,
addMemo: true,
excludeRecursion: false,
delayUntilRecursion: false,
displayIndex: index,
probability: entry.activationPercent ?? 100,
useProbability: entry.activationPercent ?? true,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: extension_prompt_roles.SYSTEM,
};
});
return outputObj;
}
function convertNovelLorebook(inputObj) {
const outputObj = {
entries: {},
};
inputObj.entries.forEach((entry, index) => {
const displayName = entry.displayName;
const addMemo = displayName !== undefined && displayName.trim() !== '';
outputObj.entries[index] = {
...newEntryTemplate,
uid: index,
key: entry.keys,
keysecondary: [],
comment: displayName || '',
content: entry.text,
constant: false,
selective: false,
vectorized: false,
selectiveLogic: world_info_logic.AND_ANY,
order: entry.contextConfig?.budgetPriority ?? 0,
position: 0,
disable: !entry.enabled,
addMemo: addMemo,
excludeRecursion: false,
delayUntilRecursion: false,
displayIndex: index,
probability: 100,
useProbability: true,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
scanDepth: null,
caseSensitive: null,
matchWholeWords: null,
useGroupScoring: null,
automationId: '',
role: extension_prompt_roles.SYSTEM,
};
});
return outputObj;
}
function convertCharacterBook(characterBook) {
const result = { entries: {}, originalData: characterBook };
characterBook.entries.forEach((entry, index) => {
// Not in the spec, but this is needed to find the entry in the original data
if (entry.id === undefined) {
entry.id = index;
}
result.entries[entry.id] = {
...newEntryTemplate,
uid: entry.id,
key: entry.keys,
keysecondary: entry.secondary_keys || [],
comment: entry.comment || '',
content: entry.content,
constant: entry.constant || false,
selective: entry.selective || false,
order: entry.insertion_order,
position: entry.extensions?.position ?? (entry.position === 'before_char' ? world_info_position.before : world_info_position.after),
excludeRecursion: entry.extensions?.exclude_recursion ?? false,
preventRecursion: entry.extensions?.prevent_recursion ?? false,
delayUntilRecursion: entry.extensions?.delay_until_recursion ?? false,
disable: !entry.enabled,
addMemo: entry.comment ? true : false,
displayIndex: entry.extensions?.display_index ?? index,
probability: entry.extensions?.probability ?? 100,
useProbability: entry.extensions?.useProbability ?? true,
depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
group: entry.extensions?.group ?? '',
groupOverride: entry.extensions?.group_override ?? false,
groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,
scanDepth: entry.extensions?.scan_depth ?? null,
caseSensitive: entry.extensions?.case_sensitive ?? null,
matchWholeWords: entry.extensions?.match_whole_words ?? null,
useGroupScoring: entry.extensions?.use_group_scoring ?? null,
automationId: entry.extensions?.automation_id ?? '',
role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
vectorized: entry.extensions?.vectorized ?? false,
};
});
return result;
}
export function setWorldInfoButtonClass(chid, forceValue = undefined) {
if (forceValue !== undefined) {
$('#set_character_world, #world_button').toggleClass('world_set', forceValue);
return;
}
if (!chid) {
return;
}
const world = characters[chid]?.data?.extensions?.world;
const worldSet = Boolean(world && world_names.includes(world));
$('#set_character_world, #world_button').toggleClass('world_set', worldSet);
}
export function checkEmbeddedWorld(chid) {
$('#import_character_info').hide();
if (chid === undefined) {
return false;
}
if (characters[chid]?.data?.character_book) {
$('#import_character_info').data('chid', chid).show();
// Only show the alert once per character
const checkKey = `AlertWI_${characters[chid].avatar}`;
const worldName = characters[chid]?.data?.extensions?.world;
if (!localStorage.getItem(checkKey) && (!worldName || !world_names.includes(worldName))) {
localStorage.setItem(checkKey, 'true');
if (power_user.world_import_dialog) {
const html = `<h3>This character has an embedded World/Lorebook.</h3>
<h3>Would you like to import it now?</h3>
<div class="m-b-1">If you want to import it later, select "Import Card Lore" in the "More..." dropdown menu on the character panel.</div>`;
const checkResult = (result) => {
if (result) {
importEmbeddedWorldInfo(true);
}
};
callPopup(html, 'confirm', '', { okButton: 'Yes' }).then(checkResult);
}
else {
toastr.info(
'To import and use it, select "Import Card Lore" in the "More..." dropdown menu on the character panel.',
`${characters[chid].name} has an embedded World/Lorebook`,
{ timeOut: 5000, extendedTimeOut: 10000, positionClass: 'toast-top-center' },
);
}
}
return true;
}
return false;
}
export async function importEmbeddedWorldInfo(skipPopup = false) {
const chid = $('#import_character_info').data('chid');
if (chid === undefined) {
return;
}
const bookName = characters[chid]?.data?.character_book?.name || `${characters[chid]?.name}'s Lorebook`;
const confirmationText = (`<h3>Are you sure you want to import "${bookName}"?</h3>`) + (world_names.includes(bookName) ? 'It will overwrite the World/Lorebook with the same name.' : '');
if (!skipPopup) {
const confirmation = await callPopup(confirmationText, 'confirm');
if (!confirmation) {
return;
}
}
const convertedBook = convertCharacterBook(characters[chid].data.character_book);
await saveWorldInfo(bookName, convertedBook, true);
await updateWorldInfoList();
$('#character_world').val(bookName).trigger('change');
toastr.success(`The world "${bookName}" has been imported and linked to the character successfully.`, 'World/Lorebook imported');
const newIndex = world_names.indexOf(bookName);
if (newIndex >= 0) {
//show&draw the WI panel before..
$('#WIDrawerIcon').trigger('click');
//..auto-opening the new imported WI
$('#world_editor_select').val(newIndex).trigger('change');
}
setWorldInfoButtonClass(chid, true);
}
function onWorldInfoChange(args, text) {
if (args !== '__notSlashCommand__') { // if it's a slash command
const silent = isTrueBoolean(args.silent);
if (text.trim() !== '') { // and args are provided
const slashInputSplitText = text.trim().toLowerCase().split(',');
slashInputSplitText.forEach((worldName) => {
const wiElement = getWIElement(worldName);
if (wiElement.length > 0) {
const name = wiElement.text();
switch (args.state) {
case 'off': {
if (selected_world_info.includes(name)) {
selected_world_info.splice(selected_world_info.indexOf(name), 1);
wiElement.prop('selected', false);
if (!silent) toastr.success(`Deactivated world: ${name}`);
} else {
if (!silent) toastr.error(`World was not active: ${name}`);
}
break;
}
case 'toggle': {
if (selected_world_info.includes(name)) {
selected_world_info.splice(selected_world_info.indexOf(name), 1);
wiElement.prop('selected', false);
if (!silent) toastr.success(`Deactivated world: ${name}`);
} else {
selected_world_info.push(name);
wiElement.prop('selected', true);
if (!silent) toastr.success(`Activated world: ${name}`);
}
break;
}
default: {
selected_world_info.push(name);
wiElement.prop('selected', true);
if (!silent) toastr.success(`Activated world: ${name}`);
}
}
} else {
if (!silent) toastr.error(`No world found named: ${worldName}`);
}
});
$('#world_info').trigger('change');
} else { // if no args, unset all worlds
if (!silent) toastr.success('Deactivated all worlds');
selected_world_info = [];
$('#world_info').val(null).trigger('change');
}
} else { //if it's a pointer selection
let tempWorldInfo = [];
let selectedWorlds = $('#world_info').val().map((e) => Number(e)).filter((e) => !isNaN(e));
if (selectedWorlds.length > 0) {
selectedWorlds.forEach((worldIndex) => {
const existingWorldName = world_names[worldIndex];
if (existingWorldName) {
tempWorldInfo.push(existingWorldName);
} else {
const wiElement = getWIElement(existingWorldName);
wiElement.prop('selected', false);
toastr.error(`The world with ${existingWorldName} is invalid or corrupted.`);
}
});
}
selected_world_info = tempWorldInfo;
}
saveSettingsDebounced();
eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
return '';
}
export async function importWorldInfo(file) {
if (!file) {
return;
}
const formData = new FormData();
formData.append('avatar', file);
try {
let jsonData;
if (file.name.endsWith('.png')) {
const buffer = new Uint8Array(await getFileBuffer(file));
jsonData = extractDataFromPng(buffer, 'naidata');
} else {
// File should be a JSON file
jsonData = await parseJsonFile(file);
}
if (jsonData === undefined || jsonData === null) {
toastr.error(`File is not valid: ${file.name}`);
return;
}
// Convert Novel Lorebook
if (jsonData.lorebookVersion !== undefined) {
console.log('Converting Novel Lorebook');
formData.append('convertedData', JSON.stringify(convertNovelLorebook(jsonData)));
}
// Convert Agnai Memory Book
if (jsonData.kind === 'memory') {
console.log('Converting Agnai Memory Book');
formData.append('convertedData', JSON.stringify(convertAgnaiMemoryBook(jsonData)));
}
// Convert Risu Lorebook
if (jsonData.type === 'risu') {
console.log('Converting Risu Lorebook');
formData.append('convertedData', JSON.stringify(convertRisuLorebook(jsonData)));
}
} catch (error) {
toastr.error(`Error parsing file: ${error}`);
return;
}
const worldName = file.name.substr(0, file.name.lastIndexOf('.'));
const sanitizedWorldName = await getSanitizedFilename(worldName);
const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) });
if (!allowed) {
return false;
}
jQuery.ajax({
type: 'POST',
url: '/api/worldinfo/import',
data: formData,
beforeSend: () => { },
cache: false,
contentType: false,
processData: false,
success: async function (data) {
if (data.name) {
await updateWorldInfoList();
const newIndex = world_names.indexOf(data.name);
if (newIndex >= 0) {
$('#world_editor_select').val(newIndex).trigger('change');
}
toastr.success(`World Info "${data.name}" imported successfully!`);
}
},
error: (_jqXHR, _exception) => { },
});
}
function assignLorebookToChat() {
const selectedName = chat_metadata[METADATA_KEY];
const template = $('#chat_world_template .chat_world').clone();
const worldSelect = template.find('select');
const chatName = template.find('.chat_name');
chatName.text(getCurrentChatId());
for (const worldName of world_names) {
const option = document.createElement('option');
option.value = worldName;
option.innerText = worldName;
option.selected = selectedName === worldName;
worldSelect.append(option);
}
worldSelect.on('change', function () {
const worldName = $(this).val();
if (worldName) {
chat_metadata[METADATA_KEY] = worldName;
$('.chat_lorebook_button').addClass('world_set');
} else {
delete chat_metadata[METADATA_KEY];
$('.chat_lorebook_button').removeClass('world_set');
}
saveMetadata();
});
callPopup(template, 'text');
}
jQuery(() => {
$('#world_info').on('mousedown change', async function (e) {
// If there's no world names, don't do anything
if (world_names.length === 0) {
e.preventDefault();
return;
}
onWorldInfoChange('__notSlashCommand__');
});
//**************************WORLD INFO IMPORT EXPORT*************************//
$('#world_import_button').on('click', function () {
$('#world_import_file').trigger('click');
});
$('#world_import_file').on('change', async function (e) {
if (!(e.target instanceof HTMLInputElement)) {
return;
}
const file = e.target.files[0];
await importWorldInfo(file);
// Will allow to select the same file twice in a row
e.target.value = '';
});
$('#world_create_button').on('click', async () => {
const tempName = getFreeWorldName();
const finalName = await callPopup('<h3>Create a new World Info?</h3>Enter a name for the new file:', 'input', tempName);
if (finalName) {
await createNewWorldInfo(finalName, { interactive: true });
}
});
$('#world_editor_select').on('change', async () => {
$('#world_info_search').val('');
worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, '', true);
const selectedIndex = String($('#world_editor_select').find(':selected').val());
if (selectedIndex === '') {
hideWorldEditor();
} else {
const worldName = world_names[selectedIndex];
showWorldEditor(worldName);
}
});
const saveSettings = () => {
saveSettingsDebounced();
eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED);
};
$('#world_info_depth').on('input', function () {
world_info_depth = Number($(this).val());
$('#world_info_depth_counter').val($(this).val());
saveSettings();
});
$('#world_info_min_activations').on('input', function () {
world_info_min_activations = Number($(this).val());
$('#world_info_min_activations_counter').val($(this).val());
saveSettings();
});
$('#world_info_min_activations_depth_max').on('input', function () {
world_info_min_activations_depth_max = Number($(this).val());
$('#world_info_min_activations_depth_max_counter').val($(this).val());
saveSettings();
});
$('#world_info_budget').on('input', function () {
world_info_budget = Number($(this).val());
$('#world_info_budget_counter').val($(this).val());
saveSettings();
});
$('#world_info_recursive').on('input', function () {
world_info_recursive = !!$(this).prop('checked');
saveSettings();
});
$('#world_info_case_sensitive').on('input', function () {
world_info_case_sensitive = !!$(this).prop('checked');
saveSettings();
});
$('#world_info_match_whole_words').on('input', function () {
world_info_match_whole_words = !!$(this).prop('checked');
saveSettings();
});
$('#world_info_character_strategy').on('change', function () {
world_info_character_strategy = Number($(this).val());
saveSettings();
});
$('#world_info_overflow_alert').on('change', function () {
world_info_overflow_alert = !!$(this).prop('checked');
saveSettingsDebounced();
});
$('#world_info_use_group_scoring').on('change', function () {
world_info_use_group_scoring = !!$(this).prop('checked');
saveSettingsDebounced();
});
$('#world_info_budget_cap').on('input', function () {
world_info_budget_cap = Number($(this).val());
$('#world_info_budget_cap_counter').val(world_info_budget_cap);
saveSettings();
});
$('#world_button').on('click', async function (event) {
const chid = $('#set_character_world').data('chid');
if (chid) {
const worldName = characters[chid]?.data?.extensions?.world;
const hasEmbed = checkEmbeddedWorld(chid);
if (worldName && world_names.includes(worldName) && !event.shiftKey) {
if (!$('#WorldInfo').is(':visible')) {
$('#WIDrawerIcon').trigger('click');
}
const index = world_names.indexOf(worldName);
$('#world_editor_select').val(index).trigger('change');
} else if (hasEmbed && !event.shiftKey) {
await importEmbeddedWorldInfo();
saveCharacterDebounced();
}
else {
$('#char-management-dropdown').val($('#set_character_world').val()).trigger('change');
}
}
});
const debouncedWorldInfoSearch = debounce((searchQuery) => {
worldInfoFilter.setFilterData(FILTER_TYPES.WORLD_INFO_SEARCH, searchQuery);
});
$('#world_info_search').on('input', function () {
const searchQuery = $(this).val();
debouncedWorldInfoSearch(searchQuery);
});
$('#world_refresh').on('click', () => {
updateEditor(navigation_option.previous);
});
$('#world_info_sort_order').on('change', function () {
const value = String($(this).find(':selected').val());
// Save sort order, but do not save search sorting, as this is a temporary sorting option
if (value !== 'search') localStorage.setItem(SORT_ORDER_KEY, value);
updateEditor(navigation_option.none);
});
$(document).on('click', '.chat_lorebook_button', assignLorebookToChat);
// Not needed on mobile
if (!isMobile()) {
$('#world_info').select2({
width: '100%',
placeholder: 'No Worlds active. Click here to select.',
allowClear: true,
closeOnSelect: false,
});
// Subscribe world loading to the select2 multiselect items (We need to target the specific select2 control)
select2ChoiceClickSubscribe($('#world_info'), target => {
const name = $(target).text();
const selectedIndex = world_names.indexOf(name);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
console.log('Quick selection of world', name);
}
}, { buttonStyle: true, closeDrawer: true });
}
$('#WorldInfo').on('scroll', () => {
$('.world_entry input[name="group"], .world_entry input[name="automationId"]').each((_, el) => {
const instance = $(el).autocomplete('instance');
if (instance !== undefined) {
$(el).autocomplete('close');
}
});
});
});