mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
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 <bdashore3@proton.me>
This commit is contained in:
@ -2937,6 +2937,21 @@
|
||||
<option value="">--- None ---</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="range-block-title">
|
||||
<h4>
|
||||
Associate extra world info
|
||||
</h4>
|
||||
</div>
|
||||
<div class="range-block-counter justifyLeft flex-container flexFlowColumn margin-bot-10px">
|
||||
Associate extra lorebooks with this character.
|
||||
NOTE: These choices are optional and won't be preserved on character export!
|
||||
</div>
|
||||
<div class="range-block-range wide100p">
|
||||
<select class="character_extra_world_info_selector" multiple>
|
||||
<option value="">-- World Info not found --</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -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');
|
||||
}
|
||||
|
||||
|
@ -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(/\.[^/.]+$/, "")
|
||||
|
@ -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") {
|
||||
// Reset selected world from old string and delete old keys
|
||||
// TODO: Remove next release
|
||||
const existingWorldInfo = settings.world_info;
|
||||
settings.world_info = [existingWorldInfo];
|
||||
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(`<option value='${i}'${world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
|
||||
$("#world_info").append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
|
||||
$("#world_editor_select").append(`<option value='${i}'>${item}</option>`);
|
||||
});
|
||||
|
||||
$("#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(`<option value='${i}'${world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
|
||||
$("#world_info").append(`<option value='${i}'${selected_world_info.includes(item) ? ' selected' : ''}>${item}</option>`);
|
||||
$("#world_editor_select").append(`<option value='${i}'>${item}</option>`);
|
||||
});
|
||||
}
|
||||
@ -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(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`);
|
||||
const data = await loadWorldInfoData(worldName);
|
||||
const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]) : [];
|
||||
entries = entries.concat(newEntries);
|
||||
}
|
||||
|
||||
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);
|
||||
|
Reference in New Issue
Block a user