From b6fbe41f93a6c432df1fedadf626210ab7a9fe0e Mon Sep 17 00:00:00 2001 From: kingbri Date: Sun, 25 Jun 2023 22:27:02 -0400 Subject: [PATCH] World Info: Add support for character layering Some characters have different cards depending on what the user wants from the character. However, maintaining multiple lorebooks for different personas of the same character can be difficult. In addition, there is redundancy, overlap, and possiblities to miss information when creating separate lorebooks with the same base info. Therefore, add a "DLC"/layering system of sorts for characters. This works the same way as multi-global world info where character lorebooks added as needed. The only catch is that a base character book must be tied to a card before selecting any extra info. Signed-off-by: kingbri --- public/index.html | 15 ++++++ public/script.js | 73 ++++++++++++++++++++++++++-- public/scripts/utils.js | 4 +- public/scripts/world-info.js | 92 ++++++++++++++++++++++++------------ 4 files changed, 150 insertions(+), 34 deletions(-) diff --git a/public/index.html b/public/index.html index b306554fa..6949d697b 100644 --- a/public/index.html +++ b/public/index.html @@ -2937,6 +2937,21 @@ + +
+

+ Associate extra world info +

+
+
+ Associate extra lorebooks with this character. + NOTE: These choices are optional and won't be preserved on character export! +
+
+ +
diff --git a/public/script.js b/public/script.js index 7dc07cad9..fdd29b289 100644 --- a/public/script.js +++ b/public/script.js @@ -131,6 +131,7 @@ import { timestampToMoment, download, isDataURL, + getCharaFilename, } from "./scripts/utils.js"; import { extension_settings, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from "./scripts/extensions.js"; @@ -155,6 +156,7 @@ import { EventEmitter } from './scripts/eventemitter.js'; import { context_settings, loadContextTemplatesFromSettings } from "./scripts/context-template.js"; import { markdownExclusionExt } from "./scripts/showdown-exclusion.js"; import { NOTE_MODULE_NAME, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from "./scripts/extensions/floating-prompt/index.js"; +import { deviceInfo } from "./scripts/RossAscends-mods.js"; //exporting functions and vars for mods export { @@ -5492,7 +5494,7 @@ function openCharacterWorldPopup() { } function onSelectCharacterWorld() { - const value = $(this).find('option:selected').val(); + const value = $('.character_world_info_selector').find('option:selected').val(); const worldIndex = value !== '' ? Number(value) : NaN; const name = !isNaN(worldIndex) ? world_names[worldIndex] : ''; @@ -5509,12 +5511,42 @@ function openCharacterWorldPopup() { setWorldInfoButtonClass(undefined, !!value); } - const name = (menu_type == 'create' ? create_save.name : characters[chid]?.data?.name) || 'Nameless'; - const worldId = (menu_type == 'create' ? create_save.world : characters[chid]?.data?.extensions?.world) || ''; + function onExtraWorldInfoChanged() { + const selectedWorlds = $('.character_extra_world_info_selector').val(); + let charLore = world_info.charLore ?? []; + + // TODO: Maybe make this utility function not use the window context? + const fileName = getCharaFilename(chid); + const tempExtraBooks = selectedWorlds.map((index) => world_names[index]).filter((e) => e !== undefined); + + const existingCharLore = charLore.find((e) => e.name === fileName); + if (existingCharLore) { + if (tempExtraBooks.length === 0) { + charLore.splice(existingCharLore, 1); + } else { + existingCharLore.extraBooks = tempExtraBooks; + } + } else { + const newCharLoreEntry = { + name: fileName, + extraBooks: tempExtraBooks + } + + charLore.push(newCharLoreEntry); + } + Object.assign(world_info, { charLore: charLore }); + saveSettingsDebounced(); + } + const template = $('#character_world_template .character_world').clone(); const select = template.find('.character_world_info_selector'); + const extraSelect = template.find('.character_extra_world_info_selector'); + const name = (menu_type == 'create' ? create_save.name : characters[chid]?.data?.name) || 'Nameless'; + const worldId = (menu_type == 'create' ? create_save.world : characters[chid]?.data?.extensions?.world) || ''; template.find('.character_name').text(name); + + // Apped to base dropdown world_names.forEach((item, i) => { const option = document.createElement('option'); option.value = i; @@ -5523,7 +5555,42 @@ function openCharacterWorldPopup() { select.append(option); }); + // Append to extras dropdown + if (world_names.length > 0) { + extraSelect.empty(); + } + world_names.forEach((item, i) => { + const option = document.createElement('option'); + option.value = i; + option.innerText = item; + + const existingCharLore = world_info.charLore?.find((e) => e.name === getCharaFilename()); + if (existingCharLore) { + option.selected = existingCharLore.extraBooks.includes(item); + } else { + option.selected = false; + } + extraSelect.append(option); + }); + select.on('change', onSelectCharacterWorld); + extraSelect.on('mousedown change', async function (e) { + // If there's no world names, don't do anything + if (world_names.length === 0) { + e.preventDefault(); + return; + } + + if (deviceInfo && deviceInfo.device.type === 'desktop') { + e.preventDefault(); + const option = $(e.target); + option.prop('selected', !option.prop('selected')); + await delay(1); + } + + onExtraWorldInfoChanged(); + }); + callPopup(template, 'text'); } diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 2087ffa90..0467c5ffe 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -457,9 +457,9 @@ export function isDataURL(str) { return regex.test(str); } -export function getCharaFilename() { +export function getCharaFilename(chid) { const context = getContext(); - const fileName = context.characters[context.characterId].avatar; + const fileName = context.characters[chid ?? context.characterId].avatar; if (fileName) { return fileName.replace(/\.[^/.]+$/, "") diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 1e9e26348..5428e851f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1,5 +1,5 @@ import { saveSettings, callPopup, substituteParams, getTokenCount, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type } from "../script.js"; -import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, delay } from "./utils.js"; +import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, delay, getCharaFilename } from "./utils.js"; import { getContext } from "./extensions.js"; import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from "./extensions/floating-prompt/index.js"; import { registerSlashCommand } from "./slash-commands.js"; @@ -26,7 +26,8 @@ const world_info_insertion_strategy = { global_first: 2, }; -let world_info = []; +let world_info = {}; +let selected_world_info = []; let world_names; let world_info_depth = 2; let world_info_budget = 25; @@ -35,7 +36,10 @@ let world_info_case_sensitive = false; let world_info_match_whole_words = false; let world_info_character_strategy = world_info_insertion_strategy.evenly; const saveWorldDebounced = debounce(async (name, data) => await _save(name, data), 1000); -const saveSettingsDebounced = debounce(() => saveSettings(), 1000); +const saveSettingsDebounced = debounce(() => { + Object.assign(world_info, { globalSelect: selected_world_info }) + saveSettings() +}, 1000); const sortFn = (a, b) => b.order - a.order; const world_info_position = { @@ -78,12 +82,19 @@ function setWorldInfoSettings(settings, data) { world_info_budget = 25; } - // Reset selected world from old string - if (typeof settings.world_info === "string") { - const existingWorldInfo = settings.world_info; - settings.world_info = [existingWorldInfo]; + // 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").text(world_info_depth); $("#world_info_depth").val(world_info_depth); @@ -98,18 +109,23 @@ function setWorldInfoSettings(settings, data) { $("#world_info_character_strategy").val(world_info_character_strategy); world_names = data.world_names?.length ? data.world_names : []; - world_info = settings.world_info?.filter((e) => world_names.includes(e)) ?? []; + + // Add to existing selected WI if it exists + selected_world_info = selected_world_info.concat(settings.world_info?.globalSelect?.filter((e) => world_names.includes(e)) ?? []); if (world_names.length > 0) { $("#world_info").empty(); } world_names.forEach((item, i) => { - $("#world_info").append(``); + $("#world_info").append(``); $("#world_editor_select").append(``); }); $("#world_editor_select").trigger("change"); + + // Update settings + saveSettingsDebounced(); } // World Info Editor @@ -162,7 +178,7 @@ async function updateWorldInfoList() { $("#world_editor_select").find('option[value!=""]').remove(); world_names.forEach((item, i) => { - $("#world_info").append(``); + $("#world_info").append(``); $("#world_editor_select").append(``); }); } @@ -593,7 +609,7 @@ async function renameWorldInfo(name, data) { return; } - const entryPreviouslySelected = world_info.findIndex((e) => e === oldName); + const entryPreviouslySelected = selected_world_info.findIndex((e) => e === oldName); await saveWorldInfo(newName, data, true); await deleteWorldInfo(oldName); @@ -622,9 +638,9 @@ async function deleteWorldInfo(worldInfoName) { }); if (response.ok) { - const existingWorldIndex = world_info.findIndex((e) => e === worldInfoName); + const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName); if (existingWorldIndex !== -1) { - world_info.splice(existingWorldIndex, 1); + selected_world_info.splice(existingWorldIndex, 1); saveSettingsDebounced(); } @@ -694,35 +710,48 @@ function transformString(str) { } async function getCharacterLore() { - const name = characters[this_chid]?.data?.extensions?.world; + const character = characters[this_chid]; + const name = character?.name; + let worldsToSearch = new Set(); - if (!name) { + 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...`) return []; } - if (world_info.includes(name)) { - console.debug(`Character ${characters[this_chid]?.name} world info is the same as global: ${name}. Skipping...`); - return []; + // TODO: Maybe make the utility function not use the window context? + const fileName = getCharaFilename(this_chid); + const extraCharLore = world_info.charLore.find((e) => e.name === fileName); + if (extraCharLore) { + worldsToSearch = new Set([...worldsToSearch, ...extraCharLore.extraBooks]); } - if (!world_names.includes(name)) { - console.log(`Character ${characters[this_chid]?.name} world info does not exist: ${name}`); - return []; + 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; + } + + const data = await loadWorldInfoData(worldName); + const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : []; + entries = entries.concat(newEntries); } - const data = await loadWorldInfoData(name); - const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : []; - console.debug(`Character ${characters[this_chid]?.name} lore (${name}) has ${entries.length} world info entries`); + console.debug(`Character ${characters[this_chid]?.name} lore (${baseWorldName}) has ${entries.length} world info entries`); return entries; } async function getGlobalLore() { - if (!world_info) { + if (!selected_world_info) { return []; } let entries = []; - for (const worldName of world_info) { + for (const worldName of selected_world_info) { const data = await loadWorldInfoData(worldName); const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : []; entries = entries.concat(newEntries); @@ -1091,7 +1120,6 @@ function onWorldInfoChange(_, text) { if (_ !== '__notSlashCommand__') { // if it's a slash command if (text !== undefined) { // and args are provided const slashInputSplitText = text.trim().toLowerCase().split(","); - console.log(slashInputSplitText); slashInputSplitText.forEach((worldName) => { const wiElement = getWIElement(worldName); @@ -1104,7 +1132,7 @@ function onWorldInfoChange(_, text) { }) } else { // if no args, unset all worlds toastr.success('Deactivated all worlds'); - world_info = []; + selected_world_info = []; $("#world_info").val(""); } } else { //if it's a pointer selection @@ -1122,7 +1150,7 @@ function onWorldInfoChange(_, text) { } }); } - world_info = tempWorldInfo; + selected_world_info = tempWorldInfo; } saveSettingsDebounced(); @@ -1137,6 +1165,12 @@ jQuery(() => { let selectScrollTop = null; $("#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; + } + if (deviceInfo.device.type === 'desktop') { e.preventDefault(); const option = $(e.target);